From 973ece9eb96c1948d8b39ee0ab741289a166390a Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:07:10 +0800 Subject: [PATCH 01/96] feat: use quick model to detect translate language (#9315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(语言检测): 移除翻译模型依赖,改用快速或默认模型 * feat(i18n): 添加希腊语翻译支持 * fix(i18n): 更新i18n 统一将翻译模型提示改为快速模型提示,优化多语言文件中的描述 * Revert "feat(i18n): 添加希腊语翻译支持" This reverts commit 42613cb2e21da2a6cb3e920b09cfa57f7784ac58. --- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/ja-jp.json | 2 +- src/renderer/src/i18n/locales/ru-ru.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- src/renderer/src/i18n/translate/el-gr.json | 4 +++- src/renderer/src/i18n/translate/es-es.json | 4 +++- src/renderer/src/i18n/translate/fr-fr.json | 4 +++- src/renderer/src/i18n/translate/pt-pt.json | 4 +++- src/renderer/src/services/ApiService.ts | 8 +++----- 10 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5d37e8a98e..36f5e61b16 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3689,7 +3689,7 @@ }, "label": "Automatic detection method", "llm": { - "tip": "Using a translation model for language detection consumes a small number of tokens. (QwenMT does not support language detection and will automatically fall back to the default assistant model.)" + "tip": "Using the quick model for language detection consumes fewer tokens." }, "placeholder": "Select automatic detection method", "tip": "Method used when automatically detecting the input language" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1a38ee0bd2..c386c301ac 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -3689,7 +3689,7 @@ }, "label": "自動検出方法", "llm": { - "tip": "翻訳モデルを使用して言語検出を行うと、少量のトークンを消費します。(QwenMTは言語検出をサポートしておらず、自動的にデフォルトのアシスタントモデルにフォールバックします)" + "tip": "高速モデルを使用して言語検出を行い、少量のトークンを消費します。" }, "placeholder": "自動検出方法を選択してください", "tip": "入力言語を自動検出する際に使用する方法" diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 7ea239bc38..aed6241d8d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -3689,7 +3689,7 @@ }, "label": "Автоматический метод обнаружения", "llm": { - "tip": "Использование модели перевода для определения языка требует небольшого количества токенов. (QwenMT не поддерживает определение языка и автоматически возвращается к модели помощника по умолчанию)" + "tip": "Использование быстрой модели для определения языка с минимальным расходом токенов." }, "placeholder": "Выберите метод автоматического определения", "tip": "Метод, используемый при автоматическом определении языка ввода" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 3901a2f5c2..60b5a544dc 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3689,7 +3689,7 @@ }, "label": "自动检测方法", "llm": { - "tip": "使用翻译模型进行语言检测,消耗少量token。(QwenMT不支持进行语言检测,会自动回退到默认助手模型)" + "tip": "使用快速模型进行语言检测,消耗少量token。" }, "placeholder": "选择自动检测方法", "tip": "自动检测输入语言时使用的方法" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e43e91249a..8487289fdf 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3689,7 +3689,7 @@ }, "label": "自動檢測方法", "llm": { - "tip": "使用翻譯模型進行語言檢測,消耗少量token。(QwenMT不支持進行語言檢測,會自動回退到預設助手模型)" + "tip": "使用快速模型進行語言檢測,消耗少量token。" }, "placeholder": "選擇自動偵測方法", "tip": "自動檢測輸入語言時使用的方法" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 161906ea26..9cf801e5ef 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -654,6 +654,8 @@ "cli_tool": "Εργαλείο CLI", "cli_tool_placeholder": "Επιλέξτε το CLI εργαλείο που θέλετε να χρησιμοποιήσετε", "description": "Εκκίνηση γρήγορα πολλών εργαλείων CLI κώδικα, για αύξηση της αποδοτικότητας ανάπτυξης", + "env_vars_help": "Εισαγάγετε προσαρμοσμένες μεταβλητές περιβάλλοντος (μία ανά γραμμή, με τη μορφή: KEY=value)", + "environment_variables": "Μεταβλητές περιβάλλοντος", "folder_placeholder": "Επιλέξτε κατάλογο εργασίας", "install_bun": "Εγκατάσταση Bun", "installing_bun": "Εγκατάσταση...", @@ -3687,7 +3689,7 @@ }, "label": "Αυτόματη μέθοδος ανίχνευσης", "llm": { - "tip": "Χρησιμοποιήστε ένα μοντέλο μετάφρασης για την ανίχνευση γλώσσας, καταναλώνοντας ελάχιστα token. (Το QwenMT δεν υποστηρίζει την ανίχνευση γλώσσας και επιστρέφει αυτόματα στο προεπιλεγμένο μοντέλο βοηθού)" + "tip": "Χρησιμοποιήστε γρήγορο μοντέλο για την ανίχνευση γλώσσας, με ελάχιστη κατανάλωση token." }, "placeholder": "Επιλέξτε τη μέθοδο αυτόματης ανίχνευσης", "tip": "Η μέθοδος που χρησιμοποιείται για την αυτόματη ανίχνευση της γλώσσας εισόδου" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 8957cf1f42..6e1a1c4d59 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -654,6 +654,8 @@ "cli_tool": "Herramienta de línea de comandos", "cli_tool_placeholder": "Seleccione la herramienta de línea de comandos que desea utilizar", "description": "Inicia rápidamente múltiples herramientas de línea de comandos para código, aumentando la eficiencia del desarrollo", + "env_vars_help": "Introduzca variables de entorno personalizadas (una por línea, formato: CLAVE=valor)", + "environment_variables": "Variables de entorno", "folder_placeholder": "Seleccionar directorio de trabajo", "install_bun": "Instalar Bun", "installing_bun": "Instalando...", @@ -3687,7 +3689,7 @@ }, "label": "Método de detección automática", "llm": { - "tip": "Utiliza el modelo de traducción para la detección de idioma, lo que consume una pequeña cantidad de tokens. (QwenMT no admite la detección de idioma y automáticamente retrocede al modelo de asistente predeterminado)" + "tip": "Utiliza un modelo rápido para la detección de idioma, consumiendo pocos tokens." }, "placeholder": "Seleccionar método de detección automática", "tip": "Método utilizado para detectar automáticamente el idioma de entrada" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 32326df24c..570e600437 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -654,6 +654,8 @@ "cli_tool": "Outil CLI", "cli_tool_placeholder": "Sélectionnez l'outil CLI à utiliser", "description": "Lancer rapidement plusieurs outils CLI de code pour améliorer l'efficacité du développement", + "env_vars_help": "Saisissez les variables d'environnement personnalisées (une par ligne, format : KEY=value)", + "environment_variables": "variables d'environnement", "folder_placeholder": "Sélectionner le répertoire de travail", "install_bun": "Installer Bun", "installing_bun": "Installation en cours...", @@ -3687,7 +3689,7 @@ }, "label": "Méthode de détection automatique", "llm": { - "tip": "Utiliser un modèle de traduction pour la détection de langue, ce qui consomme peu de jetons. (QwenMT ne prend pas en charge la détection de langue et revient automatiquement au modèle assistant par défaut.)" + "tip": "Utilisation d'un modèle rapide pour la détection linguistique, consommant peu de jetons." }, "placeholder": "Sélectionner la méthode de détection automatique", "tip": "Méthode utilisée pour la détection automatique de la langue d'entrée" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index c76b3e630b..20cae5287a 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -654,6 +654,8 @@ "cli_tool": "Ferramenta de linha de comando", "cli_tool_placeholder": "Selecione a ferramenta de linha de comando a ser utilizada", "description": "Inicie rapidamente várias ferramentas de linha de comando de código, aumentando a eficiência do desenvolvimento", + "env_vars_help": "Insira variáveis de ambiente personalizadas (uma por linha, formato: CHAVE=valor)", + "environment_variables": "variáveis de ambiente", "folder_placeholder": "Selecionar diretório de trabalho", "install_bun": "Instalar o Bun", "installing_bun": "Instalando...", @@ -3687,7 +3689,7 @@ }, "label": "Método de detecção automática", "llm": { - "tip": "Usar o modelo de tradução para detecção de idioma consome uma pequena quantidade de tokens. (O QwenMT não suporta detecção de idioma e reverterá automaticamente para o modelo assistente padrão)" + "tip": "Usar modelo rápido para detecção de idioma, consumindo poucos tokens." }, "placeholder": "Escolha o método de detecção automática", "tip": "Método utilizado para detecção automática do idioma de entrada" diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 2cddbcee2c..c01769ae84 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -65,8 +65,7 @@ import { getDefaultAssistant, getDefaultModel, getProviderByModel, - getQuickModel, - getTranslateModel + getQuickModel } from './AssistantService' import { processKnowledgeSearch } from './KnowledgeService' import { MemoryProcessor } from './MemoryProcessor' @@ -621,14 +620,13 @@ export async function fetchLanguageDetection({ text, onResponse }: FetchLanguage const listLang = translateLanguageOptions.map((item) => item.langCode) const listLangText = JSON.stringify(listLang) - let model = getTranslateModel() + const model = getQuickModel() || getDefaultModel() if (!model) { throw new Error(i18n.t('error.model.not_exists')) } if (isQwenMTModel(model)) { - logger.info('QwenMT cannot be used for language detection. Fallback to default model.') - model = getDefaultModel() + logger.info('QwenMT cannot be used for language detection.') if (isQwenMTModel(model)) { throw new Error(i18n.t('translate.error.detect.qwen_mt')) } From cffaf99b17fe4191e810d5ad79a281bfe110b2b2 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 20 Aug 2025 10:56:44 +0800 Subject: [PATCH 02/96] feat: add 'code_tools' to sidebar icons and update related components --- src/renderer/src/i18n/label.ts | 3 ++- .../settings/DisplaySettings/SidebarIconsManager.tsx | 10 +++++----- .../TranslateSettingsPopup/TranslateSettingsPopup.tsx | 3 ++- src/renderer/src/store/settings.ts | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 8be3bbb3e3..3724ef04c5 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -144,7 +144,8 @@ const sidebarIconKeyMap = { translate: 'translate.title', minapp: 'minapp.title', knowledge: 'knowledge.title', - files: 'files.title' + files: 'files.title', + code_tools: 'code.title' } as const export const getSidebarIconLabel = (key: string): string => { diff --git a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx index c19fabc70f..d5ff9cd69b 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx @@ -11,7 +11,7 @@ import { getSidebarIconLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { setSidebarIcons } from '@renderer/store/settings' import { message } from 'antd' -import { FileSearch, Folder, Languages, LayoutGrid, MessageSquareQuote, Palette, Sparkle } from 'lucide-react' +import { Code, FileSearch, Folder, Languages, LayoutGrid, MessageSquareQuote, Palette, Sparkle } from 'lucide-react' import { FC, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -116,7 +116,8 @@ const SidebarIconsManager: FC = ({ translate: , minapp: , knowledge: , - files: + files: , + code_tools: }), [] ) @@ -206,15 +207,14 @@ const IconColumn = styled.div` ` const IconList = styled.div` - height: 365px; - min-height: 365px; + height: 400px; + min-height: 400px; padding: 10px; background: var(--color-background-soft); border-radius: 8px; border: 1px solid var(--color-border); display: flex; flex-direction: column; - overflow-y: hidden; ` const IconItem = styled.div` diff --git a/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx b/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx index 6908d89ab3..7c6ff0f78e 100644 --- a/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx @@ -40,8 +40,9 @@ const PopupContainer: React.FC = ({ resolve }) => { afterClose={onClose} transitionName="animation-move-down" width="80vw" + footer={null} centered> - + diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index fd2853360f..f226f990f2 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -38,7 +38,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ 'translate', 'minapp', 'knowledge', - 'files' + 'files', + 'code_tools' ] export interface NutstoreSyncRuntime extends RemoteSyncState {} From 47f49532c631b3047cfd18ea8cab7d0a61ae9a9e Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 20 Aug 2025 11:12:42 +0800 Subject: [PATCH 03/96] fix: KaTeX math engine render --- src/renderer/src/assets/styles/markdown.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 2760ecb6e5..caf41aca5e 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -106,6 +106,10 @@ white-space: pre-wrap; } + .katex span { + white-space: pre; + } + p code, li code { background: var(--color-background-mute); From 4f2b1e23a922ad746a6fe50ddd4d377db23c0844 Mon Sep 17 00:00:00 2001 From: alickreborn0 Date: Wed, 20 Aug 2025 03:26:38 +0000 Subject: [PATCH 04/96] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E7=99=BE?= =?UTF-8?q?=E7=82=BC=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=8A=9F=E8=83=BD=20(#9205?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 同步百炼服务器功能 * cr修改 --------- Co-authored-by: yunze --- .../settings/MCPSettings/SyncServersPopup.tsx | 12 + .../settings/MCPSettings/providers/bailian.ts | 208 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts diff --git a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx index 0316975f46..cea47ae0e8 100644 --- a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { getAI302Token, saveAI302Token, syncAi302Servers } from './providers/302ai' +import { getBailianToken, saveBailianToken, syncBailianServers } from './providers/bailian' import { getTokenLanYunToken, LANYUN_KEY_HOST, saveTokenLanYunToken, syncTokenLanYunServers } from './providers/lanyun' import { getModelScopeToken, MODELSCOPE_HOST, saveModelScopeToken, syncModelScopeServers } from './providers/modelscope' import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_HOST } from './providers/tokenflux' @@ -69,6 +70,17 @@ const providers: ProviderConfig[] = [ getToken: getAI302Token, saveToken: saveAI302Token, syncServers: syncAi302Servers + }, + { + key: 'bailian', + name: '阿里云百炼', + description: '百炼平台服务', + discoverUrl: `https://bailian.console.aliyun.com/?tab=mcp#/mcp-market`, + apiKeyUrl: `https://bailian.console.aliyun.com/?tab=app#/api-key`, + tokenFieldName: 'bailianToken', + getToken: getBailianToken, + saveToken: saveBailianToken, + syncServers: syncBailianServers } ] diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts b/src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts new file mode 100644 index 0000000000..8eecb2bac2 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts @@ -0,0 +1,208 @@ +import { loggerService } from '@logger' +import { nanoid } from '@reduxjs/toolkit' +import type { MCPServer } from '@renderer/types' +import i18next from 'i18next' + +const logger = loggerService.withContext('BailianSyncUtils') + +// 常量定义 +export const BAILIAN_HOST = 'https://dashscope.aliyuncs.com' +const TOKEN_STORAGE_KEY = 'bailian_token' + +// Token 工具函数 +export const saveBailianToken = (token: string): void => { + localStorage.setItem(TOKEN_STORAGE_KEY, token) +} + +export const getBailianToken = (): string | null => { + const token = localStorage.getItem(TOKEN_STORAGE_KEY) + return token +} + +export const clearBailianToken = (): void => { + localStorage.removeItem(TOKEN_STORAGE_KEY) +} + +export const hasBailianToken = (): boolean => { + const hasToken = !!getBailianToken() + return hasToken +} + +// ========== 类型定义 ========== +export interface BailianServer { + id: string + name: string + description?: string + operationalUrl?: string + tags?: string[] + logoUrl?: string + providerUrl?: string + provider?: string + type?: 'streamableHttp' | 'sse' + active: boolean +} + +interface McpServerCherryDetailResponse { + success: boolean + message: string + requestId: string + total: number + data: BailianServer[] +} + +export interface BailianSyncResult { + success: boolean + message: string + addedServers: MCPServer[] + updatedServers: MCPServer[] + errorDetails?: string +} + +// ========== 拉取所有 MCP 服务 ========== +const PAGE_SIZE = 20 + +/** + * 拉取全部 MCP 服务器列表,分页封装 + * 抛出明确错误字符串,供 syncBailianServers 捕捉 + */ +async function fetchAllMcpServers(token: string): Promise { + const allServers: BailianServer[] = [] + let pageNum = 1 + let total = 0 + let length = 0 + + do { + const url = `${BAILIAN_HOST}/api/v1/mcps/user/list?pageNo=${pageNum}&pageSize=${PAGE_SIZE}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + // ----- 错误处理(不再封装 Result,直接 throw,外层处理) ----- + if (response.status === 401 || response.status === 403) { + throw new Error('unauthorized') + } + if (response.status === 500) { + throw new Error('server_error') + } + if (!response.ok) { + throw new Error(`Status: ${response.status}`) + } + + const result: McpServerCherryDetailResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Fetch failed') + } + + allServers.push(...(result.data || [])) + length = result.data.length + total = result.total || 0 + pageNum++ + } while ((pageNum - 1) * PAGE_SIZE < total && length > 0) + + return allServers +} + +// ========== 主同步函数 ========== +export const syncBailianServers = async (token: string, existingServers: MCPServer[]): Promise => { + const t = i18next.t + + try { + const servers = await fetchAllMcpServers(token) + + const addedServers: MCPServer[] = [] + const updatedServers: MCPServer[] = [] + + for (const server of servers) { + try { + if (!server.operationalUrl) { + continue + } + + const id = `@bailian/${server.id}` + const existingServer = existingServers.find((s) => s.id === id) + + const mcpServer: MCPServer = { + id, + name: server.name || `Bailian Server ${nanoid()}`, + description: server.description || '', + type: server.type, + baseUrl: server.operationalUrl, + command: '', + args: [], + env: {}, + isActive: server.active, + provider: server.provider, + providerUrl: server.providerUrl, + logoUrl: server.logoUrl || '', + tags: server.tags || [], + headers: { + Authorization: `Bearer ${token}` + } + } + + if (existingServer) { + updatedServers.push(mcpServer) + } else { + addedServers.push(mcpServer) + } + } catch (err) { + logger.error(`Error processing Bailian server ${server.id}:`, err as Error) + } + } + + const totalServers = addedServers.length + updatedServers.length + + return { + success: true, + message: t('settings.mcp.sync.success', { count: totalServers }), + addedServers, + updatedServers + } + } catch (error) { + let message = '' + let errorDetails: string | undefined = undefined + + if (error instanceof Error && error.message === 'unauthorized') { + clearBailianToken() + message = t('settings.mcp.sync.unauthorized', 'Sync Unauthorized') + logger.error('Unauthorized access during sync') + return { + success: false, + message, + addedServers: [], + updatedServers: [] + } + } + + if (error instanceof Error && error.message === 'server_error') { + message = t('settings.mcp.sync.error') + errorDetails = 'Status: 500' + logger.error('Server error during sync') + return { + success: false, + message, + addedServers: [], + updatedServers: [], + errorDetails + } + } + + // 其他情况 + logger.error('Bailian sync error:', error as Error) + message = t('settings.mcp.sync.error') + errorDetails = String(error) + return { + success: false, + message, + addedServers: [], + updatedServers: [], + errorDetails + } + } +} From cdca8c0ed797e784ccfac89e37f25a49a936039c Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Wed, 20 Aug 2025 13:00:56 +0800 Subject: [PATCH 05/96] fix(SelectionHook): improve validation for selected text range to handle empty strings and ensure valid extraction (#9329) chore: update selection-hook dependency to version 1.0.10 in package.json and yarn.lock --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a3158f573e..b6ebd42dab 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", - "selection-hook": "^1.0.9", + "selection-hook": "^1.0.10", "turndown": "7.2.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index c6d50331bf..210c3bea52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8630,7 +8630,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.9" + selection-hook: "npm:^1.0.10" shiki: "npm:^3.9.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" @@ -20089,14 +20089,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.9": - version: 1.0.9 - resolution: "selection-hook@npm:1.0.9" +"selection-hook@npm:^1.0.10": + version: 1.0.10 + resolution: "selection-hook@npm:1.0.10" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/5f3114b528d9e1545a5dc4b99927a0ab441570063bb348b52784d757c8f250f0d6a875175d371adf5dc2bfc82bf6bb86f99d3ee66fefe0749040c0b50f3217c3 + checksum: 10c0/263f33d59de4ba0643f783ed39b95813fb5e685dc517abc8aadde6ad6749d946de1b61382d153359cb6592abd23849cb3e2177cd39334e87aff34ed9239649e9 languageName: node linkType: hard From f8120c2ebbdc121b860e6147631fa8dd7710be79 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 20 Aug 2025 13:25:22 +0800 Subject: [PATCH 06/96] fix: web search references missing caused by early reset (#9328) --- .../messageStreaming/callbacks/citationCallbacks.ts | 6 ++++-- .../src/services/messageStreaming/callbacks/index.ts | 3 ++- .../services/messageStreaming/callbacks/textCallbacks.ts | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts index 9ba743b2cd..03d0f23d48 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts @@ -40,7 +40,6 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => status: MessageBlockStatus.SUCCESS } blockManager.smartBlockUpdate(citationBlockId, changes, MessageBlockType.CITATION, true) - citationBlockId = null } else { logger.error('[onExternalToolComplete] citationBlockId is null. Cannot update.') } @@ -121,6 +120,9 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => }, // 暴露给外部的方法,用于textCallbacks中获取citationBlockId - getCitationBlockId: () => citationBlockId + getCitationBlockId: () => citationBlockId, + setCitationBlockId: (id: string | null) => { + citationBlockId = id + } } } diff --git a/src/renderer/src/services/messageStreaming/callbacks/index.ts b/src/renderer/src/services/messageStreaming/callbacks/index.ts index 2b6fc5968a..ada8ed7b67 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/index.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/index.ts @@ -59,7 +59,8 @@ export const createCallbacks = (deps: CallbacksDependencies) => { blockManager, getState, assistantMsgId, - getCitationBlockId: citationCallbacks.getCitationBlockId + getCitationBlockId: citationCallbacks.getCitationBlockId, + setCitationBlockId: citationCallbacks.setCitationBlockId }) // 组合所有回调 diff --git a/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts index 657cf6f0f3..53621b625f 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts @@ -12,10 +12,11 @@ interface TextCallbacksDependencies { getState: any assistantMsgId: string getCitationBlockId: () => string | null + setCitationBlockId: (id: string | null) => void } export const createTextCallbacks = (deps: TextCallbacksDependencies) => { - const { blockManager, getState, assistantMsgId, getCitationBlockId } = deps + const { blockManager, getState, assistantMsgId, getCitationBlockId, setCitationBlockId } = deps // 内部维护的状态 let mainTextBlockId: string | null = null @@ -62,6 +63,7 @@ export const createTextCallbacks = (deps: TextCallbacksDependencies) => { } blockManager.smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT, true) mainTextBlockId = null + setCitationBlockId(null) } else { logger.warn( `[onTextComplete] Received text.complete but last block was not MAIN_TEXT (was ${blockManager.lastBlockType}) or lastBlockId is null.` From 1da1721ec2ee0522a585958a0e3083d6e21f90f4 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:11:07 +0800 Subject: [PATCH 07/96] feat(openai): handle special tokens for zhipu api (#9323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(openai): 添加对智谱特殊token的过滤处理 在OpenAIAPIClient中添加对智谱AI特殊token的过滤逻辑,避免不需要的token被输出 * docs(OpenAIApiClient): 添加注释 * refactor(zhipu): 重命名并更新智谱特殊token处理逻辑 将 ZHIPU_SPECIAL_TOKENS_TO_FILTER 重命名为 ZHIPU_RESULT_TOKENS 以更准确描述用途 修改智谱API特殊token处理逻辑,不再过滤而是用**标记结果token --- .../aiCore/clients/openai/OpenAIApiClient.ts | 23 +++++++++++++++---- src/renderer/src/config/models.ts | 3 +++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 7fcae3823c..e9133400dc 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -26,7 +26,8 @@ import { isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenZhipuModel, isVisionModel, - MODEL_SUPPORTED_REASONING_EFFORT + MODEL_SUPPORTED_REASONING_EFFORT, + ZHIPU_RESULT_TOKENS } from '@renderer/config/models' import { isSupportArrayContentProvider, @@ -896,10 +897,22 @@ export class OpenAIAPIClient extends OpenAIBaseClient< accumulatingText = true } // logger.silly('enqueue TEXT_DELTA') - controller.enqueue({ - type: ChunkType.TEXT_DELTA, - text: contentSource.content - }) + // 处理特殊token + // 智谱api的一个chunk中只会输出一个token,因而使用 ===,避免正常内容被误判 + if ( + context.provider.id === SystemProviderIds.zhipu && + ZHIPU_RESULT_TOKENS.some((pattern) => contentSource.content === pattern) + ) { + controller.enqueue({ + type: ChunkType.TEXT_DELTA, + text: '**' // strong + }) + } else { + controller.enqueue({ + type: ChunkType.TEXT_DELTA, + text: contentSource.content + }) + } } else { accumulatingText = false } diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index d424cf1402..a4fdcf0fb9 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -3238,3 +3238,6 @@ export const isGPT5SeriesModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) return modelId.includes('gpt-5') } + +// zhipu 视觉推理模型用这组 special token 标记推理结果 +export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const From 332ba5d678bdb62dcbda19ec27e52d334572d9d4 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Wed, 20 Aug 2025 15:46:44 +0800 Subject: [PATCH 08/96] feat: support openai codex (#9332) * support openai codex * lint * refactor: remove unused codeTools enum from constant.ts * fix build * fix lin * fix: add support for qwenCode CLI tool and improve error handling in CodeToolsService --- packages/shared/config/constant.ts | 7 ++++ src/main/services/CodeToolsService.ts | 35 ++++++++++++------- src/renderer/src/hooks/useCodeTools.ts | 3 +- src/renderer/src/pages/code/CodeToolsPage.tsx | 20 ++++++----- src/renderer/src/store/codeTools.ts | 14 ++++---- 5 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 17304f357f..82b78459a5 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -211,3 +211,10 @@ export const MIN_WINDOW_WIDTH = 1080 export const SECOND_MIN_WINDOW_WIDTH = 520 export const MIN_WINDOW_HEIGHT = 600 export const defaultByPassRules = 'localhost,127.0.0.1,::1' + +export enum codeTools { + qwenCode = 'qwen-code', + claudeCode = 'claude-code', + geminiCli = 'gemini-cli', + openaiCodex = 'openai-codex' +} diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 5fa2ce87c2..d408ad78a7 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -7,6 +7,7 @@ import { isWin } from '@main/constant' import { removeEnvProxy } from '@main/utils' import { isUserInChina } from '@main/utils/ipService' import { getBinaryName } from '@main/utils/process' +import { codeTools } from '@shared/config/constant' import { spawn } from 'child_process' import { promisify } from 'util' @@ -41,23 +42,33 @@ class CodeToolsService { } public async getPackageName(cliTool: string) { - if (cliTool === 'claude-code') { - return '@anthropic-ai/claude-code' + switch (cliTool) { + case codeTools.claudeCode: + return '@anthropic-ai/claude-code' + case codeTools.geminiCli: + return '@google/gemini-cli' + case codeTools.openaiCodex: + return '@openai/codex' + case codeTools.qwenCode: + return '@qwen-code/qwen-code' + default: + throw new Error(`Unsupported CLI tool: ${cliTool}`) } - if (cliTool === 'gemini-cli') { - return '@google/gemini-cli' - } - return '@qwen-code/qwen-code' } public async getCliExecutableName(cliTool: string) { - if (cliTool === 'claude-code') { - return 'claude' + switch (cliTool) { + case codeTools.claudeCode: + return 'claude' + case codeTools.geminiCli: + return 'gemini' + case codeTools.openaiCodex: + return 'codex' + case codeTools.qwenCode: + return 'qwen' + default: + throw new Error(`Unsupported CLI tool: ${cliTool}`) } - if (cliTool === 'gemini-cli') { - return 'gemini' - } - return 'qwen' } private async isPackageInstalled(cliTool: string): Promise { diff --git a/src/renderer/src/hooks/useCodeTools.ts b/src/renderer/src/hooks/useCodeTools.ts index ded65f75b9..db8b21bbeb 100644 --- a/src/renderer/src/hooks/useCodeTools.ts +++ b/src/renderer/src/hooks/useCodeTools.ts @@ -11,6 +11,7 @@ import { setSelectedModel } from '@renderer/store/codeTools' import { Model } from '@renderer/types' +import { codeTools } from '@shared/config/constant' import { useCallback } from 'react' export const useCodeTools = () => { @@ -20,7 +21,7 @@ export const useCodeTools = () => { // 设置选择的 CLI 工具 const setCliTool = useCallback( - (tool: string) => { + (tool: codeTools) => { dispatch(setSelectedCliTool(tool)) }, [dispatch] diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index c462a0becd..bb4457438d 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -10,6 +10,7 @@ import { getModelUniqId } from '@renderer/services/ModelService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled } from '@renderer/store/mcp' import { Model } from '@renderer/types' +import { codeTools } from '@shared/config/constant' import { Alert, Button, Checkbox, Input, Select, Space } from 'antd' import { Download, Terminal, X } from 'lucide-react' import { FC, useCallback, useEffect, useState } from 'react' @@ -18,9 +19,10 @@ import styled from 'styled-components' // CLI 工具选项 const CLI_TOOLS = [ - { value: 'qwen-code', label: 'Qwen Code' }, - { value: 'claude-code', label: 'Claude Code' }, - { value: 'gemini-cli', label: 'Gemini CLI' } + { value: codeTools.qwenCode, label: 'Qwen Code' }, + { value: codeTools.claudeCode, label: 'Claude Code' }, + { value: codeTools.geminiCli, label: 'Gemini CLI' }, + { value: codeTools.openaiCodex, label: 'OpenAI Codex' } ] const SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api'] @@ -53,7 +55,7 @@ const CodeToolsPage: FC = () => { const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false) // 处理 CLI 工具选择 - const handleCliToolChange = (value: string) => { + const handleCliToolChange = (value: codeTools) => { setCliTool(value) // 不再清空模型选择,因为每个工具都会记住自己的模型 } @@ -79,9 +81,9 @@ const CodeToolsPage: FC = () => { ) const availableProviders = - selectedCliTool === 'claude-code' + selectedCliTool === codeTools.claudeCode ? claudeProviders - : selectedCliTool === 'gemini-cli' + : selectedCliTool === codeTools.geminiCli ? geminiProviders : openAiProviders @@ -194,7 +196,7 @@ const CodeToolsPage: FC = () => { const apiKey = await aiProvider.getApiKey() let env: Record = {} - if (selectedCliTool === 'claude-code') { + if (selectedCliTool === codeTools.claudeCode) { env = { ANTHROPIC_API_KEY: apiKey, ANTHROPIC_BASE_URL: modelProvider.apiHost, @@ -202,7 +204,7 @@ const CodeToolsPage: FC = () => { } } - if (selectedCliTool === 'gemini-cli') { + if (selectedCliTool === codeTools.geminiCli) { const apiSuffix = modelProvider.id === 'aihubmix' ? '/gemini' : '' const apiBaseUrl = modelProvider.apiHost + apiSuffix env = { @@ -213,7 +215,7 @@ const CodeToolsPage: FC = () => { } } - if (selectedCliTool === 'qwen-code') { + if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.openaiCodex) { env = { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl, diff --git a/src/renderer/src/store/codeTools.ts b/src/renderer/src/store/codeTools.ts index 267671bed7..689335b43f 100644 --- a/src/renderer/src/store/codeTools.ts +++ b/src/renderer/src/store/codeTools.ts @@ -1,12 +1,13 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { Model } from '@renderer/types' +import { codeTools } from '@shared/config/constant' // 常量定义 const MAX_DIRECTORIES = 10 // 最多保存10个目录 export interface CodeToolsState { // 当前选择的 CLI 工具,默认使用 qwen-code - selectedCliTool: string + selectedCliTool: codeTools // 为每个 CLI 工具单独保存选择的模型 selectedModels: Record // 为每个 CLI 工具单独保存环境变量 @@ -18,11 +19,12 @@ export interface CodeToolsState { } export const initialState: CodeToolsState = { - selectedCliTool: 'qwen-code', + selectedCliTool: codeTools.qwenCode, selectedModels: { - 'qwen-code': null, - 'claude-code': null, - 'gemini-cli': null + [codeTools.qwenCode]: null, + [codeTools.claudeCode]: null, + [codeTools.geminiCli]: null, + [codeTools.openaiCodex]: null }, environmentVariables: { 'qwen-code': '', @@ -38,7 +40,7 @@ const codeToolsSlice = createSlice({ initialState, reducers: { // 设置选择的 CLI 工具 - setSelectedCliTool: (state, action: PayloadAction) => { + setSelectedCliTool: (state, action: PayloadAction) => { state.selectedCliTool = action.payload }, From 25531ecd76d3eb6117753f937192494a816a3bd0 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:38:13 +0800 Subject: [PATCH 09/96] fix: timeout memory leak (#9312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(MinappPopupContainer): 修复内存泄漏问题,清理未使用的定时器 在组件卸载时清理setTimeout定时器,避免潜在的内存泄漏 * fix(SelectModelButton): 修复模型选择后更新导致的卡顿问题 使用useRef存储定时器并在组件卸载时清理,避免内存泄漏 * fix(QuickPanel): 修复定时器未清理导致的内存泄漏问题 添加 clearSearchTimerRef 和 focusTimerRef 来管理定时器 在组件清理和状态变化时清理所有定时器 * fix(useInPlaceEdit): 修复编辑模式下定时器未清理的问题 添加清理定时器的逻辑,避免组件卸载时内存泄漏 * refactor(useKnowledge): 使用ref管理定时器并统一检查知识库逻辑 将分散的setTimeout调用统一为checkAllBases方法 使用useRef管理定时器并在组件卸载时清理 * fix(useScrollPosition): 修复滚动位置恢复时的内存泄漏问题 添加清理函数以清除未完成的定时器,防止组件卸载时内存泄漏 * fix(WebSearchProviderSetting): 清理定时器防止内存泄漏 在组件卸载时清理检查API有效性的定时器,避免潜在的内存泄漏问题 * fix(selection-toolbar): 修复选中文本时定时器未清理的问题 * fix(translate): 修复复制文本时定时器未清理的问题 添加 copyTimerRef 来管理复制操作的定时器,并在组件卸载时清理定时器 * fix(WebSearchSettings): 使用useRef管理订阅验证定时器以避免内存泄漏 * fix(MCPSettings): 修复定时器未清理导致的内存泄漏问题 添加 useRef 来存储定时器引用,并在组件卸载时清理定时器 * refactor(ThinkingBlock): 使用 useTemporaryValue 替换手动 setTimeout 移除手动设置的 setTimeout 来重置 copied 状态,改用 useTemporaryValue hook 自动处理 * refactor(ChatNavigation): 使用 useRef 替代 useState 管理定时器 简化定时器管理逻辑,避免不必要的状态更新 * fix(AddAssistantPopup): 清理创建助手时的定时器以避免内存泄漏 添加useEffect清理定时器,防止组件卸载时内存泄漏 * feat(hooks): 添加useTimer钩子管理定时器 实现一个自定义hook来集中管理setTimeout和setInterval定时器 自动在组件卸载时清理所有定时器防止内存泄漏 * refactor(Inputbar): 使用 useTimer 替换 setTimeout 实现延迟更新 将 setTimeout 替换为 useTimer 的自定义 setTimeoutTimer 方法,提高代码可维护性并统一计时器管理 * refactor(WindowFooter): 使用 useTimer 替换 setTimeout 以管理定时器 * docs(useTimer): 更新定时器hook的注释格式和描述 * feat(hooks): 为useTimer添加返回清理函数的功能 允许从setTimeoutTimer和setIntervalTimer返回清理函数,便于手动清除定时器 * refactor(ImportAgentPopup): 使用useTimer替换setTimeout以管理定时器 * refactor: 使用useTimer替代setTimeout以优化定时器管理 * refactor(SearchResults): 使用useTimer替换setTimeout以管理定时器 * refactor(消息组件): 使用useTimer替换setTimeout以管理定时器 * refactor: 使用useTimer替换setTimeout以优化定时器管理 * refactor(AssistantsDrawer): 使用useTimer替换setTimeout以优化定时器管理 * refactor(Inputbar): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MCPToolsButton): 使用useTimer优化定时器管理 * refactor(QuickPhrasesButton): 使用useTimer替换setTimeout以优化定时器管理 * refactor(ChatFlowHistory): 使用useTimer替换setTimeout以管理定时器 * refactor(Message): 使用useTimer替换setTimeout以管理定时器 * refactor(MessageAnchorLine): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MessageGroup): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MessageMenubar): 使用 useTemporaryValue 替换手动 setTimeout 逻辑 * refactor(Messages): 使用 useTimer 优化定时器管理 * refactor(MessageTools): 使用useTimer替换setTimeout以管理定时器 * fix(SelectionBox): 修复鼠标移动时未清除定时器导致的内存泄漏 在鼠标移动事件处理中增加定时器清理逻辑,避免组件卸载时未清除定时器导致的内存泄漏问题 * refactor(ErrorBlock): 使用自定义hook替换setTimeout 使用useTimer中的setTimeoutTimer替代原生setTimeout,便于统一管理定时器 * refactor(GeneralSettings): 使用useTimer替换setTimeout以实现更好的定时器管理 * refactor(ShortcutSettings): 使用useTimer替换setTimeout以优化定时器管理 * refactor(AssistantModelSettings): 使用useTimer替换setTimeout以优化定时器管理 * refactor(DataSettings): 使用useTimer替换setTimeout以增强定时器管理 统一使用useTimer hook管理所有定时器操作,提高代码可维护性 * refactor(NutstoreSettings): 使用useTimer优化setTimeout管理 替换直接使用setTimeout为useTimer hook的setTimeoutTimer方法,提升定时器管理的可维护性 * refactor(MCPSettings): 使用useTimer替换setTimeout以提升代码可维护性 * refactor(ProviderSetting): 使用useTimer优化setTimeout管理 * refactor(ProviderSettings): 使用 useTimer 替换 setTimeout 以优化定时器管理 * refactor(InputBar): 使用useTimer替换setTimeout以实现更好的定时器管理 * refactor(MessageEditor): 使用useTimer替换setTimeout以管理定时器 使用自定义hook useTimer来替代原生setTimeout,便于统一管理和清理定时器 * docs(useTimer): 添加 useTimer hook 的使用示例和详细说明 * refactor(MinappPopupContainer): 使用useTimer替换setTimeout实现 替换直接使用setTimeout为自定义hook useTimer,简化组件清理逻辑 * refactor(AddAssistantPopup): 使用useTimer替换手动定时器管理 用useTimer钩子替代手动管理定时器,简化代码并提高可维护性 * refactor(WebSearchSettings): 使用 useTimer 替换手动定时器管理 移除手动管理的定时器逻辑,改用 useTimer hook 统一处理 * refactor(WebSearchProviderSetting): 使用自定义hook替代原生定时器 用useTimer hook替换原有的setTimeout和clearTimeout逻辑,提高代码可维护性 * refactor(translate): 使用 useTemporaryValue 替换手动实现的复制状态定时器 * refactor(SelectionToolbar): 使用 useTimer 钩子替换 setTimeout 和 clearTimeout 重构 SelectionToolbar 组件,使用自定义的 useTimer 钩子来管理定时器,提升代码可维护性 清理隐藏时的定时器逻辑,避免内存泄漏 --- .../MinApp/MinappPopupContainer.tsx | 5 +- .../components/Popups/AddAssistantPopup.tsx | 6 +- .../src/components/QuickPanel/view.tsx | 12 +- src/renderer/src/hooks/useInPlaceEdit.ts | 11 +- src/renderer/src/hooks/useKnowledge.ts | 29 +++- src/renderer/src/hooks/useScrollPosition.ts | 10 +- src/renderer/src/hooks/useTimer.ts | 152 ++++++++++++++++++ .../agents/components/ImportAgentPopup.tsx | 4 +- src/renderer/src/pages/code/CodeToolsPage.tsx | 4 +- .../history/components/SearchResults.tsx | 4 +- .../history/components/TopicMessages.tsx | 4 +- src/renderer/src/pages/home/Chat.tsx | 16 +- .../src/pages/home/Inputbar/Inputbar.tsx | 34 ++-- .../pages/home/Inputbar/MCPToolsButton.tsx | 20 ++- .../home/Inputbar/QuickPhrasesButton.tsx | 42 +++-- .../pages/home/Inputbar/UrlContextbutton.tsx | 14 +- .../pages/home/Messages/Blocks/ErrorBlock.tsx | 4 +- .../home/Messages/Blocks/ThinkingBlock.tsx | 6 +- .../pages/home/Messages/ChatFlowHistory.tsx | 13 +- .../pages/home/Messages/ChatNavigation.tsx | 31 ++-- .../src/pages/home/Messages/Message.tsx | 31 ++-- .../pages/home/Messages/MessageAnchorLine.tsx | 24 +-- .../src/pages/home/Messages/MessageEditor.tsx | 12 +- .../src/pages/home/Messages/MessageGroup.tsx | 20 ++- .../pages/home/Messages/MessageMenubar.tsx | 6 +- .../src/pages/home/Messages/MessageTools.tsx | 4 +- .../src/pages/home/Messages/Messages.tsx | 35 ++-- .../src/pages/home/Messages/SelectionBox.tsx | 4 +- .../home/components/AssistantsDrawer.tsx | 4 +- .../home/components/SelectModelButton.tsx | 20 ++- .../AssistantModelSettings.tsx | 16 +- .../settings/DataSettings/DataSettings.tsx | 90 +++++++---- .../DataSettings/NutstoreSettings.tsx | 10 +- .../src/pages/settings/GeneralSettings.tsx | 12 +- .../MCPSettings/AddMcpServerModal.tsx | 36 +++-- .../settings/MCPSettings/InstallNpxUv.tsx | 17 +- .../ProviderSettings/ProviderSetting.tsx | 12 +- .../SelectProviderModelPopup.tsx | 4 +- .../src/pages/settings/ShortcutSettings.tsx | 12 +- .../WebSearchSettings/BlacklistSettings.tsx | 6 +- .../WebSearchProviderSetting.tsx | 4 +- .../src/pages/translate/TranslatePage.tsx | 6 +- .../windows/mini/home/components/InputBar.tsx | 4 +- .../action/components/WindowFooter.tsx | 42 +++-- .../selection/toolbar/SelectionToolbar.tsx | 63 +++++--- 45 files changed, 649 insertions(+), 266 deletions(-) create mode 100644 src/renderer/src/hooks/useTimer.ts diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 95f0f1b5d0..226598dc57 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { MinAppType } from '@renderer/types' @@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => { const isInDevelopment = process.env.NODE_ENV === 'development' + const { setTimeoutTimer } = useTimer() + useBridge() /** set the popup display status */ @@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => { window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) } if (appid == currentMinappId) { - setTimeout(() => setIsReady(true), 200) + setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200) } } diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index b3ea93662a..eecad5ec9c 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,6 +1,7 @@ import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import { useSystemAgents } from '@renderer/pages/agents' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -33,6 +34,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const loadingRef = useRef(false) const [selectedIndex, setSelectedIndex] = useState(0) const containerRef = useRef(null) + const { setTimeoutTimer } = useTimer() const agents = useMemo(() => { const allAgents = [...userAgents, ...systemAgents] as Agent[] @@ -80,11 +82,11 @@ const PopupContainer: React.FC = ({ resolve }) => { assistant = await createAssistantFromAgent(agent) } - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) resolve(assistant) setOpen(false) }, - [resolve, addAssistant, setOpen] + [setTimeoutTimer, resolve, addAssistant] ) // 添加函数内使用的依赖项 // 键盘导航处理 useEffect(() => { diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 7119f75f36..74cbc763c1 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -68,6 +68,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { // 无匹配项自动关闭的定时器 const noMatchTimeoutRef = useRef(null) + const clearSearchTimerRef = useRef(undefined) + const focusTimerRef = useRef(undefined) // 处理搜索,过滤列表 const list = useMemo(() => { @@ -145,6 +147,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (noMatchTimeoutRef.current) { clearTimeout(noMatchTimeoutRef.current) noMatchTimeoutRef.current = null + clearTimeout(clearSearchTimerRef.current) + clearTimeout(focusTimerRef.current) } // 面板不可见时不设置新定时器 @@ -165,6 +169,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { clearTimeout(noMatchTimeoutRef.current) noMatchTimeoutRef.current = null } + clearTimeout(clearSearchTimerRef.current) + clearTimeout(focusTimerRef.current) } // eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染 }, [ctx.isVisible, searchText, list.length, ctx.close]) @@ -192,7 +198,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { newText = inputText.slice(0, start) + inputText.slice(end) setInputText(newText) - setTimeout(() => { + clearTimeout(focusTimerRef.current) + focusTimerRef.current = setTimeout(() => { textArea.focus() textArea.setSelectionRange(start, start) }, 0) @@ -333,7 +340,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { textArea.removeEventListener('input', handleInput) textArea.removeEventListener('compositionupdate', handleCompositionUpdate) textArea.removeEventListener('compositionend', handleCompositionEnd) - setTimeout(() => { + clearTimeout(clearSearchTimerRef.current) + clearSearchTimerRef.current = setTimeout(() => { setSearchText('') }, 200) // 等待面板关闭动画结束后,再清空搜索词 } diff --git a/src/renderer/src/hooks/useInPlaceEdit.ts b/src/renderer/src/hooks/useInPlaceEdit.ts index 077b6d21cb..537d89fc12 100644 --- a/src/renderer/src/hooks/useInPlaceEdit.ts +++ b/src/renderer/src/hooks/useInPlaceEdit.ts @@ -26,13 +26,22 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe const [originalValue, setOriginalValue] = useState('') const inputRef = useRef(null) + const editTimerRef = useRef(undefined) + + useEffect(() => { + return () => { + clearTimeout(editTimerRef.current) + } + }, []) + const startEdit = useCallback( (initialValue: string) => { setIsEditing(true) setEditValue(initialValue) setOriginalValue(initialValue) - setTimeout(() => { + clearTimeout(editTimerRef.current) + editTimerRef.current = setTimeout(() => { inputRef.current?.focus() if (autoSelectOnStart) { inputRef.current?.select() diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index a80db2659e..c1db47e502 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -20,7 +20,7 @@ import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@r import { runAsyncFunction } from '@renderer/utils' import dayjs from 'dayjs' import { cloneDeep } from 'lodash' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useAgents } from './useAgents' @@ -29,6 +29,7 @@ import { useAssistants } from './useAssistant' export const useKnowledge = (baseId: string) => { const dispatch = useAppDispatch() const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) + const checkTimerRef = useRef(undefined) // 重命名知识库 const renameKnowledgeBase = (name: string) => { @@ -40,34 +41,46 @@ export const useKnowledge = (baseId: string) => { dispatch(updateBase(base)) } + useEffect(() => { + return () => { + clearTimeout(checkTimerRef.current) + } + }, []) + + // 检查知识库 + const checkAllBases = () => { + clearTimeout(checkTimerRef.current) + checkTimerRef.current = setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + // 批量添加文件 const addFiles = (files: FileMetadata[]) => { dispatch(addFilesThunk(baseId, files)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 添加笔记 const addNote = async (content: string) => { await dispatch(addNoteThunk(baseId, content)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 添加URL const addUrl = (url: string) => { dispatch(addItemThunk(baseId, 'url', url)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 添加 Sitemap const addSitemap = (url: string) => { dispatch(addItemThunk(baseId, 'sitemap', url)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // Add directory support const addDirectory = (path: string) => { dispatch(addItemThunk(baseId, 'directory', path)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 更新笔记内容 const updateNoteContent = async (noteId: string, content: string) => { @@ -133,7 +146,7 @@ export const useKnowledge = (baseId: string) => { uniqueId: undefined, updated_at: Date.now() }) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } } @@ -229,7 +242,7 @@ export const useKnowledge = (baseId: string) => { throw new Error(`Failed to migrate files ${files}: ${error}`) } - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } const fileItems = base?.items.filter((item) => item.type === 'file') || [] diff --git a/src/renderer/src/hooks/useScrollPosition.ts b/src/renderer/src/hooks/useScrollPosition.ts index b3f4b5d512..872004d58e 100644 --- a/src/renderer/src/hooks/useScrollPosition.ts +++ b/src/renderer/src/hooks/useScrollPosition.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' export default function useScrollPosition(key: string) { const containerRef = useRef(null) const scrollKey = `scroll:${key}` + const scrollTimerRef = useRef(undefined) const handleScroll = throttle(() => { const position = containerRef.current?.scrollTop ?? 0 @@ -15,8 +16,15 @@ export default function useScrollPosition(key: string) { useEffect(() => { const scroll = () => containerRef.current?.scrollTo({ top: window.keyv.get(scrollKey) || 0 }) scroll() - setTimeout(scroll, 50) + clearTimeout(scrollTimerRef.current) + scrollTimerRef.current = setTimeout(scroll, 50) }, [scrollKey]) + useEffect(() => { + return () => { + clearTimeout(scrollTimerRef.current) + } + }, []) + return { containerRef, handleScroll } } diff --git a/src/renderer/src/hooks/useTimer.ts b/src/renderer/src/hooks/useTimer.ts new file mode 100644 index 0000000000..139c3e7143 --- /dev/null +++ b/src/renderer/src/hooks/useTimer.ts @@ -0,0 +1,152 @@ +import { useEffect, useRef } from 'react' + +/** + * 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器 + * + * - 在设置定时器时以前会自动清理相同key的定时器 + * - 组件卸载时会自动清理所有定时器,避免内存泄漏 + * + * 通常在 `useEffect` 中使用的定时器,可以通过清理函数处理。但是,在函数中使用的定时器则相对难以管理。 + * 这个 Hook 主要解决需要在函数中设置定时器的场景。然而,`setTimeoutTimer` 和 `setIntervalTimer` 同样也返回清理函数,因此可以用于 `useEffect` 中。 + * + * @example + * ```ts + * function MyComponent() { + * const { + * setTimeoutTimer, + * setIntervalTimer, + * clearTimeoutTimer, + * clearAllTimers + * } = useTimer(); + * + * useEffect(() => { + * // 设置一个3秒后执行的定时器 + * setTimeoutTimer('notify', () => { + * console.log('3秒后执行'); + * }, 3000); + * + * // 设置一个每5秒执行一次的定时器 + * const cleanup = setIntervalTimer('poll', () => { + * console.log('每5秒执行一次'); + * }, 5000); + * + * // 手动清理指定的定时器 + * clearTimeoutTimer('notify'); + * + * // 返回清理函数来停止轮询 + * return cleanup; + * }, []); + * } + * ``` + */ +export const useTimer = () => { + const timeoutMapRef = useRef(new Map()) + const intervalMapRef = useRef(new Map()) + + // 组件卸载时自动清理所有定时器 + useEffect(() => { + return clearAllTimers + }, []) + + /** + * 设置一个 setTimeout 定时器 + * @param key - 定时器标识符,用于标识和管理不同的定时器实例 + * @param args - setTimeout 的参数列表,包含回调函数和延迟时间(毫秒) + * @returns 返回一个清理函数,可以用来手动清除该定时器 + * @example + * ```ts + * const { setTimeoutTimer } = useTimer(); + * // 设置一个3秒后执行的定时器 + * const cleanup = setTimeoutTimer('myTimer', () => { + * console.log('Timer executed'); + * }, 3000); + * + * // 需要时可以提前清理定时器 + * cleanup(); + * ``` + */ + const setTimeoutTimer = (key: string, ...args: Parameters) => { + clearTimeout(timeoutMapRef.current.get(key)) + const timer = setTimeout(...args) + timeoutMapRef.current.set(key, timer) + return () => clearTimeoutTimer(key) + } + + /** + * 设置一个 setInterval 定时器 + * @param key - 定时器标识符,用于标识和管理不同的定时器实例 + * @param args - setInterval 的参数列表,包含回调函数和时间间隔(毫秒) + * @returns 返回一个清理函数,可以用来手动清除该定时器 + * @example + * ```ts + * const { setIntervalTimer } = useTimer(); + * // 设置一个每3秒执行一次的定时器 + * const cleanup = setIntervalTimer('myTimer', () => { + * console.log('Timer executed'); + * }, 3000); + * + * // 需要时可以停止定时器 + * cleanup(); + * ``` + */ + const setIntervalTimer = (key: string, ...args: Parameters) => { + clearInterval(intervalMapRef.current.get(key)) + const timer = setInterval(...args) + intervalMapRef.current.set(key, timer) + return () => clearIntervalTimer(key) + } + + /** + * 清除指定 key 的 setTimeout 定时器 + * @param key - 定时器标识符 + */ + const clearTimeoutTimer = (key: string) => { + clearTimeout(timeoutMapRef.current.get(key)) + timeoutMapRef.current.delete(key) + } + + /** + * 清除指定 key 的 setInterval 定时器 + * @param key - 定时器标识符 + */ + const clearIntervalTimer = (key: string) => { + clearInterval(intervalMapRef.current.get(key)) + intervalMapRef.current.delete(key) + } + + /** + * 清除所有 setTimeout 定时器 + */ + const clearAllTimeoutTimers = () => { + timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) + timeoutMapRef.current.clear() + } + + /** + * 清除所有 setInterval 定时器 + */ + const clearAllIntervalTimers = () => { + intervalMapRef.current.forEach((timer) => clearInterval(timer)) + intervalMapRef.current.clear() + } + + /** + * 清除所有定时器,包括 setTimeout 和 setInterval + */ + const clearAllTimers = () => { + timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) + intervalMapRef.current.forEach((timer) => clearInterval(timer)) + timeoutMapRef.current.clear() + intervalMapRef.current.clear() + } + + return { + setTimeoutTimer, + setIntervalTimer, + clearTimeoutTimer, + clearIntervalTimer, + clearAllTimeoutTimers, + clearAllIntervalTimers, + clearAllTimers + } as const +} diff --git a/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx b/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx index a940db5772..f638545acc 100644 --- a/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx @@ -1,5 +1,6 @@ import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' +import { useTimer } from '@renderer/hooks/useTimer' import { getDefaultModel } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Agent } from '@renderer/types' @@ -19,6 +20,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const { addAgent } = useAgents() const [importType, setImportType] = useState<'url' | 'file'>('url') const [loading, setLoading] = useState(false) + const { setTimeoutTimer } = useTimer() const onFinish = async (values: { url?: string }) => { setLoading(true) @@ -77,7 +79,7 @@ const PopupContainer: React.FC = ({ resolve }) => { key: 'agents-imported' }) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + setTimeoutTimer('onFinish', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) setOpen(false) resolve(agents) } catch (error) { diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index bb4457438d..946f8be1ab 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -4,6 +4,7 @@ import ModelSelector from '@renderer/components/ModelSelector' import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' import { useCodeTools } from '@renderer/hooks/useCodeTools' import { useProviders } from '@renderer/hooks/useProvider' +import { useTimer } from '@renderer/hooks/useTimer' import { getProviderByModel } from '@renderer/services/AssistantService' import { loggerService } from '@renderer/services/LoggerService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -48,6 +49,7 @@ const CodeToolsPage: FC = () => { removeDir, selectFolder } = useCodeTools() + const { setTimeoutTimer } = useTimer() // 状态管理 const [isLaunching, setIsLaunching] = useState(false) @@ -159,7 +161,7 @@ const CodeToolsPage: FC = () => { } finally { setIsInstallingBun(false) // 重新检查安装状态 - setTimeout(checkBunInstallation, 1000) + setTimeoutTimer('handleInstallBun', checkBunInstallation, 1000) } } diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 2fd299a388..c33d8e56e0 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -1,5 +1,6 @@ import db from '@renderer/databases' import useScrollPosition from '@renderer/hooks/useScrollPosition' +import { useTimer } from '@renderer/hooks/useTimer' import { getTopicById } from '@renderer/hooks/useTopic' import { Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' @@ -18,6 +19,7 @@ interface Props extends React.HTMLAttributes { const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('SearchResults') + const { setTimeoutTimer } = useTimer() const [searchTerms, setSearchTerms] = useState( keywords @@ -112,7 +114,7 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p pagination={{ pageSize: 10, onChange: () => { - setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0) + setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0) } }} renderItem={({ message, topic, content }) => ( diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 8c98c458df..917a110b5d 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -4,6 +4,7 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -29,6 +30,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('TopicMessages') const dispatch = useAppDispatch() const { messageStyle } = useSettings() + const { setTimeoutTimer } = useTimer() useEffect(() => { topic && dispatch(loadTopicMessagesThunk(topic.id)) @@ -45,7 +47,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { SearchPopup.hide() const assistant = getAssistantById(topic.assistantId) navigate('/', { state: { assistant, topic } }) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) + setTimeoutTimer('onContinueChat', () => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) } return ( diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 93ff02fb48..1618d5e633 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -8,6 +8,7 @@ import { useChatContext } from '@renderer/hooks/useChatContext' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant, Topic } from '@renderer/types' import { classNames } from '@renderer/utils' import { Flex } from 'antd' @@ -43,6 +44,7 @@ const Chat: FC = (props) => { const [filterIncludeUser, setFilterIncludeUser] = useState(false) const maxWidth = useChatMaxWidth() + const { setTimeoutTimer } = useTimer() useHotkeys('esc', () => { contentSearchRef.current?.disable() @@ -79,10 +81,14 @@ const Chat: FC = (props) => { setFilterIncludeUser(!filterIncludeUser) requestAnimationFrame(() => { requestAnimationFrame(() => { - setTimeout(() => { - contentSearchRef.current?.search() - contentSearchRef.current?.focus() - }, 0) + setTimeoutTimer( + 'userOutlinedItemClickHandler', + () => { + contentSearchRef.current?.search() + contentSearchRef.current?.focus() + }, + 0 + ) }) }) } @@ -99,7 +105,7 @@ const Chat: FC = (props) => { } const messagesComponentFirstUpdateHandler = () => { - setTimeout(() => (firstUpdateCompleted = true), 300) + setTimeoutTimer('messagesComponentFirstUpdateHandler', () => (firstUpdateCompleted = true), 300) firstUpdateOrNoFirstUpdateHandler() } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index df690c03d0..82a157adba 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -20,6 +20,7 @@ import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' +import { useTimer } from '@renderer/hooks/useTimer' import useTranslate from '@renderer/hooks/useTranslate' import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -114,6 +115,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model]) + const { setTimeoutTimer } = useTimer() const isVisionSupported = useMemo( () => @@ -254,14 +256,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = // Clear input setText('') setFiles([]) - setTimeout(() => setText(''), 500) - setTimeout(() => resizeTextArea(true), 0) + setTimeoutTimer('sendMessage_1', () => setText(''), 500) + setTimeoutTimer('sendMessage_2', () => resizeTextArea(true), 0) setExpand(false) } catch (error) { logger.warn('Failed to send message:', error as Error) parent?.recordException(error as Error) } - }, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, text, topic]) + }, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, setTimeoutTimer, text, topic]) const translate = useCallback(async () => { if (isTranslating) { @@ -272,13 +274,13 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setIsTranslating(true) const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage)) translatedText && setText(translatedText) - setTimeout(() => resizeTextArea(), 0) + setTimeoutTimer('translate', () => resizeTextArea(), 0) } catch (error) { logger.warn('Translation failed:', error as Error) } finally { setIsTranslating(false) } - }, [isTranslating, text, getLanguageByLangcode, targetLanguage, resizeTextArea]) + }, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) const openKnowledgeFileList = useCallback( (base: KnowledgeBase) => { @@ -428,10 +430,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setText(newText) // set cursor position in the next render cycle - setTimeout(() => { - textArea.selectionStart = textArea.selectionEnd = start + 1 - onInput() // trigger resizeTextArea - }, 0) + setTimeoutTimer( + 'handleKeyDown', + () => { + textArea.selectionStart = textArea.selectionEnd = start + 1 + onInput() // trigger resizeTextArea + }, + 0 + ) } } } @@ -457,20 +463,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = addTopic(topic) setActiveTopic(topic) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0) - }, [addTopic, assistant, setActiveTopic, setModel]) + setTimeoutTimer('addNewTopic', () => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0) + }, [addTopic, assistant.defaultModel, assistant.id, setActiveTopic, setModel, setTimeoutTimer]) const onQuote = useCallback( (text: string) => { const quotedText = formatQuotedText(text) setText((prevText) => { const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n` - setTimeout(() => resizeTextArea(), 0) + setTimeoutTimer('onQuote', () => resizeTextArea(), 0) return newText }) focusTextarea() }, - [resizeTextArea, focusTextarea] + [focusTextarea, setTimeoutTimer, resizeTextArea] ) const onPause = async () => { @@ -608,7 +614,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const onTranslated = (translatedText: string) => { setText(translatedText) - setTimeout(() => resizeTextArea(), 0) + setTimeoutTimer('onTranslated', () => resizeTextArea(), 0) } const handleDragStart = (e: React.MouseEvent) => { diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index b76525d429..b4589fbc24 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -1,6 +1,7 @@ import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMCPServers } from '@renderer/hooks/useMCPServers' +import { useTimer } from '@renderer/hooks/useTimer' import { EventEmitter } from '@renderer/services/EventService' import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { Form, Input, Tooltip } from 'antd' @@ -116,6 +117,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar const [form] = Form.useForm() const { updateAssistant, assistant } = useAssistant(props.assistant.id) + const { setTimeoutTimer } = useTimer() // 使用 useRef 存储不需要触发重渲染的值 const isMountedRef = useRef(true) @@ -154,14 +156,18 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar const updateMcpEnabled = useCallback( (enabled: boolean) => { - setTimeout(() => { - updateAssistant({ - ...assistant, - mcpServers: enabled ? assistant.mcpServers || [] : [] - }) - }, 200) + setTimeoutTimer( + 'updateMcpEnabled', + () => { + updateAssistant({ + ...assistant, + mcpServers: enabled ? assistant.mcpServers || [] : [] + }) + }, + 200 + ) }, - [assistant, updateAssistant] + [assistant, setTimeoutTimer, updateAssistant] ) const menuItems = useMemo(() => { diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index 0354032fa1..a6d0de67c6 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -1,6 +1,7 @@ import { useQuickPanel } from '@renderer/components/QuickPanel' import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import QuickPhraseService from '@renderer/services/QuickPhraseService' import { useAppSelector } from '@renderer/store' import { QuickPhrase } from '@renderer/types' @@ -34,6 +35,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, state.assistants.assistants.find((a) => a.id === assistantObj.id)?.id || state.assistants.defaultAssistant.id ) const { assistant, updateAssistant } = useAssistant(activeAssistantId) + const { setTimeoutTimer } = useTimer() const loadQuickListPhrases = useCallback( async (regularPhrases: QuickPhrase[] = []) => { @@ -54,24 +56,32 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, const handlePhraseSelect = useCallback( (phrase: QuickPhrase) => { - setTimeout(() => { - setInputValue((prev) => { - const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement - const cursorPosition = textArea.selectionStart - const selectionStart = cursorPosition - const selectionEndPosition = cursorPosition + phrase.content.length - const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition) + setTimeoutTimer( + 'handlePhraseSelect_1', + () => { + setInputValue((prev) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + const cursorPosition = textArea.selectionStart + const selectionStart = cursorPosition + const selectionEndPosition = cursorPosition + phrase.content.length + const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition) - setTimeout(() => { - textArea.focus() - textArea.setSelectionRange(selectionStart, selectionEndPosition) - resizeTextArea() - }, 10) - return newText - }) - }, 10) + setTimeoutTimer( + 'handlePhraseSelect_2', + () => { + textArea.focus() + textArea.setSelectionRange(selectionStart, selectionEndPosition) + resizeTextArea() + }, + 10 + ) + return newText + }) + }, + 10 + ) }, - [setInputValue, resizeTextArea] + [setTimeoutTimer, setInputValue, resizeTextArea] ) const handleModalOk = async () => { diff --git a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx index 11cfde3f72..cfbf12f538 100644 --- a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx +++ b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx @@ -1,4 +1,5 @@ import { useAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant } from '@renderer/types' import { Tooltip } from 'antd' import { Link } from 'lucide-react' @@ -18,14 +19,19 @@ interface Props { const UrlContextButton: FC = ({ assistant, ToolbarButton }) => { const { t } = useTranslation() const { updateAssistant } = useAssistant(assistant.id) + const { setTimeoutTimer } = useTimer() const urlContentNewState = !assistant.enableUrlContext const handleToggle = useCallback(() => { - setTimeout(() => { - updateAssistant({ ...assistant, enableUrlContext: urlContentNewState }) - }, 100) - }, [assistant, urlContentNewState, updateAssistant]) + setTimeoutTimer( + 'handleToggle', + () => { + updateAssistant({ ...assistant, enableUrlContext: urlContentNewState }) + }, + 100 + ) + }, [setTimeoutTimer, updateAssistant, assistant, urlContentNewState]) return ( diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 86d6525e15..1ed33e7475 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -1,3 +1,4 @@ +import { useTimer } from '@renderer/hooks/useTimer' import { getHttpMessageLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { removeBlocksThunk } from '@renderer/store/thunk/messageThunk' @@ -19,11 +20,12 @@ const ErrorBlock: React.FC = ({ block, message }) => { const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }> = ({ block, message }) => { const { t, i18n } = useTranslation() const dispatch = useAppDispatch() + const { setTimeoutTimer } = useTimer() const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504] const onRemoveBlock = () => { - setTimeout(() => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350) + setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350) } if (block.error && HTTP_ERROR_CODES.includes(block.error?.status)) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 461f42c185..96f439fa2e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -2,6 +2,7 @@ import { CheckOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import ThinkingEffect from '@renderer/components/ThinkingEffect' import { useSettings } from '@renderer/hooks/useSettings' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { Collapse, message as antdMessage, Tooltip } from 'antd' import { memo, useCallback, useEffect, useMemo, useState } from 'react' @@ -16,7 +17,7 @@ interface Props { } const ThinkingBlock: React.FC = ({ block }) => { - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const { t } = useTranslation() const { messageFont, fontSize, thoughtAutoCollapse } = useSettings() const [activeKey, setActiveKey] = useState<'thought' | ''>(thoughtAutoCollapse ? '' : 'thought') @@ -38,14 +39,13 @@ const ThinkingBlock: React.FC = ({ block }) => { .then(() => { antdMessage.success({ content: t('message.copied'), key: 'copy-message' }) setCopied(true) - setTimeout(() => setCopied(false), 2000) }) .catch((error) => { logger.error('Failed to copy text:', error) antdMessage.error({ content: t('message.copy.failed'), key: 'copy-message-error' }) }) } - }, [block.content, t]) + }, [block.content, setCopied, t]) if (!block.content) { return null diff --git a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx index 4197b0a957..9cd1db2163 100644 --- a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx +++ b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx @@ -7,6 +7,7 @@ import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { RootState } from '@renderer/store' import { selectMessagesForTopic } from '@renderer/store/newMessage' @@ -50,6 +51,8 @@ const TooltipFooter = styled.div` // 自定义节点组件 const CustomNode: FC<{ data: any }> = ({ data }) => { const { t } = useTranslation() + const { setTimeoutTimer } = useTimer() + const nodeType = data.type let borderColor = 'var(--color-border)' let title = '' @@ -114,9 +117,13 @@ const CustomNode: FC<{ data: any }> = ({ data }) => { // 让监听器处理标签切换 document.dispatchEvent(customEvent) - setTimeout(() => { - EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + data.messageId) - }, 250) + setTimeoutTimer( + 'handleNodeClick', + () => { + EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + data.messageId) + }, + 250 + ) } } diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx index 17b98cb0b2..7694942b4d 100644 --- a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -39,7 +39,7 @@ const ChatNavigation: FC = ({ containerId }) => { const { t } = useTranslation() const [isVisible, setIsVisible] = useState(false) const [isNearButtons, setIsNearButtons] = useState(false) - const [hideTimer, setHideTimer] = useState(null) + const hideTimerRef = useRef(undefined) const [showChatHistory, setShowChatHistory] = useState(false) const [manuallyClosedUntil, setManuallyClosedUntil] = useState(null) const currentTopicId = useSelector((state: RootState) => state.messages.currentTopicId) @@ -49,20 +49,16 @@ const ChatNavigation: FC = ({ containerId }) => { // Reset hide timer and make buttons visible const resetHideTimer = useCallback(() => { - if (hideTimer) { - clearTimeout(hideTimer) - } - setIsVisible(true) // Only set a hide timer if cursor is not near the buttons if (!isNearButtons) { - const timer = setTimeout(() => { + clearTimeout(hideTimerRef.current) + hideTimerRef.current = setTimeout(() => { setIsVisible(false) }, 1500) - setHideTimer(timer) } - }, [hideTimer, isNearButtons]) + }, [isNearButtons]) // Handle mouse entering button area const handleMouseEnter = useCallback(() => { @@ -74,21 +70,21 @@ const ChatNavigation: FC = ({ containerId }) => { setIsVisible(true) // Clear any existing hide timer - if (hideTimer) { - clearTimeout(hideTimer) - setHideTimer(null) - } - }, [hideTimer, manuallyClosedUntil]) + clearTimeout(hideTimerRef.current) + }, [manuallyClosedUntil]) // Handle mouse leaving button area const handleMouseLeave = useCallback(() => { setIsNearButtons(false) // Set a timer to hide the buttons - const timer = setTimeout(() => { + hideTimerRef.current = setTimeout(() => { setIsVisible(false) }, 500) - setHideTimer(timer) + + return () => { + clearTimeout(hideTimerRef.current) + } }, []) const handleChatHistoryClick = () => { @@ -322,13 +318,10 @@ const ChatNavigation: FC = ({ containerId }) => { } else { window.removeEventListener('mousemove', handleMouseMove) } - if (hideTimer) { - clearTimeout(hideTimer) - } + clearTimeout(hideTimerRef.current) } }, [ containerId, - hideTimer, resetHideTimer, isNearButtons, handleMouseEnter, diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index c874f1bb20..da595eebce 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -6,6 +6,7 @@ import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useModel } from '@renderer/hooks/useModel' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -71,6 +72,7 @@ const MessageItem: FC = ({ const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() + const { setTimeoutTimer } = useTimer() const isEditing = editingMessageId === message.id useEffect(() => { @@ -119,18 +121,25 @@ const MessageItem: FC = ({ const isAssistantMessage = message.role === 'assistant' const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing - const messageHighlightHandler = useCallback((highlight: boolean = true) => { - if (messageContainerRef.current) { - messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) - if (highlight) { - setTimeout(() => { - const classList = messageContainerRef.current?.classList - classList?.add('message-highlight') - setTimeout(() => classList?.remove('message-highlight'), 2500) - }, 500) + const messageHighlightHandler = useCallback( + (highlight: boolean = true) => { + if (messageContainerRef.current) { + messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) + if (highlight) { + setTimeoutTimer( + 'messageHighlightHandler_1', + () => { + const classList = messageContainerRef.current?.classList + classList?.add('message-highlight') + setTimeoutTimer('messageHighlightHandler_2', () => classList?.remove('message-highlight'), 2500) + }, + 500 + ) + } } - } - }, []) + }, + [setTimeoutTimer] + ) useEffect(() => { const unsubscribes = [EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, messageHighlightHandler)] diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index 7b116f8495..f4ac909b91 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -4,6 +4,7 @@ import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelName } from '@renderer/services/ModelService' import { useAppDispatch } from '@renderer/store' @@ -32,13 +33,14 @@ const MessageAnchorLine: FC = ({ messages }) => { const avatar = useAvatar() const { theme } = useTheme() const dispatch = useAppDispatch() - const { userName } = useSettings() + const { setTimeoutTimer } = useTimer() + const messagesListRef = useRef(null) const messageItemsRef = useRef>(new Map()) const containerRef = useRef(null) - const [mouseY, setMouseY] = useState(null) + const [mouseY, setMouseY] = useState(null) const [listOffsetY, setListOffsetY] = useState(0) const [containerHeight, setContainerHeight] = useState(null) @@ -112,15 +114,19 @@ const MessageAnchorLine: FC = ({ messages }) => { ) } - setTimeout(() => { - const messageElement = document.getElementById(`message-${message.id}`) - if (messageElement) { - messageElement.scrollIntoView({ behavior: 'auto', block: 'start' }) - } - }, 100) + setTimeoutTimer( + 'setSelectedMessage', + () => { + const messageElement = document.getElementById(`message-${message.id}`) + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'auto', block: 'start' }) + } + }, + 100 + ) } }, - [dispatch, messages] + [dispatch, messages, setTimeoutTimer] ) const scrollToMessage = useCallback( diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 170f754006..155312a368 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -4,6 +4,7 @@ import TranslateButton from '@renderer/components/TranslateButton' import { isGenerateImageModel, isVisionModel } from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import FileManager from '@renderer/services/FileManager' import PasteService from '@renderer/services/PasteService' import { useAppSelector } from '@renderer/store' @@ -51,6 +52,7 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC const isUserMessage = message.role === 'user' const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, topicId)) + const { setTimeoutTimer } = useTimer() const couldAddImageFile = useMemo(() => { const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant') @@ -247,9 +249,13 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC handleTextChange(blockId, newText) // set cursor position in the next render cycle - setTimeout(() => { - textArea.selectionStart = textArea.selectionEnd = start + 1 - }, 0) + setTimeoutTimer( + 'handleKeyDown', + () => { + textArea.selectionStart = textArea.selectionEnd = start + 1 + }, + 0 + ) } } } diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 4db2a42747..4632c9ffb9 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -4,6 +4,7 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { MultiModelMessageStyle } from '@renderer/store/settings' import type { Topic } from '@renderer/types' @@ -32,6 +33,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() const { isMultiSelectMode } = useChatContext(topic) const maxWidth = useChatMaxWidth() + const { setTimeoutTimer } = useTimer() const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant') @@ -68,14 +70,18 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { // 当前选中的消息 editMessage(message.id, { foldSelected: true }) - setTimeout(() => { - const messageElement = document.getElementById(`message-${message.id}`) - if (messageElement) { - messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - }, 200) + setTimeoutTimer( + 'setSelectedMessage', + () => { + const messageElement = document.getElementById(`message-${message.id}`) + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, + 200 + ) }, - [editMessage, selectedMessageId] + [editMessage, selectedMessageId, setTimeoutTimer] ) useEffect(() => { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 0e3aa77872..4d99319132 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -9,6 +9,7 @@ import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { useEnableDeveloperMode, useMessageStyle } from '@renderer/hooks/useSettings' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import useTranslate from '@renderer/hooks/useTranslate' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' @@ -78,7 +79,7 @@ const MessageMenubar: FC = (props) => { } = props const { t } = useTranslation() const { toggleMultiSelectMode } = useChatContext(props.topic) - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const [isTranslating, setIsTranslating] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) const [showDeleteTooltip, setShowDeleteTooltip] = useState(false) @@ -136,9 +137,8 @@ const MessageMenubar: FC = (props) => { window.message.success({ content: t('message.copied'), key: 'copy-message' }) setCopied(true) - setTimeout(() => setCopied(false), 2000) }, - [message, t] // message is needed for message.id and as a fallback. t is for translation. + [message, setCopied, t] // message is needed for message.id and as a fallback. t is for translation. ) const onNewBranch = useCallback(async () => { diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 2dbcb65912..2a5077f48d 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -3,6 +3,7 @@ import { CopyIcon, LoadingIcon } from '@renderer/components/Icons' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' @@ -52,6 +53,7 @@ const MessageTools: FC = ({ block }) => { const { mcpServers, updateMCPServer } = useMCPServers() const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null) const [progress, setProgress] = useState(0) + const { setTimeoutTimer } = useTimer() const toolResponse = block.metadata?.rawMcpToolResponse @@ -130,7 +132,7 @@ const MessageTools: FC = ({ block }) => { navigator.clipboard.writeText(content) antdMessage.success({ content: t('message.copied'), key: 'copy-message' }) setCopiedMap((prev) => ({ ...prev, [toolId]: true })) - setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000) + setTimeoutTimer('copyContent', () => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000) } const handleCollapseChange = (keys: string | string[]) => { diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index d8a650412a..fbe0eb8e0b 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -9,6 +9,7 @@ import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessa import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' +import { useTimer } from '@renderer/hooks/useTimer' import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic' import SelectionBox from '@renderer/pages/home/Messages/SelectionBox' import { getDefaultTopic } from '@renderer/services/AssistantService' @@ -55,22 +56,24 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const { containerRef: scrollContainerRef, handleScroll: handleScrollPosition } = useScrollPosition( `topic-${topic.id}` ) - const { t } = useTranslation() - const { showPrompt, messageNavigation } = useSettings() - const { updateTopic, addTopic } = useAssistant(assistant.id) - const dispatch = useAppDispatch() const [displayMessages, setDisplayMessages] = useState([]) const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [isProcessingContext, setIsProcessingContext] = useState(false) - const messageElements = useRef>(new Map()) + const { updateTopic, addTopic } = useAssistant(assistant.id) + const { showPrompt, messageNavigation } = useSettings() + const { t } = useTranslation() + const dispatch = useAppDispatch() const messages = useTopicMessages(topic.id) const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) - const messagesRef = useRef(messages) + const { setTimeoutTimer } = useTimer() const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic) + const messageElements = useRef>(new Map()) + const messagesRef = useRef(messages) + useEffect(() => { messagesRef.current = messages }, [messages]) @@ -256,15 +259,19 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o if (!hasMore || isLoadingMore) return setIsLoadingMore(true) - setTimeout(() => { - const currentLength = displayMessages.length - const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT) + setTimeoutTimer( + 'loadMoreMessages', + () => { + const currentLength = displayMessages.length + const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT) - setDisplayMessages((prev) => [...prev, ...newMessages]) - setHasMore(currentLength + LOAD_MORE_COUNT < messages.length) - setIsLoadingMore(false) - }, 300) - }, [displayMessages.length, hasMore, isLoadingMore, messages]) + setDisplayMessages((prev) => [...prev, ...newMessages]) + setHasMore(currentLength + LOAD_MORE_COUNT < messages.length) + setIsLoadingMore(false) + }, + 300 + ) + }, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer]) useShortcut('copy_last_message', () => { const lastMessage = last(messages) diff --git a/src/renderer/src/pages/home/Messages/SelectionBox.tsx b/src/renderer/src/pages/home/Messages/SelectionBox.tsx index 2d12f0305c..ed9673691a 100644 --- a/src/renderer/src/pages/home/Messages/SelectionBox.tsx +++ b/src/renderer/src/pages/home/Messages/SelectionBox.tsx @@ -26,6 +26,7 @@ const SelectionBox: React.FC = ({ const DRAG_THRESHOLD = 5 useEffect(() => { + let handleMouseMoveTimer: NodeJS.Timeout if (!isMultiSelectMode) return const updateDragPos = (e: MouseEvent) => { @@ -106,7 +107,7 @@ const SelectionBox: React.FC = ({ dragSelectedIds.current.add(id) newSelectedIds.add(id) el.classList.add('selection-highlight') - setTimeout(() => el.classList.remove('selection-highlight'), 300) + handleMouseMoveTimer = setTimeout(() => el.classList.remove('selection-highlight'), 300) } }) } @@ -129,6 +130,7 @@ const SelectionBox: React.FC = ({ window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) document.body.classList.remove('no-select') + clearTimeout(handleMouseMoveTimer) } }, [isMultiSelectMode, isDragging, isMouseDown, dragStart, scrollContainerRef, messageElements, handleSelectMessage]) diff --git a/src/renderer/src/pages/home/components/AssistantsDrawer.tsx b/src/renderer/src/pages/home/components/AssistantsDrawer.tsx index d35db2b125..d37f39a234 100644 --- a/src/renderer/src/pages/home/components/AssistantsDrawer.tsx +++ b/src/renderer/src/pages/home/components/AssistantsDrawer.tsx @@ -1,5 +1,6 @@ import { TopView } from '@renderer/components/TopView' import { isMac } from '@renderer/config/constant' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant, Topic } from '@renderer/types' import { Drawer } from 'antd' import { useState } from 'react' @@ -25,10 +26,11 @@ const PopupContainer: React.FC = ({ resolve }) => { const [open, setOpen] = useState(true) + const { setTimeoutTimer } = useTimer() const onClose = () => { setOpen(false) - setTimeout(resolve, 300) + setTimeoutTimer('onClose', resolve, 300) } AssistantsDrawer.hide = onClose diff --git a/src/renderer/src/pages/home/components/SelectModelButton.tsx b/src/renderer/src/pages/home/components/SelectModelButton.tsx index 82d27abce4..0d13a2d592 100644 --- a/src/renderer/src/pages/home/components/SelectModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectModelButton.tsx @@ -7,7 +7,7 @@ import { getProviderName } from '@renderer/services/ProviderService' import { Assistant } from '@renderer/types' import { Button } from 'antd' import { ChevronsUpDown } from 'lucide-react' -import { FC } from 'react' +import { FC, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -18,17 +18,15 @@ interface Props { const SelectModelButton: FC = ({ assistant }) => { const { model, updateAssistant } = useAssistant(assistant.id) const { t } = useTranslation() - - if (isLocalAi) { - return null - } + const timerRef = useRef(undefined) const onSelectModel = async (event: React.MouseEvent) => { event.currentTarget.blur() const selectedModel = await SelectModelPopup.show({ model }) if (selectedModel) { // 避免更新数据造成关闭弹框的卡顿 - setTimeout(() => { + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { const enabledWebSearch = isWebSearchModel(selectedModel) updateAssistant({ ...assistant, @@ -39,6 +37,16 @@ const SelectModelButton: FC = ({ assistant }) => { } } + useEffect(() => { + return () => { + clearTimeout(timerRef.current) + } + }, []) + + if (isLocalAi) { + return null + } + const providerName = getProviderName(model?.provider) return ( diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index b772469b9d..668f68e1e9 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -6,6 +6,7 @@ import { HStack } from '@renderer/components/Layout' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, MAX_CONTEXT_COUNT } from '@renderer/config/constant' +import { useTimer } from '@renderer/hooks/useTimer' import { SettingRow } from '@renderer/pages/settings' import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '@renderer/types' import { modalConfirm } from '@renderer/utils' @@ -42,6 +43,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA customParametersRef.current = customParameters const { t } = useTranslation() + const { setTimeoutTimer } = useTimer() const onTemperatureChange = (value) => { if (!isNaN(value as number)) { @@ -191,13 +193,13 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA // TODO: 需要根据配置来设置默认值 if (selectedModel.name.includes('kimi-k2')) { setTemperature(0.6) - setTimeout(() => updateAssistantSettings({ temperature: 0.6 }), 500) + setTimeoutTimer('onSelectModel_1', () => updateAssistantSettings({ temperature: 0.6 }), 500) } else if (selectedModel.name.includes('moonshot')) { setTemperature(0.3) - setTimeout(() => updateAssistantSettings({ temperature: 0.3 }), 500) + setTimeoutTimer('onSelectModel_2', () => updateAssistantSettings({ temperature: 0.3 }), 500) } } - }, [assistant, defaultModel, updateAssistant, updateAssistantSettings]) + }, [assistant, defaultModel, setTimeoutTimer, updateAssistant, updateAssistantSettings]) useEffect(() => { return () => updateAssistantSettings({ customParameters: customParametersRef.current }) @@ -275,7 +277,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setTemperature(value) - setTimeout(() => updateAssistantSettings({ temperature: value }), 500) + setTimeoutTimer('temperature_onChange', () => updateAssistantSettings({ temperature: value }), 500) } }} style={{ width: '100%' }} @@ -323,7 +325,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setTopP(value) - setTimeout(() => updateAssistantSettings({ topP: value }), 500) + setTimeoutTimer('topP_onChange', () => updateAssistantSettings({ topP: value }), 500) } }} style={{ width: '100%' }} @@ -352,7 +354,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setContextCount(value) - setTimeout(() => updateAssistantSettings({ contextCount: value }), 500) + setTimeoutTimer('contextCount_onChange', () => updateAssistantSettings({ contextCount: value }), 500) } }} style={{ width: '100%' }} @@ -413,7 +415,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setMaxTokens(value) - setTimeout(() => updateAssistantSettings({ maxTokens: value }), 1000) + setTimeoutTimer('maxTokens_onChange', () => updateAssistantSettings({ maxTokens: value }), 1000) } }} style={{ width: '100%' }} diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 5ea4b9ba01..ace64ab25f 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -13,6 +13,7 @@ import BackupPopup from '@renderer/components/Popups/BackupPopup' import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' +import { useTimer } from '@renderer/hooks/useTimer' import { reset } from '@renderer/services/BackupService' import store, { useAppDispatch } from '@renderer/store' import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings' @@ -54,6 +55,7 @@ const DataSettings: FC = () => { const { size, removeAllFiles } = useKnowledgeFiles() const { theme } = useTheme() const [menu, setMenu] = useState('data') + const { setTimeoutTimer } = useTimer() const _skipBackupFile = store.getState().settings.skipBackupFile const [skipBackupFile, setSkipBackupFile] = useState(_skipBackupFile) @@ -247,11 +249,15 @@ const DataSettings: FC = () => { content: t('settings.data.app_data.restart_notice'), duration: 2 }) - setTimeout(() => { - window.api.relaunchApp({ - args: ['--new-data-path=' + newPath] - }) - }, 500) + setTimeoutTimer( + 'doubleConfirmModalBeforeCopyData', + () => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, + 500 + ) } }) } @@ -333,11 +339,15 @@ const DataSettings: FC = () => { content: t('settings.data.app_data.restart_notice'), duration: 3 }) - setTimeout(() => { - window.api.relaunchApp({ - args: ['--new-data-path=' + newPath] - }) - }, 500) + setTimeoutTimer( + 'showMigrationConfirmModal_1', + () => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, + 500 + ) return } // 如果不复制数据,直接设置新的应用数据路径 @@ -348,11 +358,15 @@ const DataSettings: FC = () => { setAppInfo(await window.api.getAppInfo()) // 通知用户并重启应用 - setTimeout(() => { - window.message.success(t('settings.data.app_data.select_success')) - window.api.setStopQuitApp(false, '') - window.api.relaunchApp() - }, 500) + setTimeoutTimer( + 'showMigrationConfirmModal_2', + () => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp() + }, + 500 + ) } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ @@ -456,7 +470,7 @@ const DataSettings: FC = () => { await window.api.flushAppData() // wait 2 seconds to flush app data - await new Promise((resolve) => setTimeout(resolve, 2000)) + await new Promise((resolve) => setTimeoutTimer('startMigration_1', resolve, 2000)) // 开始复制过程 const copyResult = await window.api.copy( @@ -476,15 +490,19 @@ const DataSettings: FC = () => { if (!copyResult.success) { // 延迟关闭加载模态框 await new Promise((resolve) => { - setTimeout(() => { - loadingModal.destroy() - window.message.error({ - content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, - key: messageKey, - duration: 5 - }) - resolve() - }, 500) + setTimeoutTimer( + 'startMigration_2', + () => { + loadingModal.destroy() + window.message.error({ + content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, + key: messageKey, + duration: 5 + }) + resolve() + }, + 500 + ) }) throw new Error(copyResult.error || 'Unknown error during copy') @@ -494,7 +512,7 @@ const DataSettings: FC = () => { await window.api.setAppDataPath(newPath) // 短暂延迟以显示100%完成 - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeoutTimer('startMigration_3', resolve, 500)) // 关闭加载模态框 loadingModal.destroy() @@ -529,13 +547,17 @@ const DataSettings: FC = () => { setAppInfo(await window.api.getAppInfo()) // 通知用户并重启应用 - setTimeout(() => { - window.message.success(t('settings.data.app_data.select_success')) - window.api.setStopQuitApp(false, '') - window.api.relaunchApp({ - args: ['--user-data-dir=' + newDataPath] - }) - }, 1000) + setTimeoutTimer( + 'handleDataMigration', + () => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp({ + args: ['--user-data-dir=' + newDataPath] + }) + }, + 1000 + ) } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ @@ -552,7 +574,7 @@ const DataSettings: FC = () => { } handleDataMigration() - }, [t]) + }, [setTimeoutTimer, t]) const onSkipBackupFilesChange = (value: boolean) => { setSkipBackupFile(value) diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index 13087359e3..e040b13ccc 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -6,6 +6,7 @@ import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' import { useNutstoreSSO } from '@renderer/hooks/useNutstoreSSO' +import { useTimer } from '@renderer/hooks/useTimer' import { backupToNutstore, checkConnection, @@ -51,17 +52,14 @@ const NutstoreSettings: FC = () => { const [nutstoreUsername, setNutstoreUsername] = useState(undefined) const [nutstorePass, setNutstorePass] = useState(undefined) const [storagePath, setStoragePath] = useState(nutstorePath) - const [checkConnectionLoading, setCheckConnectionLoading] = useState(false) const [nsConnected, setNsConnected] = useState(false) - const [syncInterval, setSyncInterval] = useState(nutstoreSyncInterval) - const [nutSkipBackupFile, setNutSkipBackupFile] = useState(nutstoreSkipBackupFile) + const [backupManagerVisible, setBackupManagerVisible] = useState(false) const nutstoreSSOHandler = useNutstoreSSO() - - const [backupManagerVisible, setBackupManagerVisible] = useState(false) + const { setTimeoutTimer } = useTimer() const handleClickNutstoreSSO = useCallback(async () => { const ssoUrl = await window.api.nutstore.getSSOUrl() @@ -120,7 +118,7 @@ const NutstoreSettings: FC = () => { setNsConnected(isConnectedToNutstore) setCheckConnectionLoading(false) - setTimeout(() => setNsConnected(false), 3000) + setTimeoutTimer('handleCheckConnection', () => setNsConnected(false), 3000) } const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } = diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index d7c5510359..8bc7354837 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -4,6 +4,7 @@ import { HStack } from '@renderer/components/Layout' import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useEnableDeveloperMode, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { RootState, useAppDispatch } from '@renderer/store' import { @@ -48,6 +49,7 @@ const GeneralSettings: FC = () => { const [proxyBypassRules, setProxyBypassRules] = useState(storeProxyBypassRules) const { theme } = useTheme() const { enableDeveloperMode, setEnableDeveloperMode } = useEnableDeveloperMode() + const { setTimeoutTimer } = useTimer() const updateTray = (isShowTray: boolean) => { setTray(isShowTray) @@ -171,9 +173,13 @@ const GeneralSettings: FC = () => { } // 重启应用 - setTimeout(() => { - window.api.relaunchApp() - }, 500) + setTimeoutTimer( + 'handleHardwareAccelerationChange', + () => { + window.api.relaunchApp() + }, + 500 + ) } }) } diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index 5d4139cd38..60f9d26d56 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -2,6 +2,7 @@ import { UploadOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import CodeEditor from '@renderer/components/CodeEditor' +import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMCPServerActive } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' @@ -72,6 +73,7 @@ const AddMcpServerModal: FC = ({ const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod) const [dxtFile, setDxtFile] = useState(null) const dispatch = useAppDispatch() + const { setTimeoutTimer } = useTimer() // Update import method when initialImportMethod changes useEffect(() => { @@ -159,21 +161,25 @@ const AddMcpServerModal: FC = ({ onClose() // Check server connectivity in background (with timeout) - setTimeout(() => { - window.api.mcp - .checkMcpConnectivity(newServer) - .then((isConnected) => { - logger.debug(`Connectivity check for ${newServer.name}: ${isConnected}`) - dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) - }) - .catch((connError: any) => { - logger.error(`Connectivity check failed for ${newServer.name}:`, connError) - // Don't show error for DXT servers as they might need additional setup - logger.warn( - `DXT server ${newServer.name} connectivity check failed, this is normal for servers requiring additional configuration` - ) - }) - }, 1000) // Delay to ensure server is properly added to store + setTimeoutTimer( + 'handleOk', + () => { + window.api.mcp + .checkMcpConnectivity(newServer) + .then((isConnected) => { + logger.debug(`Connectivity check for ${newServer.name}: ${isConnected}`) + dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) + }) + .catch((connError: any) => { + logger.error(`Connectivity check failed for ${newServer.name}:`, connError) + // Don't show error for DXT servers as they might need additional setup + logger.warn( + `DXT server ${newServer.name} connectivity check failed, this is normal for servers requiring additional configuration` + ) + }) + }, + 1000 + ) // Delay to ensure server is properly added to store } catch (error) { logger.error('DXT processing error:', error as Error) window.message.error({ diff --git a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx index 2f7e64bf34..f1a569a2f2 100644 --- a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx @@ -3,7 +3,7 @@ import { Center, VStack } from '@renderer/components/Layout' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp' import { Alert, Button } from 'antd' -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import styled from 'styled-components' @@ -26,6 +26,15 @@ const InstallNpxUv: FC = ({ mini = false }) => { const [binariesDir, setBinariesDir] = useState(null) const { t } = useTranslation() const navigate = useNavigate() + const checkBinariesTimerRef = useRef(undefined) + + // 清理定时器 + useEffect(() => { + return () => { + clearTimeout(checkBinariesTimerRef.current) + } + }, []) + const checkBinaries = useCallback(async () => { const uvExists = await window.api.isBinaryExist('uv') const bunExists = await window.api.isBinaryExist('bun') @@ -48,7 +57,8 @@ const InstallNpxUv: FC = ({ mini = false }) => { window.message.error({ content: `${t('settings.mcp.installError')}: ${error.message}`, key: 'mcp-install-error' }) setIsInstallingUv(false) } - setTimeout(checkBinaries, 1000) + clearTimeout(checkBinariesTimerRef.current) + checkBinariesTimerRef.current = setTimeout(checkBinaries, 1000) } const installBun = async () => { @@ -64,7 +74,8 @@ const InstallNpxUv: FC = ({ mini = false }) => { }) setIsInstallingBun(false) } - setTimeout(checkBinaries, 1000) + clearTimeout(checkBinariesTimerRef.current) + checkBinariesTimerRef.current = setTimeout(checkBinaries, 1000) } useEffect(() => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 8c496e64a1..fbb2806f01 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -6,6 +6,7 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList' import { checkApi } from '@renderer/services/ApiService' @@ -53,6 +54,7 @@ const ProviderSetting: FC = ({ providerId }) => { const [apiVersion, setApiVersion] = useState(provider.apiVersion) const { t } = useTranslation() const { theme } = useTheme() + const { setTimeoutTimer } = useTimer() const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -170,9 +172,13 @@ const ProviderSetting: FC = ({ providerId }) => { }) setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.SUCCESS })) - setTimeout(() => { - setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED })) - }, 3000) + setTimeoutTimer( + 'onCheckApi', + () => { + setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED })) + }, + 3000 + ) } catch (error: any) { window.message.error({ key: 'api-check', diff --git a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx index b419cb5e5d..cf65c42448 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx @@ -1,6 +1,7 @@ import ModelSelector from '@renderer/components/ModelSelector' import { TopView } from '@renderer/components/TopView' import { isRerankModel } from '@renderer/config/models' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { getModelUniqId } from '@renderer/services/ModelService' import { Model, Provider } from '@renderer/types' @@ -19,6 +20,7 @@ interface Props extends ShowParams { const PopupContainer: React.FC = ({ provider, resolve, reject }) => { const [open, setOpen] = useState(true) + const { setTimeoutTimer } = useTimer() // Keep the natural order of models const models = useMemo(() => provider.models.filter((m) => !isRerankModel(m)), [provider]) @@ -42,7 +44,7 @@ const PopupContainer: React.FC = ({ provider, resolve, reject }) => { const onCancel = () => { setOpen(false) - setTimeout(reject, 300) + setTimeoutTimer('onCancel', reject, 300) } const onClose = () => { diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 4eb91e9c05..834f5e283a 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout' import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useShortcuts } from '@renderer/hooks/useShortcuts' +import { useTimer } from '@renderer/hooks/useTimer' import { getShortcutLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts' @@ -22,6 +23,7 @@ const ShortcutSettings: FC = () => { const { shortcuts: originalShortcuts } = useShortcuts() const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) + const { setTimeoutTimer } = useTimer() //if shortcut is not available on all the platforms, block the shortcut here let shortcuts = originalShortcuts @@ -42,9 +44,13 @@ const ShortcutSettings: FC = () => { const handleAddShortcut = (record: Shortcut) => { setEditingKey(record.key) - setTimeout(() => { - inputRefs.current[record.key]?.focus() - }, 0) + setTimeoutTimer( + 'handleAddShortcut', + () => { + inputRefs.current[record.key]?.focus() + }, + 0 + ) } const isShortcutModified = (record: Shortcut) => { diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx index 8aa7783a44..de94587e2f 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' +import { useTimer } from '@renderer/hooks/useTimer' import { useBlacklist } from '@renderer/hooks/useWebSearchProviders' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setExcludeDomains } from '@renderer/store/websearch' @@ -47,6 +48,7 @@ const BlacklistSettings: FC = () => { name: source.name })) || [] ) + const { setTimeoutTimer } = useTimer() const dispatch = useAppDispatch() @@ -148,7 +150,7 @@ const BlacklistSettings: FC = () => { content: t('settings.tool.websearch.subscribe_update_success'), duration: 2 }) - setTimeout(() => setSubscribeValid(false), 3000) + setTimeoutTimer('updateSubscribe', () => setSubscribeValid(false), 3000) } else { setSubscribeValid(false) throw new Error('No valid sources updated') @@ -190,7 +192,7 @@ const BlacklistSettings: FC = () => { content: t('settings.tool.websearch.subscribe_add_success'), duration: 2 }) - setTimeout(() => setSubscribeValid(false), 3000) + setTimeoutTimer('handleAddSubscribe', () => setSubscribeValid(false), 3000) } catch (error) { setSubscribeValid(false) window.message.error({ diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 5c6faa7b78..d719b38a3e 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -6,6 +6,7 @@ import SearxngLogo from '@renderer/assets/images/search/searxng.svg' import TavilyLogo from '@renderer/assets/images/search/tavily.png' import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup' import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' +import { useTimer } from '@renderer/hooks/useTimer' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProviderId } from '@renderer/types' @@ -33,6 +34,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') const [apiValid, setApiValid] = useState(false) + const { setTimeoutTimer } = useTimer() const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey @@ -125,7 +127,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { }) } finally { setApiChecking(false) - setTimeout(() => setApiValid(false), 2500) + setTimeoutTimer('checkSearch', () => setApiValid(false), 2500) } } diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index f6d528e3a4..b04c332f30 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -9,6 +9,7 @@ import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import useTranslate from '@renderer/hooks/useTranslate' import { estimateTextTokens } from '@renderer/services/TokenService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' @@ -51,7 +52,7 @@ const TranslatePage: FC = () => { // states const [text, setText] = useState(_text) const [renderedMarkdown, setRenderedMarkdown] = useState('') - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false) const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false) const [isBidirectional, setIsBidirectional] = useState(false) @@ -222,8 +223,7 @@ const TranslatePage: FC = () => { // 控制复制按钮 const onCopy = () => { navigator.clipboard.writeText(translatedContent) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + setCopied(false) } // 控制历史记录点击 diff --git a/src/renderer/src/windows/mini/home/components/InputBar.tsx b/src/renderer/src/windows/mini/home/components/InputBar.tsx index 31114062bb..cfd7d29457 100644 --- a/src/renderer/src/windows/mini/home/components/InputBar.tsx +++ b/src/renderer/src/windows/mini/home/components/InputBar.tsx @@ -1,4 +1,5 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant } from '@renderer/types' import { Input as AntdInput } from 'antd' import { InputRef } from 'rc-input/lib/interface' @@ -25,8 +26,9 @@ const InputBar = ({ handleChange }: InputBarProps & { ref?: React.RefObject }) => { const inputRef = useRef(null) + const { setTimeoutTimer } = useTimer() if (!loading) { - setTimeout(() => inputRef.current?.input?.focus(), 0) + setTimeoutTimer('focus', () => inputRef.current?.input?.focus(), 0) } return ( diff --git a/src/renderer/src/windows/selection/action/components/WindowFooter.tsx b/src/renderer/src/windows/selection/action/components/WindowFooter.tsx index ce22eb91ba..53f3d7818b 100644 --- a/src/renderer/src/windows/selection/action/components/WindowFooter.tsx +++ b/src/renderer/src/windows/selection/action/components/WindowFooter.tsx @@ -1,5 +1,6 @@ import { LoadingOutlined } from '@ant-design/icons' import { RefreshIcon } from '@renderer/components/Icons' +import { useTimer } from '@renderer/hooks/useTimer' import { CircleX, Copy, Pause } from 'lucide-react' import { FC, useEffect, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -27,6 +28,7 @@ const WindowFooter: FC = ({ const [isContainerHovered, setIsContainerHovered] = useState(false) const [isShowMe, setIsShowMe] = useState(true) const hideTimerRef = useRef(null) + const { setTimeoutTimer } = useTimer() useEffect(() => { window.addEventListener('focus', handleWindowFocus) @@ -83,9 +85,13 @@ const WindowFooter: FC = ({ const handleEsc = () => { setIsEscHovered(true) - setTimeout(() => { - setIsEscHovered(false) - }, 200) + setTimeoutTimer( + 'handleEsc', + () => { + setIsEscHovered(false) + }, + 200 + ) if (loading && onPause) { onPause() @@ -96,9 +102,13 @@ const WindowFooter: FC = ({ const handleRegenerate = () => { setIsRegenerateHovered(true) - setTimeout(() => { - setIsRegenerateHovered(false) - }, 200) + setTimeoutTimer( + 'handleRegenerate_1', + () => { + setIsRegenerateHovered(false) + }, + 200 + ) if (loading && onPause) { onPause() @@ -106,9 +116,13 @@ const WindowFooter: FC = ({ if (onRegenerate) { //wait for a little time - setTimeout(() => { - onRegenerate() - }, 200) + setTimeoutTimer( + 'handleRegenerate_2', + () => { + onRegenerate() + }, + 200 + ) } } @@ -120,9 +134,13 @@ const WindowFooter: FC = ({ .then(() => { window.message.success(t('message.copy.success')) setIsCopyHovered(true) - setTimeout(() => { - setIsCopyHovered(false) - }, 200) + setTimeoutTimer( + 'handleCopy', + () => { + setIsCopyHovered(false) + }, + 200 + ) }) .catch(() => { window.message.error(t('message.copy.failed')) diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 6a40498cb4..54f0fcd085 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -4,6 +4,7 @@ import { loggerService } from '@logger' import { AppLogo } from '@renderer/config/env' import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import type { ActionItem } from '@renderer/types/selectionTypes' import { defaultLanguage } from '@shared/config/constant' @@ -103,7 +104,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { const [animateKey, setAnimateKey] = useState(0) const [copyIconStatus, setCopyIconStatus] = useState<'normal' | 'success' | 'fail'>('normal') const [copyIconAnimation, setCopyIconAnimation] = useState<'none' | 'enter' | 'exit'>('none') - const copyIconTimeoutRef = useRef(undefined) + const { setTimeoutTimer, clearTimeoutTimer } = useTimer() const realActionItems = useMemo(() => { return actionItems?.filter((item) => item.enabled) @@ -113,18 +114,31 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { // [macOS] only macOS has the fullscreen mode const isFullScreen = useRef(false) + const onHideCleanUp = useCallback(() => { + setCopyIconStatus('normal') + setCopyIconAnimation('none') + clearTimeoutTimer('textSelection') + clearTimeoutTimer('copyIcon') + }, [clearTimeoutTimer]) + // listen to selectionService events useEffect(() => { + const cleanups: (() => void)[] = [] // TextSelection const textSelectionListenRemover = window.electron?.ipcRenderer.on( IpcChannel.Selection_TextSelected, (_, selectionData: TextSelectionData) => { selectedText.current = selectionData.text isFullScreen.current = selectionData.isFullscreen ?? false - setTimeout(() => { - //make sure the animation is active - setAnimateKey((prev) => prev + 1) - }, 400) + const cleanup = setTimeoutTimer( + 'textSelection', + () => { + //make sure the animation is active + setAnimateKey((prev) => prev + 1) + }, + 400 + ) + cleanups.push(cleanup) } ) @@ -142,8 +156,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { return () => { textSelectionListenRemover() toolbarVisibilityChangeListenRemover() + cleanups.forEach((cleanup) => cleanup()) } - }, [demo]) + }, [demo, onHideCleanUp, setTimeoutTimer]) //make sure the toolbar size is updated when the compact mode/actionItems is changed useEffect(() => { @@ -172,11 +187,22 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } }, [customCss, demo]) - const onHideCleanUp = () => { - setCopyIconStatus('normal') - setCopyIconAnimation('none') - clearTimeout(copyIconTimeoutRef.current) - } + // copy selected text to clipboard + const handleCopy = useCallback(async () => { + if (selectedText.current) { + const result = await window.api?.selection.writeToClipboard(selectedText.current) + + setCopyIconStatus(result ? 'success' : 'fail') + setCopyIconAnimation('enter') + setTimeoutTimer( + 'copyIcon', + () => { + setCopyIconAnimation('exit') + }, + 2000 + ) + } + }, [setTimeoutTimer]) const handleAction = useCallback( (action: ActionItem) => { @@ -200,22 +226,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { break } }, - [demo] + [demo, handleCopy] ) - // copy selected text to clipboard - const handleCopy = async () => { - if (selectedText.current) { - const result = await window.api?.selection.writeToClipboard(selectedText.current) - - setCopyIconStatus(result ? 'success' : 'fail') - setCopyIconAnimation('enter') - copyIconTimeoutRef.current = setTimeout(() => { - setCopyIconAnimation('exit') - }, 2000) - } - } - const handleSearch = (action: ActionItem) => { if (!action.searchEngine) return From 11b130736c21e5c11c8d68320e00b78d4079e0d0 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:42:33 +0800 Subject: [PATCH 10/96] fix(Translate): update settings into db (#9305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(翻译): 修复设置没有储存到db的错误 * fix(translate): 修复自动检测方法设置更新失败的问题 添加错误处理逻辑,当更新自动检测方法设置失败时显示错误信息 --- src/renderer/src/i18n/locales/en-us.json | 3 ++- src/renderer/src/i18n/locales/ja-jp.json | 3 ++- src/renderer/src/i18n/locales/ru-ru.json | 3 ++- src/renderer/src/i18n/locales/zh-cn.json | 3 ++- src/renderer/src/i18n/locales/zh-tw.json | 3 ++- src/renderer/src/i18n/translate/el-gr.json | 3 ++- src/renderer/src/i18n/translate/es-es.json | 3 ++- src/renderer/src/i18n/translate/fr-fr.json | 3 ++- src/renderer/src/i18n/translate/pt-pt.json | 3 ++- src/renderer/src/pages/translate/TranslatePage.tsx | 14 +++++++++++++- .../src/pages/translate/TranslateSettings.tsx | 4 ++-- 11 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 36f5e61b16..2af6c5a9ad 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "QwenMT model cannot be used for language detection", - "unknown": "Unknown language detected" + "unknown": "Unknown language detected", + "update_setting": "Setting failed" }, "empty": "The translation result is empty content", "failed": "Translation failed", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c386c301ac..c70be947da 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "QwenMTモデルは言語検出に使用できません", - "unknown": "検出された言語は不明です" + "unknown": "検出された言語は不明です", + "update_setting": "設定に失敗しました" }, "empty": "翻訳結果が空の内容です", "failed": "翻訳に失敗しました", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index aed6241d8d..22494fb87b 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "Модель QwenMT не может использоваться для определения языка", - "unknown": "Обнаружен неизвестный язык" + "unknown": "Обнаружен неизвестный язык", + "update_setting": "Настройка не удалась" }, "empty": "Результат перевода пуст", "failed": "Перевод не удалось", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 60b5a544dc..a17b0f2247 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "QwenMT模型不能用于语言检测", - "unknown": "检测到未知语言" + "unknown": "检测到未知语言", + "update_setting": "设置失败" }, "empty": "翻译结果为空内容", "failed": "翻译失败", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8487289fdf..03e6f671ef 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "QwenMT模型不能用於語言檢測", - "unknown": "檢測到未知語言" + "unknown": "檢測到未知語言", + "update_setting": "設定失敗" }, "empty": "翻译结果为空内容", "failed": "翻譯失敗", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 9cf801e5ef..c694511a2f 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "Το μοντέλο QwenMT δεν μπορεί να χρησιμοποιηθεί για εντοπισμό γλώσσας", - "unknown": "Ανιχνεύθηκε άγνωστη γλώσσα" + "unknown": "Ανιχνεύθηκε άγνωστη γλώσσα", + "update_setting": "Η ρύθμιση απέτυχε" }, "empty": "το αποτέλεσμα της μετάφρασης είναι κενό περιεχόμενο", "failed": "Η μετάφραση απέτυχε", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 6e1a1c4d59..1a31c3861d 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "El modelo QwenMT no se puede utilizar para la detección de idiomas", - "unknown": "Se detectó un idioma desconocido" + "unknown": "Se detectó un idioma desconocido", + "update_setting": "Configuración fallida" }, "empty": "El resultado de la traducción está vacío", "failed": "Fallo en la traducción", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 570e600437..ed45fd124d 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "Le modèle QwenMT ne peut pas être utilisé pour la détection de langues", - "unknown": "Langue inconnue détectée" + "unknown": "Langue inconnue détectée", + "update_setting": "Échec du paramétrage" }, "empty": "Le résultat de la traduction est un contenu vide", "failed": "échec de la traduction", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 20cae5287a..2157fcb51e 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3702,7 +3702,8 @@ "error": { "detect": { "qwen_mt": "O modelo QwenMT não pode ser usado para detecção de idioma", - "unknown": "Idioma desconhecido detectado" + "unknown": "Idioma desconhecido detectado", + "update_setting": "Falha na configuração" }, "empty": "Resultado da tradução está vazio", "failed": "Tradução falhou", diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index b04c332f30..923aedaa22 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -18,6 +18,7 @@ import { setTranslating as setTranslatingAction } from '@renderer/store/runtime' import { setTranslatedContent as setTranslatedContentAction } from '@renderer/store/translate' import type { AutoDetectionMethod, Model, TranslateHistory, TranslateLanguage } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' +import { formatErrorMessage } from '@renderer/utils/error' import { createInputScrollHandler, createOutputScrollHandler, @@ -346,6 +347,17 @@ const TranslatePage: FC = () => { }) }, [getLanguageByLangcode]) + // 控制设置同步 + const updateAutoDetectionMethod = async (method: AutoDetectionMethod) => { + try { + await db.settings.put({ id: 'translate:detect:method', value: method }) + setAutoDetectionMethod(method) + } catch (e) { + logger.error('Failed to update auto detection method setting.', e as Error) + window.message.error(t('translate.error.detect.update_setting') + formatErrorMessage(e)) + } + } + // 控制Enter触发翻译 const onKeyDown = (e: React.KeyboardEvent) => { const isEnterPressed = e.key === 'Enter' @@ -530,7 +542,7 @@ const TranslatePage: FC = () => { setBidirectionalPair={setBidirectionalPair} translateModel={translateModel} autoDetectionMethod={autoDetectionMethod} - setAutoDetectionMethod={setAutoDetectionMethod} + setAutoDetectionMethod={updateAutoDetectionMethod} /> ) diff --git a/src/renderer/src/pages/translate/TranslateSettings.tsx b/src/renderer/src/pages/translate/TranslateSettings.tsx index 3fa4c6fe49..f26fad8114 100644 --- a/src/renderer/src/pages/translate/TranslateSettings.tsx +++ b/src/renderer/src/pages/translate/TranslateSettings.tsx @@ -5,7 +5,7 @@ import useTranslate from '@renderer/hooks/useTranslate' import { AutoDetectionMethod, Model, TranslateLanguage } from '@renderer/types' import { Button, Flex, Modal, Radio, Space, Switch, Tooltip } from 'antd' import { HelpCircle } from 'lucide-react' -import { Dispatch, FC, memo, SetStateAction, useEffect, useState } from 'react' +import { FC, memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup' @@ -23,7 +23,7 @@ const TranslateSettings: FC<{ setBidirectionalPair: (value: [TranslateLanguage, TranslateLanguage]) => void translateModel: Model | undefined autoDetectionMethod: AutoDetectionMethod - setAutoDetectionMethod: Dispatch> + setAutoDetectionMethod: (method: AutoDetectionMethod) => void }> = ({ visible, onClose, From b1e843973c798b86c2fb2809f9945ec425d58c48 Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Wed, 20 Aug 2025 18:26:38 +0800 Subject: [PATCH 11/96] Fix AWS Bedrock models not receiving uploaded document content (#9337) * Initial plan * Add file content processing to AWS Bedrock client convertMessageToSdkParam method Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> * Fix file content format to match other AI clients and update tests Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> * Update src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --- .../aiCore/clients/aws/AwsBedrockAPIClient.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts index d9bd9af9c8..48159b24b9 100644 --- a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts @@ -19,6 +19,7 @@ import { estimateTextTokens } from '@renderer/services/TokenService' import { Assistant, EFFORT_RATIO, + FileTypes, GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -53,7 +54,7 @@ import { mcpToolCallResponseToAwsBedrockMessage, mcpToolsToAwsBedrockTools } from '@renderer/utils/mcp-tools' -import { findImageBlocks } from '@renderer/utils/messageUtils/find' +import { findImageBlocks, findFileBlocks } from '@renderer/utils/messageUtils/find' import { t } from 'i18next' import { BaseApiClient } from '../BaseApiClient' @@ -683,6 +684,30 @@ export class AwsBedrockAPIClient extends BaseApiClient< } } + // 处理文件内容 + const fileBlocks = findFileBlocks(message) + for (const fileBlock of fileBlocks) { + const file = fileBlock.file + if (!file) { + logger.warn(`No file in the file block. Passed.`, { fileBlock }) + continue + } + + if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { + try { + const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim() + if (fileContent) { + parts.push({ + text: `${file.origin_name}\n${fileContent}` + }) + } + } catch (error) { + logger.error('Error reading file content:', error as Error) + parts.push({ text: `[File: ${file.origin_name} - Failed to read content]` }) + } + } + } + // 如果没有任何内容,添加默认文本而不是空文本 if (parts.length === 0) { parts.push({ text: 'No content provided' }) From bd9b34b9a0c13c6e24aa1fa61f77625bd63cb0d5 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 21 Aug 2025 00:01:03 +0800 Subject: [PATCH 12/96] feat(migrate): initialize default assistant settings if not present (#9303) * feat(migrate): update migration logic for version 134; initialize default assistant settings if not present * Update src/renderer/src/store/migrate.ts Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --------- Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --- .../aiCore/clients/aws/AwsBedrockAPIClient.ts | 2 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts index 48159b24b9..1d990dfbda 100644 --- a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts @@ -54,7 +54,7 @@ import { mcpToolCallResponseToAwsBedrockMessage, mcpToolsToAwsBedrockTools } from '@renderer/utils/mcp-tools' -import { findImageBlocks, findFileBlocks } from '@renderer/utils/messageUtils/find' +import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' import { t } from 'i18next' import { BaseApiClient } from '../BaseApiClient' diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f1c8b8bb3c..aa58b36931 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -62,7 +62,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 134, + version: 135, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index c56dcf38cb..b818fae757 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2140,6 +2140,30 @@ const migrateConfig = { try { state.llm.quickModel = state.llm.topicNamingModel + return state + } catch (error) { + logger.error('migrate 134 error', error as Error) + return state + } + }, + '135': (state: RootState) => { + try { + if (!state.assistants.defaultAssistant.settings) { + state.assistants.defaultAssistant.settings = { + temperature: DEFAULT_TEMPERATURE, + enableTemperature: true, + contextCount: DEFAULT_CONTEXTCOUNT, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + enableTopP: true, + toolUseMode: 'prompt', + customParameters: [] + } + } else if (!state.assistants.defaultAssistant.settings.toolUseMode) { + state.assistants.defaultAssistant.settings.toolUseMode = 'prompt' + } return state } catch (error) { logger.error('migrate 134 error', error as Error) From 4e54733d384a0d0538b02d15673158896c359e8c Mon Sep 17 00:00:00 2001 From: one Date: Thu, 21 Aug 2025 00:03:27 +0800 Subject: [PATCH 13/96] feat: support language aliases for code editor (#9336) * feat(CodeEditor): support language aliases * fix: mermaid * refactor: lookup * chore: sort package.json --- package.json | 26 +- .../src/components/CodeBlockView/view.tsx | 2 +- .../src/components/CodeEditor/hooks.ts | 31 +- .../src/components/CodeEditor/utils.ts | 34 + .../src/context/CodeStyleProvider.tsx | 26 +- .../src/utils/__tests__/code-language.ts | 66 ++ .../src/utils/__tests__/markdown.test.ts | 92 --- src/renderer/src/utils/code-language.ts | 35 + src/renderer/src/utils/markdown.ts | 35 - yarn.lock | 599 ++++++++---------- 10 files changed, 438 insertions(+), 508 deletions(-) create mode 100644 src/renderer/src/components/CodeEditor/utils.ts create mode 100644 src/renderer/src/utils/__tests__/code-language.ts create mode 100644 src/renderer/src/utils/code-language.ts diff --git a/package.json b/package.json index b6ebd42dab..949e39363e 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", - "@codemirror/view": "^6.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", @@ -153,9 +152,9 @@ "@types/react-transition-group": "^4.4.12", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", - "@uiw/codemirror-extensions-langs": "^4.23.14", - "@uiw/codemirror-themes-all": "^4.23.14", - "@uiw/react-codemirror": "^4.23.14", + "@uiw/codemirror-extensions-langs": "^4.25.1", + "@uiw/codemirror-themes-all": "^4.25.1", + "@uiw/react-codemirror": "^4.25.1", "@vitejs/plugin-react-swc": "^3.9.0", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", @@ -274,21 +273,24 @@ "zod": "^3.25.74" }, "resolutions": { - "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", + "@codemirror/language": "6.11.3", + "@codemirror/lint": "6.8.5", + "@codemirror/view": "6.38.1", + "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch", "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", "@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", - "libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch", - "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch", - "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch", - "node-abi": "4.12.0", - "undici": "6.21.2", - "vite": "npm:rolldown-vite@latest", "atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch", "file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch", + "libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch", + "node-abi": "4.12.0", "openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", - "openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch" + "openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", + "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", + "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", + "undici": "6.21.2", + "vite": "npm:rolldown-vite@latest" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 086218c023..21c6da743f 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -18,8 +18,8 @@ import { BasicPreviewHandles } from '@renderer/components/Preview' import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useSettings } from '@renderer/hooks/useSettings' import { pyodideService } from '@renderer/services/PyodideService' +import { getExtensionByLanguage } from '@renderer/utils/code-language' import { extractTitle } from '@renderer/utils/formats' -import { getExtensionByLanguage } from '@renderer/utils/markdown' import dayjs from 'dayjs' import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index d49a703297..7917cebd80 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -1,10 +1,11 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' -import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { Extension, keymap } from '@uiw/react-codemirror' import { useEffect, useMemo, useState } from 'react' +import { getNormalizedExtension } from './utils' + const logger = loggerService.withContext('CodeEditorHooks') // 语言对应的 linter 加载器 @@ -17,32 +18,33 @@ const linterLoaders: Record Promise> = { /** * 特殊语言加载器 + * key: 语言文件扩展名(不包含 `.`) */ const specialLanguageLoaders: Record Promise> = { dot: async () => { const mod = await import('@viz-js/lang-dot') return mod.dot() + }, + // @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来 + mmd: async () => { + const mod = await import('codemirror-lang-mermaid') + return mod.mermaid() } } /** * 加载语言扩展 */ -async function loadLanguageExtension(language: string, languageMap: Record): Promise { - let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() - - // 如果语言名包含 `-`,转换为驼峰命名法 - if (normalizedLang.includes('-')) { - normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) - } +async function loadLanguageExtension(language: string): Promise { + const fileExt = await getNormalizedExtension(language) // 尝试加载特殊语言 - const specialLoader = specialLanguageLoaders[normalizedLang] + const specialLoader = specialLanguageLoaders[fileExt] if (specialLoader) { try { return await specialLoader() } catch (error) { - logger.debug(`Failed to load language ${normalizedLang}`, error as Error) + logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error) return null } } @@ -50,10 +52,10 @@ async function loadLanguageExtension(language: string, languageMap: Record * 加载语言相关扩展 */ export const useLanguageExtensions = (language: string, lint?: boolean) => { - const { languageMap } = useCodeStyle() const [extensions, setExtensions] = useState([]) useEffect(() => { @@ -87,7 +88,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => { try { // 加载所有扩展 const [languageResult, linterResult] = await Promise.allSettled([ - loadLanguageExtension(language, languageMap), + loadLanguageExtension(language), lint ? loadLinterExtension(language) : Promise.resolve(null) ]) @@ -119,7 +120,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => { return () => { cancelled = true } - }, [language, lint, languageMap]) + }, [language, lint]) return extensions } diff --git a/src/renderer/src/components/CodeEditor/utils.ts b/src/renderer/src/components/CodeEditor/utils.ts new file mode 100644 index 0000000000..251778b9d1 --- /dev/null +++ b/src/renderer/src/components/CodeEditor/utils.ts @@ -0,0 +1,34 @@ +import { getExtensionByLanguage } from '@renderer/utils/code-language' + +// 自定义语言文件扩展名映射 +// key: 语言名小写 +// value: 扩展名 +const _customLanguageExtensions: Record = { + svg: 'xml', + vab: 'vb', + graphviz: 'dot' +} + +/** + * 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs + * - 先搜索自定义扩展名 + * - 再搜索 github linguist 扩展名 + * @param language 语言名称 + * @returns 扩展名(不包含 `.`) + */ +export async function getNormalizedExtension(language: string) { + const lowerLanguage = language.toLowerCase() + + const customExt = _customLanguageExtensions[lowerLanguage] + if (customExt) { + return customExt + } + + const linguistExt = getExtensionByLanguage(language) + if (linguistExt) { + return linguistExt.slice(1) + } + + // 回退到语言名称 + return language +} diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx index 2b4d9d4004..1ef41d1eba 100644 --- a/src/renderer/src/context/CodeStyleProvider.tsx +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -20,7 +20,6 @@ interface CodeStyleContextType { activeShikiTheme: string isShikiThemeDark: boolean activeCmTheme: any - languageMap: Record } const defaultCodeStyleContext: CodeStyleContextType = { @@ -33,8 +32,7 @@ const defaultCodeStyleContext: CodeStyleContextType = { themeNames: ['auto'], activeShikiTheme: 'auto', isShikiThemeDark: false, - activeCmTheme: null, - languageMap: {} + activeCmTheme: null } const CodeStyleContext = createContext(defaultCodeStyleContext) @@ -93,8 +91,8 @@ export const CodeStyleProvider: React.FC = ({ children }) => return cmThemes[themeName as keyof typeof cmThemes] || themeName }, [theme, codeEditor, themeNames]) - // 一些语言的别名 - const languageMap = useMemo(() => { + // 自定义 shiki 语言别名 + const languageAliases = useMemo(() => { return { bash: 'shell', 'objective-c++': 'objective-cpp', @@ -114,10 +112,10 @@ export const CodeStyleProvider: React.FC = ({ children }) => // 流式代码高亮,返回已高亮的 token lines const highlightCodeChunk = useCallback( async (trunk: string, language: string, callerId: string) => { - const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + const normalizedLang = languageAliases[language as keyof typeof languageAliases] || language.toLowerCase() return shikiStreamService.highlightCodeChunk(trunk, normalizedLang, activeShikiTheme, callerId) }, - [activeShikiTheme, languageMap] + [activeShikiTheme, languageAliases] ) // 清理代码高亮资源 @@ -128,19 +126,19 @@ export const CodeStyleProvider: React.FC = ({ children }) => // 高亮流式输出的代码 const highlightStreamingCode = useCallback( async (fullContent: string, language: string, callerId: string) => { - const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + const normalizedLang = languageAliases[language as keyof typeof languageAliases] || language.toLowerCase() return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId) }, - [activeShikiTheme, languageMap] + [activeShikiTheme, languageAliases] ) // 获取 Shiki pre 标签属性 const getShikiPreProperties = useCallback( async (language: string) => { - const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + const normalizedLang = languageAliases[language as keyof typeof languageAliases] || language.toLowerCase() return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme) }, - [activeShikiTheme, languageMap] + [activeShikiTheme, languageAliases] ) const highlightCode = useCallback( @@ -176,8 +174,7 @@ export const CodeStyleProvider: React.FC = ({ children }) => themeNames, activeShikiTheme, isShikiThemeDark, - activeCmTheme, - languageMap + activeCmTheme }), [ highlightCodeChunk, @@ -189,8 +186,7 @@ export const CodeStyleProvider: React.FC = ({ children }) => themeNames, activeShikiTheme, isShikiThemeDark, - activeCmTheme, - languageMap + activeCmTheme ] ) diff --git a/src/renderer/src/utils/__tests__/code-language.ts b/src/renderer/src/utils/__tests__/code-language.ts new file mode 100644 index 0000000000..dc7cebe4fa --- /dev/null +++ b/src/renderer/src/utils/__tests__/code-language.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { getExtensionByLanguage } from '../code-language' + +describe('code-language', () => { + describe('getExtensionByLanguage', () => { + // 批量测试语言名称到扩展名的映射 + const testLanguageExtensions = (testCases: Record) => { + for (const [language, expectedExtension] of Object.entries(testCases)) { + const result = getExtensionByLanguage(language) + expect(result).toBe(expectedExtension) + } + } + + it('should return extension for exact language name match', () => { + testLanguageExtensions({ + '4D': '.4dm', + 'C#': '.cs', + JavaScript: '.js', + TypeScript: '.ts', + 'Objective-C++': '.mm', + Python: '.py', + SVG: '.svg', + 'Visual Basic .NET': '.vb' + }) + }) + + it('should return extension for case-insensitive language name match', () => { + testLanguageExtensions({ + '4d': '.4dm', + 'c#': '.cs', + javascript: '.js', + typescript: '.ts', + 'objective-c++': '.mm', + python: '.py', + svg: '.svg', + 'visual basic .net': '.vb' + }) + }) + + it('should return extension for language aliases', () => { + testLanguageExtensions({ + js: '.js', + node: '.js', + 'obj-c++': '.mm', + 'objc++': '.mm', + 'objectivec++': '.mm', + py: '.py', + 'visual basic': '.vb' + }) + }) + + it('should return fallback extension for unknown languages', () => { + testLanguageExtensions({ + 'unknown-language': '.unknown-language', + custom: '.custom' + }) + }) + + it('should handle empty string input', () => { + testLanguageExtensions({ + '': '.' + }) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index 7cd877b945..3b3c4f6cd6 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -6,8 +6,6 @@ import { describe, expect, it } from 'vitest' import { convertMathFormula, findCitationInChildren, - getCodeBlockId, - getExtensionByLanguage, isHtmlCode, markdownToPlainText, processLatexBrackets, @@ -146,96 +144,6 @@ describe('markdown', () => { }) }) - describe('getExtensionByLanguage', () => { - // 批量测试语言名称到扩展名的映射 - const testLanguageExtensions = (testCases: Record) => { - for (const [language, expectedExtension] of Object.entries(testCases)) { - const result = getExtensionByLanguage(language) - expect(result).toBe(expectedExtension) - } - } - - it('should return extension for exact language name match', () => { - testLanguageExtensions({ - '4D': '.4dm', - 'C#': '.cs', - JavaScript: '.js', - TypeScript: '.ts', - 'Objective-C++': '.mm', - Python: '.py', - SVG: '.svg', - 'Visual Basic .NET': '.vb' - }) - }) - - it('should return extension for case-insensitive language name match', () => { - testLanguageExtensions({ - '4d': '.4dm', - 'c#': '.cs', - javascript: '.js', - typescript: '.ts', - 'objective-c++': '.mm', - python: '.py', - svg: '.svg', - 'visual basic .net': '.vb' - }) - }) - - it('should return extension for language aliases', () => { - testLanguageExtensions({ - js: '.js', - node: '.js', - 'obj-c++': '.mm', - 'objc++': '.mm', - 'objectivec++': '.mm', - py: '.py', - 'visual basic': '.vb' - }) - }) - - it('should return fallback extension for unknown languages', () => { - testLanguageExtensions({ - 'unknown-language': '.unknown-language', - custom: '.custom' - }) - }) - - it('should handle empty string input', () => { - testLanguageExtensions({ - '': '.' - }) - }) - }) - - describe('getCodeBlockId', () => { - it('should generate ID from position information', () => { - // 从位置信息生成ID - const start = { line: 10, column: 5, offset: 123 } - const result = getCodeBlockId(start) - expect(result).toBe('10:5:123') - }) - - it('should handle zero position values', () => { - // 处理零值位置 - const start = { line: 1, column: 0, offset: 0 } - const result = getCodeBlockId(start) - expect(result).toBe('1:0:0') - }) - - it('should return null for null or undefined input', () => { - // 处理null或undefined输入 - expect(getCodeBlockId(null)).toBeNull() - expect(getCodeBlockId(undefined)).toBeNull() - }) - - it('should handle missing properties in position object', () => { - // 处理缺少属性的位置对象 - const invalidStart = { line: 5 } - const result = getCodeBlockId(invalidStart) - expect(result).toBe('5:undefined:undefined') - }) - }) - describe('updateCodeBlock', () => { /** * 辅助函数:用户获取代码块的实际 ID diff --git a/src/renderer/src/utils/code-language.ts b/src/renderer/src/utils/code-language.ts new file mode 100644 index 0000000000..26f130287e --- /dev/null +++ b/src/renderer/src/utils/code-language.ts @@ -0,0 +1,35 @@ +import { languages } from '@shared/config/languages' + +/** + * 根据语言名称获取文件扩展名 + * - 先精确匹配,再忽略大小写,最后匹配别名 + * - 返回第一个扩展名 + * @param language 语言名称 + * @returns 文件扩展名 + */ +export function getExtensionByLanguage(language: string): string { + const lowerLanguage = language.toLowerCase() + + // 精确匹配语言名称 + const directMatch = languages[language] + if (directMatch?.extensions?.[0]) { + return directMatch.extensions[0] + } + + // 大小写不敏感的语言名称匹配 + for (const [langName, data] of Object.entries(languages)) { + if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) { + return data.extensions[0] + } + } + + // 通过别名匹配 + for (const [, data] of Object.entries(languages)) { + if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) { + return data.extensions?.[0] || `.${language}` + } + } + + // 回退到语言名称 + return `.${language}` +} diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index 435a160350..131dcc016a 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -1,4 +1,3 @@ -import { languages } from '@shared/config/languages' import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import removeMarkdown from 'remove-markdown' @@ -185,40 +184,6 @@ export function removeTrailingDoubleSpaces(markdown: string): string { return markdown.replace(/ {2}$/gm, '') } -/** - * 根据语言名称获取文件扩展名 - * - 先精确匹配,再忽略大小写,最后匹配别名 - * - 返回第一个扩展名 - * @param language 语言名称 - * @returns 文件扩展名 - */ -export function getExtensionByLanguage(language: string): string { - const lowerLanguage = language.toLowerCase() - - // 精确匹配语言名称 - const directMatch = languages[language] - if (directMatch?.extensions?.[0]) { - return directMatch.extensions[0] - } - - // 大小写不敏感的语言名称匹配 - for (const [langName, data] of Object.entries(languages)) { - if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) { - return data.extensions[0] - } - } - - // 通过别名匹配 - for (const [, data] of Object.entries(languages)) { - if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) { - return data.extensions?.[0] || `.${language}` - } - } - - // 回退到语言名称 - return `.${language}` -} - /** * 根据代码块节点的起始位置生成 ID * @param start 代码块节点的起始位置 diff --git a/yarn.lock b/yarn.lock index 210c3bea52..bc6ca7f8b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2295,7 +2295,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-html@npm:^6.0.0, @codemirror/lang-html@npm:^6.4.0": +"@codemirror/lang-html@npm:^6.0.0": version: 6.4.9 resolution: "@codemirror/lang-html@npm:6.4.9" dependencies: @@ -2322,7 +2322,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-javascript@npm:^6.0.0, @codemirror/lang-javascript@npm:^6.1.0, @codemirror/lang-javascript@npm:^6.1.2": +"@codemirror/lang-javascript@npm:^6.0.0, @codemirror/lang-javascript@npm:^6.1.2": version: 6.2.4 resolution: "@codemirror/lang-javascript@npm:6.2.4" dependencies: @@ -2347,7 +2347,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-less@npm:^6.0.0, @codemirror/lang-less@npm:^6.0.1": +"@codemirror/lang-less@npm:^6.0.0": version: 6.0.2 resolution: "@codemirror/lang-less@npm:6.0.2" dependencies: @@ -2360,19 +2360,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-lezer@npm:^6.0.0": - version: 6.0.1 - resolution: "@codemirror/lang-lezer@npm:6.0.1" - dependencies: - "@codemirror/language": "npm:^6.0.0" - "@codemirror/state": "npm:^6.0.0" - "@lezer/common": "npm:^1.0.0" - "@lezer/lezer": "npm:^1.0.0" - checksum: 10c0/2ca832683e071ccc41ff9a290a782cef3f6c172a99668eaee9e90f75b419433c477dd84629da12c0572234ab9de438d640c055e30ee534dddc6ef51cc5c82d24 - languageName: node - linkType: hard - -"@codemirror/lang-liquid@npm:^6.0.0, @codemirror/lang-liquid@npm:^6.0.1": +"@codemirror/lang-liquid@npm:^6.0.0": version: 6.2.3 resolution: "@codemirror/lang-liquid@npm:6.2.3" dependencies: @@ -2388,7 +2376,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-markdown@npm:^6.0.0, @codemirror/lang-markdown@npm:^6.1.0": +"@codemirror/lang-markdown@npm:^6.0.0": version: 6.3.2 resolution: "@codemirror/lang-markdown@npm:6.3.2" dependencies: @@ -2416,7 +2404,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-python@npm:^6.0.0, @codemirror/lang-python@npm:^6.1.0": +"@codemirror/lang-python@npm:^6.0.0": version: 6.2.1 resolution: "@codemirror/lang-python@npm:6.2.1" dependencies: @@ -2439,7 +2427,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sass@npm:^6.0.0, @codemirror/lang-sass@npm:^6.0.1": +"@codemirror/lang-sass@npm:^6.0.0": version: 6.0.2 resolution: "@codemirror/lang-sass@npm:6.0.2" dependencies: @@ -2452,7 +2440,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-sql@npm:^6.0.0, @codemirror/lang-sql@npm:^6.4.0": +"@codemirror/lang-sql@npm:^6.0.0": version: 6.8.0 resolution: "@codemirror/lang-sql@npm:6.8.0" dependencies: @@ -2521,7 +2509,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/language-data@npm:>=6.0.0": +"@codemirror/language-data@npm:^6.5.1": version: 6.5.1 resolution: "@codemirror/language-data@npm:6.5.1" dependencies: @@ -2551,9 +2539,9 @@ __metadata: languageName: node linkType: hard -"@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0, @codemirror/language@npm:^6.8.0, @codemirror/language@npm:^6.9.0": - version: 6.11.0 - resolution: "@codemirror/language@npm:6.11.0" +"@codemirror/language@npm:6.11.3": + version: 6.11.3 + resolution: "@codemirror/language@npm:6.11.3" dependencies: "@codemirror/state": "npm:^6.0.0" "@codemirror/view": "npm:^6.23.0" @@ -2561,11 +2549,11 @@ __metadata: "@lezer/highlight": "npm:^1.0.0" "@lezer/lr": "npm:^1.0.0" style-mod: "npm:^4.0.0" - checksum: 10c0/a8e45ef6dbccc5a7389f277b026412eeecc4ce3dfd806f77f34a8d85df20e257b3b5d0da26c59d9f024e7109542614c62cfa708158da447b0ae97b8fd8476fa9 + checksum: 10c0/0cbc2a98bd9e94e8e186af30613741a553fc45479e3875c79bddc32340d4d75ecc36229c66e2ee5fcffbc8fdd49b442e43847799d1d68651e3b4f8ec20d3d092 languageName: node linkType: hard -"@codemirror/legacy-modes@npm:>=6.0.0, @codemirror/legacy-modes@npm:^6.4.0": +"@codemirror/legacy-modes@npm:^6.4.0": version: 6.5.1 resolution: "@codemirror/legacy-modes@npm:6.5.1" dependencies: @@ -2574,7 +2562,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/lint@npm:^6.0.0": +"@codemirror/lint@npm:6.8.5": version: 6.8.5 resolution: "@codemirror/lint@npm:6.8.5" dependencies: @@ -2617,14 +2605,15 @@ __metadata: languageName: node linkType: hard -"@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.35.0": - version: 6.36.8 - resolution: "@codemirror/view@npm:6.36.8" +"@codemirror/view@npm:6.38.1": + version: 6.38.1 + resolution: "@codemirror/view@npm:6.38.1" dependencies: "@codemirror/state": "npm:^6.5.0" + crelt: "npm:^1.0.6" style-mod: "npm:^4.1.0" w3c-keyname: "npm:^2.2.4" - checksum: 10c0/0ec264fb6f3e72b45c5a4f94bc3d6bf4c281e5ff087254afae7213b8c401403bbc73cf56c741ba0ed74e524b915878198d2aaaf4a641e375d458c622738f49d4 + checksum: 10c0/dfb4253275b62c95f2fd0410bd09de102122c56137bdf3c3b03fd3fc894a194d474449191d7a435a459c222b1afcef9fba6c6f38594424e3fce875872139f96d languageName: node linkType: hard @@ -4154,16 +4143,6 @@ __metadata: languageName: node linkType: hard -"@lezer/lezer@npm:^1.0.0": - version: 1.1.2 - resolution: "@lezer/lezer@npm:1.1.2" - dependencies: - "@lezer/highlight": "npm:^1.0.0" - "@lezer/lr": "npm:^1.0.0" - checksum: 10c0/7e769be79fe59eb0616880ad7b14ac184544baa512316a8ab4c7b5b66de616858f5ebeb426598440ee95b4df586a80bba6bfb51affecfb270809eefd7f7ebb17 - languageName: node - linkType: hard - "@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2" @@ -4450,25 +4429,6 @@ __metadata: languageName: node linkType: hard -"@nextjournal/lang-clojure@npm:^1.0.0": - version: 1.0.0 - resolution: "@nextjournal/lang-clojure@npm:1.0.0" - dependencies: - "@codemirror/language": "npm:^6.0.0" - "@nextjournal/lezer-clojure": "npm:1.0.0" - checksum: 10c0/a454e6bfe600bf758bb8bab0a479f3df8fa1eb52a3744d6d1508ebdd801e703af66e7e9f5c61b6f7080ab5178ce0c53792d402145445656c6e0afdaa4dd63a83 - languageName: node - linkType: hard - -"@nextjournal/lezer-clojure@npm:1.0.0": - version: 1.0.0 - resolution: "@nextjournal/lezer-clojure@npm:1.0.0" - dependencies: - "@lezer/lr": "npm:^1.0.0" - checksum: 10c0/a37584d8a95a3d96a310954ce9ee821855d8b23e616af12fa1223c900602300727ac22b55b5366ec5404969f4f4f4daa50453d3057de27e21d6a863c2f0a9299 - languageName: node - linkType: hard - "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -5202,21 +5162,6 @@ __metadata: languageName: node linkType: hard -"@replit/codemirror-lang-csharp@npm:^6.1.0": - version: 6.2.0 - resolution: "@replit/codemirror-lang-csharp@npm:6.2.0" - peerDependencies: - "@codemirror/autocomplete": ^6.0.0 - "@codemirror/language": ^6.0.0 - "@codemirror/state": ^6.0.0 - "@codemirror/view": ^6.0.0 - "@lezer/common": ^1.0.0 - "@lezer/highlight": ^1.0.0 - "@lezer/lr": ^1.0.0 - checksum: 10c0/c379968e07939a0605376ca607dd1736733083e927fa514c64180093421c92e2e5061c94cbe31e78555fb8fd7f161cd2b581e03a68d107ad338dfa7c2bbb0c72 - languageName: node - linkType: hard - "@replit/codemirror-lang-nix@npm:^6.0.1": version: 6.0.1 resolution: "@replit/codemirror-lang-nix@npm:6.0.1" @@ -7699,9 +7644,9 @@ __metadata: languageName: node linkType: hard -"@uiw/codemirror-extensions-basic-setup@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.14" +"@uiw/codemirror-extensions-basic-setup@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.25.1" dependencies: "@codemirror/autocomplete": "npm:^6.0.0" "@codemirror/commands": "npm:^6.0.0" @@ -7718,410 +7663,389 @@ __metadata: "@codemirror/search": ">=6.0.0" "@codemirror/state": ">=6.0.0" "@codemirror/view": ">=6.0.0" - checksum: 10c0/1a8287669b38c41693a432f840209f5e443c3cbf42a21209d2e46cf36a4ec6d3cc1d8bc5739a450f7acd386643f1ee876a6c75f005fac5028abf57c97a13d139 + checksum: 10c0/2488c5ddd9a7fecebe001dc9a9c02c8b6219eb7b47848fed4fb8415407ae05f36a57c9fca4caa505da3dccbc477e10af2dd0a6fe466c45c88f526ffc278f8d9d languageName: node linkType: hard -"@uiw/codemirror-extensions-langs@npm:^4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-extensions-langs@npm:4.23.14" +"@uiw/codemirror-extensions-langs@npm:^4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-extensions-langs@npm:4.25.1" dependencies: - "@codemirror/lang-angular": "npm:^0.1.0" - "@codemirror/lang-cpp": "npm:^6.0.0" - "@codemirror/lang-css": "npm:^6.2.0" - "@codemirror/lang-html": "npm:^6.4.0" - "@codemirror/lang-java": "npm:^6.0.0" - "@codemirror/lang-javascript": "npm:^6.1.0" - "@codemirror/lang-json": "npm:^6.0.0" - "@codemirror/lang-less": "npm:^6.0.1" - "@codemirror/lang-lezer": "npm:^6.0.0" - "@codemirror/lang-liquid": "npm:^6.0.1" - "@codemirror/lang-markdown": "npm:^6.1.0" - "@codemirror/lang-php": "npm:^6.0.0" - "@codemirror/lang-python": "npm:^6.1.0" - "@codemirror/lang-rust": "npm:^6.0.0" - "@codemirror/lang-sass": "npm:^6.0.1" - "@codemirror/lang-sql": "npm:^6.4.0" - "@codemirror/lang-vue": "npm:^0.1.1" - "@codemirror/lang-wast": "npm:^6.0.0" - "@codemirror/lang-xml": "npm:^6.0.0" - "@codemirror/language-data": "npm:>=6.0.0" - "@codemirror/legacy-modes": "npm:>=6.0.0" - "@nextjournal/lang-clojure": "npm:^1.0.0" - "@replit/codemirror-lang-csharp": "npm:^6.1.0" + "@codemirror/language": "npm:^6.0.0" + "@codemirror/language-data": "npm:^6.5.1" "@replit/codemirror-lang-nix": "npm:^6.0.1" "@replit/codemirror-lang-solidity": "npm:^6.0.1" "@replit/codemirror-lang-svelte": "npm:^6.0.0" codemirror-lang-mermaid: "npm:^0.5.0" peerDependencies: + "@codemirror/language": ">=6.0.0" "@codemirror/language-data": ">=6.0.0" - "@codemirror/legacy-modes": ">=6.0.0" - checksum: 10c0/ce2c21d523f3b7b82ba4133fa2d4d07cab2530f68bd263bf873ede9d009f360be96b49d150e7425097859be2714b3918afd14f05cf64c759b413556f2a44442f + checksum: 10c0/27c805ac57bdbc7ff4b3349770d5e6969ee87a82984eedeffe6b126c9e9c05c626b17cda74466dbcbc5f9a6d918655dde718fb1ef5817211b03ec2109f1229c6 languageName: node linkType: hard -"@uiw/codemirror-theme-abcdef@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-abcdef@npm:4.23.14" +"@uiw/codemirror-theme-abcdef@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-abcdef@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/823eedc5d44095c527a81eb79ae6f60ff9226afa9a038175cc31c0b4ce7acdefc475322c10b33f704fc4a8e53af76bc366b6b4a64cd4d05cfba8d1a63f4c9d4f + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/50654812056feb226f0c41fc8232624a8c8c4600e9115ded0acf4a93e9da3ee8b044f01ecaf63f755d8b2b4aec99b7377257a3113fb673ea80fa9c6c1660ce97 languageName: node linkType: hard -"@uiw/codemirror-theme-abyss@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-abyss@npm:4.23.14" +"@uiw/codemirror-theme-abyss@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-abyss@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/d338b65bd6859d37d56480b1e2e948e6332beefd3421ad9a0f1cb1f39ace238462172c0cee260a7de120072b9cd723a450a606a2ab7fb4e21679116ff5a57e37 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/7cceddd3655d694ba089af97e60cb88c9f944d0bbc00d3f68443a17ea520a74865477a7f51e4ed140229d17af3ff377639373c1337b1e3197103dc9aefe93020 languageName: node linkType: hard -"@uiw/codemirror-theme-androidstudio@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-androidstudio@npm:4.23.14" +"@uiw/codemirror-theme-androidstudio@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-androidstudio@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/80fd25b25c8179f704d65c48b7f770057a92b493f70ca589757ffab72c4e8e0a33f64a40b202cd92b70715307f93e6b06c754a4a90c90b5130e51df93ccca412 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/49510509452379878a1c49ca8c9355b259fa4a1ef2b7427d0bcb5cf7d2c313e76334facf04a0c35964eb8829063a1b00dc0710b79e359fa16b50f777e2fd6e21 languageName: node linkType: hard -"@uiw/codemirror-theme-andromeda@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-andromeda@npm:4.23.14" +"@uiw/codemirror-theme-andromeda@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-andromeda@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/80ea31adc48b68ba58b977ff288a220025fc3f22a0d8bb2756a4b1d7a761dcd8838110b33a2a80c6a7e6c78ebf954329209c552d5f0f2c382dbe9335e37f0a83 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/6d8028991730feaadeb31039326eed5df2142e782662aff2a00a9f3e34fb972a6fb1ce3b6dd96953bf24d208fe0c3c6c54de55d0687d990eec8e899a04e3eeea languageName: node linkType: hard -"@uiw/codemirror-theme-atomone@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-atomone@npm:4.23.14" +"@uiw/codemirror-theme-atomone@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-atomone@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/9d2594d94d5a7374d1d5e7a317faa8246ef11f64e08b71f11fd9e0ec406cc311d4a0aca3a131085b88ffa58883de120d3a042db91ad32228a617565366cc5b80 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/c2d3fabd19be96de261918309352445d7680b9ffb044b3a939bcfb322b26c40003f329498f11d1ac9201637c93d7b6af1e1170c143061efacc4c2f353468a3f4 languageName: node linkType: hard -"@uiw/codemirror-theme-aura@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-aura@npm:4.23.14" +"@uiw/codemirror-theme-aura@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-aura@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/4612323b93b226bc7db56856e5d32a1173c30d39ccbd701ae08b45a648b668f1526802f7d312ab6819b506386a91ff5c3e3996f4ef99c6239dc8bc055b7ef5ff + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/5e90fc0d2945e05cbdf59e30fd684af3a119ceb3efe9d79ac255e9deb081054f315d4a0cd4f23e6410245417abfb23261dc02921226ce1cb94ffdbbbe1b91a47 languageName: node linkType: hard -"@uiw/codemirror-theme-basic@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-basic@npm:4.23.14" +"@uiw/codemirror-theme-basic@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-basic@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/55edf154e1e14ea5bb94c1bdddf2b89dcd430b26b1e07eaed1169f657d1f936f6fdaff467573d1d8f2347e4ed84aa23da4a1b0b10209d39a2fc83c21ec8ac1e6 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/162441c4f3a59dfa013b62c71cf80feaf65fc761244841423de9990ad481b414d0638a321e0d8cb670473e1d5e7c28e75bbd033a918a83c06b6a45aeec4223fa languageName: node linkType: hard -"@uiw/codemirror-theme-bbedit@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-bbedit@npm:4.23.14" +"@uiw/codemirror-theme-bbedit@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-bbedit@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/a6c9d615abe9b6e584d9461ad4e5917cd79dc46319694d403140ed3f67bc72400d7e538df79bfceea22d78ef92ce4895e8ab67d40e7320caa437596901defcf3 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/9697c795da864d5caa09c26060e9063998efca522016451a79caf4163c0555917aabb3c2eaadae7658c9013688eac7e56faa369309b481477cbd72b4dab81abd languageName: node linkType: hard -"@uiw/codemirror-theme-bespin@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-bespin@npm:4.23.14" +"@uiw/codemirror-theme-bespin@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-bespin@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/da91d7aa704d24c65f0ffacad3c7a85b4537a15d0f236365c2107735d350b95bdaede0aa728933405b893bf81a2ecd9bc94bc5e73d9d664f3e2890e3a1ae91db + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/1dee27b1785ee14f0791be47a9392a84d40f010dd9a4f530e81e746761d77e3e30a013ac8b5e8207d196e476d8ce41804370d3b587add4d15d4453bc01bc720e languageName: node linkType: hard -"@uiw/codemirror-theme-console@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-console@npm:4.23.14" +"@uiw/codemirror-theme-console@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-console@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/5703813818a134821514142cb1c2b78c2d32c81ef4dea7f8808529f133a1df06a639e2d4a6b614b86bb2fffe7ed74e57221db175d2483111fc7d4fcab9728cb8 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/1b1fb2761a8e4a650e0370fda6a5a999854707abe478a8b8698bec40b34ffcd0c6af3f64b15f1d605d038e938bbfec04db3ad8a46b8ac6a26b48d82577858554 languageName: node linkType: hard -"@uiw/codemirror-theme-copilot@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-copilot@npm:4.23.14" +"@uiw/codemirror-theme-copilot@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-copilot@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/3fb798ddb3666c50a7e0f55d7807173249b4e8d75ee7e17b14ff79e53034420ffceffbe9c897b2eb5a802df4d7990934b18bd9f33977a51b61f262bdb51039f4 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/f13e1e997d19d6fef4083839f016eaf907162e6b3d2829916909561c3561747128946ee7258e86b7fac6d8e940f7acd62bbf0c0be1c2e76b467dde3c1d9e4f99 languageName: node linkType: hard -"@uiw/codemirror-theme-darcula@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-darcula@npm:4.23.14" +"@uiw/codemirror-theme-darcula@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-darcula@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/02ccb8fb17cf2c1256ec13cf084580e0e8cebb75978e548778a702ab16573006fbefb8e64737dd12d43e62ef3ec1fa7b4862069a4e0907fc30342614b44d8b48 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/90d3cb9160cbb9452161c317807c8b7f012a513246f06a01a2a7c3cdd0c29ad5aa3fdce6a2639ae00290462bf8c8ebcac29a66a8052b3cb4536883880b5ad5de languageName: node linkType: hard -"@uiw/codemirror-theme-dracula@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-dracula@npm:4.23.14" +"@uiw/codemirror-theme-dracula@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-dracula@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/a4b5a416e0638e845e02e4304acd3b83b2bc4302fe4267690914e4490430317f90c99eda24d948e48e5a4a0ae8af60af5f3135c92c455679b9684489a28fdec9 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/dcd94682aa64c60679a7a93e1f177b272e5e03ee78e9c3745ca1e49fea1fd2cc5ae78ced3561f5925caf6ce6ebd6ba38f6fe0d550923c2d99fab4e2c001a236d languageName: node linkType: hard -"@uiw/codemirror-theme-duotone@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-duotone@npm:4.23.14" +"@uiw/codemirror-theme-duotone@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-duotone@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/0d7705e59bcccc1aa8e27f07a9183874f95d66e4baa053c6c3b663978be74c1cddce659f8b41973a8fc86e8e9689700d965f0e5f1065c109d6788b03ce9782d7 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/e3802434b0392e8397af8656a599c7ce983dddde7a38acdd51b72009ba48c56819d396f4847548d0b8a74879cbb2562a4902ec96cffcc219c8f60f523e3b2e52 languageName: node linkType: hard -"@uiw/codemirror-theme-eclipse@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-eclipse@npm:4.23.14" +"@uiw/codemirror-theme-eclipse@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-eclipse@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/37b27868395f0ff59ae8e63f0b828766d90638dfc235226217fba2478a59109174999dbe02ed5568feee7dda01c677d9f23e25a8638b6bf3bed7d5eae2dbd5dd + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/f15d24e97db487eebe827270dd0549b81a066dfbb1c4feee1c9ed3bb0e89d9f965a741b9cdfbd9592306b310cd796d585d9d9fcded32c7a85a63cafff919cd5a languageName: node linkType: hard -"@uiw/codemirror-theme-github@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-github@npm:4.23.14" +"@uiw/codemirror-theme-github@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-github@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/5e80110fa8ac6689c2f25ccec1aa8a7aeea8d0c17686ddd1d0be7f12ab6412ce4ad79c3d548eda8f8f76bb280c17d7466e9683a8acf7b1cf3e42a26f29e6d700 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/d64f8286b4777f476a290ee37ade4355db5ee04ecc2e98bd7fe3052e086793976bebcd87893e3bea53ca8caab647ad4970f73b2e05356a5e04cb5ae6ef66ec71 languageName: node linkType: hard -"@uiw/codemirror-theme-gruvbox-dark@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-gruvbox-dark@npm:4.23.14" +"@uiw/codemirror-theme-gruvbox-dark@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-gruvbox-dark@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/81f4f763748e20b967e50e0087f556fad966b8c465d2e2df44a7b682a00b6aeb4f2edf20480a5bc0cee52d4ef0113d50369d8290a9eb8271d03b7602f7e834bd + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/244c98e6aa7b67cd828dae640e29b8850ebf50931dbfbe6b726dfab215dc9f16147cb624c1cecc76bc2d96c6acc9dcf8905241b6bc6de6b81a92df9283a771e1 languageName: node linkType: hard -"@uiw/codemirror-theme-kimbie@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-kimbie@npm:4.23.14" +"@uiw/codemirror-theme-kimbie@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-kimbie@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/0eccfb738687bf0c9cdb05a29307a787947db2a37f392eb048a4109106e12ae5d18510e6a797a2cd702ca57182a7e60b5d9a88dc604ccdda5e7d4e6ef43dc639 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/f086f3a5aeaebb1c273864df96613c65a669216021d22ce818ffd120a99b297ab0ef5570ed0074ef822592853017d8aad8928d213c94fd2f4a4cc40f08139af7 languageName: node linkType: hard -"@uiw/codemirror-theme-material@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-material@npm:4.23.14" +"@uiw/codemirror-theme-material@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-material@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/9ad0615cacca29665fe79fbd3353763b6a91b83eaa4b00c360d893af365e453eba525e2622f2d03aa400e096fe4b305b82c60f6bbf3d181ecd87029e843cd652 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/68a6eddb322d84958dd6bcbbf4ab078d4cfd1e193ffc57a5a90b8b6ccf1bb5f1ac6435b1533c33fe1f1099d1b3c7b69adcd57fad7e61ac1b91255e3bdae49e33 languageName: node linkType: hard -"@uiw/codemirror-theme-monokai-dimmed@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-monokai-dimmed@npm:4.23.14" +"@uiw/codemirror-theme-monokai-dimmed@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-monokai-dimmed@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/21612b7241cbf8877c2dd8980408b45ff63aeb148ce9c13fdcd2b91bab835fdfd20e938e0b2c710264a914a77d256e8ab269deb6872873f9290afbb6339ce840 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/72e166ac80b4b2de71aebbc521d4f57103a050bc56353420a4eab2173d76d0bf7a9cee8bdd14b7f2b7cc60fce177e195b62d5bb526670fd5d57ee441cfa18854 languageName: node linkType: hard -"@uiw/codemirror-theme-monokai@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-monokai@npm:4.23.14" +"@uiw/codemirror-theme-monokai@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-monokai@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/f97dd8e63c4566931fdecc7be13a9f1e012b7b5d18a575bbfc2821ed346901581a5093a4b85a531abc78a91de3e64221fe2271a7245d529468551314e9836433 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/b22ebf9cab32a5b30bec6cb2de6de5ab96710d93bfe5797b3a476907b790936c78d1a11d5223d2d023b711a3d28a67983554773b13677eba1ea0ae655ea02a05 languageName: node linkType: hard -"@uiw/codemirror-theme-noctis-lilac@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-noctis-lilac@npm:4.23.14" +"@uiw/codemirror-theme-noctis-lilac@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-noctis-lilac@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/74bd357de0bc7cdc66634f3ba9d4cc9a680e77ebc5c5130801938c3d5df146468a468b2fe7de55e8bedc09d8ebfe40e84b85b9d6b33cbcd95fba437637e6777e + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/b3560ce35350b3ce1c57bc2531a404590cdb8ac8e89f6669991d90ddd1c7ab83b36f20431e3106664d573d6eab62bd673526486af65bb2d6eab8699a053f1ed5 languageName: node linkType: hard -"@uiw/codemirror-theme-nord@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-nord@npm:4.23.14" +"@uiw/codemirror-theme-nord@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-nord@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/10b910cfe0a93b72b83e058cf371998c4f9a80b487319874e814b8ffe50cf6fda60ac43b433f5f004ff1e5111f21f9b8e7d6ce80bcc14c639a57d2265764e85b + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/934d5ae729253173abe9256cee046de775ddfdf4e5cf8fed7dce9d4180429841ecffe869b9f695335f688127bec1403fd15070a683a3cdcaae986c041baa3df9 languageName: node linkType: hard -"@uiw/codemirror-theme-okaidia@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-okaidia@npm:4.23.14" +"@uiw/codemirror-theme-okaidia@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-okaidia@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/f8116f1e43877bde3d41329aa3c708afc866bad69ce17114835eb4cbc02da6f07c25a1f4ac988df84446b4c04e1c4041f6825d0349d7e171013807ca7203b956 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/c5edd8fb073f934756d7371987be40c487935006c0dfcb7decbbba992548ea3565a73544516c626b650d347754653fa0728fd899ce00bed2a84d7d6c1ae38ca1 languageName: node linkType: hard -"@uiw/codemirror-theme-quietlight@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-quietlight@npm:4.23.14" +"@uiw/codemirror-theme-quietlight@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-quietlight@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/64ea0dfa7b75157e5327e7210060d60fbb4571602ad250cd102bc22b24da1f715877d4f7823201e6526c602f9c328ab3387db29d2b28157bb60db72c48eccfdb + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/c0cce80298d5d5a3add0d768838cd7aafa29d64b42543f7f6455cb4ab176ff61b95d477522152611f4f6f55d9bdb813e5d61e0bbda3d1a50a36554ac1424fbf8 languageName: node linkType: hard -"@uiw/codemirror-theme-red@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-red@npm:4.23.14" +"@uiw/codemirror-theme-red@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-red@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/953d53bca1ccad4ba784fd4b75928f618dcfc17c3aa82605dddce4f1948a3688148760b2791371d7d9fd2602ae378199b7b57dd9910135cef05de3a76efacc97 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/15fdd4b2106d14a572abc31b3f8ae5fc6dbc2ff31fb0494df7ffb5d2e069888d790e462faba844f30268381ee26293476687688b861112e04d222899f3e4ac97 languageName: node linkType: hard -"@uiw/codemirror-theme-solarized@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-solarized@npm:4.23.14" +"@uiw/codemirror-theme-solarized@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-solarized@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/8fb3cda75fc7f63744c8adefeff7d8748ff1f654b5c89de14a907a623a8ce8529922b7a6ef3cb19102ba0cc2c90e64ccfbfd7a22438aec0621911cad762568c4 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/c5f119f019c6f51bf956213bfd2d58721a4959fc481f6b78ea70d5008fcf289ab3e21e1abfd4b3ea8c847212d43e01076d86a298119a78657e4fdd56fc94372a languageName: node linkType: hard -"@uiw/codemirror-theme-sublime@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-sublime@npm:4.23.14" +"@uiw/codemirror-theme-sublime@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-sublime@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/b08d5242463dc5937bfb85ea33f6d3b8edf351e68f606d5ee9cc33bcfc9e1eeaddaccab57799f51b884132ef2f69f02d164502863314e564a56c6142f0c095e4 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/135ad92560f1c76c906a5b63cb543632e5d1d0195465069073edd0bee1709808c5fdb625aa4f7b08352352919c5289fdb2eb0d1367232126c136edaa9ef7dc38 languageName: node linkType: hard -"@uiw/codemirror-theme-tokyo-night-day@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-tokyo-night-day@npm:4.23.14" +"@uiw/codemirror-theme-tokyo-night-day@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-tokyo-night-day@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/035f7c5cddaa98295216d903ccb5960257c9497dbc083e26c081287b7806685a7d5a33d04e3fc5e13446cabe696277c0835722a2e62c164e44c4531a0450445f + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/11e3d32e721948946dd16b0c16338a2eec975a51b373219b6a16d31f6980c109d518e1a119b830a1a1084f4f44320f5ce11a6be2b4d6a2d360e8ec7e3e9e2d7c languageName: node linkType: hard -"@uiw/codemirror-theme-tokyo-night-storm@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-tokyo-night-storm@npm:4.23.14" +"@uiw/codemirror-theme-tokyo-night-storm@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-tokyo-night-storm@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/520e488045f73a196198bc2f28222322a0b02ca963e5d407c55282ce319904871cebe8be62f59dbc296c3bb3c70302eb59f9159ee1ff694f04618bef5f105136 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/1abdd0f4e7f4a8c72ab034ba3acb2032b781aca1d26e30dd598c8220d9239aacf22bceb09d1f445d31624e308e02e6f2ed8d1fdccdc67cb737734d51e760ec46 languageName: node linkType: hard -"@uiw/codemirror-theme-tokyo-night@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-tokyo-night@npm:4.23.14" +"@uiw/codemirror-theme-tokyo-night@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-tokyo-night@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/ee6394b133af3031bc4456ab829b65b86b6641af584d494fc949bdb01ff68187e681427fca0606aaf232b62d054d247b6442cdd16a58f3daf9a76e7a24e31d7c + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/f2e284970e7cf7fbcbdaa60f4b8d538cc67ef653d4ae6e7b34fa5d2df8745a7f24ff9b68ec0636428a932351f9ea432d92bbf4e38d44a854f21f0cf6fd4d605f languageName: node linkType: hard -"@uiw/codemirror-theme-tomorrow-night-blue@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-tomorrow-night-blue@npm:4.23.14" +"@uiw/codemirror-theme-tomorrow-night-blue@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-tomorrow-night-blue@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/9d6059c4a54524b90b60151bac8ecf652ec8ce08b6c38516f102addac7acf1e48a6d3e5e47c4e2404edeee891cf88dbec10e956e1965c5f2437d5c7c224926bc + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/44e583dcbaf02a893bbbca6d39afdbf07fb1dfbf96d821626d7738aeb843d842173876ff5a524e0c5c33e4db76389e91adfe1c5d40a7628b728ed9ff760d7f90 languageName: node linkType: hard -"@uiw/codemirror-theme-vscode@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-vscode@npm:4.23.14" +"@uiw/codemirror-theme-vscode@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-vscode@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/537a60066da173f2246bae8fc1dd607e54c89139e68d858625595a39f53fa6b54a03a70f42a2842daaae9e93a757d37263f8c1999e7f2882e3c362484aa87307 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/57f9c406f6282542f104eb744eb3d14c74a0ff4ce06bbba8b064c31080b3c11251a4d858718896cc295b42d8be81c60986a604baca8a054b680e8d57177e9ef9 languageName: node linkType: hard -"@uiw/codemirror-theme-white@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-white@npm:4.23.14" +"@uiw/codemirror-theme-white@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-white@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/46bfcce9f33953ff8f8d78871d4d2250673dfcd8d148a0a68548cac616ae609289c88ef7767a70b05049d4369b97ff0e5241b386b83a9dc3987b39acf48a20b5 + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/bf17d098940a89938ea6b8293921ded231c2e55f016a64dd1614fa7de73afe3699089f41655e742ec2ebbc7330f98e211026f9799fe114360343fcbc34dd9e8b languageName: node linkType: hard -"@uiw/codemirror-theme-xcode@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-theme-xcode@npm:4.23.14" +"@uiw/codemirror-theme-xcode@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-theme-xcode@npm:4.25.1" dependencies: - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/9c324b5d547f85e10ac8d676d9343b258bef09025895998fcbada8ac3ecfbf25262a67e2ba815cf6355042a9b23737ef39a7a4b80020fea494a0e5b938cb437c + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/11081666cbe9ef0cbb79a2067b77ad4c4589c995860d6558d291f6e8451b2602e58f6e820a43a61a1b0c597a449bddd03c280ea5f0c00f3e46e7887152bd2f1b languageName: node linkType: hard -"@uiw/codemirror-themes-all@npm:^4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-themes-all@npm:4.23.14" +"@uiw/codemirror-themes-all@npm:^4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-themes-all@npm:4.25.1" dependencies: - "@uiw/codemirror-theme-abcdef": "npm:4.23.14" - "@uiw/codemirror-theme-abyss": "npm:4.23.14" - "@uiw/codemirror-theme-androidstudio": "npm:4.23.14" - "@uiw/codemirror-theme-andromeda": "npm:4.23.14" - "@uiw/codemirror-theme-atomone": "npm:4.23.14" - "@uiw/codemirror-theme-aura": "npm:4.23.14" - "@uiw/codemirror-theme-basic": "npm:4.23.14" - "@uiw/codemirror-theme-bbedit": "npm:4.23.14" - "@uiw/codemirror-theme-bespin": "npm:4.23.14" - "@uiw/codemirror-theme-console": "npm:4.23.14" - "@uiw/codemirror-theme-copilot": "npm:4.23.14" - "@uiw/codemirror-theme-darcula": "npm:4.23.14" - "@uiw/codemirror-theme-dracula": "npm:4.23.14" - "@uiw/codemirror-theme-duotone": "npm:4.23.14" - "@uiw/codemirror-theme-eclipse": "npm:4.23.14" - "@uiw/codemirror-theme-github": "npm:4.23.14" - "@uiw/codemirror-theme-gruvbox-dark": "npm:4.23.14" - "@uiw/codemirror-theme-kimbie": "npm:4.23.14" - "@uiw/codemirror-theme-material": "npm:4.23.14" - "@uiw/codemirror-theme-monokai": "npm:4.23.14" - "@uiw/codemirror-theme-monokai-dimmed": "npm:4.23.14" - "@uiw/codemirror-theme-noctis-lilac": "npm:4.23.14" - "@uiw/codemirror-theme-nord": "npm:4.23.14" - "@uiw/codemirror-theme-okaidia": "npm:4.23.14" - "@uiw/codemirror-theme-quietlight": "npm:4.23.14" - "@uiw/codemirror-theme-red": "npm:4.23.14" - "@uiw/codemirror-theme-solarized": "npm:4.23.14" - "@uiw/codemirror-theme-sublime": "npm:4.23.14" - "@uiw/codemirror-theme-tokyo-night": "npm:4.23.14" - "@uiw/codemirror-theme-tokyo-night-day": "npm:4.23.14" - "@uiw/codemirror-theme-tokyo-night-storm": "npm:4.23.14" - "@uiw/codemirror-theme-tomorrow-night-blue": "npm:4.23.14" - "@uiw/codemirror-theme-vscode": "npm:4.23.14" - "@uiw/codemirror-theme-white": "npm:4.23.14" - "@uiw/codemirror-theme-xcode": "npm:4.23.14" - "@uiw/codemirror-themes": "npm:4.23.14" - checksum: 10c0/489608feb4aa75fe466505ce00ec9f2a55d057d0921be5937c2a17081299aafc598a2a53e6c356f201ed55c002a39c9cb898c90834c70307052fe1cacbef7ed2 + "@uiw/codemirror-theme-abcdef": "npm:4.25.1" + "@uiw/codemirror-theme-abyss": "npm:4.25.1" + "@uiw/codemirror-theme-androidstudio": "npm:4.25.1" + "@uiw/codemirror-theme-andromeda": "npm:4.25.1" + "@uiw/codemirror-theme-atomone": "npm:4.25.1" + "@uiw/codemirror-theme-aura": "npm:4.25.1" + "@uiw/codemirror-theme-basic": "npm:4.25.1" + "@uiw/codemirror-theme-bbedit": "npm:4.25.1" + "@uiw/codemirror-theme-bespin": "npm:4.25.1" + "@uiw/codemirror-theme-console": "npm:4.25.1" + "@uiw/codemirror-theme-copilot": "npm:4.25.1" + "@uiw/codemirror-theme-darcula": "npm:4.25.1" + "@uiw/codemirror-theme-dracula": "npm:4.25.1" + "@uiw/codemirror-theme-duotone": "npm:4.25.1" + "@uiw/codemirror-theme-eclipse": "npm:4.25.1" + "@uiw/codemirror-theme-github": "npm:4.25.1" + "@uiw/codemirror-theme-gruvbox-dark": "npm:4.25.1" + "@uiw/codemirror-theme-kimbie": "npm:4.25.1" + "@uiw/codemirror-theme-material": "npm:4.25.1" + "@uiw/codemirror-theme-monokai": "npm:4.25.1" + "@uiw/codemirror-theme-monokai-dimmed": "npm:4.25.1" + "@uiw/codemirror-theme-noctis-lilac": "npm:4.25.1" + "@uiw/codemirror-theme-nord": "npm:4.25.1" + "@uiw/codemirror-theme-okaidia": "npm:4.25.1" + "@uiw/codemirror-theme-quietlight": "npm:4.25.1" + "@uiw/codemirror-theme-red": "npm:4.25.1" + "@uiw/codemirror-theme-solarized": "npm:4.25.1" + "@uiw/codemirror-theme-sublime": "npm:4.25.1" + "@uiw/codemirror-theme-tokyo-night": "npm:4.25.1" + "@uiw/codemirror-theme-tokyo-night-day": "npm:4.25.1" + "@uiw/codemirror-theme-tokyo-night-storm": "npm:4.25.1" + "@uiw/codemirror-theme-tomorrow-night-blue": "npm:4.25.1" + "@uiw/codemirror-theme-vscode": "npm:4.25.1" + "@uiw/codemirror-theme-white": "npm:4.25.1" + "@uiw/codemirror-theme-xcode": "npm:4.25.1" + "@uiw/codemirror-themes": "npm:4.25.1" + checksum: 10c0/6e91a33eba94cd48fd2c0c2ee2a25163e9b60f51dbb339f06b74b0b5356892c3bfb41e097f2a151e1d13bc88c55cce22aa45c19aa3d265171ec6985195f7e708 languageName: node linkType: hard -"@uiw/codemirror-themes@npm:4.23.14": - version: 4.23.14 - resolution: "@uiw/codemirror-themes@npm:4.23.14" +"@uiw/codemirror-themes@npm:4.25.1": + version: 4.25.1 + resolution: "@uiw/codemirror-themes@npm:4.25.1" dependencies: "@codemirror/language": "npm:^6.0.0" "@codemirror/state": "npm:^6.0.0" @@ -8130,19 +8054,19 @@ __metadata: "@codemirror/language": ">=6.0.0" "@codemirror/state": ">=6.0.0" "@codemirror/view": ">=6.0.0" - checksum: 10c0/a15f8984d1993c2d247f0d36f4f3c854081b60b680396d2d8fea6ebd3664d14632bacf3f689ccd6439ccba4e38462d49b1d35486fdbe6d808821ff91495c4746 + checksum: 10c0/cd9c804c6901695953b1ff6149ecf9ff5a41fefdfd19a2f77550b214d9e1cc4143c6b0e3cd0186d6465bf5a4e6a0344bde376245bfca528e2986972e43ea3478 languageName: node linkType: hard -"@uiw/react-codemirror@npm:^4.23.14": - version: 4.23.14 - resolution: "@uiw/react-codemirror@npm:4.23.14" +"@uiw/react-codemirror@npm:^4.25.1": + version: 4.25.1 + resolution: "@uiw/react-codemirror@npm:4.25.1" dependencies: "@babel/runtime": "npm:^7.18.6" "@codemirror/commands": "npm:^6.1.0" "@codemirror/state": "npm:^6.1.1" "@codemirror/theme-one-dark": "npm:^6.0.0" - "@uiw/codemirror-extensions-basic-setup": "npm:4.23.14" + "@uiw/codemirror-extensions-basic-setup": "npm:4.25.1" codemirror: "npm:^6.0.0" peerDependencies: "@babel/runtime": ">=7.11.0" @@ -8150,9 +8074,9 @@ __metadata: "@codemirror/theme-one-dark": ">=6.0.0" "@codemirror/view": ">=6.0.0" codemirror: ">=6.0.0" - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 10c0/9c16bd76c9f07ae8ff71dc6b1f916913e327dbe6dd9eb46311b5af6fea9f68ed46d63e69b2547da587576e139d23e3a01899cb5f4bae7ccbdf9a7f32b8e4c1a2 + react: ">=17.0.0" + react-dom: ">=17.0.0" + checksum: 10c0/0a81f3c6bd722764795734bf97e55e8cb35d243404c1a1ba7e27d92ec73734abb55c2194b5845aaa873d4be3f9b4fd50060d69618c9a15a1f5895d392a6c0c1c languageName: node linkType: hard @@ -8473,7 +8397,6 @@ __metadata: "@cherrystudio/embedjs-loader-xml": "npm:^0.1.31" "@cherrystudio/embedjs-ollama": "npm:^0.1.31" "@cherrystudio/embedjs-openai": "npm:^0.1.31" - "@codemirror/view": "npm:^6.0.0" "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" @@ -8526,9 +8449,9 @@ __metadata: "@types/react-transition-group": "npm:^4.4.12" "@types/tinycolor2": "npm:^1" "@types/word-extractor": "npm:^1" - "@uiw/codemirror-extensions-langs": "npm:^4.23.14" - "@uiw/codemirror-themes-all": "npm:^4.23.14" - "@uiw/react-codemirror": "npm:^4.23.14" + "@uiw/codemirror-extensions-langs": "npm:^4.25.1" + "@uiw/codemirror-themes-all": "npm:^4.25.1" + "@uiw/react-codemirror": "npm:^4.25.1" "@vitejs/plugin-react-swc": "npm:^3.9.0" "@vitest/browser": "npm:^3.2.4" "@vitest/coverage-v8": "npm:^3.2.4" @@ -10468,7 +10391,7 @@ __metadata: languageName: node linkType: hard -"crelt@npm:^1.0.5": +"crelt@npm:^1.0.5, crelt@npm:^1.0.6": version: 1.0.6 resolution: "crelt@npm:1.0.6" checksum: 10c0/e0fb76dff50c5eb47f2ea9b786c17f9425c66276025adee80876bdbf4a84ab72e899e56d3928431ab0cb057a105ef704df80fe5726ef0f7b1658f815521bdf09 From 8297546ed7de2a793cb7aec1f981871daa33084e Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 21 Aug 2025 00:21:14 +0800 Subject: [PATCH 14/96] fix(SelectionHook): [macOS] add type safety to prevent crashes (#9354) chore: update selection-hook dependency to version 1.0.11 in package.json and yarn.lock --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 949e39363e..7c25e90115 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", - "selection-hook": "^1.0.10", + "selection-hook": "^1.0.11", "turndown": "7.2.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index bc6ca7f8b4..265dd202a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8553,7 +8553,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.10" + selection-hook: "npm:^1.0.11" shiki: "npm:^3.9.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" @@ -20012,14 +20012,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.10": - version: 1.0.10 - resolution: "selection-hook@npm:1.0.10" +"selection-hook@npm:^1.0.11": + version: 1.0.11 + resolution: "selection-hook@npm:1.0.11" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/263f33d59de4ba0643f783ed39b95813fb5e685dc517abc8aadde6ad6749d946de1b61382d153359cb6592abd23849cb3e2177cd39334e87aff34ed9239649e9 + checksum: 10c0/1618947251c02e28321e40414b555295cd899a676d4358113d78a588bdb9757759b21a8983ea04bf5007fcdec8e3f9dc3db87eaa0d2cd904c6d356fa36127f7b languageName: node linkType: hard From 27eef50b9f2f0a56aa723f4edaefdd235578571c Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Thu, 21 Aug 2025 00:49:42 +0800 Subject: [PATCH 15/96] fix: sidebar code icon reset bug (#9307) (#9333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复侧边栏重置时 Code 图标消失的问题 (#9307) 问题原因: - types/index.ts 中的 SidebarIcon 类型定义缺少 'code_tools' - 存在重复的类型定义和常量定义导致不一致 修复内容: - 在 types/index.ts 的 SidebarIcon 类型中添加 'code_tools' - 删除 minapps.ts 中重复的 DEFAULT_SIDEBAR_ICONS 常量 - 统一从 @renderer/types 导入 SidebarIcon 类型 - 删除 settings.ts 中重复的 SidebarIcon 类型定义 这确保了在导航栏设置为左侧时,点击侧边栏设置的重置按钮后, Code 图标能够正确显示。 * refactor: 将侧边栏配置移至 config 目录 根据 code review 建议,将侧边栏相关配置从 store/settings.ts 移动到 config/sidebar.ts,使配置管理更加清晰。 改动内容: - 创建 config/sidebar.ts 存放侧边栏配置常量 - 更新相关文件的导入路径 - 在 settings.ts 中重新导出以保持向后兼容 - 添加 REQUIRED_SIDEBAR_ICONS 常量便于未来扩展 这个改动保持了最小化原则,不影响现有功能。 --- src/renderer/src/config/sidebar.ts | 23 ++++++++++++++++++ .../DisplaySettings/DisplaySettings.tsx | 2 +- .../DisplaySettings/SidebarIconsManager.tsx | 3 +-- src/renderer/src/store/migrate.ts | 3 ++- src/renderer/src/store/minapps.ts | 12 +--------- src/renderer/src/store/settings.ts | 24 ++++--------------- src/renderer/src/types/index.ts | 2 +- 7 files changed, 33 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/config/sidebar.ts diff --git a/src/renderer/src/config/sidebar.ts b/src/renderer/src/config/sidebar.ts new file mode 100644 index 0000000000..90a83cf42d --- /dev/null +++ b/src/renderer/src/config/sidebar.ts @@ -0,0 +1,23 @@ +import { SidebarIcon } from '@renderer/types' + +/** + * 默认显示的侧边栏图标 + * 这些图标会在侧边栏中默认显示 + */ +export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ + 'assistants', + 'agents', + 'paintings', + 'translate', + 'minapp', + 'knowledge', + 'files', + 'code_tools' +] + +/** + * 必须显示的侧边栏图标(不能被隐藏) + * 这些图标必须始终在侧边栏中可见 + * 抽取为参数方便未来扩展 + */ +export const REQUIRED_SIDEBAR_ICONS: SidebarIcon[] = ['assistants'] diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index acc1f668e2..1c4749aebd 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -3,13 +3,13 @@ import { ResetIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import TextBadge from '@renderer/components/TextBadge' import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant' +import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import { useTheme } from '@renderer/context/ThemeProvider' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import useUserTheme from '@renderer/hooks/useUserTheme' import { useAppDispatch } from '@renderer/store' import { AssistantIconType, - DEFAULT_SIDEBAR_ICONS, setAssistantIconType, setClickAssistantToShowTopic, setCustomCss, diff --git a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx index d5ff9cd69b..8e4f609b53 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx @@ -10,14 +10,13 @@ import { import { getSidebarIconLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { setSidebarIcons } from '@renderer/store/settings' +import { SidebarIcon } from '@renderer/types' import { message } from 'antd' import { Code, FileSearch, Folder, Languages, LayoutGrid, MessageSquareQuote, Palette, Sparkle } from 'lucide-react' import { FC, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SidebarIcon } from '../../../store/settings' - interface SidebarIconsManagerProps { visibleIcons: SidebarIcon[] disabledIcons: SidebarIcon[] diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index b818fae757..f83a384a46 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -4,6 +4,7 @@ import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, isMac } from '@renderer/conf import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { isFunctionCallingModel, isNotSupportedTextDelta, SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' +import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import { isSupportArrayContentProvider, isSupportDeveloperRoleProvider, @@ -32,7 +33,7 @@ import { DEFAULT_TOOL_ORDER } from './inputTools' import { initialState as llmInitialState, moveProvider } from './llm' import { mcpSlice } from './mcp' import { defaultActionItems } from './selectionStore' -import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings' +import { initialState as settingsInitialState } from './settings' import { initialState as shortcutsInitialState } from './shortcuts' import { defaultWebSearchProviders } from './websearch' diff --git a/src/renderer/src/store/minapps.ts b/src/renderer/src/store/minapps.ts index cf2f194e46..95fea71a09 100644 --- a/src/renderer/src/store/minapps.ts +++ b/src/renderer/src/store/minapps.ts @@ -1,16 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' -import { MinAppType, SidebarIcon } from '@renderer/types' - -export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ - 'assistants', - 'agents', - 'paintings', - 'translate', - 'minapp', - 'knowledge', - 'files' -] +import { MinAppType } from '@renderer/types' export interface MinAppsState { enabled: MinAppType[] diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index f226f990f2..ae51e9f556 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' +import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import { ApiServerConfig, AssistantsSortType, @@ -10,6 +11,7 @@ import { OpenAISummaryText, PaintingProvider, S3Config, + SidebarIcon, ThemeMode, TranslateLanguageCode } from '@renderer/types' @@ -21,26 +23,8 @@ import { RemoteSyncState } from './backup' export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter' -export type SidebarIcon = - | 'assistants' - | 'agents' - | 'paintings' - | 'translate' - | 'minapp' - | 'knowledge' - | 'files' - | 'code_tools' - -export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ - 'assistants', - 'agents', - 'paintings', - 'translate', - 'minapp', - 'knowledge', - 'files', - 'code_tools' -] +// Re-export for backward compatibility +export { DEFAULT_SIDEBAR_ICONS } export interface NutstoreSyncRuntime extends RemoteSyncState {} diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index cc7427f4c1..f889056ab1 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -692,7 +692,7 @@ export const isAutoDetectionMethod = (method: string): method is AutoDetectionMe return Object.hasOwn(AutoDetectionMethods, method) } -export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files' +export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files' | 'code_tools' export type ExternalToolResult = { mcpTools?: MCPTool[] From 0e750c64db575e8f4523632e773cfa881af7fee1 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 21 Aug 2025 08:42:20 +0800 Subject: [PATCH 16/96] refactor: improve locate highlight animation (#9345) --- src/renderer/src/assets/styles/animation.scss | 20 +++++++++++++++++++ .../src/pages/home/Messages/Message.tsx | 15 ++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.scss index bbc1c569f3..9b8b428c5c 100644 --- a/src/renderer/src/assets/styles/animation.scss +++ b/src/renderer/src/assets/styles/animation.scss @@ -68,3 +68,23 @@ transform-origin: center; animation: animation-rotate 0.75s linear infinite; } + +// 定位高亮动画 +@keyframes animation-locate-highlight { + 0% { + background-color: transparent; + } + 10% { + background-color: var(--color-primary-mute); + } + 70% { + background-color: var(--color-primary-mute); + } + 100% { + background-color: transparent; + } +} + +.animation-locate-highlight { + animation: animation-locate-highlight 2.5s ease-in-out; +} diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index da595eebce..4f7c1fd1c5 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -127,11 +127,17 @@ const MessageItem: FC = ({ messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) if (highlight) { setTimeoutTimer( - 'messageHighlightHandler_1', + 'messageHighlightHandler', () => { const classList = messageContainerRef.current?.classList - classList?.add('message-highlight') - setTimeoutTimer('messageHighlightHandler_2', () => classList?.remove('message-highlight'), 2500) + classList?.add('animation-locate-highlight') + + const handleAnimationEnd = () => { + classList?.remove('animation-locate-highlight') + messageContainerRef.current?.removeEventListener('animationend', handleAnimationEnd) + } + + messageContainerRef.current?.addEventListener('animationend', handleAnimationEnd) }, 500 ) @@ -242,9 +248,6 @@ const MessageContainer = styled.div` padding: 10px; padding-bottom: 0; border-radius: 10px; - &.message-highlight { - background-color: var(--color-primary-mute); - } .menubar { opacity: 0; transition: opacity 0.2s ease; From a671f95bee1abb22e2ab0b62bb718295cd6cbb53 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:03:07 +0800 Subject: [PATCH 17/96] feat(utils): show weekday in date and datetime prompt variables (#9362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(utils): 优化日期时间变量替换格式 为 {{date}} 和 {{datetime}} 变量替换添加更详细的格式选项,包括星期、年月日和时间信息 * test(prompt): 更新测试中日期时间的本地化格式 --- .../src/utils/__tests__/prompt.test.ts | 24 ++++++++++++++++--- src/renderer/src/utils/prompt.ts | 17 +++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/utils/__tests__/prompt.test.ts b/src/renderer/src/utils/__tests__/prompt.test.ts index ab4be618c9..6d990c16eb 100644 --- a/src/renderer/src/utils/__tests__/prompt.test.ts +++ b/src/renderer/src/utils/__tests__/prompt.test.ts @@ -133,7 +133,15 @@ describe('prompt', () => { const result = await replacePromptVariables(userPrompt, assistant.model?.name) const expectedPrompt = ` 以下是一些辅助信息: - - 日期和时间: ${mockDate.toLocaleString()}; + - 日期和时间: ${mockDate.toLocaleString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric' + })}; - 操作系统: macOS; - 中央处理器架构: darwin64; - 语言: zh-CN; @@ -176,7 +184,12 @@ describe('prompt', () => { basePrompt = await replacePromptVariables(initialPrompt, assistant.model?.name) expectedBasePrompt = ` System Information: - - Date: ${mockDate.toLocaleDateString()} + - Date: ${mockDate.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric' + })} - User: MockUser Instructions: Be helpful. @@ -239,7 +252,12 @@ describe('prompt', () => { const basePrompt = await replacePromptVariables(initialPrompt, assistant.model?.name) const expectedBasePrompt = ` System Information: - - Date: ${mockDate.toLocaleDateString()} + - Date: ${mockDate.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric' + })} - User: MockUser Instructions: Be helpful. diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 4f7b9d5408..85766953ba 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -176,7 +176,12 @@ export const replacePromptVariables = async (userSystemPrompt: string, modelName const now = new Date() if (userSystemPrompt.includes('{{date}}')) { - const date = now.toLocaleDateString() + const date = now.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric' + }) userSystemPrompt = userSystemPrompt.replace(/{{date}}/g, date) } @@ -186,7 +191,15 @@ export const replacePromptVariables = async (userSystemPrompt: string, modelName } if (userSystemPrompt.includes('{{datetime}}')) { - const datetime = now.toLocaleString() + const datetime = now.toLocaleString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric' + }) userSystemPrompt = userSystemPrompt.replace(/{{datetime}}/g, datetime) } From c93b96a03fcffbe609641e43b6507fd1e9f5d4b8 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 21 Aug 2025 10:16:22 +0800 Subject: [PATCH 18/96] refactor(CodeToolsPage): simplify CLI tool change handling and optimize provider filtering logic --- src/renderer/src/pages/code/CodeToolsPage.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index 946f8be1ab..115dab30ef 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -14,7 +14,7 @@ import { Model } from '@renderer/types' import { codeTools } from '@shared/config/constant' import { Alert, Button, Checkbox, Input, Select, Space } from 'antd' import { Download, Terminal, X } from 'lucide-react' -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -56,13 +56,10 @@ const CodeToolsPage: FC = () => { const [isInstallingBun, setIsInstallingBun] = useState(false) const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false) - // 处理 CLI 工具选择 - const handleCliToolChange = (value: codeTools) => { - setCliTool(value) - // 不再清空模型选择,因为每个工具都会记住自己的模型 - } + const handleCliToolChange = (value: codeTools) => setCliTool(value) - const openAiProviders = providers.filter((p) => p.type.includes('openai')) + const openAiCompatibleProviders = providers.filter((p) => p.type.includes('openai')) + const openAiProviders = providers.filter((p) => p.id === 'openai') const geminiProviders = providers.filter((p) => p.type === 'gemini' || SUPPORTED_PROVIDERS.includes(p.id)) const claudeProviders = providers.filter((p) => p.type === 'anthropic' || SUPPORTED_PROVIDERS.includes(p.id)) @@ -82,14 +79,22 @@ const CodeToolsPage: FC = () => { [selectedCliTool] ) - const availableProviders = - selectedCliTool === codeTools.claudeCode - ? claudeProviders - : selectedCliTool === codeTools.geminiCli - ? geminiProviders - : openAiProviders + const availableProviders = useMemo(() => { + if (selectedCliTool === codeTools.claudeCode) { + return claudeProviders + } + if (selectedCliTool === codeTools.geminiCli) { + return geminiProviders + } + if (selectedCliTool === codeTools.qwenCode) { + return openAiCompatibleProviders + } + if (selectedCliTool === codeTools.openaiCodex) { + return openAiProviders + } + return [] + }, [claudeProviders, geminiProviders, openAiCompatibleProviders, openAiProviders, selectedCliTool]) - // 处理模型选择 const handleModelChange = (value: string) => { if (!value) { setModel(null) From 6e9b77a97aaf824dbaef44839c6717a7887797c9 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 21 Aug 2025 10:42:21 +0800 Subject: [PATCH 19/96] fix(newMessage): reduce default display count from 20 to 10 --- src/renderer/src/store/newMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/store/newMessage.ts b/src/renderer/src/store/newMessage.ts index 9b85a8aab7..205ab1a9cd 100644 --- a/src/renderer/src/store/newMessage.ts +++ b/src/renderer/src/store/newMessage.ts @@ -24,7 +24,7 @@ const initialState: MessagesState = messagesAdapter.getInitialState({ currentTopicId: null, loadingByTopic: {}, fulfilledByTopic: {}, - displayCount: 20 + displayCount: 10 }) // Payload for receiving messages (used by loadTopicMessagesThunk) From 84212d0b1d660574cb2488a19dbdbfcd90102f25 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 21 Aug 2025 10:28:34 +0800 Subject: [PATCH 20/96] feat(AssistantService): introduce DEFAULT_ASSISTANT_SETTINGS for consistent assistant configuration and update migration logic for version 136 --- src/renderer/src/services/AssistantService.ts | 39 +++++++------------ src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 32 ++++++++------- src/renderer/src/types/index.ts | 10 ++++- 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 311b1d1528..f11dba8ec4 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -24,6 +24,19 @@ import { uuid } from '@renderer/utils' const logger = loggerService.withContext('AssistantService') +export const DEFAULT_ASSISTANT_SETTINGS: AssistantSettings = { + temperature: DEFAULT_TEMPERATURE, + enableTemperature: true, + contextCount: DEFAULT_CONTEXTCOUNT, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + enableTopP: true, + toolUseMode: 'prompt', + customParameters: [] +} + export function getDefaultAssistant(): Assistant { return { id: 'default', @@ -34,18 +47,7 @@ export function getDefaultAssistant(): Assistant { messages: [], type: 'assistant', regularPhrases: [], // Added regularPhrases - settings: { - temperature: DEFAULT_TEMPERATURE, - enableTemperature: true, - contextCount: DEFAULT_CONTEXTCOUNT, - enableMaxTokens: false, - maxTokens: 0, - streamOutput: true, - topP: 1, - enableTopP: true, - toolUseMode: 'prompt', - customParameters: [] - } + settings: DEFAULT_ASSISTANT_SETTINGS } } @@ -171,18 +173,7 @@ export async function createAssistantFromAgent(agent: Agent) { model: agent.defaultModel, type: 'assistant', regularPhrases: agent.regularPhrases || [], // Ensured regularPhrases - settings: agent.settings || { - temperature: DEFAULT_TEMPERATURE, - enableTemperature: true, - contextCount: DEFAULT_CONTEXTCOUNT, - enableMaxTokens: false, - maxTokens: 0, - streamOutput: true, - topP: 1, - enableTopP: true, - toolUseMode: 'prompt', - customParameters: [] - } + settings: agent.settings || DEFAULT_ASSISTANT_SETTINGS } store.dispatch(addAssistant(assistant)) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index aa58b36931..d90ee7282d 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -62,7 +62,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 135, + version: 136, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f83a384a46..ee677ead39 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -4,15 +4,16 @@ import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, isMac } from '@renderer/conf import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { isFunctionCallingModel, isNotSupportedTextDelta, SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' -import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import { isSupportArrayContentProvider, isSupportDeveloperRoleProvider, isSupportStreamOptionsProvider, SYSTEM_PROVIDERS } from '@renderer/config/providers' +import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import db from '@renderer/databases' import i18n from '@renderer/i18n' +import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService' import { Assistant, isSystemProvider, @@ -2150,24 +2151,27 @@ const migrateConfig = { '135': (state: RootState) => { try { if (!state.assistants.defaultAssistant.settings) { - state.assistants.defaultAssistant.settings = { - temperature: DEFAULT_TEMPERATURE, - enableTemperature: true, - contextCount: DEFAULT_CONTEXTCOUNT, - enableMaxTokens: false, - maxTokens: 0, - streamOutput: true, - topP: 1, - enableTopP: true, - toolUseMode: 'prompt', - customParameters: [] - } + state.assistants.defaultAssistant.settings = DEFAULT_ASSISTANT_SETTINGS } else if (!state.assistants.defaultAssistant.settings.toolUseMode) { state.assistants.defaultAssistant.settings.toolUseMode = 'prompt' } return state } catch (error) { - logger.error('migrate 134 error', error as Error) + logger.error('migrate 135 error', error as Error) + return state + } + }, + '136': (state: RootState) => { + try { + state.settings.sidebarIcons.visible = [...new Set(state.settings.sidebarIcons.visible)].filter((icon) => + DEFAULT_SIDEBAR_ICONS.includes(icon) + ) + state.settings.sidebarIcons.disabled = [...new Set(state.settings.sidebarIcons.disabled)].filter((icon) => + DEFAULT_SIDEBAR_ICONS.includes(icon) + ) + return state + } catch (error) { + logger.error('migrate 136 error', error as Error) return state } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index f889056ab1..147408d83c 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -692,7 +692,15 @@ export const isAutoDetectionMethod = (method: string): method is AutoDetectionMe return Object.hasOwn(AutoDetectionMethods, method) } -export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files' | 'code_tools' +export type SidebarIcon = + | 'assistants' + | 'agents' + | 'paintings' + | 'translate' + | 'minapp' + | 'knowledge' + | 'files' + | 'code_tools' export type ExternalToolResult = { mcpTools?: MCPTool[] From 174b9bdc3d885f6fd178624586083ecfc25a2ad3 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 21 Aug 2025 10:59:55 +0800 Subject: [PATCH 21/96] chore: release v1.5.7-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c25e90115..e9a5d1d6e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.5.7-rc.1", + "version": "1.5.7-rc.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 1af4a2686b394bd3aba28fd6d51ccec3936cfca6 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:02:41 +0800 Subject: [PATCH 22/96] fix(Markdown/Link): set href to undefined when it's empty (#9343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(Markdown/Link): 处理空链接时设置href为undefined --- src/renderer/src/pages/home/Markdown/Link.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Markdown/Link.tsx b/src/renderer/src/pages/home/Markdown/Link.tsx index af06ff5036..5a008a40f3 100644 --- a/src/renderer/src/pages/home/Markdown/Link.tsx +++ b/src/renderer/src/pages/home/Markdown/Link.tsx @@ -1,4 +1,4 @@ -import { omit } from 'lodash' +import { isEmpty, omit } from 'lodash' import React from 'react' import type { Node } from 'unist' @@ -33,6 +33,7 @@ const Link: React.FC = (props) => { e.stopPropagation()} From c5d8ec9c1a241fa26d3ecb08f121fe0b1a515f93 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 21 Aug 2025 12:48:12 +0800 Subject: [PATCH 23/96] chores: upgrade @types/node to version 22.17.1 and electron to version 37.3.1 in package.json and yarn.lock (#9364) --- package.json | 4 ++-- yarn.lock | 23 ++++++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index e9a5d1d6e0..97adcc809c 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", - "@types/node": "^18.19.9", + "@types/node": "^22.17.1", "@types/pako": "^1.0.2", "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", @@ -179,7 +179,7 @@ "diff": "^7.0.0", "docx": "^9.0.2", "dotenv-cli": "^7.4.2", - "electron": "37.2.3", + "electron": "37.3.1", "electron-builder": "26.0.15", "electron-devtools-installer": "^3.2.0", "electron-store": "^8.2.0", diff --git a/yarn.lock b/yarn.lock index 265dd202a7..c0950f0a4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7350,7 +7350,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^18.11.18, @types/node@npm:^18.19.9": +"@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" dependencies: @@ -7359,6 +7359,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.17.1": + version: 22.17.2 + resolution: "@types/node@npm:22.17.2" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/23cd13aa35da6322a6d66cf4b3a45dbd40764ba726ab8681960270156c3abba776dd8dc173250c467f708d40612ecd725755d7659b775b513904680d5205eaff + languageName: node + linkType: hard + "@types/pako@npm:^1.0.2": version: 1.0.7 resolution: "@types/pako@npm:1.0.7" @@ -8441,7 +8450,7 @@ __metadata: "@types/lodash": "npm:^4.17.5" "@types/markdown-it": "npm:^14" "@types/md5": "npm:^2.3.5" - "@types/node": "npm:^18.19.9" + "@types/node": "npm:^22.17.1" "@types/pako": "npm:^1.0.2" "@types/react": "npm:^19.0.12" "@types/react-dom": "npm:^19.0.4" @@ -8476,7 +8485,7 @@ __metadata: diff: "npm:^7.0.0" docx: "npm:^9.0.2" dotenv-cli: "npm:^7.4.2" - electron: "npm:37.2.3" + electron: "npm:37.3.1" electron-builder: "npm:26.0.15" electron-devtools-installer: "npm:^3.2.0" electron-store: "npm:^8.2.0" @@ -11606,16 +11615,16 @@ __metadata: languageName: node linkType: hard -"electron@npm:37.2.3": - version: 37.2.3 - resolution: "electron@npm:37.2.3" +"electron@npm:37.3.1": + version: 37.3.1 + resolution: "electron@npm:37.3.1" dependencies: "@electron/get": "npm:^2.0.0" "@types/node": "npm:^22.7.7" extract-zip: "npm:^2.0.1" bin: electron: cli.js - checksum: 10c0/589e885345573a2d68be4af828b4d8b795f2dcc045d98c1a0245b639aceb0af148dce94ffdd46d36f000608523d4e0cce7b832f80180852d17445fed0b2f3a19 + checksum: 10c0/1b171804646f229768c29413629b70b968c4c06686913b69e8615da09c58d63a95817021c31ed3c3bdbba0ad360ad4469773b9dda2662884d7df2866559f95f1 languageName: node linkType: hard From 062b3b0a33fd22112f513745a796190de9656027 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:48:27 +0800 Subject: [PATCH 24/96] feat: search translate history (#9342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(翻译历史): 添加搜索翻译历史UI 在翻译历史页面添加搜索框 * feat(翻译历史): 优化搜索功能并添加延迟渲染 - 将搜索逻辑提取为独立函数并使用useDeferredValue优化性能 - 重构类型命名和状态管理 - 格式化日期显示并移入memo计算 * feat(i18n): 为翻译历史添加搜索框占位文本 * refactor(translate): 移除未使用的InputRef引用和inputRef变量 --- src/renderer/src/i18n/locales/en-us.json | 3 + src/renderer/src/i18n/locales/ja-jp.json | 3 + src/renderer/src/i18n/locales/ru-ru.json | 3 + src/renderer/src/i18n/locales/zh-cn.json | 3 + src/renderer/src/i18n/locales/zh-tw.json | 3 + src/renderer/src/i18n/translate/el-gr.json | 3 + src/renderer/src/i18n/translate/es-es.json | 3 + src/renderer/src/i18n/translate/fr-fr.json | 3 + src/renderer/src/i18n/translate/pt-pt.json | 3 + .../src/pages/translate/TranslateHistory.tsx | 73 ++++++++++++++++--- 10 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 2af6c5a9ad..eef4f124aa 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3723,6 +3723,9 @@ "error": { "save": "Failed to save translation history" }, + "search": { + "placeholder": "Search translation history" + }, "title": "Translation History" }, "input": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c70be947da..289914cca6 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -3723,6 +3723,9 @@ "error": { "save": "保存翻訳履歴に失敗しました" }, + "search": { + "placeholder": "翻訳履歴を検索する" + }, "title": "翻訳履歴" }, "input": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 22494fb87b..ca219486dd 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -3723,6 +3723,9 @@ "error": { "save": "Не удалось сохранить историю переводов" }, + "search": { + "placeholder": "Поиск истории переводов" + }, "title": "История переводов" }, "input": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a17b0f2247..ff692f3bbd 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3723,6 +3723,9 @@ "error": { "save": "保存翻译历史失败" }, + "search": { + "placeholder": "搜索翻译历史" + }, "title": "翻译历史" }, "input": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 03e6f671ef..aef5a324dd 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3723,6 +3723,9 @@ "error": { "save": "保存翻譯歷史失敗" }, + "search": { + "placeholder": "搜索翻譯歷史" + }, "title": "翻譯歷史" }, "input": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c694511a2f..7e60d79b49 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3723,6 +3723,9 @@ "error": { "save": "Αποτυχία αποθήκευσης του ιστορικού μεταφράσεων" }, + "search": { + "placeholder": "Αναζήτηση ιστορικού μεταφράσεων" + }, "title": "Ιστορικό μετάφρασης" }, "input": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 1a31c3861d..ef4234f334 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3723,6 +3723,9 @@ "error": { "save": "Error al guardar el historial de traducciones" }, + "search": { + "placeholder": "Historial de búsqueda de traducción" + }, "title": "Historial de traducciones" }, "input": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index ed45fd124d..9e2f724425 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3723,6 +3723,9 @@ "error": { "save": "Échec de la sauvegarde de l'historique des traductions" }, + "search": { + "placeholder": "Rechercher l'historique des traductions" + }, "title": "Historique des traductions" }, "input": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 2157fcb51e..9bed40be77 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3723,6 +3723,9 @@ "error": { "save": "Falha ao guardar o histórico de traduções" }, + "search": { + "placeholder": "Pesquisar histórico de tradução" + }, "title": "Histórico de Tradução" }, "input": { diff --git a/src/renderer/src/pages/translate/TranslateHistory.tsx b/src/renderer/src/pages/translate/TranslateHistory.tsx index 1b65285b72..371237f9eb 100644 --- a/src/renderer/src/pages/translate/TranslateHistory.tsx +++ b/src/renderer/src/pages/translate/TranslateHistory.tsx @@ -1,28 +1,32 @@ import { DeleteOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' import { DynamicVirtualList } from '@renderer/components/VirtualList' import db from '@renderer/databases' import useTranslate from '@renderer/hooks/useTranslate' import { clearHistory, deleteHistory } from '@renderer/services/TranslateService' import { TranslateHistory, TranslateLanguage } from '@renderer/types' -import { Button, Drawer, Dropdown, Empty, Flex, Popconfirm } from 'antd' +import { Button, Drawer, Dropdown, Empty, Flex, Input, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' import { isEmpty } from 'lodash' -import { FC, useMemo } from 'react' +import { SearchIcon } from 'lucide-react' +import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -type DisplayedTranslateHistory = TranslateHistory & { +type DisplayedTranslateHistoryItem = TranslateHistory & { _sourceLanguage: TranslateLanguage _targetLanguage: TranslateLanguage } type TranslateHistoryProps = { isOpen: boolean - onHistoryItemClick: (history: DisplayedTranslateHistory) => void + onHistoryItemClick: (history: DisplayedTranslateHistoryItem) => void onClose: () => void } +// const logger = loggerService.withContext('TranslateHistory') + // px const ITEM_HEIGHT = 140 @@ -30,17 +34,35 @@ const TranslateHistoryList: FC = ({ isOpen, onHistoryItem const { t } = useTranslation() const { getLanguageByLangcode } = useTranslate() const _translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), []) + const [search, setSearch] = useState('') + const [displayedHistory, setDisplayedHistory] = useState([]) - const translateHistory: DisplayedTranslateHistory[] = useMemo(() => { + const translateHistory: DisplayedTranslateHistoryItem[] = useMemo(() => { if (!_translateHistory) return [] return _translateHistory.map((item) => ({ ...item, _sourceLanguage: getLanguageByLangcode(item.sourceLanguage), - _targetLanguage: getLanguageByLangcode(item.targetLanguage) + _targetLanguage: getLanguageByLangcode(item.targetLanguage), + createdAt: dayjs(item.createdAt).format('MM/DD HH:mm') })) }, [_translateHistory, getLanguageByLangcode]) + const searchFilter = useCallback( + (item: DisplayedTranslateHistoryItem) => { + if (isEmpty(search)) return true + const content = `${item._sourceLanguage.label()} ${item._targetLanguage.label()} ${item.sourceText} ${item.targetText} ${item.createdAt}` + return content.includes(search) + }, + [search] + ) + + useEffect(() => { + setDisplayedHistory(translateHistory.filter(searchFilter)) + }, [searchFilter, translateHistory]) + + const deferredHistory = useDeferredValue(displayedHistory) + return ( = ({ isOpen, onHistoryItem } }}> - {translateHistory && translateHistory.length ? ( + {/* Search Bar */} + + + + + } + placeholder={t('translate.history.search.placeholder')} + value={search} + onChange={(e) => { + setSearch(e.target.value) + }} + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0, height: '3em' }} + variant="borderless" + size="middle" + /> + + + {/* Virtual List */} + {deferredHistory.length > 0 ? ( - ITEM_HEIGHT}> + ITEM_HEIGHT}> {(item) => { return ( = ({ isOpen, onHistoryItem {item._sourceLanguage.label()} → {item._targetLanguage.label()} - {dayjs(item.createdAt).format('MM/DD HH:mm')} + {item.createdAt} {item.sourceText} @@ -192,4 +237,14 @@ const HistoryListItemLanguage = styled.div` color: var(--color-text-3); ` +const IconWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 30px; + width: 30px; + border-radius: 15px; + background-color: var(--color-background-soft); +` + export default TranslateHistoryList From ea6a1752e7d3af244dfd9714b0ded1aca89c8d08 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:18:19 +0800 Subject: [PATCH 25/96] feat: reasoning effort cache (#9357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(useAssistant): 修改模型切换时推理努力值回退逻辑 当模型切换时,确保推理努力值回退到模型支持的第一个有效值,并默认开启思考模式。使用useRef优化设置引用,避免不必要的依赖。 * feat(assistant): 添加 reasoning_effort_cache 以保留思考模型设置 当从非思考模型切换回思考模型时,恢复上次使用的 reasoning_effort 值 * fix(assistant): 修复思考模式切换时缓存未正确更新的问题 * fix(useAssistant): 修复模型选项回退逻辑以支持推理模式 当启用推理模式时,回退到支持推理的选项,否则回退到默认选项 * docs(types): 完善 AssistantSettings 类型注释中的 TODO 说明 --- src/renderer/src/hooks/useAssistant.ts | 49 ++++++++++++++----- .../pages/home/Inputbar/ThinkingButton.tsx | 2 + src/renderer/src/types/index.ts | 7 +++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index b029f96b06..02a327c345 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -3,7 +3,8 @@ import { getThinkModelType, isSupportedReasoningEffortModel, isSupportedThinkingTokenModel, - MODEL_SUPPORTED_OPTIONS + MODEL_SUPPORTED_OPTIONS, + MODEL_SUPPORTED_REASONING_EFFORT } from '@renderer/config/models' import { db } from '@renderer/databases' import { getDefaultTopic } from '@renderer/services/AssistantService' @@ -24,9 +25,9 @@ import { updateTopics } from '@renderer/store/assistants' import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm' -import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' +import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { TopicManager } from './useTopic' @@ -84,6 +85,12 @@ export function useAssistant(id: string) { const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model]) + const settingsRef = useRef(assistant?.settings) + + useEffect(() => { + settingsRef.current = assistant.settings + }, [assistant?.settings]) + const updateAssistantSettings = useCallback( (settings: Partial) => { assistant?.id && dispatch(_updateAssistantSettings({ assistantId: assistant.id, settings })) @@ -93,28 +100,46 @@ export function useAssistant(id: string) { // 当model变化时,同步reasoning effort为模型支持的合法值 useEffect(() => { - if (assistant?.settings) { + const settings = settingsRef.current + if (settings) { + const currentReasoningEffort = settings.reasoning_effort if (isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) { - const currentReasoningEffort = assistant?.settings?.reasoning_effort - const supportedOptions = MODEL_SUPPORTED_OPTIONS[getThinkModelType(model)] - if (currentReasoningEffort && !supportedOptions.includes(currentReasoningEffort)) { - // 选项不支持时,回退到第一个支持的值 - // 注意:这里假设可用的options不会为空 - const fallbackOption = supportedOptions[0] + const modelType = getThinkModelType(model) + const supportedOptions = MODEL_SUPPORTED_OPTIONS[modelType] + if (supportedOptions.every((option) => option !== currentReasoningEffort)) { + const cache = settings.reasoning_effort_cache + let fallbackOption: ThinkingOption + + // 选项不支持时,首先尝试恢复到上次使用的值 + if (cache && supportedOptions.includes(cache)) { + fallbackOption = cache + } else { + // 灵活回退到支持的值 + // 注意:这里假设可用的options不会为空 + const enableThinking = currentReasoningEffort !== undefined + fallbackOption = enableThinking + ? MODEL_SUPPORTED_REASONING_EFFORT[modelType][0] + : MODEL_SUPPORTED_OPTIONS[modelType][0] + } updateAssistantSettings({ reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption, - qwenThinkMode: fallbackOption === 'off' + reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption, + qwenThinkMode: fallbackOption === 'off' ? undefined : true }) + } else { + // 对于支持的选项, 不再更新 cache. } } else { + // 切换到非思考模型时保留cache updateAssistantSettings({ reasoning_effort: undefined, + reasoning_effort_cache: currentReasoningEffort, qwenThinkMode: undefined }) } } - }, [assistant?.settings, model, updateAssistantSettings]) + }, [model, updateAssistantSettings]) return { assistant: assistantWithModel, diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index d3765c4067..4ad9269380 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -77,12 +77,14 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re if (!isEnabled) { updateAssistantSettings({ reasoning_effort: undefined, + reasoning_effort_cache: undefined, qwenThinkMode: false }) return } updateAssistantSettings({ reasoning_effort: option, + reasoning_effort_cache: option, qwenThinkMode: true }) return diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 147408d83c..e3663b966d 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -99,6 +99,13 @@ export type AssistantSettings = { defaultModel?: Model customParameters?: AssistantSettingCustomParameters[] reasoning_effort?: ReasoningEffortOption + /** 保留上一次使用思考模型时的 reasoning effort, 在从非思考模型切换到思考模型时恢复. + * + * TODO: 目前 reasoning_effort === undefined 有两个语义,有的场景是显式关闭思考,有的场景是不传参。 + * 未来应该重构思考控制,将启用/关闭思考和思考选项分离,这样就不用依赖 cache 了。 + * + */ + reasoning_effort_cache?: ReasoningEffortOption qwenThinkMode?: boolean toolUseMode: 'function' | 'prompt' } From 4dabc214f2cbbc0818ef0ec14a4c3ba86d39a8ff Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 21 Aug 2025 14:19:51 +0800 Subject: [PATCH 26/96] feat: enhance file extension handling in Inputbar (#9269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add isTextFile functionality and improve file selection handling - Introduced a new IPC channel for checking if a file is a text file. - Implemented isTextFile method in FileStorage service to determine file type based on content. - Enhanced AttachmentButton to filter selected files based on text file validation. - Updated translations to include support for displaying unsupported file counts across multiple languages. - Added utility functions for text file validation and filtering in file utilities. * refactor(FileStorage): replace hardcoded buffer size with constant for improved readability * restore yarn lock * add isbinaryfile dep * refactor: 整理导入顺序 * fix(preload): 为isTextFile方法添加返回类型Promise * refactor(FileManager): update getSafePath to use file metadata for path retrieval - Modified getSafePath method to utilize the path from file metadata instead of a hardcoded file path. - Enhanced handling for files not stored in the file storage system. * refactor(FileUtilities): rename text file functions for clarity - Updated function names from isTextFile to isSupportedFile and filterTextFiles to filterSupportedFiles to better reflect their purpose. - Adjusted related imports and usages in AttachmentButton and PasteService components to align with the new naming conventions. * fix drop files * refactor(MarkdownStyles): remove last-child margin override; adjust MessageFooter margin and clean up unused code in MessageAttachments * feat(Sidebar): add 'code_tools' icon and route; enhance CodeToolsPage layout with Navbar and improved provider filtering * feat(CodeTools): add environment variable support for CLI tools; update UI to manage environment variables and enhance localization for related strings * refactor(Sidebar): remove unused imports and code related to documentation; streamline sidebar functionality * refactor(SvgPreview): use transparent container for SVG (#9294) * refactor(SvgPreview): use transparent container for SVG * test: fix snapshot * refactor(CodeToolsService): replace npm package version fetching with direct API call; simplify command construction for installation * chore: release v1.5.7-rc.1 * refactor(CodeToolsService): adjust command construction for Windows compatibility; streamline installation command handling * refactor(Markdown): update disallowed elements to include 'script' for enhanced security * feat: quick model (#9290) * refactor(i18n): 将话题命名模型相关文案更新为摘要模型 更新所有语言文件中关于话题命名模型的文案,统一改为摘要模型,以反映功能的扩展和更通用的用途 * refactor(设置页面): 优化主题命名弹窗组件性能 使用useCallback和useMemo优化回调函数和渲染性能 将重复的JSX代码提取为独立组件 * feat(设置): 在模型设置中添加话题命名折叠面板 将话题命名设置从直接显示改为折叠面板形式,提升界面整洁度 * refactor(i18n): 重构话题命名相关翻译字段结构 * docs(i18n): 添加生成图像的高度、宽度和安全容忍度翻译占位符 * fix(settings): 修正主题命名弹窗中的翻译键名 * style(ui): 调整主题命名弹窗的间距和文本区域高度 移除多余的上下边距,并使用自适应高度的文本区域 * refactor(llm): 将 topicNamingModel 重命名为 summaryModel 更新相关函数、状态和测试用例以反映命名变更 增加迁移逻辑处理旧状态数据 更新持久化版本号至133 * fix(ApiService): 优先使用摘要模型替代默认模型 当获取摘要时,优先使用getSummaryModel()返回的模型,其次才是助手指定的模型或默认模型,以确保摘要生成的一致性 * docs(i18n): 更新摘要模型描述中的搜索关键词提炼 将"搜索结果摘要"修改为"搜索关键字提炼"以更准确描述功能 * fix(i18n): 更新多语言翻译文件中的摘要模型相关文本 * feat(i18n): 为摘要模型设置添加工具提示说明 添加摘要模型设置的工具提示,建议用户选择轻量模型而非思考模型 * refactor(i18n): 将摘要模型相关文案更新为快速模型 更新国际化文案和组件引用,将"摘要模型"统一改为"快速模型"以更准确描述功能用途 * feat(i18n): 将摘要模型重命名为快速模型并更新相关描述 * refactor(llm): 将summaryModel重命名为quickModel以提升语义清晰度 * test(api): 在ApiService测试中添加LlmState类型和awsBedrock配置 添加LlmState类型以满足类型检查要求,并补充awsBedrock的mock配置以完善测试覆盖 * Revert "feat(设置): 在模型设置中添加话题命名折叠面板" This reverts commit 4d58c053dafe77acbdefc2e73a891c96d248e188. * refactor(settings): 重命名并移动 TopicNamingModalPopup 组件文件 将 TopicNamingModalPopup.tsx 重命名为 QuickModelPopup.tsx 并移动到相应目录 * refactor(QuickModelPopup): 优化主题命名设置布局和样式 移除 TopicNamingSettings 组件内联实现,直接整合到 Modal 中 调整间距和样式,提升视觉一致性 修复文本区域 onChange 去除换行的逻辑 * feat(模型设置): 在快速模型弹窗中添加重置按钮图标并调整布局 将重置按钮改为图标形式并内联显示,同时调整输入区域的高度样式 * docs(i18n): 更新快速模型相关翻译文本 * fix: 将迁移错误日志从133更新为134 * style(settings): 替换模型设置中的图标为Rocket图标以提升视觉一致性 * fix: unexpected quitting full screen mode (#9200) * fix(Inputbar): 修正拼写错误,将expend改为expand * fix: 修复Escape键事件冒泡问题并改进全屏处理 修复多个组件中Escape键事件未阻止冒泡的问题 添加全屏控制IPC通道 将全屏退出逻辑移至渲染进程处理 移除主进程中冗余的全屏退出处理代码 * fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect 将键盘事件监听从window移动到Modal容器,避免事件冒泡问题 移除无效的useEffect并更新键盘事件类型定义 * fix(QuickPanel): 拦截window上的keydown事件 * fix(QuickPanel): 修复事件监听器移除时未使用相同参数的问题 * fix(TopView): 修复左侧导航栏布局崩坏问题 * fix: 修正变量名拼写错误,将expended改为expanded * Revert "fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect" This reverts commit 4211780b95c737989a62e6598728ff6e17b6b61b. * feat: use quick model to detect translate language (#9315) * refactor(语言检测): 移除翻译模型依赖,改用快速或默认模型 * feat(i18n): 添加希腊语翻译支持 * fix(i18n): 更新i18n 统一将翻译模型提示改为快速模型提示,优化多语言文件中的描述 * Revert "feat(i18n): 添加希腊语翻译支持" This reverts commit 42613cb2e21da2a6cb3e920b09cfa57f7784ac58. * feat: add 'code_tools' to sidebar icons and update related components * fix: KaTeX math engine render * feat: 同步百炼服务器功能 (#9205) * 同步百炼服务器功能 * cr修改 --------- Co-authored-by: yunze * fix(SelectionHook): improve validation for selected text range to handle empty strings and ensure valid extraction (#9329) chore: update selection-hook dependency to version 1.0.10 in package.json and yarn.lock * fix: web search references missing caused by early reset (#9328) * feat(openai): handle special tokens for zhipu api (#9323) * feat(openai): 添加对智谱特殊token的过滤处理 在OpenAIAPIClient中添加对智谱AI特殊token的过滤逻辑,避免不需要的token被输出 * docs(OpenAIApiClient): 添加注释 * refactor(zhipu): 重命名并更新智谱特殊token处理逻辑 将 ZHIPU_SPECIAL_TOKENS_TO_FILTER 重命名为 ZHIPU_RESULT_TOKENS 以更准确描述用途 修改智谱API特殊token处理逻辑,不再过滤而是用**标记结果token * feat: support openai codex (#9332) * support openai codex * lint * refactor: remove unused codeTools enum from constant.ts * fix build * fix lin * fix: add support for qwenCode CLI tool and improve error handling in CodeToolsService * fix: timeout memory leak (#9312) * fix(MinappPopupContainer): 修复内存泄漏问题,清理未使用的定时器 在组件卸载时清理setTimeout定时器,避免潜在的内存泄漏 * fix(SelectModelButton): 修复模型选择后更新导致的卡顿问题 使用useRef存储定时器并在组件卸载时清理,避免内存泄漏 * fix(QuickPanel): 修复定时器未清理导致的内存泄漏问题 添加 clearSearchTimerRef 和 focusTimerRef 来管理定时器 在组件清理和状态变化时清理所有定时器 * fix(useInPlaceEdit): 修复编辑模式下定时器未清理的问题 添加清理定时器的逻辑,避免组件卸载时内存泄漏 * refactor(useKnowledge): 使用ref管理定时器并统一检查知识库逻辑 将分散的setTimeout调用统一为checkAllBases方法 使用useRef管理定时器并在组件卸载时清理 * fix(useScrollPosition): 修复滚动位置恢复时的内存泄漏问题 添加清理函数以清除未完成的定时器,防止组件卸载时内存泄漏 * fix(WebSearchProviderSetting): 清理定时器防止内存泄漏 在组件卸载时清理检查API有效性的定时器,避免潜在的内存泄漏问题 * fix(selection-toolbar): 修复选中文本时定时器未清理的问题 * fix(translate): 修复复制文本时定时器未清理的问题 添加 copyTimerRef 来管理复制操作的定时器,并在组件卸载时清理定时器 * fix(WebSearchSettings): 使用useRef管理订阅验证定时器以避免内存泄漏 * fix(MCPSettings): 修复定时器未清理导致的内存泄漏问题 添加 useRef 来存储定时器引用,并在组件卸载时清理定时器 * refactor(ThinkingBlock): 使用 useTemporaryValue 替换手动 setTimeout 移除手动设置的 setTimeout 来重置 copied 状态,改用 useTemporaryValue hook 自动处理 * refactor(ChatNavigation): 使用 useRef 替代 useState 管理定时器 简化定时器管理逻辑,避免不必要的状态更新 * fix(AddAssistantPopup): 清理创建助手时的定时器以避免内存泄漏 添加useEffect清理定时器,防止组件卸载时内存泄漏 * feat(hooks): 添加useTimer钩子管理定时器 实现一个自定义hook来集中管理setTimeout和setInterval定时器 自动在组件卸载时清理所有定时器防止内存泄漏 * refactor(Inputbar): 使用 useTimer 替换 setTimeout 实现延迟更新 将 setTimeout 替换为 useTimer 的自定义 setTimeoutTimer 方法,提高代码可维护性并统一计时器管理 * refactor(WindowFooter): 使用 useTimer 替换 setTimeout 以管理定时器 * docs(useTimer): 更新定时器hook的注释格式和描述 * feat(hooks): 为useTimer添加返回清理函数的功能 允许从setTimeoutTimer和setIntervalTimer返回清理函数,便于手动清除定时器 * refactor(ImportAgentPopup): 使用useTimer替换setTimeout以管理定时器 * refactor: 使用useTimer替代setTimeout以优化定时器管理 * refactor(SearchResults): 使用useTimer替换setTimeout以管理定时器 * refactor(消息组件): 使用useTimer替换setTimeout以管理定时器 * refactor: 使用useTimer替换setTimeout以优化定时器管理 * refactor(AssistantsDrawer): 使用useTimer替换setTimeout以优化定时器管理 * refactor(Inputbar): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MCPToolsButton): 使用useTimer优化定时器管理 * refactor(QuickPhrasesButton): 使用useTimer替换setTimeout以优化定时器管理 * refactor(ChatFlowHistory): 使用useTimer替换setTimeout以管理定时器 * refactor(Message): 使用useTimer替换setTimeout以管理定时器 * refactor(MessageAnchorLine): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MessageGroup): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MessageMenubar): 使用 useTemporaryValue 替换手动 setTimeout 逻辑 * refactor(Messages): 使用 useTimer 优化定时器管理 * refactor(MessageTools): 使用useTimer替换setTimeout以管理定时器 * fix(SelectionBox): 修复鼠标移动时未清除定时器导致的内存泄漏 在鼠标移动事件处理中增加定时器清理逻辑,避免组件卸载时未清除定时器导致的内存泄漏问题 * refactor(ErrorBlock): 使用自定义hook替换setTimeout 使用useTimer中的setTimeoutTimer替代原生setTimeout,便于统一管理定时器 * refactor(GeneralSettings): 使用useTimer替换setTimeout以实现更好的定时器管理 * refactor(ShortcutSettings): 使用useTimer替换setTimeout以优化定时器管理 * refactor(AssistantModelSettings): 使用useTimer替换setTimeout以优化定时器管理 * refactor(DataSettings): 使用useTimer替换setTimeout以增强定时器管理 统一使用useTimer hook管理所有定时器操作,提高代码可维护性 * refactor(NutstoreSettings): 使用useTimer优化setTimeout管理 替换直接使用setTimeout为useTimer hook的setTimeoutTimer方法,提升定时器管理的可维护性 * refactor(MCPSettings): 使用useTimer替换setTimeout以提升代码可维护性 * refactor(ProviderSetting): 使用useTimer优化setTimeout管理 * refactor(ProviderSettings): 使用 useTimer 替换 setTimeout 以优化定时器管理 * refactor(InputBar): 使用useTimer替换setTimeout以实现更好的定时器管理 * refactor(MessageEditor): 使用useTimer替换setTimeout以管理定时器 使用自定义hook useTimer来替代原生setTimeout,便于统一管理和清理定时器 * docs(useTimer): 添加 useTimer hook 的使用示例和详细说明 * refactor(MinappPopupContainer): 使用useTimer替换setTimeout实现 替换直接使用setTimeout为自定义hook useTimer,简化组件清理逻辑 * refactor(AddAssistantPopup): 使用useTimer替换手动定时器管理 用useTimer钩子替代手动管理定时器,简化代码并提高可维护性 * refactor(WebSearchSettings): 使用 useTimer 替换手动定时器管理 移除手动管理的定时器逻辑,改用 useTimer hook 统一处理 * refactor(WebSearchProviderSetting): 使用自定义hook替代原生定时器 用useTimer hook替换原有的setTimeout和clearTimeout逻辑,提高代码可维护性 * refactor(translate): 使用 useTemporaryValue 替换手动实现的复制状态定时器 * refactor(SelectionToolbar): 使用 useTimer 钩子替换 setTimeout 和 clearTimeout 重构 SelectionToolbar 组件,使用自定义的 useTimer 钩子来管理定时器,提升代码可维护性 清理隐藏时的定时器逻辑,避免内存泄漏 * fix(Translate): update settings into db (#9305) * fix(翻译): 修复设置没有储存到db的错误 * fix(translate): 修复自动检测方法设置更新失败的问题 添加错误处理逻辑,当更新自动检测方法设置失败时显示错误信息 * Fix AWS Bedrock models not receiving uploaded document content (#9337) * Initial plan * Add file content processing to AWS Bedrock client convertMessageToSdkParam method Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> * Fix file content format to match other AI clients and update tests Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> * Update src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> * feat(migrate): initialize default assistant settings if not present (#9303) * feat(migrate): update migration logic for version 134; initialize default assistant settings if not present * Update src/renderer/src/store/migrate.ts Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --------- Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> * feat: support language aliases for code editor (#9336) * feat(CodeEditor): support language aliases * fix: mermaid * refactor: lookup * chore: sort package.json * fix(SelectionHook): [macOS] add type safety to prevent crashes (#9354) chore: update selection-hook dependency to version 1.0.11 in package.json and yarn.lock * fix: sidebar code icon reset bug (#9307) (#9333) * fix: 修复侧边栏重置时 Code 图标消失的问题 (#9307) 问题原因: - types/index.ts 中的 SidebarIcon 类型定义缺少 'code_tools' - 存在重复的类型定义和常量定义导致不一致 修复内容: - 在 types/index.ts 的 SidebarIcon 类型中添加 'code_tools' - 删除 minapps.ts 中重复的 DEFAULT_SIDEBAR_ICONS 常量 - 统一从 @renderer/types 导入 SidebarIcon 类型 - 删除 settings.ts 中重复的 SidebarIcon 类型定义 这确保了在导航栏设置为左侧时,点击侧边栏设置的重置按钮后, Code 图标能够正确显示。 * refactor: 将侧边栏配置移至 config 目录 根据 code review 建议,将侧边栏相关配置从 store/settings.ts 移动到 config/sidebar.ts,使配置管理更加清晰。 改动内容: - 创建 config/sidebar.ts 存放侧边栏配置常量 - 更新相关文件的导入路径 - 在 settings.ts 中重新导出以保持向后兼容 - 添加 REQUIRED_SIDEBAR_ICONS 常量便于未来扩展 这个改动保持了最小化原则,不影响现有功能。 * refactor: improve locate highlight animation (#9345) * feat(utils): show weekday in date and datetime prompt variables (#9362) * feat(utils): 优化日期时间变量替换格式 为 {{date}} 和 {{datetime}} 变量替换添加更详细的格式选项,包括星期、年月日和时间信息 * test(prompt): 更新测试中日期时间的本地化格式 * refactor(CodeToolsPage): simplify CLI tool change handling and optimize provider filtering logic * fix(newMessage): reduce default display count from 20 to 10 * feat(AssistantService): introduce DEFAULT_ASSISTANT_SETTINGS for consistent assistant configuration and update migration logic for version 136 * chore: release v1.5.7-rc.2 * fix(Markdown/Link): set href to undefined when it's empty (#9343) fix(Markdown/Link): 处理空链接时设置href为undefined * fix(Inputbar): update file handling to use functional state update for setFiles * refactor(file): update isSupportedFile function to accept filePath instead of FileMetadata for improved clarity and consistency in file handling --------- Co-authored-by: icarus Co-authored-by: kangfenmao Co-authored-by: one Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> Co-authored-by: alickreborn0 Co-authored-by: yunze Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Co-authored-by: caozhiyuan <568022847@qq.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> Co-authored-by: SuYao Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com> --- package.json | 1 + packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 1 + src/main/services/FileStorage.ts | 32 ++++++++++++++++++- src/preload/index.ts | 3 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + .../pages/home/Inputbar/AttachmentButton.tsx | 30 ++++++++++++++--- .../src/pages/home/Inputbar/Inputbar.tsx | 24 ++++++-------- src/renderer/src/services/FileManager.ts | 4 ++- src/renderer/src/services/PasteService.ts | 5 +-- src/renderer/src/utils/file.ts | 28 ++++++++++++++++ yarn.lock | 15 +++++---- 20 files changed, 121 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 97adcc809c..b866ba7138 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "husky": "^9.1.7", "i18next": "^23.11.5", "iconv-lite": "^0.6.3", + "isbinaryfile": "5.0.4", "jaison": "^2.0.2", "jest-styled-components": "^7.2.0", "linguist-languages": "^8.0.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 20bc852e7e..56ebfb3d58 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -157,6 +157,7 @@ export enum IpcChannel { File_GetPdfInfo = 'file:getPdfInfo', Fs_Read = 'fs:read', File_OpenWithRelativePath = 'file:openWithRelativePath', + File_IsTextFile = 'file:isTextFile', // file service FileService_Upload = 'file-service:upload', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f094e8d580..8689ab2c3b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -444,6 +444,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) + ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) // file service ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => { diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index f5df9ed3f7..39a16713d7 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file' -import { documentExts, imageExts, MB } from '@shared/config/constant' +import { documentExts, imageExts, KB, MB } from '@shared/config/constant' import { FileMetadata } from '@types' +import chardet from 'chardet' import * as crypto from 'crypto' import { dialog, @@ -15,6 +16,7 @@ import { import * as fs from 'fs' import { writeFileSync } from 'fs' import { readFile } from 'fs/promises' +import { isBinaryFile } from 'isbinaryfile' import officeParser from 'officeparser' import * as path from 'path' import { PDFDocument } from 'pdf-lib' @@ -630,6 +632,34 @@ class FileStorage { public getFilePathById(file: FileMetadata): string { return path.join(this.storageDir, file.id + file.ext) } + + public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { + try { + const isBinary = await isBinaryFile(filePath) + if (isBinary) { + return false + } + + const length = 8 * KB + const fileHandle = await fs.promises.open(filePath, 'r') + const buffer = Buffer.alloc(length) + const { bytesRead } = await fileHandle.read(buffer, 0, length, 0) + await fileHandle.close() + + const sampleBuffer = buffer.subarray(0, bytesRead) + const matches = chardet.analyse(sampleBuffer) + + // 如果检测到的编码置信度较高,认为是文本文件 + if (matches.length > 0 && matches[0].confidence > 0.8) { + return true + } + + return false + } catch (error) { + logger.error('Failed to check if file is text:', error as Error) + return false + } + } } export const fileStorage = new FileStorage() diff --git a/src/preload/index.ts b/src/preload/index.ts index 1a630a19ee..1059826224 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -170,7 +170,8 @@ const api = { base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId), getPathForFile: (file: File) => webUtils.getPathForFile(file), - openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file) + openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), + isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) }, fs: { read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index eef4f124aa..1841716293 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -322,6 +322,7 @@ "expand": "Expand", "file_error": "Error processing file", "file_not_supported": "Model does not support this file type", + "file_not_supported_count": "{{count}} files are not supported", "generate_image": "Generate image", "generate_image_not_supported": "The model does not support generating images.", "knowledge_base": "Knowledge Base", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 289914cca6..5842f6b24b 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -322,6 +322,7 @@ "expand": "展開", "file_error": "ファイル処理エラー", "file_not_supported": "モデルはこのファイルタイプをサポートしません", + "file_not_supported_count": "{{count}} 個のファイルはサポートされていません", "generate_image": "画像を生成する", "generate_image_not_supported": "モデルは画像の生成をサポートしていません。", "knowledge_base": "ナレッジベース", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index ca219486dd..d1a8fff8e5 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -322,6 +322,7 @@ "expand": "Развернуть", "file_error": "Ошибка обработки файла", "file_not_supported": "Модель не поддерживает этот тип файла", + "file_not_supported_count": "{{count}} файлов не поддерживаются", "generate_image": "Сгенерировать изображение", "generate_image_not_supported": "Модель не поддерживает генерацию изображений.", "knowledge_base": "База знаний", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ff692f3bbd..812d182c94 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -322,6 +322,7 @@ "expand": "展开", "file_error": "文件处理出错", "file_not_supported": "模型不支持此文件类型", + "file_not_supported_count": "{{count}} 个文件不被支持", "generate_image": "生成图片", "generate_image_not_supported": "模型不支持生成图片", "knowledge_base": "知识库", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index aef5a324dd..5b0bc186ca 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -322,6 +322,7 @@ "expand": "展開", "file_error": "檔案處理錯誤", "file_not_supported": "模型不支援此檔案類型", + "file_not_supported_count": "{{count}} 個檔案不被支援", "generate_image": "生成圖片", "generate_image_not_supported": "模型不支援生成圖片", "knowledge_base": "知識庫", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 7e60d79b49..05c1efa2cf 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -322,6 +322,7 @@ "expand": "Επεκτάση", "file_error": "Σφάλμα κατά την επεξεργασία του αρχείου", "file_not_supported": "Το μοντέλο δεν υποστηρίζει αυτό το είδος αρχείων", + "file_not_supported_count": "{{count}} αρχεία δεν υποστηρίζονται", "generate_image": "Δημιουργία εικόνας", "generate_image_not_supported": "Το μοντέλο δεν υποστηρίζει τη δημιουργία εικόνων", "knowledge_base": "Βάση γνώσεων", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index ef4234f334..d63cda4c58 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -322,6 +322,7 @@ "expand": "Expandir", "file_error": "Error al procesar el archivo", "file_not_supported": "El modelo no admite este tipo de archivo", + "file_not_supported_count": "{{count}} archivos no soportados", "generate_image": "Generar imagen", "generate_image_not_supported": "El modelo no soporta la generación de imágenes", "knowledge_base": "Base de conocimientos", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 9e2f724425..a7575df024 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -322,6 +322,7 @@ "expand": "Développer", "file_error": "Erreur lors du traitement du fichier", "file_not_supported": "Le modèle ne prend pas en charge ce type de fichier", + "file_not_supported_count": "{{count}} fichiers non pris en charge", "generate_image": "Générer une image", "generate_image_not_supported": "Le modèle ne supporte pas la génération d'images", "knowledge_base": "Base de connaissances", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 9bed40be77..3234ad8215 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -322,6 +322,7 @@ "expand": "Expandir", "file_error": "Erro ao processar o arquivo", "file_not_supported": "O modelo não suporta este tipo de arquivo", + "file_not_supported_count": "{{count}} arquivos não suportados", "generate_image": "Gerar imagem", "generate_image_not_supported": "Modelo não suporta geração de imagem", "knowledge_base": "Base de conhecimento", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index dc439e79c7..7e6d583fc3 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,4 +1,5 @@ -import { FileType } from '@renderer/types' +import { FileMetadata, FileType } from '@renderer/types' +import { filterSupportedFiles } from '@renderer/utils/file' import { Tooltip } from 'antd' import { Paperclip } from 'lucide-react' import { FC, useCallback, useImperativeHandle } from 'react' @@ -30,20 +31,39 @@ const AttachmentButton: FC = ({ const { t } = useTranslation() const onSelectFile = useCallback(async () => { - const _files = await window.api.file.select({ + // when the number of extensions is greater than 20, use *.* to avoid selecting window lag + const useAllFiles = extensions.length > 20 + + const _files: FileMetadata[] = await window.api.file.select({ properties: ['openFile', 'multiSelections'], filters: [ { name: 'Files', - extensions: extensions.map((i) => i.replace('.', '')) + extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', '')) } ] }) if (_files) { - setFiles([...files, ..._files]) + if (!useAllFiles) { + setFiles([...files, ..._files]) + return + } + const supportedFiles = await filterSupportedFiles(_files, extensions) + if (supportedFiles.length > 0) { + setFiles([...files, ...supportedFiles]) + } + + if (supportedFiles.length !== _files.length) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported_count', { + count: _files.length - supportedFiles.length + }) + }) + } } - }, [extensions, files, setFiles]) + }, [extensions, files, setFiles, t]) const openQuickPanel = useCallback(() => { onSelectFile() diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 82a157adba..f36af1635e 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -37,7 +37,7 @@ import { setSearching } from '@renderer/store/runtime' import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk' import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types' import type { MessageInputBaseParams } from '@renderer/types/newMessage' -import { classNames, delay, formatFileSize } from '@renderer/utils' +import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils' import { formatQuotedText } from '@renderer/utils/formats' import { getFilesFromDropEvent, @@ -585,26 +585,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setText(text + data) - const files = await getFilesFromDropEvent(e).catch((err) => { + const droppedFiles = await getFilesFromDropEvent(e).catch((err) => { logger.error('handleDrop:', err) return null }) - if (files) { - let supportedFiles = 0 - - files.forEach((file) => { - if (supportedExts.includes(file.ext)) { - setFiles((prevFiles) => [...prevFiles, file]) - supportedFiles++ - } - }) - - // 如果有文件,但都不支持 - if (files.length > 0 && supportedFiles === 0) { + if (droppedFiles) { + const supportedFiles = await filterSupportedFiles(droppedFiles, supportedExts) + supportedFiles.length > 0 && setFiles((prevFiles) => [...prevFiles, ...supportedFiles]) + if (droppedFiles.length > 0 && supportedFiles.length !== droppedFiles.length) { window.message.info({ key: 'file_not_supported', - content: t('chat.input.file_not_supported') + content: t('chat.input.file_not_supported_count', { + count: droppedFiles.length - supportedFiles.length + }) }) } } diff --git a/src/renderer/src/services/FileManager.ts b/src/renderer/src/services/FileManager.ts index f6b6945df4..4b780cfa94 100644 --- a/src/renderer/src/services/FileManager.ts +++ b/src/renderer/src/services/FileManager.ts @@ -132,7 +132,9 @@ class FileManager { } static getSafePath(file: FileMetadata) { - return this.isDangerFile(file) ? getFileDirectory(this.getFilePath(file)) : this.getFilePath(file) + // use the path from the file metadata instead + // this function is used to get path for files which are not in the filestorage + return this.isDangerFile(file) ? getFileDirectory(file.path) : file.path } static getFileUrl(file: FileMetadata) { diff --git a/src/renderer/src/services/PasteService.ts b/src/renderer/src/services/PasteService.ts index 845ec536dd..277bc9ef66 100644 --- a/src/renderer/src/services/PasteService.ts +++ b/src/renderer/src/services/PasteService.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import { FileMetadata } from '@renderer/types' -import { getFileExtension } from '@renderer/utils' +import { getFileExtension, isSupportedFile } from '@renderer/utils' const logger = loggerService.withContext('PasteService') @@ -60,6 +60,7 @@ export const handlePaste = async ( // 2. 文件/图片粘贴(仅在无文本时处理) if (event.clipboardData?.files && event.clipboardData.files.length > 0) { event.preventDefault() + const extensionSet = new Set(supportExts) try { for (const file of event.clipboardData.files) { // 使用新的API获取文件路径 @@ -90,7 +91,7 @@ export const handlePaste = async ( } // 有路径的情况 - if (supportExts.includes(getFileExtension(filePath))) { + if (await isSupportedFile(filePath, extensionSet)) { const selectedFile = await window.api.file.get(filePath) if (selectedFile) { setFiles((prevFiles) => [...prevFiles, selectedFile]) diff --git a/src/renderer/src/utils/file.ts b/src/renderer/src/utils/file.ts index 0c0f5039a6..6218e3f5c1 100644 --- a/src/renderer/src/utils/file.ts +++ b/src/renderer/src/utils/file.ts @@ -1,3 +1,4 @@ +import { FileMetadata } from '@renderer/types' import { KB, MB } from '@shared/config/constant' /** @@ -55,3 +56,30 @@ export function removeSpecialCharactersForFileName(str: string): string { .replace(/[\r\n]+/g, ' ') .trim() } + +export async function isSupportedFile(filePath: string, supportExts: Set): Promise { + try { + if (supportExts.has(getFileExtension(filePath))) { + return true + } + + if (await window.api.file.isTextFile(filePath)) { + return true + } + + return false + } catch (error) { + return false + } +} + +export async function filterSupportedFiles(files: FileMetadata[], supportExts: string[]): Promise { + const extensionSet = new Set(supportExts) + const validationResults = await Promise.all( + files.map(async (file) => ({ + file, + isValid: await isSupportedFile(file.path, extensionSet) + })) + ) + return validationResults.filter((result) => result.isValid).map((result) => result.file) +} diff --git a/yarn.lock b/yarn.lock index c0950f0a4a..b9833e0cc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8510,6 +8510,7 @@ __metadata: husky: "npm:^9.1.7" i18next: "npm:^23.11.5" iconv-lite: "npm:^0.6.3" + isbinaryfile: "npm:5.0.4" jaison: "npm:^2.0.2" jest-styled-components: "npm:^7.2.0" jsdom: "npm:26.1.0" @@ -14389,6 +14390,13 @@ __metadata: languageName: node linkType: hard +"isbinaryfile@npm:5.0.4, isbinaryfile@npm:^5.0.0": + version: 5.0.4 + resolution: "isbinaryfile@npm:5.0.4" + checksum: 10c0/fea255bfae67ff4827e8dd2238d6700d4803d02b4d892b72eeac4541487284e901251a3427966af5018d4eb29fa155b036dcb75dd217634146a072991afbc2c2 + languageName: node + linkType: hard + "isbinaryfile@npm:^4.0.8": version: 4.0.10 resolution: "isbinaryfile@npm:4.0.10" @@ -14396,13 +14404,6 @@ __metadata: languageName: node linkType: hard -"isbinaryfile@npm:^5.0.0": - version: 5.0.4 - resolution: "isbinaryfile@npm:5.0.4" - checksum: 10c0/fea255bfae67ff4827e8dd2238d6700d4803d02b4d892b72eeac4541487284e901251a3427966af5018d4eb29fa155b036dcb75dd217634146a072991afbc2c2 - languageName: node - linkType: hard - "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" From 4615e97ad5401411f3ebb76fb70be31389e97e81 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:55:11 +0800 Subject: [PATCH 27/96] fix(translate): improve auto translate language detection (#9375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(translate): 调整语言检测阈值并增加回退逻辑 当文本较短时使用LLM检测语言,较长时优先使用franc检测 当franc检测失败时回退到LLM检测 同时将LLM检测的文本长度限制从50提高到100 --- src/renderer/src/utils/translate.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index 9d6f665ea6..dbe6749e50 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -29,7 +29,15 @@ export const detectLanguage = async (inputText: string): Promise { detectedLang = text.replace(/^\s*\n+/g, '') } From 0da122281e78f3351b3019f7c3f9cc84ddaccdc1 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:39 +0800 Subject: [PATCH 28/96] fix(AttachmentButton): Add selection state to prevent repeated file selection triggering (#9379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(AttachmentButton): 添加选择状态防止重复触发文件选择 添加 selecting 状态变量以防止在文件选择过程中重复触发选择操作,避免潜在的文件选择窗口冲突 --- .../src/pages/home/Inputbar/AttachmentButton.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 7e6d583fc3..8135eecf7c 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -2,7 +2,7 @@ import { FileMetadata, FileType } from '@renderer/types' import { filterSupportedFiles } from '@renderer/utils/file' import { Tooltip } from 'antd' import { Paperclip } from 'lucide-react' -import { FC, useCallback, useImperativeHandle } from 'react' +import { FC, useCallback, useImperativeHandle, useState } from 'react' import { useTranslation } from 'react-i18next' export interface AttachmentButtonRef { @@ -29,11 +29,16 @@ const AttachmentButton: FC = ({ disabled }) => { const { t } = useTranslation() + const [selecting, setSelecting] = useState(false) const onSelectFile = useCallback(async () => { + if (selecting) { + return + } // when the number of extensions is greater than 20, use *.* to avoid selecting window lag const useAllFiles = extensions.length > 20 + setSelecting(true) const _files: FileMetadata[] = await window.api.file.select({ properties: ['openFile', 'multiSelections'], filters: [ @@ -43,6 +48,7 @@ const AttachmentButton: FC = ({ } ] }) + setSelecting(false) if (_files) { if (!useAllFiles) { @@ -63,7 +69,7 @@ const AttachmentButton: FC = ({ }) } } - }, [extensions, files, setFiles, t]) + }, [extensions, files, selecting, setFiles, t]) const openQuickPanel = useCallback(() => { onSelectFile() From 39b1332e4924fa174640a1f638e49f04a0ec8db0 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 21 Aug 2025 15:14:07 +0800 Subject: [PATCH 29/96] feat(DraggableList): add listProps support for custom list configurations - Enhanced DraggableList component to accept listProps, allowing for customization of the Ant Design List component. - Updated MCPSettings to utilize the new listProps feature, providing a custom empty state message when no servers are available. --- .../src/components/DraggableList/list.tsx | 5 +++- .../settings/MCPSettings/McpServersList.tsx | 23 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/DraggableList/list.tsx b/src/renderer/src/components/DraggableList/list.tsx index 3a2cc5b108..4452cee4c9 100644 --- a/src/renderer/src/components/DraggableList/list.tsx +++ b/src/renderer/src/components/DraggableList/list.tsx @@ -9,13 +9,14 @@ import { ResponderProvided } from '@hello-pangea/dnd' import { droppableReorder } from '@renderer/utils' -import { List } from 'antd' +import { List, ListProps } from 'antd' import { FC } from 'react' interface Props { list: T[] style?: React.CSSProperties listStyle?: React.CSSProperties + listProps?: ListProps children: (item: T, index: number) => React.ReactNode onUpdate: (list: T[]) => void onDragStart?: OnDragStartResponder @@ -28,6 +29,7 @@ const DraggableList: FC> = ({ list, style, listStyle, + listProps, droppableProps, onDragStart, onUpdate, @@ -51,6 +53,7 @@ const DraggableList: FC> = ({ {(provided) => (
{ const id = item.id || item diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index 01aa1599a2..b493714785 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -210,7 +210,21 @@ const McpServersList: FC = () => { - + + ) + } + }}> {(server: MCPServer) => (
navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}> {
)}
- {mcpServers.length === 0 && ( - - )} From 25d3b519d9f154b1ffac042e57e3db1ae1368efd Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:48:22 +0800 Subject: [PATCH 30/96] fix(translate): fix translating state management (#9387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(translate): 修复翻译状态管理逻辑 调整翻译状态设置的位置,确保在翻译开始和结束时正确更新状态 * fix(translate): 添加缺失的setTranslating属性 * fix(translate): 去除检测语言结果中的空格 检测语言返回的结果可能包含多余空格,导致后续处理出现问题。通过trim()去除前后空格确保结果干净 --- src/renderer/src/pages/translate/TranslatePage.tsx | 9 +++++---- src/renderer/src/utils/translate.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 923aedaa22..f426616a5d 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -122,8 +122,6 @@ const TranslatePage: FC = () => { return } - setTranslating(true) - let translated: string try { translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100)) @@ -145,8 +143,6 @@ const TranslatePage: FC = () => { } catch (e) { logger.error('Failed to translate', e as Error) window.message.error(t('translate.error.unknown') + ': ' + (e as Error).message) - } finally { - setTranslating(false) } }, [setTranslatedContent, setTranslating, t, translating] @@ -163,6 +159,8 @@ const TranslatePage: FC = () => { return } + setTranslating(true) + try { // 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测 let actualSourceLanguage: TranslateLanguage @@ -202,11 +200,14 @@ const TranslatePage: FC = () => { key: 'translate-message' }) return + } finally { + setTranslating(false) } }, [ bidirectionalPair, getLanguageByLangcode, isBidirectional, + setTranslating, sourceLanguage, t, targetLanguage, diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index dbe6749e50..9e7e48590b 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -49,7 +49,7 @@ export const detectLanguage = async (inputText: string): Promise => { From 1c0e29f029df09894662cb39368b2c9ba757b106 Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:58:16 +0800 Subject: [PATCH 31/96] fix: knowledge encrypted (#9385) --- src/main/knowledge/preprocess/BasePreprocessProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/knowledge/preprocess/BasePreprocessProvider.ts b/src/main/knowledge/preprocess/BasePreprocessProvider.ts index 7981e6f139..daf9901498 100644 --- a/src/main/knowledge/preprocess/BasePreprocessProvider.ts +++ b/src/main/knowledge/preprocess/BasePreprocessProvider.ts @@ -91,7 +91,7 @@ export default abstract class BasePreprocessProvider { } public async readPdf(buffer: Buffer) { - const pdfDoc = await PDFDocument.load(buffer) + const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true }) return { numPages: pdfDoc.getPageCount() } From 4191d878f294463e597de2b7dbbd095d3b01e8f7 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 21 Aug 2025 16:59:04 +0800 Subject: [PATCH 32/96] fix: do not reset citation block (#9383) * fix: do not reset citation block id * refactor: disable external websearch for mandatory websearch models * refactor: predicate * refactor: include openrouter perplexity --- src/renderer/src/config/models.ts | 15 +++++++++++++++ src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 6 +++++- .../src/pages/home/Inputbar/InputbarTools.tsx | 5 +++-- .../callbacks/citationCallbacks.ts | 5 +---- .../services/messageStreaming/callbacks/index.ts | 3 +-- .../messageStreaming/callbacks/textCallbacks.ts | 4 +--- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index a4fdcf0fb9..ac2a527a9c 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -3004,6 +3004,21 @@ export function isWebSearchModel(model: Model): boolean { return false } +export function isMandatoryWebSearchModel(model: Model): boolean { + if (!model) { + return false + } + + const provider = getProviderByModel(model) + const modelId = getLowerBaseModelName(model.id) + + if (provider.id === 'perplexity' || provider.id === 'openrouter') { + return PERPLEXITY_SEARCH_MODELS.includes(modelId) + } + + return false +} + export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index f36af1635e..e2de99df8c 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -5,6 +5,7 @@ import TranslateButton from '@renderer/components/TranslateButton' import { isGenerateImageModel, isGenerateImageModels, + isMandatoryWebSearchModel, isSupportedDisableGenerationModel, isSupportedReasoningEffortModel, isSupportedThinkingTokenModel, @@ -767,7 +768,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (!isWebSearchModel(model) && assistant.enableWebSearch) { updateAssistant({ ...assistant, enableWebSearch: false }) } - if (assistant.webSearchProviderId && !WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId)) { + if ( + assistant.webSearchProviderId && + (!WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId) || isMandatoryWebSearchModel(model)) + ) { updateAssistant({ ...assistant, webSearchProviderId: undefined }) } if (!isGenerateImageModel(model) && assistant.enableGenerateImage) { diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index 426bd8957c..0f60ef9e7f 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -1,6 +1,6 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd' import { QuickPanelListItem } from '@renderer/components/QuickPanel' -import { isGenerateImageModel } from '@renderer/config/models' +import { isGenerateImageModel, isMandatoryWebSearchModel } 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' @@ -340,7 +340,8 @@ const InputbarTools = ({ { key: 'web_search', label: t('chat.input.web_search.label'), - component: + component: , + condition: !isMandatoryWebSearchModel(model) }, { key: 'url_context', diff --git a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts index 03d0f23d48..56d0680839 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts @@ -120,9 +120,6 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => }, // 暴露给外部的方法,用于textCallbacks中获取citationBlockId - getCitationBlockId: () => citationBlockId, - setCitationBlockId: (id: string | null) => { - citationBlockId = id - } + getCitationBlockId: () => citationBlockId } } diff --git a/src/renderer/src/services/messageStreaming/callbacks/index.ts b/src/renderer/src/services/messageStreaming/callbacks/index.ts index ada8ed7b67..2b6fc5968a 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/index.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/index.ts @@ -59,8 +59,7 @@ export const createCallbacks = (deps: CallbacksDependencies) => { blockManager, getState, assistantMsgId, - getCitationBlockId: citationCallbacks.getCitationBlockId, - setCitationBlockId: citationCallbacks.setCitationBlockId + getCitationBlockId: citationCallbacks.getCitationBlockId }) // 组合所有回调 diff --git a/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts index 53621b625f..657cf6f0f3 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts @@ -12,11 +12,10 @@ interface TextCallbacksDependencies { getState: any assistantMsgId: string getCitationBlockId: () => string | null - setCitationBlockId: (id: string | null) => void } export const createTextCallbacks = (deps: TextCallbacksDependencies) => { - const { blockManager, getState, assistantMsgId, getCitationBlockId, setCitationBlockId } = deps + const { blockManager, getState, assistantMsgId, getCitationBlockId } = deps // 内部维护的状态 let mainTextBlockId: string | null = null @@ -63,7 +62,6 @@ export const createTextCallbacks = (deps: TextCallbacksDependencies) => { } blockManager.smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT, true) mainTextBlockId = null - setCitationBlockId(null) } else { logger.warn( `[onTextComplete] Received text.complete but last block was not MAIN_TEXT (was ${blockManager.lastBlockType}) or lastBlockId is null.` From a2d24a5cdac5557861cb15c8c9ae23af5966a792 Mon Sep 17 00:00:00 2001 From: Yuhang <190720896+YuhangHere@users.noreply.github.com> Date: Thu, 21 Aug 2025 19:54:48 +0800 Subject: [PATCH 33/96] fix: incorrect default avatar casing in custom provider (#9384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: incorrect default avatar casing in custom provider * add background color to default avatar in custom provider distinction among providers. * set ProviderInitialsLogo text color to white 添加完背景色后发现,模型列表中默认头像字体始终为白色,而编辑提供商时默认头像字体颜色会随主题色而变,黑色字体某些背景色下不清晰(比如a),所以改成始终白色 * fix: default avatar fallback when no text is entered -设置背景色后发现,未输入文本时的背景色是根据上一个背景色继续保持的,该情况下回退到默认背景颜色 -回退后白色字体又看不清,该情况下字体颜色回退到黑色 -最终效果就是未输入文本时显示的默认头像回退到与之前一致 --- .../settings/ProviderSettings/AddProviderPopup.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index c8575d67ff..4a845521d9 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -3,7 +3,7 @@ import { Center, VStack } from '@renderer/components/Layout' import { TopView } from '@renderer/components/TopView' import ImageStorage from '@renderer/services/ImageStorage' import { Provider, ProviderType } from '@renderer/types' -import { compressImage } from '@renderer/utils' +import { compressImage, generateColorFromChar } from '@renderer/utils' import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -78,7 +78,7 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { } const getInitials = () => { - return name.charAt(0).toUpperCase() || 'P' + return name.charAt(0) || 'P' } const items = [ @@ -171,7 +171,14 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { onOpenChange={(visible) => { setDropdownOpen(visible) }}> - {logo ? : {getInitials()}} + {logo ? ( + + ) : ( + + {getInitials()} + + )} @@ -236,6 +243,7 @@ const ProviderInitialsLogo = styled.div` transition: opacity 0.3s ease; background-color: var(--color-background-soft); border: 0.5px solid var(--color-border); + color: white; &:hover { opacity: 0.8; } From 9c2a88179bc3adeff7506da6d9e6a83927aab55d Mon Sep 17 00:00:00 2001 From: one Date: Thu, 21 Aug 2025 23:43:12 +0800 Subject: [PATCH 34/96] refactor: increase dropdown menu maxHeight (#9279) --- src/renderer/src/assets/styles/ant.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 9ebc658010..227f2d84fa 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -76,7 +76,7 @@ } .ant-dropdown-menu .ant-dropdown-menu-sub { - max-height: 50vh; + max-height: 80vh; width: max-content; overflow-y: auto; overflow-x: hidden; @@ -88,7 +88,7 @@ border-radius: var(--ant-border-radius-lg); user-select: none; .ant-dropdown-menu { - max-height: 50vh; + max-height: 80vh; overflow-y: auto; border: 0.5px solid var(--color-border); From daaf685c9ef64d6ef24486b8babd8acb071f8837 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 23:46:30 +0800 Subject: [PATCH 35/96] feat(TopicsTab): double click topic name to edit (#9382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(TopicsTab): 添加双击话题名称开始编辑功能 * feat(话题标签): 添加通过弹窗重命名话题的功能 * refactor(TopicsTab): 移除未使用的topicEdit参数 * style(TopicsTab): 调整主题名称容器的样式和输入框边框 移除主题编辑输入框的边框和阴影效果,并设置固定高度 * feat(i18n): 添加话题重命名提示文本并支持在弹窗中显示 为话题编辑功能添加多语言提示文本,说明双击可快速重命名 在PromptPopup组件中新增extraNode属性以支持显示额外提示信息 * docs(i18n): 为话题重命名提示添加"提示"前缀 --- .../src/components/Popups/PromptPopup.tsx | 5 ++- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/ja-jp.json | 3 +- src/renderer/src/i18n/locales/ru-ru.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 3 +- src/renderer/src/i18n/locales/zh-tw.json | 3 +- src/renderer/src/i18n/translate/el-gr.json | 3 +- src/renderer/src/i18n/translate/es-es.json | 3 +- src/renderer/src/i18n/translate/fr-fr.json | 3 +- src/renderer/src/i18n/translate/pt-pt.json | 3 +- .../src/pages/home/Tabs/TopicsTab.tsx | 35 ++++++++++++------- 11 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx index 9d8b9ae752..0d254d3fb9 100644 --- a/src/renderer/src/components/Popups/PromptPopup.tsx +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -1,6 +1,6 @@ import { Input, Modal } from 'antd' import { TextAreaProps } from 'antd/es/input' -import { useRef, useState } from 'react' +import { ReactNode, useRef, useState } from 'react' import { Box } from '../Layout' import { TopView } from '../TopView' @@ -11,6 +11,7 @@ interface PromptPopupShowParams { defaultValue?: string inputPlaceholder?: string inputProps?: TextAreaProps + extraNode?: ReactNode } interface Props extends PromptPopupShowParams { @@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC = ({ defaultValue = '', inputPlaceholder = '', inputProps = {}, + extraNode = null, resolve }) => { const [value, setValue] = useState(defaultValue) @@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC = ({ rows={1} {...inputProps} /> + {extraNode} ) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 1841716293..8f05b3d2d9 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "Enter new name", - "title": "Edit Name" + "title": "Edit Name", + "title_tip": "Tips: Double-click the topic name to rename it directly in place" }, "export": { "image": "Export as image", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5842f6b24b..9f5f782ea7 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "新しい名前を入力", - "title": "名前を編集" + "title": "名前を編集", + "title_tip": "ヒント: トピック名をダブルクリックすると、直接その場で名前を変更できます" }, "export": { "image": "画像としてエクスポート", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d1a8fff8e5..4896b85fee 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "Введите новый заголовок", - "title": "Редактировать заголовок" + "title": "Редактировать заголовок", + "title_tip": "Совет: двойной щелчок по названию темы позволяет переименовать её на месте" }, "export": { "image": "Экспорт как изображение", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 812d182c94..87f50debf0 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "输入新名称", - "title": "编辑话题名" + "title": "编辑话题名", + "title_tip": "提示: 双击话题名可以直接就地重命名" }, "export": { "image": "导出为图片", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 5b0bc186ca..f92c27f973 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "輸入新名稱", - "title": "編輯名稱" + "title": "編輯名稱", + "title_tip": "提示:雙擊話題名可以直接就地重新命名" }, "export": { "image": "匯出為圖片", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 05c1efa2cf..9cbbda52c1 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "Εισαγάγετε το νέο όνομα", - "title": "Επεξεργασία ονόματος θέματος" + "title": "Επεξεργασία ονόματος θέματος", + "title_tip": "Συμβουλές: Διπλό κλικ στο όνομα του θέματος για να το μετονομάσετε απευθείας" }, "export": { "image": "Εξαγωγή ως εικόνα", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index d63cda4c58..25e186c567 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "Introduce nuevo nombre", - "title": "Editar nombre del tema" + "title": "Editar nombre del tema", + "title_tip": "Consejos: hacer doble clic en el nombre del tema permite cambiar el nombre directamente en el lugar" }, "export": { "image": "Exportar como imagen", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index a7575df024..266985c94b 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "Entrez un nouveau nom", - "title": "Modifier le nom du sujet" + "title": "Modifier le nom du sujet", + "title_tip": "Conseil : double-cliquez sur le nom du sujet pour le renommer directement sur place" }, "export": { "image": "Exporter sous forme d'image", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 3234ad8215..def1ee8900 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -584,7 +584,8 @@ }, "edit": { "placeholder": "Digite novo nome", - "title": "Editar nome do tópico" + "title": "Editar nome do tópico", + "title_tip": "Dicas: Clique duas vezes no nome do tópico para renomeá-lo diretamente no local" }, "export": { "image": "Exportar como imagem", diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 52d442b5ae..e5e31b6d63 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -222,9 +222,19 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, key: 'rename', icon: , disabled: isRenaming(topic.id), - onClick() { - setEditingTopicId(topic.id) - topicEdit.startEdit(topic.name) + async onClick() { + const name = await PromptPopup.show({ + title: t('chat.topics.edit.title'), + message: '', + defaultValue: topic?.name || '', + extraNode: ( +
{t('chat.topics.edit.title_tip')}
+ ) + }) + if (name && topic?.name !== name) { + const updatedTopic = { ...topic, name, isNameManuallyEdited: true } + updateTopic(updatedTopic) + } } }, { @@ -448,7 +458,6 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, assistants, assistant, updateTopic, - topicEdit, activeTopic.id, setActiveTopic, onPinTopic, @@ -519,7 +528,13 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, onClick={(e) => e.stopPropagation()} /> ) : ( - + { + setEditingTopicId(topic.id) + topicEdit.startEdit(topic.name) + }}> {topicName} )} @@ -622,6 +637,7 @@ const TopicNameContainer = styled.div` flex-direction: row; align-items: center; gap: 4px; + height: 20px; justify-content: space-between; ` @@ -675,19 +691,14 @@ const TopicName = styled.div` const TopicEditInput = styled.input` background: var(--color-background); - border: 1px solid var(--color-border); - border-radius: 4px; + border: none; color: var(--color-text-1); font-size: 13px; font-family: inherit; padding: 2px 6px; width: 100%; outline: none; - - &:focus { - border-color: var(--color-primary); - box-shadow: 0 0 0 2px var(--color-primary-alpha); - } + padding: 0; ` const PendingIndicator = styled.div.attrs({ From c7dcbdcb5b336c089a89f4b5bf86be4dc54a13a7 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 21 Aug 2025 23:51:36 +0800 Subject: [PATCH 36/96] fix: gpt-oss should support temperature and topP (#9390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复OpenAI推理模型温度控制判断逻辑 添加isOpenAIOpenWeightModel检查以排除开源权重模型 * fix(openai): 修正开发者角色设置条件逻辑 修改系统消息中开发者角色设置的条件判断,增加对OpenAIOpenWeightModel的检查 --- .../src/aiCore/clients/openai/OpenAIApiClient.ts | 13 +++++++------ .../clients/openai/OpenAIResponseAPIClient.ts | 13 +++++++------ src/renderer/src/config/models.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index e9133400dc..4808d8a4e9 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -11,6 +11,7 @@ import { isGPT5SeriesModel, isGrokReasoningModel, isNotSupportSystemMessageModel, + isOpenAIOpenWeightModel, isOpenAIReasoningModel, isQwenAlwaysThinkModel, isQwenMTModel, @@ -531,12 +532,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // 1. 处理系统消息 const systemMessage = { role: 'system', content: assistant.prompt || '' } - if (isSupportedReasoningEffortOpenAIModel(model)) { - if (isSupportDeveloperRoleProvider(this.provider)) { - systemMessage.role = 'developer' - } else { - systemMessage.role = 'system' - } + if ( + isSupportedReasoningEffortOpenAIModel(model) && + isSupportDeveloperRoleProvider(this.provider) && + !isOpenAIOpenWeightModel(model) + ) { + systemMessage.role = 'developer' } if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 10a2ee7bbe..36666fcaf2 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -5,6 +5,7 @@ import { isGPT5SeriesModel, isOpenAIChatCompletionOnlyModel, isOpenAILLMModel, + isOpenAIOpenWeightModel, isSupportedReasoningEffortOpenAIModel, isSupportVerbosityModel, isVisionModel @@ -374,12 +375,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< text: assistant.prompt || '', type: 'input_text' } - if (isSupportedReasoningEffortOpenAIModel(model)) { - if (isSupportDeveloperRoleProvider(this.provider)) { - systemMessage.role = 'developer' - } else { - systemMessage.role = 'system' - } + if ( + isSupportedReasoningEffortOpenAIModel(model) && + isSupportDeveloperRoleProvider(this.provider) && + isOpenAIOpenWeightModel(model) + ) { + systemMessage.role = 'developer' } // 2. 设置工具 diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index ac2a527a9c..9ee608b1b0 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2909,7 +2909,11 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean { return true } - if (isOpenAIReasoningModel(model) || isOpenAIChatCompletionOnlyModel(model) || isQwenMTModel(model)) { + if ( + (isOpenAIReasoningModel(model) && !isOpenAIOpenWeightModel(model)) || + isOpenAIChatCompletionOnlyModel(model) || + isQwenMTModel(model) + ) { return true } @@ -3254,5 +3258,10 @@ export const isGPT5SeriesModel = (model: Model) => { return modelId.includes('gpt-5') } +export const isOpenAIOpenWeightModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-oss') +} + // zhipu 视觉推理模型用这组 special token 标记推理结果 export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const From 44b2d09e63b85284c0ce44fb8d6e6caed34e7298 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Fri, 22 Aug 2025 00:05:04 +0800 Subject: [PATCH 37/96] fix: throw error when translate language detection failed (#9393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(ApiService): 为语言检测函数添加详细注释并修改错误处理 移除冗余的try-catch块,改为依赖shouldThrow参数控制错误抛出 * fix(翻译动作): 添加语言检测错误处理 捕获语言检测时的异常并记录错误日志,防止未处理的异常导致应用崩溃 * docs(ApiService): 修正语言检测函数的返回注释说明 原注释说明检测失败会返回空字符串,实际实现会抛出错误,修正注释以反映实际行为 * fix: 移除语言检测中多余的或空字符串检查 --- src/renderer/src/services/ApiService.ts | 15 ++++++++++----- .../action/components/ActionTranslate.tsx | 12 ++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index c01769ae84..e6d2c5655c 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -615,6 +615,14 @@ interface FetchLanguageDetectionProps { onResponse?: (text: string, isComplete: boolean) => void } +/** + * 检测文本语言 + * @param params - 参数对象 + * @param {string} params.text - 需要检测语言的文本内容 + * @param {function} [params.onResponse] - 流式响应回调函数,用于实时获取检测结果 + * @returns {Promise} 返回检测到的语言代码,如果检测失败会抛出错误 + * @throws {Error} + */ export async function fetchLanguageDetection({ text, onResponse }: FetchLanguageDetectionProps) { const translateLanguageOptions = await getTranslateOptions() const listLang = translateLanguageOptions.map((item) => item.langCode) @@ -661,16 +669,13 @@ export async function fetchLanguageDetection({ text, onResponse }: FetchLanguage assistant, streamOutput: stream, enableReasoning: false, + shouldThrow: true, onResponse } const AI = new AiProvider(provider) - try { - return (await AI.completions(params)).getText() || '' - } catch (error: any) { - return '' - } + return (await AI.completions(params)).getText() } export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) { diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index d50b2fef61..9dd9cbeb40 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -9,7 +9,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import useTranslate from '@renderer/hooks/useTranslate' import MessageContent from '@renderer/pages/home/Messages/MessageContent' import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' -import { Assistant, Topic, TranslateLanguage } from '@renderer/types' +import { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types' import type { ActionItem } from '@renderer/types/selectionTypes' import { runAsyncFunction } from '@renderer/utils' import { abortCompletion } from '@renderer/utils/abortController' @@ -114,7 +114,15 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { setIsLoading(true) - const sourceLanguageCode = await detectLanguage(action.selectedText) + let sourceLanguageCode: TranslateLanguageCode + + try { + sourceLanguageCode = await detectLanguage(action.selectedText) + } catch (err) { + onError(err instanceof Error ? err : new Error('An error occurred')) + logger.error('Error detecting language:', err as Error) + return + } let translateLang: TranslateLanguage From cd1b0e01a0b3c5cc1c3704dee0500e34cc29fc1d Mon Sep 17 00:00:00 2001 From: one Date: Fri, 22 Aug 2025 09:09:21 +0800 Subject: [PATCH 38/96] fix: add provider check in isMandatoryWebSearchModel (#9398) * fix: add provider check in isMandatoryWebSearchModel * Fix: Add provider check in isMandatoryWebSearchModel The isMandatoryWebSearchModel function was throwing an error when the provider was undefined. This change adds a check to ensure the provider exists before accessing its properties, similar to how it's handled in isWebSearchModel. The position of the check has also been moved to be between the provider and modelId initializations for better code flow. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- src/renderer/src/config/models.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 9ee608b1b0..5425c918db 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -3014,6 +3014,11 @@ export function isMandatoryWebSearchModel(model: Model): boolean { } const provider = getProviderByModel(model) + + if (!provider) { + return false + } + const modelId = getLowerBaseModelName(model.id) if (provider.id === 'perplexity' || provider.id === 'openrouter') { From 76c025d53b164b9fcb06e91b0e5ec9d37ff1ded2 Mon Sep 17 00:00:00 2001 From: Yuhang <190720896+YuhangHere@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:42:24 +0800 Subject: [PATCH 39/96] Feat/add built-in provider avatar options when adding a provider (#9350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add 'builtin avatar' option to avatar dropdown -Introduces a new 'builtin avatar' option to the avatar selection dropdown in AddProviderPopup. -Updates i18n translation files for all supported languages to include the 'builtin' avatar label. Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com> * Add provider logo picker for builtin avatar selection -Introduces a ProviderLogoPicker component for selecting a builtin provider logo as an avatar in AddProviderPopup. -Updates provider logo handling in ProviderSettings.(If deleting the logoFile caused any issues, I sincerely apologize.) Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com> * Adjust ProviderLogoPicker layout dimensions and grid Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com> * Fix ProviderLogoPicker popover trigger behavior Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com> * Merge branch 'main' into feat/add-builtin-provider-avatars * Update index.tsx --------- Signed-off-by: Yuhang <190720896+YuhangHere@users.noreply.github.com> --- .../components/ProviderLogoPicker/index.tsx | 113 ++++++++++++++++++ src/renderer/src/config/providers.ts | 2 +- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../ProviderSettings/AddProviderPopup.tsx | 69 +++++++++-- .../pages/settings/ProviderSettings/index.tsx | 2 +- 9 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/ProviderLogoPicker/index.tsx diff --git a/src/renderer/src/components/ProviderLogoPicker/index.tsx b/src/renderer/src/components/ProviderLogoPicker/index.tsx new file mode 100644 index 0000000000..b18b2b47d7 --- /dev/null +++ b/src/renderer/src/components/ProviderLogoPicker/index.tsx @@ -0,0 +1,113 @@ +import { SearchOutlined } from '@ant-design/icons' +import { PROVIDER_LOGO_MAP } from '@renderer/config/providers' +import { getProviderLabel } from '@renderer/i18n/label' +import { Input, Tooltip } from 'antd' +import { FC, useMemo, useState } from 'react' +import styled from 'styled-components' + +interface Props { + onProviderClick: (providerId: string) => void +} + +// 用于选择内置头像的提供商Logo选择器组件 +const ProviderLogoPicker: FC = ({ onProviderClick }) => { + const [searchText, setSearchText] = useState('') + + const filteredProviders = useMemo(() => { + const providers = Object.entries(PROVIDER_LOGO_MAP).map(([id, logo]) => ({ + id, + logo, + name: getProviderLabel(id) + })) + + if (!searchText) return providers + + const searchLower = searchText.toLowerCase() + return providers.filter((p) => p.name.toLowerCase().includes(searchLower)) + }, [searchText]) + + const handleProviderClick = (event: React.MouseEvent, providerId: string) => { + event.stopPropagation() + onProviderClick(providerId) + } + + return ( + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + size="small" + allowClear + style={{ + borderRadius: 'var(--list-item-border-radius)', + background: 'var(--color-background-soft)' + }} + /> + + + {filteredProviders.map(({ id, logo, name }) => ( + + handleProviderClick(e, id)}> + {name} + + + ))} + + + ) +} + +const Container = styled.div` + width: 350px; + max-height: 300px; + display: flex; + flex-direction: column; + padding: 12px; + background: var(--color-background); + border-radius: 8px; +` + +const SearchContainer = styled.div` + margin-bottom: 12px; +` + +const LogoGrid = styled.div` + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; + overflow-y: auto; + flex: 1; + padding: 4px; +` + +const LogoItem = styled.div` + width: 52px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s ease; + background: var(--color-background-soft); + border: 0.5px solid var(--color-border); + + &:hover { + background: var(--color-background-mute); + transform: scale(1.05); + border-color: var(--color-primary); + } + + img { + width: 32px; + height: 32px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + } +` + +export default ProviderLogoPicker diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 2e9bb483f8..4b429dd1c0 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -594,7 +594,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = export const SYSTEM_PROVIDERS: SystemProvider[] = Object.values(SYSTEM_PROVIDERS_CONFIG) -const PROVIDER_LOGO_MAP: AtLeast = { +export const PROVIDER_LOGO_MAP: AtLeast = { ph8: Ph8ProviderLogo, '302ai': Ai302ProviderLogo, openai: OpenAiProviderLogo, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8f05b3d2d9..a7a52d1ac3 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2679,7 +2679,8 @@ "title": "Auto Update" }, "avatar": { - "reset": "Reset Avatar" + "builtin": "Builtin avatar", + "reset": "Reset avatar" }, "backup": { "button": "Backup", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9f5f782ea7..953213fdbf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2679,6 +2679,7 @@ "title": "自動更新" }, "avatar": { + "builtin": "内蔵アバター", "reset": "アバターをリセット" }, "backup": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 4896b85fee..a251854c34 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2679,6 +2679,7 @@ "title": "Автоматическое обновление" }, "avatar": { + "builtin": "Встроенный аватар", "reset": "Сброс аватара" }, "backup": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 87f50debf0..8b55525a9c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2679,6 +2679,7 @@ "title": "自动更新" }, "avatar": { + "builtin": "内置头像", "reset": "重置头像" }, "backup": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f92c27f973..2e2babab9c 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2679,6 +2679,7 @@ "title": "自動更新" }, "avatar": { + "builtin": "內置頭像", "reset": "重設頭像" }, "backup": { diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index 4a845521d9..16060ba7c1 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -1,10 +1,12 @@ import { loggerService } from '@logger' import { Center, VStack } from '@renderer/components/Layout' +import ProviderLogoPicker from '@renderer/components/ProviderLogoPicker' import { TopView } from '@renderer/components/TopView' +import { PROVIDER_LOGO_MAP } from '@renderer/config/providers' import ImageStorage from '@renderer/services/ImageStorage' import { Provider, ProviderType } from '@renderer/types' import { compressImage, generateColorFromChar } from '@renderer/utils' -import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd' +import { Divider, Dropdown, Form, Input, Modal, Popover, Select, Upload } from 'antd' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -21,6 +23,7 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { const [name, setName] = useState(provider?.name || '') const [type, setType] = useState(provider?.type || 'openai') const [logo, setLogo] = useState(null) + const [logoPickerOpen, setLogoPickerOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() @@ -63,6 +66,25 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { const buttonDisabled = name.length === 0 + // 处理内置头像的点击事件 + const handleProviderLogoClick = async (providerId: string) => { + try { + const logoUrl = PROVIDER_LOGO_MAP[providerId] + + if (provider?.id) { + await ImageStorage.set(`provider-${provider.id}`, logoUrl) + const savedLogo = await ImageStorage.get(`provider-${provider.id}`) + setLogo(savedLogo) + } else { + setLogo(logoUrl) + } + + setLogoPickerOpen(false) + } catch (error: any) { + window.message.error(error.message) + } + } + const handleReset = async () => { try { setLogo(null) @@ -131,6 +153,20 @@ const PopupContainer: React.FC = ({ provider, resolve }) => {
) }, + { + key: 'builtin', + label: ( +
{ + e.stopPropagation() + setDropdownOpen(false) + setLogoPickerOpen(true) + }}> + {t('settings.general.avatar.builtin')} +
+ ) + }, { key: 'reset', label: ( @@ -170,15 +206,30 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { placement="bottom" onOpenChange={(visible) => { setDropdownOpen(visible) + if (visible) { + setLogoPickerOpen(false) + } }}> - {logo ? ( - - ) : ( - - {getInitials()} - - )} + } + trigger="click" + open={logoPickerOpen} + onOpenChange={(visible) => { + setLogoPickerOpen(visible) + if (visible) { + setDropdownOpen(false) + } + }} + placement="bottom"> + {logo ? ( + + ) : ( + + {getInitials()} + + )} +
diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 5fdfaecd64..86207ccc57 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -327,7 +327,7 @@ const ProvidersList: FC = () => { if (name) { updateProvider({ ...provider, name, type }) if (provider.id) { - if (logoFile && logo) { + if (logo) { try { await ImageStorage.set(`provider-${provider.id}`, logo) setProviderLogos((prev) => ({ From b4a3a483e95f916cbc47d72244ba6fa174fc35e1 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 22 Aug 2025 12:30:07 +0800 Subject: [PATCH 40/96] fix: change title bar overlay color for windows (#9407) * fix: update titleBarOverlayDark color for improved visibility * refactor: import isDev and isWin constants for cleaner configuration --- src/main/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/config.ts b/src/main/config.ts index c676823b89..dce2013199 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1,7 +1,7 @@ import { app } from 'electron' import { getDataPath } from './utils' -const isDev = process.env.NODE_ENV === 'development' +import { isWin, isDev } from '@main/constant' if (isDev) { app.setPath('userData', app.getPath('userData') + 'Dev') @@ -11,7 +11,7 @@ export const DATA_PATH = getDataPath() export const titleBarOverlayDark = { height: 42, - color: 'rgba(255,255,255,0)', + color: isWin ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0)', symbolColor: '#fff' } From 3501d377f61b8b136b1de71c6f3b52dd1ac3df89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Fri, 22 Aug 2025 12:42:27 +0800 Subject: [PATCH 41/96] =?UTF-8?q?refactor(CodeToolsPage):=20streamline=20C?= =?UTF-8?q?LI=20tool=20management=20and=20enhance=20p=E2=80=A6=20(#9386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(CodeToolsPage): streamline CLI tool management and enhance provider filtering logic - Removed hardcoded CLI tool options and supported providers, replacing them with imported constants for better maintainability. - Optimized provider filtering to include additional providers for Claude and Gemini tools. - Updated environment variable handling for CLI tools to utilize a centralized API base URL function. * refactor(CodeToolsPage): enhance CLI tool management and environment variable handling - Updated provider filtering logic to utilize a centralized mapping for CLI tools, improving maintainability and extensibility. - Refactored environment variable generation and parsing to streamline the launch process for different CLI tools. - Simplified state management for tool selection and directory handling, enhancing code clarity. --- src/renderer/src/pages/code/CodeToolsPage.tsx | 199 ++++++------------ src/renderer/src/pages/code/index.ts | 134 ++++++++++++ 2 files changed, 198 insertions(+), 135 deletions(-) diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index 115dab30ef..2e6c87b7ca 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -11,22 +11,19 @@ import { getModelUniqId } from '@renderer/services/ModelService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled } from '@renderer/store/mcp' import { Model } from '@renderer/types' -import { codeTools } from '@shared/config/constant' import { Alert, Button, Checkbox, Input, Select, Space } from 'antd' import { Download, Terminal, X } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -// CLI 工具选项 -const CLI_TOOLS = [ - { value: codeTools.qwenCode, label: 'Qwen Code' }, - { value: codeTools.claudeCode, label: 'Claude Code' }, - { value: codeTools.geminiCli, label: 'Gemini CLI' }, - { value: codeTools.openaiCodex, label: 'OpenAI Codex' } -] - -const SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api'] +import { + CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS, + CLI_TOOL_PROVIDER_MAP, + CLI_TOOLS, + generateToolEnvironment, + parseEnvironmentVariables +} from '.' const logger = loggerService.withContext('CodeToolsPage') @@ -51,25 +48,17 @@ const CodeToolsPage: FC = () => { } = useCodeTools() const { setTimeoutTimer } = useTimer() - // 状态管理 const [isLaunching, setIsLaunching] = useState(false) const [isInstallingBun, setIsInstallingBun] = useState(false) const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false) - const handleCliToolChange = (value: codeTools) => setCliTool(value) - - const openAiCompatibleProviders = providers.filter((p) => p.type.includes('openai')) - const openAiProviders = providers.filter((p) => p.id === 'openai') - const geminiProviders = providers.filter((p) => p.type === 'gemini' || SUPPORTED_PROVIDERS.includes(p.id)) - const claudeProviders = providers.filter((p) => p.type === 'anthropic' || SUPPORTED_PROVIDERS.includes(p.id)) - const modelPredicate = useCallback( (m: Model) => { if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) { return false } if (selectedCliTool === 'claude-code') { - return m.id.includes('claude') + return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider) } if (selectedCliTool === 'gemini-cli') { return m.id.includes('gemini') @@ -80,20 +69,9 @@ const CodeToolsPage: FC = () => { ) const availableProviders = useMemo(() => { - if (selectedCliTool === codeTools.claudeCode) { - return claudeProviders - } - if (selectedCliTool === codeTools.geminiCli) { - return geminiProviders - } - if (selectedCliTool === codeTools.qwenCode) { - return openAiCompatibleProviders - } - if (selectedCliTool === codeTools.openaiCodex) { - return openAiProviders - } - return [] - }, [claudeProviders, geminiProviders, openAiCompatibleProviders, openAiProviders, selectedCliTool]) + const filterFn = CLI_TOOL_PROVIDER_MAP[selectedCliTool] + return filterFn ? filterFn(providers) : [] + }, [providers, selectedCliTool]) const handleModelChange = (value: string) => { if (!value) { @@ -111,25 +89,6 @@ const CodeToolsPage: FC = () => { } } - // 处理环境变量更改 - const handleEnvVarsChange = (e: React.ChangeEvent) => { - setEnvVars(e.target.value) - } - - // 处理文件夹选择 - const handleFolderSelect = async () => { - try { - await selectFolder() - } catch (error) { - logger.error('选择文件夹失败:', error as Error) - } - } - - // 处理目录选择 - const handleDirectoryChange = (value: string) => { - setCurrentDir(value) - } - // 处理删除目录 const handleRemoveDirectory = (directory: string, e: React.MouseEvent) => { e.stopPropagation() @@ -170,104 +129,74 @@ const CodeToolsPage: FC = () => { } } - // 处理启动 - const handleLaunch = async () => { + // 验证启动条件 + const validateLaunch = (): { isValid: boolean; message?: string } => { if (!canLaunch || !isBunInstalled) { - if (!isBunInstalled) { - window.message.warning({ - content: t('code.launch.bun_required'), - key: 'code-launch-message' - }) - } else { - window.message.warning({ - content: t('code.launch.validation_error'), - key: 'code-launch-message' - }) + return { + isValid: false, + message: !isBunInstalled ? t('code.launch.bun_required') : t('code.launch.validation_error') } - return } - setIsLaunching(true) - if (!selectedModel) { - window.message.error({ - content: t('code.model_required'), - key: 'code-launch-message' - }) - return + return { isValid: false, message: t('code.model_required') } } + return { isValid: true } + } + + // 准备启动环境 + const prepareLaunchEnvironment = async (): Promise | null> => { + if (!selectedModel) return null + const modelProvider = getProviderByModel(selectedModel) const aiProvider = new AiProvider(modelProvider) const baseUrl = await aiProvider.getBaseURL() const apiKey = await aiProvider.getApiKey() - let env: Record = {} - if (selectedCliTool === codeTools.claudeCode) { - env = { - ANTHROPIC_API_KEY: apiKey, - ANTHROPIC_BASE_URL: modelProvider.apiHost, - ANTHROPIC_MODEL: selectedModel.id - } + // 生成工具特定的环境变量 + const toolEnv = generateToolEnvironment({ + tool: selectedCliTool, + model: selectedModel, + modelProvider, + apiKey, + baseUrl + }) + + // 合并用户自定义的环境变量 + const userEnv = parseEnvironmentVariables(environmentVariables) + + return { ...toolEnv, ...userEnv } + } + + // 执行启动操作 + const executeLaunch = async (env: Record) => { + window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { autoUpdateToLatest }) + window.message.success({ content: t('code.launch.success'), key: 'code-launch-message' }) + } + + // 处理启动 + const handleLaunch = async () => { + const validation = validateLaunch() + + if (!validation.isValid) { + window.message.warning({ content: validation.message, key: 'code-launch-message' }) + return } - if (selectedCliTool === codeTools.geminiCli) { - const apiSuffix = modelProvider.id === 'aihubmix' ? '/gemini' : '' - const apiBaseUrl = modelProvider.apiHost + apiSuffix - env = { - GEMINI_API_KEY: apiKey, - GEMINI_BASE_URL: apiBaseUrl, - GOOGLE_GEMINI_BASE_URL: apiBaseUrl, - GEMINI_MODEL: selectedModel.id - } - } - - if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.openaiCodex) { - env = { - OPENAI_API_KEY: apiKey, - OPENAI_BASE_URL: baseUrl, - OPENAI_MODEL: selectedModel.id - } - } - - // 解析用户自定义的环境变量 - if (environmentVariables) { - const lines = environmentVariables.split('\n') - for (const line of lines) { - const trimmedLine = line.trim() - if (trimmedLine && trimmedLine.includes('=')) { - const [key, ...valueParts] = trimmedLine.split('=') - const trimmedKey = key.trim() - const value = valueParts.join('=').trim() - if (trimmedKey) { - env[trimmedKey] = value - } - } - } - } + setIsLaunching(true) try { - // 这里可以添加实际的启动逻辑 - logger.info('启动配置:', { - cliTool: selectedCliTool, - model: selectedModel, - folder: currentDirectory - }) + const env = await prepareLaunchEnvironment() + if (!env) { + window.message.error({ content: t('code.model_required'), key: 'code-launch-message' }) + return + } - window.api.codeTools.run(selectedCliTool, selectedModel?.id, currentDirectory, env, { - autoUpdateToLatest - }) - - window.message.success({ - content: t('code.launch.success'), - key: 'code-launch-message' - }) + await executeLaunch(env) } catch (error) { logger.error('启动失败:', error as Error) - window.message.error({ - content: t('code.launch.error'), - key: 'code-launch-message' - }) + window.message.error({ content: t('code.launch.error'), key: 'code-launch-message' }) } finally { setIsLaunching(false) } @@ -320,7 +249,7 @@ const CodeToolsPage: FC = () => { style={{ width: '100%' }} placeholder={t('code.cli_tool_placeholder')} value={selectedCliTool} - onChange={handleCliToolChange} + onChange={setCliTool} options={CLI_TOOLS} /> @@ -345,7 +274,7 @@ const CodeToolsPage: FC = () => { style={{ flex: 1, width: 480 }} placeholder={t('code.folder_placeholder')} value={currentDirectory || undefined} - onChange={handleDirectoryChange} + onChange={setCurrentDir} allowClear showSearch filterOption={(input, option) => { @@ -366,7 +295,7 @@ const CodeToolsPage: FC = () => { ) }))} /> - @@ -377,7 +306,7 @@ const CodeToolsPage: FC = () => { setEnvVars(e.target.value)} rows={2} style={{ fontFamily: 'monospace' }} /> diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 434a3f0a45..c94e8bc867 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -1 +1,135 @@ +import { EndpointType, Model, Provider } from '@renderer/types' +import { codeTools } from '@shared/config/constant' + +export interface LaunchValidationResult { + isValid: boolean + message?: string +} + +export interface ToolEnvironmentConfig { + tool: codeTools + model: Model + modelProvider: Provider + apiKey: string + baseUrl: string +} + +// CLI 工具选项 +export const CLI_TOOLS = [ + { value: codeTools.qwenCode, label: 'Qwen Code' }, + { value: codeTools.claudeCode, label: 'Claude Code' }, + { value: codeTools.geminiCli, label: 'Gemini CLI' }, + { value: codeTools.openaiCodex, label: 'OpenAI Codex' } +] + +export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api'] +export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu'] +export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS] + +// Provider 过滤映射 +export const CLI_TOOL_PROVIDER_MAP: Record Provider[]> = { + [codeTools.claudeCode]: (providers) => + providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.geminiCli]: (providers) => + providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')), + [codeTools.openaiCodex]: (providers) => providers.filter((p) => p.id === 'openai') +} + +export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { + const CODE_TOOLS_API_ENDPOINTS = { + aihubmix: { + gemini: { + api_base_url: 'https://api.aihubmix.com/gemini' + } + }, + deepseek: { + anthropic: { + api_base_url: 'https://api.deepseek.com/anthropic' + } + }, + moonshot: { + anthropic: { + api_base_url: 'https://api.moonshot.cn/anthropic' + } + }, + zhipu: { + anthropic: { + api_base_url: 'https://open.bigmodel.cn/api/anthropic' + } + } + } + + const provider = model.provider + + return CODE_TOOLS_API_ENDPOINTS[provider]?.[type]?.api_base_url +} + +// 解析环境变量字符串为对象 +export const parseEnvironmentVariables = (envVars: string): Record => { + const env: Record = {} + if (!envVars) return env + + const lines = envVars.split('\n') + for (const line of lines) { + const trimmedLine = line.trim() + if (trimmedLine && trimmedLine.includes('=')) { + const [key, ...valueParts] = trimmedLine.split('=') + const trimmedKey = key.trim() + const value = valueParts.join('=').trim() + if (trimmedKey) { + env[trimmedKey] = value + } + } + } + return env +} + +// 为不同 CLI 工具生成环境变量配置 +export const generateToolEnvironment = ({ + tool, + model, + modelProvider, + apiKey, + baseUrl +}: { + tool: codeTools + model: Model + modelProvider: Provider + apiKey: string + baseUrl: string +}): Record => { + const env: Record = {} + + switch (tool) { + case codeTools.claudeCode: + env.ANTHROPIC_BASE_URL = getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.apiHost + env.ANTHROPIC_MODEL = model.id + if (modelProvider.type === 'anthropic') { + env.ANTHROPIC_API_KEY = apiKey + } else { + env.ANTHROPIC_AUTH_TOKEN = apiKey + } + break + + case codeTools.geminiCli: { + const apiBaseUrl = getCodeToolsApiBaseUrl(model, 'gemini') || modelProvider.apiHost + env.GEMINI_API_KEY = apiKey + env.GEMINI_BASE_URL = apiBaseUrl + env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl + env.GEMINI_MODEL = model.id + break + } + + case codeTools.qwenCode: + case codeTools.openaiCodex: + env.OPENAI_API_KEY = apiKey + env.OPENAI_BASE_URL = baseUrl + env.OPENAI_MODEL = model.id + break + } + + return env +} + export { default } from './CodeToolsPage' From a4cdb5d45f71d586694f22e01fc5f72aa6eb2697 Mon Sep 17 00:00:00 2001 From: one Date: Fri, 22 Aug 2025 14:39:57 +0800 Subject: [PATCH 42/96] perf: history page search performance and loading state (#9344) * refactor(HistoryPage): add loading state to search results * refactor: add min height * perf: speedup message search * refactor: use cached topics map in onTopicClick * refactor: smooth scrolling * refactor: use MutationObserver for better scroll timing * refactor: remove search.length restrictions * refactor: use getTopicById in TopicMessages, improve error messages * fix: i18n --- src/renderer/src/i18n/locales/en-us.json | 3 + src/renderer/src/i18n/locales/ja-jp.json | 3 + src/renderer/src/i18n/locales/ru-ru.json | 3 + src/renderer/src/i18n/locales/zh-cn.json | 3 + src/renderer/src/i18n/locales/zh-tw.json | 3 + .../src/pages/history/HistoryPage.tsx | 13 ++- .../history/components/SearchResults.tsx | 95 ++++++++++++------- .../history/components/TopicMessages.tsx | 21 ++-- .../history/components/TopicsHistory.tsx | 19 ++-- src/renderer/src/store/assistants.ts | 15 ++- 10 files changed, 117 insertions(+), 61 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a7a52d1ac3..0a6ffbf92f 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -885,6 +885,9 @@ }, "history": { "continue_chat": "Continue Chatting", + "error": { + "topic_not_found": "Topic not found" + }, "locate": { "message": "Locate the message" }, diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 953213fdbf..f35e31231a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -885,6 +885,9 @@ }, "history": { "continue_chat": "チャットを続ける", + "error": { + "topic_not_found": "トピックが見つかりません" + }, "locate": { "message": "メッセージを探す" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index a251854c34..69b64ce26f 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -885,6 +885,9 @@ }, "history": { "continue_chat": "Продолжить чат", + "error": { + "topic_not_found": "Топик не найден" + }, "locate": { "message": "Найти сообщение" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8b55525a9c..3164f068ab 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -885,6 +885,9 @@ }, "history": { "continue_chat": "继续聊天", + "error": { + "topic_not_found": "话题不存在" + }, "locate": { "message": "定位到消息" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 2e2babab9c..df5500fc71 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -885,6 +885,9 @@ }, "history": { "continue_chat": "繼續聊天", + "error": { + "topic_not_found": "話題不存在" + }, "locate": { "message": "定位到訊息" }, diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index d20accfd87..7d0857f2e9 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -22,7 +22,7 @@ let _stack: Route[] = ['topics'] let _topic: Topic | undefined let _message: Message | undefined -const TopicsPage: FC = () => { +const HistoryPage: FC = () => { const { t } = useTranslation() const [search, setSearch] = useState(_search) const [searchKeywords, setSearchKeywords] = useState(_search) @@ -52,7 +52,12 @@ const TopicsPage: FC = () => { setTopic(undefined) } - const onTopicClick = (topic: Topic) => { + // topic 不包含 messages,用到的时候才会获取 + const onTopicClick = (topic: Topic | null | undefined) => { + if (!topic) { + window.message.error(t('history.error.topic_not_found')) + return + } setStack((prev) => [...prev, 'topic']) setTopic(topic) } @@ -86,7 +91,7 @@ const TopicsPage: FC = () => { ) } - suffix={search.length >= 2 ? : null} + suffix={search.length ? : null} ref={inputRef} placeholder={t('history.search.placeholder')} value={search} @@ -146,4 +151,4 @@ const SearchIcon = styled.div` } ` -export default TopicsPage +export default HistoryPage diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index c33d8e56e0..a88e97dd0e 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -1,16 +1,23 @@ +import { LoadingIcon } from '@renderer/components/Icons' import db from '@renderer/databases' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { useTimer } from '@renderer/hooks/useTimer' -import { getTopicById } from '@renderer/hooks/useTopic' +import { selectTopicsMap } from '@renderer/store/assistants' import { Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' -import { List, Typography } from 'antd' +import { List, Spin, Typography } from 'antd' import { useLiveQuery } from 'dexie-react-hooks' -import { FC, memo, useCallback, useEffect, useState } from 'react' +import { FC, memo, useCallback, useEffect, useRef, useState } from 'react' +import { useSelector } from 'react-redux' import styled from 'styled-components' const { Text, Title } = Typography +type SearchResult = { + message: Message + topic: Topic + content: string +} + interface Props extends React.HTMLAttributes { keywords: string onMessageClick: (message: Message) => void @@ -19,7 +26,7 @@ interface Props extends React.HTMLAttributes { const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('SearchResults') - const { setTimeoutTimer } = useTimer() + const observerRef = useRef(null) const [searchTerms, setSearchTerms] = useState( keywords @@ -29,9 +36,12 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p ) const topics = useLiveQuery(() => db.topics.toArray(), []) + // FIXME: db 中没有 topic.name 等信息,只能从 store 获取 + const storeTopicsMap = useSelector(selectTopicsMap) - const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([]) + const [searchResults, setSearchResults] = useState([]) const [searchStats, setSearchStats] = useState({ count: 0, time: 0 }) + const [isLoading, setIsLoading] = useState(false) const removeMarkdown = (text: string) => { return text @@ -46,33 +56,40 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p const onSearch = useCallback(async () => { setSearchResults([]) + setIsLoading(true) if (keywords.length === 0) { setSearchStats({ count: 0, time: 0 }) setSearchTerms([]) + setIsLoading(false) return } const startTime = performance.now() - const results: { message: Message; topic: Topic; content: string }[] = [] const newSearchTerms = keywords .toLowerCase() .split(' ') .filter((term) => term.length > 0) + const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i')) - const blocksArray = await db.message_blocks.toArray() - const blocks = blocksArray + const blocks = (await db.message_blocks.toArray()) .filter((block) => block.type === MessageBlockType.MAIN_TEXT) - .filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term))) + .filter((block) => searchRegexes.some((regex) => regex.test(block.content))) - const messages = topics?.map((topic) => topic.messages).flat() + const messages = topics?.flatMap((topic) => topic.messages) - for (const block of blocks) { - const message = messages?.find((message) => message.id === block.messageId) - if (message) { - results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content }) - } - } + const results = await Promise.all( + blocks.map(async (block) => { + const message = messages?.find((message) => message.id === block.messageId) + if (message) { + const topic = storeTopicsMap.get(message.topicId) + if (topic) { + return { message, topic, content: block.content } + } + } + return null + }) + ).then((results) => results.filter(Boolean) as SearchResult[]) const endTime = performance.now() setSearchResults(results) @@ -81,7 +98,8 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p time: (endTime - startTime) / 1000 }) setSearchTerms(newSearchTerms) - }, [keywords, topics]) + setIsLoading(false) + }, [keywords, storeTopicsMap, topics]) const highlightText = (text: string) => { let highlightedText = removeMarkdown(text) @@ -100,9 +118,24 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p onSearch() }, [onSearch]) + useEffect(() => { + if (!containerRef.current) return + + observerRef.current = new MutationObserver(() => { + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + }) + + observerRef.current.observe(containerRef.current, { + childList: true, + subtree: true + }) + + return () => observerRef.current?.disconnect() + }, [containerRef]) + return ( - + }> {searchResults.length > 0 && ( Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds @@ -113,19 +146,15 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p dataSource={searchResults} pagination={{ pageSize: 10, - onChange: () => { - setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0) - } + hideOnSinglePage: true }} + style={{ opacity: isLoading ? 0 : 1 }} renderItem={({ message, topic, content }) => ( { - const _topic = await getTopicById(topic.id) - onTopicClick(_topic) - }}> + onClick={() => onTopicClick(topic)}> {topic.name}
onMessageClick(message)}> @@ -138,24 +167,17 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p )} />
- + ) } const Container = styled.div` width: 100%; - padding: 20px; + height: 100%; + padding: 20px 36px; overflow-y: auto; display: flex; - flex-direction: row; - justify-content: center; -` - -const ContainerWrapper = styled.div` - width: 100%; - padding: 0 16px; - display: flex; flex-direction: column; ` @@ -166,6 +188,7 @@ const SearchStats = styled.div` const SearchResultTime = styled.div` margin-top: 10px; + text-align: right; ` export default memo(SearchResults) diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 917a110b5d..9f5111a254 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -5,18 +5,17 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' +import { getTopicById } from '@renderer/hooks/useTopic' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' import NavigationService from '@renderer/services/NavigationService' -import { useAppDispatch } from '@renderer/store' -import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' -import { classNames } from '@renderer/utils' +import { classNames, runAsyncFunction } from '@renderer/utils' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' import { Forward } from 'lucide-react' -import { FC, useEffect } from 'react' +import { FC, useEffect, useState } from 'react' import styled from 'styled-components' import { default as MessageItem } from '../../home/Messages/Message' @@ -25,16 +24,22 @@ interface Props extends React.HTMLAttributes { topic?: Topic } -const TopicMessages: FC = ({ topic, ...props }) => { +const TopicMessages: FC = ({ topic: _topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') - const dispatch = useAppDispatch() const { messageStyle } = useSettings() const { setTimeoutTimer } = useTimer() + const [topic, setTopic] = useState(_topic) + useEffect(() => { - topic && dispatch(loadTopicMessagesThunk(topic.id)) - }, [dispatch, topic]) + if (!_topic) return + + runAsyncFunction(async () => { + const topic = await getTopicById(_topic.id) + setTopic(topic) + }) + }, [_topic, topic]) const isEmpty = (topic?.messages || []).length === 0 diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index 2051d536bf..37113891f2 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -1,14 +1,14 @@ import { SearchOutlined } from '@ant-design/icons' import { VStack } from '@renderer/components/Layout' -import { useAssistants } from '@renderer/hooks/useAssistant' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { getTopicById } from '@renderer/hooks/useTopic' +import { selectAllTopics } from '@renderer/store/assistants' import { Topic } from '@renderer/types' import { Button, Divider, Empty, Segmented } from 'antd' import dayjs from 'dayjs' import { groupBy, isEmpty, orderBy } from 'lodash' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import styled from 'styled-components' type SortType = 'createdAt' | 'updatedAt' @@ -20,18 +20,18 @@ type Props = { } & React.HTMLAttributes const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props }) => { - const { assistants } = useAssistants() const { t } = useTranslation() const { handleScroll, containerRef } = useScrollPosition('TopicsHistory') const [sortType, setSortType] = useState('createdAt') - const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc') + // FIXME: db 中没有 topic.name 等信息,只能从 store 获取 + const topics = useSelector(selectAllTopics) const filteredTopics = topics.filter((topic) => { return topic.name.toLowerCase().includes(keywords.toLowerCase()) }) - const groupedTopics = groupBy(filteredTopics, (topic) => { + const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => { return dayjs(topic[sortType]).format('MM/DD') }) @@ -66,19 +66,14 @@ const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props {date} {items.map((topic) => ( - { - const _topic = await getTopicById(topic.id) - onClick(_topic) - }}> + onClick(topic)}> {topic.name.substring(0, 50)} {dayjs(topic[sortType]).format('HH:mm')} ))} ))} - {keywords.length >= 2 && ( + {keywords && (
+ + + } + /> + + ) +} + +const ErrorBoundaryCustomized = ({ + children, + fallbackComponent +}: { + children: ReactNode + fallbackComponent?: ComponentType +}) => { + return {children} +} + +const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: 8px; +` + +export { ErrorBoundaryCustomized as ErrorBoundary } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b440f49282..faeebeb541 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -808,6 +808,13 @@ "backup": { "file_format": "Backup file format error" }, + "boundary": { + "default": { + "devtools": "Open debug panel", + "message": "It seems that something went wrong...", + "reload": "Reload" + } + }, "chat": { "chunk": { "non_json": "Returned an invalid data format" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1b68659cf5..42dbead394 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -808,6 +808,13 @@ "backup": { "file_format": "バックアップファイルの形式エラー" }, + "boundary": { + "default": { + "devtools": "デバッグパネルを開く", + "message": "何か問題が発生したようです...", + "reload": "再読み込み" + } + }, "chat": { "chunk": { "non_json": "無効なデータ形式が返されました" diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d2fa9a969a..28880dbf96 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -808,6 +808,13 @@ "backup": { "file_format": "Ошибка формата файла резервной копии" }, + "boundary": { + "default": { + "devtools": "Открыть панель отладки", + "message": "Похоже, возникла какая-то проблема...", + "reload": "Перезагрузить" + } + }, "chat": { "chunk": { "non_json": "Вернулся недопустимый формат данных" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 816343ea89..8cd92d2649 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -808,6 +808,13 @@ "backup": { "file_format": "备份文件格式错误" }, + "boundary": { + "default": { + "devtools": "打开调试面板", + "message": "似乎出现了一些问题...", + "reload": "重新加载" + } + }, "chat": { "chunk": { "non_json": "返回了无效的数据格式" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8825bbce7c..a193d23e07 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -808,6 +808,13 @@ "backup": { "file_format": "備份檔案格式錯誤" }, + "boundary": { + "default": { + "devtools": "打開除錯面板", + "message": "似乎出現了一些問題...", + "reload": "重新載入" + } + }, "chat": { "chunk": { "non_json": "返回了無效的資料格式" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 985544229b..91d2fc46f0 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -808,6 +808,13 @@ "backup": { "file_format": "Λάθος μορφή αρχείου που επιστρέφεται" }, + "boundary": { + "default": { + "devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης", + "message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...", + "reload": "Επαναφόρτωση" + } + }, "chat": { "chunk": { "non_json": "Επέστρεψε μη έγκυρη μορφή δεδομένων" @@ -888,6 +895,9 @@ }, "history": { "continue_chat": "Συνεχίστε το συνομιλημένο", + "error": { + "topic_not_found": "Το θέμα δεν υπάρχει" + }, "locate": { "message": "Εφαρμογή στο μήνυμα" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index d0cd6bd565..99f7637098 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -808,6 +808,13 @@ "backup": { "file_format": "Formato de archivo de copia de seguridad incorrecto" }, + "boundary": { + "default": { + "devtools": "Abrir el panel de depuración", + "message": "Parece que ha surgido un problema...", + "reload": "Recargar" + } + }, "chat": { "chunk": { "non_json": "Devuelve un formato de datos no válido" @@ -888,6 +895,9 @@ }, "history": { "continue_chat": "Continuar chat", + "error": { + "topic_not_found": "El tema no existe" + }, "locate": { "message": "Localizar mensaje" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d91c04abe2..d8059651f3 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -808,6 +808,13 @@ "backup": { "file_format": "Le format du fichier de sauvegarde est incorrect" }, + "boundary": { + "default": { + "devtools": "Ouvrir le panneau de débogage", + "message": "Il semble que quelques problèmes soient survenus...", + "reload": "Recharger" + } + }, "chat": { "chunk": { "non_json": "a renvoyé un format de données invalide" @@ -888,6 +895,9 @@ }, "history": { "continue_chat": "Continuer la conversation", + "error": { + "topic_not_found": "Le sujet n'existe pas" + }, "locate": { "message": "Localiser le message" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index b66907dfaa..c10a01f78a 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -808,6 +808,13 @@ "backup": { "file_format": "Formato do arquivo de backup está incorreto" }, + "boundary": { + "default": { + "devtools": "Abrir o painel de depuração", + "message": "Parece que ocorreu um problema...", + "reload": "Recarregar" + } + }, "chat": { "chunk": { "non_json": "Devolveu um formato de dados inválido" @@ -888,6 +895,9 @@ }, "history": { "continue_chat": "Continuar conversando", + "error": { + "topic_not_found": "Tópico inexistente" + }, "locate": { "message": "Localizar mensagem" }, diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index ece6389baa..a32eff2bb1 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -1,3 +1,4 @@ +import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { useAssistants } from '@renderer/hooks/useAssistant' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useActiveTopic } from '@renderer/hooks/useTopic' @@ -100,20 +101,24 @@ const HomePage: FC = () => { )} {showAssistants && ( - + + + )} - + + + ) diff --git a/yarn.lock b/yarn.lock index b9833e0cc3..3ef1c7aa89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8538,6 +8538,7 @@ __metadata: proxy-agent: "npm:^6.5.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" + react-error-boundary: "npm:^6.0.0" react-hotkeys-hook: "npm:^4.6.1" react-i18next: "npm:^14.1.2" react-infinite-scroll-component: "npm:^6.1.0" @@ -19026,6 +19027,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^6.0.0": + version: 6.0.0 + resolution: "react-error-boundary@npm:6.0.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8 + languageName: node + linkType: hard + "react-hotkeys-hook@npm:^4.6.1": version: 4.6.2 resolution: "react-hotkeys-hook@npm:4.6.2" From 56cec268584d45903581537c99b755bd856a0bbe Mon Sep 17 00:00:00 2001 From: one Date: Sun, 24 Aug 2025 21:10:33 +0800 Subject: [PATCH 57/96] fix: topics tab tooltip not hide (#9457) --- src/renderer/src/pages/home/Tabs/TopicsTab.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index e5e31b6d63..ed61673d6f 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -542,11 +542,10 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, -
- {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
+
+ {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
}> = ({ assistant: _assistant, activeTopic, setActiveTopic, } }}> {deletingTopicId === topic.id ? ( - + ) : ( - + )}
From 8925d7d546e3061f2918987cde63e7dd57c82664 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 25 Aug 2025 00:10:41 +0800 Subject: [PATCH 58/96] feat: translate history star (#9433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(types): 为翻译历史记录添加收藏状态字段 * feat(翻译服务): 添加更新翻译历史记录功能 新增updateTranslateHistory方法用于更新翻译历史记录,支持修改原文、译文、语言及收藏状态 * refactor(TranslateService): 简化更新翻译历史记录的参数结构 * fix(TranslateService): 添加删除翻译历史的错误处理 捕获删除翻译历史时的异常并记录日志,避免静默失败 * feat(翻译历史): 添加收藏功能并优化删除操作 - 新增翻译历史项的收藏功能 - 将删除操作从右键菜单移至显式按钮 - 增加删除失败的国际化提示 - 调整列表项高度以适应新功能 * feat(翻译历史): 添加收藏筛选功能 新增显示已收藏翻译历史的功能,用户可以通过点击星标按钮切换筛选状态 * feat(i18n): 添加翻译历史删除失败的错误消息 为翻译历史功能添加删除操作失败时的错误提示消息,支持多语言显示 * fix(翻译历史): 将删除按钮文本改为"删除翻译历史"并添加确认弹窗 修改删除按钮文本使其更明确,并添加确认弹窗防止误操作 * style(TabContainer): 移除多余的空行以保持代码整洁 --- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/ja-jp.json | 3 +- src/renderer/src/i18n/locales/ru-ru.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 3 +- src/renderer/src/i18n/locales/zh-tw.json | 3 +- src/renderer/src/i18n/translate/el-gr.json | 3 +- src/renderer/src/i18n/translate/es-es.json | 3 +- src/renderer/src/i18n/translate/fr-fr.json | 3 +- src/renderer/src/i18n/translate/pt-pt.json | 3 +- .../src/pages/translate/TranslateHistory.tsx | 140 +++++++++++++----- src/renderer/src/services/TranslateService.ts | 26 +++- src/renderer/src/types/index.ts | 2 + 12 files changed, 151 insertions(+), 44 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index faeebeb541..428b7693e0 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3734,9 +3734,10 @@ "history": { "clear": "Clear History", "clear_description": "Clear history will delete all translation history, continue?", - "delete": "Delete", + "delete": "Delete translation history", "empty": "No translation history", "error": { + "delete": "Deletion failed", "save": "Failed to save translation history" }, "search": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 42dbead394..5db622e895 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -3734,9 +3734,10 @@ "history": { "clear": "履歴をクリア", "clear_description": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?", - "delete": "削除", + "delete": "翻訳履歴を削除する", "empty": "翻訳履歴がありません", "error": { + "delete": "削除に失敗しました", "save": "保存翻訳履歴に失敗しました" }, "search": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 28880dbf96..2a04e0ccef 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -3734,9 +3734,10 @@ "history": { "clear": "Очистить историю", "clear_description": "Очистка истории удалит все записи переводов. Продолжить?", - "delete": "Удалить", + "delete": "Удалить историю переводов", "empty": "История переводов отсутствует", "error": { + "delete": "Удаление не удалось", "save": "Не удалось сохранить историю переводов" }, "search": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8cd92d2649..9db6cf963b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3734,9 +3734,10 @@ "history": { "clear": "清空历史", "clear_description": "清空历史将删除所有翻译历史记录,是否继续?", - "delete": "删除", + "delete": "删除翻译历史", "empty": "暂无翻译历史", "error": { + "delete": "删除失败", "save": "保存翻译历史失败" }, "search": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a193d23e07..04ce4ed230 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3734,9 +3734,10 @@ "history": { "clear": "清空歷史", "clear_description": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?", - "delete": "刪除", + "delete": "刪除翻譯歷史", "empty": "翻譯歷史為空", "error": { + "delete": "删除失败", "save": "保存翻譯歷史失敗" }, "search": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 91d2fc46f0..e095cbf0da 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3734,9 +3734,10 @@ "history": { "clear": "Καθαρισμός ιστορικού", "clear_description": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;", - "delete": "Διαγραφή", + "delete": "Διαγραφή του ιστορικού μετάφρασης", "empty": "δεν υπάρχουν απομνημονεύματα μετάφρασης", "error": { + "delete": "Αποτυχία διαγραφής", "save": "Αποτυχία αποθήκευσης του ιστορικού μεταφράσεων" }, "search": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 99f7637098..2ee02a11ec 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3734,9 +3734,10 @@ "history": { "clear": "Borrar historial", "clear_description": "Borrar el historial eliminará todos los registros de traducciones, ¿desea continuar?", - "delete": "Eliminar", + "delete": "Eliminar historial de traducción", "empty": "Sin historial de traducciones por el momento", "error": { + "delete": "Eliminación fallida", "save": "Error al guardar el historial de traducciones" }, "search": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d8059651f3..20364b838f 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3734,9 +3734,10 @@ "history": { "clear": "Effacer l'historique", "clear_description": "L'effacement de l'historique supprimera toutes les entrées d'historique de traduction, voulez-vous continuer ?", - "delete": "Supprimer", + "delete": "Supprimer l'historique des traductions", "empty": "Aucun historique de traduction pour le moment", "error": { + "delete": "Échec de la suppression", "save": "Échec de la sauvegarde de l'historique des traductions" }, "search": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index c10a01f78a..5d54ecc50a 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3734,9 +3734,10 @@ "history": { "clear": "Limpar Histórico", "clear_description": "Limpar histórico irá deletar todos os registros de tradução. Deseja continuar?", - "delete": "Excluir", + "delete": "Apagar histórico de traduções", "empty": "Nenhum histórico de tradução disponível", "error": { + "delete": "Falha ao excluir", "save": "Falha ao guardar o histórico de traduções" }, "search": { diff --git a/src/renderer/src/pages/translate/TranslateHistory.tsx b/src/renderer/src/pages/translate/TranslateHistory.tsx index 371237f9eb..5930fa9e64 100644 --- a/src/renderer/src/pages/translate/TranslateHistory.tsx +++ b/src/renderer/src/pages/translate/TranslateHistory.tsx @@ -1,11 +1,11 @@ -import { DeleteOutlined } from '@ant-design/icons' +import { DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import { DynamicVirtualList } from '@renderer/components/VirtualList' import db from '@renderer/databases' import useTranslate from '@renderer/hooks/useTranslate' -import { clearHistory, deleteHistory } from '@renderer/services/TranslateService' +import { clearHistory, deleteHistory, updateTranslateHistory } from '@renderer/services/TranslateService' import { TranslateHistory, TranslateLanguage } from '@renderer/types' -import { Button, Drawer, Dropdown, Empty, Flex, Input, Popconfirm } from 'antd' +import { Button, Drawer, Empty, Flex, Input, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' import { isEmpty } from 'lodash' @@ -28,7 +28,7 @@ type TranslateHistoryProps = { // const logger = loggerService.withContext('TranslateHistory') // px -const ITEM_HEIGHT = 140 +const ITEM_HEIGHT = 160 const TranslateHistoryList: FC = ({ isOpen, onHistoryItemClick, onClose }) => { const { t } = useTranslation() @@ -36,6 +36,7 @@ const TranslateHistoryList: FC = ({ isOpen, onHistoryItem const _translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), []) const [search, setSearch] = useState('') const [displayedHistory, setDisplayedHistory] = useState([]) + const [showStared, setShowStared] = useState(false) const translateHistory: DisplayedTranslateHistoryItem[] = useMemo(() => { if (!_translateHistory) return [] @@ -57,15 +58,64 @@ const TranslateHistoryList: FC = ({ isOpen, onHistoryItem [search] ) + const starFilter = useMemo( + () => (showStared ? (item: DisplayedTranslateHistoryItem) => !!item.star : () => true), + [showStared] + ) + + const finalFilter = useCallback( + (item: DisplayedTranslateHistoryItem) => searchFilter(item) && starFilter(item), + [searchFilter, starFilter] + ) + + const handleStar = useCallback( + (id: string) => { + const origin = translateHistory.find((item) => item.id === id) + if (!origin) { + return + } + updateTranslateHistory(id, { star: !origin.star }) + }, + [translateHistory] + ) + + const handleDelete = useCallback( + (id: string) => { + try { + deleteHistory(id) + } catch (e) { + window.message.error(t('translate.history.error.delete')) + } + }, + [t] + ) + useEffect(() => { - setDisplayedHistory(translateHistory.filter(searchFilter)) - }, [searchFilter, translateHistory]) + setDisplayedHistory(translateHistory.filter(finalFilter)) + }, [finalFilter, translateHistory]) + + const Title = () => { + return ( + + {t('translate.history.title')} + - - - ) - } - }}> - {(server: MCPServer) => ( -
navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}> - handleToggleActive(server, active)} - onDelete={() => onDeleteMcpServer(server)} - onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)} - onOpenUrl={(url) => window.open(url, '_blank')} - /> -
+ { + const newList = [...mcpServers] + const [removed] = newList.splice(oldIndex, 1) + newList.splice(newIndex, 0, removed) + updateMcpServers(newList) + }} + layout="grid" + useDragOverlay + showGhost + renderItem={(server) => ( + handleToggleActive(server, active)} + onDelete={() => onDeleteMcpServer(server)} + onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)} + onOpenUrl={(url) => window.open(url, '_blank')} + /> )} -
+ /> + {mcpServers.length === 0 && ( + + )} diff --git a/yarn.lock b/yarn.lock index 3ef1c7aa89..6c1b8a4bb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2691,6 +2691,68 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + +"@dnd-kit/modifiers@npm:^9.0.0": + version: 9.0.0 + resolution: "@dnd-kit/modifiers@npm:9.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/ca8cc9da8296df10774d779c1611074dc327ccc3c49041c102111c98c7f2b2b73b6af5209c0eef6b2fe978ac63dc2a985efa87c85a8d786577304bd2e64cee1d + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0 + languageName: node + linkType: hard + "@electron-toolkit/eslint-config-prettier@npm:^3.0.0": version: 3.0.0 resolution: "@electron-toolkit/eslint-config-prettier@npm:3.0.0" @@ -8406,6 +8468,10 @@ __metadata: "@cherrystudio/embedjs-loader-xml": "npm:^0.1.31" "@cherrystudio/embedjs-ollama": "npm:^0.1.31" "@cherrystudio/embedjs-openai": "npm:^0.1.31" + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/modifiers": "npm:^9.0.0" + "@dnd-kit/sortable": "npm:^10.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" @@ -21309,7 +21375,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From c49201f36535bc046c73f806d8347c1a9495b908 Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:20:15 +0800 Subject: [PATCH 64/96] fix: Knowledge Search Not Open Target (#9504) * fix: #9488 * chore --- .../pages/knowledge/components/KnowledgeSearchPopup.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSearchPopup.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSearchPopup.tsx index 562c6c729b..8ef69b59ba 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSearchPopup.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSearchPopup.tsx @@ -158,9 +158,13 @@ const PopupContainer: React.FC = ({ base, resolve }) => {
{item.file.origin_name} + ) : item.metadata.type !== 'LocalPathLoader' ? ( + + {item.metadata.source} + ) : ( - // item.metadata.source - + // 处理预处理后的文件source + {item.metadata.source.split('/').pop() || item.metadata.source} )} From a398010213b1d92bd4263af719c90fd647b46d8c Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:06:14 +0800 Subject: [PATCH 65/96] feat(QuickPanel): Soft hide and symbol toggle fix(#9326) (#9371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(QuickPanel): 软隐藏与符号切换;性能优化与清理 - 交互改进 - 无匹配时“软隐藏”(不销毁、折叠且不拦截) - 回删修正后有结果自动展开 - 输入新符号(/ 或 @)即切换到对应面板 - 性能优化 - 搜索 50ms 防抖,降低高频输入开销 - 按搜索词只构建一次模糊匹配正则 - 使用 WeakMap 缓存每项拼音,避免重复转换 - 折叠时不渲染列表、不注册全局键盘监听 - 代码清理 - 删除 noMatchTimeoutRef 及其清理 effect - 删除未使用的 currentMessageId 引用 - 移除重复的 setText('') 清空逻辑 - 保持不变 - 多选/固定/清空等既有模型面板逻辑 - ESC、外部点击、删除符号的关闭语义 - 初始空查询直接展示可选项 * feat(quickpanel): 清除模型时同时删除@符号和搜索文本 - 在MentionModelsButton中记录触发信息 - 清除操作时根据触发类型删除@符号 - 仅处理输入触发的场景,按钮触发不需要处理 * refactor(quickpanel): 提取通用的删除@符号函数 - 创建 removeAtSymbolAndText 函数统一处理删除逻辑 - 支持两种模式:精确删除(ESC,使用searchText)和自动查找(清除) - ESC和清除操作现在使用相同的核心逻辑 - 提高代码可维护性和一致性 * handleInput 中的 ctx.close('delete-symbol') 替换为本地 handleClose('delete-symbol'),确保 Backspace 删除触发符时同步受控输入值。 * - 统一 @ 清除逻辑:基于光标+搜索词的锚点定位 - 修复 ESC/清除误删邮箱/URL 中 @ 的问题 - 精确匹配优先:从光标左侧最近的 “@+searchText” - 失败兜底:验证触发位 position,一致删整段,不一致仅删单个 @ - 清除按钮:未知搜索词时按光标左侧最近 @ 删至空格/换行 - 保持行为一致:ESC 与“清除模型”共用同一删除函数 * - 修复:无匹配时“清除”被过滤导致不可用的问题 - 方案:为“清除”项添加 alwaysVisible 标记,不参与过滤并始终置顶展示 - 过滤改造:QuickPanel 将列表拆分为固定项与普通项,仅对普通项执行包含/模糊/拼音过滤,最终合并渲染 - 折叠逻辑:collapsed 仅依据“非固定项”的匹配数;当仅剩“清除”时仍折叠隐藏,UI 不受影响 --- .../src/components/QuickPanel/types.ts | 6 + .../src/components/QuickPanel/view.tsx | 212 +++++++++--------- .../src/pages/home/Inputbar/Inputbar.tsx | 56 +++-- .../home/Inputbar/MentionModelsButton.tsx | 104 +++++++-- 4 files changed, 229 insertions(+), 149 deletions(-) diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 8cf79fb270..d8e2ff26b0 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -54,6 +54,12 @@ export type QuickPanelListItem = { isSelected?: boolean isMenu?: boolean disabled?: boolean + /** + * 固定显示项:不参与过滤,始终出现在列表顶部。 + * 例如“清除”按钮可设置为 alwaysVisible,从而在有匹配项时始终可见; + * 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。 + */ + alwaysVisible?: boolean action?: (options: QuickPanelCallBackOptions) => void } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 74cbc763c1..08878b8478 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -1,10 +1,12 @@ import { RightOutlined } from '@ant-design/icons' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { isMac } from '@renderer/config/constant' +import { useTimer } from '@renderer/hooks/useTimer' import useUserTheme from '@renderer/hooks/useUserTheme' import { classNames } from '@renderer/utils' import { Flex } from 'antd' import { t } from 'i18next' +import { debounce } from 'lodash' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' @@ -62,20 +64,32 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const searchText = useDeferredValue(_searchText) const searchTextRef = useRef('') + // 缓存:按 item 缓存拼音文本,避免重复转换 + const pinyinCacheRef = useRef>(new WeakMap()) + + // 轻量防抖:减少高频输入时的过滤调用 + const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), []) + // 跟踪上一次的搜索文本和符号,用于判断是否需要重置index const prevSearchTextRef = useRef('') const prevSymbolRef = useRef('') - - // 无匹配项自动关闭的定时器 - const noMatchTimeoutRef = useRef(null) - const clearSearchTimerRef = useRef(undefined) - const focusTimerRef = useRef(undefined) - - // 处理搜索,过滤列表 + const { setTimeoutTimer } = useTimer() + // 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部) const list = useMemo(() => { if (!ctx.isVisible && !ctx.symbol) return [] - const newList = ctx.list?.filter((item) => { - const _searchText = searchText.replace(/^[/@]/, '') + const _searchText = searchText.replace(/^[/@]/, '') + const lowerSearchText = _searchText.toLowerCase() + const fuzzyPattern = lowerSearchText + .split('') + .map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*') + const fuzzyRegex = new RegExp(fuzzyPattern, 'ig') + + // 拆分:固定显示项(不参与过滤)与普通项 + const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible) + const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible) + + const filteredNormalItems = normalItems.filter((item) => { if (!_searchText) return true let filterText = item.filterText || '' @@ -87,29 +101,24 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } const lowerFilterText = filterText.toLowerCase() - const lowerSearchText = _searchText.toLowerCase() if (lowerFilterText.includes(lowerSearchText)) { return true } - 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() - const regex = new RegExp(pattern, 'ig') - return regex.test(pinyinText) + let pinyinText = pinyinCacheRef.current.get(item) + if (!pinyinText) { + pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() + pinyinCacheRef.current.set(item, pinyinText) + } + return fuzzyRegex.test(pinyinText) } catch (error) { return true } } else { - const regex = new RegExp(pattern, 'ig') - return regex.test(filterText.toLowerCase()) + return fuzzyRegex.test(filterText.toLowerCase()) } }) @@ -122,8 +131,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } else { // 如果当前index超出范围,调整到有效范围内 setIndex((prevIndex) => { - if (prevIndex >= newList.length) { - return newList.length > 0 ? newList.length - 1 : -1 + const combinedLength = pinnedItems.length + filteredNormalItems.length + if (prevIndex >= combinedLength) { + return combinedLength > 0 ? combinedLength - 1 : -1 } return prevIndex }) @@ -132,81 +142,52 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { prevSearchTextRef.current = searchText prevSymbolRef.current = ctx.symbol - return newList + // 固定项置顶 + 过滤后的普通项 + return [...pinnedItems, ...filteredNormalItems] }, [ctx.isVisible, ctx.symbol, ctx.list, searchText]) const canForwardAndBackward = useMemo(() => { return list.some((item) => item.isMenu) || historyPanel.length > 0 }, [list, historyPanel]) - // 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板 - useEffect(() => { - const _searchText = searchText.replace(/^[/@]/, '') - - // 清除之前的定时器(无论面板是否可见都要清理) - if (noMatchTimeoutRef.current) { - clearTimeout(noMatchTimeoutRef.current) - noMatchTimeoutRef.current = null - clearTimeout(clearSearchTimerRef.current) - clearTimeout(focusTimerRef.current) - } - - // 面板不可见时不设置新定时器 - if (!ctx.isVisible) { - return - } - - // 只有在有搜索文本但无匹配项时才设置延迟关闭 - if (_searchText && _searchText.length > 0 && list.length === 0) { - noMatchTimeoutRef.current = setTimeout(() => { - ctx.close('no-matches') - }, 300) - } - - // 清理函数 - return () => { - if (noMatchTimeoutRef.current) { - clearTimeout(noMatchTimeoutRef.current) - noMatchTimeoutRef.current = null - } - clearTimeout(clearSearchTimerRef.current) - clearTimeout(focusTimerRef.current) - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染 - }, [ctx.isVisible, searchText, list.length, ctx.close]) - const clearSearchText = useCallback( (includeSymbol = false) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + if (!textArea) return + const cursorPosition = textArea.selectionStart ?? 0 - const prevChar = textArea.value[cursorPosition - 1] - if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) { - searchTextRef.current = prevChar - } + const textBeforeCursor = textArea.value.slice(0, cursorPosition) - const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '') - if (!_searchText) return + // 查找最后一个 @ 或 / 符号的位置 + const lastAtIndex = textBeforeCursor.lastIndexOf('@') + const lastSlashIndex = textBeforeCursor.lastIndexOf('/') + const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex) - const inputText = textArea.value - let newText = inputText - const searchPattern = new RegExp(`${_searchText}$`) + if (lastSymbolIndex === -1) return - const match = inputText.slice(0, cursorPosition).match(searchPattern) - if (match) { - const start = match.index || 0 - const end = start + match[0].length - newText = inputText.slice(0, start) + inputText.slice(end) - setInputText(newText) + // 根据 includeSymbol 决定是否删除符号 + const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1 + const deleteEnd = cursorPosition - clearTimeout(focusTimerRef.current) - focusTimerRef.current = setTimeout(() => { + if (deleteStart >= deleteEnd) return + + // 删除文本 + const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd) + setInputText(newText) + + // 设置光标位置 + setTimeoutTimer( + 'quickpanel_focus', + () => { textArea.focus() - textArea.setSelectionRange(start, start) - }, 0) - } + textArea.setSelectionRange(deleteStart, deleteStart) + }, + 0 + ) + setSearchText('') }, - [setInputText] + [setInputText, setTimeoutTimer] ) const handleClose = useCallback( @@ -317,9 +298,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (lastSymbolIndex !== -1) { const newSearchText = textBeforeCursor.slice(lastSymbolIndex) - setSearchText(newSearchText) + setSearchTextDebounced(newSearchText) } else { - ctx.close('delete-symbol') + // 使用本地 handleClose,确保在删除触发符时同步受控输入值 + handleClose('delete-symbol') } } @@ -340,10 +322,14 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { textArea.removeEventListener('input', handleInput) textArea.removeEventListener('compositionupdate', handleCompositionUpdate) textArea.removeEventListener('compositionend', handleCompositionEnd) - clearTimeout(clearSearchTimerRef.current) - clearSearchTimerRef.current = setTimeout(() => { - setSearchText('') - }, 200) // 等待面板关闭动画结束后,再清空搜索词 + setSearchTextDebounced.cancel() + setTimeoutTimer( + 'quickpanel_clear_search', + () => { + setSearchText('') + }, + 200 + ) // 等待面板关闭动画结束后,再清空搜索词 } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctx.isVisible]) @@ -357,9 +343,11 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { scrollTriggerRef.current = 'none' }, [index]) - // 处理键盘事件 + // 处理键盘事件(折叠时不拦截全局键盘) useEffect(() => { - if (!ctx.isVisible) return + const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0 + const isCollapsed = hasSearchTextFlag && list.length === 0 + if (!ctx.isVisible || isCollapsed) return const handleKeyDown = (e: KeyboardEvent) => { if (isMac ? e.metaKey : e.ctrlKey) { @@ -495,7 +483,17 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { window.removeEventListener('keyup', handleKeyUp, true) window.removeEventListener('click', handleClickOutside, true) } - }, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText]) + }, [ + index, + isAssistiveKeyPressed, + historyPanel, + ctx, + list, + handleItemAction, + handleClose, + clearSearchText, + searchText + ]) const [footerWidth, setFooterWidth] = useState(0) @@ -515,6 +513,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const listHeight = useMemo(() => { return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT }, [ctx.pageSize, list.length]) + const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText]) + // 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠 + const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list]) + const collapsed = hasSearchText && visibleNonPinnedCount === 0 const estimateSize = useCallback(() => ITEM_HEIGHT, []) @@ -562,6 +564,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { $pageSize={ctx.pageSize} $selectedColor={selectedColor} $selectedColorHover={selectedColorHover} + $collapsed={collapsed} className={ctx.isVisible ? 'visible' : ''} data-testid="quick-panel"> = ({ setInputText }) => { return prev ? prev : true }) }> - - {rowRenderer} - + {!collapsed && ( + + {rowRenderer} + + )} {ctx.title || ''} @@ -626,6 +631,7 @@ const QuickPanelContainer = styled.div<{ $pageSize: number $selectedColor: string $selectedColorHover: string + $collapsed?: boolean }>` --focused-color: rgba(0, 0, 0, 0.06); --selected-color: ${(props) => props.$selectedColor}; @@ -644,8 +650,8 @@ const QuickPanelContainer = styled.div<{ pointer-events: none; &.visible { - pointer-events: auto; - max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px; + pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')}; + max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px; } body[theme-mode='dark'] & { --focused-color: rgba(255, 255, 255, 0.1); diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index e2de99df8c..d97a0164e1 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -111,7 +111,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) const startHeight = useRef(0) - const currentMessageId = useRef('') const { bases: knowledgeBases } = useKnowledgeBases() const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) @@ -251,7 +250,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const { message, blocks } = getUserMessage(baseUserMessage) message.traceId = parent?.spanContext().traceId - currentMessageId.current = message.id dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id)) // Clear input @@ -512,30 +510,42 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const cursorPosition = textArea?.selectionStart ?? 0 const lastSymbol = newText[cursorPosition - 1] - if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { - const quickPanelMenu = - inputbarToolsRef.current?.getQuickPanelMenu({ - t, - files, - couldAddImageFile, - text: newText, - openSelectFileMenu, - translate - }) || [] + // 触发符号为 '/':若当前未打开或符号不同,则切换/打开 + if (enableQuickPanelTriggers && lastSymbol === '/') { + if (quickPanel.isVisible && quickPanel.symbol !== '/') { + quickPanel.close('switch-symbol') + } + if (!quickPanel.isVisible || quickPanel.symbol !== '/') { + const quickPanelMenu = + inputbarToolsRef.current?.getQuickPanelMenu({ + t, + files, + couldAddImageFile, + text: newText, + openSelectFileMenu, + translate + }) || [] - quickPanel.open({ - title: t('settings.quickPanel.title'), - list: quickPanelMenu, - symbol: '/' - }) + quickPanel.open({ + title: t('settings.quickPanel.title'), + list: quickPanelMenu, + symbol: '/' + }) + } } - if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { - inputbarToolsRef.current?.openMentionModelsPanel({ - type: 'input', - position: cursorPosition - 1, - originalText: newText - }) + // 触发符号为 '@':若当前未打开或符号不同,则切换/打开 + if (enableQuickPanelTriggers && lastSymbol === '@') { + if (quickPanel.isVisible && quickPanel.symbol !== '@') { + quickPanel.close('switch-symbol') + } + if (!quickPanel.isVisible || quickPanel.symbol !== '@') { + inputbarToolsRef.current?.openMentionModelsPanel({ + type: 'input', + position: cursorPosition - 1, + originalText: newText + }) + } } }, [enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate] diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 06c1a38695..cf48743b12 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -48,6 +48,66 @@ const MentionModelsButton: FC = ({ // 记录是否有模型被选择的动作发生 const hasModelActionRef = useRef(false) + // 记录触发信息,用于清除操作 + const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>( + undefined + ) + + // 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本 + const removeAtSymbolAndText = useCallback( + (currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => { + const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length)) + + // ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配 + if (searchText !== undefined) { + const pattern = '@' + searchText + const fromIndex = Math.max(0, safeCaret - 1) + const start = currentText.lastIndexOf(pattern, fromIndex) + if (start !== -1) { + const end = start + pattern.length + return currentText.slice(0, start) + currentText.slice(end) + } + + // 兜底:使用打开时的 position 做校验后再删 + if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') { + const expected = pattern + const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length) + if (actual === expected) { + return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length) + } + // 如果不完全匹配,安全起见仅删除单个 '@' + return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1) + } + + // 未找到匹配则不改动 + return currentText + } + + // 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾) + { + const fromIndex = Math.max(0, safeCaret - 1) + const start = currentText.lastIndexOf('@', fromIndex) + if (start === -1) { + // 兜底:使用打开时的 position(若存在),按空白边界删除 + if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') { + let endPos = fallbackPosition + 1 + while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') { + endPos++ + } + return currentText.slice(0, fallbackPosition) + currentText.slice(endPos) + } + return currentText + } + + let endPos = start + 1 + while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') { + endPos++ + } + return currentText.slice(0, start) + currentText.slice(endPos) + } + }, + [] + ) const pinnedModels = useLiveQuery( async () => { @@ -140,9 +200,20 @@ const MentionModelsButton: FC = ({ label: t('settings.input.clear.all'), description: t('settings.input.clear.models'), icon: , + alwaysVisible: true, isSelected: false, action: () => { onClearMentionModels() + + // 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除) + if (triggerInfoRef.current?.type === 'input') { + setText((currentText) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null + const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length + return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position) + }) + } + quickPanel.close() } }) @@ -157,13 +228,17 @@ const MentionModelsButton: FC = ({ onMentionModel, navigate, quickPanel, - onClearMentionModels + onClearMentionModels, + setText, + removeAtSymbolAndText ]) const openQuickPanel = useCallback( (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => { // 重置模型动作标记 hasModelActionRef.current = false + // 保存触发信息 + triggerInfoRef.current = triggerInfo quickPanel.open({ title: t('agents.edit.model.select.title'), @@ -183,28 +258,11 @@ const MentionModelsButton: FC = ({ closeTriggerInfo?.type === 'input' && closeTriggerInfo?.position !== undefined ) { - // 使用React的setText来更新状态 + // 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底 setText((currentText) => { - const position = closeTriggerInfo.position! - // 验证位置的字符是否仍是 @ - if (currentText[position] !== '@') { - return currentText - } - - // 计算删除范围:@ + searchText - const deleteLength = 1 + (searchText?.length || 0) - - // 验证要删除的内容是否匹配预期 - const expectedText = '@' + (searchText || '') - const actualText = currentText.slice(position, position + deleteLength) - - if (actualText !== expectedText) { - // 如果实际文本不匹配,只删除 @ 字符 - return currentText.slice(0, position) + currentText.slice(position + 1) - } - - // 删除 @ 和搜索文本 - return currentText.slice(0, position) + currentText.slice(position + deleteLength) + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null + const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length + return removeAtSymbolAndText(currentText, caret, searchText || '', closeTriggerInfo.position!) }) } } @@ -213,7 +271,7 @@ const MentionModelsButton: FC = ({ } }) }, - [modelItems, quickPanel, t, setText] + [modelItems, quickPanel, t, setText, removeAtSymbolAndText] ) const handleOpenQuickPanel = useCallback(() => { From e90b9a5a95d52dc174917232ef0ce6cbd6e2eede Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 25 Aug 2025 19:41:00 +0800 Subject: [PATCH 66/96] fix: unexpected anthropic model recognization (#9517) * fix: unexpected anthropic model recognization * refactor(RawStreamListenerMiddleware): replace model provider retrieval with API client instance check --- .../src/aiCore/middleware/core/RawStreamListenerMiddleware.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts b/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts index 25d0e358c6..fa936af479 100644 --- a/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts @@ -1,5 +1,4 @@ import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient' -import { isAnthropicModel } from '@renderer/config/models' import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' import { AnthropicStreamListener } from '../../clients/types' @@ -16,9 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware = // 在这里可以监听到从SDK返回的最原始流 if (result.rawOutput) { - const model = params.assistant.model // TODO: 后面下放到AnthropicAPIClient - if (isAnthropicModel(model)) { + if (ctx.apiClientInstance instanceof AnthropicAPIClient) { const anthropicListener: AnthropicStreamListener = { onMessage: (message) => { if (ctx._internal?.toolProcessingState) { From 1764be8a302e5d6247160a4ba73567e0e19492c5 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:49:23 +0800 Subject: [PATCH 67/96] style(selection-toolbar): use primary color for selection toolbar (#9515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(selection-toolbar): 统一使用主色变量并移除冗余样式 移除重复定义的颜色变量,统一使用 --color-primary 作为悬停状态的主色 * style: 移除重复的 --color-primary 变量定义 --- src/renderer/src/assets/styles/selection-toolbar.scss | 6 ------ .../src/windows/selection/toolbar/SelectionToolbar.tsx | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss index 23f0edfb34..cf3c672c45 100644 --- a/src/renderer/src/assets/styles/selection-toolbar.scss +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -6,10 +6,8 @@ html { :root { // Basic Colors - --color-primary: #00b96b; --color-error: #f44336; - --selection-toolbar-color-primary: var(--color-primary); --selection-toolbar-color-error: var(--color-error); // Toolbar @@ -54,8 +52,6 @@ html { --selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9); --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); - --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); - --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); --selection-toolbar-button-bgcolor: transparent; // default: transparent --selection-toolbar-button-bgcolor-hover: #333333; } @@ -72,7 +68,5 @@ html { --selection-toolbar-button-text-color: rgba(0, 0, 0, 1); --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); - --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); - --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); --selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04); } diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 54f0fcd085..6e54d26186 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -374,10 +374,10 @@ const ActionButton = styled.div` } &:hover { .btn-icon { - color: var(--selection-toolbar-button-icon-color-hover); + color: var(--color-primary); } .btn-title { - color: var(--selection-toolbar-button-text-color-hover); + color: var(--color-primary); } background-color: var(--selection-toolbar-button-bgcolor-hover); } From 57702f545da93efe352608e6f484e975d10fba02 Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 25 Aug 2025 19:49:52 +0800 Subject: [PATCH 68/96] =?UTF-8?q?fix(OpenAIApiClient):=20=E9=80=82?= =?UTF-8?q?=E9=85=8Dglm=204.5=20toolcall=20(#9516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(OpenAIApiClient): update toolCalls handling to support dynamic index assignment * refactor(OpenAIApiClient): streamline toolCalls management with reusable object structure --- .../src/aiCore/clients/openai/OpenAIApiClient.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 4808d8a4e9..9693988ed1 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -924,13 +924,19 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if ('index' in toolCall) { const { id, index, function: fun } = toolCall if (fun?.name) { - toolCalls[index] = { + const toolCallObject = { id: id || '', function: { name: fun.name, arguments: fun.arguments || '' }, - type: 'function' + type: 'function' as const + } + + if (index === -1) { + toolCalls.push(toolCallObject) + } else { + toolCalls[index] = toolCallObject } } else if (fun?.arguments) { if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) { From fd7d2b75800bf3ccbf66585eb4379357975e054f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Mon, 25 Aug 2025 20:02:13 +0800 Subject: [PATCH 69/96] fix(codetool): quote executable path to handle spaces (#9519) * fix(cmd): quote executable path on Windows in command string Wrap the executable path in double quotes when running on Windows sopaths containing spaces are handled correctly. Previously the base command used an unquoted path which could break execution for users whose install location includes spaces. This change only alters the Windows branch to produce a quoted executable path while keeping the non-Windows command unchanged. * fix(codetool): quote bun paths in shell commands to spaces --- src/main/services/CodeToolsService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index d408ad78a7..28f1068b8f 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -203,7 +203,7 @@ class CodeToolsService { ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` - const updateCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}` + const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` logger.info(`Executing update command: ${updateCommand}`) await execAsync(updateCommand, { timeout: 60000 }) @@ -307,7 +307,7 @@ class CodeToolsService { } // Build command to execute - let baseCommand = isWin ? `${executablePath}` : `${bunPath} ${executablePath}` + let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"` const bunInstallPath = path.join(os.homedir(), '.cherrystudio') if (isInstalled) { From ffa2eb57b1e5c849f4d40fd830d5d7e55fe39f97 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 25 Aug 2025 20:35:32 +0800 Subject: [PATCH 70/96] refactor(Svg): relax sanitizer rules (#9522) --- src/renderer/src/components/Preview/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index 7257251e7b..3a8119f166 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -17,8 +17,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme // Sanitize the SVG content const sanitizedContent = DOMPurify.sanitize(svgContent, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ['style', 'defs', 'foreignObject'] + ADD_TAGS: ['foreignObject'] }) const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' }) From 548916e6e176991b42677cf57c99f8f6e16ed845 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 25 Aug 2025 20:35:48 +0800 Subject: [PATCH 71/96] feat(McpServersList): add a search bar (#9520) * feat(McpServersList): add a search bar * refactor: show different empty tips --- .../src/components/CollapsibleSearchBar.tsx | 27 ++++++---- .../Popups/SelectModelPopup/searchbar.tsx | 2 +- src/renderer/src/i18n/locales/en-us.json | 9 +++- src/renderer/src/i18n/locales/ja-jp.json | 9 +++- src/renderer/src/i18n/locales/ru-ru.json | 9 +++- src/renderer/src/i18n/locales/zh-cn.json | 9 +++- src/renderer/src/i18n/locales/zh-tw.json | 9 +++- src/renderer/src/i18n/translate/el-gr.json | 9 +++- src/renderer/src/i18n/translate/es-es.json | 9 +++- src/renderer/src/i18n/translate/fr-fr.json | 9 +++- src/renderer/src/i18n/translate/pt-pt.json | 9 +++- .../settings/MCPSettings/McpServersList.tsx | 53 ++++++++++++++----- .../ProviderSettings/ModelList/ModelList.tsx | 6 ++- 13 files changed, 137 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/CollapsibleSearchBar.tsx b/src/renderer/src/components/CollapsibleSearchBar.tsx index 3ce3b436be..04b838e37a 100644 --- a/src/renderer/src/components/CollapsibleSearchBar.tsx +++ b/src/renderer/src/components/CollapsibleSearchBar.tsx @@ -1,21 +1,30 @@ +import i18n from '@renderer/i18n' import { Input, InputRef, Tooltip } from 'antd' import { Search } from 'lucide-react' import { motion } from 'motion/react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' interface CollapsibleSearchBarProps { onSearch: (text: string) => void + placeholder?: string + tooltip?: string icon?: React.ReactNode maxWidth?: string | number + style?: React.CSSProperties } /** * A collapsible search bar for list headers * Renders as an icon initially, expands to full search input when clicked */ -const CollapsibleSearchBar: React.FC = ({ onSearch, icon, maxWidth }) => { - const { t } = useTranslation() +const CollapsibleSearchBar = ({ + onSearch, + placeholder = i18n.t('common.search'), + tooltip = i18n.t('common.search'), + icon = , + maxWidth = '100%', + style +}: CollapsibleSearchBarProps) => { const [searchVisible, setSearchVisible] = useState(false) const [searchText, setSearchText] = useState('') const inputRef = useRef(null) @@ -46,16 +55,16 @@ const CollapsibleSearchBar: React.FC = ({ onSearch, i initial="collapsed" animate={searchVisible ? 'expanded' : 'collapsed'} variants={{ - expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } }, + expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } }, collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } } }} style={{ overflow: 'hidden', flex: 1 }}> } + suffix={icon} value={searchText} autoFocus allowClear @@ -71,7 +80,7 @@ const CollapsibleSearchBar: React.FC = ({ onSearch, i if (!searchText) setSearchVisible(false) }} onClear={handleClear} - style={{ width: '100%' }} + style={{ width: '100%', ...style }} /> = ({ onSearch, i }} style={{ cursor: 'pointer', display: 'flex' }} onClick={() => setSearchVisible(true)}> - - {icon || } + + {icon}
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx index 1b4d1dbb02..ab641cf14b 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx @@ -41,7 +41,7 @@ const SelectModelSearchBar: React.FC = ({ onSearch }) } ref={inputRef} - placeholder={t('models.search')} + placeholder={t('models.search.placeholder')} value={searchText} onChange={(e) => handleTextChange(e.target.value)} onClear={handleClear} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 428b7693e0..48bb9664a1 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "Currently, the reranker model does not support this provider ({{provider}})", "rerank_model_support_provider": "Currently, the reranker model only supports some providers ({{provider}})", "rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add.", - "search": "Search models...", + "search": { + "placeholder": "Search models...", + "tooltip": "Search models" + }, "stream_output": "Stream output", "type": { "embedding": "Embedding", @@ -2912,6 +2915,10 @@ "text": "Text", "uri": "URI" }, + "search": { + "placeholder": "Search MCP servers...", + "tooltip": "Search MCP servers" + }, "searchNpx": "Search MCP", "serverPlural": "servers", "serverSingular": "server", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5db622e895..d731da1934 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。", "rerank_model_support_provider": "現在の再順序付けモデルは、{{provider}} のみサポートしています", "rerank_model_tooltip": "設定->モデルサービスに移動し、管理ボタンをクリックして追加します。", - "search": "モデルを検索...", + "search": { + "placeholder": "モデルを検索...", + "tooltip": "モデルを検索" + }, "stream_output": "ストリーム出力", "type": { "embedding": "埋め込み", @@ -2912,6 +2915,10 @@ "text": "テキスト", "uri": "URI" }, + "search": { + "placeholder": "MCP サーバーを検索...", + "tooltip": "MCP サーバーを検索" + }, "searchNpx": "MCP を検索", "serverPlural": "サーバー", "serverSingular": "サーバー", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 2a04e0ccef..21251f332d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})", "rerank_model_support_provider": "Текущая модель переупорядочивания поддерживается только некоторыми поставщиками ({{provider}})", "rerank_model_tooltip": "В настройках -> Служба модели нажмите кнопку \"Управление\", чтобы добавить.", - "search": "Поиск моделей...", + "search": { + "placeholder": "Поиск моделей...", + "tooltip": "Поиск моделей" + }, "stream_output": "Потоковый вывод", "type": { "embedding": "Встраиваемые", @@ -2912,6 +2915,10 @@ "text": "Текст", "uri": "URI" }, + "search": { + "placeholder": "Найти MCP серверы...", + "tooltip": "Найти MCP серверы" + }, "searchNpx": "Найти MCP", "serverPlural": "серверы", "serverSingular": "сервер", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9db6cf963b..4307fb1208 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})", "rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})", "rerank_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加", - "search": "搜索模型...", + "search": { + "placeholder": "搜索模型...", + "tooltip": "搜索模型" + }, "stream_output": "流式输出", "type": { "embedding": "嵌入", @@ -2912,6 +2915,10 @@ "text": "文本", "uri": "URI" }, + "search": { + "placeholder": "搜索 MCP 服务器...", + "tooltip": "搜索 MCP 服务器" + }, "searchNpx": "搜索 MCP", "serverPlural": "服务器", "serverSingular": "服务器", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 04ce4ed230..e9a41e2813 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})", "rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})", "rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加", - "search": "搜尋模型...", + "search": { + "placeholder": "搜尋模型...", + "tooltip": "搜尋模型" + }, "stream_output": "串流輸出", "type": { "embedding": "嵌入", @@ -2912,6 +2915,10 @@ "text": "文字", "uri": "URI" }, + "search": { + "placeholder": "搜索 MCP 伺服器...", + "tooltip": "搜索 MCP 伺服器" + }, "searchNpx": "搜索 MCP", "serverPlural": "伺服器", "serverSingular": "伺服器", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index e095cbf0da..b0c96f2aa7 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "Ο επαναξιολογητικός μοντέλος δεν υποστηρίζει αυτόν τον πάροχο ({{provider}})", "rerank_model_support_provider": "Σημειώστε ότι το μοντέλο αναδιάταξης υποστηρίζεται από μερικούς παρόχους ({{provider}})", "rerank_model_tooltip": "Κάντε κλικ στο κουμπί Διαχείριση στο παράθυρο Ρυθμίσεις -> Υπηρεσία Μοντέλων", - "search": "Αναζήτηση μοντέλου...", + "search": { + "placeholder": "Αναζήτηση μοντέλου...", + "tooltip": "Αναζήτηση μοντέλου" + }, "stream_output": "Διαρκής Εξόδος", "type": { "embedding": "ενσωμάτωση", @@ -2912,6 +2915,10 @@ "text": "Κείμενο", "uri": "URI" }, + "search": { + "placeholder": "Αναζήτηση MCP διακομιστών...", + "tooltip": "Αναζήτηση MCP διακομιστών" + }, "searchNpx": "Αναζήτηση MCP", "serverPlural": "Διακομιστές", "serverSingular": "Διακομιστής", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 2ee02a11ec..efd6643820 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "Actualmente, el modelo de reordenamiento no admite este proveedor ({{provider}})", "rerank_model_support_provider": "Actualmente, el modelo de reordenamiento solo es compatible con algunos proveedores ({{provider}})", "rerank_model_tooltip": "Haga clic en el botón Administrar en Configuración->Servicio de modelos para agregar", - "search": "Buscar modelo...", + "search": { + "placeholder": "Buscar modelo...", + "tooltip": "Buscar modelo" + }, "stream_output": "Salida en flujo", "type": { "embedding": "Incrustación", @@ -2912,6 +2915,10 @@ "text": "Texto", "uri": "URI" }, + "search": { + "placeholder": "Buscar servidores MCP...", + "tooltip": "Buscar servidores MCP" + }, "searchNpx": "Buscar MCP", "serverPlural": "Servidores", "serverSingular": "Servidor", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 20364b838f..37008d5c4f 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "Le modèle de réordonnancement ne prend pas en charge ce fournisseur ({{provider}}) pour le moment", "rerank_model_support_provider": "Le modèle de réordonnancement ne prend actuellement en charge que certains fournisseurs ({{provider}})", "rerank_model_tooltip": "Cliquez sur le bouton Gérer dans Paramètres -> Services de modèles pour ajouter", - "search": "Rechercher un modèle...", + "search": { + "placeholder": "Rechercher un modèle...", + "tooltip": "Rechercher un modèle" + }, "stream_output": "Sortie en flux", "type": { "embedding": "Incorporation", @@ -2912,6 +2915,10 @@ "text": "Текст", "uri": "URI" }, + "search": { + "placeholder": "Rechercher des serveurs MCP...", + "tooltip": "Rechercher des serveurs MCP" + }, "searchNpx": "Поиск MCP", "serverPlural": "Serveurs", "serverSingular": "Serveur", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 5d54ecc50a..f245c4a463 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1544,7 +1544,10 @@ "rerank_model_not_support_provider": "Atualmente o modelo de reclassificação não suporta este provedor ({{provider}})", "rerank_model_support_provider": "O modelo de reclassificação atualmente suporta apenas alguns provedores ({{provider}})", "rerank_model_tooltip": "Clique no botão Gerenciar em Configurações -> Serviço de modelos para adicionar", - "search": "Procurar modelo...", + "search": { + "placeholder": "Procurar modelo...", + "tooltip": "Procurar modelo" + }, "stream_output": "Saída em fluxo", "type": { "embedding": "inserção", @@ -2912,6 +2915,10 @@ "text": "Texto", "uri": "URI" }, + "search": { + "placeholder": "Buscar servidores MCP...", + "tooltip": "Buscar servidores MCP" + }, "searchNpx": "Buscar MCP", "serverPlural": "Servidores", "serverSingular": "Servidor", diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index 57e6cc80c6..f9f98476d9 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -1,13 +1,15 @@ import { nanoid } from '@reduxjs/toolkit' -import { Sortable } from '@renderer/components/dnd' +import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar' +import { Sortable, useDndReorder } from '@renderer/components/dnd' import { EditIcon, RefreshIcon } from '@renderer/components/Icons' import Scrollbar from '@renderer/components/Scrollbar' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { MCPServer } from '@renderer/types' import { formatMcpError } from '@renderer/utils/error' +import { matchKeywordsInString } from '@renderer/utils/match' import { Button, Dropdown, Empty } from 'antd' import { Plus } from 'lucide-react' -import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FC, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import styled from 'styled-components' @@ -30,6 +32,32 @@ const McpServersList: FC = () => { const [loadingServerIds, setLoadingServerIds] = useState>(new Set()) const [serverVersions, setServerVersions] = useState>({}) + const [searchText, _setSearchText] = useState('') + + const setSearchText = useCallback((text: string) => { + startTransition(() => { + _setSearchText(text) + }) + }, []) + + const filteredMcpServers = useMemo(() => { + if (!searchText.trim()) return mcpServers + + const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean) + + return mcpServers.filter((server) => { + const searchTarget = `${server.name} ${server.description} ${server.tags?.join(' ')}` + return matchKeywordsInString(keywords, searchTarget) + }) + }, [mcpServers, searchText]) + + const { onSortEnd } = useDndReorder({ + originalList: mcpServers, + filteredList: filteredMcpServers, + onUpdate: updateMcpServers, + idKey: 'id' + }) + const scrollRef = useRef(null) // 简单的滚动位置记忆 @@ -190,8 +218,14 @@ const McpServersList: FC = () => { return ( - + {t('settings.mcp.newServer')} + @@ -213,14 +247,9 @@ const McpServersList: FC = () => { { - const newList = [...mcpServers] - const [removed] = newList.splice(oldIndex, 1) - newList.splice(newIndex, 0, removed) - updateMcpServers(newList) - }} + onSortEnd={onSortEnd} layout="grid" useDragOverlay showGhost @@ -236,10 +265,10 @@ const McpServersList: FC = () => { /> )} /> - {mcpServers.length === 0 && ( + {(mcpServers.length === 0 || filteredMcpServers.length === 0) && ( )} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx index 14832e5aee..58468f09bb 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx @@ -106,7 +106,11 @@ const ModelList: React.FC = ({ providerId }) => { {modelCount} )} - + From 3d7a64a11dc3cebb9fefbc62186c6a85f47134de Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 25 Aug 2025 20:41:26 +0800 Subject: [PATCH 72/96] fix: stream output option should not be true when undefined (#9518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 修复streamOutput默认值设置问题 --- src/renderer/src/pages/home/Tabs/SettingsTab.tsx | 2 +- .../pages/settings/AssistantSettings/AssistantModelSettings.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 8f9bf765ff..bbeb31985a 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -72,7 +72,7 @@ const SettingsTab: FC = (props) => { const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false) const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0) const [fontSizeValue, setFontSizeValue] = useState(fontSize) - const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true) + const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput) const { translateLanguages } = useTranslate() const { t } = useTranslation() diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 668f68e1e9..54c90a6b85 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -28,7 +28,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT) const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false) const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0) - const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true) + const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput) const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt') const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel) const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1) From 0af5a85f673977aa64db67e7c42491f6e2f9666f Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:13:24 +0800 Subject: [PATCH 73/96] feat: Image OCR (#9409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: 添加 tesseract.js 及其类型定义依赖 * feat(ocr): 添加OCR类型定义文件以支持OCR功能扩展 * feat(ocr): 添加 Tesseract OCR 提供程序配置 * feat(ocr): 添加Tesseract.js的logo * refactor(settings): 重构文档预处理设置模块结构 将PreprocessSettings重命名为DocProcessSettings并调整文件结构 更新相关路由和组件引用以保持功能一致性 * refactor(config): 重命名OCR_PROVIDER_CONFIG为BUILTIN_OCR_PROVIDERS以更准确描述用途 * refactor(ocr): 更改文件名 * refactor(ocr): 将获取OCR提供商logo的功能移动到utils目录 将getOcrProviderLogo函数从config/ocr.ts移动到utils/ocr.ts,保持功能集中 * refactor(ocr): 重构OCR配置结构以支持默认提供者 将内置OCR提供者数组重构为单独定义的常量,并添加默认OCR提供者映射。这提高了代码的可维护性并支持未来扩展。 * feat(store): 添加OCR状态管理切片 实现OCR提供商的增删改查功能,使用Redux Toolkit管理OCR相关状态 * feat(types): 添加图片文件类型守卫函数 添加 ImageFileMetadata 类型和 isImageFile 类型守卫函数,用于检查文件是否为图片类型 * feat(ocr): 添加对OCR支持文件类型的类型定义和校验函数 添加SupportedOcrFileType类型和isSupportedOcrFileType校验函数 添加SupportedOcrFile类型和isSupportedOcrFile校验函数 * feat(ocr): 添加OCR功能支持 实现基于Tesseract的OCR功能,包括文件类型检查、服务接口和IPC通信 新增OCR相关类型定义和服务实现 * refactor(OcrService): 更新日志上下文为'main:OcrService' * feat(ocr): 添加OCR服务基础功能 实现OCR服务的基础功能,通过调用window.api.ocr接口处理支持的文件类型 * feat(store): 添加ocr模块到redux store * feat(ocr): 添加OCR功能支持及文件类型校验 添加OCR功能钩子useOcr,支持图片文件识别 添加不支持文件类型的错误提示国际化文案 * refactor(ocr): 重命名updatePreprocessProvider为updateOcrProvider以保持命名一致性 * feat(ocr): 添加设置图片OCR提供商的功能 * refactor(ocr): 统一OCR类型导入路径 将所有OCR相关类型从'@renderer/types/ocr'改为从'@renderer/types'或'@types'导入 优化DEFAULT_OCR_PROVIDER类型定义 * feat(store): 更新持久化存储版本并添加OCR配置迁移 添加137版本迁移逻辑,初始化OCR提供者和默认图像提供者配置 * feat(ocr): 添加OCR服务设置界面及提供商选择功能 实现OCR服务设置界面,包含图片OCR提供商的选择功能 修复ocr.ts中imageProvider的类型定义 添加相关国际化文本 * fix(ocr): 添加图像大小检查并优化错误处理 检查图像文件大小是否超过50MB限制 使用buffer读取文件替代直接路径识别 简化错误处理逻辑,直接抛出原始错误 * feat(OCR服务): 支持base64字符串作为OCR输入 扩展tesseractOcr函数以接受base64字符串或图像文件作为输入 * build: 将 tesseract.js 从 devDependencies 移至 dependencies 确保生产环境能正确使用 tesseract.js 功能 * refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置 * refactor(TesseractService): 添加日志记录并更新worker配置 添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger * feat(i18n): 添加OCR功能的多语言支持 * refactor(preload): 移动OCR类型定义到共享类型文件 将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性 * refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本 返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑 * fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置 将错误抛出语句移至else分支 * refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义 * fix(ocr): 改进OCR处理中的消息管理和错误处理 在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑 移除TranslatePage中重复的消息管理代码,简化OCR处理流程 * feat(i18n): 添加OCR相关的错误和状态翻译文本 * fix(useOcr): 修复未支持文件类型错误抛出位置 将不支持的OCR文件类型错误抛出逻辑移至条件判断内 * refactor(ocr): ocrImage实现使用OcrService并更新日志上下文 将ocrImage函数从useOcr钩子移动到OcrService中,提高代码复用性 更新日志服务上下文从'main'改为'renderer'以更准确反映模块位置 * style(TabContainer): 移除多余的空行并保持代码整洁 * refactor(ocr): 简化OCR文件类型检查逻辑 使用现有的isImageFile函数替代冗余的类型检查逻辑,提高代码复用性 * fix: 将迁移错误日志从136更新为137 * feat(ocr): enhance Tesseract service with language support and worker management - Added support for multiple Tesseract languages: Chinese (Simplified and Traditional) and English. - Refactored Tesseract worker management into a class for better encapsulation and reuse. - Introduced methods to dynamically determine language path based on IP country and manage worker lifecycle. * update cn url * support cn data * change to asyn * use register design mode * add type * use bind function * refactor(ipc): 简化OCR处理程序参数 * refactor(ocr): 修改ocrProviderCapabilityRecord类型定义 允许只定义部分能力 * refactor(ocr): 将Tesseract相关配置移至服务内部 将语言列表和下载URL常量从共享配置移至Tesseract服务内部 使用常量定义图片大小阈值以提高可读性 * refactor(ocr): 统一使用 SupportedOcrFile 类型替换 FileMetadata 更新 OCR 服务及其 Tesseract 实现,使用 SupportedOcrFile 类型替代原有的 FileMetadata 类型,以提高类型安全性和一致性。同时在 OcrService 中添加重复注册的警告日志。 * refactor(ocr): 重构OCR类型定义以支持模型和API配置 将OCR提供者配置拆分为独立类型,增加模型能力记录和API配置类型检查 添加OCR处理程序类型定义,为未来扩展提供更好的类型支持 * refactor(OcrService): 移除重复的OcrHandler类型定义 已在@types中定义OcrHandler类型,移除重复定义以提高代码一致性 * refactor(ocr): 将OcrService移动到ocr目录下并更新引用路径 * feat(ocr): 添加OCR API客户端工厂及示例实现 实现OCR API客户端工厂模式,支持根据不同提供商创建对应的客户端 新增OcrBaseApiClient作为基础类,提供通用功能 添加OcrExampleApiClient作为示例实现 修改OcrService以使用新的客户端工厂 * refactor(ocr): 添加日志记录以跟踪OCR文件处理 在OCR服务中添加日志记录功能,便于跟踪文件处理过程 * fix(deps): 更新 tesseract.js 依赖并添加补丁文件 修复 tesseract.js 类型定义问题并添加语言常量支持 * refactor(ocr): 移除注释掉的tesseract语言映射代码 使用Tesseract.js的LanguageCode类型替代硬编码的语言列表,提高类型安全性 * feat(ocr): 添加 Tesseract OCR 配置类型 * refactor(OCR设置): 重命名OcrImageProviderSettings为OcrImageSettings并优化代码结构 * refactor(ocr): 将 Tesseract 相关类型移动到文件底部以改善代码组织 * feat(ocr): 添加 Tesseract OCR 提供者类型检查函数 * feat(ocr): 添加更新OCR提供者配置的功能 * feat: 添加OCR提供者钩子函数 实现useOcrProvider钩子用于获取和更新OCR提供者配置 * refactor(ocr): 修改removeOcrProvider参数为字符串id 简化removeOcrProvider方法的参数类型,直接使用字符串id进行过滤,提高代码简洁性 * refactor(ocr): 将内置OCR提供者从数组改为映射结构 重构OCR配置模块,使用映射结构存储内置OCR提供者以便于扩展和维护 * refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组 使用Object.freeze确保数组不可变,提高代码安全性 * feat(ocr): 添加OCR提供者管理功能并改进错误处理 添加useOcrProviders钩子用于管理OCR提供者的添加和删除 当内置OCR提供者不存在时自动恢复默认配置 改进错误提示信息并增加国际化支持 * Revert "refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组" This reverts commit f23e37941abba4fcc703b31e955b67bff565c432. * feat(ocr): 为Tesseract OCR添加多语言支持配置 添加对简体中文、繁体中文和英文的语言支持配置,扩展OCR功能以满足多语言识别需求 * refactor(types): 将Tesseract.LanguageCode重命名为TesseractLangCode以提高可读性 * feat(OCR设置): 添加OCR提供商设置组件及状态管理 新增OCR提供商设置组件,支持显示当前选择的OCR提供商信息 在OCR图片设置中添加状态管理,同步提供商选择到父组件 添加Tesseract OCR设置组件,支持多语言选择(暂不可用) * fix(DocProcessSettings): 修复OCR语言选择默认值问题 * feat(i18n): 添加OCR提供商相关错误和警告的翻译 * fix(ocr): 将 Tesseract 语言配置类型改为部分 * fix(ocr): 修复ocrImage函数未使用await导致的问题 * fix(ocr): 修复迁移配置中ocr状态的初始化方式 将分散的属性赋值改为对象整体赋值,避免潜在的属性丢失问题 * chore: 移除不再使用的@types/tesseract.js依赖 * refactor(OCR设置): 添加错误边界处理并移除无用注释 在OCR设置组件中添加ErrorBoundary以处理潜在错误 移除OcrTesseractSettings中的TODO注释 * build: 添加 sharp 依赖以支持图片处理功能 * refactor(ocr): 添加OCR图像预处理功能并优化TesseractService Co-authored-by: Qwen-Coder * refactor(ocr): 移除独立的灰度处理模块并改进预处理流程 将灰度处理功能直接集成到OCR预处理中,不再需要单独的image模块 添加normalise和threshold处理以提升OCR识别效果 * improve image preprocess --------- Co-authored-by: beyondkmp Co-authored-by: Qwen-Coder --- .../tesseract.js-npm-6.0.1-2562a7e46d.patch | 348 +++++++++++++++ package.json | 5 +- packages/shared/IpcChannel.ts | 5 +- src/main/ipc.ts | 4 + src/main/services/ocr/OcrService.ts | 34 ++ .../ocr/tesseract/TesseractService.ts | 82 ++++ src/main/utils/ocr.ts | 29 ++ src/preload/index.ts | 7 + .../assets/images/providers/Tesseract.js.png | Bin 0 -> 23940 bytes src/renderer/src/config/ocr.ts | 32 ++ src/renderer/src/config/ocrProviders.ts | 12 - src/renderer/src/hooks/useOcr.ts | 54 +++ src/renderer/src/hooks/useOcrProvider.ts | 84 ++++ src/renderer/src/i18n/locales/en-us.json | 34 ++ src/renderer/src/i18n/locales/ja-jp.json | 34 ++ src/renderer/src/i18n/locales/ru-ru.json | 34 ++ src/renderer/src/i18n/locales/zh-cn.json | 34 ++ src/renderer/src/i18n/locales/zh-tw.json | 34 ++ src/renderer/src/i18n/translate/el-gr.json | 34 ++ src/renderer/src/i18n/translate/es-es.json | 34 ++ src/renderer/src/i18n/translate/fr-fr.json | 34 ++ src/renderer/src/i18n/translate/pt-pt.json | 34 ++ .../DocProcessSettings/OcrImageSettings.tsx | 62 +++ .../OcrProviderSettings.tsx | 52 +++ .../DocProcessSettings/OcrSettings.tsx | 42 ++ .../OcrTesseractSettings.tsx | 51 +++ .../PreprocessProviderSettings.tsx} | 0 .../PreprocessSettings.tsx} | 8 +- .../settings/DocProcessSettings/index.tsx | 18 + .../src/pages/settings/SettingsPage.tsx | 8 +- src/renderer/src/services/ocr/OcrService.ts | 23 + .../ocr/clients/OcrApiClientFactory.ts | 28 ++ .../services/ocr/clients/OcrBaseApiClient.ts | 43 ++ .../ocr/clients/OcrExampleApiClient.ts | 15 + src/renderer/src/store/index.ts | 6 +- src/renderer/src/store/migrate.ts | 13 + src/renderer/src/store/ocr.ts | 61 +++ src/renderer/src/types/file.ts | 13 + src/renderer/src/types/index.ts | 2 + src/renderer/src/types/ocr.ts | 142 +++++++ src/renderer/src/utils/ocr.ts | 12 + yarn.lock | 398 +++++++++++++++++- 42 files changed, 1972 insertions(+), 27 deletions(-) create mode 100644 .yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch create mode 100644 src/main/services/ocr/OcrService.ts create mode 100644 src/main/services/ocr/tesseract/TesseractService.ts create mode 100644 src/main/utils/ocr.ts create mode 100644 src/renderer/src/assets/images/providers/Tesseract.js.png create mode 100644 src/renderer/src/config/ocr.ts delete mode 100644 src/renderer/src/config/ocrProviders.ts create mode 100644 src/renderer/src/hooks/useOcr.ts create mode 100644 src/renderer/src/hooks/useOcrProvider.ts create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/OcrProviderSettings.tsx create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/OcrSettings.tsx create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/OcrTesseractSettings.tsx rename src/renderer/src/pages/settings/{PreprocessSettings/PreprocessSettings.tsx => DocProcessSettings/PreprocessProviderSettings.tsx} (100%) rename src/renderer/src/pages/settings/{PreprocessSettings/index.tsx => DocProcessSettings/PreprocessSettings.tsx} (90%) create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/index.tsx create mode 100644 src/renderer/src/services/ocr/OcrService.ts create mode 100644 src/renderer/src/services/ocr/clients/OcrApiClientFactory.ts create mode 100644 src/renderer/src/services/ocr/clients/OcrBaseApiClient.ts create mode 100644 src/renderer/src/services/ocr/clients/OcrExampleApiClient.ts create mode 100644 src/renderer/src/store/ocr.ts create mode 100644 src/renderer/src/types/ocr.ts create mode 100644 src/renderer/src/utils/ocr.ts diff --git a/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch b/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch new file mode 100644 index 0000000000..0cb156ee99 --- /dev/null +++ b/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch @@ -0,0 +1,348 @@ +diff --git a/src/constants/languages.d.ts b/src/constants/languages.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..6a2ba5086187622b8ca8887bcc7406018fba8a89 +--- /dev/null ++++ b/src/constants/languages.d.ts +@@ -0,0 +1,43 @@ ++/** ++ * Languages with existing tesseract traineddata ++ * https://tesseract-ocr.github.io/tessdoc/Data-Files#data-files-for-version-400-november-29-2016 ++ */ ++ ++// Define the language codes as string literals ++type LanguageCode = ++ | 'afr' | 'amh' | 'ara' | 'asm' | 'aze' | 'aze_cyrl' | 'bel' | 'ben' | 'bod' | 'bos' ++ | 'bul' | 'cat' | 'ceb' | 'ces' | 'chi_sim' | 'chi_tra' | 'chr' | 'cym' | 'dan' | 'deu' ++ | 'dzo' | 'ell' | 'eng' | 'enm' | 'epo' | 'est' | 'eus' | 'fas' | 'fin' | 'fra' ++ | 'frk' | 'frm' | 'gle' | 'glg' | 'grc' | 'guj' | 'hat' | 'heb' | 'hin' | 'hrv' ++ | 'hun' | 'iku' | 'ind' | 'isl' | 'ita' | 'ita_old' | 'jav' | 'jpn' | 'kan' | 'kat' ++ | 'kat_old' | 'kaz' | 'khm' | 'kir' | 'kor' | 'kur' | 'lao' | 'lat' | 'lav' | 'lit' ++ | 'mal' | 'mar' | 'mkd' | 'mlt' | 'msa' | 'mya' | 'nep' | 'nld' | 'nor' | 'ori' ++ | 'pan' | 'pol' | 'por' | 'pus' | 'ron' | 'rus' | 'san' | 'sin' | 'slk' | 'slv' ++ | 'spa' | 'spa_old' | 'sqi' | 'srp' | 'srp_latn' | 'swa' | 'swe' | 'syr' | 'tam' | 'tel' ++ | 'tgk' | 'tgl' | 'tha' | 'tir' | 'tur' | 'uig' | 'ukr' | 'urd' | 'uzb' | 'uzb_cyrl' ++ | 'vie' | 'yid'; ++ ++// Define the language keys as string literals ++type LanguageKey = ++ | 'AFR' | 'AMH' | 'ARA' | 'ASM' | 'AZE' | 'AZE_CYRL' | 'BEL' | 'BEN' | 'BOD' | 'BOS' ++ | 'BUL' | 'CAT' | 'CEB' | 'CES' | 'CHI_SIM' | 'CHI_TRA' | 'CHR' | 'CYM' | 'DAN' | 'DEU' ++ | 'DZO' | 'ELL' | 'ENG' | 'ENM' | 'EPO' | 'EST' | 'EUS' | 'FAS' | 'FIN' | 'FRA' ++ | 'FRK' | 'FRM' | 'GLE' | 'GLG' | 'GRC' | 'GUJ' | 'HAT' | 'HEB' | 'HIN' | 'HRV' ++ | 'HUN' | 'IKU' | 'IND' | 'ISL' | 'ITA' | 'ITA_OLD' | 'JAV' | 'JPN' | 'KAN' | 'KAT' ++ | 'KAT_OLD' | 'KAZ' | 'KHM' | 'KIR' | 'KOR' | 'KUR' | 'LAO' | 'LAT' | 'LAV' | 'LIT' ++ | 'MAL' | 'MAR' | 'MKD' | 'MLT' | 'MSA' | 'MYA' | 'NEP' | 'NLD' | 'NOR' | 'ORI' ++ | 'PAN' | 'POL' | 'POR' | 'PUS' | 'RON' | 'RUS' | 'SAN' | 'SIN' | 'SLK' | 'SLV' ++ | 'SPA' | 'SPA_OLD' | 'SQI' | 'SRP' | 'SRP_LATN' | 'SWA' | 'SWE' | 'SYR' | 'TAM' | 'TEL' ++ | 'TGK' | 'TGL' | 'THA' | 'TIR' | 'TUR' | 'UIG' | 'UKR' | 'URD' | 'UZB' | 'UZB_CYRL' ++ | 'VIE' | 'YID'; ++ ++// Create a mapped type to ensure each key maps to its specific value ++type LanguagesMap = { ++ [K in LanguageKey]: LanguageCode; ++}; ++ ++// Declare the exported constant with the specific type ++export const LANGUAGES: LanguagesMap; ++ ++// Export the individual types for use in other modules ++export type { LanguageCode, LanguageKey, LanguagesMap }; +\ No newline at end of file +diff --git a/src/index.d.ts b/src/index.d.ts +index 1f5a9c8094fe4de7983467f9efb43bdb4de535f2..16dc95cf68663673e37e189b719cb74897b7735f 100644 +--- a/src/index.d.ts ++++ b/src/index.d.ts +@@ -1,31 +1,74 @@ ++// Import the languages types ++import { LanguagesMap } from "./constants/languages"; ++ ++/// ++ + declare namespace Tesseract { +- function createScheduler(): Scheduler +- function createWorker(langs?: string | string[] | Lang[], oem?: OEM, options?: Partial, config?: string | Partial): Promise +- function setLogging(logging: boolean): void +- function recognize(image: ImageLike, langs?: string, options?: Partial): Promise +- function detect(image: ImageLike, options?: Partial): any ++ function createScheduler(): Scheduler; ++ function createWorker( ++ langs?: LanguageCode | LanguageCode[] | Lang[], ++ oem?: OEM, ++ options?: Partial, ++ config?: string | Partial ++ ): Promise; ++ function setLogging(logging: boolean): void; ++ function recognize( ++ image: ImageLike, ++ langs?: LanguageCode, ++ options?: Partial ++ ): Promise; ++ function detect(image: ImageLike, options?: Partial): any; ++ ++ // Export languages constant ++ const languages: LanguagesMap; ++ ++ type LanguageCode = import("./constants/languages").LanguageCode; ++ type LanguageKey = import("./constants/languages").LanguageKey; + + interface Scheduler { +- addWorker(worker: Worker): string +- addJob(action: 'recognize', ...args: Parameters): Promise +- addJob(action: 'detect', ...args: Parameters): Promise +- terminate(): Promise +- getQueueLen(): number +- getNumWorkers(): number ++ addWorker(worker: Worker): string; ++ addJob( ++ action: "recognize", ++ ...args: Parameters ++ ): Promise; ++ addJob( ++ action: "detect", ++ ...args: Parameters ++ ): Promise; ++ terminate(): Promise; ++ getQueueLen(): number; ++ getNumWorkers(): number; + } + + interface Worker { +- load(jobId?: string): Promise +- writeText(path: string, text: string, jobId?: string): Promise +- readText(path: string, jobId?: string): Promise +- removeText(path: string, jobId?: string): Promise +- FS(method: string, args: any[], jobId?: string): Promise +- reinitialize(langs?: string | Lang[], oem?: OEM, config?: string | Partial, jobId?: string): Promise +- setParameters(params: Partial, jobId?: string): Promise +- getImage(type: imageType): string +- recognize(image: ImageLike, options?: Partial, output?: Partial, jobId?: string): Promise +- detect(image: ImageLike, jobId?: string): Promise +- terminate(jobId?: string): Promise ++ load(jobId?: string): Promise; ++ writeText( ++ path: string, ++ text: string, ++ jobId?: string ++ ): Promise; ++ readText(path: string, jobId?: string): Promise; ++ removeText(path: string, jobId?: string): Promise; ++ FS(method: string, args: any[], jobId?: string): Promise; ++ reinitialize( ++ langs?: string | Lang[], ++ oem?: OEM, ++ config?: string | Partial, ++ jobId?: string ++ ): Promise; ++ setParameters( ++ params: Partial, ++ jobId?: string ++ ): Promise; ++ getImage(type: imageType): string; ++ recognize( ++ image: ImageLike, ++ options?: Partial, ++ output?: Partial, ++ jobId?: string ++ ): Promise; ++ detect(image: ImageLike, jobId?: string): Promise; ++ terminate(jobId?: string): Promise; + } + + interface Lang { +@@ -34,43 +77,43 @@ declare namespace Tesseract { + } + + interface InitOptions { +- load_system_dawg: string +- load_freq_dawg: string +- load_unambig_dawg: string +- load_punc_dawg: string +- load_number_dawg: string +- load_bigram_dawg: string +- } +- +- type LoggerMessage = { +- jobId: string +- progress: number +- status: string +- userJobId: string +- workerId: string ++ load_system_dawg: string; ++ load_freq_dawg: string; ++ load_unambig_dawg: string; ++ load_punc_dawg: string; ++ load_number_dawg: string; ++ load_bigram_dawg: string; + } +- ++ ++ type LoggerMessage = { ++ jobId: string; ++ progress: number; ++ status: string; ++ userJobId: string; ++ workerId: string; ++ }; ++ + interface WorkerOptions { +- corePath: string +- langPath: string +- cachePath: string +- dataPath: string +- workerPath: string +- cacheMethod: string +- workerBlobURL: boolean +- gzip: boolean +- legacyLang: boolean +- legacyCore: boolean +- logger: (arg: LoggerMessage) => void, +- errorHandler: (arg: any) => void ++ corePath: string; ++ langPath: string; ++ cachePath: string; ++ dataPath: string; ++ workerPath: string; ++ cacheMethod: string; ++ workerBlobURL: boolean; ++ gzip: boolean; ++ legacyLang: boolean; ++ legacyCore: boolean; ++ logger: (arg: LoggerMessage) => void; ++ errorHandler: (arg: any) => void; + } + interface WorkerParams { +- tessedit_pageseg_mode: PSM +- tessedit_char_whitelist: string +- tessedit_char_blacklist: string +- preserve_interword_spaces: string +- user_defined_dpi: string +- [propName: string]: any ++ tessedit_pageseg_mode: PSM; ++ tessedit_char_whitelist: string; ++ tessedit_char_blacklist: string; ++ preserve_interword_spaces: string; ++ user_defined_dpi: string; ++ [propName: string]: any; + } + interface OutputFormats { + text: boolean; +@@ -88,36 +131,36 @@ declare namespace Tesseract { + debug: boolean; + } + interface RecognizeOptions { +- rectangle: Rectangle +- pdfTitle: string +- pdfTextOnly: boolean +- rotateAuto: boolean +- rotateRadians: number ++ rectangle: Rectangle; ++ pdfTitle: string; ++ pdfTextOnly: boolean; ++ rotateAuto: boolean; ++ rotateRadians: number; + } + interface ConfigResult { +- jobId: string +- data: any ++ jobId: string; ++ data: any; + } + interface RecognizeResult { +- jobId: string +- data: Page ++ jobId: string; ++ data: Page; + } + interface DetectResult { +- jobId: string +- data: DetectData ++ jobId: string; ++ data: DetectData; + } + interface DetectData { +- tesseract_script_id: number | null +- script: string | null +- script_confidence: number | null +- orientation_degrees: number | null +- orientation_confidence: number | null ++ tesseract_script_id: number | null; ++ script: string | null; ++ script_confidence: number | null; ++ orientation_degrees: number | null; ++ orientation_confidence: number | null; + } + interface Rectangle { +- left: number +- top: number +- width: number +- height: number ++ left: number; ++ top: number; ++ width: number; ++ height: number; + } + enum OEM { + TESSERACT_ONLY, +@@ -126,28 +169,36 @@ declare namespace Tesseract { + DEFAULT, + } + enum PSM { +- OSD_ONLY = '0', +- AUTO_OSD = '1', +- AUTO_ONLY = '2', +- AUTO = '3', +- SINGLE_COLUMN = '4', +- SINGLE_BLOCK_VERT_TEXT = '5', +- SINGLE_BLOCK = '6', +- SINGLE_LINE = '7', +- SINGLE_WORD = '8', +- CIRCLE_WORD = '9', +- SINGLE_CHAR = '10', +- SPARSE_TEXT = '11', +- SPARSE_TEXT_OSD = '12', +- RAW_LINE = '13' ++ OSD_ONLY = "0", ++ AUTO_OSD = "1", ++ AUTO_ONLY = "2", ++ AUTO = "3", ++ SINGLE_COLUMN = "4", ++ SINGLE_BLOCK_VERT_TEXT = "5", ++ SINGLE_BLOCK = "6", ++ SINGLE_LINE = "7", ++ SINGLE_WORD = "8", ++ CIRCLE_WORD = "9", ++ SINGLE_CHAR = "10", ++ SPARSE_TEXT = "11", ++ SPARSE_TEXT_OSD = "12", ++ RAW_LINE = "13", + } + const enum imageType { + COLOR = 0, + GREY = 1, +- BINARY = 2 ++ BINARY = 2, + } +- type ImageLike = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement +- | CanvasRenderingContext2D | File | Blob | Buffer | OffscreenCanvas; ++ type ImageLike = ++ | string ++ | HTMLImageElement ++ | HTMLCanvasElement ++ | HTMLVideoElement ++ | CanvasRenderingContext2D ++ | File ++ | Blob ++ | (typeof Buffer extends undefined ? never : Buffer) ++ | OffscreenCanvas; + interface Block { + paragraphs: Paragraph[]; + text: string; +@@ -179,7 +230,7 @@ declare namespace Tesseract { + text: string; + confidence: number; + baseline: Baseline; +- rowAttributes: RowAttributes ++ rowAttributes: RowAttributes; + bbox: Bbox; + } + interface Paragraph { diff --git a/package.json b/package.json index 472326ee65..5613c74d3f 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", "selection-hook": "^1.0.11", + "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "turndown": "7.2.0" }, "devDependencies": { @@ -257,6 +258,7 @@ "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", + "sharp": "^0.34.3", "shiki": "^3.9.1", "strict-url-sanitise": "^0.0.1", "string-width": "^7.2.0", @@ -296,7 +298,8 @@ "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "undici": "6.21.2", - "vite": "npm:rolldown-vite@latest" + "vite": "npm:rolldown-vite@latest", + "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 56ebfb3d58..f35db50bc6 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -281,5 +281,8 @@ export enum IpcChannel { TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage', // CodeTools - CodeTools_Run = 'code-tools:run' + CodeTools_Run = 'code-tools:run', + + // OCR + OCR_ocr = 'ocr:ocr' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2183c30831..3d72b67390 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -30,6 +30,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' +import { ocrService } from './services/ocr/OcrService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' @@ -709,4 +710,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // CodeTools ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) + + // OCR + ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters) => ocrService.ocr(...args)) } diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts new file mode 100644 index 0000000000..6ac8c311e3 --- /dev/null +++ b/src/main/services/ocr/OcrService.ts @@ -0,0 +1,34 @@ +import { loggerService } from '@logger' +import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' + +import { tesseractService } from './tesseract/TesseractService' + +const logger = loggerService.withContext('OcrService') + +export class OcrService { + private registry: Map = new Map() + + register(providerId: string, handler: OcrHandler): void { + if (this.registry.has(providerId)) { + logger.warn(`Provider ${providerId} has existing handler. Overwrited.`) + } + this.registry.set(providerId, handler) + } + + unregister(providerId: string): void { + this.registry.delete(providerId) + } + + public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise { + const handler = this.registry.get(provider.id) + if (!handler) { + throw new Error(`Provider ${provider.id} is not registered`) + } + return handler(file) + } +} + +export const ocrService = new OcrService() + +// Register built-in providers +ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService)) diff --git a/src/main/services/ocr/tesseract/TesseractService.ts b/src/main/services/ocr/tesseract/TesseractService.ts new file mode 100644 index 0000000000..d2ba6d2ed8 --- /dev/null +++ b/src/main/services/ocr/tesseract/TesseractService.ts @@ -0,0 +1,82 @@ +import { loggerService } from '@logger' +import { getIpCountry } from '@main/utils/ipService' +import { loadOcrImage } from '@main/utils/ocr' +import { MB } from '@shared/config/constant' +import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types' +import { app } from 'electron' +import fs from 'fs' +import path from 'path' +import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' + +const logger = loggerService.withContext('TesseractService') + +// config +const MB_SIZE_THRESHOLD = 50 +const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[] +enum TesseractLangsDownloadUrl { + CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/', + GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/' +} + +export class TesseractService { + private worker: Tesseract.Worker | null = null + + async getWorker(): Promise { + if (!this.worker) { + // for now, only support limited languages + this.worker = await createWorker(tesseractLangs, undefined, { + langPath: await this._getLangPath(), + cachePath: await this._getCacheDir(), + gzip: false, + logger: (m) => logger.debug('From worker', m) + }) + } + return this.worker + } + + async imageOcr(file: ImageFileMetadata): Promise { + const worker = await this.getWorker() + const stat = await fs.promises.stat(file.path) + if (stat.size > MB_SIZE_THRESHOLD * MB) { + throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`) + } + const buffer = await loadOcrImage(file) + const result = await worker.recognize(buffer) + return { text: result.data.text } + } + + async ocr(file: SupportedOcrFile): Promise { + if (!isImageFile(file)) { + throw new Error('Only image files are supported currently') + } + return this.imageOcr(file) + } + + private async _getLangPath(): Promise { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL + } + + private async _getCacheDir(): Promise { + const cacheDir = path.join(app.getPath('userData'), 'tesseract') + // use access to check if the directory exists + if ( + !(await fs.promises + .access(cacheDir, fs.constants.F_OK) + .then(() => true) + .catch(() => false)) + ) { + await fs.promises.mkdir(cacheDir, { recursive: true }) + } + return cacheDir + } + + async dispose(): Promise { + if (this.worker) { + await this.worker.terminate() + this.worker = null + } + } +} + +export const tesseractService = new TesseractService() diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts new file mode 100644 index 0000000000..b0079f2a50 --- /dev/null +++ b/src/main/utils/ocr.ts @@ -0,0 +1,29 @@ +import { ImageFileMetadata } from '@types' +import { readFile } from 'fs/promises' +import sharp from 'sharp' + +const preprocessImage = async (buffer: Buffer) => { + return await sharp(buffer) + .grayscale() // 转为灰度 + .normalize() + .sharpen() + .threshold(100) // 可能需要根据具体图片调整 + .png({ quality: 100 }) + .toBuffer() +} + +/** + * 加载并预处理OCR图像 + * @param file - 图像文件元数据 + * @returns 预处理后的图像Buffer + * @throws {Error} 当文件不存在或无法读取时抛出错误;当图像预处理失败时抛出错误 + * + * 预处理步骤: + * 1. 读取图像文件 + * 2. 转换为灰度图 + * 3. 后续可扩展其他预处理步骤 + */ +export const loadOcrImage = async (file: ImageFileMetadata): Promise => { + const buffer = await readFile(file.path) + return await preprocessImage(buffer) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1059826224..af4803fd50 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,9 +17,12 @@ import { MemoryConfig, MemoryListOptions, MemorySearchOptions, + OcrProvider, + OcrResult, Provider, S3Config, Shortcut, + SupportedOcrFile, ThemeMode, WebDavConfig } from '@types' @@ -406,6 +409,10 @@ const api = { env: Record, options?: { autoUpdateToLatest?: boolean } ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options) + }, + ocr: { + ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => + ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider) } } diff --git a/src/renderer/src/assets/images/providers/Tesseract.js.png b/src/renderer/src/assets/images/providers/Tesseract.js.png new file mode 100644 index 0000000000000000000000000000000000000000..d60b9b68780650003097cf24063bbc4d4293d6fe GIT binary patch literal 23940 zcmeEt^;=Y5)bGKOK^g%`DM{%RB!}*j?ozs>o1r_DR=T^SyGuF*Y3WW8>3i^d?{oix z_qUhlVdlW>z4pqzK5OkgVM+>;=qN-e005v%ONqS$fG6PN6X4n7nqW$<;5YHbm7%S<^Zm^@|zdv@NBxF{A>gz7ggu(puef}Ca0S#VhPY(b>5*- zE(2?j#Mdw=?5FID+7TTuRDCU6&+WO@40Ox}i{yt9N{H{B@?5fw#jlhZeCE1$@!n#P z%!6!4dHnW>*PhPOi|Ay3F;C7ee*ZatIr)ch=P=}9^9VDb;7}k$)g^ zjf#-|@7XIix2wC~kcW20XaQ_Jm+~+silHG;nnX1<@AEh9U9@K69&6?mt=G-h!eKyt z$5KaZ)NAFRTrC8drSwF=2jMh3+qa*TPA?VL?OyK1;I#fY-@QkwLm+PGr+q)#$ry=T z`!0SH6pAq~F;RWkHF`PGV38eL5+AE8iu61vv1P2NI*tB_t8XtlwkD+s2mQ#7=#UTFr6%mjZ%pSf+dpO$S zs9TAA{_)!wERFf{pk>FKqPKS2jSZ%!!U=@$h0BzDRVVrG{ob-6M5EqA&=?;=r*z*) z|C|jn0@|mX2J}}wor`T2pBKv41wm}QIp}tpT!MYQ9V~rN`MHEo>Y9o;ndRhh zfxwFi?xNcj#h7s{>+tq#785CQpEDt=I1!Fw z0zo+oP+Rei`jk&JvAX?RB%Bmf53Za(sQxbh*r{^)KHYSW>M_bFE7<5uwgTi|4_K+z zEUoA@{65YPsu1|3q2E0%9S#PS<}>Y-o4mwXb3d2%t6lBAjqef0n34%xtj?bwBNC@e zt?v2-zu?rn!+X+P1rIx^uz}+LXj@ZkaFNDTxY<}NMv0dFJUSNM+c*f&GLuQ+l?IKp zKQv(VvXT0tZsLdCrR$~fY84?!{U>j}!aFdG97McOL{wvE%}ZQ->zSdp1bYvApBr+t z_o#G4aKV5K#7e1VP#UwU`Ow_nj25;afrzn^O5~Xzj0yoiey15KeZ_WlAe2bE$T@uU zk2Rm$nUqC`f7E5T2;HMXrG+)arJ7f!Ryi(v+xw?<4vbfeetzP6f>N^Q&ISWH6jxPs z_t|~kFB&I)Ml>?4KOQ))c6qS0Ko<9aR-Ji|wyJv9ryAcw$sx zI#1E?PqT$&9Urb~&2!dgVmf7RsR+3Dq9DHv5P1=Q)Y@~GaM1U>f!dOhaC+!y8{6uk z>cK%}9?=A5kU!Zp$VhRIdc`8MnzN#lgtkTGlN&lUQy;aLhR||+18xGRC2G@LcGnaS z%5P=T3NVs}5?|Hv@ou!cu2Z zdX>St>iTTFl?{b6`(DKA8qt3$5v({hKeEdYbT^kYsai}h@WriLyLrjsns zrwZ?t-EF3>!3?T=k%op;lBagHid(sBwz=qo z2A#?flEhrW%Rln=6z6jXnRXHe84@vSJTWQ*`d!7Qo9nM0Kg5T#R( z?n`WM;yHpc{gq>PKD)aZ{8jKTgbMfD>$IbLlOAk}POMNbQ#9*PzL&D=>(CRyLK1A_ zO{LJrRcOtQ#)g=G^#Wk9mrowWh^`yU7W4(NP6*pTSmV)y0|og??m}M3r#+o0%ucko z07G8S!#V8lCj)sj=O;YhaYz_VfWAL3QHbKX=xnJfri4x;HTG`i&K%{fk(D`t1=Q|{ z3?$qy-4T;wA?kWRIDmR%smy^JLlqe`6BDi-D{CQ6#dBv$j>k^BUlbd6Zv{Pjr zLMR7SstWD#S1k5pB^QOOM)jfcY=k-GWEHug65?ioyFU}q4p{sS)_ZIcYh!>$)HsAy z^hrj6skrT>$zCpq5GvD97{*xMuDRKNo;XTE6L6*Bof`pC39;$mXu*ModsIFnamaFM zHEHI+8|*Wp%cuC13SvTc(%2*t*3wZncCdw?fLySc(x<;%XlopwcvE6^zE4ZvghCBb zf4vAkiFH+%-r^z>3u$MbkWeC8kWexQ@|DoCs6>o%odY-C(UK(-Tn}=aCG%R~OaP3` zIkWs9H6-)8qy#a*#E_{jvnvh`e)G?YLZe@|@`IeHcc_E15mwh50*9csd(Z z{yrM=&j`N|+7PA(=7P|oxI`TEj(0WwN1ZY1uHS;d6>>lfy9n4K5EiX%{qT4EotmKxAR zK;5XmI3s#~>X(W6J4ieXgF@3_==f2Yzb{QQ?5aCsV}JYLrm-+ZUh#e|iYpF>W~N6X zboQHtZgII_k-V(cIDk$8y5#Y?bS>%WB%7~2*x>FQXg9%T?9T55K;rKf!m-YXgvQT_ zdPT&`6+yxT4iusy4R|dJk~){)jrVfjWg`?)q*e>dqq zThsKK^Hah&40}PvtLcwm;RvxemWpp9ABlxg++0`xT|eE7l1sD3L5DlM8)-x}c#1E; zq2ZQK93gI}74$?AvZEB1h(<9M+mT^1{iEh@s(Nq?i{zG|$DGSFK6};yB?YY*hC1 z4bs;&ry)P*Epq1R3(&>IAy_zTTv*jv8k=2WA-{Cqt-r)89kKvsjiiP@9dqBCh<9O1 ztM72r(-XnrSVbSnhlj1=sd-;UG1%4YbYY;c&Wm)`!%U=}DZV6kKI3@%p>HV2zPzkESV--XQ8SBv@dCZWd5Er9 zGO|x^-5)Wd2EoPO1spya%R@vv5X;H}P5+AD%2Lzt=Oqi&9Kd^LA&-;IL7GBzMA4L1 zK{w$CT_sX$Kosq=8$W@&3d*9rf7HPf5hdsv`*4edaCUb;IdA;*!s~_r=s4uVE*diY zp{9!SH8Hs9(;f{yY7D0KD?SQ;{ANGy<5A*axu7)`NwLkWULFG7NbLAK+JeA^^AcHz zk`C3rWL! z43D168D+8T_!iM*ieC|*J?8aw&?>=r!^WU~)<)1I{oM+ehF1>l?k_{ha}skPjp}>s zNs^w?)m_brGwt~hnD-#|NE8Rg%URY>4a^UHK!-QA!UjhkQN}0mf%)tYbD~_D*9YZ; zU*`D=lfXtGJi^8&NO3s)nBGIE2 zxmdi-HH1wfh?Z`InGHejVEVvLrJrCbwR-+HGL%UwZ9_T~I_^7EVlEcj3yBjEU$`UZ zHKbyG%lR!lfd##TL{)hql3ms7H{s^rmBc<0QI?t%OelJ$V$fXplCby~fvZ8aSO_AU zLGVJv4h*6sLMNg8`F;}gw|s@@xz9x3EuIm<$Q0UJ8%5l%N62|g{|bg!opJ*8FJvai zBk8TJrPmd`*f66C16mwLMW+Cdq}k9y6yU{gyKkCf+tF{bMFmzwIe|<3Ar;&rIyG@Z zZ2Kp<3KB7$k}s7GV%71DGadOD}2i8@}yD{Bf_xNfoZp^&Lns&hnwc7tyQzHQQ@ z5f$Nac2In#LTdpW(wEt4J2T=>;DCXi#XI4rGRz6=qlhyFJ1VV)+fM#PC&D`!E$)F& z51B4Je6X=QjB!Tc%vg~CXEA)x=iqRo8&7w=ah0Ge1O zeY4^WIs?S1=PL4!3iRKDyuK}$;2tIE;HUQ$zBb1fM0`XYyKBL_eM8wi6GnOt=FmrJ zzM^1>;y<#+EZu7xqE@rpA<*Afj7mGpd6~J&? zS&Qpgbi;?--+5EXv9)~yziWJ_fZN-1Joo17wl@$Iwi4G*UsN#7&#gG| zGXZ$aeQy)$J}^JCe-nmL^>6hyuO|FGG~{)5#q`<9_n!(DP@r&*u4uT3D-R_(o-<__ zMiYpqGiEQVqQCs&1=l6UA~qSD*34|vSOBoj;DtyVT+4JzN8F(pi3ixl3`FpYTsA6W zYC>$&y^X-}tB`8P7m8`BGk~_X_VZ)bqx0VjlBi?Iw_XhrPS3vA9HhdVvJo`Z=&F@1 zh3H3OJCKUv7Vc0>_SBfJ6F7klE@lA`l2$Jt(N6e*fcD)_L#Z7l7Oe)(c}zPmB47F{ zk=|+PUNnFW11uAt?`F39gXl%YDb(;q7={KOm_5-&UoonfN$tG$@lS&;sZ3Ccu3#m^1~HLuD)EK5lJbc2J1~y)AOWSkUl<-FXY4#- zPK6W`!Xy*G0SyT29QQVaG^q8@E2SViB3hUJURojUEZ8LRxab|}lLXj<$rwnYBGr2Q zFeE^6F6GlZCD%L_EcDeqd_D?7wP$AItE-d5WPofBZ)xSJktj%%@gOAoz2q-rs_o3sRTN z9uc3%04H2lO?|57=soEfkwD8v7zVMzr=83e(DF#4Px5ZdXei$?I-f1gNDEs?fcs9S zUZ^e)cG)J^{q%qpE;X;*iez@cW1ovR@Es|lz>o(euKF8ihoSW0$ZQWHxasI8tr`9O zI6J&itKZ$;-JVu)QcjhhLF#6~Ps2Sw20zv3iIGD$aC}u!lRwx7RZ?E4S>5NjjKq9y zH)ix>M@dZDn{wqY5gUX~M54w`bfQnlFpuk|2g%|_^{uNi5aQLj-R-1KacOzY@qah= zf{;r2IV*gkl?%>o4yebE#uoZ05_92_z6iqfey%%h%m4wnEGCbJ{g$&Sn@j@wj)$d~ z3fH@Bq<`_%jZ|RG+NQUD2i#3`p`IVes``}~vC!4laK-TZal!9TZWrDOpehM(u*|&3+nBvN7$#su zc-f8rg0h(BGdLeUbd&bx;MgNu^qIQI8Cd%jb}T>f+b5(7urysU`1*^oKEHRHb${iH zh%8|DtDcGg`>zWEr6npvYM?A=#KWWc%MRA|s-u;>Md3;OP`x@08hiSJkHmd;3+&UU z3XHR)y~J@+SlsGvdMcF%m+i^@^+p8K8&>$v!56;1h7Le3yIUEXq?94Ei}rwB`4{s( zwIGKiA;>Lcv!0DgL>Gte8KeVwtE(4}2)6R_md)g*XIScKi2BLVXm%GBo6@iL6yr+K z_p$AE6>_lCiD8Q;3^Ha?)7kEstrgcRz;Zsq?_Y$6%cm4b-7<6MRk?7K2m0#Yc|rbD zo?HBE;h6E{bYj}C8BpGGh;rwx!FHt~O1(hj*d2~2SPhK67MhjO5+|VO_L%jZr8cne zm2}9#3K}RjX{3|UeqHjcgo;4Z*TkK_&AJqr<^U)M_Xo8;X+A41HKUYrPkSxE?dbbs zNpRLQM2cNpDc*}jOWQXcU%|3OJVTA}n*h39aa}Vt5!?YfK2g&8vGBp_`&vwYQpPq` z{g>iWtL0mh&UKEa;gipz_iHtwAEQI3a#4!uhh3Qj55TfoB@jS|DMRd!W{fZ0HqUtLsk=-*;VvW`2oes38-?zKy`m(q$tW-bF8% zazr2hOEc2TFk3me^)uJko8P(=Y)D6FPrDJmj)KCp#FVb}dx$t4n z^2dkRQkSY9A~WBw>w06VjA;07|Dg;|R^}DW4qD-I#jfO(jo~5vauDd3gP^$#ayYyo zi4-+2(G)@9o-GQq+*~*mL#p)<)Uy!E&g?uiMj*ttbsTkb(XW|$8vr$=g*0porshuQ zoehM=sdyOOiKXqVW@$fmACYmn-M~agF?V#S*mwXcpt1yk#{dm@c=}A$8Nve9RdC`- zyjdtB=he*Go>B|$0r7|`JC$xRUcglg!IhwX6ceyuv5aDAGKRmiW?>44KH#F6eE1MI zKHx@w?12al3YB(3TR5&ZPy zRD>Tl;j0y^?C#UQ;V#($8!ksC*DlQ1%Y54d0k_fBFi(R}b}A*(w*#>|dIL9S#|~I$ z<>Q(N=pBC>E_#6lK*jF;q>wj_op?tjcaByra0i);Q6*MhGB z)c&QiSyJiy=V1JAOYFw0}u5YD$6_w%*@4g3N#L>+T_W?>z{t~-;fNc#!VA-dks5@${ zP3aCER-jj24^I%uNFyvlY#}gLkx;&)I%lN0bhQ}Uwb_lZ;ljnDqg0Z6mdO6M6(|>0 zR{P$hqT#*X8}N&|Z>3lC2pt~(e9^9#*D}~vMz~i3VjqOC$j;8n(>i3%#&X?vATF)_mYj4l z3B^vc=bkdi+2pOVt+e-s1dFk1FB6Kpyq8+7Pp_suAN|;Qy%f2a{6o%LuEaYz8A|>E z*h+p|I){dV>%oqMj@dD91S{5KhMwq1n5uc&Nj19X9{g>|=ydFrNwU)XOvi4E-TJEo zsrSd;y8DMZBdHx~({wFkl%@W~945B0_LKhE)VFUcMnaRM-963ugWu( zT=>abH{oZf;si2m`$0s%f(X7SUo7)Vn`ee-m}!EoJsb3nK>5A0u;*PM85w>o^>?%8 zpCS-y=CQJ`=BwSl&Cp0X&8=Bsk!-~6EBi0ffIXbo!T!U_rX;~)rXRa&S_^!yvcRUQ zP3@*j%{*9J+W}jfRC2^z)z+J#T0yR}R2Ff>^cmwP`4BSVcgMZ#ex_fG7Vou&|Tc=pbQQ!T{RjqW)wx1X5ptQ*EWw~i4@YHg9S_gidX|JY?a z1y~ybhbBz5TbdEN5fHltu4|cszYuqxrr`!(;>V^(C4mhe30l~aDSy@~)e{uHyAFzW zSae>o&ag{6!GzJ*48j&sNaZI49?ZJ-c_)f+JV6(R7H)!}_XLvCb~ zWE2QCRc~|{o>0zzPIA->dCgawZQ_jMz?kmkUk&X=mw9=b`ZC^w?yu6H+9sJ7wenwm z27Il9Z&lvB&uQe5hh!ooS9CQRn69i-y1(gkeO3}P^(ReGZ*hEnthm^2`AFmMMyb^4 zLH8_YcED%HeF0Oc%<<=DQj6IjgSQu#R!DEo^{lT9@cQ?RzJKoSVKMMm$VM0kc<(4a zauQT4CsX)g{ve-CJE-6#sh9^3@_Zju8%~49OkCuJW?8r1N~pKlFQE65eW7^!Vf%X5 zuj@P-Nv37#VIc2B^)dSvTbfV)c%HY7Gr#}4ko~3R^+{`7yau;vv-<^8)J!c#bH15^ z*DF%%Jf4qr*eJsB0dy!gb-C$>&?WIeBsrI+>wJ)zrcN^79)eKZ4+3o~lD7fbL+7&5 z4Tuv6X-MPOxEUu0-3)XjCPz>ZXap5p`%@s$_id4Fx>rdEN-FU^R@so9P8n55G$xPO z0QR`OeqS*a9*vGC)vDAd^*@WZ=C`Q5R~H77+NlOBlqm#`m=?i3bBDWhI6B%Mb4Nl% z8W_y!6GxZ0dcKYdJ2@ab9vI@!WJlK61$exi7Vd5Nw94$5w6ny5YcuLF4^K$Fngg)G?d@Z)M!r z$PqHrHxXod%r+Qmu@e4?p*b9_Gz%^Z7aNXdKYc5()x=5?~&NDa+xo?yu)};+BlzEbRNm_>fKV;*4o#R{oavUn`;N8t&Nv&*C76aaZ{9vfp4= zp(q4-U@6Y}+sOC|H8SpBF9NbNW{ztTYYKOIHa&coggKdsd{Y*!`P?j4fR9_oBvvbQ zhg@V+^pr|=lOt12Mb13I5l!K0fAZ8mT>>q7>KWMDhOU0L7b{TqkWL?r42=@n(3pM` zF{aCB;+*udie{p-mYr?uI9Wr!qw?*rToNi;A6s zpJI@wFN3>ta`Va0lyYt(Z-Kn3CgesuTHXe2nm+y^$52+$wS?X(@aS|=NZBLV?fq(O z)D{s3$O3u(%QOm#))W7j3TvD{&gdmpl%VXQU2_|i+uS4W7;92a{V$(B4t$MMc-7g5 z%P~Gmn(>FxA!RFKtS!d6S_`G%tpKChHwV5*&3xE@FRWU6(=4TYzxcGRRAo=fX`I>@ zk*gqp!ia*Hl@Yxp=|)txyed)xWTc_!WV>f9tUDsw=tN}+${UJdxbbOcTzZWn0xH^t zqM-zxK-f?G-Zs+6{LlaGt$v;OQw}?x1I8+~3fxsq%^cEB_BvVQqFR5zvmm8EUro_) zVs3OHlSS~3(a-x+ z%h{<80%!8BiA?mvCP$(wGQmQ$h2@*$cQq24e?d<+aAXil83T4b`)Lj|4V|T(lnkJK zw_5eh{_Kr)ni)NO!n<3qKtrB994uYA#?O%SpSBB2S!GRnRH~Jx*J(33+r|7)-*;G4 zEK%1n7K6D&gucV-4AMh*!8DZpu5z_&Em#YgB9JOJ3QJ@_A#t!x?#J$B3hiLrD&n^l z_oEge+IA5`c1HunqqD|4Y5hjmPfHY8?nWK%Hj?tcXZI#|4Ao1C&h^wo0tqtzs=@Y0 z@S;Y_SLDzDVEmze^jGSaO4n2r#i*Qi{=eU^DW>l7B@#19wY@p1R3A*&PSvXl-+m24 z0Dx=4f*v7d$B@7|%D7&m``}*nS)sU394fmR&hcj_Sl)^R>N(wL`<4iAX?8j2!0Ts_ zNXMwzzwTcuPKmgu+RXaO7#ubt-E`*Lq?p^yw5nBJ=I%OFNzUdE(bmt*oZo>nM3*Rx z^fvgq+XggrI=8u4H1Wqh_ZI7EW1Ps6KRZ}%Y$c)=i0RnTmd&Et#l%SQdY~yL9w4@LKfT0vG z)kfBCV$uP+7ccjoz*TfD>uT}06k!t2xYLVK8WVx9Vf}(6940EJ+CFKgb}s<{o;9>v zod2S^3$d?&2d8;zK1R5*UXu| zmkV7ec+e*-f5Z<}-t4r$mTS8?te|qYME|*l3;>xz$UnKxI)C_R4wvsYd1{@d=Q^;U zLJ;D-VlFoo9X7IE(5LQ-s}4HVBz64Pf0BW`^SrV-rUz=6&~BY%YFgHD92dR)SKzAL zHMY^rZ6E!Nn+zxc%+jYIBLEJ&VWCK zwX)h@9YADVwg%W#$p~`?Y_IL6=-*q(KMvY?1}gdLBVa#v;X_QR=iX`n%WGZQvrn&M zb39OW)unvA

C)!!CpTJd+&jK=T|!g_|wwfTjDAv5k>acx$8V>4`aLT@hh&rny4?b6aJMiVR#saBWP9U!X;=_-*sd+o^Sl6Tj zE*XZxI5w#t*6$f1k2FAD$;a-Is!Q?9O7pLxSkB77c#hk;{itWfqUhJ!RI(Blk7Y(1 zaPn1^*j!nbBD7Jw++QelevsLAWheBfu^V_K0*_9HbbQ=kS8@*qfyc@GQsk{A26X-I;T@KWRVLj5$wC#Zp&F_<2F+Y1z?J#&= z2Pt@H(0rgCGdj-w-ak%i^N+YL8!^0Gw4mZAN!+NqavHI6IQ>^IsJKM;j!8mHxirb0 zYJB-yGIyIX%!rotnOwcF4^brbjsKkTl9~Y6u}sOZ)}t?RH-fcYj6`63Kc?o2DmNf7 zcTLpcRJ?p}S?;WFW2QT9DaLracz~qZzKIK8ux2-Tl~YZ?BM#8Y#6rjbVJ8ygGzN2`DHea53NV z+)Soa5wTh8_k>iHIdaxwLE@B7EuaGe}P z;3kd4H=FnO;cvFMGz?@t$}caI5=6h%BDApDw>0=1NoomYv_0_4zYlggKXWQb`nT%Q z4$vVjs)9UoL!dd8hJiabeRpGvdd! zBEtoN>HaUq(Q1OFJF(;6reF74mF128l2`xO-BL!&oMHrewY8Jx;vvevC}(9w0N_pi zf46|0*x0)_4g>1Z^L1$7wey5(D|x@I!OB-!NqZ#ErWO1owYwPZsXn>zAACzf`a~pcuTQ33jBgk%bo79MC_pL2T$Wgxsj`=-6?5|YTJnQ>9jxoGXJ1U+7fBzyZbOwuqA+60 zWE03T1YiKH9$fXQ@ZuAA+G2Gd{)6i~yO2PymGfHxa|ce1y7KF5g4c41H*pu~-roir zovSVu)S6?ix;|dSD{sWnGtAg3%A#+eJ1m0O0swX#Lwc(98r#1(!ukxnNwDXG zeZFQiC(;~C)4LLp03B(MeghXQXw~DT+RJav*maV6+L_ckaNE4SUM3OJMEYpq$wx{Z z058=${>!qRomkdn1kWae#LPTRktI;CNB?3Ftwwb1ZXkRA!JDqf1Lo2XU!HwSW>F9; zf%G~dPQGvtfb;!}xq#S3hZ4~>{BO70m2yTMf8?%rU3z>Cgh+sfN5g`8%b<1-36u^A zv>tJ~>VnPAB$WKwxX1@Z(tSiU0KqWl-{!d^kPrKQ`-+wppC4Q6gh9^CM5vFlWSRjm zl_9IIvsZjn5_0wVx<2!!YcQhFntd7W(x zL*r&%w-ApNXljWN>4!lnLA8agvycCfjRzEX^13%c9>je29tx@1q9Q6;<4fN^U((zV zlPrQL3bYa-@T_v6fQqZ#_FAGqR74{uV3CZx{+TMs&+C5{N=Jah{^EwK{6!DS9<1#!m=uE_v5w@C!u2N_!Io<1a`6TfYNI3akk0VwWK_ZGE zDxZjsOB#u;#fJvhOc|x+k6?-ko+ktC9DUJFK1&oV?RF{^XCJgW6cFlL`r3yksE3rht&%RebTZHz>aO z|FeZ&d7k&ng=0gYXH_k}y`NSI?;eF)B`C=GE%+w@X9r)l zkRBi7K^m+=k5B(g@_#^=1QI~>qm^K0j~fi!C%3NZqRP4GQQuf}uS-t^>o0m@(j(D! zj~8U`SFemdISLNbpSXDo;{ZD<+6$!`auN@0aXB}43UL*mvYu-Uhuv^y)nqk};J*aP z5`3QKtn-bOHf3%burZzsWKY&NQOF5L~Sm=iIzwh$Nc@ zGP#S2?S5kEyMY;v-7-eCMg8s}HJmzWG%~GSfUlrt!psSj2+DMZH$YdK(YpCpe9Cx( zDUaY9X&8Q6dq$aD^_rtBb9A)|9T-i^|MAD}ar@+X^(|slv@g@=XQA$m$Ss&S(&zCq zvL5A1jaTI^TmI_0`RyYm4IODe^fK=CipZ9w#?dHKx#byHEb{b=2G0q3n{3TJd~;ns z{y43H|3LhnfdUsPU>ABjDI)TOf&xb8$3uZWsfY00;nSwRJ$@qF9F%*}({44oY}xa; z^|D;eckDpp#}}zOD%tsF4#&}NF=Rd>lcTO%i;bI(dcjl!JNL_B*wcJg*=ChLF0eJVK`XzyY$^UE_j!b6TYA-@v+;#+agBp^7w~0g}s>||B~-= z=X6bWFotL-)ZQf4FF5tVHd-=vqTT5s_61bQ<^I~zM?CF03goff(ypdI>pwI%h8OI# z--gx3kzs&Uz3X7BW#bq!Dh^Q^Vzv?MSvtm3MkN1z{jHdULdrb>c0Ly{{AqYDj^EO~jDQ}V>Q4fw$JkUc{-WE|C1I}F1Ex!<-gwqr_35)D~}5C$UiqVdn% z>f*Ps5OC^m%GFv;S-6+Qaf}$Krw)W5hLl*1f@bd1HdZQDVWOl3R8fDZ;aGv9rU&|f z73KJvMQGd)H3EV|yvnurrbkRuF^_$}ZfSHKK>^7myEX4djt4uJ@p-#yR0g}D*i3{u z$-)svzE7t+a%H_!hO~!$zbQRZifz@w%b?@HOUaAGy7kW9in8guuWkke*zH=RKAeT% zg&K6jLd5(_{R|QQmudaPSGmqFpg>tOuFCRs>p$)C_}icQzbk<`HQb1j6x`f+nl0yn zH;>eqR)m^4;S@}a?$m`{pX&(~v^B0z1@R4%(xfMT)*f=}V}WAAba#8v&oT1^7$u!L zFr5<_ehP4o_#RynVuMrPIS98dEE6mLGUc`Y(Y8TridZg~X0c%N*l`}fCaKdMyxD!i zmK`tz@UQOC5?~g$-_f22aO>^8mjVMsFOO@4n0bSrk3}=93y}fFW9?tjSj-B~-)n6P z8VHyG%mHJu5IaiQl~0tsg*(J!JT3SkxoSB|f3kPR+l{4K4xhWf(KUG_CHt3sEHI(1 zmiH`VUK)oKBcqawf;CUZhF;B)$=`d>R~z4(TAcxH93<(|x(raQe$LU+Y7L8s8un^u z50<{p>*pBfr%ep&QdXb4v_Uo^`jZA_9ghpXK{Uu`V9mmB`mB*YjO#g2>=i-u$5K%zt?;2=l!Cd`B+DL9%kz{ ztMb{FZ&5qR$K8UUjXPH3aZl-_<-N5?D?3$Sge+u4d2E7T_H6(5qVU7l2DS1;sJ&Hf zP2CNZ`kEH`0ETi1dWp&Xotmy;Mp91}vGG@%_8>!}4kYyI2Az*H|A`E^XCK|tUlpaEVjpSJ)681gEYy?uuu<;Jw~L6bbzn#As} zatRoyZrXn-kkkP`yWbR}YECLV3J%{n`B@*n11A=B5TC_M@FSU<|Mg%UUjNH@g8)0- z>{|yIB#U&qixte9g4`^cm0?y57f<}Cn(n<6q%YtYI+eeTh<&<^zE@N}TKW#>1lsQ( zZlLqL93##R>QV{+L@O%m(@Zb2PZRGs#G^xq9Ri84WGdqXS|+n*#UCUH_KoA4heJZ>;jm%yt`o%WgoBRSnql}eY)56RC%R?%p zs3-{ClmpPg5oq(B0Im`~{CXB^MXVE29-bI)AY_%%r6EQ7=Jv_dB~?!D?4w?ttdcE+ z3(n8a)pQwC{Fdyqbf7K+%g$bL^v{DS3Gg}jy<%_fhlXNV0}xh~Jv-WWG-TBx3RtH+ zM}C(257FE>aZ>;f3)h;RM>Nh60$c@g@BdMyF`CdjuhaeBGSrcM*}ViRqOwBikmsdR zU@pH8j#hBb*AmJxO+su5E?~1ks&0{bY0bSoBE!l|GO@!8GOWl()l;IxYC;5eM~994 zG%^42rl0PMpUo#x3|w_C3PusB!oKSd${bm{Gd7139PsJv;I~LBA9VO< zC=jxh)h%+^&|n|%{bUksPJ56<4?Mj#G^~oAxYZ<3_@Pp11GCF5ezOt@*=bNi?8k)H zX>+oMjW~+?y(2n>WKI^U?iA6=qf3Mqrl*exC)u3^7@5vs7(tlAwS<~kFOhW0tt>3s z3H1JWvQp9fx_O@Nqv^^_>?p(uP!a?Bo)vv+0L4v?^k=bj9`9o3cdT*yv7Ce;7}*r(dAt7&WL>;Oj%XkAvGsE}p*=ncv4 z8pHQkg7syqKDlK8i$6R2eE-9*YCYz^tRAT)qx_3*L1;#ft z=&%(iocK)2@U$dN49V0G>2nE_WI3K4>*tmgeao%SP&l4q>Y%R^hMH9QFk-09;Kc9Y z&wUu)dlH6Wg<;vOSZIvc!6_ENH3#4@=mzQZA2A#dyfvS zW&&_B0U9W_Nn||jzn}p>uRNfCND};Pemoqy1&pVKk&g76qS&R0U6K-D1KI;bu%H+I z4=wWz z^4-(+KWqSQpNXTT(DEnFcOK7=M1M%Wqc{I0p%L{W^J(R`Z@*bdj4?W#{HF5|c)}6r zvb&!?%|$6;Nb)18r(Px?oNGhXq2<;at!;G}WTWb{iPk_1qu>HyJ>!vqiBxXs*7dLU zbP%Lu2r?U{$%1?KrcxA}WdF<-CquX_d-wAYo)N=bwAQO}$axFnIXNnyRI3RW+?14% z55#IZFp{W+Q?LRv%Id$vv$qCE8L}9Qwi1SETYP#8XIW;F7@IfP z>+dH=f0*4qek`>ZfFgFO$m_tu?Q%c*to>R&?DT!xI zi>#X&-Y~b|l3_C69}Yd!YWPj^Bg|^U@`xlEVbvFr6gnSO`BlX$WiYaJFzo7rX6Wy4 z8vE?k-z-ubRI0e4jC2Y_=!OI609mdsuCiYEP9}ThJmM;nT9ycTFy?8JqJLt;0a`uovAas@#X z*C7O(thbmtEZ&1smsyCSY*fG9CM*DqyN_+@es__CbN7YfR%?B)V6_n0{uq;+dbjLZ zMEB8Kl-4y-F#_xY5!rE^$k{fdCXbeJAW*2)%&@>Xjg!67*$cV7mByC%l|1dN-d^Qf zy@HIs_p^29H5UeXTtuQzmae<|_jkds7ri{6I1Xy1^S$ElW61I+oI^l~eXsgq=7)6a zTzkiTK1c3f5>6iWWEt3Tro;tv;14Q^HlpScsh;awFP5>kgW zt!sW7#@BiHR+M*CUr$JAE}1Zzmv4aJBnXg0zIS+}jt(=JL0wLQTCFwDf@U_{n%Ti~ z1d_T54oG6gzyLFcpkU8^vD$9G0tkNL(D4wogLo3@iQx{6JnOG1_#wRe^cs9SPf^jZ z#NQM7;T;W#Fp<2tU`s&ThkU_C>9FbHCnKAzKEg@ER%V&M*Y*4UeXsYu zU+?GZ^?HuS^YwU8eExh?{cw$60YP}y<}FJrf3awkEPM9cC&A@bjRoZ z{m$c(yMC6-uR(ccrBY>ybXq*5r@GWgy)!j0=L7}GKzl9A76d12xN$H3Yc7cO?Abp= zu_0R{)?LGWTtyLUrgg6EMVfd2+;b+mOs$j%FMx(ehNmfe1T~;tl_X~Ak z?zH$juqfh^_PI1|kZD?NNI|(Q5TVTrFUZOcoy)y#SrsAYo9lQ!1eHhtg**N%uyTc$0q0L@y`!Dn#e4 zKM65eIqjVzHOb`^{?hlVn!fIK=Xp3**;D>3!5va+s7SvVdfjV;F2p( zFo`~Qv%*fs=l6#`{>`6>F_)=)uIHUN zV#CA!3r*7uAVw^?}A@mvMILwEgQ3-dh(l<=2#0yS=jUmp!OcyGx16lNRObem-2 zAr2uN>&n-MrCV=*&Ss8=*;Y^<-Sp}wjNZ$s{e)+cWj0?Hk-K7XeAtf=zVdeK<@qg^ zFmv`e%MKwpHmdgM)X?Y_{mSf<69Nle*BeWC8omwS5fll~Ff91+rFHm672Dg-MQ@cK zPMc(k(m^^Xf$lrHXWs;lc^$+dL_ie??uI?a9=}DIy1$#$r8;W;lat*2V5sPdoOQMWiPK zsqa~C9vLK4ViLuo0W;=+Ni`?dGx|Euz80a^*9%Wm?y0AFn(V2SCH`+NToU- z0yJd!)216;T9Xexos)Q9GO0s$*Rb;1e?baPkjz&9w)RB8m&O{Q7sB()2AqHz5BSr| z1pKPI5Y%pq96yoB`6a#?{&3iV(Ps$<_>WlwAjRM9mk?jAO+b0ZyAqlKmuV24&Li~F z;cFv~y%J6z!-3pUH}L1_{yw!Q<*o`_enL?#?{>G0c5k{TX{J+Iw-u1$%}_SxLGnxW zStOiCOAMaS)t_GL)zL6e*Iqd2Lf`Y}nfqzu?e$Dhc1kjq>O32G0*%s~T}(@i))tsc z93f&nfsBTGOrqn^q0ASW$D_e(g<(ZrfoQ6jBa7n_Eb5s`q^c?cECE6`<_gZF5{g4F9a zG%a&Ha#^tYep%^+OB=icNlKO%S&%Y-+WjigYd}~~$Nr|cpsoV7YID!RcC>2kvnyG8 z%~H_!zhblJ&Go*5Tuy60FrO+WkEorLL^HEw@M}C-*Rjb*Q5XGO&}mX zK(wKvxeyfH-9ve!xH;im{8c~}!2O7u$&nS>QId-^Y-Wd5oE=MCAUiV-`F@n(3~4&4 z5mBAabfcfOm`c9;s@T_DDx~1^RT{PpTm&%ZGHPoOipaYbj^dc_BiG>t%*Zit+0 zK;e}3B(cw*KzFLTcQW#E8+-g^&SGA>4tB8RpokVGOOi__Y_|FlB-r?pb&h|-3(c&s zu(;3$i((IZ^%Hh~N()a3$+v@lUY_>IK*g((Qvi~x^HGk(aXtx7LV=r^LC6w*af81H z2@=|u1$5v0$8LSArRS3LRR^5zU0>(RJx|L$og!8LD2GXK1BRind7Ke;CN(Zg^O+GA zFkw5XV13(PbG?rbJCrGQl^=RM4{*G=YIT)&6*zGYjMtMteTlJ5LAqLJ3xz)R66I+TRb79oCzmHMR28#Wq|&=7kvUcRWr45#VP zfaWtim(xo3Auk}dx@i#W$ToU&#_GaEYb(qk^?ZPM2FQ!Bfn?EWCC$n9_SpA7x$1os zLwCcZyUB}-^b0~)O> z?lP=cUL2%cK8YZwWxz;q-`ock#ZB(k|G3!@Qc%PB8M}^@;&%yM)wuzD(vLz#O5bIR z0wl|ggQf2imY+Hvk;jVFIoipZbOf;mMUF zKVZbT{dl{R!K_Bp`X9bwdY><>8kfsFw)oLByi&*UN1FGqJh;dE3MUr@g5{seV(Z~T zs+g;DFhzwFBV8vxrW@29N%+Dk?`Kn}OW?%gOA`IL)f^>V3Q4FUX;lMqhJ(iM<)G-` zGMr!u37O~vGgXJ74@CITr~UGy0~?>Lh!_Lb4uS}mno@WjX=g-=Mdk9nl#rXmB_{*` zFyA6b-uAV5hu8}r#hZQCT^-i|CcKObTJK)ouFunZ?BR4(P{l?XK(L zSKUsZGcL&)d2LwTQjA8yZ#-Hr^2573k*ep3<4~qmzem}p)Mvk# zbUsuVIM+sIG|-MMHU)M9*q8PN29|V@l=cGwZM)eh#m9_{ODu6rlc3gwG2@4Vf*(=T znU0t)>T~0B$xHvf-_p6oZ$}ajCt%QGkK^S@Q>>T3ZgIf@)KKlHD{aPWeX7m-m0|P3 zS`Ez`rt9t8jUAwFF+yn3=5b)2fvwUB@U1{WDQc@a)`BIkwUUiHZ6j(M4;jphg05rZ z3_&?no8IV0#*Z+ftphu^}j)PACelg3xQ?7U79xGxX{vsA`1ucGD`>Q?0 ztOuy?-L8*D7kp|*K&z*RBZ$ZF)U|bk(HOVXsWT zm`{P_|2kElye9;>>x~_th*twM{aP@wLfVP-Z4o8xlF&d>rlUQEIYd~1x*)Nl;q?_t zfl%MTcd{t`uK14VNXH2elr(h*0gY`~SM9NtgxOKT+?jL1d04*Pn;qsgxggUsUp;<; zR*ibAz&R3`AuWArpKqU=6&n*FM&3CLkZ>-SZ$7A`bZ}OAF>wHEM$scm)>S?o=Y9hz z{0Pmw5C7Y$DT3%rg;=@g_Y=N-eo17z%b+nYmZk>qe_lO6f(2T zCPtOVpg|#=A_$*1+d1ssU03<)99kT1Ci(!HGg_GNlXy)=W-5HbnX-9+ZeICc+bwge zlth;p`w-Dr@;2QCsW4KOPf(uX|8WdWOJTQC9?m||uRJ?3I#xh`Gh_U6>=1aJV%V3- zJE3Ton-!YeU{bq_BP_e!h{PFOR6(Cnz5O;P8}Fh1>wi~7*dv*&EDH)q%J0+JVs z5BPvKKuTT<;j4)W?hMF2EY^r~Zwm^sH8WBx$#<5aV~16&uShvygEyBcpuctfZPqq@>>Fo)-<7Re8hQ$IaOJ6s~;u{vAbbXjCN7L-LG} z;{`=VPo5o_E}kl8uNlP5DQI*eZfgF#d41Wif9B<}#bTZpGgl1e3I>)txxssyf9*E0 zn!I50L@>c8Sb0Olxd)w~ES`k8#MtTz?(lE{$92B1RJRJq&msJoi_YWC3i;@8* zrB)SWjmaE>1Xj%&LoyB;B5YPd?+zy_?&lu++SnNj%gG0Wv}iS-R=-+4E9Mu)h>db2 zStI+wzXq2<`j1BMXZBk`>T@q%z$5lYwRb~7r!kZo$>sz0%P_7(K%jEJtCoI?(GlY^ z#*CDq+yzcXj)pGl+JAwWoavjrc;b&UNtH~r{8!y0KQLb4;~@>&+yEp<=4 z`dKsb`FCdm!GYiQeG%6zL>IHa2tSqRol(!)dD{S`;!8!_4@W7o(+Iw#hfWv z^Iw;k-f?a1*T?z^w~$gX51?ifbx#<&PQxJyWlaI=ka@&ka(SU~!U6SNrnWlzI-S10 zyG8oDTdFXz$Fz@~GBdP0T7cu&^sblS_9AvFsM>y2t2<8L`OM#FZulm^#(w5-=SDZs zfM8aZf|f8ag~-&FMhSE(%CkBS%$xZOyl%2oGCzlrZ!eF0faTich^wWhO{jvLJT(_9 zb__S_auShK`ZF12O$+Nl^o6$LEklvKnVanoUyUqG^#<`IEn7*ALGk=Jr-uFFcrQkf zbxr{Rqg$-NiFrI%A^c_xGrKBHR?~9)z1Z5LfHh>hfR7b>E21~bAI(E5BOhH%0qZl7 zCsc@Y>r<>cK_{9Az2B#_XLIG9E9{!jVobyP-iUBMqhnKbL}2IAsaJM*$l8uTTBaK!%_KrhFNFgUm;t>in_AU{yHEQ^1he zx(i~^@t3!DcZE&#C`+Qw&?jv|Tfz96WAe+}1lnLFAawD{Z0fhtmY zL;(%PkqpbFx;SoiN$c|qW@L{H7Q~mcZxvyoEdB>kH&^8{RtB;!9e?lsGR_*ZzW?F@ zG@*N*gah9pW30i!&0vR#_oUgali;Rufk4B@`$CCLdr9h?tm#!r2X@Tlw4V;pnxn3! zrQ+mPwc-N`J6n5e_5k8~!UDC;CtUQdH!zq~EZRQ`t0QUnd^o$M0~8s95#fuVe?-%K zNGZwvD@Su8&j2n4E<7BfA4a#*D4s_O7 zxlq+q{F4pQeeyb%X_5PJKRdv$bAZ3Xuwt{zGV87__Bt1$%hLQnTNSi?UuoX*DF9}- zqEzNV-zLj^MHO?ADPsNCE6|yuh9Y;tjf`FgIHKgpTD~s7E}j+123-0z95LbY;{*v8 zHbqQ@(@dP34AA`3tT6gd%b;QRK-D~IQCAm0nIe{+))<`QgcieKKv^q~NSkydE`+*c z{EHu|V*TC-AAe27E5M@%;qS)Vs&rLy;({jc>hG+tdLLW|nk4&TP^P7w@lcfI)vsYd z!v(Gbbemoi?|{UD|JFzUY5MDR1+CYFhe$odxbZZHmA9r*QrH}cfo*!0MBBI77R~Go zH|oAAr9QN7%Lk7<<-iq!st+Z^C`5%tkxV5d+F+;}NeZC0+4qEEp5${L$49HN9v($& zLjTrD(`-(;_w2AAE}L$H8Jy3jP#LG{u7`oP+6R1xZ(*0tMV!lt2yj4=4DRVV@Ct|bog6|1>({47Fasmv`Arqr%txENqkks zc!tn_Ispe!;;q@NEG>fTvbwGdA%;ZFC&0mN6tom#EB{TJ!S@~_ijLvT&*%dUFS9V$ z#5w+KP$`MG=drL?Jb^3e%^6wD#Y2CynmW6BSUTa&V#Q&9IeS2Dq)J{v)aY$yQQ_oo zpLewc9r{BD3}^-osrkA*jbNEO>0(Xo4Qr|Nj^qBtVow>L>wI8SGA+&JyK zQPQ%KFQD}m1~HO4uKx`REO%o@iV0A?v!p-_%VqU@Y_|$D31i0-L3w?OqYXZKb9|ABKN9ERf2P3xH-7dq zd-nag{_^{pL{ulp$k=Hq`Q-|tRgD7HA24sh76_Vm;i}}C#B(9b6y!o1lsJo2{L0mx zRLB1RHHzqu)Cnnn-BdR)LmyEx0-}p9zOr;j_iez<>q846Va{I4A>?W+7C>W%11;9} z+eU=cyE}b$)?%?YZG+{C;RZEqFnszNYob`bjxroV?!1@bgE2II>;ftKb<6 zmq-HBAH)6nB3i!vgt{IvV^q1^VJ6=SZiEEo4G9q4t3);!A|9c8;$bybn-Nh?d%8b< za?tdrX;U+HsQjU0f0P7KI`>&b-Q3P|A>6P3Dm(t1(y^WXG2Q*1=>?n?3?P6k7KnK| z{QFH!_3O11uvXHXu=?vhr6!?3HYs%se4rMJY&*ktV8Ptq0Dv5_#0zOFzvI^>z~ctd zaHT8^V%0y0vE?=QZWMiM?rHFlQ_hCuc(?!8DcmoB)&%JPpaXj~7v#5~a^rQq9Wcwa zoiIt)wHXS51o_99>@Qz+J@<7uZ&&4h(^^d0_RoV*B_h(RsO@ND5e8-jRo o60!aE%C}?;1R0P*nMkO^&`}trBy8lpP5kke+8xyzwAJJP0}W78dH?_b literal 0 HcmV?d00001 diff --git a/src/renderer/src/config/ocr.ts b/src/renderer/src/config/ocr.ts new file mode 100644 index 0000000000..b899cbb5f0 --- /dev/null +++ b/src/renderer/src/config/ocr.ts @@ -0,0 +1,32 @@ +import { + BuiltinOcrProvider, + BuiltinOcrProviderId, + ImageOcrProvider, + OcrProviderCapability, + OcrTesseractProvider +} from '@renderer/types' + +const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = { + id: 'tesseract', + name: 'Tesseract', + capabilities: { + image: true + }, + config: { + langs: { + chi_sim: true, + chi_tra: true, + eng: true + } + } +} as const satisfies OcrTesseractProvider + +export const BUILTIN_OCR_PROVIDERS_MAP = { + tesseract +} as const satisfies Record + +export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP) + +export const DEFAULT_OCR_PROVIDER = { + image: tesseract +} as const satisfies Record diff --git a/src/renderer/src/config/ocrProviders.ts b/src/renderer/src/config/ocrProviders.ts deleted file mode 100644 index 5e482e10ef..0000000000 --- a/src/renderer/src/config/ocrProviders.ts +++ /dev/null @@ -1,12 +0,0 @@ -import MacOSLogo from '@renderer/assets/images/providers/macos.svg' - -export function getOcrProviderLogo(providerId: string) { - switch (providerId) { - case 'system': - return MacOSLogo - default: - return undefined - } -} - -export const OCR_PROVIDER_CONFIG = {} diff --git a/src/renderer/src/hooks/useOcr.ts b/src/renderer/src/hooks/useOcr.ts new file mode 100644 index 0000000000..a1cbac0f8f --- /dev/null +++ b/src/renderer/src/hooks/useOcr.ts @@ -0,0 +1,54 @@ +import { loggerService } from '@logger' +import * as OcrService from '@renderer/services/ocr/OcrService' +import { useAppSelector } from '@renderer/store' +import { ImageFileMetadata, isImageFile, SupportedOcrFile } from '@renderer/types' +import { uuid } from '@renderer/utils' +import { formatErrorMessage } from '@renderer/utils/error' +import { useTranslation } from 'react-i18next' + +const logger = loggerService.withContext('useOcr') + +export const useOcr = () => { + const { t } = useTranslation() + const imageProvider = useAppSelector((state) => state.ocr.imageProvider) + + /** + * 对图片文件进行OCR识别 + * @param image 图片文件元数据 + * @returns OCR识别结果的Promise + * @throws OCR失败时抛出错误 + */ + const ocrImage = async (image: ImageFileMetadata) => { + return OcrService.ocr(image, imageProvider) + } + + /** + * 对支持的文件进行OCR识别. + * @param file 支持OCR的文件 + * @returns OCR识别结果的Promise + * @throws 当文件类型不支持或OCR失败时抛出错误 + */ + const ocr = async (file: SupportedOcrFile) => { + const key = uuid() + window.message.loading({ content: t('ocr.processing'), key, duration: 0 }) + // await to keep show loading message + try { + if (isImageFile(file)) { + return await ocrImage(file) + } else { + // @ts-expect-error all types should be covered + throw new Error(t('ocr.file.not_supported', { type: file.type })) + } + } catch (e) { + logger.error('Failed to ocr.', e as Error) + window.message.error(t('ocr.error.unknown') + ': ' + formatErrorMessage(e)) + throw e + } finally { + window.message.destroy(key) + } + } + + return { + ocr + } +} diff --git a/src/renderer/src/hooks/useOcrProvider.ts b/src/renderer/src/hooks/useOcrProvider.ts new file mode 100644 index 0000000000..ce2eb5b8fc --- /dev/null +++ b/src/renderer/src/hooks/useOcrProvider.ts @@ -0,0 +1,84 @@ +import { loggerService } from '@logger' +import { BUILTIN_OCR_PROVIDERS_MAP } from '@renderer/config/ocr' +import { useAppSelector } from '@renderer/store' +import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr' +import { isBuiltinOcrProviderId, OcrProvider, OcrProviderConfig } from '@renderer/types' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' + +const logger = loggerService.withContext('useOcrProvider') + +export const useOcrProviders = () => { + const providers = useAppSelector((state) => state.ocr.providers) + const dispatch = useDispatch() + const { t } = useTranslation() + + /** + * 添加一个新的OCR服务提供者 + * @param provider - OCR提供者对象,包含id和其他配置信息 + * @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误 + */ + const addProvider = (provider: OcrProvider) => { + if (providers.some((p) => p.id === provider.id)) { + const msg = `Provider with id ${provider.id} already exists` + logger.error(msg) + window.message.error(t('ocr.error.provider.existing')) + throw new Error(msg) + } + dispatch(addOcrProvider(provider)) + } + + /** + * 移除一个OCR服务提供者 + * @param id - 要移除的OCR提供者ID + * @throws {Error} 当尝试移除一个内置提供商时抛出错误 + */ + const removeProvider = (id: string) => { + if (isBuiltinOcrProviderId(id)) { + const msg = `Cannot remove builtin provider ${id}` + logger.error(msg) + window.message.error(t('ocr.error.provider.cannot_remove_builtin')) + throw new Error(msg) + } + + dispatch(removeOcrProvider(id)) + } + + return { providers, addProvider, removeProvider } +} + +export const useOcrProvider = (id: string) => { + const { t } = useTranslation() + const dispatch = useDispatch() + const { providers, addProvider } = useOcrProviders() + let provider = providers.find((p) => p.id === id) + + // safely fallback + if (!provider) { + logger.error(`Ocr Provider ${id} not found`) + window.message.error(t('ocr.error.provider.not_found')) + if (isBuiltinOcrProviderId(id)) { + try { + addProvider(BUILTIN_OCR_PROVIDERS_MAP[id]) + } catch (e) { + logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`) + window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name })) + } finally { + provider = BUILTIN_OCR_PROVIDERS_MAP[id] + } + } else { + logger.warn(`Fallback to tesseract`) + window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' })) + provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract + } + } + + const updateConfig = (update: Partial) => { + dispatch(updateOcrProviderConfig({ id: provider.id, update })) + } + + return { + provider, + updateConfig + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 48bb9664a1..9dbf612fa5 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1574,6 +1574,26 @@ }, "tip": "If the response is successful, then only messages exceeding 30 seconds will trigger a reminder" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "Cannot delete built-in provider", + "existing": "The provider already exists", + "not_found": "OCR provider does not exist", + "update_failed": "Failed to update configuration" + }, + "unknown": "An error occurred during the OCR process" + }, + "file": { + "not_supported": "Unsupported file type {{type}}" + }, + "processing": "OCR processing...", + "warning": { + "provider": { + "fallback": "Reverted to {{name}}, which may cause issues" + } + } + }, "ollama": { "keep_alive_time": { "description": "The time in minutes to keep the connection alive, default is 5 minutes.", @@ -3498,6 +3518,20 @@ }, "title": "Settings", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "The provider does not exist" + }, + "tesseract": { + "langs": "Supported languages", + "temp_tooltip": "Currently only Chinese and English are supported" + }, + "title": "Image" + }, + "image_provider": "OCR service provider", + "title": "OCR service" + }, "preprocess": { "provider": "Document Processing Provider", "provider_placeholder": "Choose a document processing provider", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d731da1934..f3a819565b 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1574,6 +1574,26 @@ }, "tip": "応答が成功した場合、30秒を超えるメッセージのみに通知を行います" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "組み込みプロバイダーは削除できません", + "existing": "プロバイダーはすでに存在します", + "not_found": "OCRプロバイダーが存在しません", + "update_failed": "更新構成に失敗しました" + }, + "unknown": "OCR処理中にエラーが発生しました" + }, + "file": { + "not_supported": "サポートされていないファイルタイプ {{type}}" + }, + "processing": "OCR処理中...", + "warning": { + "provider": { + "fallback": "{{name}} に戻されました。これにより問題が発生する可能性があります。" + } + } + }, "ollama": { "keep_alive_time": { "description": "モデルがメモリに保持される時間(デフォルト:5分)", @@ -3498,6 +3518,20 @@ }, "title": "設定", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "該提供者は存在しません" + }, + "tesseract": { + "langs": "サポートされている言語", + "temp_tooltip": "現在のところ、中国語と英語のみをサポートしています" + }, + "title": "画像" + }, + "image_provider": "OCRサービスプロバイダー", + "title": "OCRサービス" + }, "preprocess": { "provider": "プレプロセスプロバイダー", "provider_placeholder": "前処理プロバイダーを選択してください", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 21251f332d..e5a7323bcc 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1574,6 +1574,26 @@ }, "tip": "Если ответ успешен, уведомление выдается только по сообщениям, превышающим 30 секунд" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "Не удается удалить встроенного поставщика", + "existing": "Поставщик уже существует", + "not_found": "Поставщик OCR отсутствует", + "update_failed": "Обновление конфигурации не удалось" + }, + "unknown": "Произошла ошибка в процессе распознавания текста" + }, + "file": { + "not_supported": "Неподдерживаемый тип файла {{type}}" + }, + "processing": "Обработка OCR...", + "warning": { + "provider": { + "fallback": "Возвращено к {{name}}, это может вызвать проблемы" + } + } + }, "ollama": { "keep_alive_time": { "description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.", @@ -3498,6 +3518,20 @@ }, "title": "Настройки", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "Поставщик не существует" + }, + "tesseract": { + "langs": "Поддерживаемые языки", + "temp_tooltip": "На данный момент поддерживаются только китайский и английский языки" + }, + "title": "Изображение" + }, + "image_provider": "Поставщик услуг OCR", + "title": "OCR-сервис" + }, "preprocess": { "provider": "Поставщик обработки документов", "provider_placeholder": "Выберите поставщика услуг обработки документов", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 4307fb1208..4ba42ba646 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1574,6 +1574,26 @@ }, "tip": "如果响应成功,则只针对超过30秒的消息进行提醒" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "不能删除内置提供商", + "existing": "提供商已存在", + "not_found": "OCR 提供商不存在", + "update_failed": "更新配置失败" + }, + "unknown": "OCR 过程发生错误" + }, + "file": { + "not_supported": "不支持的文件类型 {{type}}" + }, + "processing": "OCR 处理中...", + "warning": { + "provider": { + "fallback": "已回退到 {{name}},这可能导致问题" + } + } + }, "ollama": { "keep_alive_time": { "description": "对话后模型在内存中保持的时间(默认:5 分钟)", @@ -3498,6 +3518,20 @@ }, "title": "设置", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "该提供商不存在" + }, + "tesseract": { + "langs": "支持的语言", + "temp_tooltip": "目前暂时只支持中文和英文" + }, + "title": "图片" + }, + "image_provider": "OCR 服务提供商", + "title": "OCR 服务" + }, "preprocess": { "provider": "文档处理服务商", "provider_placeholder": "选择一个文档处理服务商", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e9a41e2813..6d25a814b0 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1574,6 +1574,26 @@ }, "tip": "如果回應成功,則只針對超過30秒的訊息發出提醒" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "不能刪除內建提供者", + "existing": "提供商已存在", + "not_found": "OCR 提供商不存在", + "update_failed": "更新配置失敗" + }, + "unknown": "OCR過程發生錯誤" + }, + "file": { + "not_supported": "不支持的文件類型 {{type}}" + }, + "processing": "OCR 處理中...", + "warning": { + "provider": { + "fallback": "已回退到 {{name}},這可能導致問題" + } + } + }, "ollama": { "keep_alive_time": { "description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)", @@ -3498,6 +3518,20 @@ }, "title": "設定", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "該提供商不存在" + }, + "tesseract": { + "langs": "支援的語言", + "temp_tooltip": "目前暫時只支援中文和英文" + }, + "title": "圖片" + }, + "image_provider": "OCR 服務提供商", + "title": "OCR 服務" + }, "preprocess": { "provider": "文件處理供應商", "provider_placeholder": "選擇一個文件處理供應商", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index b0c96f2aa7..43bdc945ac 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1574,6 +1574,26 @@ }, "tip": "Εάν η απάντηση είναι επιτυχής, η ειδοποίηση εμφανίζεται μόνο για μηνύματα που υπερβαίνουν τα 30 δευτερόλεπτα" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου", + "existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη", + "not_found": "Ο πάροχος OCR δεν υπάρχει", + "update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης" + }, + "unknown": "Η διαδικασία OCR εμφάνισε σφάλμα" + }, + "file": { + "not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}" + }, + "processing": "Η επεξεργασία OCR βρίσκεται σε εξέλιξη...", + "warning": { + "provider": { + "fallback": "Επαναφέρθηκε στο {{name}}, το οποίο μπορεί να προκαλέσει προβλήματα" + } + } + }, "ollama": { "keep_alive_time": { "description": "Χρόνος που ο μοντέλος διατηρείται στη μνήμη μετά τη συζήτηση (προεπιλογή: 5 λεπτά)", @@ -3498,6 +3518,20 @@ }, "title": "Ρυθμίσεις", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "Ο πάροχος δεν υπάρχει" + }, + "tesseract": { + "langs": "Υποστηριζόμενες γλώσσες", + "temp_tooltip": "Προς το παρόν υποστηρίζονται μόνο η κινεζική και η αγγλική γλώσσα" + }, + "title": "Εικόνα" + }, + "image_provider": "Πάροχοι υπηρεσιών OCR", + "title": "Υπηρεσία OCR" + }, "preprocess": { "provider": "πάροχος υπηρεσιών προεπεξεργασίας εγγράφων", "provider_placeholder": "Επιλέξτε έναν πάροχο υπηρεσιών προεπεξεργασίας εγγράφων", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index efd6643820..e0d86e7a37 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1574,6 +1574,26 @@ }, "tip": "Si la respuesta es exitosa, solo se enviará un recordatorio para mensajes que excedan los 30 segundos" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "No se puede eliminar el proveedor integrado", + "existing": "El proveedor ya existe", + "not_found": "El proveedor de OCR no existe", + "update_failed": "Actualización de la configuración fallida" + }, + "unknown": "El proceso OCR ha fallado" + }, + "file": { + "not_supported": "Tipo de archivo no compatible {{type}}" + }, + "processing": "Procesando OCR...", + "warning": { + "provider": { + "fallback": "Se ha revertido a {{name}}, lo que podría causar problemas" + } + } + }, "ollama": { "keep_alive_time": { "description": "Tiempo que el modelo permanece en memoria después de la conversación (por defecto: 5 minutos)", @@ -3498,6 +3518,20 @@ }, "title": "Configuración", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "El proveedor no existe" + }, + "tesseract": { + "langs": "Idiomas compatibles", + "temp_tooltip": "Actualmente solo se admiten chino e inglés." + }, + "title": "Imagen" + }, + "image_provider": "Proveedor de servicios OCR", + "title": "Servicio OCR" + }, "preprocess": { "provider": "Proveedor de servicios de preprocesamiento de documentos", "provider_placeholder": "Seleccionar un proveedor de servicios de preprocesamiento de documentos", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 37008d5c4f..646e2b28a4 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1574,6 +1574,26 @@ }, "tip": "Si la réponse est réussie, un rappel est envoyé uniquement pour les messages dépassant 30 secondes" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré", + "existing": "Le fournisseur existe déjà", + "not_found": "Le fournisseur OCR n'existe pas", + "update_failed": "Échec de la mise à jour de la configuration" + }, + "unknown": "Une erreur s'est produite lors du processus OCR" + }, + "file": { + "not_supported": "Type de fichier non pris en charge {{type}}" + }, + "processing": "Traitement OCR en cours...", + "warning": { + "provider": { + "fallback": "Revenu à {{name}}, ce qui pourrait entraîner des problèmes" + } + } + }, "ollama": { "keep_alive_time": { "description": "Le temps pendant lequel le modèle reste en mémoire après la conversation (par défaut : 5 minutes)", @@ -3498,6 +3518,20 @@ }, "title": "Paramètres", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "Ce fournisseur n'existe pas" + }, + "tesseract": { + "langs": "Langues prises en charge", + "temp_tooltip": "Pour le moment, seuls le chinois et l'anglais sont pris en charge." + }, + "title": "Image" + }, + "image_provider": "Fournisseur de service OCR", + "title": "Service OCR" + }, "preprocess": { "provider": "fournisseur de services de prétraitement de documents", "provider_placeholder": "Choisissez un prestataire de traitement de documents", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index f245c4a463..e1828408f0 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1574,6 +1574,26 @@ }, "tip": "Se a resposta for bem-sucedida, lembrete apenas para mensagens que excedam 30 segundos" }, + "ocr": { + "error": { + "provider": { + "cannot_remove_builtin": "Não é possível excluir o provedor integrado", + "existing": "O provedor já existe", + "not_found": "O provedor OCR não existe", + "update_failed": "Falha ao atualizar a configuração" + }, + "unknown": "O processo OCR apresentou um erro" + }, + "file": { + "not_supported": "Tipo de arquivo não suportado {{type}}" + }, + "processing": "Processamento OCR em andamento...", + "warning": { + "provider": { + "fallback": "Revertido para {{name}}, o que pode causar problemas" + } + } + }, "ollama": { "keep_alive_time": { "description": "Tempo que o modelo permanece na memória após a conversa (padrão: 5 minutos)", @@ -3498,6 +3518,20 @@ }, "title": "Configurações", "tool": { + "ocr": { + "image": { + "error": { + "provider_not_found": "O provedor não existe" + }, + "tesseract": { + "langs": "Idiomas suportados", + "temp_tooltip": "No momento, apenas chinês e inglês são suportados." + }, + "title": "Imagem" + }, + "image_provider": "Provedor de serviços OCR", + "title": "Serviço OCR" + }, "preprocess": { "provider": "prestador de serviços de pré-processamento de documentos", "provider_placeholder": "Escolha um fornecedor de pré-processamento de documentos", diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx new file mode 100644 index 0000000000..3efdf94fa0 --- /dev/null +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx @@ -0,0 +1,62 @@ +import { loggerService } from '@logger' +import { useAppSelector } from '@renderer/store' +import { setImageOcrProvider } from '@renderer/store/ocr' +import { isImageOcrProvider, OcrProvider } from '@renderer/types' +import { Select } from 'antd' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' + +import { SettingRow, SettingRowTitle } from '..' + +const logger = loggerService.withContext('OcrImageSettings') + +type Props = { + setProvider: (provider: OcrProvider) => void +} + +const OcrImageSettings = ({ setProvider }: Props) => { + const { t } = useTranslation() + const providers = useAppSelector((state) => state.ocr.providers) + const imageProvider = useAppSelector((state) => state.ocr.imageProvider) + const imageProviders = providers.filter((p) => isImageOcrProvider(p)) + const dispatch = useDispatch() + + // 挂载时更新外部状态 + useEffect(() => { + setProvider(imageProvider) + }, [imageProvider, setProvider]) + + const updateImageProvider = (id: string) => { + const provider = imageProviders.find((p) => p.id === id) + if (!provider) { + logger.error(`Failed to find image provider by id: ${id}`) + window.message.error(t('settings.tool.ocr.image.error.provider_not_found')) + return + } + + setProvider(provider) + dispatch(setImageOcrProvider(provider)) + } + + return ( + <> + + {t('settings.tool.ocr.image_provider')} +

+ +
+ + + ) +} diff --git a/src/renderer/src/pages/settings/PreprocessSettings/PreprocessSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx similarity index 100% rename from src/renderer/src/pages/settings/PreprocessSettings/PreprocessSettings.tsx rename to src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx diff --git a/src/renderer/src/pages/settings/PreprocessSettings/index.tsx b/src/renderer/src/pages/settings/DocProcessSettings/PreprocessSettings.tsx similarity index 90% rename from src/renderer/src/pages/settings/PreprocessSettings/index.tsx rename to src/renderer/src/pages/settings/DocProcessSettings/PreprocessSettings.tsx index f80c0cd679..a09265a637 100644 --- a/src/renderer/src/pages/settings/PreprocessSettings/index.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/PreprocessSettings.tsx @@ -5,8 +5,8 @@ import { Select } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' -import PreprocessProviderSettings from './PreprocessSettings' +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +import PreprocessProviderSettings from './PreprocessProviderSettings' const PreprocessSettings: FC = () => { const { preprocessProviders } = usePreprocessProviders() @@ -25,7 +25,7 @@ const PreprocessSettings: FC = () => { } return ( - + <> {t('settings.tool.preprocess.title')} @@ -52,7 +52,7 @@ const PreprocessSettings: FC = () => { )} - + ) } export default PreprocessSettings diff --git a/src/renderer/src/pages/settings/DocProcessSettings/index.tsx b/src/renderer/src/pages/settings/DocProcessSettings/index.tsx new file mode 100644 index 0000000000..526f507fff --- /dev/null +++ b/src/renderer/src/pages/settings/DocProcessSettings/index.tsx @@ -0,0 +1,18 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import { FC } from 'react' + +import { SettingContainer } from '..' +import OcrSettings from './OcrSettings' +import PreprocessSettings from './PreprocessSettings' + +const DocProcessSettings: FC = () => { + const { theme: themeMode } = useTheme() + + return ( + + + + + ) +} +export default DocProcessSettings diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 3a72865d63..b8666a8f7d 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -26,10 +26,10 @@ import styled from 'styled-components' import AboutSettings from './AboutSettings' import DataSettings from './DataSettings/DataSettings' import DisplaySettings from './DisplaySettings/DisplaySettings' +import DocProcessSettings from './DocProcessSettings' import GeneralSettings from './GeneralSettings' import MCPSettings from './MCPSettings' import MemorySettings from './MemorySettings' -import PreprocessSettings from './PreprocessSettings' import ProvidersList from './ProviderSettings' import QuickAssistantSettings from './QuickAssistantSettings' import QuickPhraseSettings from './QuickPhraseSettings' @@ -100,8 +100,8 @@ const SettingsPage: FC = () => { {t('memory.title')} - - + + {t('settings.tool.preprocess.title')} @@ -144,7 +144,7 @@ const SettingsPage: FC = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/services/ocr/OcrService.ts b/src/renderer/src/services/ocr/OcrService.ts new file mode 100644 index 0000000000..3d8339f6e3 --- /dev/null +++ b/src/renderer/src/services/ocr/OcrService.ts @@ -0,0 +1,23 @@ +import { loggerService } from '@logger' +import { isOcrApiProvider, OcrProvider, OcrResult, SupportedOcrFile } from '@renderer/types' + +import { OcrApiClientFactory } from './clients/OcrApiClientFactory' + +const logger = loggerService.withContext('renderer:OcrService') + +/** + * ocr a file + * @param file any supported file + * @param provider ocr provider + * @returns ocr result + * @throws {Error} + */ +export const ocr = async (file: SupportedOcrFile, provider: OcrProvider): Promise => { + logger.info(`ocr file ${file.path}`) + if (isOcrApiProvider(provider)) { + const client = OcrApiClientFactory.create(provider) + return client.ocr(file) + } else { + return window.api.ocr.ocr(file, provider) + } +} diff --git a/src/renderer/src/services/ocr/clients/OcrApiClientFactory.ts b/src/renderer/src/services/ocr/clients/OcrApiClientFactory.ts new file mode 100644 index 0000000000..e685c0e3f9 --- /dev/null +++ b/src/renderer/src/services/ocr/clients/OcrApiClientFactory.ts @@ -0,0 +1,28 @@ +import { loggerService } from '@logger' +import { OcrApiProvider } from '@renderer/types' + +import { OcrBaseApiClient } from './OcrBaseApiClient' +import { OcrExampleApiClient } from './OcrExampleApiClient' + +const logger = loggerService.withContext('OcrApiClientFactory') + +export class OcrApiClientFactory { + /** + * Create an ApiClient instance for the given provider + * 为给定的提供者创建ApiClient实例 + */ + static create(provider: OcrApiProvider): OcrBaseApiClient { + logger.debug(`Creating ApiClient for provider:`, { + id: provider.id, + config: provider.config + }) + + let instance: OcrBaseApiClient + + // Extend other clients here + // eslint-disable-next-line prefer-const + instance = new OcrExampleApiClient(provider) + + return instance + } +} diff --git a/src/renderer/src/services/ocr/clients/OcrBaseApiClient.ts b/src/renderer/src/services/ocr/clients/OcrBaseApiClient.ts new file mode 100644 index 0000000000..c9605671ae --- /dev/null +++ b/src/renderer/src/services/ocr/clients/OcrBaseApiClient.ts @@ -0,0 +1,43 @@ +import { OcrApiProvider, OcrHandler } from '@renderer/types' + +export abstract class OcrBaseApiClient { + public provider: OcrApiProvider + protected host: string + protected apiKey: string + + constructor(provider: OcrApiProvider) { + this.provider = provider + this.host = this.getHost() + this.apiKey = this.getApiKey() + } + + abstract ocr: OcrHandler + + // copy from BaseApiClient + public getHost(): string { + return this.provider.config.api.apiHost + } + + // copy from BaseApiClient + public getApiKey() { + const keys = this.provider.config.api.apiKey.split(',').map((key) => key.trim()) + const keyName = `ocr_provider:${this.provider.id}:last_used_key` + + if (keys.length === 1) { + return keys[0] + } + + const lastUsedKey = window.keyv.get(keyName) + if (!lastUsedKey) { + window.keyv.set(keyName, keys[0]) + return keys[0] + } + + const currentIndex = keys.indexOf(lastUsedKey) + const nextIndex = (currentIndex + 1) % keys.length + const nextKey = keys[nextIndex] + window.keyv.set(keyName, nextKey) + + return nextKey + } +} diff --git a/src/renderer/src/services/ocr/clients/OcrExampleApiClient.ts b/src/renderer/src/services/ocr/clients/OcrExampleApiClient.ts new file mode 100644 index 0000000000..34d28173bb --- /dev/null +++ b/src/renderer/src/services/ocr/clients/OcrExampleApiClient.ts @@ -0,0 +1,15 @@ +import { OcrApiProvider, SupportedOcrFile } from '@renderer/types' + +import { OcrBaseApiClient } from './OcrBaseApiClient' + +export type OcrExampleProvider = OcrApiProvider + +export class OcrExampleApiClient extends OcrBaseApiClient { + constructor(provider: OcrApiProvider) { + super(provider) + } + + public ocr = async (file: SupportedOcrFile) => { + return { text: `Example output: ${file.path}` } + } +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index d90ee7282d..cdd3be560d 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -20,6 +20,7 @@ import migrate from './migrate' import minapps from './minapps' import newMessagesReducer from './newMessage' import nutstore from './nutstore' +import ocr from './ocr' import paintings from './paintings' import preprocess from './preprocess' import runtime from './runtime' @@ -55,14 +56,15 @@ const rootReducer = combineReducers({ messages: newMessagesReducer, messageBlocks: messageBlocksReducer, inputTools: inputToolsReducer, - translate + translate, + ocr }) const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 136, + version: 137, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index ee677ead39..f1f170eafb 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -3,6 +3,7 @@ import { nanoid } from '@reduxjs/toolkit' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, isMac } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { isFunctionCallingModel, isNotSupportedTextDelta, SYSTEM_MODELS } from '@renderer/config/models' +import { BUILTIN_OCR_PROVIDERS, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { isSupportArrayContentProvider, @@ -2174,6 +2175,18 @@ const migrateConfig = { logger.error('migrate 136 error', error as Error) return state } + }, + '137': (state: RootState) => { + try { + state.ocr = { + providers: BUILTIN_OCR_PROVIDERS, + imageProvider: DEFAULT_OCR_PROVIDER.image + } + return state + } catch (error) { + logger.error('migrate 137 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/ocr.ts b/src/renderer/src/store/ocr.ts new file mode 100644 index 0000000000..7e4ba3d348 --- /dev/null +++ b/src/renderer/src/store/ocr.ts @@ -0,0 +1,61 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { BUILTIN_OCR_PROVIDERS, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' +import { ImageOcrProvider, OcrProvider, OcrProviderConfig } from '@renderer/types' + +export interface OcrState { + providers: OcrProvider[] + imageProvider: ImageOcrProvider +} + +const initialState: OcrState = { + providers: BUILTIN_OCR_PROVIDERS, + imageProvider: DEFAULT_OCR_PROVIDER.image +} + +const ocrSlice = createSlice({ + name: 'ocr', + initialState, + reducers: { + setOcrProviders(state, action: PayloadAction) { + state.providers = action.payload + }, + addOcrProvider(state, action: PayloadAction) { + state.providers.push(action.payload) + }, + removeOcrProvider(state, action: PayloadAction) { + state.providers = state.providers.filter((provider) => provider.id !== action.payload) + }, + updateOcrProvider(state, action: PayloadAction>) { + const index = state.providers.findIndex((provider) => provider.id === action.payload.id) + if (index !== -1) { + Object.assign(state.providers[index], action.payload) + } + }, + updateOcrProviderConfig( + state, + action: PayloadAction<{ id: string; update: Omit, 'id'> }> + ) { + const index = state.providers.findIndex((provider) => provider.id === action.payload.id) + if (index !== -1) { + if (!state.providers[index].config) { + state.providers[index].config = {} + } + Object.assign(state.providers[index].config, action.payload.update) + } + }, + setImageOcrProvider(state, action: PayloadAction) { + state.imageProvider = action.payload + } + } +}) + +export const { + setOcrProviders, + addOcrProvider, + removeOcrProvider, + updateOcrProvider, + updateOcrProviderConfig, + setImageOcrProvider +} = ocrSlice.actions + +export default ocrSlice.reducer diff --git a/src/renderer/src/types/file.ts b/src/renderer/src/types/file.ts index db998c60d6..db5c51e5b3 100644 --- a/src/renderer/src/types/file.ts +++ b/src/renderer/src/types/file.ts @@ -100,3 +100,16 @@ export enum FileTypes { DOCUMENT = 'document', OTHER = 'other' } + +export type ImageFileMetadata = FileMetadata & { + type: FileTypes.IMAGE +} + +/** + * 类型守卫函数,用于检查一个 FileMetadata 是否为图片文件元数据 + * @param file - 要检查的文件元数据 + * @returns 如果文件是图片类型则返回 true + */ +export const isImageFile = (file: FileMetadata): file is ImageFileMetadata => { + return file.type === FileTypes.IMAGE +} diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index edb81bd969..ee35d7202f 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -9,6 +9,8 @@ export * from './file' import type { FileMetadata } from './file' import type { Message } from './newMessage' +export * from './ocr' + export type Assistant = { id: string name: string diff --git a/src/renderer/src/types/ocr.ts b/src/renderer/src/types/ocr.ts new file mode 100644 index 0000000000..c537191318 --- /dev/null +++ b/src/renderer/src/types/ocr.ts @@ -0,0 +1,142 @@ +import Tesseract from 'tesseract.js' + +import { FileMetadata, ImageFileMetadata, isImageFile } from '.' + +export const BuiltinOcrProviderIds = { + tesseract: 'tesseract' +} as const + +export type BuiltinOcrProviderId = keyof typeof BuiltinOcrProviderIds + +export const isBuiltinOcrProviderId = (id: string): id is BuiltinOcrProviderId => { + return Object.hasOwn(BuiltinOcrProviderIds, id) +} + +// extensible +export const OcrProviderCapabilities = { + image: 'image' +} as const + +export type OcrProviderCapability = keyof typeof OcrProviderCapabilities + +export const isOcrProviderCapability = (cap: string): cap is OcrProviderCapability => { + return Object.hasOwn(OcrProviderCapabilities, cap) +} + +export type OcrProviderCapabilityRecord = Partial> + +// OCR models and providers share the same type definition. +// A provider can offer capabilities to process multiple file types, +// while a model belonging to that provider may be limited to processing only one specific file type. +export type OcrModelCapabilityRecord = OcrProviderCapabilityRecord + +export interface OcrModel { + id: string + name: string + providerId: string + capabilities: OcrModelCapabilityRecord +} + +/** + * Extend this type to define provider-specefic config types. + */ +export type OcrProviderApiConfig = { + apiKey: string + apiHost: string + apiVersion?: string +} + +export const isOcrProviderApiConfig = (config: unknown): config is OcrProviderApiConfig => { + return ( + typeof config === 'object' && + config !== null && + 'apiKey' in config && + typeof config.apiKey === 'string' && + 'apiHost' in config && + typeof config.apiHost === 'string' && + (!('apiVersion' in config) || typeof config.apiVersion === 'string') + ) +} + +/** + * For future. Model based ocr, api based ocr. May different api client. + * + * Extend this type to define provider-specific config types. + */ +export type OcrProviderConfig = { + /** Not used for now. Could safely remove. */ + api?: OcrProviderApiConfig + /** Not used for now. Could safely remove. */ + models?: OcrModel[] + /** Not used for now. Could safely remove. */ + enabled?: boolean +} + +export type OcrProvider = { + id: string + name: string + capabilities: OcrProviderCapabilityRecord + config?: OcrProviderConfig +} + +export type OcrApiProvider = OcrProvider & { + config: OcrProviderConfig & { + api: OcrProviderApiConfig + } +} + +export const isOcrApiProvider = (p: OcrProvider): p is OcrApiProvider => { + return !!(p.config && p.config.api && isOcrProviderApiConfig(p.config.api)) +} + +export type BuiltinOcrProvider = OcrProvider & { + id: BuiltinOcrProviderId +} + +export const isBuiltinOcrProvider = (p: OcrProvider): p is BuiltinOcrProvider => { + return isBuiltinOcrProviderId(p.id) +} + +// Not sure compatiable api endpoint exists. May not support custom ocr provider +export type CustomOcrProvider = OcrProvider & { + id: Exclude +} + +export type ImageOcrProvider = OcrProvider & { + capabilities: OcrProviderCapabilityRecord & { + [OcrProviderCapabilities.image]: true + } +} + +export const isImageOcrProvider = (p: OcrProvider): p is ImageOcrProvider => { + return p.capabilities.image === true +} + +export type SupportedOcrFile = ImageFileMetadata + +export const isSupportedOcrFile = (file: FileMetadata): file is SupportedOcrFile => { + return isImageFile(file) +} + +export type OcrResult = { + text: string +} + +export type OcrHandler = (file: SupportedOcrFile) => Promise + +export type OcrImageHandler = (file: ImageFileMetadata) => Promise + +// Tesseract Types +export type OcrTesseractConfig = OcrProviderConfig & { + langs: Partial> +} + +export type OcrTesseractProvider = BuiltinOcrProvider & { + config: OcrTesseractConfig +} + +export const isOcrTesseractProvider = (p: OcrProvider): p is OcrTesseractProvider => { + return p.id === BuiltinOcrProviderIds.tesseract +} + +export type TesseractLangCode = Tesseract.LanguageCode diff --git a/src/renderer/src/utils/ocr.ts b/src/renderer/src/utils/ocr.ts new file mode 100644 index 0000000000..1c4e6628d3 --- /dev/null +++ b/src/renderer/src/utils/ocr.ts @@ -0,0 +1,12 @@ +import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png' +import { isBuiltinOcrProviderId } from '@renderer/types' + +export function getOcrProviderLogo(providerId: string) { + if (isBuiltinOcrProviderId(providerId)) { + switch (providerId) { + case 'tesseract': + return TesseractLogo + } + } + return undefined +} diff --git a/yarn.lock b/yarn.lock index 6c1b8a4bb7..db442ef6f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2953,7 +2953,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.4.5": +"@emnapi/runtime@npm:^1.4.4, @emnapi/runtime@npm:^1.4.5": version: 1.4.5 resolution: "@emnapi/runtime@npm:1.4.5" dependencies: @@ -3524,6 +3524,207 @@ __metadata: languageName: node linkType: hard +"@img/sharp-darwin-arm64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-darwin-arm64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-darwin-x64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-darwin-x64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-x64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linux-arm@npm:1.2.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-ppc64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-x64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linux-x64@npm:1.2.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-x64@npm:1.2.0": + version: 1.2.0 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linux-arm64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linux-arm64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-arm@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linux-arm@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-ppc64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linux-ppc64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linux-ppc64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linux-ppc64": + optional: true + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linux-s390x@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-x64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linux-x64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-arm64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-x64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-linuxmusl-x64@npm:0.34.3" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.0" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-wasm32@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-wasm32@npm:0.34.3" + dependencies: + "@emnapi/runtime": "npm:^1.4.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@img/sharp-win32-arm64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-win32-arm64@npm:0.34.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-win32-ia32@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-win32-ia32@npm:0.34.3" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@img/sharp-win32-x64@npm:0.34.3": + version: 0.34.3 + resolution: "@img/sharp-win32-x64@npm:0.34.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -8631,11 +8832,13 @@ __metadata: rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" selection-hook: "npm:^1.0.11" + sharp: "npm:^0.34.3" shiki: "npm:^3.9.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" tar: "npm:^7.4.3" + tesseract.js: "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch" tiny-pinyin: "npm:^1.3.2" tokenx: "npm:^1.1.0" tsx: "npm:^4.20.3" @@ -9371,6 +9574,13 @@ __metadata: languageName: node linkType: hard +"bmp-js@npm:^0.1.0": + version: 0.1.0 + resolution: "bmp-js@npm:0.1.0" + checksum: 10c0/c651bd5936dcf8d67900050fac14dcbe30baf87c3d21c58f4934fcdf46172e152a87d8c0c3ca25caa2b4b2c7780ef3b5fcc6cd20afd8f0351856cadb1bef9694 + languageName: node + linkType: hard + "body-parser@npm:^2.2.0": version: 2.2.0 resolution: "body-parser@npm:2.2.0" @@ -10139,7 +10349,7 @@ __metadata: languageName: node linkType: hard -"color-string@npm:^1.6.0": +"color-string@npm:^1.6.0, color-string@npm:^1.9.0": version: 1.9.1 resolution: "color-string@npm:1.9.1" dependencies: @@ -10168,6 +10378,16 @@ __metadata: languageName: node linkType: hard +"color@npm:^4.2.3": + version: 4.2.3 + resolution: "color@npm:4.2.3" + dependencies: + color-convert: "npm:^2.0.1" + color-string: "npm:^1.9.0" + checksum: 10c0/7fbe7cfb811054c808349de19fb380252e5e34e61d7d168ec3353e9e9aacb1802674bddc657682e4e9730c2786592a4de6f8283e7e0d3870b829bb0b7b2f6118 + languageName: node + linkType: hard + "color@npm:^5.0.0": version: 5.0.0 resolution: "color@npm:5.0.0" @@ -11280,7 +11500,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4": version: 2.0.4 resolution: "detect-libc@npm:2.0.4" checksum: 10c0/c15541f836eba4b1f521e4eecc28eefefdbc10a94d3b8cb4c507689f332cc111babb95deda66f2de050b22122113189986d5190be97d51b5a2b23b938415e67c @@ -14050,6 +14270,13 @@ __metadata: languageName: node linkType: hard +"idb-keyval@npm:^6.2.0": + version: 6.2.2 + resolution: "idb-keyval@npm:6.2.2" + checksum: 10c0/b52f0d2937cc2ec9f1da536b0b5c0875af3043ca210714beaffead4ec1f44f2ad322220305fd024596203855224d9e3523aed83e971dfb62ddc21b5b1721aeef + languageName: node + linkType: hard + "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -14441,6 +14668,13 @@ __metadata: languageName: node linkType: hard +"is-url@npm:^1.2.4": + version: 1.2.4 + resolution: "is-url@npm:1.2.4" + checksum: 10c0/0157a79874f8f95fdd63540e3f38c8583c2ef572661cd0693cda80ae3e42dfe8e9a4a972ec1b827f861d9a9acf75b37f7d58a37f94a8a053259642912c252bc3 + languageName: node + linkType: hard + "is-wsl@npm:^2.2.0": version: 2.2.0 resolution: "is-wsl@npm:2.2.0" @@ -17550,6 +17784,15 @@ __metadata: languageName: node linkType: hard +"opencollective-postinstall@npm:^2.0.3": + version: 2.0.3 + resolution: "opencollective-postinstall@npm:2.0.3" + bin: + opencollective-postinstall: index.js + checksum: 10c0/8a0104a218bc1afaae943f0af378461eeb2836f9848bad872bbd067ec5d1d9791636f307454ab77d0746f10341366f295384656a340ebdb87a2585058e8567e5 + languageName: node + linkType: hard + "option@npm:~0.2.1": version: 0.2.4 resolution: "option@npm:0.2.4" @@ -19454,6 +19697,13 @@ __metadata: languageName: node linkType: hard +"regenerator-runtime@npm:^0.13.3": + version: 0.13.11 + resolution: "regenerator-runtime@npm:0.13.11" + checksum: 10c0/12b069dc774001fbb0014f6a28f11c09ebfe3c0d984d88c9bced77fdb6fedbacbca434d24da9ae9371bfbf23f754869307fb51a4c98a8b8b18e5ef748677ca24 + languageName: node + linkType: hard + "regex-recursion@npm:^6.0.2": version: 6.0.2 resolution: "regex-recursion@npm:6.0.2" @@ -20145,6 +20395,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.2": + version: 7.7.2 + resolution: "semver@npm:7.7.2" + bin: + semver: bin/semver.js + checksum: 10c0/aca305edfbf2383c22571cb7714f48cadc7ac95371b4b52362fb8eeffdfbc0de0669368b82b2b15978f8848f01d7114da65697e56cd8c37b0dab8c58e543f9ea + languageName: node + linkType: hard + "send@npm:^1.1.0, send@npm:^1.2.0": version: 1.2.0 resolution: "send@npm:1.2.0" @@ -20213,6 +20472,84 @@ __metadata: languageName: node linkType: hard +"sharp@npm:^0.34.3": + version: 0.34.3 + resolution: "sharp@npm:0.34.3" + dependencies: + "@img/sharp-darwin-arm64": "npm:0.34.3" + "@img/sharp-darwin-x64": "npm:0.34.3" + "@img/sharp-libvips-darwin-arm64": "npm:1.2.0" + "@img/sharp-libvips-darwin-x64": "npm:1.2.0" + "@img/sharp-libvips-linux-arm": "npm:1.2.0" + "@img/sharp-libvips-linux-arm64": "npm:1.2.0" + "@img/sharp-libvips-linux-ppc64": "npm:1.2.0" + "@img/sharp-libvips-linux-s390x": "npm:1.2.0" + "@img/sharp-libvips-linux-x64": "npm:1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.0" + "@img/sharp-linux-arm": "npm:0.34.3" + "@img/sharp-linux-arm64": "npm:0.34.3" + "@img/sharp-linux-ppc64": "npm:0.34.3" + "@img/sharp-linux-s390x": "npm:0.34.3" + "@img/sharp-linux-x64": "npm:0.34.3" + "@img/sharp-linuxmusl-arm64": "npm:0.34.3" + "@img/sharp-linuxmusl-x64": "npm:0.34.3" + "@img/sharp-wasm32": "npm:0.34.3" + "@img/sharp-win32-arm64": "npm:0.34.3" + "@img/sharp-win32-ia32": "npm:0.34.3" + "@img/sharp-win32-x64": "npm:0.34.3" + color: "npm:^4.2.3" + detect-libc: "npm:^2.0.4" + semver: "npm:^7.7.2" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-ppc64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-ppc64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-wasm32": + optional: true + "@img/sharp-win32-arm64": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + checksum: 10c0/df9e6645e3db6ed298a0ac956ba74e468c367fc038b547936fbdddc6a29fce9af40413acbef73b3716291530760f311a20e45c8983f20ee5ea69dd2f21464a2b + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -21001,6 +21338,47 @@ __metadata: languageName: node linkType: hard +"tesseract.js-core@npm:^6.0.0": + version: 6.0.0 + resolution: "tesseract.js-core@npm:6.0.0" + checksum: 10c0/c04be8bbaa296be658664496754f21e857bdffff84113f08adf02f03a1f84596d68b3542ed2fda4a6dc138abb84b09b30ab07c04ee5950879e780876d343955f + languageName: node + linkType: hard + +"tesseract.js@npm:6.0.1": + version: 6.0.1 + resolution: "tesseract.js@npm:6.0.1" + dependencies: + bmp-js: "npm:^0.1.0" + idb-keyval: "npm:^6.2.0" + is-url: "npm:^1.2.4" + node-fetch: "npm:^2.6.9" + opencollective-postinstall: "npm:^2.0.3" + regenerator-runtime: "npm:^0.13.3" + tesseract.js-core: "npm:^6.0.0" + wasm-feature-detect: "npm:^1.2.11" + zlibjs: "npm:^0.3.1" + checksum: 10c0/1d73bb1fbc00c8629756d9594989d8bbfabda657a8cad84922ad68eb0f073148c82845bf71a882e5d2427a46edb5a470356864e60562c7a8442bddd70251435a + languageName: node + linkType: hard + +"tesseract.js@patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch": + version: 6.0.1 + resolution: "tesseract.js@patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch::version=6.0.1&hash=a9cf7b" + dependencies: + bmp-js: "npm:^0.1.0" + idb-keyval: "npm:^6.2.0" + is-url: "npm:^1.2.4" + node-fetch: "npm:^2.6.9" + opencollective-postinstall: "npm:^2.0.3" + regenerator-runtime: "npm:^0.13.3" + tesseract.js-core: "npm:^6.0.0" + wasm-feature-detect: "npm:^1.2.11" + zlibjs: "npm:^0.3.1" + checksum: 10c0/8a94fcc688ff21a9e82b721563d8fa174837ba807d0f01290fe9a1bb6a1c96ecaf7dc1c83510510f3d5185abd15f1cc5fc3cb7ad6c0eee0c4b3e278106f8a5da + languageName: node + linkType: hard + "test-exclude@npm:^7.0.1": version: 7.0.1 resolution: "test-exclude@npm:7.0.1" @@ -22173,6 +22551,13 @@ __metadata: languageName: node linkType: hard +"wasm-feature-detect@npm:^1.2.11": + version: 1.8.0 + resolution: "wasm-feature-detect@npm:1.8.0" + checksum: 10c0/2cb43e91bbf7aa7c121bc76b3133de3ab6dc4f482acc1d2dc46c528e8adb7a51c72df5c2aacf1d219f113c04efd1706f18274d5790542aa5dd49e0644e3ee665 + languageName: node + linkType: hard + "wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" @@ -22678,6 +23063,13 @@ __metadata: languageName: node linkType: hard +"zlibjs@npm:^0.3.1": + version: 0.3.1 + resolution: "zlibjs@npm:0.3.1" + checksum: 10c0/2d110bfcb0f8b8dbf225423f6556da9c5bca95c8b849c1218983676158a24b5cd0350357e0c4d504e27f8c7e18d471d9712576f35114a81a51bcf83453f02beb + languageName: node + linkType: hard + "zod-to-json-schema@npm:^3.22.3, zod-to-json-schema@npm:^3.22.4, zod-to-json-schema@npm:^3.22.5, zod-to-json-schema@npm:^3.24.1": version: 3.24.5 resolution: "zod-to-json-schema@npm:3.24.5" From 7bb3826cddeef868f5de2c6e02fb4564418d7d2b Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:21:26 +0800 Subject: [PATCH 74/96] feat: ocr image to translate (#9423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: 添加 tesseract.js 及其类型定义依赖 * feat(ocr): 添加OCR类型定义文件以支持OCR功能扩展 * feat(ocr): 添加 Tesseract OCR 提供程序配置 * feat(ocr): 添加Tesseract.js的logo * refactor(settings): 重构文档预处理设置模块结构 将PreprocessSettings重命名为DocProcessSettings并调整文件结构 更新相关路由和组件引用以保持功能一致性 * refactor(config): 重命名OCR_PROVIDER_CONFIG为BUILTIN_OCR_PROVIDERS以更准确描述用途 * refactor(ocr): 更改文件名 * refactor(ocr): 将获取OCR提供商logo的功能移动到utils目录 将getOcrProviderLogo函数从config/ocr.ts移动到utils/ocr.ts,保持功能集中 * refactor(ocr): 重构OCR配置结构以支持默认提供者 将内置OCR提供者数组重构为单独定义的常量,并添加默认OCR提供者映射。这提高了代码的可维护性并支持未来扩展。 * feat(store): 添加OCR状态管理切片 实现OCR提供商的增删改查功能,使用Redux Toolkit管理OCR相关状态 * feat(types): 添加图片文件类型守卫函数 添加 ImageFileMetadata 类型和 isImageFile 类型守卫函数,用于检查文件是否为图片类型 * feat(ocr): 添加对OCR支持文件类型的类型定义和校验函数 添加SupportedOcrFileType类型和isSupportedOcrFileType校验函数 添加SupportedOcrFile类型和isSupportedOcrFile校验函数 * feat(ocr): 添加OCR功能支持 实现基于Tesseract的OCR功能,包括文件类型检查、服务接口和IPC通信 新增OCR相关类型定义和服务实现 * refactor(OcrService): 更新日志上下文为'main:OcrService' * feat(ocr): 添加OCR服务基础功能 实现OCR服务的基础功能,通过调用window.api.ocr接口处理支持的文件类型 * feat(store): 添加ocr模块到redux store * feat(ocr): 添加OCR功能支持及文件类型校验 添加OCR功能钩子useOcr,支持图片文件识别 添加不支持文件类型的错误提示国际化文案 * refactor(ocr): 重命名updatePreprocessProvider为updateOcrProvider以保持命名一致性 * feat(ocr): 添加设置图片OCR提供商的功能 * refactor(ocr): 统一OCR类型导入路径 将所有OCR相关类型从'@renderer/types/ocr'改为从'@renderer/types'或'@types'导入 优化DEFAULT_OCR_PROVIDER类型定义 * feat(store): 更新持久化存储版本并添加OCR配置迁移 添加137版本迁移逻辑,初始化OCR提供者和默认图像提供者配置 * feat(ocr): 添加OCR服务设置界面及提供商选择功能 实现OCR服务设置界面,包含图片OCR提供商的选择功能 修复ocr.ts中imageProvider的类型定义 添加相关国际化文本 * fix(ocr): 添加图像大小检查并优化错误处理 检查图像文件大小是否超过50MB限制 使用buffer读取文件替代直接路径识别 简化错误处理逻辑,直接抛出原始错误 * feat(OCR服务): 支持base64字符串作为OCR输入 扩展tesseractOcr函数以接受base64字符串或图像文件作为输入 * feat(hooks): 添加useFiles钩子用于文件选择功能 * refactor(useFiles): 移除multipleSelections参数并重构文件选择逻辑 将multipleSelections从组件props移动到onSelectFile方法参数中,简化组件接口 重构文件选择逻辑,移除不必要的useMemo,提升代码可维护性 * refactor(useFiles): 使用useMemo优化扩展名处理逻辑 将扩展名处理逻辑移至useMemo中,避免不必要的重复计算。当props.extensions未提供时默认返回['*'] * feat(文件选择): 增强文件选择功能并添加清除文件方法 - 为文件选择API添加返回类型声明 - 完善文件选择回调函数的文档注释 - 修改文件选择逻辑以返回选中的文件数组 - 添加清除文件列表的方法 * refactor(useFiles): 将参数从布尔值改为对象以增强可扩展性 * feat(hooks): 在useFiles钩子中暴露selecting状态 * feat(translate): 添加文件OCR功能支持 在翻译页面新增浮动按钮,支持通过OCR识别文件内容并自动填充到输入框。添加相关hooks和文件类型检查逻辑,提升用户输入便捷性。 * build: 将 tesseract.js 从 devDependencies 移至 dependencies 确保生产环境能正确使用 tesseract.js 功能 * refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置 * refactor(TesseractService): 添加日志记录并更新worker配置 添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger * feat(翻译页面): 添加OCR处理中的加载状态提示 在翻译页面中添加OCR处理时的加载状态提示,提升用户体验 * fix(translate): 为OCR处理消息添加无限持续时间 防止OCR处理过程中消息自动消失,确保用户明确知道处理状态 * fix: 添加OCR未知错误的翻译并更新错误提示 在OCR处理失败时,使用翻译后的错误消息替代原始错误提示 * style(translate): 调整浮动按钮位置从右上到左下 * fix(translate): 处理未选择文件时提前返回以避免空指针异常 * feat(i18n): 添加OCR功能的多语言支持 * feat(fs): 添加自动识别编码读取文本文件功能 实现通过自动检测文件编码来读取文本文件的功能 在IPC通道、预加载API和文件服务中添加相关方法 * feat(翻译): 添加文件读取功能并改进错误处理 添加对文本文件的支持并优化文件处理流程 改进错误提示信息,包括文件过大和读取失败的场景 * fix(i18n): 更新文件大小限制错误信息并添加多语言支持 修改文件大小限制的错误信息格式,移除括号内的限制范围 为多种语言添加文件操作相关的翻译条目 在错误提示中动态显示文件大小限制范围 * refactor(AttachmentButton): 移除类型注释,使用自动类型推断 * fix(hooks): 返回变量supportedFiles * fix(ocr): 改进OCR处理中的消息管理和错误处理 在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑 移除TranslatePage中重复的消息管理代码,简化OCR处理流程 * fix(translate): 在选择文件后清除文件状态以避免残留 在文件选择完成后调用clearFiles以清除文件状态 * refactor(preload): 移动OCR类型定义到共享类型文件 将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性 * refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本 返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑 * fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置 将错误抛出语句移至else分支 * refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义 * build: 将 tesseract.js 从 devDependencies 移至 dependencies 确保生产环境能正确使用 tesseract.js 功能 * refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置 * refactor(TesseractService): 添加日志记录并更新worker配置 添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger * feat(i18n): 添加OCR功能的多语言支持 * refactor(preload): 移动OCR类型定义到共享类型文件 将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性 * refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本 返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑 * fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置 将错误抛出语句移至else分支 * refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义 * fix(ocr): 改进OCR处理中的消息管理和错误处理 在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑 移除TranslatePage中重复的消息管理代码,简化OCR处理流程 * feat(i18n): 添加OCR相关的错误和状态翻译文本 * fix(useOcr): 修复未支持文件类型错误抛出位置 将不支持的OCR文件类型错误抛出逻辑移至条件判断内 * refactor(ocr): ocrImage实现使用OcrService并更新日志上下文 将ocrImage函数从useOcr钩子移动到OcrService中,提高代码复用性 更新日志服务上下文从'main'改为'renderer'以更准确反映模块位置 * style(TabContainer): 移除多余的空行并保持代码整洁 * refactor(ocr): 简化OCR文件类型检查逻辑 使用现有的isImageFile函数替代冗余的类型检查逻辑,提高代码复用性 * fix: 将迁移错误日志从136更新为137 * feat(ocr): enhance Tesseract service with language support and worker management - Added support for multiple Tesseract languages: Chinese (Simplified and Traditional) and English. - Refactored Tesseract worker management into a class for better encapsulation and reuse. - Introduced methods to dynamically determine language path based on IP country and manage worker lifecycle. * update cn url * support cn data * change to asyn * use register design mode * add type * use bind function * refactor(ipc): 简化OCR处理程序参数 * refactor(ocr): 修改ocrProviderCapabilityRecord类型定义 允许只定义部分能力 * refactor(ocr): 将Tesseract相关配置移至服务内部 将语言列表和下载URL常量从共享配置移至Tesseract服务内部 使用常量定义图片大小阈值以提高可读性 * refactor(ocr): 统一使用 SupportedOcrFile 类型替换 FileMetadata 更新 OCR 服务及其 Tesseract 实现,使用 SupportedOcrFile 类型替代原有的 FileMetadata 类型,以提高类型安全性和一致性。同时在 OcrService 中添加重复注册的警告日志。 * refactor(ocr): 重构OCR类型定义以支持模型和API配置 将OCR提供者配置拆分为独立类型,增加模型能力记录和API配置类型检查 添加OCR处理程序类型定义,为未来扩展提供更好的类型支持 * refactor(OcrService): 移除重复的OcrHandler类型定义 已在@types中定义OcrHandler类型,移除重复定义以提高代码一致性 * refactor(ocr): 将OcrService移动到ocr目录下并更新引用路径 * feat(ocr): 添加OCR API客户端工厂及示例实现 实现OCR API客户端工厂模式,支持根据不同提供商创建对应的客户端 新增OcrBaseApiClient作为基础类,提供通用功能 添加OcrExampleApiClient作为示例实现 修改OcrService以使用新的客户端工厂 * refactor(ocr): 添加日志记录以跟踪OCR文件处理 在OCR服务中添加日志记录功能,便于跟踪文件处理过程 * fix(deps): 更新 tesseract.js 依赖并添加补丁文件 修复 tesseract.js 类型定义问题并添加语言常量支持 * refactor(ocr): 移除注释掉的tesseract语言映射代码 使用Tesseract.js的LanguageCode类型替代硬编码的语言列表,提高类型安全性 * feat(ocr): 添加 Tesseract OCR 配置类型 * refactor(OCR设置): 重命名OcrImageProviderSettings为OcrImageSettings并优化代码结构 * refactor(ocr): 将 Tesseract 相关类型移动到文件底部以改善代码组织 * feat(ocr): 添加 Tesseract OCR 提供者类型检查函数 * feat(ocr): 添加更新OCR提供者配置的功能 * feat: 添加OCR提供者钩子函数 实现useOcrProvider钩子用于获取和更新OCR提供者配置 * refactor(ocr): 修改removeOcrProvider参数为字符串id 简化removeOcrProvider方法的参数类型,直接使用字符串id进行过滤,提高代码简洁性 * refactor(ocr): 将内置OCR提供者从数组改为映射结构 重构OCR配置模块,使用映射结构存储内置OCR提供者以便于扩展和维护 * refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组 使用Object.freeze确保数组不可变,提高代码安全性 * feat(ocr): 添加OCR提供者管理功能并改进错误处理 添加useOcrProviders钩子用于管理OCR提供者的添加和删除 当内置OCR提供者不存在时自动恢复默认配置 改进错误提示信息并增加国际化支持 * Revert "refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组" This reverts commit f23e37941abba4fcc703b31e955b67bff565c432. * feat(ocr): 为Tesseract OCR添加多语言支持配置 添加对简体中文、繁体中文和英文的语言支持配置,扩展OCR功能以满足多语言识别需求 * refactor(types): 将Tesseract.LanguageCode重命名为TesseractLangCode以提高可读性 * feat(OCR设置): 添加OCR提供商设置组件及状态管理 新增OCR提供商设置组件,支持显示当前选择的OCR提供商信息 在OCR图片设置中添加状态管理,同步提供商选择到父组件 添加Tesseract OCR设置组件,支持多语言选择(暂不可用) * fix(DocProcessSettings): 修复OCR语言选择默认值问题 * feat(i18n): 添加OCR提供商相关错误和警告的翻译 * fix(ocr): 将 Tesseract 语言配置类型改为部分 * fix(ocr): 修复ocrImage函数未使用await导致的问题 * fix(ocr): 修复迁移配置中ocr状态的初始化方式 将分散的属性赋值改为对象整体赋值,避免潜在的属性丢失问题 * chore: 移除不再使用的@types/tesseract.js依赖 * refactor(OCR设置): 添加错误边界处理并移除无用注释 在OCR设置组件中添加ErrorBoundary以处理潜在错误 移除OcrTesseractSettings中的TODO注释 * build: 添加 sharp 依赖以支持图片处理功能 * refactor(ocr): 添加OCR图像预处理功能并优化TesseractService Co-authored-by: Qwen-Coder * refactor(ocr): 移除独立的灰度处理模块并改进预处理流程 将灰度处理功能直接集成到OCR预处理中,不再需要单独的image模块 添加normalise和threshold处理以提升OCR识别效果 * feat(i18n): 添加文件上传tool tip的翻译文本 * feat(hooks): 添加useDrag钩子实现拖拽功能 * feat(translate): 添加拖拽上传文件功能 实现文件拖拽上传功能,包括拖拽区域高亮显示和提示文本 添加多文件上传错误提示和未知错误处理 * feat(i18n): 添加文件拖拽和多文件上传错误提示的翻译 * refactor(PasteService): 优化粘贴服务逻辑并移除不必要的翻译依赖 将`t`参数改为布尔类型的`showMessage`参数,简化消息显示逻辑 添加默认的粘贴文本长度阈值 使文件扩展名检查变为可选参数 更新相关调用处的参数传递 * Revert "refactor(PasteService): 优化粘贴服务逻辑并移除不必要的翻译依赖" This reverts commit 07c7ecd0cfb586759ac4d7a09641b9bf3e8f88bb. * fix(preload): 为文件获取方法添加返回类型声明 添加Promise返回类型以明确get方法的返回值类型,提高代码可读性和类型安全性 * refactor(TopView): 移除未使用的loggerService导入和调用 * feat(TranslatePage): 添加对粘贴上传文件的支持 新增粘贴上传文件功能,处理剪贴板中的文件数据并支持图片临时文件创建 添加文件类型检查和不支持类型的错误提示 重构文件选择逻辑到通用函数 getSingleFile * feat(i18n): 添加不支持文件类型的多语言翻译 * feat(translate): 添加翻译输入状态并优化内容更新逻辑 添加translateInput状态以存储翻译输入内容 优化setTranslatedContent reducer直接修改状态而非返回新对象 * refactor(translate): 将文本输入状态迁移至redux存储 移除本地状态_text和使用useState管理的text,改为从redux store中获取和管理输入文本 * fix(translate): 修复依赖数组中缺少setText导致的状态更新问题 * fix(store): 初始化翻译输入为空字符串 修复迁移配置时未初始化翻译输入的问题,避免潜在的undefined错误 * fix(hooks): 使 useDrag 的 onDrop 参数变为可选 处理 onDrop 未定义时的调用情况,避免运行时错误 * fix(拖拽): 修复拖拽状态未正确更新的问题 修复 handleDragOver 中未设置 isDragging 状态的问题 为输入区域添加独立的拖拽状态处理 防止容器元素意外触发文件拖放 * refactor(translate): 在文件拖放错误处理中移动错误提示位置 将文件拖放错误提示从空文件检查移动到文件读取错误捕获中 * improve image preprocess --------- Co-authored-by: beyondkmp Co-authored-by: Qwen-Coder --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 1 + src/main/services/FileSystemService.ts | 12 + src/main/utils/file.ts | 1 + src/preload/index.ts | 8 +- src/renderer/src/components/TopView/index.tsx | 6 +- src/renderer/src/hooks/useDrag.ts | 43 +++ src/renderer/src/hooks/useFiles.ts | 97 +++++++ src/renderer/src/i18n/locales/en-us.json | 13 + src/renderer/src/i18n/locales/ja-jp.json | 13 + src/renderer/src/i18n/locales/ru-ru.json | 13 + src/renderer/src/i18n/locales/zh-cn.json | 13 + src/renderer/src/i18n/locales/zh-tw.json | 13 + src/renderer/src/i18n/translate/el-gr.json | 13 + src/renderer/src/i18n/translate/es-es.json | 13 + src/renderer/src/i18n/translate/fr-fr.json | 13 + src/renderer/src/i18n/translate/pt-pt.json | 13 + .../pages/home/Inputbar/AttachmentButton.tsx | 4 +- .../src/pages/translate/TranslatePage.tsx | 271 +++++++++++++++++- src/renderer/src/services/PasteService.ts | 2 +- src/renderer/src/store/migrate.ts | 1 + src/renderer/src/store/translate.ts | 12 +- src/renderer/src/utils/file.ts | 9 + 23 files changed, 558 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/hooks/useDrag.ts create mode 100644 src/renderer/src/hooks/useFiles.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index f35db50bc6..f2b856ef1d 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -156,6 +156,7 @@ export enum IpcChannel { File_Base64File = 'file:base64File', File_GetPdfInfo = 'file:getPdfInfo', Fs_Read = 'fs:read', + Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', File_IsTextFile = 'file:isTextFile', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3d72b67390..20ccf06d76 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -470,6 +470,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // fs ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService)) + ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService)) // export ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService)) diff --git a/src/main/services/FileSystemService.ts b/src/main/services/FileSystemService.ts index 47e897e15b..2cd0d5aeb6 100644 --- a/src/main/services/FileSystemService.ts +++ b/src/main/services/FileSystemService.ts @@ -1,3 +1,4 @@ +import { readTextFileWithAutoEncoding } from '@main/utils/file' import { TraceMethod } from '@mcp-trace/trace-core' import fs from 'fs/promises' @@ -8,4 +9,15 @@ export default class FileService { if (encoding) return fs.readFile(path, { encoding }) return fs.readFile(path) } + + /** + * 自动识别编码,读取文本文件 + * @param _ event + * @param pathOrUrl + * @throws 路径不存在时抛出错误 + */ + @TraceMethod({ spanName: 'readTextFileWithAutoEncoding', tag: 'FileService' }) + public static async readTextFileWithAutoEncoding(_: Electron.IpcMainInvokeEvent, path: string): Promise { + return readTextFileWithAutoEncoding(path) + } } diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index dc6af193f8..150a28eaca 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -168,6 +168,7 @@ export function getMcpDir() { * 读取文件内容并自动检测编码格式进行解码 * @param filePath - 文件路径 * @returns 解码后的文件内容 + * @throws 如果路径不存在抛出错误 */ export async function readTextFileWithAutoEncoding(filePath: string): Promise { const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8' diff --git a/src/preload/index.ts b/src/preload/index.ts index af4803fd50..49c40d1166 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -136,14 +136,15 @@ const api = { checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) }, file: { - select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), + select: (options?: OpenDialogOptions): Promise => + ipcRenderer.invoke(IpcChannel.File_Select, options), upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), read: (fileId: string, detectEncoding?: boolean) => ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext), - get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath), + get: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_Get, filePath), /** * 创建一个空的临时文件 * @param fileName 文件名 @@ -177,7 +178,8 @@ const api = { isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) }, fs: { - read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding) + read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding), + readText: (pathOrUrl: string): Promise => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl) }, export: { toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName) diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index b138ab5f08..8c2cb4a3bb 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -1,4 +1,4 @@ -import { loggerService } from '@logger' +// import { loggerService } from '@logger' import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer' import { useAppInit } from '@renderer/hooks/useAppInit' import { useShortcuts } from '@renderer/hooks/useShortcuts' @@ -26,7 +26,7 @@ type ElementItem = { element: React.FC | React.ReactNode } -const logger = loggerService.withContext('TopView') +// const logger = loggerService.withContext('TopView') const TopViewContainer: React.FC = ({ children }) => { const [elements, setElements] = useState([]) @@ -80,7 +80,7 @@ const TopViewContainer: React.FC = ({ children }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - logger.debug('keydown', e) + // logger.debug('keydown', e) if (!enableQuitFullScreen) return if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { diff --git a/src/renderer/src/hooks/useDrag.ts b/src/renderer/src/hooks/useDrag.ts new file mode 100644 index 0000000000..803d86048e --- /dev/null +++ b/src/renderer/src/hooks/useDrag.ts @@ -0,0 +1,43 @@ +// import { loggerService } from '@logger' +import { useCallback, useState } from 'react' + +// const logger = loggerService.withContext('useDrag') + +export const useDrag = (onDrop?: (e: React.DragEvent) => Promise | void) => { + const [isDragging, setIsDragging] = useState(false) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + // 确保是离开当前元素,而不是进入子元素 + // logger.debug('drag leave', { currentTarget: e.currentTarget, relatedTarget: e.relatedTarget }) + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return + } + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + await onDrop?.(e) + }, + [onDrop] + ) + + return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } +} diff --git a/src/renderer/src/hooks/useFiles.ts b/src/renderer/src/hooks/useFiles.ts new file mode 100644 index 0000000000..59a9c99cfa --- /dev/null +++ b/src/renderer/src/hooks/useFiles.ts @@ -0,0 +1,97 @@ +import { FileMetadata } from '@renderer/types' +import { filterSupportedFiles } from '@renderer/utils' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type Props = { + /** 支持选择的扩展名 */ + extensions?: string[] +} + +export const useFiles = (props?: Props) => { + const { t } = useTranslation() + + const [files, setFiles] = useState([]) + const [selecting, setSelecting] = useState(false) + + const extensions = useMemo(() => { + if (props?.extensions) { + return props.extensions + } else { + return ['*'] + } + }, [props?.extensions]) + + /** + * 选择文件的回调函数 + * @param multipleSelections - 是否允许多选文件,默认为 true + * @returns 返回选中的文件元数据数组 + * @description + * 1. 打开系统文件选择对话框 + * 2. 根据扩展名过滤文件 + * 3. 更新内部文件状态 + * 4. 当选择了不支持的文件类型时,会显示提示信息 + */ + const onSelectFile = useCallback( + async ({ multipleSelections = true }: { multipleSelections?: boolean }): Promise => { + if (selecting) { + return [] + } + + const selectProps: Electron.OpenDialogOptions['properties'] = multipleSelections + ? ['openFile', 'multiSelections'] + : ['openFile'] + + // when the number of extensions is greater than 20, use *.* to avoid selecting window lag + const useAllFiles = extensions.length > 20 + + setSelecting(true) + const _files = await window.api.file.select({ + properties: selectProps, + filters: [ + { + name: 'Files', + extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', '')) + } + ] + }) + setSelecting(false) + + if (_files) { + if (!useAllFiles) { + setFiles([...files, ..._files]) + return _files + } + const supportedFiles = await filterSupportedFiles(_files, extensions) + if (supportedFiles.length > 0) { + setFiles([...files, ...supportedFiles]) + } + + if (supportedFiles.length !== _files.length) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported_count', { + count: _files.length - supportedFiles.length + }) + }) + } + return supportedFiles + } else { + return [] + } + }, + [extensions, files, selecting, t] + ) + + const clearFiles = useCallback(() => { + setFiles([]) + }, []) + + return { + files, + selecting, + setFiles, + onSelectFile, + clearFiles + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9dbf612fa5..24f8b55e33 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -750,6 +750,9 @@ "enabled": "Enabled", "error": "error", "expand": "Expand", + "file": { + "not_supported": "Unsupported file type {{type}}" + }, "footnote": "Reference content", "footnotes": "References", "fullscreen": "Entered fullscreen mode. Press F11 to exit", @@ -790,6 +793,7 @@ "success": "Success", "swap": "Swap", "topics": "Topics", + "upload_files": "Upload file", "warning": "Warning", "you": "You" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Swap the source and target languages" }, + "files": { + "drag_text": "Drop here", + "error": { + "multiple": "Multiple file uploads are not allowed", + "too_large": "File too large", + "unknown": "Failed to read file content" + }, + "reading": "Reading file content..." + }, "history": { "clear": "Clear History", "clear_description": "Clear history will delete all translation history, continue?", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index f3a819565b..79a56ebdaf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -750,6 +750,9 @@ "enabled": "有効", "error": "エラー", "expand": "展開", + "file": { + "not_supported": "サポートされていないファイルタイプ {{type}}" + }, "footnote": "引用内容", "footnotes": "脚注", "fullscreen": "全画面モードに入りました。F11キーで終了します", @@ -790,6 +793,7 @@ "success": "成功", "swap": "交換", "topics": "トピック", + "upload_files": "ファイルをアップロードする", "warning": "警告", "you": "あなた" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "入力言語と出力言語を入れ替える" }, + "files": { + "drag_text": "ここにドラッグ&ドロップしてください", + "error": { + "multiple": "複数のファイルのアップロードは許可されていません", + "too_large": "ファイルが大きすぎます", + "unknown": "ファイルの内容を読み取るのに失敗しました" + }, + "reading": "ファイルの内容を読み込んでいます..." + }, "history": { "clear": "履歴をクリア", "clear_description": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index e5a7323bcc..5b0c28fa2d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -750,6 +750,9 @@ "enabled": "Включено", "error": "ошибка", "expand": "Развернуть", + "file": { + "not_supported": "Неподдерживаемый тип файла {{type}}" + }, "footnote": "Цитируемый контент", "footnotes": "Сноски", "fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода", @@ -790,6 +793,7 @@ "success": "Успешно", "swap": "Поменять местами", "topics": "Топики", + "upload_files": "Загрузить файл", "warning": "Предупреждение", "you": "Вы" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Поменяйте исходный и целевой языки местами" }, + "files": { + "drag_text": "Перетащите сюда", + "error": { + "multiple": "Не разрешается загружать несколько файлов", + "too_large": "Файл слишком большой", + "unknown": "Ошибка при чтении содержимого файла" + }, + "reading": "Чтение содержимого файла..." + }, "history": { "clear": "Очистить историю", "clear_description": "Очистка истории удалит все записи переводов. Продолжить?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 4ba42ba646..43cf5d3c7a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -750,6 +750,9 @@ "enabled": "已启用", "error": "错误", "expand": "展开", + "file": { + "not_supported": "不支持的文件类型 {{type}}" + }, "footnote": "引用内容", "footnotes": "引用内容", "fullscreen": "已进入全屏模式,按 F11 退出", @@ -790,6 +793,7 @@ "success": "成功", "swap": "交换", "topics": "话题", + "upload_files": "上传文件", "warning": "警告", "you": "用户" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "交换源语言与目标语言" }, + "files": { + "drag_text": "拖放到此处", + "error": { + "multiple": "不允许上传多个文件", + "too_large": "文件过大", + "unknown": "读取文件内容失败" + }, + "reading": "读取文件内容中..." + }, "history": { "clear": "清空历史", "clear_description": "清空历史将删除所有翻译历史记录,是否继续?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6d25a814b0..d500e51b79 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -750,6 +750,9 @@ "enabled": "已啟用", "error": "錯誤", "expand": "展開", + "file": { + "not_supported": "不支持的文件類型 {{type}}" + }, "footnote": "引用內容", "footnotes": "引用", "fullscreen": "已進入全螢幕模式,按 F11 結束", @@ -790,6 +793,7 @@ "success": "成功", "swap": "交換", "topics": "話題", + "upload_files": "上傳檔案", "warning": "警告", "you": "您" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "交換源語言與目標語言" }, + "files": { + "drag_text": "拖放到此处", + "error": { + "multiple": "不允许上传多个文件", + "too_large": "文件過大", + "unknown": "读取文件内容失败" + }, + "reading": "讀取檔案內容中..." + }, "history": { "clear": "清空歷史", "clear_description": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 43bdc945ac..2b3ee33c66 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -750,6 +750,9 @@ "enabled": "Ενεργοποιημένο", "error": "σφάλμα", "expand": "Επεκτάση", + "file": { + "not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}" + }, "footnote": "Παραπομπή", "footnotes": "Παραπομπές", "fullscreen": "Εισήχθη σε πλήρη οθόνη, πατήστε F11 για να έξω", @@ -790,6 +793,7 @@ "success": "Επιτυχία", "swap": "Εναλλαγή", "topics": "Θέματα", + "upload_files": "Ανέβασμα αρχείου", "warning": "Προσοχή", "you": "Εσείς" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Ανταλλαγή γλώσσας πηγής και γλώσσας προορισμού" }, + "files": { + "drag_text": "Σύρετε και αφήστε εδώ", + "error": { + "multiple": "Δεν επιτρέπεται η μεταφόρτωση πολλαπλών αρχείων", + "too_large": "Το αρχείο είναι πολύ μεγάλο", + "unknown": "Αποτυχία ανάγνωσης του περιεχομένου του αρχείου" + }, + "reading": "Διαβάζοντας το περιεχόμενο του αρχείου..." + }, "history": { "clear": "Καθαρισμός ιστορικού", "clear_description": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index e0d86e7a37..9cf86e2eae 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -750,6 +750,9 @@ "enabled": "Activado", "error": "error", "expand": "Expandir", + "file": { + "not_supported": "Tipo de archivo no compatible {{type}}" + }, "footnote": "Nota al pie", "footnotes": "Notas al pie", "fullscreen": "En modo pantalla completa, presione F11 para salir", @@ -790,6 +793,7 @@ "success": "Éxito", "swap": "Intercambiar", "topics": "Temas", + "upload_files": "Subir archivo", "warning": "Advertencia", "you": "Usuario" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Intercambiar el idioma de origen y el idioma de destino" }, + "files": { + "drag_text": "Arrastrar y soltar aquí", + "error": { + "multiple": "No se permite cargar varios archivos", + "too_large": "El archivo es demasiado grande", + "unknown": "Error al leer el contenido del archivo" + }, + "reading": "Leyendo el contenido del archivo..." + }, "history": { "clear": "Borrar historial", "clear_description": "Borrar el historial eliminará todos los registros de traducciones, ¿desea continuar?", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 646e2b28a4..3b4188ba2d 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -750,6 +750,9 @@ "enabled": "Activé", "error": "erreur", "expand": "Développer", + "file": { + "not_supported": "Type de fichier non pris en charge {{type}}" + }, "footnote": "Note de bas de page", "footnotes": "Notes de bas de page", "fullscreen": "Mode plein écran, appuyez sur F11 pour quitter", @@ -790,6 +793,7 @@ "success": "Succès", "swap": "Échanger", "topics": "Sujets", + "upload_files": "Uploader des fichiers", "warning": "Avertissement", "you": "Vous" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Échanger la langue source et la langue cible" }, + "files": { + "drag_text": "Glisser-déposer ici", + "error": { + "multiple": "Impossible de téléverser plusieurs fichiers", + "too_large": "Fichier trop volumineux", + "unknown": "Échec de la lecture du contenu du fichier" + }, + "reading": "Lecture du contenu du fichier en cours..." + }, "history": { "clear": "Effacer l'historique", "clear_description": "L'effacement de l'historique supprimera toutes les entrées d'historique de traduction, voulez-vous continuer ?", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index e1828408f0..0a4d8f7a14 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -750,6 +750,9 @@ "enabled": "Ativado", "error": "错误", "expand": "Expandir", + "file": { + "not_supported": "Tipo de arquivo não suportado {{type}}" + }, "footnote": "Nota de rodapé", "footnotes": "Notas de rodapé", "fullscreen": "Entrou no modo de tela cheia, pressione F11 para sair", @@ -790,6 +793,7 @@ "success": "Sucesso", "swap": "Trocar", "topics": "Tópicos", + "upload_files": "Carregar arquivo", "warning": "Aviso", "you": "Você" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Trocar idioma de origem e idioma de destino" }, + "files": { + "drag_text": "Arraste e solte aqui", + "error": { + "multiple": "Não é permitido fazer upload de vários arquivos", + "too_large": "Arquivo muito grande", + "unknown": "Falha ao ler o conteúdo do arquivo" + }, + "reading": "Lendo o conteúdo do arquivo..." + }, "history": { "clear": "Limpar Histórico", "clear_description": "Limpar histórico irá deletar todos os registros de tradução. Deseja continuar?", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 8135eecf7c..a67382a888 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,4 +1,4 @@ -import { FileMetadata, FileType } from '@renderer/types' +import { FileType } from '@renderer/types' import { filterSupportedFiles } from '@renderer/utils/file' import { Tooltip } from 'antd' import { Paperclip } from 'lucide-react' @@ -39,7 +39,7 @@ const AttachmentButton: FC = ({ const useAllFiles = extensions.length > 20 setSelecting(true) - const _files: FileMetadata[] = await window.api.file.select({ + const _files = await window.api.file.select({ properties: ['openFile', 'multiSelections'], filters: [ { diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index f426616a5d..fcb759daa4 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -1,4 +1,4 @@ -import { SendOutlined, SwapOutlined } from '@ant-design/icons' +import { PlusOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { CopyIcon } from '@renderer/components/Icons' @@ -9,26 +9,38 @@ import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { useDrag } from '@renderer/hooks/useDrag' +import { useFiles } from '@renderer/hooks/useFiles' +import { useOcr } from '@renderer/hooks/useOcr' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import useTranslate from '@renderer/hooks/useTranslate' import { estimateTextTokens } from '@renderer/services/TokenService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setTranslating as setTranslatingAction } from '@renderer/store/runtime' -import { setTranslatedContent as setTranslatedContentAction } from '@renderer/store/translate' -import type { AutoDetectionMethod, Model, TranslateHistory, TranslateLanguage } from '@renderer/types' -import { runAsyncFunction } from '@renderer/utils' +import { setTranslatedContent as setTranslatedContentAction, setTranslateInput } from '@renderer/store/translate' +import { + type AutoDetectionMethod, + FileMetadata, + isSupportedOcrFile, + type Model, + type TranslateHistory, + type TranslateLanguage +} from '@renderer/types' +import { getFileExtension, runAsyncFunction } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' +import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input' import { createInputScrollHandler, createOutputScrollHandler, detectLanguage, determineTargetLanguage } from '@renderer/utils/translate' -import { Button, Flex, Popover, Tooltip, Typography } from 'antd' +import { imageExts, MB, textExts } from '@shared/config/constant' +import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import { isEmpty, throttle } from 'lodash' -import { Check, FolderClock, Settings2 } from 'lucide-react' +import { Check, FolderClock, Settings2, UploadIcon } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -39,7 +51,6 @@ import TranslateSettings from './TranslateSettings' const logger = loggerService.withContext('TranslatePage') // cache variables -let _text = '' let _sourceLanguage: TranslateLanguage | 'auto' = 'auto' let _targetLanguage = LanguagesEnum.enUS @@ -49,9 +60,11 @@ const TranslatePage: FC = () => { const { translateModel, setTranslateModel } = useDefaultModel() const { prompt, getLanguageByLangcode } = useTranslate() const { shikiMarkdownIt } = useCodeStyle() + const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) + const { ocr } = useOcr() // states - const [text, setText] = useState(_text) + // const [text, setText] = useState(_text) const [renderedMarkdown, setRenderedMarkdown] = useState('') const [copied, setCopied] = useTemporaryValue(false, 2000) const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false) @@ -67,8 +80,10 @@ const TranslatePage: FC = () => { const [sourceLanguage, setSourceLanguage] = useState(_sourceLanguage) const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const [autoDetectionMethod, setAutoDetectionMethod] = useState('franc') + const [isProcessing, setIsProcessing] = useState(false) // redux states + const text = useAppSelector((state) => state.translate.translateInput) const translatedContent = useAppSelector((state) => state.translate.translatedContent) const translating = useAppSelector((state) => state.runtime.translating) @@ -80,7 +95,6 @@ const TranslatePage: FC = () => { const dispatch = useAppDispatch() - _text = text _sourceLanguage = sourceLanguage _targetLanguage = targetLanguage @@ -91,6 +105,13 @@ const TranslatePage: FC = () => { } // 控制翻译状态 + const setText = useCallback( + (input: string) => { + dispatch(setTranslateInput(input)) + }, + [dispatch] + ) + const setTranslatedContent = useCallback( (content: string) => { dispatch(setTranslatedContentAction(content)) @@ -414,15 +435,195 @@ const TranslatePage: FC = () => { (sourceLanguage !== 'auto' && sourceLanguage.langCode === UNKNOWN.langCode) || targetLanguage.langCode === UNKNOWN.langCode || (isBidirectional && - (bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) + (bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) || + isProcessing ) - }, [bidirectionalPair, isBidirectional, sourceLanguage, targetLanguage.langCode, text]) + }, [bidirectionalPair, isBidirectional, isProcessing, sourceLanguage, targetLanguage.langCode, text]) // 控制token估计 const tokenCount = useMemo(() => estimateTextTokens(text + prompt), [prompt, text]) + // 统一的文件处理 + const processFile = useCallback( + async (file: FileMetadata) => { + // extensible + const shouldOCR = isSupportedOcrFile(file) + + if (shouldOCR) { + try { + const ocrResult = await ocr(file) + setText(ocrResult.text) + } finally { + // do nothing when failed. + } + } else { + // the threshold may be too large + if (file.size > 5 * MB) { + window.message.error(t('translate.files.error.too_large') + ' (0 ~ 5 MB)') + } else { + window.message.loading({ content: t('translate.files.reading'), key: 'translate_files_reading', duration: 0 }) + try { + const result = await window.api.fs.readText(file.path) + setText(result) + } catch (e) { + logger.error('Failed to read text file.', e as Error) + window.message.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + } finally { + window.message.destroy('translate_files_reading') + } + } + } + }, + [ocr, setText, t] + ) + + // 点击上传文件按钮 + const handleSelectFile = useCallback(async () => { + if (selecting) return + setIsProcessing(true) + try { + const [file] = await onSelectFile({ multipleSelections: false }) + if (!file) { + return + } + + return await processFile(file) + } catch (e) { + logger.error('Unknown error when selecting file.', e as Error) + window.message.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + } finally { + clearFiles() + setIsProcessing(false) + } + }, [clearFiles, onSelectFile, processFile, selecting, t]) + + const getSingleFile = useCallback( + (files: FileMetadata[] | FileList): FileMetadata | File | null => { + if (files.length === 0) return null + if (files.length > 1) { + // 多文件上传时显示提示信息 + window.message.error({ + key: 'multiple_files', + content: t('translate.files.error.multiple') + }) + return null + } + return files[0] + }, + [t] + ) + + // 拖动上传文件 + const onDrop = useCallback( + async (e: React.DragEvent) => { + setIsProcessing(true) + // const supportedFiles = await filterSupportedFiles(_files, extensions) + const data = await getTextFromDropEvent(e).catch((err) => { + logger.error('getTextFromDropEvent', err) + window.message.error({ + key: 'file_error', + content: t('translate.files.error.unknown') + }) + return null + }) + if (data === null) { + return + } + setText(text + data) + + const droppedFiles = await getFilesFromDropEvent(e).catch((err) => { + logger.error('handleDrop:', err) + window.message.error({ + key: 'file_error', + content: t('translate.files.error.unknown') + }) + return null + }) + + if (droppedFiles) { + const file = getSingleFile(droppedFiles) as FileMetadata + if (!file) return + processFile(file) + } + setIsProcessing(false) + }, + [getSingleFile, processFile, setText, t, text] + ) + + const { + isDragging, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop: preventDrop + } = useDrag() + const { + isDragging: isDraggingOnInput, + handleDragEnter: handleDragEnterInput, + handleDragLeave: handleDragLeaveInput, + handleDragOver: handleDragOverInput, + handleDrop + } = useDrag(onDrop) + + // 粘贴上传文件 + const onPaste = useCallback( + async (event: React.ClipboardEvent) => { + setIsProcessing(true) + logger.debug('event', event) + if (event.clipboardData?.files && event.clipboardData.files.length > 0) { + event.preventDefault() + const files = event.clipboardData.files + const file = getSingleFile(files) as File + if (!file) return + try { + // 使用新的API获取文件路径 + const filePath = window.api.file.getPathForFile(file) + let selectedFile: FileMetadata | null + + // 如果没有路径,可能是剪贴板中的图像数据 + if (!filePath) { + if (file.type.startsWith('image/')) { + const tempFilePath = await window.api.file.createTempFile(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + selectedFile = await window.api.file.get(tempFilePath) + } else { + window.message.info({ + key: 'file_not_supported', + content: t('common.file.not_supported', { type: getFileExtension(filePath) }) + }) + return + } + } else { + // 有路径的情况 + selectedFile = await window.api.file.get(filePath) + } + + if (!selectedFile) { + window.message.error({ + key: 'file_error', + content: t('translate.files.error.unknown') + }) + return + } + processFile(selectedFile) + } catch (error) { + logger.error('onPaste:', error as Error) + window.message.error(t('chat.input.file_error')) + } + } + setIsProcessing(false) + }, + [getSingleFile, processFile, t] + ) return ( - + {t('translate.title')} @@ -484,7 +685,27 @@ const TranslatePage: FC = () => { - + + {(isDragging || isDraggingOnInput) && ( + + + {t('translate.files.drag_text')} + + )} + } + tooltip={t('common.upload_files')} + shape="circle" + type="primary" + onClick={handleSelectFile} + />