mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🔨 chore: pre-merge desktop implement (#7516)
* update locale * update code
This commit is contained in:
80
.cursor/rules/desktop-local-tools-implement.mdc
Normal file
80
.cursor/rules/desktop-local-tools-implement.mdc
Normal 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 的插件系统中。
|
||||
@@ -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
|
||||
|
||||
@@ -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": "انتظار الاتصال بالتفويض"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Изчакване на авторизационна връзка"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "در انتظار اتصال مجوز"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "認証接続を待機中"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "인증 연결 대기 중"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Ожидание авторизации"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ı açı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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "等待授权连接"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "等待授權連接"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
src/server/modules/ElectronIPCClient/index.ts
Normal file
36
src/server/modules/ElectronIPCClient/index.ts
Normal 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();
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
299
src/server/services/file/impls/local.test.ts
Normal file
299
src/server/services/file/impls/local.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
183
src/server/services/file/impls/local.ts
Normal file
183
src/server/services/file/impls/local.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -12,6 +12,10 @@ class AutoUpdateService {
|
||||
installLater = async () => {
|
||||
return dispatch('installLater');
|
||||
};
|
||||
|
||||
downloadUpdate() {
|
||||
return dispatch('downloadUpdate');
|
||||
}
|
||||
}
|
||||
|
||||
export const autoUpdateService = new AutoUpdateService();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
src/services/tableViewer/desktop.ts
Normal file
15
src/services/tableViewer/desktop.ts
Normal 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 });
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
59
src/store/electron/actions/app.ts
Normal file
59
src/store/electron/actions/app.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ============ //
|
||||
|
||||
85
src/utils/client/GlobalAgentContextManager.ts
Normal file
85
src/utils/client/GlobalAgentContextManager.ts
Normal 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();
|
||||
78
src/utils/promptTemplate.test.ts
Normal file
78
src/utils/promptTemplate.test.ts
Normal 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: ');
|
||||
});
|
||||
});
|
||||
17
src/utils/promptTemplate.ts
Normal file
17
src/utils/promptTemplate.ts
Normal 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) : '';
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user