🔨 chore: pre-merge desktop implement (#7516)

* update locale

* update code
This commit is contained in:
Arvin Xu
2025-04-23 00:54:22 +08:00
committed by GitHub
parent cc5525aab9
commit 5cdf0e2146
65 changed files with 3461 additions and 70 deletions

View File

@@ -0,0 +1,80 @@
---
description:
globs:
alwaysApply: false
---
**新增桌面端工具流程:**
1. **定义工具接口 (Manifest):**
* **文件:** `src/tools/[tool_category]/index.ts` (例如: `src/tools/local-files/index.ts`)
* **操作:**
* 在 `ApiName` 对象(例如 `LocalFilesApiName`)中添加一个新的、唯一的 API 名称。
* 在 `Manifest` 对象(例如 `LocalFilesManifest`)的 `api` 数组中,新增一个对象来定义新工具的接口。
* **关键字段:**
* `name`: 使用上一步定义的 API 名称。
* `description`: 清晰描述工具的功能,供 Agent 理解和向用户展示。
* `parameters`: 使用 JSON Schema 定义工具所需的输入参数。
* `type`: 通常是 'object'。
* `properties`: 定义每个参数的名称、`description`、`type` (string, number, boolean, array, etc.),使用英文。
* `required`: 一个字符串数组,列出必须提供的参数名称。
2. **定义相关类型:**
* **文件 1:** `packages/electron-client-ipc/src/types.ts` (或类似的共享 IPC 类型文件)
* **操作:** 定义传递给 IPC 事件的参数类型接口 (例如: `RenameLocalFileParams`, `MoveLocalFileParams`)。确保与 Manifest 中定义的 `parameters` 一致。
* **文件 2:** `src/tools/[tool_category]/type.ts` (例如: `src/tools/local-files/type.ts`)
* **操作:** 定义此工具执行后,存储在前端 Zustand Store 中的状态类型接口 (例如: `LocalRenameFileState`, `LocalMoveFileState`)。这通常包含操作结果(成功/失败)、错误信息以及相关数据(如旧路径、新路径等)。
3. **实现前端状态管理 (Store Action):**
* **文件:** `src/store/chat/slices/builtinTool/actions/[tool_category].ts` (例如: `src/store/chat/slices/builtinTool/actions/localFile.ts`)
* **操作:**
* 导入在步骤 2 中定义的 IPC 参数类型和状态类型。
* 在 Action 接口 (例如: `LocalFileAction`) 中添加新 Action 的方法签名,使用对应的 IPC 参数类型。
* 在 `createSlice` (例如: `localFileSlice`) 中实现该 Action 方法:
* 接收 `id` (消息 ID) 和 `params` (符合 IPC 参数类型)。
* 设置加载状态 (`toggleLocalFileLoading(id, true)`)。
* 调用对应的 `Service` 层方法 (见步骤 4),传递 `params`。
* 使用 `try...catch` 处理 `Service` 调用可能发生的错误。
* **成功时:**
* 调用 `updatePluginState(id, {...})` 更新插件状态,使用步骤 2 中定义的状态类型。
* 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,通常包含成功确认信息。
* **失败时:**
* 记录错误 (`console.error`)。
* 调用 `updatePluginState(id, {...})` 更新插件状态,包含错误信息。
* 调用 `internal_updateMessagePluginError(id, {...})` 设置消息的错误状态。
* 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,包含错误信息。
* 在 `finally` 块中取消加载状态 (`toggleLocalFileLoading(id, false)`)。
* 返回操作是否成功 (`boolean`)。
4. **实现 Service 层 (调用 IPC):**
* **文件:** `src/services/electron/[tool_category]Service.ts` (例如: `src/services/electron/localFileService.ts`)
* **操作:**
* 导入在步骤 2 中定义的 IPC 参数类型。
* 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
* 方法接收 `params` (符合 IPC 参数类型)。
* 使用从 `@lobechat/electron-client-ipc` 导入的 `dispatch` (或 `invoke`) 函数,调用与 Manifest 中 `name` 字段匹配的 IPC 事件名称,并将 `params` 传递过去。
* 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
5. **实现后端逻辑 (Controller / IPC Handler):**
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
* **操作:**
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ipcClientEvent`, 参数类型等)。
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
* 使用 `@ipcClientEvent('yourApiName')` 装饰器将此方法注册为对应 IPC 事件的处理器,确保 `'yourApiName'` 与 Manifest 中的 `name` 和 Service 层调用的事件名称一致。
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
* 实现核心业务逻辑:
* 进行必要的输入验证。
* 执行文件系统操作或其他后端任务 (例如: `fs.promises.rename`)。
* 使用 `try...catch` 捕获执行过程中的错误。
* 处理特定错误码 (`error.code`) 以提供更友好的错误消息。
* 返回一个包含 `success` (boolean) 和可选 `error` (string) 字段的对象。
6. **更新 Agent 文档 (System Role):**
* **文件:** `src/tools/[tool_category]/systemRole.ts` (例如: `src/tools/local-files/systemRole.ts`)
* **操作:**
* 在 `<core_capabilities>` 部分添加新工具的简要描述。
* 如果需要,更新 `<workflow>`。
* 在 `<tool_usage_guidelines>` 部分为新工具添加详细的使用说明,解释其参数、用途和预期行为。
* 如有必要,更新 `<security_considerations>`。
* 如有必要(例如工具返回了新的数据结构或路径),更新 `<response_format>` 中的示例。
通过遵循这些步骤,可以系统地将新的桌面端工具集成到 LobeChat 的插件系统中。

View File

@@ -1,7 +1,8 @@
# copy this file to .env when you want to develop the desktop app or you will fail
APP_URL=http://localhost:3015
FEATURE_FLAGS=+pin_list
FEATURE_FLAGS=-check_updates,+pin_list
KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
DEFAULT_AGENT_CONFIG="model=qwen2.5;provider=ollama;chatConfig.searchFCModel.provider=ollama;chatConfig.searchFCModel.model=qwen2.5"
SYSTEM_AGENT="default=ollama/qwen2.5"
SEARCH_PROVIDERS=search1api

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "غير متصل",
"urlRequired": "يرجى إدخال عنوان الخادم"
},
"sync": {
"continue": "استمر",
"inCloud": "تستخدم حاليًا المزامنة السحابية",
"inLocalStorage": "تستخدم حاليًا التخزين المحلي",
"isIniting": "جارٍ التهيئة...",
"lobehubCloud": {
"description": "الإصدار السحابي المقدم رسميًا",
"title": "LobeHub Cloud"
},
"local": {
"description": "استخدام قاعدة بيانات محلية، متاحة بالكامل دون اتصال",
"title": "قاعدة بيانات محلية"
},
"mode": {
"cloudSync": "مزامنة سحابية",
"localStorage": "تخزين محلي",
"title": "اختر وضع الاتصال الخاص بك",
"useSelfHosted": "استخدام نسخة مستضافة ذاتيًا؟"
},
"selfHosted": {
"description": "نسخة المجتمع التي تم نشرها ذاتيًا",
"title": "نسخة مستضافة ذاتيًا"
}
},
"updater": {
"checkingUpdate": "التحقق من وجود تحديثات",
"checkingUpdateDesc": "جارٍ الحصول على معلومات الإصدار...",
"downloadNewVersion": "تحميل إصدار جديد",
"downloadingUpdate": "جارٍ تنزيل التحديث",
"downloadingUpdateDesc": "يتم تنزيل التحديث، يرجى الانتظار...",
"installLater": "تحديث عند بدء التشغيل التالي",
"isLatestVersion": "الإصدار الحالي هو الأحدث",
"isLatestVersionDesc": "رائع، الإصدار {{version}} الذي تستخدمه هو أحدث إصدار متاح.",
"later": "تحديث لاحقًا",
"newVersionAvailable": "يتوفر إصدار جديد",
"newVersionAvailableDesc": "تم العثور على إصدار جديد {{version}}، هل ترغب في التنزيل الآن؟",
"restartAndInstall": "إعادة التشغيل والتثبيت",
"restartAndInstall": "تثبيت التحديث وإعادة التشغيل",
"updateError": "خطأ في التحديث",
"updateReady": "التحديث جاهز",
"updateReadyDesc": "تم تنزيل Lobe Chat {{version}} بنجاح، سيتم التثبيت بعد إعادة تشغيل التطبيق.",
"updateReadyDesc": "تم تنزيل الإصدار الجديد {{version}}، يمكنك إكمال التثبيت بعد إعادة تشغيل التطبيق.",
"upgradeNow": "تحديث الآن"
},
"waitingOAuth": {
"cancel": "إلغاء",
"description": "تم فتح صفحة التفويض في المتصفح، يرجى إكمال التفويض في المتصفح",
"helpText": "إذا لم يفتح المتصفح تلقائيًا، يرجى النقر على إلغاء ثم المحاولة مرة أخرى",
"title": "انتظار الاتصال بالتفويض"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Не е свързан",
"urlRequired": "Моля, въведете адреса на сървъра"
},
"sync": {
"continue": "Продължи",
"inCloud": "В момента използвате облачно синхронизиране",
"inLocalStorage": "В момента използвате локално хранилище",
"isIniting": "Инициализиране...",
"lobehubCloud": {
"description": "Официално предоставената облачна версия",
"title": "LobeHub Облак"
},
"local": {
"description": "Използва локална база данни, напълно офлайн",
"title": "Локална база данни"
},
"mode": {
"cloudSync": "Облачно синхронизиране",
"localStorage": "Локално хранилище",
"title": "Изберете вашия режим на свързване",
"useSelfHosted": "Използвате ли самостоятелно хостван екземпляр?"
},
"selfHosted": {
"description": "Общностна версия, разположена от вас",
"title": "Самостоятелно хостван екземпляр"
}
},
"updater": {
"checkingUpdate": "Проверка за нова версия",
"checkingUpdateDesc": "Извличане на информация за версията...",
"downloadNewVersion": "Изтегляне на нова версия",
"downloadingUpdate": "Изтегляне на актуализация",
"downloadingUpdateDesc": "Актуализацията се изтегля, моля изчакайте...",
"installLater": "Актуализиране при следващо стартиране",
"isLatestVersion": "Вече имате най-новата версия",
"isLatestVersionDesc": "Страхотно, версията {{version}} е най-новата налична версия.",
"later": "Актуализирай по-късно",
"newVersionAvailable": "Налична е нова версия",
"newVersionAvailableDesc": "Открита е нова версия {{version}}, искате ли да я изтеглите сега?",
"restartAndInstall": "Рестартирай и инсталирай",
"restartAndInstall": "Инсталиране на актуализацията и рестартиране",
"updateError": "Грешка при актуализацията",
"updateReady": "Актуализацията е готова",
"updateReadyDesc": "Lobe Chat {{version}} е изтеглен, рестартирайте приложението, за да завършите инсталацията.",
"updateReadyDesc": "Нова версия {{version}} е изтеглена, инсталацията ще завърши след рестартиране на приложението.",
"upgradeNow": "Актуализирай сега"
},
"waitingOAuth": {
"cancel": "Отмени",
"description": "Браузърът е отворил страницата за авторизация, моля, завършете авторизацията в браузъра",
"helpText": "Ако браузърът не се е отворил автоматично, моля, кликнете върху отмяна и опитайте отново",
"title": "Изчакване на авторизационна връзка"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Nicht verbunden",
"urlRequired": "Bitte geben Sie die Serveradresse ein"
},
"sync": {
"continue": "Fortfahren",
"inCloud": "Aktuell Cloud-Synchronisierung verwenden",
"inLocalStorage": "Aktuell lokale Speicherung verwenden",
"isIniting": "Wird initialisiert...",
"lobehubCloud": {
"description": "Offizielle Cloud-Version",
"title": "LobeHub Cloud"
},
"local": {
"description": "Verwendet lokale Datenbank, vollständig offline verfügbar",
"title": "Lokale Datenbank"
},
"mode": {
"cloudSync": "Cloud-Synchronisierung",
"localStorage": "Lokale Speicherung",
"title": "Wählen Sie Ihren Verbindungsmodus",
"useSelfHosted": "Selbstgehostete Instanz verwenden?"
},
"selfHosted": {
"description": "Community-Version, die selbst bereitgestellt wird",
"title": "Selbstgehostete Instanz"
}
},
"updater": {
"checkingUpdate": "Überprüfen auf Updates",
"checkingUpdateDesc": "Versioninformationen werden abgerufen...",
"downloadNewVersion": "Neue Version herunterladen",
"downloadingUpdate": "Update wird heruntergeladen",
"downloadingUpdateDesc": "Das Update wird heruntergeladen, bitte warten...",
"installLater": "Beim nächsten Start aktualisieren",
"isLatestVersion": "Sie verwenden bereits die neueste Version",
"isLatestVersionDesc": "Fantastisch, die verwendete Version {{version}} ist die aktuellste Version.",
"later": "Später aktualisieren",
"newVersionAvailable": "Neue Version verfügbar",
"newVersionAvailableDesc": "Eine neue Version {{version}} wurde gefunden, möchten Sie jetzt herunterladen?",
"restartAndInstall": "Neustarten und installieren",
"restartAndInstall": "Update installieren und neu starten",
"updateError": "Update-Fehler",
"updateReady": "Update ist bereit",
"updateReadyDesc": "Lobe Chat {{version}} wurde heruntergeladen, starten Sie die Anwendung neu, um die Installation abzuschließen.",
"updateReadyDesc": "Die neue Version {{version}} wurde heruntergeladen, die Installation wird nach dem Neustart der Anwendung abgeschlossen.",
"upgradeNow": "Jetzt aktualisieren"
},
"waitingOAuth": {
"cancel": "Abbrechen",
"description": "Der Browser hat die Autorisierungsseite geöffnet, bitte schließen Sie die Autorisierung im Browser ab",
"helpText": "Wenn der Browser nicht automatisch geöffnet wurde, klicken Sie bitte auf Abbrechen und versuchen Sie es erneut",
"title": "Warten auf Autorisierungsverbindung"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Not connected",
"urlRequired": "Please enter the server address"
},
"sync": {
"continue": "Continue",
"inCloud": "Currently using cloud sync",
"inLocalStorage": "Currently using local storage",
"isIniting": "Initializing...",
"lobehubCloud": {
"description": "Officially provided cloud version",
"title": "LobeHub Cloud"
},
"local": {
"description": "Uses a local database, fully available offline",
"title": "Local Database"
},
"mode": {
"cloudSync": "Cloud Sync",
"localStorage": "Local Storage",
"title": "Select your connection mode",
"useSelfHosted": "Use a self-hosted instance?"
},
"selfHosted": {
"description": "Community version that you can deploy yourself",
"title": "Self-Hosted Instance"
}
},
"updater": {
"checkingUpdate": "Checking for updates",
"checkingUpdateDesc": "Retrieving version information...",
"downloadNewVersion": "Download new version",
"downloadingUpdate": "Downloading update",
"downloadingUpdateDesc": "The update is downloading, please wait...",
"installLater": "Update on next launch",
"isLatestVersion": "You are using the latest version",
"isLatestVersionDesc": "Great! The version {{version}} you are using is the latest available.",
"later": "Update later",
"newVersionAvailable": "New version available",
"newVersionAvailableDesc": "A new version {{version}} has been found, would you like to download it now?",
"restartAndInstall": "Restart and install",
"restartAndInstall": "Install updates and restart",
"updateError": "Update error",
"updateReady": "Update ready",
"updateReadyDesc": "Lobe Chat {{version}} has been downloaded, restart the application to complete the installation.",
"updateReadyDesc": "The new version {{version}} has been downloaded. Restart the application to complete the installation.",
"upgradeNow": "Update now"
},
"waitingOAuth": {
"cancel": "Cancel",
"description": "The browser has opened the authorization page, please complete the authorization in the browser",
"helpText": "If the browser did not open automatically, please click cancel and try again",
"title": "Waiting for Authorization Connection"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Desconectado",
"urlRequired": "Por favor, introduzca la dirección del servidor"
},
"sync": {
"continue": "Continuar",
"inCloud": "Actualmente utilizando sincronización en la nube",
"inLocalStorage": "Actualmente utilizando almacenamiento local",
"isIniting": "Inicializando...",
"lobehubCloud": {
"description": "Versión en la nube proporcionada oficialmente",
"title": "LobeHub Cloud"
},
"local": {
"description": "Usando base de datos local, completamente disponible sin conexión",
"title": "Base de datos local"
},
"mode": {
"cloudSync": "Sincronización en la nube",
"localStorage": "Almacenamiento local",
"title": "Elige tu modo de conexión",
"useSelfHosted": "¿Usar instancia autohospedada?"
},
"selfHosted": {
"description": "Versión comunitaria desplegada por uno mismo",
"title": "Instancia autohospedada"
}
},
"updater": {
"checkingUpdate": "Comprobando actualizaciones",
"checkingUpdateDesc": "Obteniendo información de la versión...",
"downloadNewVersion": "Descargar nueva versión",
"downloadingUpdate": "Descargando actualización",
"downloadingUpdateDesc": "La actualización se está descargando, por favor espere...",
"installLater": "Actualizar en el próximo inicio",
"isLatestVersion": "Ya tienes la última versión",
"isLatestVersionDesc": "¡Genial! La versión {{version}} que estás utilizando ya es la más avanzada.",
"later": "Actualizar más tarde",
"newVersionAvailable": "Nueva versión disponible",
"newVersionAvailableDesc": "Se ha encontrado una nueva versión {{version}}, ¿desea descargarla ahora?",
"restartAndInstall": "Reiniciar e instalar",
"restartAndInstall": "Instalar actualizaciones y reiniciar",
"updateError": "Error de actualización",
"updateReady": "Actualización lista",
"updateReadyDesc": "Lobe Chat {{version}} se ha descargado, reinicie la aplicación para completar la instalación.",
"updateReadyDesc": "La nueva versión {{version}} se ha descargado, reinicia la aplicación para completar la instalación.",
"upgradeNow": "Actualizar ahora"
},
"waitingOAuth": {
"cancel": "Cancelar",
"description": "Se ha abierto la página de autorización en el navegador, por favor completa la autorización en el navegador",
"helpText": "Si el navegador no se abre automáticamente, haz clic en cancelar y vuelve a intentarlo",
"title": "Esperando conexión de autorización"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "قطع شده",
"urlRequired": "لطفاً آدرس سرور را وارد کنید"
},
"sync": {
"continue": "ادامه",
"inCloud": "در حال حاضر از همگام‌سازی ابری استفاده می‌شود",
"inLocalStorage": "در حال حاضر از ذخیره‌سازی محلی استفاده می‌شود",
"isIniting": "در حال راه‌اندازی...",
"lobehubCloud": {
"description": "نسخه ابری ارائه شده توسط رسمی",
"title": "LobeHub Cloud"
},
"local": {
"description": "استفاده از پایگاه داده محلی، کاملاً آفلاین قابل استفاده است",
"title": "پایگاه داده محلی"
},
"mode": {
"cloudSync": "همگام‌سازی ابری",
"localStorage": "ذخیره‌سازی محلی",
"title": "مدل اتصال خود را انتخاب کنید",
"useSelfHosted": "آیا از نمونه خود میزبانی شده استفاده می‌کنید؟"
},
"selfHosted": {
"description": "نسخه جامعه‌ای که خودتان مستقر کرده‌اید",
"title": "نمونه خود میزبانی شده"
}
},
"updater": {
"checkingUpdate": "بررسی نسخه جدید",
"checkingUpdateDesc": "در حال دریافت اطلاعات نسخه...",
"downloadNewVersion": "دانلود نسخه جدید",
"downloadingUpdate": "در حال دانلود به‌روزرسانی",
"downloadingUpdateDesc": "به‌روزرسانی در حال دانلود است، لطفاً صبر کنید...",
"installLater": "به‌روزرسانی در راه‌اندازی بعدی",
"isLatestVersion": "در حال حاضر آخرین نسخه است",
"isLatestVersionDesc": "عالی است، نسخه {{version}} که استفاده می‌کنید، آخرین نسخه موجود است.",
"later": "به‌روزرسانی بعداً",
"newVersionAvailable": "نسخه جدید در دسترس است",
"newVersionAvailableDesc": "نسخه جدید {{version}} شناسایی شد، آیا می‌خواهید بلافاصله دانلود کنید؟",
"restartAndInstall": "راه‌اندازی مجدد و نصب",
"restartAndInstall": "نصب به‌روزرسانی و راه‌اندازی مجدد",
"updateError": "خطا در به‌روزرسانی",
"updateReady": "به‌روزرسانی آماده است",
"updateReadyDesc": "Lobe Chat {{version}} دانلود شده است، با راه‌اندازی مجدد برنامه می‌توانید نصب را کامل کنید.",
"updateReadyDesc": "نسخه جدید {{version}} دانلود شده است، با راه‌اندازی مجدد برنامه نصب کامل می‌شود.",
"upgradeNow": "همین حالا به‌روزرسانی کنید"
},
"waitingOAuth": {
"cancel": "لغو",
"description": "صفحه مجوز در مرورگر باز شده است، لطفاً مجوز را در مرورگر کامل کنید",
"helpText": "اگر مرورگر به طور خودکار باز نشد، لطفاً روی لغو کلیک کرده و دوباره تلاش کنید",
"title": "در انتظار اتصال مجوز"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Déconnecté",
"urlRequired": "Veuillez entrer l'adresse du serveur"
},
"sync": {
"continue": "Continuer",
"inCloud": "Utilisation actuelle de la synchronisation dans le cloud",
"inLocalStorage": "Utilisation actuelle du stockage local",
"isIniting": "Initialisation en cours...",
"lobehubCloud": {
"description": "Version cloud fournie par l'éditeur",
"title": "LobeHub Cloud"
},
"local": {
"description": "Utilise une base de données locale, entièrement hors ligne",
"title": "Base de données locale"
},
"mode": {
"cloudSync": "Synchronisation dans le cloud",
"localStorage": "Stockage local",
"title": "Choisissez votre mode de connexion",
"useSelfHosted": "Utiliser une instance auto-hébergée ?"
},
"selfHosted": {
"description": "Version communautaire déployée par vos soins",
"title": "Instance auto-hébergée"
}
},
"updater": {
"checkingUpdate": "Vérification des mises à jour",
"checkingUpdateDesc": "Récupération des informations de version...",
"downloadNewVersion": "Télécharger la nouvelle version",
"downloadingUpdate": "Téléchargement de la mise à jour",
"downloadingUpdateDesc": "La mise à jour est en cours de téléchargement, veuillez patienter...",
"installLater": "Mettre à jour au prochain démarrage",
"isLatestVersion": "Vous utilisez déjà la dernière version",
"isLatestVersionDesc": "Super, la version {{version}} que vous utilisez est à la pointe de la technologie.",
"later": "Mettre à jour plus tard",
"newVersionAvailable": "Nouvelle version disponible",
"newVersionAvailableDesc": "Une nouvelle version {{version}} a été trouvée, souhaitez-vous la télécharger maintenant ?",
"restartAndInstall": "Redémarrer et installer",
"restartAndInstall": "Installer la mise à jour et redémarrer",
"updateError": "Erreur de mise à jour",
"updateReady": "Mise à jour prête",
"updateReadyDesc": "Lobe Chat {{version}} a été téléchargé, redémarrez l'application pour terminer l'installation.",
"updateReadyDesc": "La nouvelle version {{version}} a été téléchargée avec succès, redémarrez l'application pour terminer l'installation.",
"upgradeNow": "Mettre à jour maintenant"
},
"waitingOAuth": {
"cancel": "Annuler",
"description": "La page d'autorisation a été ouverte dans le navigateur, veuillez compléter l'autorisation dans le navigateur",
"helpText": "Si le navigateur ne s'est pas ouvert automatiquement, veuillez cliquer sur annuler puis réessayer",
"title": "En attente de connexion d'autorisation"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Non connesso",
"urlRequired": "Inserisci l'indirizzo del server"
},
"sync": {
"continue": "Continua",
"inCloud": "Attualmente utilizza la sincronizzazione cloud",
"inLocalStorage": "Attualmente utilizza l'archiviazione locale",
"isIniting": "In fase di inizializzazione...",
"lobehubCloud": {
"description": "Versione cloud fornita ufficialmente",
"title": "LobeHub Cloud"
},
"local": {
"description": "Utilizza un database locale, completamente disponibile offline",
"title": "Database locale"
},
"mode": {
"cloudSync": "Sincronizzazione cloud",
"localStorage": "Archiviazione locale",
"title": "Scegli il tuo modo di connessione",
"useSelfHosted": "Utilizzare un'istanza autogestita?"
},
"selfHosted": {
"description": "Versione comunitaria auto-ospitata",
"title": "Istanze autogestite"
}
},
"updater": {
"checkingUpdate": "Controllo aggiornamenti",
"checkingUpdateDesc": "Sto recuperando le informazioni sulla versione...",
"downloadNewVersion": "Scarica nuova versione",
"downloadingUpdate": "Download dell'aggiornamento in corso",
"downloadingUpdateDesc": "L'aggiornamento è in fase di download, attendere...",
"installLater": "Aggiorna al prossimo avvio",
"isLatestVersion": "Sei già all'ultima versione",
"isLatestVersionDesc": "Ottimo, la versione {{version}} che stai utilizzando è già la più recente.",
"later": "Aggiorna più tardi",
"newVersionAvailable": "Nuova versione disponibile",
"newVersionAvailableDesc": "È disponibile una nuova versione {{version}}, desideri scaricarla subito?",
"restartAndInstall": "Riavvia e installa",
"restartAndInstall": "Installa aggiornamenti e riavvia",
"updateError": "Errore di aggiornamento",
"updateReady": "Aggiornamento pronto",
"updateReadyDesc": "Lobe Chat {{version}} è stato scaricato, riavvia l'app per completare l'installazione.",
"updateReadyDesc": "La nuova versione {{version}} è stata scaricata, riavvia l'app per completare l'installazione.",
"upgradeNow": "Aggiorna ora"
},
"waitingOAuth": {
"cancel": "Annulla",
"description": "Il browser ha aperto la pagina di autorizzazione, completare l'autorizzazione nel browser",
"helpText": "Se il browser non si è aperto automaticamente, fai clic su annulla e riprova",
"title": "In attesa di autorizzazione"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "未接続",
"urlRequired": "サーバーアドレスを入力してください"
},
"sync": {
"continue": "続行",
"inCloud": "現在、クラウド同期を使用しています",
"inLocalStorage": "現在、ローカルストレージを使用しています",
"isIniting": "初期化中...",
"lobehubCloud": {
"description": "公式提供のクラウド版",
"title": "LobeHub Cloud"
},
"local": {
"description": "ローカルデータベースを使用し、完全にオフラインで利用可能",
"title": "ローカルデータベース"
},
"mode": {
"cloudSync": "クラウド同期",
"localStorage": "ローカルストレージ",
"title": "接続モードを選択",
"useSelfHosted": "自己ホストのインスタンスを使用しますか?"
},
"selfHosted": {
"description": "自分でデプロイしたコミュニティ版",
"title": "自己ホストインスタンス"
}
},
"updater": {
"checkingUpdate": "更新を確認中",
"checkingUpdateDesc": "バージョン情報を取得しています...",
"downloadNewVersion": "新しいバージョンをダウンロード",
"downloadingUpdate": "更新をダウンロード中",
"downloadingUpdateDesc": "更新をダウンロードしています。しばらくお待ちください...",
"installLater": "次回起動時に更新",
"isLatestVersion": "現在最新のバージョンです",
"isLatestVersionDesc": "素晴らしい、使用中のバージョン {{version}} は最新のバージョンです。",
"later": "後で更新",
"newVersionAvailable": "新しいバージョンが利用可能です",
"newVersionAvailableDesc": "新しいバージョン {{version}} が見つかりました。今すぐダウンロードしますか?",
"restartAndInstall": "再起動してインストール",
"restartAndInstall": "更新をインストールして再起動",
"updateError": "更新エラー",
"updateReady": "更新が準備完了",
"updateReadyDesc": "Lobe Chat {{version}} ダウンロード完了しました。アプリを再起動するとインストールが完了します。",
"updateReadyDesc": "新しいバージョン {{version}} ダウンロード完了しました。アプリを再起動するとインストールが完了します。",
"upgradeNow": "今すぐ更新"
},
"waitingOAuth": {
"cancel": "キャンセル",
"description": "ブラウザが認証ページを開きました。ブラウザで認証を完了してください",
"helpText": "ブラウザが自動的に開かない場合は、キャンセルをクリックして再試行してください",
"title": "認証接続を待機中"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "연결되지 않음",
"urlRequired": "서버 주소를 입력하세요"
},
"sync": {
"continue": "계속",
"inCloud": "현재 클라우드 동기화를 사용 중",
"inLocalStorage": "현재 로컬 저장소를 사용 중",
"isIniting": "초기화 중...",
"lobehubCloud": {
"description": "공식에서 제공하는 클라우드 버전",
"title": "로브허브 클라우드"
},
"local": {
"description": "로컬 데이터베이스를 사용하며, 완전히 오프라인에서 사용 가능",
"title": "로컬 데이터베이스"
},
"mode": {
"cloudSync": "클라우드 동기화",
"localStorage": "로컬 저장소",
"title": "연결 모드 선택",
"useSelfHosted": "자체 호스팅 인스턴스 사용?"
},
"selfHosted": {
"description": "자체 배포된 커뮤니티 버전",
"title": "자체 호스팅 인스턴스"
}
},
"updater": {
"checkingUpdate": "새 버전 확인",
"checkingUpdateDesc": "버전 정보를 가져오는 중...",
"downloadNewVersion": "새 버전 다운로드",
"downloadingUpdate": "업데이트 다운로드 중",
"downloadingUpdateDesc": "업데이트가 다운로드 중입니다. 잠시 기다려 주세요...",
"installLater": "다음 시작 시 업데이트",
"isLatestVersion": "현재 최신 버전입니다",
"isLatestVersionDesc": "훌륭합니다! 사용 중인 버전 {{version}}은 최신 버전입니다.",
"later": "나중에 업데이트",
"newVersionAvailable": "새 버전 사용 가능",
"newVersionAvailableDesc": "새 버전 {{version}}이 발견되었습니다. 지금 다운로드 하시겠습니까?",
"restartAndInstall": "재시작 및 설치",
"restartAndInstall": "업데이트 설치 및 재시작",
"updateError": "업데이트 오류",
"updateReady": "업데이트 준비 완료",
"updateReadyDesc": "Lobe Chat {{version}}이 다운로드 완료되었습니다. 을 재시작하면 설치가 완료됩니다.",
"updateReadyDesc": "새 버전 {{version}}이 다운로드 완료되었습니다. 애플리케이션을 재시작하면 설치가 완료됩니다.",
"upgradeNow": "지금 업데이트"
},
"waitingOAuth": {
"cancel": "취소",
"description": "브라우저에서 인증 페이지가 열렸습니다. 브라우저에서 인증을 완료하세요.",
"helpText": "브라우저가 자동으로 열리지 않으면, 취소를 클릭한 후 다시 시도하세요.",
"title": "인증 연결 대기 중"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Niet verbonden",
"urlRequired": "Voer het serveradres in"
},
"sync": {
"continue": "Doorgaan",
"inCloud": "Momenteel gebruik makend van cloud synchronisatie",
"inLocalStorage": "Momenteel gebruik makend van lokale opslag",
"isIniting": "Bezig met initialiseren...",
"lobehubCloud": {
"description": "De door de officiële instantie aangeboden cloudversie",
"title": "LobeHub Cloud"
},
"local": {
"description": "Gebruik makend van een lokale database, volledig offline beschikbaar",
"title": "Lokale database"
},
"mode": {
"cloudSync": "Cloud synchronisatie",
"localStorage": "Lokale opslag",
"title": "Kies je verbindingsmodus",
"useSelfHosted": "Gebruik je een zelfgehoste instantie?"
},
"selfHosted": {
"description": "Gemeenschapsversie die zelf is geïmplementeerd",
"title": "Zelfgehoste instantie"
}
},
"updater": {
"checkingUpdate": "Controleer op updates",
"checkingUpdateDesc": "Bezig met het ophalen van versie-informatie...",
"downloadNewVersion": "Download nieuwe versie",
"downloadingUpdate": "Update aan het downloaden",
"downloadingUpdateDesc": "De update wordt gedownload, even geduld...",
"installLater": "Bij de volgende opstarten bijwerken",
"isLatestVersion": "Je hebt de nieuwste versie",
"isLatestVersionDesc": "Geweldig, de versie {{version}} die je gebruikt is de meest recente versie.",
"later": "Later bijwerken",
"newVersionAvailable": "Nieuwe versie beschikbaar",
"newVersionAvailableDesc": "Nieuwe versie {{version}} gevonden, wilt u deze nu downloaden?",
"restartAndInstall": "Herstarten en installeren",
"restartAndInstall": "Installeer de update en herstart",
"updateError": "Updatefout",
"updateReady": "Update gereed",
"updateReadyDesc": "Lobe Chat {{version}} is gedownload, herstart de applicatie om de installatie te voltooien.",
"updateReadyDesc": "Nieuwe versie {{version}} is gedownload, herstart de applicatie om de installatie te voltooien.",
"upgradeNow": "Nu bijwerken"
},
"waitingOAuth": {
"cancel": "Annuleren",
"description": "De browser heeft de autorisatiepagina geopend, voltooi de autorisatie in de browser",
"helpText": "Als de browser niet automatisch opent, klik dan op annuleren en probeer het opnieuw",
"title": "Wachten op autorisatieverbinding"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Nie połączono",
"urlRequired": "Proszę wprowadzić adres serwera"
},
"sync": {
"continue": "Kontynuuj",
"inCloud": "Obecnie używasz synchronizacji w chmurze",
"inLocalStorage": "Obecnie używasz lokalnego przechowywania",
"isIniting": "Inicjalizacja...",
"lobehubCloud": {
"description": "Oficjalna wersja chmurowa",
"title": "LobeHub Cloud"
},
"local": {
"description": "Używa lokalnej bazy danych, całkowicie offline",
"title": "Lokalna baza danych"
},
"mode": {
"cloudSync": "Synchronizacja w chmurze",
"localStorage": "Lokalne przechowywanie",
"title": "Wybierz tryb połączenia",
"useSelfHosted": "Używasz instancji samodzielnie hostowanej?"
},
"selfHosted": {
"description": "Wersja społecznościowa do samodzielnego wdrożenia",
"title": "Instancja samodzielnie hostowana"
}
},
"updater": {
"checkingUpdate": "Sprawdzanie aktualizacji",
"checkingUpdateDesc": "Pobieranie informacji o wersji...",
"downloadNewVersion": "Pobierz nową wersję",
"downloadingUpdate": "Pobieranie aktualizacji",
"downloadingUpdateDesc": "Aktualizacja jest pobierana, proszę czekać...",
"installLater": "Zaktualizuj przy następnym uruchomieniu",
"isLatestVersion": "Aktualnie używasz najnowszej wersji",
"isLatestVersionDesc": "Świetnie, używana wersja {{version}} jest najnowszą dostępną wersją.",
"later": "Zaktualizuj później",
"newVersionAvailable": "Dostępna nowa wersja",
"newVersionAvailableDesc": "Znaleziono nową wersję {{version}}, czy chcesz ją pobrać teraz?",
"restartAndInstall": "Uruchom ponownie i zainstaluj",
"restartAndInstall": "Zainstaluj aktualizację i uruchom ponownie",
"updateError": "Błąd aktualizacji",
"updateReady": "Aktualizacja gotowa",
"updateReadyDesc": "Lobe Chat {{version}} została pobrana, uruchom aplikację ponownie, aby zakończyć instalację.",
"updateReadyDesc": "Nowa wersja {{version}} została pobrana, zainstaluj ją po ponownym uruchomieniu aplikacji.",
"upgradeNow": "Zaktualizuj teraz"
},
"waitingOAuth": {
"cancel": "Anuluj",
"description": "Przeglądarka otworzyła stronę autoryzacji, proszę zakończyć autoryzację w przeglądarce",
"helpText": "Jeśli przeglądarka nie otworzyła się automatycznie, kliknij anuluj i spróbuj ponownie",
"title": "Oczekiwanie na połączenie autoryzacyjne"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Desconectado",
"urlRequired": "Por favor, insira o endereço do servidor"
},
"sync": {
"continue": "Continuar",
"inCloud": "Atualmente usando sincronização em nuvem",
"inLocalStorage": "Atualmente usando armazenamento local",
"isIniting": "Inicializando...",
"lobehubCloud": {
"description": "Versão em nuvem fornecida oficialmente",
"title": "LobeHub Cloud"
},
"local": {
"description": "Usando banco de dados local, totalmente disponível offline",
"title": "Banco de Dados Local"
},
"mode": {
"cloudSync": "Sincronização em Nuvem",
"localStorage": "Armazenamento Local",
"title": "Escolha seu modo de conexão",
"useSelfHosted": "Usar instância auto-hospedada?"
},
"selfHosted": {
"description": "Versão comunitária autoimplantada",
"title": "Instância Auto-Hospedada"
}
},
"updater": {
"checkingUpdate": "Verificando atualizações",
"checkingUpdateDesc": "Obtendo informações da versão...",
"downloadNewVersion": "Baixar nova versão",
"downloadingUpdate": "Baixando atualização",
"downloadingUpdateDesc": "A atualização está sendo baixada, por favor aguarde...",
"installLater": "Atualizar na próxima inicialização",
"isLatestVersion": "Você já está na versão mais recente",
"isLatestVersionDesc": "Ótimo, a versão {{version}} que você está usando já é a mais atual.",
"later": "Atualizar depois",
"newVersionAvailable": "Nova versão disponível",
"newVersionAvailableDesc": "Uma nova versão {{version}} foi encontrada, deseja baixar agora?",
"restartAndInstall": "Reiniciar e instalar",
"restartAndInstall": "Instalar atualização e reiniciar",
"updateError": "Erro na atualização",
"updateReady": "Atualização pronta",
"updateReadyDesc": "Lobe Chat {{version}} foi baixado com sucesso, reinicie o aplicativo para concluir a instalação.",
"updateReadyDesc": "A nova versão {{version}} foi baixada com sucesso, reinicie o aplicativo para concluir a instalação.",
"upgradeNow": "Atualizar agora"
},
"waitingOAuth": {
"cancel": "Cancelar",
"description": "A página de autorização foi aberta no navegador, por favor, complete a autorização no navegador",
"helpText": "Se o navegador não abrir automaticamente, clique em cancelar e tente novamente",
"title": "Aguardando conexão de autorização"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Не подключено",
"urlRequired": "Пожалуйста, введите адрес сервера"
},
"sync": {
"continue": "Продолжить",
"inCloud": "Текущий режим синхронизации с облаком",
"inLocalStorage": "Текущий режим локального хранения",
"isIniting": "Инициализация...",
"lobehubCloud": {
"description": "Облачная версия, предоставленная официально",
"title": "LobeHub Cloud"
},
"local": {
"description": "Использует локальную базу данных, полностью доступна в оффлайн-режиме",
"title": "Локальная база данных"
},
"mode": {
"cloudSync": "Синхронизация с облаком",
"localStorage": "Локальное хранилище",
"title": "Выберите режим подключения",
"useSelfHosted": "Использовать самоуправляемый экземпляр?"
},
"selfHosted": {
"description": "Сообщество, развернутое самостоятельно",
"title": "Самоуправляемый экземпляр"
}
},
"updater": {
"checkingUpdate": "Проверка обновлений",
"checkingUpdateDesc": "Получение информации о версии...",
"downloadNewVersion": "Скачать новую версию",
"downloadingUpdate": "Загрузка обновления",
"downloadingUpdateDesc": "Обновление загружается, пожалуйста, подождите...",
"installLater": "Обновить при следующем запуске",
"isLatestVersion": "Вы используете последнюю версию",
"isLatestVersionDesc": "Отлично, вы используете версию {{version}}, которая является самой последней.",
"later": "Обновить позже",
"newVersionAvailable": "Доступна новая версия",
"newVersionAvailableDesc": "Обнаружена новая версия {{version}}, хотите скачать сейчас?",
"restartAndInstall": "Перезагрузить и установить",
"restartAndInstall": "Установить обновление и перезагрузить",
"updateError": "Ошибка обновления",
"updateReady": "Обновление готово",
"updateReadyDesc": "Lobe Chat {{version}} загружен, перезагрузите приложение для завершения установки.",
"updateReadyDesc": "Новая версия {{version}} загружена, установка завершится после перезагрузки приложения.",
"upgradeNow": "Обновить сейчас"
},
"waitingOAuth": {
"cancel": "Отмена",
"description": "Браузер открыл страницу авторизации, пожалуйста, завершите авторизацию в браузере",
"helpText": "Если браузер не открылся автоматически, нажмите отмену и попробуйте снова",
"title": "Ожидание авторизации"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Bağlı değil",
"urlRequired": "Lütfen sunucu adresini girin"
},
"sync": {
"continue": "Devam et",
"inCloud": "Şu anda bulut senkronizasyonu kullanılıyor",
"inLocalStorage": "Şu anda yerel depolama kullanılıyor",
"isIniting": "Başlatılıyor...",
"lobehubCloud": {
"description": "Resmi olarak sağlanan bulut versiyonu",
"title": "LobeHub Bulut"
},
"local": {
"description": "Yerel veritabanı kullanarak tamamen çevrimdışı kullanılabilir",
"title": "Yerel Veritabanı"
},
"mode": {
"cloudSync": "Bulut Senkronizasyonu",
"localStorage": "Yerel Depolama",
"title": "Bağlantı modunuzu seçin",
"useSelfHosted": "Kendi barındırdığınız örneği mi kullanmak istiyorsunuz?"
},
"selfHosted": {
"description": "Kendi başınıza dağıttığınız topluluk versiyonu",
"title": "Kendi Barındırdığınız Örnek"
}
},
"updater": {
"checkingUpdate": "Yeni sürüm kontrol ediliyor",
"checkingUpdateDesc": "Sürüm bilgileri alınıyor...",
"downloadNewVersion": "Yeni sürümü indir",
"downloadingUpdate": "Güncelleme indiriliyor",
"downloadingUpdateDesc": "Güncelleme indiriliyor, lütfen bekleyin...",
"installLater": "Gelecek başlatmada güncelle",
"isLatestVersion": "Şu anda en son sürümdesiniz",
"isLatestVersionDesc": "Harika, kullandığınız sürüm {{version}} en güncel sürüm. ",
"later": "Güncellemeyi daha sonra yap",
"newVersionAvailable": "Yeni sürüm mevcut",
"newVersionAvailableDesc": "Yeni sürüm {{version}} bulundu, hemen indirmek ister misiniz?",
"restartAndInstall": "Yeniden başlat ve yükle",
"restartAndInstall": "Güncellemeyi yükle ve yeniden başlat",
"updateError": "Güncelleme hatası",
"updateReady": "Güncelleme hazır",
"updateReadyDesc": "Lobe Chat {{version}} indirildi, yüklemeyi tamamlamak için uygulamayı yeniden başlatın.",
"updateReadyDesc": "Yeni sürüm {{version}} indirildi, uygulamayı yeniden başlattığınızda yükleme tamamlanacaktır.",
"upgradeNow": "Şimdi güncelle"
},
"waitingOAuth": {
"cancel": "İptal",
"description": "Tarayıcıda yetkilendirme sayfasııldı, lütfen tarayıcıda yetkilendirmeyi tamamlayın",
"helpText": "Tarayıcı otomatik olarak açılmadıysa, lütfen iptal'e tıklayıp yeniden deneyin",
"title": "Yetkilendirme bağlantısını bekliyor"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "Chưa kết nối",
"urlRequired": "Vui lòng nhập địa chỉ máy chủ"
},
"sync": {
"continue": "Tiếp tục",
"inCloud": "Hiện đang sử dụng đồng bộ đám mây",
"inLocalStorage": "Hiện đang sử dụng lưu trữ cục bộ",
"isIniting": "Đang khởi tạo...",
"lobehubCloud": {
"description": "Phiên bản đám mây do chính thức cung cấp",
"title": "LobeHub Cloud"
},
"local": {
"description": "Sử dụng cơ sở dữ liệu cục bộ, hoàn toàn có thể sử dụng ngoại tuyến",
"title": "Cơ sở dữ liệu cục bộ"
},
"mode": {
"cloudSync": "Đồng bộ đám mây",
"localStorage": "Lưu trữ cục bộ",
"title": "Chọn chế độ kết nối của bạn",
"useSelfHosted": "Sử dụng phiên bản tự lưu trữ?"
},
"selfHosted": {
"description": "Phiên bản cộng đồng tự triển khai",
"title": "Phiên bản tự lưu trữ"
}
},
"updater": {
"checkingUpdate": "Kiểm tra phiên bản mới",
"checkingUpdateDesc": "Đang lấy thông tin phiên bản...",
"downloadNewVersion": "Tải phiên bản mới",
"downloadingUpdate": "Đang tải bản cập nhật",
"downloadingUpdateDesc": "Bản cập nhật đang được tải xuống, vui lòng chờ...",
"installLater": "Cập nhật khi khởi động lần sau",
"isLatestVersion": "Hiện tại đã là phiên bản mới nhất",
"isLatestVersionDesc": "Rất tuyệt, phiên bản {{version}} bạn đang sử dụng đã là phiên bản tiên tiến nhất.",
"later": "Cập nhật sau",
"newVersionAvailable": "Có phiên bản mới",
"newVersionAvailableDesc": "Đã phát hiện phiên bản mới {{version}}, có muốn tải xuống ngay không?",
"restartAndInstall": "Khởi động lại và cài đặt",
"restartAndInstall": "Cài đặt cập nhật và khởi động lại",
"updateError": "Lỗi cập nhật",
"updateReady": "Cập nhật đã sẵn sàng",
"updateReadyDesc": "Lobe Chat {{version}} đã tải xong, khởi động lại ứng dụng để hoàn tất cài đặt.",
"updateReadyDesc": "Phiên bản mới {{version}} đã tải xong, khởi động lại ứng dụng để hoàn tất cài đặt.",
"upgradeNow": "Cập nhật ngay"
},
"waitingOAuth": {
"cancel": "Hủy",
"description": "Trình duyệt đã mở trang ủy quyền, vui lòng hoàn tất ủy quyền trong trình duyệt",
"helpText": "Nếu trình duyệt không tự động mở, vui lòng nhấp vào hủy và thử lại",
"title": "Đang chờ kết nối ủy quyền"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "未连接",
"urlRequired": "请输入服务器地址"
},
"sync": {
"continue": "继续",
"inCloud": "当前使用云端同步",
"inLocalStorage": "当前使用本地存储",
"isIniting": "正在初始化...",
"lobehubCloud": {
"description": "官方提供的云版本",
"title": "LobeHub Cloud"
},
"local": {
"description": "使用本地数据库,完全离线可用",
"title": "本地数据库"
},
"mode": {
"cloudSync": "云端同步",
"localStorage": "本地存储",
"title": "选择你的连接模式",
"useSelfHosted": "使用自托管实例?"
},
"selfHosted": {
"description": "自行部署的社区版本",
"title": "自托管实例"
}
},
"updater": {
"checkingUpdate": "检查新版本",
"checkingUpdateDesc": "正在获取版本信息...",
"downloadNewVersion": "下载新版本",
"downloadingUpdate": "正在下载更新",
"downloadingUpdateDesc": "更新正在下载中,请稍候...",
"installLater": "下次启动时更新",
"isLatestVersion": "当前已是最新版本",
"isLatestVersionDesc": "非常棒,使用的版本 {{version}} 已是最前沿的版本。",
"later": "稍后更新",
"newVersionAvailable": "新版本可用",
"newVersionAvailableDesc": "发现新版本 {{version}},是否立即下载?",
"restartAndInstall": "重启并安装",
"restartAndInstall": "安装更新并重启",
"updateError": "更新错误",
"updateReady": "更新已就绪",
"updateReadyDesc": "Lobe Chat {{version}} 已下载完成,重启应用后即可完成安装。",
"updateReadyDesc": "新版本 {{version}} 已下载完成,重启应用后即可完成安装。",
"upgradeNow": "立即更新"
},
"waitingOAuth": {
"cancel": "取消",
"description": "浏览器已打开授权页面,请在浏览器中完成授权",
"helpText": "如果浏览器没有自动打开,请点击取消后重新尝试",
"title": "等待授权连接"
}
}

View File

@@ -17,16 +17,52 @@
"statusDisconnected": "未連接",
"urlRequired": "請輸入伺服器地址"
},
"sync": {
"continue": "繼續",
"inCloud": "當前使用雲端同步",
"inLocalStorage": "當前使用本地儲存",
"isIniting": "正在初始化...",
"lobehubCloud": {
"description": "官方提供的雲版本",
"title": "LobeHub Cloud"
},
"local": {
"description": "使用本地資料庫,完全離線可用",
"title": "本地資料庫"
},
"mode": {
"cloudSync": "雲端同步",
"localStorage": "本地儲存",
"title": "選擇你的連接模式",
"useSelfHosted": "使用自托管實例?"
},
"selfHosted": {
"description": "自行部署的社區版本",
"title": "自托管實例"
}
},
"updater": {
"checkingUpdate": "檢查新版本",
"checkingUpdateDesc": "正在獲取版本資訊...",
"downloadNewVersion": "下載新版本",
"downloadingUpdate": "正在下載更新",
"downloadingUpdateDesc": "更新正在下載中,請稍候...",
"installLater": "下次啟動時更新",
"isLatestVersion": "當前已是最新版本",
"isLatestVersionDesc": "非常棒,使用的版本 {{version}} 已是最前沿的版本。",
"later": "稍後更新",
"newVersionAvailable": "新版本可用",
"newVersionAvailableDesc": "發現新版本 {{version}},是否立即下載?",
"restartAndInstall": "重啟並安裝",
"restartAndInstall": "安裝更新並重啟",
"updateError": "更新錯誤",
"updateReady": "更新已就緒",
"updateReadyDesc": "Lobe Chat {{version}} 已下載完成,重啟應用後即可完成安裝。",
"updateReadyDesc": "新版本 {{version}} 已下載完成,重啟應用後即可完成安裝。",
"upgradeNow": "立即更新"
},
"waitingOAuth": {
"cancel": "取消",
"description": "瀏覽器已打開授權頁面,請在瀏覽器中完成授權",
"helpText": "如果瀏覽器沒有自動打開,請點擊取消後重新嘗試",
"title": "等待授權連接"
}
}

View File

@@ -9,12 +9,12 @@ export interface AutoUpdateDispatchEvents {
}
export interface AutoUpdateBroadcastEvents {
updateAvailable: (info: UpdateInfo) => void;
updateCheckStart: () => void;
manualUpdateAvailable: (info: UpdateInfo) => void;
manualUpdateCheckStart: () => void;
manualUpdateNotAvailable: (info: UpdateInfo) => void;
updateDownloadProgress: (progress: ProgressInfo) => void;
updateDownloadStart: () => void;
updateDownloaded: (info: UpdateInfo) => void;
updateError: (message: string) => void;
updateNotAvailable: (info: UpdateInfo) => void;
updateWillInstallLater: () => void;
}

View File

@@ -0,0 +1,222 @@
import { Input } from '@lobehub/ui';
import { LobeHub } from '@lobehub/ui/brand';
import { Button } from 'antd';
import { createStyles } from 'antd-style';
import { ComputerIcon, Server } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useElectronStore } from '@/store/electron';
import { AccessOption, Option } from './Option';
const useStyles = createStyles(({ token, css }) => {
return {
cardGroup: css`
width: 400px; /* Increased width */
`,
container: css`
overflow-y: auto;
width: 100%;
height: 100%;
padding-block: 0 40px;
padding-inline: 24px; /* Increased top padding */
`,
continueButton: css`
width: 100%;
margin-block-start: 40px;
`,
groupTitle: css`
padding-inline-start: 4px; /* Align with card padding */
font-size: 16px;
font-weight: 500;
color: ${token.colorTextSecondary};
`,
header: css`
text-align: center;
`,
inputError: css`
margin-block-start: 8px;
font-size: 12px;
color: ${token.colorError};
`,
modal: css`
.ant-drawer-close {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 0;
}
`,
selfHostedInput: css`
margin-block-start: 12px;
`,
selfHostedText: css`
cursor: pointer;
font-size: 14px;
color: ${token.colorTextTertiary};
:hover {
color: ${token.colorTextSecondary};
}
`,
title: css`
margin-block: 16px 48px; /* Increased Spacing below title */
font-size: 24px; /* Increased font size */
font-weight: 600;
color: ${token.colorTextHeading};
`,
};
});
interface ConnectionModeProps {
setIsOpen: (open: boolean) => void;
setWaiting: (waiting: boolean) => void;
}
const ConnectionMode = memo<ConnectionModeProps>(({ setIsOpen, setWaiting }) => {
const { styles } = useStyles();
const { t } = useTranslation(['electron', 'common']);
const [selectedOption, setSelectedOption] = useState<AccessOption>();
const [selfHostedUrl, setSelfHostedUrl] = useState('');
const [urlError, setUrlError] = useState<string | undefined>();
const connect = useElectronStore((s) => s.connectRemoteServer);
const disconnect = useElectronStore((s) => s.disconnectRemoteServer);
const validateUrl = useCallback((url: string) => {
if (!url) {
return t('remoteServer.urlRequired');
}
try {
new URL(url);
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error('Invalid protocol');
}
return undefined;
} catch {
return t('remoteServer.invalidUrl');
}
}, []);
const handleSelectOption = (option: AccessOption) => {
setSelectedOption(option);
if (option !== 'self-hosted') {
setUrlError(undefined);
} else {
setUrlError(validateUrl(selfHostedUrl));
}
};
const handleContinue = async () => {
if (selectedOption === 'self-hosted') {
const error = validateUrl(selfHostedUrl);
setUrlError(error);
if (error) {
return;
}
}
if (selectedOption === 'local') {
await disconnect();
setIsOpen(false);
return;
}
// try to connect
setWaiting(true);
await connect(
selectedOption === 'self-hosted'
? { isSelfHosted: true, serverUrl: selfHostedUrl }
: { isSelfHosted: false },
);
};
return (
<Center className={styles.container}>
<Flexbox align={'center'} gap={0}>
<h1 className={styles.title}>{t('sync.mode.title')}</h1>
</Flexbox>
<Flexbox className={styles.cardGroup} gap={24}>
<Flexbox gap={16}>
<Flexbox align="center" horizontal justify="space-between">
<div className={styles.groupTitle}>{t('sync.mode.cloudSync')}</div>
<div
className={styles.selfHostedText}
onClick={() => handleSelectOption('self-hosted')}
>
{t('sync.mode.useSelfHosted')}
</div>
</Flexbox>
<Option
description={t('sync.lobehubCloud.description')}
icon={LobeHub}
isSelected={selectedOption === 'cloud'}
label={t('sync.lobehubCloud.title')}
onClick={handleSelectOption}
value="cloud"
/>
{selectedOption === 'self-hosted' && (
<Option
description={t('sync.selfHosted.description')}
icon={Server}
isSelected={selectedOption === 'self-hosted'}
label={t('sync.selfHosted.title')}
onClick={handleSelectOption}
value="self-hosted"
>
{selectedOption === 'self-hosted' && (
<>
<Input
autoFocus
className={styles.selfHostedInput}
onChange={(e) => {
const newUrl = e.target.value;
setSelfHostedUrl(newUrl);
setUrlError(validateUrl(newUrl));
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://your-lobechat.com"
status={urlError ? 'error' : undefined}
value={selfHostedUrl}
/>
{urlError && <div className={styles.inputError}>{urlError}</div>}
</>
)}
</Option>
)}
</Flexbox>
<Flexbox>
<div className={styles.groupTitle} style={{ marginBottom: 12 }}>
{t('sync.mode.localStorage')}
</div>
<Option
description={t('sync.local.description')}
icon={ComputerIcon}
isSelected={selectedOption === 'local'}
label={t('sync.local.title')}
onClick={handleSelectOption}
value="local"
/>
</Flexbox>
</Flexbox>
<Button
className={styles.continueButton}
disabled={
!selectedOption || (selectedOption === 'self-hosted' && (!!urlError || !selfHostedUrl))
}
onClick={handleContinue}
size="large"
style={{ maxWidth: 400 }}
type="primary"
>
{selectedOption === 'local' ? t('save', { ns: 'common' }) : t('sync.continue')}
</Button>
</Center>
);
});
export default ConnectionMode;

View File

@@ -0,0 +1,104 @@
import { CheckCircleFilled } from '@ant-design/icons';
import { createStyles } from 'antd-style';
import { ComponentType, ReactNode } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ token, css }) => ({
checked: css`
position: relative;
border: 1px solid ${token.colorPrimary};
`,
description: css`
margin-block-start: 4px; /* Adjust spacing */
font-size: 13px; /* Slightly larger description */
color: ${token.colorTextSecondary};
`,
iconWrapper: css`
margin-block-start: 2px;
padding: 0;
color: ${token.colorTextSecondary};
svg {
display: block;
font-size: 24px; /* Increased icon size */
stroke-width: 2; /* Ensure lucide icons look bolder */
}
`,
label: css`
font-size: 16px;
font-weight: 600; /* Bolder label */
color: ${token.colorText};
`,
optionCard: css`
cursor: pointer;
width: 100%;
padding: 16px;
border: 1px solid ${token.colorBorderSecondary}; /* Use secondary border */
border-radius: ${token.borderRadiusLG}px;
color: ${token.colorText};
background-color: ${token.colorBgContainer};
transition: all 0.2s ${token.motionEaseInOut};
:hover {
border-color: ${token.colorPrimary};
}
`,
optionInner: css`
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
`,
}));
// 定义选项类型
export type AccessOption = 'cloud' | 'self-hosted' | 'local';
export interface OptionProps {
children?: ReactNode;
description: string;
icon: ComponentType<any>;
isSelected: boolean;
label: string;
onClick: (value: AccessOption) => void;
value: AccessOption; // For self-hosted input
}
export const Option = ({
description,
icon: PrefixIcon,
label,
value,
isSelected,
onClick,
children,
}: OptionProps) => {
const { styles, cx } = useStyles();
return (
<Flexbox
className={cx(styles.optionCard, isSelected && styles.checked)}
direction="vertical"
key={value}
onClick={() => onClick(value)}
>
<div className={styles.optionInner}>
<Flexbox gap={16} horizontal>
<Center className={styles.iconWrapper}>
<PrefixIcon />
</Center>
<Flexbox gap={8}>
<div className={styles.label}>{label}</div>
<div className={styles.description}>{description}</div>
</Flexbox>
</Flexbox>
{isSelected && <CheckCircleFilled style={{ fontSize: 16 }} />}
</div>
{children}
</Flexbox>
);
};

View File

@@ -0,0 +1,42 @@
import { ActionIcon } from '@lobehub/ui';
import { Loader, Wifi, WifiOffIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useElectronStore } from '@/store/electron';
import { electronSyncSelectors } from '@/store/electron/selectors';
interface SyncProps {
onClick: () => void;
}
const Sync = memo<SyncProps>(({ onClick }) => {
const { t } = useTranslation('electron');
const [isIniting, isSyncActive, useRemoteServerConfig] = useElectronStore((s) => [
!s.isInitRemoteServerConfig,
electronSyncSelectors.isSyncActive(s),
s.useRemoteServerConfig,
]);
// 使用useSWR获取远程服务器配置
useRemoteServerConfig();
return (
<ActionIcon
icon={isIniting ? Loader : isSyncActive ? Wifi : WifiOffIcon}
loading={isIniting}
onClick={onClick}
placement={'bottomRight'}
size="small"
title={
isIniting
? t('sync.isIniting')
: isSyncActive
? t('sync.inCloud')
: t('sync.inLocalStorage')
}
/>
);
});
export default Sync;

View File

@@ -0,0 +1,203 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Icon } from '@lobehub/ui';
import { Button, Typography } from 'antd';
import { createStyles, cx, keyframes } from 'antd-style';
import { WifiIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useElectronStore } from '@/store/electron';
const { Text, Title } = Typography;
const airdropPulse = keyframes`
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0.5;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
`;
const useStyles = createStyles(({ css, token }) => ({
container: css`
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
color: ${token.colorTextBase};
background-color: ${token.colorBgContainer};
`,
content: css`
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
`,
description: css`
margin-block-end: ${token.marginXL}px !important;
color: ${token.colorTextSecondary} !important;
`,
helpLink: css`
margin-inline-start: ${token.marginXXS}px;
color: ${token.colorTextSecondary};
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: ${token.colorText};
}
`,
helpText: css`
margin-block-start: ${token.marginLG}px;
font-size: ${token.fontSizeSM}px;
color: ${token.colorTextTertiary};
`,
// 新增:图标和脉冲动画的容器
iconContainer: css`
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 160px;
height: 160px;
margin-block-end: ${token.marginXL}px;
`,
// 新增:不同延迟的脉冲动画
pulse1: css`
animation: ${airdropPulse} 3s ease-out infinite;
`,
pulse2: css`
animation: ${airdropPulse} 3s ease-out 1.2s infinite;
`,
pulse3: css`
animation: ${airdropPulse} 3s ease-out 1.8s infinite;
`,
// 新增:基础脉冲样式
pulseBase: css`
pointer-events: none;
content: '';
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
width: 100px;
height: 100px;
border-radius: 50%;
opacity: 0;
background-color: ${token.colorPrimaryBgHover};
`,
// 新增Radar 图标样式
radarIcon: css`
z-index: 1;
color: ${token.colorPrimary};
`,
ring1: css`
width: 80px;
height: 80px;
border: 1px solid ${token.colorText};
`,
ring2: css`
width: 120px;
height: 120px;
border: 1px solid ${token.colorTextQuaternary};
`,
ring3: css`
width: 160px;
height: 160px;
border: 1px solid ${token.colorFillSecondary};
`,
// 新增:星环基础样式
ringBase: css`
pointer-events: none;
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
`,
title: css`
margin-block-end: ${token.marginSM}px !important;
color: ${token.colorText} !important;
`,
}));
interface WaitingOAuthProps {
setIsOpen: (open: boolean) => void;
setWaiting: (waiting: boolean) => void;
}
const WaitingOAuth = memo<WaitingOAuthProps>(({ setWaiting, setIsOpen }) => {
const { styles } = useStyles();
const { t } = useTranslation('electron'); // 指定 namespace 为 electron
const [disconnect, refreshServerConfig] = useElectronStore((s) => [
s.disconnectRemoteServer,
s.refreshServerConfig,
]);
const handleCancel = async () => {
await disconnect();
setWaiting(false);
};
useWatchBroadcast('authorizationSuccessful', async () => {
setIsOpen(false);
setWaiting(false);
await refreshServerConfig();
});
return (
<div className={styles.container}>
<div className={styles.content}>
{/* 更新为新的图标和脉冲动画结构 */}
<div className={styles.iconContainer}>
{/* 新增:星环 */}
<div className={cx(styles.ringBase, styles.ring1)} />
<div className={cx(styles.ringBase, styles.ring2)} />
<div className={cx(styles.ringBase, styles.ring3)} />
{/* 脉冲 */}
<div className={cx(styles.pulseBase, styles.pulse1)} />
<div className={cx(styles.pulseBase, styles.pulse2)} />
<div className={cx(styles.pulseBase, styles.pulse3)} />
<Icon className={styles.radarIcon} icon={WifiIcon} size={{ fontSize: 40 }} />
</div>
<Title className={styles.title} level={4}>
{t('waitingOAuth.title')}
</Title>
<Text className={styles.description}>{t('waitingOAuth.description')}</Text>
<Button onClick={handleCancel}>{t('waitingOAuth.cancel')}</Button>{' '}
<Text className={styles.helpText}>{t('waitingOAuth.helpText')}</Text>
</div>
</div>
);
});
export default WaitingOAuth;

View File

@@ -0,0 +1,57 @@
import { Drawer } from 'antd';
import { createStyles } from 'antd-style';
import { useState } from 'react';
import Mode from './Mode';
import Sync from './Sync';
import WaitingOAuth from './Waiting';
const useStyles = createStyles(({ css }) => {
return {
modal: css`
.ant-drawer-close {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 0;
}
`,
};
});
const Connection = () => {
const { styles, theme } = useStyles();
const [isOpen, setIsOpen] = useState(false);
const [isWaiting, setWaiting] = useState(false);
return (
<>
<Sync
onClick={() => {
setIsOpen(true);
}}
/>
<Drawer
classNames={{ header: styles.modal }}
height={'100vh'}
onClose={() => {
setIsOpen(false);
}}
open={isOpen}
placement={'top'}
style={{
background: theme.colorBgLayout,
}}
styles={{ body: { padding: 0 }, header: { padding: 0 } }}
>
{isWaiting ? (
<WaitingOAuth setIsOpen={setIsOpen} setWaiting={setWaiting} />
) : (
<Mode setIsOpen={setIsOpen} setWaiting={setWaiting} />
)}
</Drawer>
</>
);
};
export default Connection;

View File

@@ -0,0 +1,242 @@
import { ProgressInfo, UpdateInfo, useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { App, Button, Modal, Progress, Spin } from 'antd';
import React, { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { autoUpdateService } from '@/services/electron/autoUpdate';
export const UpdateModal = memo(() => {
const { t } = useTranslation(['electron', 'common']);
const [isChecking, setIsChecking] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [updateAvailableInfo, setUpdateAvailableInfo] = useState<UpdateInfo | null>(null);
const [downloadedInfo, setDownloadedInfo] = useState<UpdateInfo | null>(null);
const [progress, setProgress] = useState<ProgressInfo | null>(null);
const [latestVersionInfo, setLatestVersionInfo] = useState<UpdateInfo | null>(null); // State for latest version modal
const { modal } = App.useApp();
// --- Event Listeners ---
useWatchBroadcast('manualUpdateCheckStart', () => {
console.log('[Manual Update] Check Start');
setIsChecking(true);
setUpdateAvailableInfo(null);
setDownloadedInfo(null);
setProgress(null);
setLatestVersionInfo(null); // Reset latest version info
// Optional: Show a brief notification that check has started
// notification.info({ message: t('updater.checking') });
});
useWatchBroadcast('manualUpdateAvailable', (info: UpdateInfo) => {
console.log('[Manual Update] Available:', info);
// Only react if it's part of a manual check flow (i.e., isChecking was true)
// No need to check isChecking here as this event is specific
setIsChecking(false);
setUpdateAvailableInfo(info);
});
useWatchBroadcast('manualUpdateNotAvailable', (info) => {
console.log('[Manual Update] Not Available:', info);
// Only react if it's part of a manual check flow
// No need to check isChecking here as this event is specific
setIsChecking(false);
setLatestVersionInfo(info); // Set info for the modal
// notification.success({
// description: t('updater.isLatestVersionDesc', { version: info.version }),
// message: t('updater.isLatestVersion'),
// });
});
useWatchBroadcast('updateError', (message: string) => {
console.log('[Manual Update] Error:', message);
// Only react if it's part of a manual check/download flow
if (isChecking || isDownloading) {
setIsChecking(false);
setIsDownloading(false);
// Show error modal or notification
modal.error({ content: message, title: t('updater.updateError') });
setLatestVersionInfo(null); // Ensure other modals are closed on error
setUpdateAvailableInfo(null);
setDownloadedInfo(null);
}
});
useWatchBroadcast('updateDownloadStart', () => {
console.log('[Manual Update] Download Start');
// This event implies a manual download was triggered (likely from the 'updateAvailable' modal)
setIsDownloading(true);
setUpdateAvailableInfo(null); // Hide the 'download' button modal
setProgress({ bytesPerSecond: 0, percent: 0, total: 0, transferred: 0 }); // Reset progress
setLatestVersionInfo(null); // Ensure other modals are closed
// Optional: Show notification that download started
// notification.info({ message: t('updater.downloadingUpdate') });
});
useWatchBroadcast('updateDownloadProgress', (progressInfo: ProgressInfo) => {
console.log('[Manual Update] Progress:', progressInfo);
// Only update progress if we are in the manual download state
setProgress(progressInfo);
});
useWatchBroadcast('updateDownloaded', (info: UpdateInfo) => {
console.log('[Manual Update] Downloaded:', info);
// This event implies a download finished, likely the one we started manually
setIsChecking(false);
setIsDownloading(false);
setDownloadedInfo(info);
setProgress(null); // Clear progress
setLatestVersionInfo(null); // Ensure other modals are closed
setUpdateAvailableInfo(null);
});
// --- Render Logic ---
const handleDownload = () => {
if (!updateAvailableInfo) return;
// No need to set states here, 'updateDownloadStart' will handle it
autoUpdateService.downloadUpdate();
};
const handleInstallNow = () => {
setDownloadedInfo(null); // Close modal immediately
autoUpdateService.installNow();
};
const handleInstallLater = () => {
// No need to set state here, 'updateWillInstallLater' handles it
autoUpdateService.installLater();
setDownloadedInfo(null); // Close the modal after clicking
};
const closeAvailableModal = () => setUpdateAvailableInfo(null);
const closeDownloadedModal = () => setDownloadedInfo(null);
const closeLatestVersionModal = () => setLatestVersionInfo(null);
const renderCheckingModal = () => (
<Modal
closable={false}
footer={null}
maskClosable={false}
open={isChecking}
title={t('updater.checkingUpdate')}
>
<Spin spinning={true}>
<div style={{ padding: '20px', textAlign: 'center' }}>
{t('updater.checkingUpdateDesc')}
</div>
</Spin>
</Modal>
);
const renderAvailableModal = () => (
<Modal
footer={[
<Button key="cancel" onClick={closeAvailableModal}>
{t('cancel', { ns: 'common' })}
</Button>,
<Button key="download" onClick={handleDownload} type="primary">
{t('updater.downloadNewVersion')}
</Button>,
]}
onCancel={closeAvailableModal}
open={!!updateAvailableInfo}
title={t('updater.newVersionAvailable')}
>
<h4>{t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })}</h4>
{updateAvailableInfo?.releaseNotes && (
<div
dangerouslySetInnerHTML={{ __html: updateAvailableInfo.releaseNotes as string }}
style={{
// background:theme
borderRadius: 4,
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
padding: '8px 12px',
}}
/>
)}
</Modal>
);
const renderDownloadingModal = () => {
const percent = progress ? Math.round(progress.percent) : 0;
return (
<Modal
closable={false}
footer={null}
maskClosable={false}
open={isDownloading && !downloadedInfo}
title={t('updater.downloadingUpdate')}
>
<div style={{ padding: '20px 0' }}>
<Progress percent={percent} status="active" />
<div style={{ fontSize: 12, marginTop: 8, textAlign: 'center' }}>
{t('updater.downloadingUpdateDesc', { percent })}
{progress && progress.bytesPerSecond > 0 && (
<span> ({(progress.bytesPerSecond / 1024 / 1024).toFixed(2)} MB/s)</span>
)}
</div>
</div>
</Modal>
);
};
const renderDownloadedModal = () => (
<Modal
footer={[
<Button key="later" onClick={handleInstallLater}>
{t('updater.installLater')}
</Button>,
<Button key="now" onClick={handleInstallNow} type="primary">
{t('updater.restartAndInstall')}
</Button>,
]}
onCancel={closeDownloadedModal} // Allow closing if they don't want to decide now
open={!!downloadedInfo}
title={t('updater.updateReady')}
>
<h4>{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}</h4>
{downloadedInfo?.releaseNotes && (
<div
dangerouslySetInnerHTML={{ __html: downloadedInfo.releaseNotes as string }}
style={{
borderRadius: 4,
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
padding: '8px 12px',
}}
/>
)}
</Modal>
);
// New modal for "latest version"
const renderLatestVersionModal = () => (
<Modal
footer={[
<Button key="ok" onClick={closeLatestVersionModal} type="primary">
{t('ok', { ns: 'common' })}
</Button>,
]}
onCancel={closeLatestVersionModal}
open={!!latestVersionInfo}
title={t('updater.isLatestVersion')}
>
<p>{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}</p>
</Modal>
);
return (
<>
{renderCheckingModal()}
{renderAvailableModal()}
{renderDownloadingModal()}
{renderDownloadedModal()}
{renderLatestVersionModal()}
{/* Error state is handled by Modal.error currently */}
</>
);
});

View File

@@ -0,0 +1,193 @@
import { DownloadOutlined } from '@ant-design/icons';
import { UpdateInfo, useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Icon } from '@lobehub/ui';
import { Badge, Button, Popover, Progress, Tooltip, theme } from 'antd';
import { createStyles } from 'antd-style';
import { Download } from 'lucide-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { autoUpdateService } from '@/services/electron/autoUpdate';
const useStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
height: 24px;
padding-inline: 8px;
border: 1px solid ${token.green7A};
border-radius: 24px;
font-size: 12px;
line-height: 22px;
color: ${token.green11A};
background: ${token.green2A};
`,
releaseNote: css`
overflow: scroll;
max-height: 300px;
padding: 8px;
border-radius: 8px;
background: ${token.colorFillQuaternary};
`,
}));
export const UpdateNotification: React.FC = () => {
const { t } = useTranslation('electron');
const { styles } = useStyles();
const { token } = theme.useToken();
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateDownloaded, setUpdateDownloaded] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [willInstallLater, setWillInstallLater] = useState(false);
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
useWatchBroadcast('updateDownloadProgress', (progress: { percent: number }) => {
setDownloadProgress(progress.percent);
});
useWatchBroadcast('updateDownloaded', (info: UpdateInfo) => {
setUpdateInfo(info);
setUpdateDownloaded(true);
setUpdateAvailable(false);
});
useWatchBroadcast('updateWillInstallLater', () => {
setWillInstallLater(true);
setTimeout(() => setWillInstallLater(false), 5000); // 5秒后自动隐藏提示
});
// 没有更新或正在下载时不显示任何内容
if ((!updateAvailable && !updateDownloaded) || (downloadProgress > 0 && downloadProgress < 100)) {
return null;
}
// 如果正在下载,显示下载进度
if (downloadProgress > 0 && downloadProgress < 100) {
return (
<div
style={{
position: 'fixed',
right: 12,
top: 12,
zIndex: 1000,
}}
>
<Tooltip title={t('updater.downloadingUpdateDesc', '正在下载更新...')}>
<Badge
count={<DownloadOutlined style={{ color: token.colorPrimary }} />}
offset={[-4, 4]}
>
<div
style={{
alignItems: 'center',
background: token.colorBgElevated,
borderRadius: '50%',
boxShadow: token.boxShadow,
display: 'flex',
height: 32,
justifyContent: 'center',
position: 'relative',
width: 32,
}}
>
<Progress
percent={Math.round(downloadProgress)}
showInfo={false}
strokeWidth={12}
type="circle"
width={30}
/>
<span
style={{
fontSize: 10,
fontWeight: 'bold',
position: 'absolute',
}}
>
{Math.round(downloadProgress)}%
</span>
</div>
</Badge>
</Tooltip>
</div>
);
}
return (
<Flexbox>
<Popover
arrow={false}
content={
<Flexbox gap={8} style={{ maxWidth: 380 }}>
<div>
<h3 style={{ margin: 0 }}>{t('updater.updateReady')}</h3>
<div style={{ color: token.colorTextSecondary, fontSize: 12 }}>
{updateInfo?.version}
</div>
</div>
{updateInfo?.releaseNotes && (
<div
className={styles.releaseNote}
dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }}
style={{ maxHeight: 300, overflow: 'scroll' }}
/>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button
onClick={() => {
autoUpdateService.installNow();
}}
size="small"
type="primary"
>
{t('updater.upgradeNow')}
</Button>
</div>
</Flexbox>
}
onOpenChange={setIsPopoverVisible}
open={isPopoverVisible}
placement="bottomRight"
title={null}
trigger="hover"
>
<Flexbox
align={'center'}
className={styles.container}
gap={4}
horizontal
onClick={() => setIsPopoverVisible(true)}
>
<Icon icon={Download} style={{ fontSize: 14 }} />
</Flexbox>
</Popover>
{/* 下次启动时更新提示 */}
{willInstallLater && (
<div
style={{
backgroundColor: token.colorBgElevated,
borderRadius: token.borderRadius,
bottom: 20,
boxShadow: token.boxShadow,
color: token.colorText,
padding: '10px 16px',
position: 'fixed',
right: 20,
zIndex: 1000,
}}
>
{t('updater.willInstallLater', '更新将在下次启动时安装')}
</div>
)}
</Flexbox>
);
};

