diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 81f5c15bd9..78bffa6692 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -163,7 +163,7 @@ class FileStorage { fs.mkdirSync(this.storageDir, { recursive: true }) } if (!fs.existsSync(this.notesDir)) { - fs.mkdirSync(this.storageDir, { recursive: true }) + fs.mkdirSync(this.notesDir, { recursive: true }) } if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) diff --git a/src/main/services/mcp/oauth/callback.ts b/src/main/services/mcp/oauth/callback.ts index c13ecd5c07..7da7544585 100644 --- a/src/main/services/mcp/oauth/callback.ts +++ b/src/main/services/mcp/oauth/callback.ts @@ -128,8 +128,8 @@ export class CallBackServer { }) return new Promise((resolve, reject) => { - server.listen(port, () => { - logger.info(`OAuth callback server listening on port ${port}`) + server.listen(port, '127.0.0.1', () => { + logger.info(`OAuth callback server listening on 127.0.0.1:${port}`) resolve(server) }) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 9d55c2aef0..0ad15ea895 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -32,6 +32,7 @@ import { isSupportStreamOptionsProvider, isVertexProvider } from '@renderer/utils/provider' +import { defaultAppHeaders } from '@shared/utils' import { cloneDeep, isEmpty } from 'lodash' import type { AiSdkConfig } from '../types' @@ -197,18 +198,13 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A extraOptions.mode = 'chat' } - // 添加额外headers - if (actualProvider.extra_headers) { - extraOptions.headers = actualProvider.extra_headers - // copy from openaiBaseClient/openaiResponseApiClient - if (aiSdkProviderId === 'openai') { - extraOptions.headers = { - ...extraOptions.headers, - 'HTTP-Referer': 'https://cherry-ai.com', - 'X-Title': 'Cherry Studio', - 'X-Api-Key': baseConfig.apiKey - } - } + extraOptions.headers = { + ...defaultAppHeaders(), + ...actualProvider.extra_headers + } + + if (aiSdkProviderId === 'openai') { + extraOptions.headers['X-Api-Key'] = baseConfig.apiKey } // azure // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest diff --git a/src/renderer/src/components/Avatar/AssistantAvatar.tsx b/src/renderer/src/components/Avatar/AssistantAvatar.tsx new file mode 100644 index 0000000000..7ab87e633e --- /dev/null +++ b/src/renderer/src/components/Avatar/AssistantAvatar.tsx @@ -0,0 +1,34 @@ +import EmojiIcon from '@renderer/components/EmojiIcon' +import { useSettings } from '@renderer/hooks/useSettings' +import { getDefaultModel } from '@renderer/services/AssistantService' +import type { Assistant } from '@renderer/types' +import { getLeadingEmoji } from '@renderer/utils' +import type { FC } from 'react' +import { useMemo } from 'react' + +import ModelAvatar from './ModelAvatar' + +interface AssistantAvatarProps { + assistant: Assistant + size?: number + className?: string +} + +const AssistantAvatar: FC = ({ assistant, size = 24, className }) => { + const { assistantIconType } = useSettings() + const defaultModel = getDefaultModel() + + const assistantName = useMemo(() => assistant.name || '', [assistant.name]) + + if (assistantIconType === 'model') { + return + } + + if (assistantIconType === 'emoji') { + return + } + + return null +} + +export default AssistantAvatar diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 298ffeb86c..f38cdc1def 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -472,6 +472,7 @@ "button": "Import", "error": { "fetch_failed": "Failed to fetch from URL", + "file_required": "Please select a file first", "invalid_format": "Invalid assistant format: missing required fields", "url_required": "Please enter a URL" }, @@ -486,11 +487,14 @@ }, "manage": { "batch_delete": { - "button": "Batch Delete", + "button": "Delete", "confirm": "Are you sure you want to delete the selected {{count}} assistants?" }, + "batch_export": { + "button": "Export" + }, "mode": { - "delete": "Delete", + "manage": "Manage", "sort": "Sort" }, "title": "Manage Assistants" @@ -1027,6 +1031,29 @@ "yuque": "Export to Yuque" }, "list": "Topic List", + "manage": { + "clear_selection": "Clear Selection", + "delete": { + "confirm": { + "content": "Are you sure you want to delete {{count}} selected topic(s)? This action cannot be undone.", + "title": "Delete Topics" + }, + "success": "Deleted {{count}} topic(s)" + }, + "deselect_all": "Deselect All", + "error": { + "at_least_one": "At least one topic must be kept" + }, + "move": { + "button": "Move", + "placeholder": "Select target assistant", + "success": "Moved {{count}} topic(s)" + }, + "pinned": "Pinned Topics", + "selected_count": "{{count}} selected", + "title": "Manage Topics", + "unpinned": "Unpinned Topics" + }, "move_to": "Move to", "new": "New Topic", "pin": "Pin Topic", @@ -1037,6 +1064,10 @@ "label": "Topic Prompts", "tips": "Topic Prompts: Additional supplementary prompts provided for the current topic" }, + "search": { + "placeholder": "Search topics...", + "title": "Search" + }, "title": "Topics", "unpin": "Unpin Topic" }, @@ -1221,11 +1252,13 @@ } }, "stop": "Stop", + "subscribe": "Subscribe", "success": "Success", "swap": "Swap", "topics": "Topics", "unknown": "Unknown", "unnamed": "Unnamed", + "unsubscribe": "Unsubscribe", "update_success": "Update successfully", "upload_files": "Upload file", "warning": "Warning", @@ -1720,7 +1753,7 @@ "import": { "error": "Import failed" }, - "imported": "Imported successfully" + "imported": "Successfully imported {{count}} assistant(s)" }, "api": { "check": { @@ -2152,6 +2185,7 @@ "collapse": "Collapse", "content_placeholder": "Please enter the note content...", "copyContent": "Copy Content", + "crossPlatformRestoreWarning": "Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "delete", "delete_confirm": "Are you sure you want to delete this {{type}}?", "delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8c10f3687b..882b897ef5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -472,6 +472,7 @@ "button": "导入", "error": { "fetch_failed": "从 URL 获取数据失败", + "file_required": "请先选择文件", "invalid_format": "无效的助手格式:缺少必填字段", "url_required": "请输入 URL" }, @@ -486,11 +487,14 @@ }, "manage": { "batch_delete": { - "button": "批量删除", + "button": "删除", "confirm": "确定要删除选中的 {{count}} 个助手吗?" }, + "batch_export": { + "button": "导出" + }, "mode": { - "delete": "删除", + "manage": "管理", "sort": "排序" }, "title": "管理助手" @@ -1027,6 +1031,29 @@ "yuque": "导出到语雀" }, "list": "话题列表", + "manage": { + "clear_selection": "取消选择", + "delete": { + "confirm": { + "content": "确定要删除选中的 {{count}} 个话题吗?此操作不可撤销。", + "title": "删除话题" + }, + "success": "已删除 {{count}} 个话题" + }, + "deselect_all": "取消全选", + "error": { + "at_least_one": "至少需要保留一个话题" + }, + "move": { + "button": "移动", + "placeholder": "选择目标助手", + "success": "已移动 {{count}} 个话题" + }, + "pinned": "已固定的话题", + "selected_count": "已选择 {{count}} 个", + "title": "管理话题", + "unpinned": "未固定的话题" + }, "move_to": "移动到", "new": "开始新对话", "pin": "固定话题", @@ -1037,6 +1064,10 @@ "label": "话题提示词", "tips": "话题提示词:针对当前话题提供额外的补充提示词" }, + "search": { + "placeholder": "搜索话题...", + "title": "搜索" + }, "title": "话题", "unpin": "取消固定" }, @@ -1221,11 +1252,13 @@ } }, "stop": "停止", + "subscribe": "订阅", "success": "成功", "swap": "交换", "topics": "话题", "unknown": "未知", "unnamed": "未命名", + "unsubscribe": "退订", "update_success": "更新成功", "upload_files": "上传文件", "warning": "警告", @@ -1720,7 +1753,7 @@ "import": { "error": "导入失败" }, - "imported": "导入成功" + "imported": "成功导入 {{count}} 个助手" }, "api": { "check": { @@ -2152,6 +2185,7 @@ "collapse": "收起", "content_placeholder": "请输入笔记内容...", "copyContent": "复制内容", + "crossPlatformRestoreWarning": "检测到从其他设备恢复配置,但笔记目录为空。请将笔记文件复制到: {{path}}", "delete": "删除", "delete_confirm": "确定要删除这个{{type}}吗?", "delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 162373b557..3feb287c1d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -472,6 +472,7 @@ "button": "匯入", "error": { "fetch_failed": "從 URL 取得資料失敗", + "file_required": "請先選擇一個檔案", "invalid_format": "無效的助手格式:缺少必填欄位", "url_required": "請輸入 URL" }, @@ -489,8 +490,11 @@ "button": "批次刪除", "confirm": "確定要刪除所選的 {{count}} 個助手嗎?" }, + "batch_export": { + "button": "匯出" + }, "mode": { - "delete": "刪除", + "manage": "管理", "sort": "排序" }, "title": "管理助手" @@ -1027,6 +1031,29 @@ "yuque": "匯出到語雀" }, "list": "話題列表", + "manage": { + "clear_selection": "取消選擇", + "delete": { + "confirm": { + "content": "確定要刪除選中的 {{count}} 個話題嗎?此操作不可撤銷。", + "title": "刪除話題" + }, + "success": "已刪除 {{count}} 個話題" + }, + "deselect_all": "取消全選", + "error": { + "at_least_one": "至少需要保留一個話題" + }, + "move": { + "button": "移動", + "placeholder": "選擇目標助手", + "success": "已移動 {{count}} 個話題" + }, + "pinned": "已固定的話題", + "selected_count": "已選擇 {{count}} 個", + "title": "管理話題", + "unpinned": "未固定的話題" + }, "move_to": "移動到", "new": "開始新對話", "pin": "固定話題", @@ -1037,6 +1064,10 @@ "label": "話題提示詞", "tips": "話題提示詞:針對目前話題提供額外的補充提示詞" }, + "search": { + "placeholder": "搜尋話題...", + "title": "搜尋" + }, "title": "話題", "unpin": "取消固定" }, @@ -1221,11 +1252,13 @@ } }, "stop": "停止", + "subscribe": "訂閱", "success": "成功", "swap": "交換", "topics": "話題", "unknown": "未知", "unnamed": "未命名", + "unsubscribe": "取消訂閱", "update_success": "更新成功", "upload_files": "上傳檔案", "warning": "警告", @@ -2152,6 +2185,7 @@ "collapse": "收合", "content_placeholder": "請輸入筆記內容...", "copyContent": "複製內容", + "crossPlatformRestoreWarning": "偵測到從其他裝置恢復設定,但筆記目錄為空。請將筆記檔案複製到:{{path}}", "delete": "刪除", "delete_confirm": "確定要刪除此 {{type}} 嗎?", "delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 4bc992759c..f535978606 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -472,6 +472,7 @@ "button": "Importieren", "error": { "fetch_failed": "Daten von URL abrufen fehlgeschlagen", + "file_required": "Bitte wählen Sie zuerst eine Datei aus", "invalid_format": "Ungültiges Assistentenformat: Pflichtfelder fehlen", "url_required": "Bitte geben Sie eine URL ein" }, @@ -489,8 +490,11 @@ "button": "Stapel löschen", "confirm": "Sind Sie sicher, dass Sie die ausgewählten {{count}} Assistenten löschen möchten?" }, + "batch_export": { + "button": "Exportieren" + }, "mode": { - "delete": "Löschen", + "manage": "Verwalten", "sort": "Sortieren" }, "title": "Assistenten verwalten" @@ -1027,6 +1031,29 @@ "yuque": "Nach Yuque exportieren" }, "list": "Themenliste", + "manage": { + "clear_selection": "Auswahl aufheben", + "delete": { + "confirm": { + "content": "Sind Sie sicher, dass Sie {{count}} ausgewähltes Thema bzw. ausgewählte Themen löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "title": "Themen löschen" + }, + "success": "{{count}} Thema/Themen gelöscht" + }, + "deselect_all": "Alle abwählen", + "error": { + "at_least_one": "Mindestens ein Thema muss beibehalten werden" + }, + "move": { + "button": "Bewegen", + "placeholder": "Ziel auswählen", + "success": "{{count}} Thema(s) verschoben" + }, + "pinned": "Angepinnte Themen", + "selected_count": "{{count}} ausgewählt", + "title": "Themen verwalten", + "unpinned": "Losgelöste Themen" + }, "move_to": "Verschieben nach", "new": "Neues Gespräch starten", "pin": "Thema anheften", @@ -1037,6 +1064,10 @@ "label": "Themen-Prompt", "tips": "Themen-Prompt: Bietet zusätzliche ergänzende Prompts für das aktuelle Thema" }, + "search": { + "placeholder": "Themen durchsuchen...", + "title": "Suche" + }, "title": "Thema", "unpin": "Anheften aufheben" }, @@ -1221,11 +1252,13 @@ } }, "stop": "Stoppen", + "subscribe": "Abonnieren", "success": "Erfolgreich", "swap": "Tauschen", "topics": "Themen", "unknown": "Unbekannt", "unnamed": "Unbenannt", + "unsubscribe": "Abmelden", "update_success": "Erfolgreich aktualisiert", "upload_files": "Dateien hochladen", "warning": "Warnung", @@ -2152,6 +2185,7 @@ "collapse": "Einklappen", "content_placeholder": "Bitte Notizinhalt eingeben...", "copyContent": "Inhalt kopieren", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "Löschen", "delete_confirm": "Möchten Sie diesen {{type}} wirklich löschen?", "delete_folder_confirm": "Möchten Sie Ordner \"{{name}}\" und alle seine Inhalte wirklich löschen?", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 44fba429f7..99592e9adc 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -472,6 +472,7 @@ "button": "Εισαγωγή", "error": { "fetch_failed": "Αποτυχία λήψης δεδομένων από το URL", + "file_required": "Παρακαλώ επιλέξτε πρώτα ένα αρχείο", "invalid_format": "Μη έγκυρη μορφή βοηθού: λείπουν υποχρεωτικά πεδία", "url_required": "Παρακαλώ εισάγετε ένα URL" }, @@ -489,8 +490,11 @@ "button": "Μαζική Διαγραφή", "confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τους επιλεγμένους {{count}} βοηθούς;" }, + "batch_export": { + "button": "Εξαγωγή" + }, "mode": { - "delete": "Διαγραφή", + "manage": "Διαχειριστείτε", "sort": "Ταξινόμηση" }, "title": "Διαχείριση βοηθών" @@ -1027,6 +1031,29 @@ "yuque": "Εξαγωγή στο Yuque" }, "list": "Λίστα θεμάτων", + "manage": { + "clear_selection": "Καθαρισμός Επιλογής", + "delete": { + "confirm": { + "content": "Είσαι βέβαιος ότι θέλεις να διαγράψεις {{count}} επιλεγμένο(α) θέμα(τα); Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", + "title": "Διαγραφή Θεμάτων" + }, + "success": "Διαγράφηκαν {{count}} θέμα(τα)" + }, + "deselect_all": "Αποεπιλογή όλων", + "error": { + "at_least_one": "Τουλάχιστον ένα θέμα πρέπει να διατηρηθεί" + }, + "move": { + "button": "Μετακίνηση", + "placeholder": "Επιλέξτε στόχο", + "success": "Μετακινήθηκαν {{count}} θέματα" + }, + "pinned": "Καρφιτσωμένα Θέματα", + "selected_count": "{{count}} επιλεγμένα", + "title": "Διαχείριση Θεμάτων", + "unpinned": "Ξεκαρφωμένα Θέματα" + }, "move_to": "Μετακίνηση στο", "new": "Ξεκινήστε νέα συζήτηση", "pin": "Σταθερά θέματα", @@ -1037,6 +1064,10 @@ "label": "Προσδοκώμενα όρια", "tips": "Προσδοκώμενα όρια: προσθέτει επιπλέον επιστημονικές προσθήκες για το παρόν θέμα" }, + "search": { + "placeholder": "Αναζήτηση θεμάτων...", + "title": "Αναζήτηση" + }, "title": "Θέματα", "unpin": "Ξεκαρφίτσωμα" }, @@ -1221,11 +1252,13 @@ } }, "stop": "σταματήστε", + "subscribe": "Εγγραφείτε", "success": "Επιτυχία", "swap": "Εναλλαγή", "topics": "Θέματα", "unknown": "Άγνωστο", "unnamed": "Χωρίς όνομα", + "unsubscribe": "Απεγγραφή", "update_success": "Επιτυχής ενημέρωση", "upload_files": "Ανέβασμα αρχείου", "warning": "Προσοχή", @@ -2152,6 +2185,7 @@ "collapse": "σύμπτυξη", "content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...", "copyContent": "αντιγραφή περιεχομένου", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "διαγραφή", "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};", "delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 5cf620ed45..31c7158587 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -472,6 +472,7 @@ "button": "Importar", "error": { "fetch_failed": "Error al obtener datos desde la URL", + "file_required": "Por favor, selecciona primero un archivo", "invalid_format": "Formato de asistente inválido: faltan campos obligatorios", "url_required": "Por favor introduce una URL" }, @@ -489,8 +490,11 @@ "button": "Eliminación por lotes", "confirm": "¿Estás seguro de que quieres eliminar los {{count}} asistentes seleccionados?" }, + "batch_export": { + "button": "Exportar" + }, "mode": { - "delete": "Eliminar", + "manage": "Gestionar", "sort": "Ordenar" }, "title": "Gestionar asistentes" @@ -1027,6 +1031,29 @@ "yuque": "Exportar a Yuque" }, "list": "Lista de temas", + "manage": { + "clear_selection": "Borrar selección", + "delete": { + "confirm": { + "content": "¿Estás seguro de que quieres eliminar {{count}} tema(s) seleccionado(s)? Esta acción no se puede deshacer.", + "title": "Eliminar Temas" + }, + "success": "Eliminado(s) {{count}} tema(s)" + }, + "deselect_all": "Deseleccionar todo", + "error": { + "at_least_one": "Al menos se debe mantener un tema." + }, + "move": { + "button": "Mover", + "placeholder": "Seleccionar asistente de destino", + "success": "Movido(s) {{count}} tema(s)" + }, + "pinned": "Temas Fijados", + "selected_count": "{{count}} seleccionado", + "title": "Administrar Temas", + "unpinned": "Temas no fijados" + }, "move_to": "Mover a", "new": "Iniciar nueva conversación", "pin": "Fijar tema", @@ -1037,6 +1064,10 @@ "label": "Palabras clave del tema", "tips": "Palabras clave del tema: proporcionar indicaciones adicionales para el tema actual" }, + "search": { + "placeholder": "Buscar temas...", + "title": "Buscar" + }, "title": "Tema", "unpin": "Quitar fijación" }, @@ -1221,11 +1252,13 @@ } }, "stop": "Detener", + "subscribe": "Suscribirse", "success": "Éxito", "swap": "Intercambiar", "topics": "Temas", "unknown": "Desconocido", "unnamed": "Sin nombre", + "unsubscribe": "Cancelar suscripción", "update_success": "Actualización exitosa", "upload_files": "Subir archivo", "warning": "Advertencia", @@ -2152,6 +2185,7 @@ "collapse": "ocultar", "content_placeholder": "Introduzca el contenido de la nota...", "copyContent": "copiar contenido", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "eliminar", "delete_confirm": "¿Estás seguro de que deseas eliminar este {{type}}?", "delete_folder_confirm": "¿Está seguro de que desea eliminar la carpeta \"{{name}}\" y todo su contenido?", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index fdb72727b8..da1d297a7f 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -472,6 +472,7 @@ "button": "Importer", "error": { "fetch_failed": "Échec de la récupération des données depuis l'URL", + "file_required": "Veuillez d'abord sélectionner un fichier", "invalid_format": "Format d'assistant invalide : champs obligatoires manquants", "url_required": "Veuillez saisir une URL" }, @@ -489,8 +490,11 @@ "button": "Suppression par lot", "confirm": "Êtes-vous sûr de vouloir supprimer les {{count}} assistants sélectionnés ?" }, + "batch_export": { + "button": "Exporter" + }, "mode": { - "delete": "Supprimer", + "manage": "Gérer", "sort": "Trier" }, "title": "Gérer les assistants" @@ -1027,6 +1031,29 @@ "yuque": "Exporter vers Yuque" }, "list": "Liste des sujets", + "manage": { + "clear_selection": "Effacer la sélection", + "delete": { + "confirm": { + "content": "Êtes-vous sûr de vouloir supprimer {{count}} sujet(s) sélectionné(s) ? Cette action est irréversible.", + "title": "Supprimer des sujets" + }, + "success": "Supprimé {{count}} sujet(s)" + }, + "deselect_all": "Tout désélectionner", + "error": { + "at_least_one": "Au moins un sujet doit être conservé" + }, + "move": { + "button": "Déplacer", + "placeholder": "Sélectionner la cible", + "success": "Déplacé {{count}} sujet(s)" + }, + "pinned": "Sujets épinglés", + "selected_count": "{{count}} sélectionné", + "title": "Gérer les sujets", + "unpinned": "Sujets non épinglés" + }, "move_to": "Déplacer vers", "new": "Commencer une nouvelle conversation", "pin": "Fixer le sujet", @@ -1037,6 +1064,10 @@ "label": "Indicateurs de sujet", "tips": "Indicateurs de sujet : fournir des indications supplémentaires pour le sujet actuel" }, + "search": { + "placeholder": "Rechercher des sujets...", + "title": "Rechercher" + }, "title": "Sujet", "unpin": "Annuler le fixage" }, @@ -1221,11 +1252,13 @@ } }, "stop": "Arrêter", + "subscribe": "S'abonner", "success": "Succès", "swap": "Échanger", "topics": "Sujets", "unknown": "Inconnu", "unnamed": "Sans nom", + "unsubscribe": "Se désabonner", "update_success": "Mise à jour réussie", "upload_files": "Uploader des fichiers", "warning": "Avertissement", @@ -2152,6 +2185,7 @@ "collapse": "réduire", "content_placeholder": "Veuillez saisir le contenu de la note...", "copyContent": "contenu copié", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "supprimer", "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ?", "delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" et tout son contenu ?", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index d004d539e5..93bacf506c 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -472,6 +472,7 @@ "button": "インポート", "error": { "fetch_failed": "URLからのデータ取得に失敗しました", + "file_required": "まずファイルを選択してください", "invalid_format": "無効なアシスタント形式:必須フィールドが不足しています", "url_required": "URLを入力してください" }, @@ -489,8 +490,11 @@ "button": "バッチ削除", "confirm": "選択した{{count}}件のアシスタントを削除してもよろしいですか?" }, + "batch_export": { + "button": "エクスポート" + }, "mode": { - "delete": "削除", + "manage": "管理", "sort": "並べ替え" }, "title": "アシスタントを管理" @@ -1027,6 +1031,29 @@ "yuque": "語雀にエクスポート" }, "list": "トピックリスト", + "manage": { + "clear_selection": "選択をクリア", + "delete": { + "confirm": { + "content": "{{count}}件の選択したトピックを削除してもよろしいですか?この操作は元に戻せません。", + "title": "トピックを削除" + }, + "success": "{{count}}件のトピックを削除しました" + }, + "deselect_all": "すべての選択を解除", + "error": { + "at_least_one": "少なくとも1つのトピックは保持されなければなりません" + }, + "move": { + "button": "移動", + "placeholder": "対象を選択", + "success": "{{count}}件のトピックを移動しました" + }, + "pinned": "ピン留めされたトピック", + "selected_count": "{{count}} 選択済み", + "title": "トピックを管理", + "unpinned": "ピン留めされていないトピック" + }, "move_to": "移動先", "new": "新しいトピック", "pin": "トピックを固定", @@ -1037,6 +1064,10 @@ "label": "トピック提示語", "tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供" }, + "search": { + "placeholder": "トピックを検索...", + "title": "検索" + }, "title": "トピック", "unpin": "固定解除" }, @@ -1221,11 +1252,13 @@ } }, "stop": "停止", + "subscribe": "購読", "success": "成功", "swap": "交換", "topics": "トピック", "unknown": "Unknown", "unnamed": "無題", + "unsubscribe": "配信停止", "update_success": "更新成功", "upload_files": "ファイルをアップロードする", "warning": "警告", @@ -2152,6 +2185,7 @@ "collapse": "閉じる", "content_placeholder": "メモの内容を入力してください...", "copyContent": "コンテンツをコピーします", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "削除", "delete_confirm": "この{{type}}を本当に削除しますか?", "delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 32c1965f54..9bd6881673 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -472,6 +472,7 @@ "button": "Importar", "error": { "fetch_failed": "Falha ao obter dados do URL", + "file_required": "Por favor, selecione um arquivo primeiro", "invalid_format": "Formato de assistente inválido: campos obrigatórios em falta", "url_required": "Por favor insere um URL" }, @@ -489,8 +490,11 @@ "button": "Exclusão em Lote", "confirm": "Tem certeza de que deseja excluir os {{count}} assistentes selecionados?" }, + "batch_export": { + "button": "Exportar" + }, "mode": { - "delete": "Excluir", + "manage": "Gerenciar", "sort": "Ordenar" }, "title": "Gerir assistentes" @@ -1027,6 +1031,29 @@ "yuque": "Exportar para Yuque" }, "list": "Lista de tópicos", + "manage": { + "clear_selection": "Limpar Seleção", + "delete": { + "confirm": { + "content": "Tem certeza de que deseja excluir {{count}} tópico(s) selecionado(s)? Esta ação não pode ser desfeita.", + "title": "Excluir Tópicos" + }, + "success": "Excluído(s) {{count}} tópico(s)" + }, + "deselect_all": "Desmarcar Todos", + "error": { + "at_least_one": "Pelo menos um tópico deve ser mantido" + }, + "move": { + "button": "Mover", + "placeholder": "Selecionar assistente de destino", + "success": "Movido(s) {{count}} tópico(s)" + }, + "pinned": "Tópicos Fixados", + "selected_count": "{{count}} selecionado", + "title": "Gerenciar Tópicos", + "unpinned": "Tópicos Desafixados" + }, "move_to": "Mover para", "new": "Começar nova conversa", "pin": "Fixar tópico", @@ -1037,6 +1064,10 @@ "label": "Prompt do tópico", "tips": "Prompt do tópico: fornecer prompts adicionais para o tópico atual" }, + "search": { + "placeholder": "Pesquisar tópicos...", + "title": "Pesquisar" + }, "title": "Tópicos", "unpin": "Desfixar" }, @@ -1221,11 +1252,13 @@ } }, "stop": "Parar", + "subscribe": "Subscrever", "success": "Sucesso", "swap": "Trocar", "topics": "Tópicos", "unknown": "Desconhecido", "unnamed": "Sem nome", + "unsubscribe": "Cancelar inscrição", "update_success": "Atualização bem-sucedida", "upload_files": "Carregar arquivo", "warning": "Aviso", @@ -2152,6 +2185,7 @@ "collapse": "[minimizar]", "content_placeholder": "Introduza o conteúdo da nota...", "copyContent": "copiar conteúdo", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "eliminar", "delete_confirm": "Tem a certeza de que deseja eliminar este {{type}}?", "delete_folder_confirm": "Tem a certeza de que deseja eliminar a pasta \"{{name}}\" e todos os seus conteúdos?", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 3d022ae24b..7665115d5c 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -472,6 +472,7 @@ "button": "Импортировать", "error": { "fetch_failed": "Ошибка получения данных с URL", + "file_required": "Сначала выберите файл", "invalid_format": "Неверный формат помощника: отсутствуют обязательные поля", "url_required": "Пожалуйста, введите URL" }, @@ -489,8 +490,11 @@ "button": "Массовое удаление", "confirm": "Вы уверены, что хотите удалить выбранных {{count}} ассистентов?" }, + "batch_export": { + "button": "Экспорт" + }, "mode": { - "delete": "Удалить", + "manage": "Управлять", "sort": "Сортировать" }, "title": "Управление помощниками" @@ -1027,6 +1031,29 @@ "yuque": "Экспорт в Yuque" }, "list": "Список топиков", + "manage": { + "clear_selection": "Очистить выбор", + "delete": { + "confirm": { + "content": "Вы уверены, что хотите удалить выбранные темы ({{count}})? Это действие нельзя отменить.", + "title": "Удалить темы" + }, + "success": "Удалено {{count}} тем(ы)" + }, + "deselect_all": "Снять выделение со всех", + "error": { + "at_least_one": "Должна быть сохранена хотя бы одна тема" + }, + "move": { + "button": "Переместить", + "placeholder": "Выберите цель", + "success": "Перемещено {{count}} тем(ы)" + }, + "pinned": "Закреплённые темы", + "selected_count": "{{count}} выбрано", + "title": "Управление темами", + "unpinned": "Откреплённые темы" + }, "move_to": "Переместить в", "new": "Новый топик", "pin": "Закрепленные темы", @@ -1037,6 +1064,10 @@ "label": "Тематические подсказки", "tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы" }, + "search": { + "placeholder": "Искать темы...", + "title": "Поиск" + }, "title": "Топики", "unpin": "Открепленные темы" }, @@ -1221,11 +1252,13 @@ } }, "stop": "остановить", + "subscribe": "Подписаться", "success": "Успешно", "swap": "Поменять местами", "topics": "Топики", "unknown": "Неизвестно", "unnamed": "Без имени", + "unsubscribe": "Отписаться", "update_success": "Обновление выполнено успешно", "upload_files": "Загрузить файл", "warning": "Предупреждение", @@ -2152,6 +2185,7 @@ "collapse": "Свернуть", "content_placeholder": "Введите содержимое заметки...", "copyContent": "Копировать контент", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "удалить", "delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?", "delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?", diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index 61c1eeeb34..92d5e88dae 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -1,17 +1,15 @@ -import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' -import EmojiIcon from '@renderer/components/EmojiIcon' +import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import PromptPopup from '@renderer/components/Popups/PromptPopup' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { useTags } from '@renderer/hooks/useTags' import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' -import { getDefaultModel } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { useAppDispatch } from '@renderer/store' import { setActiveTopicOrSessionAction } from '@renderer/store/runtime' import type { Assistant, AssistantsSortType } from '@renderer/types' -import { cn, getLeadingEmoji, uuid } from '@renderer/utils' +import { cn, uuid } from '@renderer/utils' import { hasTopicPendingRequests } from '@renderer/utils/queue' import type { MenuProps } from 'antd' import { Dropdown } from 'antd' @@ -67,8 +65,7 @@ const AssistantItem: FC = ({ const { t } = useTranslation() const { allTags } = useTags() const { removeAllTopics } = useAssistant(assistant.id) - const { clickAssistantToShowTopic, topicPosition, assistantIconType, setAssistantIconType } = useSettings() - const defaultModel = getDefaultModel() + const { clickAssistantToShowTopic, topicPosition, setAssistantIconType } = useSettings() const { assistants, updateAssistants } = useAssistants() const [isPending, setIsPending] = useState(false) @@ -166,20 +163,11 @@ const AssistantItem: FC = ({ popupRender={(menu) =>
e.stopPropagation()}>{menu}
}> - {assistantIconType === 'model' ? ( - - ) : ( - assistantIconType === 'emoji' && ( - - ) - )} + {assistantName} {isActive && ( diff --git a/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx new file mode 100644 index 0000000000..08c47311fb --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx @@ -0,0 +1,411 @@ +import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar' +import { modelGenerating } from '@renderer/hooks/useRuntime' +import { TopicManager } from '@renderer/hooks/useTopic' +import type { Assistant, Topic } from '@renderer/types' +import { cn } from '@renderer/utils' +import { Dropdown, Tooltip } from 'antd' +import { CheckSquare, FolderOpen, Search, Square, Trash2, XIcon } from 'lucide-react' +import type { FC, PropsWithChildren, Ref } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export interface TopicManageModeState { + isManageMode: boolean + selectedIds: Set + searchText: string + enterManageMode: () => void + exitManageMode: () => void + toggleSelectTopic: (topicId: string) => void + setSelectedIds: React.Dispatch>> + setSearchText: React.Dispatch> +} + +/** + * Hook for managing topic selection state + */ +export function useTopicManageMode(): TopicManageModeState { + const [isManageMode, setIsManageMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [searchText, setSearchText] = useState('') + + const enterManageMode = useCallback(() => { + setIsManageMode(true) + setSelectedIds(new Set()) + setSearchText('') + }, []) + + const exitManageMode = useCallback(() => { + setIsManageMode(false) + setSelectedIds(new Set()) + setSearchText('') + }, []) + + const toggleSelectTopic = useCallback((topicId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(topicId)) { + next.delete(topicId) + } else { + next.add(topicId) + } + return next + }) + }, []) + + return { + isManageMode, + selectedIds, + searchText, + enterManageMode, + exitManageMode, + toggleSelectTopic, + setSelectedIds, + setSearchText + } +} + +interface TopicManagePanelProps { + assistant: Assistant + assistants: Assistant[] + activeTopic: Topic + setActiveTopic: (topic: Topic) => void + removeTopic: (topic: Topic) => void + moveTopic: (topic: Topic, toAssistant: Assistant) => void + manageState: TopicManageModeState + filteredTopics: Topic[] +} + +/** + * Bottom panel component for topic management mode + */ +export const TopicManagePanel: React.FC = ({ + assistant, + assistants, + activeTopic, + setActiveTopic, + removeTopic, + moveTopic, + manageState, + filteredTopics +}) => { + const { t } = useTranslation() + const { isManageMode, selectedIds, searchText, exitManageMode, setSelectedIds, setSearchText } = manageState + const [isSearchMode, setIsSearchMode] = useState(false) + const searchInputRef = useRef(null) + + // Topics that can be selected (non-pinned, and filtered when in search mode) + const selectableTopics = useMemo(() => { + const baseTopics = isSearchMode ? filteredTopics : assistant.topics + return baseTopics.filter((topic) => !topic.pinned) + }, [assistant.topics, filteredTopics, isSearchMode]) + + // Check if all selectable topics are selected + const isAllSelected = useMemo(() => { + return selectableTopics.length > 0 && selectableTopics.every((topic) => selectedIds.has(topic.id)) + }, [selectableTopics, selectedIds]) + + // Other assistants for move operation + const otherAssistants = useMemo(() => assistants.filter((a) => a.id !== assistant.id), [assistants, assistant.id]) + + // Handle select all / deselect all + const handleSelectAll = useCallback(() => { + if (isAllSelected) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(selectableTopics.map((topic) => topic.id))) + } + }, [isAllSelected, selectableTopics, setSelectedIds]) + + // Handle clear selection + const handleClearSelection = useCallback(() => { + setSelectedIds(new Set()) + }, [setSelectedIds]) + + // Handle delete selected topics + const handleDeleteSelected = useCallback(async () => { + if (selectedIds.size === 0) return + + const remainingTopics = assistant.topics.filter((topic) => !selectedIds.has(topic.id)) + if (remainingTopics.length === 0) { + window.toast.error(t('chat.topics.manage.error.at_least_one')) + return + } + + const confirmed = await window.modal.confirm({ + title: t('chat.topics.manage.delete.confirm.title'), + content: t('chat.topics.manage.delete.confirm.content', { count: selectedIds.size }), + centered: true, + okButtonProps: { danger: true } + }) + + if (!confirmed) return + + await modelGenerating() + + const deletedCount = selectedIds.size + for (const id of selectedIds) { + const topic = assistant.topics.find((t) => t.id === id) + if (topic) { + await TopicManager.removeTopic(id) + removeTopic(topic) + } + } + + // Switch to first remaining topic if current topic was deleted + if (selectedIds.has(activeTopic.id)) { + setActiveTopic(remainingTopics[0]) + } + + window.toast.success(t('chat.topics.manage.delete.success', { count: deletedCount })) + exitManageMode() + }, [selectedIds, assistant.topics, removeTopic, activeTopic.id, setActiveTopic, t, exitManageMode]) + + // Handle move selected topics to another assistant + const handleMoveSelected = useCallback( + async (targetAssistantId: string) => { + if (selectedIds.size === 0) return + + const targetAssistant = assistants.find((a) => a.id === targetAssistantId) + if (!targetAssistant) return + + const remainingTopics = assistant.topics.filter((topic) => !selectedIds.has(topic.id)) + if (remainingTopics.length === 0) { + window.toast.error(t('chat.topics.manage.error.at_least_one')) + return + } + + await modelGenerating() + + const movedCount = selectedIds.size + for (const id of selectedIds) { + const topic = assistant.topics.find((t) => t.id === id) + if (topic) { + moveTopic(topic, targetAssistant) + } + } + + // Switch to first remaining topic if current topic was moved + if (selectedIds.has(activeTopic.id)) { + setActiveTopic(remainingTopics[0]) + } + + window.toast.success(t('chat.topics.manage.move.success', { count: movedCount })) + exitManageMode() + }, + [selectedIds, assistant.topics, assistants, moveTopic, activeTopic.id, setActiveTopic, t, exitManageMode] + ) + + // Enter search mode + const enterSearchMode = useCallback(() => { + setIsSearchMode(true) + }, []) + + // Exit search mode + const exitSearchMode = useCallback(() => { + setIsSearchMode(false) + setSearchText('') + }, [setSearchText]) + + // Focus input when entering search mode + useEffect(() => { + if (isSearchMode && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [isSearchMode]) + + // Reset search mode when exiting manage mode + useEffect(() => { + if (!isManageMode) { + setIsSearchMode(false) + } + }, [isManageMode]) + + // Handle search input keydown + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + exitSearchMode() + } + }, + [exitSearchMode] + ) + + if (!isManageMode) return null + + // Search mode UI + if (isSearchMode) { + return ( + + + + + + {isAllSelected ? : } + + + {selectedIds.size > 0 && ( + + {selectedIds.size} + + )} + + + setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> + + + + + + + + + ) + } + + // Normal manage mode UI + return ( + + + + + + {isAllSelected ? : } + + + {selectedIds.size > 0 && ( + + {selectedIds.size} + + )} + + + + + + + + {otherAssistants.length > 0 && ( + ({ + key: a.id, + label: a.name, + icon: , + onClick: () => handleMoveSelected(a.id), + disabled: selectedIds.size === 0 + })) + }} + trigger={['click']} + disabled={selectedIds.size === 0}> + + + + + + + )} + + + + + + + + + + + + + + + ) +} + +// Tailwind components +const ManagePanel: FC = ({ children }) => ( +
+ {children} +
+) + +const ManagePanelContent: FC = ({ children }) => ( +
{children}
+) + +interface ManageIconButtonProps extends React.ButtonHTMLAttributes { + danger?: boolean +} + +const ManageIconButton: FC> = ({ + children, + className, + danger, + disabled, + ...props +}) => ( + +) + +const ManageDivider: FC = () =>
+ +const LeftGroup: FC = ({ children }) =>
{children}
+ +const RightGroup: FC = ({ children }) => ( +
{children}
+) + +const SelectedBadge: FC>> = ({ + children, + className, + ...props +}) => ( + + {children} + +) + +const SearchInputWrapper: FC = ({ children }) => ( +
{children}
+) + +interface SearchInputProps extends React.InputHTMLAttributes { + ref?: Ref +} + +const SearchInput: FC = ({ className, ref, ...props }) => ( + +) + +export default TopicManagePanel diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 7284f9167c..29232e65d9 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -1,3 +1,4 @@ +import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar' import { DraggableVirtualList } from '@renderer/components/DraggableList' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' @@ -37,8 +38,10 @@ import dayjs from 'dayjs' import { findIndex } from 'lodash' import { BrushCleaning, + CheckSquare, FolderOpen, HelpCircle, + ListChecks, MenuIcon, NotebookPen, PackagePlus, @@ -46,6 +49,7 @@ import { PinOffIcon, Save, Sparkles, + Square, UploadIcon, XIcon } from 'lucide-react' @@ -55,6 +59,7 @@ import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' import AddButton from './AddButton' +import { TopicManagePanel, useTopicManageMode } from './TopicManageMode' interface Props { assistant: Assistant @@ -81,6 +86,10 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se const deleteTimerRef = useRef(null) const [editingTopicId, setEditingTopicId] = useState(null) + // 管理模式状态 + const manageState = useTopicManageMode() + const { isManageMode, selectedIds, searchText, enterManageMode, exitManageMode, toggleSelectTopic } = manageState + const { startEdit, isEditing, inputProps } = useInPlaceEdit({ onSave: (name: string) => { const topic = assistant.topics.find((t) => t.id === editingTopicId) @@ -437,6 +446,7 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se .map((a) => ({ label: a.name, key: a.id, + icon: , onClick: () => onMoveTopic(topic, a) })) }) @@ -492,107 +502,187 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se return assistant.topics }, [assistant.topics, pinTopicsToTop]) + // Filter topics based on search text (only in manage mode) + // Supports: case-insensitive, space-separated keywords (all must match) + const deferredSearchText = useDeferredValue(searchText) + const filteredTopics = useMemo(() => { + if (!isManageMode || !deferredSearchText.trim()) { + return sortedTopics + } + // Split by spaces and filter out empty strings + const keywords = deferredSearchText + .toLowerCase() + .split(/\s+/) + .filter((k) => k.length > 0) + if (keywords.length === 0) { + return sortedTopics + } + // All keywords must match (AND logic) + return sortedTopics.filter((topic) => { + const lowerName = topic.name.toLowerCase() + return keywords.every((keyword) => lowerName.includes(keyword)) + }) + }, [sortedTopics, deferredSearchText, isManageMode]) + const singlealone = topicPosition === 'right' && position === 'right' return ( - - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> - {t('chat.add.topic.title')} - -
- - }> - {(topic) => { - const isActive = topic.id === activeTopic?.id - const topicName = topic.name.replace('`', '') - const topicPrompt = topic.prompt - const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt - - const getTopicNameClassName = () => { - if (isRenaming(topic.id)) return 'shimmer' - if (isNewlyRenamed(topic.id)) return 'typing' - return '' + <> + + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> + {t('chat.add.topic.title')} + + + + + + + } + disabled={isManageMode}> + {(topic) => { + const isActive = topic.id === activeTopic?.id + const topicName = topic.name.replace('`', '') + const topicPrompt = topic.prompt + const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt + const isSelected = selectedIds.has(topic.id) + const canSelect = !topic.pinned - return ( - - setTargetTopic(topic)} - className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} - onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)} - style={{ - borderRadius, - cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer' - }}> - {isPending(topic.id) && !isActive && } - {isFulfilled(topic.id) && !isActive && } - - {editingTopicId === topic.id && isEditing ? ( - e.stopPropagation()} /> - ) : ( - { - setEditingTopicId(topic.id) - startEdit(topic.name) - }}> - {topicName} - + const getTopicNameClassName = () => { + if (isRenaming(topic.id)) return 'shimmer' + if (isNewlyRenamed(topic.id)) return 'typing' + return '' + } + + const handleItemClick = () => { + if (isManageMode) { + if (canSelect) { + toggleSelectTopic(topic.id) + } + } else { + onSwitchTopic(topic) + } + } + + return ( + + setTargetTopic(topic)} + className={classNames( + isActive && !isManageMode ? 'active' : '', + singlealone ? 'singlealone' : '', + isManageMode && isSelected ? 'selected' : '', + isManageMode && !canSelect ? 'disabled' : '' )} - {!topic.pinned && ( - - {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
- }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } - }}> - {deletingTopicId === topic.id ? ( - + onClick={editingTopicId === topic.id && isEditing ? undefined : handleItemClick} + style={{ + borderRadius, + cursor: + editingTopicId === topic.id && isEditing + ? 'default' + : isManageMode && !canSelect + ? 'not-allowed' + : 'pointer' + }}> + {isPending(topic.id) && !isActive && } + {isFulfilled(topic.id) && !isActive && } + + {isManageMode && ( + + {isSelected ? ( + ) : ( - + )} + + )} + {editingTopicId === topic.id && isEditing ? ( + e.stopPropagation()} /> + ) : ( + { + setEditingTopicId(topic.id) + startEdit(topic.name) + } + }> + {topicName} + + )} + {!topic.pinned && ( + + {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} + + }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} + + + )} + {topic.pinned && ( + + - + )} + + {topicPrompt && ( + + {fullTopicPrompt} + )} - {topic.pinned && ( - - - + {showTopicTime && ( + {dayjs(topic.createdAt).format('MM/DD HH:mm')} )} - - {topicPrompt && ( - - {fullTopicPrompt} - - )} - {showTopicTime && {dayjs(topic.createdAt).format('MM/DD HH:mm')}} - - - ) - }} - + + + ) + }} + + + {/* 管理模式底部面板 */} + + ) } @@ -640,6 +730,15 @@ const TopicListItem = styled.div` box-shadow: none; } } + + &.selected { + background-color: var(--color-primary-bg); + box-shadow: inset 0 0 0 1px var(--color-primary); + } + + &.disabled { + opacity: 0.5; + } ` const TopicNameContainer = styled.div` @@ -648,7 +747,6 @@ const TopicNameContainer = styled.div` align-items: center; gap: 4px; height: 20px; - justify-content: space-between; ` const TopicName = styled.div` @@ -659,6 +757,8 @@ const TopicName = styled.div` font-size: 13px; position: relative; will-change: background-position, width; + flex: 1; + text-align: left; --color-shimmer-mid: var(--color-text-1); --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); @@ -765,3 +865,49 @@ const MenuButton = styled.div` font-size: 12px; } ` + +const HeaderRow = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding-right: 10px; + margin-bottom: 5px; +` + +const HeaderIconButton = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + border-radius: var(--list-item-border-radius); + cursor: pointer; + color: var(--color-text-2); + transition: all 0.2s; + + &:hover { + background-color: var(--color-background-mute); + color: var(--color-text-1); + } + + &.active { + color: var(--color-primary); + + &:hover { + background-color: var(--color-background-mute); + } + } +` + +const SelectIcon = styled.div` + display: flex; + align-items: center; + margin-right: 4px; + + &.disabled { + opacity: 0.5; + } +` diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 6317ff3bca..4504550a16 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -145,6 +145,7 @@ const Container = styled.div` width: var(--assistants-width); transition: width 0.3s; height: calc(100vh - var(--navbar-height)); + position: relative; &.right { height: calc(100vh - var(--navbar-height)); diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 7692aa9975..22d4ba4459 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -39,6 +39,7 @@ import { } from '@renderer/store/note' import type { NotesSortType, NotesTreeNode } from '@renderer/types/note' import type { FileChangeEvent } from '@shared/config/types' +import { message } from 'antd' import { debounce } from 'lodash' import { AnimatePresence, motion } from 'motion/react' import type { FC } from 'react' @@ -246,6 +247,43 @@ const NotesPage: FC = () => { updateNotesPath(defaultPath) return } + + // 验证路径是否有效(处理跨平台恢复场景) + try { + // 获取当前平台的默认路径 + const info = await window.api.getAppInfo() + const defaultPath = info.notesPath + + // 如果当前路径就是默认路径,跳过验证(默认路径始终有效) + if (notesPath === defaultPath) { + return + } + + const isValid = await window.api.file.validateNotesDirectory(notesPath) + if (!isValid) { + logger.warn('Invalid notes path detected, resetting to default', { path: notesPath }) + + // 重置为默认路径 + updateNotesPath(defaultPath) + + // 检查默认路径下是否有笔记文件 + try { + const tree = await window.api.file.getDirectoryStructure(defaultPath) + if (!tree || tree.length === 0) { + // 默认目录为空,提示用户需要迁移文件 + message.warning({ + content: t('notes.crossPlatformRestoreWarning', { path: defaultPath }), + duration: 10 + }) + } + } catch (error) { + // 目录不存在或读取失败,会由 FileStorage 自动创建 + logger.debug('Default notes directory will be created', { error }) + } + } + } catch (error) { + logger.error('Failed to validate notes path:', error as Error) + } } initialize() diff --git a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx index 2b1bed5ebe..65be642adc 100644 --- a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx @@ -2,13 +2,14 @@ import { CloseCircleFilled, QuestionCircleOutlined } from '@ant-design/icons' import EmojiPicker from '@renderer/components/EmojiPicker' import { ResetIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' +import Selector from '@renderer/components/Selector' import { TopView } from '@renderer/components/TopView' import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultAssistant } from '@renderer/hooks/useAssistant' import type { AssistantSettings as AssistantSettingsType } from '@renderer/types' import { getLeadingEmoji, modalConfirm } from '@renderer/utils' -import { Button, Col, Flex, Input, InputNumber, Modal, Popover, Row, Slider, Switch, Tooltip } from 'antd' +import { Button, Col, Divider, Flex, Input, InputNumber, Modal, Popover, Row, Slider, Switch, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' import type { Dispatch, FC, SetStateAction } from 'react' import { useState } from 'react' @@ -26,6 +27,9 @@ const AssistantSettings: FC = () => { const [maxTokens, setMaxTokens] = useState(defaultAssistant?.settings?.maxTokens ?? 0) const [topP, setTopP] = useState(defaultAssistant.settings?.topP ?? 1) const [enableTopP, setEnableTopP] = useState(defaultAssistant.settings?.enableTopP ?? false) + const [toolUseMode, setToolUseMode] = useState( + defaultAssistant.settings?.toolUseMode ?? 'function' + ) const [emoji, setEmoji] = useState(defaultAssistant.emoji || getLeadingEmoji(defaultAssistant.name) || '') const [name, setName] = useState( defaultAssistant.name.replace(getLeadingEmoji(defaultAssistant.name) || '', '').trim() @@ -46,7 +50,8 @@ const AssistantSettings: FC = () => { maxTokens: settings.maxTokens ?? maxTokens, streamOutput: settings.streamOutput ?? true, topP: settings.topP ?? topP, - enableTopP: settings.enableTopP ?? enableTopP + enableTopP: settings.enableTopP ?? enableTopP, + toolUseMode: settings.toolUseMode ?? toolUseMode } }) } @@ -73,6 +78,7 @@ const AssistantSettings: FC = () => { setMaxTokens(0) setTopP(1) setEnableTopP(false) + setToolUseMode('function') updateDefaultAssistant({ ...defaultAssistant, settings: { @@ -84,7 +90,8 @@ const AssistantSettings: FC = () => { maxTokens: DEFAULT_MAX_TOKENS, streamOutput: true, topP: 1, - enableTopP: false + enableTopP: false, + toolUseMode: 'function' } }) } @@ -107,10 +114,9 @@ const AssistantSettings: FC = () => { return ( - {t('common.name')} - + } arrow trigger="click"> @@ -161,6 +167,7 @@ const AssistantSettings: FC = () => { - diff --git a/src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx b/src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx deleted file mode 100755 index 8ea3b92fde..0000000000 --- a/src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { HStack } from '@renderer/components/Layout' -import { useTheme } from '@renderer/context/ThemeProvider' -import { useSettings } from '@renderer/hooks/useSettings' -import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '@renderer/pages/settings' -import { useAppDispatch } from '@renderer/store' -import { setAgentssubscribeUrl } from '@renderer/store/settings' -import Input from 'antd/es/input/Input' -import { HelpCircle } from 'lucide-react' -import type { FC } from 'react' -import { useTranslation } from 'react-i18next' - -const AssistantsSubscribeUrlSettings: FC = () => { - const { t } = useTranslation() - const { theme } = useTheme() - const dispatch = useAppDispatch() - - const { agentssubscribeUrl } = useSettings() - - const handleAgentChange = (e: React.ChangeEvent) => { - dispatch(setAgentssubscribeUrl(e.target.value)) - } - - const handleHelpClick = () => { - window.open('https://docs.cherry-ai.com/data-settings/assistants-subscribe', '_blank') - } - - return ( - - - - {t('assistants.presets.tag.agent')} - {t('settings.tool.websearch.subscribe_add')} - - - - - - {t('settings.tool.websearch.subscribe_url')} - - - - - - ) -} - -export default AssistantsSubscribeUrlSettings diff --git a/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx b/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx index 9a76d1d02e..37b7938248 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx @@ -1,11 +1,15 @@ import { TopView } from '@renderer/components/TopView' import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' +import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import { getDefaultModel } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { useAppDispatch } from '@renderer/store' +import { setAgentssubscribeUrl } from '@renderer/store/settings' import type { AssistantPreset } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Button, Flex, Form, Input, Modal, Radio } from 'antd' +import { Button, Divider, Flex, Form, Input, Modal, Radio, Typography } from 'antd' +import { HelpCircle } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,35 +24,53 @@ const PopupContainer: React.FC = ({ resolve }) => { const { addAssistantPreset } = useAssistantPresets() const [importType, setImportType] = useState<'url' | 'file'>('url') const [loading, setLoading] = useState(false) + const [subscribeLoading, setSubscribeLoading] = useState(false) const { setTimeoutTimer } = useTimer() + const dispatch = useAppDispatch() + const { agentssubscribeUrl } = useSettings() + const [subscribeUrl, setSubscribeUrl] = useState(agentssubscribeUrl || '') + const [selectedFile, setSelectedFile] = useState<{ name: string; content: Uint8Array } | null>(null) + const [urlValue, setUrlValue] = useState('') + + const isImportDisabled = importType === 'url' ? !urlValue.trim() : !selectedFile + const isSubscribed = !!agentssubscribeUrl + + const handleSelectFile = async () => { + const result = await window.api.file.open({ + filters: [{ name: t('assistants.presets.import.file_filter'), extensions: ['json'] }] + }) + + if (result) { + setSelectedFile({ name: result.fileName, content: result.content }) + } + } + + const onFinish = async () => { + // Validate before setting loading + if (importType === 'url' && !urlValue.trim()) { + window.toast.error(t('assistants.presets.import.error.url_required')) + return + } + if (importType === 'file' && !selectedFile) { + window.toast.error(t('assistants.presets.import.error.file_required')) + return + } - const onFinish = async (values: { url?: string }) => { setLoading(true) try { let presets: AssistantPreset[] = [] if (importType === 'url') { - if (!values.url) { - throw new Error(t('assistants.presets.import.error.url_required')) - } - const response = await fetch(values.url) + const response = await fetch(urlValue.trim()) if (!response.ok) { throw new Error(t('assistants.presets.import.error.fetch_failed')) } const data = await response.json() presets = Array.isArray(data) ? data : [data] } else { - const result = await window.api.file.open({ - filters: [{ name: t('assistants.presets.import.file_filter'), extensions: ['json'] }] - }) - - if (result) { - presets = JSON.parse(new TextDecoder('utf-8').decode(result.content)) - if (!Array.isArray(presets)) { - presets = [presets] - } - } else { - return + presets = JSON.parse(new TextDecoder('utf-8').decode(selectedFile!.content)) + if (!Array.isArray(presets)) { + presets = [presets] } } @@ -74,7 +96,7 @@ const PopupContainer: React.FC = ({ resolve }) => { addAssistantPreset(newPreset) } - window.toast.success(t('message.agents.imported')) + window.toast.success(t('message.agents.imported', { count: presets.length })) setTimeoutTimer('onFinish', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) setOpen(false) @@ -88,7 +110,42 @@ const PopupContainer: React.FC = ({ resolve }) => { const onCancel = () => { setOpen(false) - resolve(null) + } + + const handleSubscribeUrlChange = (e: React.ChangeEvent) => { + setSubscribeUrl(e.target.value) + } + + const handleSubscribe = async () => { + // If already subscribed, unsubscribe + if (isSubscribed) { + dispatch(setAgentssubscribeUrl('')) + setSubscribeUrl('') + window.location.reload() + return + } + + if (!subscribeUrl.trim()) { + return + } + + setSubscribeLoading(true) + try { + const response = await fetch(subscribeUrl) + if (!response.ok) { + throw new Error(t('assistants.presets.import.error.fetch_failed')) + } + dispatch(setAgentssubscribeUrl(subscribeUrl)) + window.location.reload() + } catch (error) { + window.toast.error(error instanceof Error ? error.message : t('message.agents.import.error')) + } finally { + setSubscribeLoading(false) + } + } + + const handleHelpClick = () => { + window.open('https://docs.cherry-ai.com/data-settings/assistants-subscribe', '_blank') } return ( @@ -96,39 +153,79 @@ const PopupContainer: React.FC = ({ resolve }) => { title={t('assistants.presets.import.title')} open={open} onCancel={onCancel} - maskClosable={false} - footer={ - - - - - } + afterClose={() => resolve(null)} + footer={null} transitionName="animation-move-down" + styles={{ body: { padding: '16px' } }} centered>
- - setImportType(e.target.value)}> - {t('assistants.presets.import.type.url')} - {t('assistants.presets.import.type.file')} - + + + setImportType(e.target.value)}> + {t('assistants.presets.import.type.url')} + {t('assistants.presets.import.type.file')} + + + {importType === 'url' && ( + + setUrlValue(e.target.value)} + /> + + )} + + {importType === 'file' && ( + <> + + {selectedFile && ( + + {selectedFile.name} + + )} +
+ + )} + + + - - {importType === 'url' && ( - - - - )} - - {importType === 'file' && ( - - - - )} + + + + + + {t('assistants.presets.tag.agent')} + {t('settings.tool.websearch.subscribe_add')} + + + + + + + + ) } diff --git a/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx b/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx index b75a569c5f..961ec24abe 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx @@ -1,4 +1,4 @@ -import { MenuOutlined } from '@ant-design/icons' +import { ExportOutlined, MenuOutlined } from '@ant-design/icons' import { DraggableList } from '@renderer/components/DraggableList' import { DeleteIcon } from '@renderer/components/Icons' import { Box, HStack } from '@renderer/components/Layout' @@ -10,13 +10,13 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -type Mode = 'sort' | 'delete' +type Mode = 'sort' | 'manage' const PopupContainer: React.FC = () => { const [open, setOpen] = useState(true) const { t } = useTranslation() const { presets, setAssistantPresets } = useAssistantPresets() - const [mode, setMode] = useState(() => (presets.length > 50 ? 'delete' : 'sort')) + const [mode, setMode] = useState('manage') const [selectedIds, setSelectedIds] = useState>(new Set()) const onCancel = () => { @@ -88,6 +88,23 @@ const PopupContainer: React.FC = () => { }) } + const handleBatchExport = async () => { + if (selectedIds.size === 0) return + + const selectedPresets = presets.filter((p) => selectedIds.has(p.id)) + const exportData = selectedPresets.map((p) => ({ + name: p.name, + emoji: p.emoji, + prompt: p.prompt, + description: p.description, + group: p.group + })) + + const fileName = selectedIds.size === 1 ? `${selectedPresets[0].name}.json` : `assistants_${selectedIds.size}.json` + + await window.api.file.save(fileName, JSON.stringify(exportData, null, 2)) + } + const isAllSelected = presets.length > 0 && selectedIds.size === presets.length const isIndeterminate = selectedIds.size > 0 && selectedIds.size < presets.length @@ -98,13 +115,14 @@ const PopupContainer: React.FC = () => { onCancel={onCancel} afterClose={onClose} footer={null} + width={600} transitionName="animation-move-down" centered> {presets.length > 0 && ( <> - {mode === 'delete' ? ( + {mode === 'manage' ? ( {t('common.select_all')} @@ -119,15 +137,24 @@ const PopupContainer: React.FC = () => {
)} - {mode === 'delete' && ( - + {mode === 'manage' && ( + <> + + + )} { onChange={(value) => handleModeChange(value as Mode)} options={[ { label: t('assistants.presets.manage.mode.sort'), value: 'sort' }, - { label: t('assistants.presets.manage.mode.delete'), value: 'delete' } + { label: t('assistants.presets.manage.mode.manage'), value: 'manage' } ]} /> diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 7eb762afd8..b29b4d1086 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 184, + version: 186, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 0a1f8ea70d..03510133e1 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2913,31 +2913,6 @@ const migrateConfig = { return state } }, - '180': (state: RootState) => { - try { - if (state.settings.apiServer) { - state.settings.apiServer.host = API_SERVER_DEFAULTS.HOST - } - // @ts-expect-error - if (state.settings.openAI.summaryText === 'undefined') { - state.settings.openAI.summaryText = undefined - } - // @ts-expect-error - if (state.settings.openAI.verbosity === 'undefined') { - state.settings.openAI.verbosity = undefined - } - state.llm.providers.forEach((provider) => { - if (provider.id === SystemProviderIds.ollama) { - provider.type = 'ollama' - } - }) - logger.info('migrate 180 success') - return state - } catch (error) { - logger.error('migrate 180 error', error as Error) - return state - } - }, '181': (state: RootState) => { try { state.llm.providers.forEach((provider) => { @@ -3017,6 +2992,52 @@ const migrateConfig = { logger.error('migrate 184 error', error as Error) return state } + }, + '185': (state: RootState) => { + try { + // Reset toolUseMode to function for default assistant + if (state.assistants.defaultAssistant.settings?.toolUseMode) { + state.assistants.defaultAssistant.settings.toolUseMode = 'function' + } + // Reset toolUseMode to function for assistants + state.assistants.assistants.forEach((assistant) => { + if (assistant.settings?.toolUseMode === 'prompt') { + if (assistant.model && isFunctionCallingModel(assistant.model)) { + assistant.settings.toolUseMode = 'function' + } + } + }) + logger.info('migrate 185 success') + return state + } catch (error) { + logger.error('migrate 185 error', error as Error) + return state + } + }, + '186': (state: RootState) => { + try { + if (state.settings.apiServer) { + state.settings.apiServer.host = API_SERVER_DEFAULTS.HOST + } + // @ts-expect-error + if (state.settings.openAI.summaryText === 'undefined') { + state.settings.openAI.summaryText = undefined + } + // @ts-expect-error + if (state.settings.openAI.verbosity === 'undefined') { + state.settings.openAI.verbosity = undefined + } + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.ollama) { + provider.type = 'ollama' + } + }) + logger.info('migrate 186 success') + return state + } catch (error) { + logger.error('migrate 186 error', error as Error) + return state + } } }