View File

@@ -1,11 +1,20 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useElectronStore } from '@/store/electron';
import { electronStylish } from '@/styles/electron';
import Connection from './Connection';
import { UpdateModal } from './UpdateModal';
import { UpdateNotification } from './UpdateNotification';
export const TITLE_BAR_HEIGHT = 36;
const TitleBar = memo(() => {
const initElectronAppState = useElectronStore((s) => s.useInitElectronAppState);
initElectronAppState();
return (
<Flexbox
align={'center'}
@@ -19,7 +28,12 @@ const TitleBar = memo(() => {
>
<div />
<div>{/* TODO */}</div>
<div>{/* TODO */}</div>
<Flexbox className={electronStylish.nodrag} gap={8} horizontal>
<UpdateNotification />
<Connection />
</Flexbox>
<UpdateModal />
</Flexbox>
);
});

View File

@@ -3,6 +3,7 @@ import { Book, Github } from 'lucide-react';
import Link from 'next/link';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { DOCUMENTS_REFER_URL, GITHUB } from '@/const/url';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
@@ -12,7 +13,7 @@ const BottomActions = memo(() => {
const { hideGitHub, hideDocs } = useServerConfigStore(featureFlagsSelectors);
return (
<>
<Flexbox gap={8}>
{!hideGitHub && (
<Link aria-label={'GitHub'} href={GITHUB} target={'_blank'}>
<ActionIcon icon={Github} placement={'right'} title={'GitHub'} />
@@ -23,7 +24,7 @@ const BottomActions = memo(() => {
<ActionIcon icon={Book} placement={'right'} title={t('document')} />
</Link>
)}
</>
</Flexbox>
);
});

View File

@@ -14,9 +14,9 @@ import { usePlatform } from '@/hooks/usePlatform';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { HotkeyScopeEnum } from '@/types/hotkey';
import TitleBar, { TITLE_BAR_HEIGHT } from './ElectronTitlebar';
import RegisterHotkeys from './RegisterHotkeys';
import SideBar from './SideBar';
import TitleBar, { TITLE_BAR_HEIGHT } from './Titlebar';
const CloudBanner = dynamic(() => import('@/features/AlertBanner/CloudBanner'));

View File

@@ -7,6 +7,7 @@ import { isRtlLang } from 'rtl-detect';
import Analytics from '@/components/Analytics';
import { DEFAULT_LANG } from '@/const/locale';
import { isDesktop } from '@/const/version';
import PWAInstall from '@/features/PWAInstall';
import AuthProvider from '@/layout/AuthProvider';
import GlobalProvider from '@/layout/GlobalProvider';
@@ -78,7 +79,7 @@ export const generateViewport = async (props: DynamicLayoutProps): ResolvingView
export const generateStaticParams = () => {
const themes: ThemeAppearance[] = ['dark', 'light'];
const mobileOptions = [true, false];
const mobileOptions = isDesktop ? [false] : [true, false];
// only static for serveral page, other go to dynamtic
const staticLocales: Locales[] = [DEFAULT_LANG, 'zh-CN'];

View File

@@ -0,0 +1,65 @@
import { createStyles } from 'antd-style';
import React from 'react';
import { Flexbox } from 'react-layout-kit';
import FileIcon from '@/components/FileIcon';
import { localFileService } from '@/services/electron/localFileService';
const useStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
padding-block: 2px;
padding-inline: 4px 8px;
border-radius: 4px;
color: ${token.colorTextSecondary};
:hover {
color: ${token.colorText};
background: ${token.colorFillTertiary};
}
`,
title: css`
overflow: hidden;
display: block;
line-height: 20px;
color: inherit;
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
const LocalFile = ({
name,
path,
isDirectory,
}: {
isDirectory: boolean;
name: string;
path: string;
}) => {
const { styles } = useStyles();
const handleClick = () => {
localFileService.openLocalFileOrFolder(path, isDirectory);
};
return (
<Flexbox
align={'center'}
className={styles.container}
gap={4}
horizontal
onClick={handleClick}
style={{ display: 'inline-flex', verticalAlign: 'middle' }}
>
<FileIcon fileName={name} isDirectory={isDirectory} size={22} variant={'pure'} />
<Flexbox align={'baseline'} gap={4} horizontal style={{ overflow: 'hidden', width: '100%' }}>
<div className={styles.title}>{name}</div>
</Flexbox>
</Flexbox>
);
};
export default LocalFile;

View File

@@ -0,0 +1,29 @@
import isEqual from 'fast-deep-equal';
import React, { memo } from 'react';
import { MarkdownElementProps } from '../../type';
import LocalFile from './LocalFile';
interface LocalFileProps {
isDirectory: boolean;
name: string;
path: string;
}
const Render = memo<MarkdownElementProps<LocalFileProps>>(({ node }) => {
// 从 node.properties 中提取属性
const { name, path, isDirectory } = node?.properties || {};
if (!name || !path) {
// 如果缺少必要属性,可以选择渲染错误提示或 null
console.error('LocalFile Render component missing required properties:', node?.properties);
return null; // 或者返回一个错误占位符
}
// isDirectory 属性可能为 true (来自插件) 或 undefined我们需要确保它是 boolean
const isDir = isDirectory === true;
return <LocalFile isDirectory={isDir} name={name} path={path} />;
}, isEqual);
export default Render;

View File

@@ -0,0 +1,16 @@
import { FC } from 'react';
import { createRemarkSelfClosingTagPlugin } from '../remarkPlugins/createRemarkSelfClosingTagPlugin';
import { MarkdownElement, MarkdownElementProps } from '../type';
import RenderComponent from './Render';
// 定义此元素的标签名
const tag = 'localFile';
const LocalFileElement: MarkdownElement = {
Component: RenderComponent as FC<MarkdownElementProps>,
remarkPlugin: createRemarkSelfClosingTagPlugin(tag),
tag,
};
export default LocalFileElement;

View File

@@ -1,6 +1,12 @@
import LobeArtifact from './LobeArtifact';
import LobeThinking from './LobeThinking';
import LocalFile from './LocalFile';
import Thinking from './Thinking';
import { MarkdownElement } from './type';
export const markdownElements: MarkdownElement[] = [Thinking, LobeArtifact, LobeThinking];
export const markdownElements: MarkdownElement[] = [
Thinking,
LobeArtifact,
LobeThinking,
LocalFile,
];

View File

@@ -0,0 +1,260 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`createRemarkSelfClosingTagPlugin > should handle tag within a list item and generate snapshot 1`] = `
{
"children": [
{
"children": [
{
"checked": null,
"children": [
{
"children": [
{
"position": {
"end": {
"column": 26,
"line": 2,
"offset": 26,
},
"start": {
"column": 4,
"line": 2,
"offset": 4,
},
},
"type": "text",
"value": "文件名:飞机全书 一部明晰可见的历史.pdf",
},
],
"position": {
"end": {
"column": 26,
"line": 2,
"offset": 26,
},
"start": {
"column": 4,
"line": 2,
"offset": 4,
},
},
"type": "paragraph",
},
{
"children": [
{
"checked": null,
"children": [
{
"children": [
{
"position": {
"end": {
"column": 10,
"line": 3,
"offset": 36,
},
"start": {
"column": 6,
"line": 3,
"offset": 32,
},
},
"type": "text",
"value": "路径1",
},
{
"data": {
"hName": "localFile",
"hProperties": {
"name": "飞机全书 一部明晰可见的历史.pdf",
"path": "/Users/abc/Zotero/storage/ASBMAURK/飞机全书 一部明晰可见的历史.pdf",
},
},
"type": "localFile",
},
],
"position": {
"end": {
"column": 110,
"line": 3,
"offset": 136,
},
"start": {
"column": 6,
"line": 3,
"offset": 32,
},
},
"type": "paragraph",
},
],
"position": {
"end": {
"column": 110,
"line": 3,
"offset": 136,
},
"start": {
"column": 4,
"line": 3,
"offset": 30,
},
},
"spread": false,
"type": "listItem",
},
{
"checked": null,
"children": [
{
"children": [
{
"position": {
"end": {
"column": 56,
"line": 4,
"offset": 192,
},
"start": {
"column": 6,
"line": 4,
"offset": 142,
},
},
"type": "text",
"value": "路径2/Users/abc/Downloads/测试 PDF/飞机全书 一部明晰可见的历史.pdf",
},
],
"position": {
"end": {
"column": 56,
"line": 4,
"offset": 192,
},
"start": {
"column": 6,
"line": 4,
"offset": 142,
},
},
"type": "paragraph",
},
],
"position": {
"end": {
"column": 56,
"line": 4,
"offset": 192,
},
"start": {
"column": 4,
"line": 4,
"offset": 140,
},
},
"spread": false,
"type": "listItem",
},
],
"ordered": false,
"position": {
"end": {
"column": 56,
"line": 4,
"offset": 192,
},
"start": {
"column": 4,
"line": 3,
"offset": 30,
},
},
"spread": false,
"start": null,
"type": "list",
},
],
"position": {
"end": {
"column": 56,
"line": 4,
"offset": 192,
},
"start": {
"column": 1,
"line": 2,
"offset": 1,
},
},
"spread": false,
"type": "listItem",
},
],
"ordered": true,
"position": {
"end": {
"column": 56,
"line": 4,
"offset": 192,
},
"start": {
"column": 1,
"line": 2,
"offset": 1,
},
},
"spread": false,
"start": 1,
"type": "list",
},
{
"children": [
{
"position": {
"end": {
"column": 75,
"line": 6,
"offset": 268,
},
"start": {
"column": 1,
"line": 6,
"offset": 194,
},
},
"type": "text",
"value": "这是一本 PDF 格式的书,并且在你的 Zotero 和 Downloads 文件夹里都能找到。如果需要进一步操作,比如阅读或者提取内容,可以告诉我",
},
],
"position": {
"end": {
"column": 75,
"line": 6,
"offset": 268,
},
"start": {
"column": 1,
"line": 6,
"offset": 194,
},
},
"type": "paragraph",
},
],
"position": {
"end": {
"column": 1,
"line": 7,
"offset": 269,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
`;

View File

@@ -0,0 +1,204 @@
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { describe, expect, it } from 'vitest';
import { createRemarkSelfClosingTagPlugin } from './createRemarkSelfClosingTagPlugin';
// Helper function to process markdown and get the resulting tree
const processMarkdown = (markdown: string, tagName: string) => {
const processor = unified().use(remarkParse).use(createRemarkSelfClosingTagPlugin(tagName));
const tree = processor.parse(markdown);
return processor.runSync(tree);
};
describe('createRemarkSelfClosingTagPlugin', () => {
const tagName = 'localFile';
it('should replace a single self-closing tag (parsed as HTML) with a custom node', () => {
const markdown = `<${tagName} name="test.txt" path="/path/to/test.txt" />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
const node = tree.children[0];
expect(node.type).toBe(tagName);
expect(node.data?.hProperties).toEqual({
name: 'test.txt',
path: '/path/to/test.txt',
});
expect(node.data?.hName).toBe(tagName);
});
it('should handle boolean attributes in a standalone tag', () => {
const markdown = `<${tagName} name="docs" path="/path/to/docs" isDirectory />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
const node = tree.children[0];
expect(node.type).toBe(tagName);
expect(node.data?.hProperties).toEqual({
name: 'docs',
path: '/path/to/docs',
isDirectory: true,
});
expect(node.data?.hName).toBe(tagName);
});
it('should handle tags surrounded by text (parsed within paragraph)', () => {
const markdown = `Here is a file: <${tagName} name="report.pdf" path="report.pdf" /> Please review.`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].type).toBe('paragraph');
const paragraphChildren = tree.children[0].children;
expect(paragraphChildren).toHaveLength(3);
expect(paragraphChildren[0].type).toBe('text');
expect(paragraphChildren[0].value).toBe('Here is a file: ');
const tagNode = paragraphChildren[1];
expect(tagNode.type).toBe(tagName);
expect(tagNode.data?.hProperties).toEqual({
name: 'report.pdf',
path: 'report.pdf',
});
expect(tagNode.data?.hName).toBe(tagName);
expect(paragraphChildren[2].type).toBe('text');
expect(paragraphChildren[2].value).toBe(' Please review.');
});
it('should handle multiple tags within the same text block', () => {
const markdown = `File 1: <${tagName} name="a.txt" path="a" /> and File 2: <${tagName} name="b.txt" path="b" isDirectory />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].type).toBe('paragraph');
const paragraphChildren = tree.children[0].children;
expect(paragraphChildren).toHaveLength(4);
expect(paragraphChildren[0].value).toBe('File 1: ');
const tagNode1 = paragraphChildren[1];
expect(tagNode1.type).toBe(tagName);
expect(tagNode1.data?.hProperties).toEqual({ name: 'a.txt', path: 'a' });
expect(tagNode1.data?.hName).toBe(tagName);
expect(paragraphChildren[2].value).toBe(' and File 2: ');
const tagNode2 = paragraphChildren[3];
expect(tagNode2.type).toBe(tagName);
expect(tagNode2.data?.hProperties).toEqual({
name: 'b.txt',
path: 'b',
isDirectory: true,
});
expect(tagNode2.data?.hName).toBe(tagName);
});
it('should handle standalone tags with no attributes', () => {
const markdown = `<${tagName} />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
const node = tree.children[0];
expect(node.type).toBe(tagName);
expect(node.data?.hProperties).toEqual({});
expect(node.data?.hName).toBe(tagName);
});
it('should ignore tags with different names (parsed within a paragraph)', () => {
const markdown = `<other_tag name="ignore_me" /> <${tagName} name="process_me" path="/p" />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].type).toBe('paragraph');
const paragraphChildren = tree.children[0].children;
expect(paragraphChildren).toHaveLength(2);
expect(paragraphChildren[0].type).toBe('text');
expect(paragraphChildren[0].value).toBe('<other_tag name="ignore_me" /> ');
const tagNode = paragraphChildren[1];
expect(tagNode.type).toBe(tagName);
expect(tagNode.data?.hProperties).toEqual({ name: 'process_me', path: '/p' });
expect(tagNode.data?.hName).toBe(tagName);
});
it('should not modify markdown without the target tag', () => {
const markdown = 'This is just regular text.';
const tree = processMarkdown(markdown, tagName);
const originalTree = unified().use(remarkParse).parse(markdown);
expect(tree).toEqual(originalTree);
});
it('should work with a different tag name provided to the creator', () => {
const otherTagName = 'customData';
const markdown = `Data: <${otherTagName} id="123" value="abc" active />`;
const tree = processMarkdown(markdown, otherTagName);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].type).toBe('paragraph');
const paragraphChildren = tree.children[0].children;
expect(paragraphChildren).toHaveLength(2);
expect(paragraphChildren[0].value).toBe('Data: ');
const tagNode = paragraphChildren[1];
expect(tagNode.type).toBe(otherTagName);
expect(tagNode.data?.hProperties).toEqual({ id: '123', value: 'abc', active: true });
expect(tagNode.data?.hName).toBe(otherTagName);
});
it('should handle tag at the beginning of the text', () => {
const markdown = `<${tagName} name="start.log" path="/logs/start.log" /> Log started.`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].type).toBe('paragraph');
const paragraphChildren = tree.children[0].children;
expect(paragraphChildren).toHaveLength(2);
const tagNode = paragraphChildren[0];
expect(tagNode.type).toBe(tagName);
expect(tagNode.data?.hProperties).toEqual({ name: 'start.log', path: '/logs/start.log' });
expect(tagNode.data?.hName).toBe(tagName);
expect(paragraphChildren[1].type).toBe('text');
expect(paragraphChildren[1].value).toBe(' Log started.');
});
it('should handle tag at the end of the text', () => {
const markdown = `Log ended: <${tagName} name="end.log" path="/logs/end.log" />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].type).toBe('paragraph');
const paragraphChildren = tree.children[0].children;
expect(paragraphChildren).toHaveLength(2);
expect(paragraphChildren[0].type).toBe('text');
expect(paragraphChildren[0].value).toBe('Log ended: ');
const tagNode = paragraphChildren[1];
expect(tagNode.type).toBe(tagName);
expect(tagNode.data?.hProperties).toEqual({ name: 'end.log', path: '/logs/end.log' });
expect(tagNode.data?.hName).toBe(tagName);
});
it('should handle tag within a list item and generate snapshot', () => {
const markdown = `
1. 文件名:飞机全书 一部明晰可见的历史.pdf
- 路径1<${tagName} name="飞机全书 一部明晰可见的历史.pdf" path="/Users/abc/Zotero/storage/ASBMAURK/飞机全书 一部明晰可见的历史.pdf" />
- 路径2/Users/abc/Downloads/测试 PDF/飞机全书 一部明晰可见的历史.pdf
这是一本 PDF 格式的书,并且在你的 Zotero 和 Downloads 文件夹里都能找到。如果需要进一步操作,比如阅读或者提取内容,可以告诉我
`;
const tree = processMarkdown(markdown, tagName);
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,133 @@
import debug from 'debug';
import type { Plugin } from 'unified';
import { SKIP, visit } from 'unist-util-visit';
// 创建 debugger 实例
const log = debug('lobe-markdown:remark-plugin:self-closing');
// Regex to parse attributes from a string
// Handles keys, keys with quoted values (double or single), and boolean keys
const attributeRegex = /([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
// Helper function to parse the attribute string into an object
const parseAttributes = (attributeString: string): Record<string, string | boolean> => {
const attributes: Record<string, string | boolean> = {};
let match;
while ((match = attributeRegex.exec(attributeString)) !== null) {
const [, key, valueDouble, valueSingle, valueUnquoted] = match;
// If any value group is captured, use it, otherwise treat as boolean true
attributes[key] = valueDouble ?? valueSingle ?? valueUnquoted ?? true;
}
return attributes;
};
export const createRemarkSelfClosingTagPlugin =
(tagName: string): Plugin<[], any> =>
() => {
// Regex for the specific tag, ensure it matches the entire string for HTML check
const exactTagRegex = new RegExp(`^<${tagName}(\\s+[^>]*?)?\\s*\\/>$`);
// Regex for finding tags within text
const textTagRegex = new RegExp(`<${tagName}(\\s+[^>]*?)?\\s*\\/>`, 'g');
return (tree) => {
// --- DEBUG LOG START (Before Visit) ---
log('Plugin execution start for tag: %s', tagName);
log('Tree: %o', tree);
log('Tree type: %s', tree?.type);
log('Tree children count: %d', tree?.children?.length);
if (!tree || !Array.isArray(tree.children)) {
log('ERROR: Invalid Tree Structure Detected Before Visit! %o', tree);
} else {
const hasUndefinedChild = tree.children.includes(undefined);
if (hasUndefinedChild) {
log('ERROR: Tree contains undefined children Before Visit!');
log(
'Children types: %o',
tree.children.map((c: any) => c?.type),
);
}
}
log('---------------------------------------------------');
// --- DEBUG LOG END (Before Visit) ---
// 1. Visit HTML nodes first for exact matches
// @ts-ignore
visit(tree, 'html', (node, index: number, parent) => {
log('>>> Visiting HTML node: %s', node.value);
const match = node.value.match(exactTagRegex);
if (match && parent && typeof index === 'number') {
const [, attributesString] = match;
const properties = attributesString ? parseAttributes(attributesString.trim()) : {};
const newNode = {
data: {
hName: tagName,
hProperties: properties,
},
type: tagName,
};
log('Replacing HTML node at index %d with %s node: %o', index, tagName, newNode);
parent.children.splice(index, 1, newNode);
return [SKIP, index + 1]; // Skip the node we just inserted
}
});
// 2. Visit Text nodes for inline matches
// @ts-ignore
visit(tree, 'text', (node: any, index: number, parent) => {
log('>>> Visiting Text node: "%s"', node.value);
if (!parent || typeof index !== 'number' || !node.value?.includes(`<${tagName}`)) {
return; // Quick exit if tag isn't possibly present
}
const text = node.value;
let lastIndex = 0;
const newChildren = [];
let match;
textTagRegex.lastIndex = 0; // Reset regex state
while ((match = textTagRegex.exec(text)) !== null) {
const [fullMatch, attributesString] = match;
const matchIndex = match.index;
// Add text before the match
if (matchIndex > lastIndex) {
newChildren.push({ type: 'text', value: text.slice(lastIndex, matchIndex) });
}
// Parse attributes and create the new node
const properties = attributesString ? parseAttributes(attributesString.trim()) : {};
newChildren.push({
data: {
hName: tagName,
hProperties: properties,
},
type: tagName,
});
lastIndex = matchIndex + fullMatch.length;
}
// If matches were found, replace the original text node
if (newChildren.length > 0) {
// Add any remaining text after the last match
if (lastIndex < text.length) {
newChildren.push({ type: 'text', value: text.slice(lastIndex) });
}
// --- DEBUG LOG START (Before Splice - Text Node) ---
log('--- Replacing Text Node Content ---');
log('Original text node index: %d', index);
log('-----------------------------------');
// --- DEBUG LOG END (Before Splice - Text Node) ---
parent.children.splice(index, 1, ...newChildren);
return [SKIP, index + newChildren.length]; // Skip new nodes
}
});
};
};

View File

@@ -1,8 +1,12 @@
import { FC, ReactNode } from 'react';
export interface MarkdownElementProps {
export interface MarkdownElementProps<T = any> {
children: ReactNode;
id: string;
node: {
properties: T;
};
tagName: string;
type: string;
}

View File

@@ -17,18 +17,54 @@ const electron = {
statusDisconnected: '未连接',
urlRequired: '请输入服务器地址',
},
sync: {
continue: '继续',
inCloud: '当前使用云端同步',
inLocalStorage: '当前使用本地存储',
isIniting: '正在初始化...',
lobehubCloud: {
description: '官方提供的云版本',
title: 'LobeHub Cloud',
},
local: {
description: '使用本地数据库,完全离线可用',
title: '本地数据库',
},
mode: {
cloudSync: '云端同步',
localStorage: '本地存储',
title: '选择你的连接模式',
useSelfHosted: '使用自托管实例?',
},
selfHosted: {
description: '自行部署的社区版本',
title: '自托管实例',
},
},
updater: {
checkingUpdate: '检查新版本',
checkingUpdateDesc: '正在获取版本信息...',
downloadNewVersion: '下载新版本',
downloadingUpdate: '正在下载更新',
downloadingUpdateDesc: '更新正在下载中,请稍候...',
installLater: '下次启动时更新',
isLatestVersion: '当前已是最新版本',
isLatestVersionDesc: '非常棒,使用的版本 {{version}} 已是最前沿的版本。',
later: '稍后更新',
newVersionAvailable: '新版本可用',
newVersionAvailableDesc: '发现新版本 {{version}},是否立即下载?',
restartAndInstall: '重启并安装',
restartAndInstall: '安装更新并重启',
updateError: '更新错误',
updateReady: '更新已就绪',
updateReadyDesc: 'Lobe Chat {{version}} 已下载完成,重启应用后即可完成安装。',
updateReadyDesc: '新版本 {{version}} 已下载完成,重启应用后即可完成安装。',
upgradeNow: '立即更新',
},
waitingOAuth: {
cancel: '取消',
description: '浏览器已打开授权页面,请在浏览器中完成授权',
helpText: '如果浏览器没有自动打开,请点击取消后重新尝试',
title: '等待授权连接',
},
};
export default electron;

View File

@@ -0,0 +1,36 @@
import { ElectronIpcClient } from '@lobechat/electron-server-ipc';
class LobeHubElectronIpcClient extends ElectronIpcClient {
// 获取数据库路径
getDatabasePath = async (): Promise<string> => {
return this.sendRequest<string>('getDatabasePath');
};
// 获取用户数据路径
getUserDataPath = async (): Promise<string> => {
return this.sendRequest<string>('getUserDataPath');
};
getDatabaseSchemaHash = async () => {
return this.sendRequest<string>('setDatabaseSchemaHash');
};
setDatabaseSchemaHash = async (hash: string | undefined) => {
if (!hash) return;
return this.sendRequest('setDatabaseSchemaHash', hash);
};
getFilePathById = async (id: string) => {
return this.sendRequest<string>('getStaticFilePath', id);
};
deleteFiles = async (paths: string[]) => {
return this.sendRequest<{ errors?: { message: string; path: string }[]; success: boolean }>(
'deleteFiles',
paths,
);
};
}
export const electronIpcClient = new LobeHubElectronIpcClient();

View File

@@ -97,14 +97,10 @@ export const sessionRouter = router({
}),
getGroupedSessions: publicProcedure.query(async ({ ctx }): Promise<ChatSessionList> => {
if (!ctx.userId)
return {
sessionGroups: [],
sessions: [],
};
if (!ctx.userId) return { sessionGroups: [], sessions: [] };
const serverDB = await getServerDB();
const sessionModel = new SessionModel(serverDB, ctx.userId);
const sessionModel = new SessionModel(serverDB, ctx.userId!);
return sessionModel.queryWithGroups();
}),

View File

@@ -1,11 +1,19 @@
import { isDesktop } from '@/const/version';
import { DesktopLocalFileImpl } from './local';
import { S3StaticFileImpl } from './s3';
import { FileServiceImpl } from './type';
/**
* 创建文件服务模块
* 根据环境自动选择使用S3或桌面本地文件实现
*/
export const createFileServiceModule = (): FileServiceImpl => {
// 默认使用 S3 实现
// 如果在桌面应用环境,使用本地文件实现
if (isDesktop) {
return new DesktopLocalFileImpl();
}
return new S3StaticFileImpl();
};

View File

@@ -0,0 +1,299 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
import { DesktopLocalFileImpl } from './local';
// 模拟依赖项
vi.mock('node:fs', async (importOriginal) => ({
...((await importOriginal()) as any),
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));
vi.mock('@/server/modules/ElectronIPCClient', () => ({
electronIpcClient: {
getFilePathById: vi.fn(),
deleteFiles: vi.fn(),
},
}));
describe('DesktopLocalFileImpl', () => {
let service: DesktopLocalFileImpl;
const testFilePath = '/path/to/file.txt';
const testFileKey = 'desktop://file.txt';
const testFileContent = 'test file content';
const testFileBuffer = Buffer.from(testFileContent);
beforeEach(() => {
service = new DesktopLocalFileImpl();
// 重置所有模拟
vi.resetAllMocks();
// 设置默认模拟行为
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue(testFilePath);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValueOnce(testFileBuffer);
});
describe('getLocalFileUrl', () => {
it.skip('应该正确获取本地文件URL并转换为data URL', async () => {
// 准备: readFileSync在第一次被调用时返回文件内容
vi.mocked(readFileSync).mockReturnValueOnce(testFileBuffer);
// 使用私有方法进行测试,通过原型访问
const result = await (service as any).getLocalFileUrl(testFileKey);
// 验证
expect(electronIpcClient.getFilePathById).toHaveBeenCalledWith(testFileKey);
expect(existsSync).toHaveBeenCalledWith(testFilePath);
expect(readFileSync).toHaveBeenCalledWith(testFilePath);
// 验证返回的data URL格式正确
expect(result).toContain('data:text/plain;base64,');
expect(result).toContain(testFileBuffer.toString('base64'));
});
it('当文件不存在时应返回原始键', async () => {
// 准备: 文件不存在
vi.mocked(existsSync).mockReturnValueOnce(false);
// 使用私有方法进行测试
const result = await (service as any).getLocalFileUrl(testFileKey);
// 验证
expect(result).toBe(testFileKey);
});
it('当发生错误时应返回空字符串', async () => {
// 准备: 模拟错误
vi.mocked(electronIpcClient.getFilePathById).mockRejectedValueOnce(new Error('测试错误'));
// 使用私有方法进行测试
const result = await (service as any).getLocalFileUrl(testFileKey);
// 验证
expect(result).toBe('');
});
});
describe('getMimeTypeFromPath', () => {
it('应该返回正确的MIME类型', () => {
// 使用私有方法进行测试
const jpgResult = (service as any).getMimeTypeFromPath('test.jpg');
const pngResult = (service as any).getMimeTypeFromPath('test.png');
const unknownResult = (service as any).getMimeTypeFromPath('test.unknown');
// 验证
expect(jpgResult).toBe('image/jpeg');
expect(pngResult).toBe('image/png');
expect(unknownResult).toBe('application/octet-stream');
});
});
describe('createPreSignedUrl', () => {
it('应该返回原始键', async () => {
const result = await service.createPreSignedUrl(testFileKey);
expect(result).toBe(testFileKey);
});
});
describe('createPreSignedUrlForPreview', () => {
it('应该调用getLocalFileUrl获取预览URL', async () => {
// 准备
const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
getLocalFileUrlSpy.mockResolvedValueOnce('data:text/plain;base64,dGVzdA==');
// 执行
const result = await service.createPreSignedUrlForPreview(testFileKey);
// 验证
expect(getLocalFileUrlSpy).toHaveBeenCalledWith(testFileKey);
expect(result).toBe('data:text/plain;base64,dGVzdA==');
});
});
describe('deleteFile', () => {
it('应该调用deleteFiles方法删除单个文件', async () => {
// 准备
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValueOnce({ success: true });
const deleteFilesSpy = vi.spyOn(service, 'deleteFiles');
// 执行
await service.deleteFile(testFileKey);
// 验证
expect(deleteFilesSpy).toHaveBeenCalledWith([testFileKey]);
});
});
describe('deleteFiles', () => {
it('应该成功删除有效的文件', async () => {
// 准备
const keys = ['desktop://file1.txt', 'desktop://file2.png'];
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValueOnce({ success: true });
// 执行
const result = await service.deleteFiles(keys);
// 验证
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith(keys);
expect(result).toEqual({ success: true });
});
it('当提供无效键时应返回错误', async () => {
// 准备: 包含无效的文件路径
const keys = ['invalid://file1.txt', 'desktop://file2.png'];
// 执行
const result = await service.deleteFiles(keys);
// 验证
expect(electronIpcClient.deleteFiles).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBe(1);
expect(result.errors![0].path).toBe('invalid://file1.txt');
});
it('当未提供键时应返回成功', async () => {
// 执行
const result = await service.deleteFiles([]);
// 验证
expect(electronIpcClient.deleteFiles).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('当删除过程中出现错误时应正确处理', async () => {
// 准备
const keys = ['desktop://file1.txt'];
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValueOnce(new Error('删除错误'));
// 执行
const result = await service.deleteFiles(keys);
// 验证
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors![0].message).toContain('删除错误');
});
});
describe.skip('getFileByteArray', () => {
it('应该返回文件的字节数组', async () => {
// 准备
vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from('测试内容'));
// 执行
const result = await service.getFileByteArray(testFileKey);
// 验证
expect(electronIpcClient.getFilePathById).toHaveBeenCalledWith(testFileKey);
expect(existsSync).toHaveBeenCalledWith(testFilePath);
expect(readFileSync).toHaveBeenCalledWith(testFilePath);
expect(result).toBeInstanceOf(Uint8Array);
expect(Buffer.from(result).toString()).toBe('测试内容');
});
it('当文件不存在时应返回空数组', async () => {
// 准备
vi.mocked(existsSync).mockReturnValueOnce(false);
// 执行
const result = await service.getFileByteArray(testFileKey);
// 验证
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(0);
});
it('当发生错误时应返回空数组', async () => {
// 准备
vi.mocked(electronIpcClient.getFilePathById).mockRejectedValueOnce(new Error('测试错误'));
// 执行
const result = await service.getFileByteArray(testFileKey);
// 验证
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(0);
});
});
describe.skip('getFileContent', () => {
it('应该返回文件内容', async () => {
// 准备
vi.mocked(readFileSync).mockReturnValueOnce('文件内容');
// 执行
const result = await service.getFileContent(testFileKey);
// 验证
expect(electronIpcClient.getFilePathById).toHaveBeenCalledWith(testFileKey);
expect(existsSync).toHaveBeenCalledWith(testFilePath);
expect(readFileSync).toHaveBeenCalledWith(testFilePath, 'utf8');
expect(result).toBe('文件内容');
});
it('当文件不存在时应返回空字符串', async () => {
// 准备
vi.mocked(existsSync).mockReturnValueOnce(false);
// 执行
const result = await service.getFileContent(testFileKey);
// 验证
expect(result).toBe('');
});
it('当发生错误时应返回空字符串', async () => {
// 准备
vi.mocked(electronIpcClient.getFilePathById).mockRejectedValueOnce(new Error('测试错误'));
// 执行
const result = await service.getFileContent(testFileKey);
// 验证
expect(result).toBe('');
});
});
describe('getFullFileUrl', () => {
it('应该调用getLocalFileUrl获取完整URL', async () => {
// 准备
const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
// 执行
const result = await service.getFullFileUrl(testFileKey);
// 验证
expect(getLocalFileUrlSpy).toHaveBeenCalledWith(testFileKey);
expect(result).toBe('data:image/png;base64,test');
});
it('当url为空时应返回空字符串', async () => {
// 执行
const result = await service.getFullFileUrl(null);
// 验证
expect(result).toBe('');
});
});
describe('uploadContent', () => {
it('应该正确处理上传内容的请求', async () => {
// 目前这个方法未实现,仅验证调用不会导致错误
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await service.uploadContent('path/to/file', 'content');
expect(consoleSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,183 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
import { FileServiceImpl } from './type';
/**
* 桌面应用本地文件服务实现
*/
export class DesktopLocalFileImpl implements FileServiceImpl {
/**
* 获取本地文件的URL
* Electron返回文件的绝对路径然后在服务端将文件转为base64
*/
private async getLocalFileUrl(key: string): Promise<string> {
try {
// 从Electron获取文件的绝对路径
const filePath = await electronIpcClient.getFilePathById(key);
// 检查文件是否存在
if (!existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
return key;
}
// 读取文件内容
const fileContent = readFileSync(filePath);
// 确定文件的MIME类型
const mimeType = this.getMimeTypeFromPath(filePath);
// 转换为base64并返回data URL
const base64 = fileContent.toString('base64');
return `data:${mimeType};base64,${base64}`;
} catch (e) {
console.error('[DesktopLocalFileImpl] Failed to process file from Electron IPC:', e);
return '';
}
}
/**
* 根据文件路径获取MIME类型
*/
private getMimeTypeFromPath(filePath: string): string {
const extension = path.extname(filePath).toLowerCase();
// 常见文件类型的MIME映射
const mimeTypes: Record<string, string> = {
'.css': 'text/css',
'.gif': 'image/gif',
'.html': 'text/html',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript',
'.json': 'application/json',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.webp': 'image/webp',
};
return mimeTypes[extension] || 'application/octet-stream';
}
/**
* 创建预签名上传URL本地版实际上是直接返回文件路径可能需要进一步扩展
*/
async createPreSignedUrl(key: string): Promise<string> {
// 在桌面应用本地文件实现中不需要预签名URL
// 直接返回文件路径
return key;
}
/**
* 创建预签名预览URL本地版是通过Electron获取本地文件URL
*/
async createPreSignedUrlForPreview(key: string): Promise<string> {
return this.getLocalFileUrl(key);
}
async deleteFile(key: string): Promise<any> {
return await this.deleteFiles([key]);
}
/**
* 批量删除文件
*/
async deleteFiles(keys: string[]): Promise<any> {
try {
if (!keys || keys.length === 0) return { success: true };
// 确保所有路径都是合法的desktop://路径
const invalidKeys = keys.filter((key) => !key.startsWith('desktop://'));
if (invalidKeys.length > 0) {
console.error('Invalid desktop file paths:', invalidKeys);
return {
errors: invalidKeys.map((key) => ({ message: 'Invalid desktop file path', path: key })),
success: false,
};
}
// 使用electronIpcClient的专用方法
return await electronIpcClient.deleteFiles(keys);
} catch (error) {
console.error('Failed to delete files:', error);
return {
errors: [
{
message: `Batch delete failed: ${(error as Error).message}`,
path: 'batch',
},
],
success: false,
};
}
}
/**
* 获取文件字节数组
*/
async getFileByteArray(key: string): Promise<Uint8Array> {
try {
// 从Electron获取文件的绝对路径
const filePath = await electronIpcClient.getFilePathById(key);
// 检查文件是否存在
if (!existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
return new Uint8Array();
}
// 读取文件内容并转换为Uint8Array
const buffer = readFileSync(filePath);
return new Uint8Array(buffer);
} catch (e) {
console.error('Failed to get file byte array:', e);
return new Uint8Array();
}
}
/**
* 获取文件内容
*/
async getFileContent(key: string): Promise<string> {
try {
// 从Electron获取文件的绝对路径
const filePath = await electronIpcClient.getFilePathById(key);
// 检查文件是否存在
if (!existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
return '';
}
// 读取文件内容并转换为字符串
return readFileSync(filePath, 'utf8');
} catch (e) {
console.error('Failed to get file content:', e);
return '';
}
}
/**
* 获取完整文件URL
*/
async getFullFileUrl(url?: string | null): Promise<string> {
if (!url) return '';
return this.getLocalFileUrl(url);
}
/**
* 上传内容
* 注意这个功能可能需要扩展Electron IPC接口
*/
async uploadContent(filePath: string, content: string): Promise<any> {
// 这里需要扩展electronIpcClient以支持上传文件内容
// 例如: return electronIpcClient.uploadContent(filePath, content);
console.warn('uploadContent not implemented for Desktop local file service', filePath, content);
return;
}
}

View File

@@ -1,5 +1,9 @@
import { isDesktop } from '@/const/version';
import { ClientService } from './client';
import { ServerService } from './server';
export const aiModelService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: new ClientService();

View File

@@ -1,5 +1,9 @@
import { isDesktop } from '@/const/version';
import { ClientService } from './client';
import { ServerService } from './server';
export const aiProviderService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: new ClientService();

View File

@@ -12,6 +12,10 @@ class AutoUpdateService {
installLater = async () => {
return dispatch('installLater');
};
downloadUpdate() {
return dispatch('downloadUpdate');
}
}
export const autoUpdateService = new AutoUpdateService();

View File

@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const fileService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;

View File

@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const messageService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;

View File

@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const pluginService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;

View File

@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const sessionService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;

View File

@@ -0,0 +1,15 @@
import { desktopClient } from '@/libs/trpc/client/desktop';
export class DesktopService {
getAllTables = async () => {
return desktopClient.pgTable.getAllTables.query();
};
getTableDetails = async (tableName: string) => {
return desktopClient.pgTable.getTableDetails.query({ tableName });
};
getTableData = async (tableName: string) => {
return desktopClient.pgTable.getTableData.query({ page: 1, pageSize: 300, tableName });
};
}

View File

@@ -1,3 +1,6 @@
import { ClientService } from './client';
import { isDesktop } from '@/const/version';
export const tableViewerService = new ClientService();
import { ClientService } from './client';
import { DesktopService } from './desktop';
export const tableViewerService = isDesktop ? new DesktopService() : new ClientService();

View File

@@ -1,5 +1,9 @@
import { isDesktop } from '@/const/version';
import { ClientService } from './client';
import { ServerService } from './server';
export const threadService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: new ClientService();

View File

@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const topicService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;

View File

@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,6 +8,8 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const userService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
export const userClientService = clientService;

View File

@@ -0,0 +1,59 @@
import { ElectronAppState } from '@lobechat/electron-client-ipc';
import { SWRResponse } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { useOnlyFetchOnceSWR } from '@/libs/swr';
// Import for type usage
import { electronSystemService } from '@/services/electron/system';
import { globalAgentContextManager } from '@/utils/client/GlobalAgentContextManager';
import { ElectronStore } from '../store';
// Import the new service
// ======== State ======== //
// Note: Actual state is defined in initialState.ts and ElectronState interface
// ======== Action Interface ======== //
export interface ElectronAppAction {
/**
* Initializes the basic Electron application state, including system info and special paths.
* Should be called once when the application starts.
*/
useInitElectronAppState: () => SWRResponse<ElectronAppState>;
}
// ======== Action Implementation ======== //
export const createElectronAppSlice: StateCreator<
ElectronStore,
[['zustand/devtools', never]],
[],
ElectronAppAction
> = (set) => ({
useInitElectronAppState: () =>
useOnlyFetchOnceSWR<ElectronAppState>(
'initElectronAppState',
async () => electronSystemService.getAppState(),
{
onSuccess: (result) => {
set({ appState: result }, false, 'initElectronAppState');
// Update the global agent context manager with relevant paths
// We typically only need paths in the agent context for now.
globalAgentContextManager.updateContext({
desktopPath: result.userPath!.desktop,
documentsPath: result.userPath!.documents,
downloadsPath: result.userPath!.downloads,
homePath: result.userPath!.home,
musicPath: result.userPath!.music,
picturesPath: result.userPath!.pictures,
userDataPath: result.userPath!.userData,
videosPath: result.userPath!.videos,
});
},
},
),
});

View File

@@ -109,8 +109,12 @@ export const remoteSyncSlice: StateCreator<
},
{
onSuccess: (data) => {
console.log('remote server config:', data);
set({ isInitRemoteServerConfig: true, remoteServerConfig: data });
get().refreshUserData();
if (data.active) {
get().refreshUserData();
}
},
},
),

View File

@@ -1,8 +1,9 @@
import { RemoteServerConfig } from '@lobechat/electron-client-ipc';
import { ElectronAppState, RemoteServerConfig } from '@lobechat/electron-client-ipc';
export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR';
export interface ElectronState {
appState: ElectronAppState;
isConnectingServer?: boolean;
isInitRemoteServerConfig: boolean;
isSyncActive?: boolean;
@@ -11,6 +12,7 @@ export interface ElectronState {
}
export const initialState: ElectronState = {
appState: {},
isConnectingServer: false,
isInitRemoteServerConfig: false,
isSyncActive: false,

View File

@@ -3,12 +3,16 @@ import { createWithEqualityFn } from 'zustand/traditional';
import { StateCreator } from 'zustand/vanilla';
import { createDevtools } from '../middleware/createDevtools';
import { type ElectronAppAction, createElectronAppSlice } from './actions/app';
import { type ElectronRemoteServerAction, remoteSyncSlice } from './actions/sync';
import { type ElectronState, initialState } from './initialState';
// =============== 聚合 createStoreFn ============ //
export interface ElectronStore extends ElectronState, ElectronRemoteServerAction {
export interface ElectronStore
extends ElectronState,
ElectronRemoteServerAction,
ElectronAppAction {
/* empty */
}
@@ -17,6 +21,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
) => ({
...initialState,
...remoteSyncSlice(...parameters),
...createElectronAppSlice(...parameters),
});
// =============== 实装 useStore ============ //

View File

@@ -0,0 +1,85 @@
export interface LobeGlobalAgentContext {
// Other potential context
currentTime?: string;
// App's data directory
// Paths commonly used by agents
desktopPath?: string;
documentsPath?: string;
downloadsPath?: string;
homePath?: string;
musicPath?: string;
picturesPath?: string; // User's home directory
userDataPath?: string;
videosPath?: string;
// Add other global context properties needed by agents here
}
// Augment the Window interface to include our global context
declare global {
interface Window {
__LOBE_GLOBAL_AGENT_CONTEXT__?: LobeGlobalAgentContext;
}
}
const CONTEXT_KEY = '__LOBE_GLOBAL_AGENT_CONTEXT__';
class GlobalAgentContextManager {
private get context(): LobeGlobalAgentContext {
if (typeof window === 'undefined') return {};
if (!window[CONTEXT_KEY]) {
window[CONTEXT_KEY] = {};
}
return window[CONTEXT_KEY]!;
}
private set context(value: LobeGlobalAgentContext) {
if (typeof window === 'undefined') return;
window[CONTEXT_KEY] = value;
}
/**
* Retrieves the current global agent context.
*/
public getContext(): LobeGlobalAgentContext {
return this.context;
}
/**
* Updates the global agent context by merging updates.
* This is typically called by the Electron store initializer.
* @param updates - Partial context updates to merge.
*/
public updateContext(updates: Partial<LobeGlobalAgentContext>): void {
this.context = { ...this.context, ...updates };
}
/**
* Sets the entire global agent context, replacing the existing one.
* @param context - The new context object.
*/
public setContext(context: LobeGlobalAgentContext): void {
this.context = context;
}
/**
* Fills a template string using the current global agent context.
* Replaces {{key}} placeholders with values from the context.
* @param template - The template string with placeholders.
* @returns The filled template string.
*/
public fillTemplate(template?: string): string {
const ctx = this.getContext();
if (!template) return '';
// Updated to use replaceAll for potentially multiple occurrences
return template.replaceAll(/{{([^}]+)}}/g, (match, key) => {
const trimmedKey = key.trim() as keyof LobeGlobalAgentContext;
return ctx[trimmedKey] !== undefined ? String(ctx[trimmedKey]) : '[N/A]';
});
}
}
// Export a singleton instance for global use
export const globalAgentContextManager = new GlobalAgentContextManager();

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { hydrationPrompt } from './promptTemplate';
describe('hydrationPrompt', () => {
it('should replace basic variables', () => {
const prompt = 'Hello {{name}}!';
const context = { name: 'World' };
expect(hydrationPrompt(prompt, context)).toBe('Hello World!');
});
it('should replace missing variables with an empty string', () => {
const prompt = 'Hello {{name}}! Your age is {{age}}.';
const context = { name: 'World' };
expect(hydrationPrompt(prompt, context)).toBe('Hello World! Your age is .');
});
it('should replace nested variables', () => {
const prompt = 'User: {{user.name}}, Role: {{user.role.name}}';
const context = { user: { name: 'Alice', role: { name: 'Admin' } } };
expect(hydrationPrompt(prompt, context)).toBe('User: Alice, Role: Admin');
});
it('should handle missing nested variables gracefully', () => {
const prompt = 'User: {{user.name}}, City: {{user.address.city}}';
const context = { user: { name: 'Bob' } };
expect(hydrationPrompt(prompt, context)).toBe('User: Bob, City: ');
});
it('should handle multiple variables, some missing', () => {
const prompt = '{{greeting}} {{user.name}}. Welcome to {{place}}. Your id is {{id}}';
const context = { greeting: 'Hi', user: { name: 'Charlie' } };
expect(hydrationPrompt(prompt, context)).toBe('Hi Charlie. Welcome to . Your id is ');
});
it('should handle empty context', () => {
const prompt = 'Hello {{name}}!';
const context = {};
expect(hydrationPrompt(prompt, context)).toBe('Hello !');
});
it('should handle empty prompt string', () => {
const prompt = '';
const context = { name: 'World' };
expect(hydrationPrompt(prompt, context)).toBe('');
});
it('should handle prompt with no variables', () => {
const prompt = 'This is a plain string.';
const context = { name: 'World' };
expect(hydrationPrompt(prompt, context)).toBe('This is a plain string.');
});
it('should handle different data types in context', () => {
const prompt = 'Count: {{count}}, Active: {{isActive}}, User: {{user}}';
const context = { count: 123, isActive: true, user: null };
// Note: null becomes "null" when converted to string
expect(hydrationPrompt(prompt, context)).toBe('Count: 123, Active: true, User: null');
});
it('should handle keys with leading/trailing whitespace', () => {
const prompt = 'Value: {{ spacedKey }}';
const context = { spacedKey: 'Trimmed' };
expect(hydrationPrompt(prompt, context)).toBe('Value: Trimmed');
});
it('should replace variables with undefined value with an empty string', () => {
const prompt = 'Name: {{name}}, Age: {{age}}';
const context = { name: 'Defined', age: undefined };
expect(hydrationPrompt(prompt, context)).toBe('Name: Defined, Age: ');
});
it('should handle complex nested structures and missing parts', () => {
const prompt = 'Data: {{a.b.c}}, Missing: {{x.y.z}}, Partial: {{a.b.d}}';
const context = { a: { b: { c: 'Found' } } };
expect(hydrationPrompt(prompt, context)).toBe('Data: Found, Missing: , Partial: ');
});
});

View File

@@ -0,0 +1,17 @@
import { get } from 'lodash-es';
export const hydrationPrompt = (prompt: string, context: any) => {
const regex = /{{([\S\s]+?)}}/g;
// Use String.prototype.replace with a replacer function
return prompt.replaceAll(regex, (match, key) => {
const trimmedKey = key.trim();
// Safely get the value from the context, including nested paths
const value = get(context, trimmedKey);
// If the value exists (is not undefined), convert it to string and return.
// Otherwise, return an empty string to replace the placeholder.
return value !== undefined ? String(value) : '';
});
};