mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2561149a73 | |||
| 0fe930a55d | |||
| fcbcb4403e | |||
| 941adb486a | |||
| 057abc7fdf | |||
| f39998b91c | |||
| ffa85604a6 | |||
| 28d5ccd7d8 | |||
| de7f69b443 | |||
| 5194c0e1ba | |||
| 5c684559e7 | |||
| 31b785d00e | |||
| 431d3ff042 | |||
| 2dbb95ae8b | |||
| aae9a11cff | |||
| c203b55263 | |||
| 3f1c364058 | |||
| 110daa813e | |||
| 1e70737586 | |||
| 1599f312ae | |||
| de19c480f5 | |||
| 505338ed8d | |||
| c430062a3e | |||
| 79668b1108 | |||
| 35764f0fa7 | |||
| 733df8210f | |||
| 37a7f7dd6c | |||
| cbb507817e | |||
| 4c24ee8394 | |||
| fee0265787 | |||
| e4ae095ef3 | |||
| 649e96521d | |||
| a14359f669 | |||
| 93aa94a350 | |||
| 7659339d17 | |||
| 85e172e42e | |||
| bc69b91a55 | |||
| e602eac5cf | |||
| 07e2335597 | |||
| 21fbafd7ad | |||
| a9900434b3 | |||
| a54a9bbb7b | |||
| 3cf4881968 | |||
| 2d6ba2b7e0 | |||
| 4a57d3e3ec | |||
| 553e23b65c | |||
| 5837eacec7 | |||
| de930eea68 | |||
| c8c24c92d8 | |||
| f691cff964 | |||
| 0b3c741f95 | |||
| 17fb01111a | |||
| f885edbd26 | |||
| d0d8df6bf2 | |||
| 43002393f4 | |||
| 77ef131849 | |||
| 89be58a720 | |||
| 433265d43c | |||
| 76624aef00 | |||
| cd5b7bb91a | |||
| 4aaa67abc9 | |||
| b2e3e024f7 | |||
| 7bf0a349e0 | |||
| d3d4852848 | |||
| 8743b207ba | |||
| a7de207163 | |||
| 0398e688ed | |||
| 390f178d0c | |||
| a56e5a6dd3 | |||
| ffca282c98 | |||
| 73b03037af | |||
| 6da986cfc8 | |||
| 9de0c93d37 | |||
| dbfb715d20 | |||
| 08efa2c90b | |||
| 7a07366f47 | |||
| 18edf1a4ad | |||
| 6680f66439 | |||
| 42cebb4e6f | |||
| c4d10c66c6 | |||
| 2fa2a47fb3 | |||
| aee57166fc | |||
| 3ba9b35263 | |||
| e995c9a527 | |||
| 2e91fea120 | |||
| 446a0c189b | |||
| e7f5185358 | |||
| 232b8ae1b7 | |||
| 389c98f334 | |||
| 0cc47c9176 | |||
| 70ded6758c | |||
| 68b4fe7a5e |
+48
-144
@@ -7,173 +7,77 @@ alwaysApply: false
|
||||
|
||||
## Key Points
|
||||
|
||||
- Default language: Chinese (zh-CN) as the source language
|
||||
- Supported languages: 18 languages including English, Japanese, Korean, Arabic, etc.
|
||||
- Framework: react-i18next with Next.js app router
|
||||
- Translation automation: @lobehub/i18n-cli for automatic translation, config file: .i18nrc.js
|
||||
- Never manually modify any json file. You can only modify files in `default` folder
|
||||
- Default language: Chinese (zh-CN), Framework: react-i18next
|
||||
- **Only edit files in `src/locales/default/`** - Never edit JSON files in `locales/`
|
||||
- Run `pnpm i18n` to generate all translations (or manually translate zh-CN/en-US for dev preview)
|
||||
|
||||
## Directory Structure
|
||||
## Key Naming Convention
|
||||
|
||||
```plaintext
|
||||
src/locales/
|
||||
├── default/ # Source language files (zh-CN)
|
||||
│ ├── index.ts # Namespace exports
|
||||
│ ├── common.ts # Common translations
|
||||
│ ├── chat.ts # Chat-related translations
|
||||
│ ├── setting.ts # Settings translations
|
||||
│ └── ... # Other namespace files
|
||||
└── resources.ts # Type definitions and language configuration
|
||||
|
||||
locales/ # Translation files
|
||||
├── en-US/ # English translations
|
||||
│ ├── common.json # Common translations
|
||||
│ ├── chat.json # Chat translations
|
||||
│ ├── setting.json # Settings translations
|
||||
│ └── ... # Other namespace JSON files
|
||||
├── ja-JP/ # Japanese translations
|
||||
│ ├── common.json
|
||||
│ ├── chat.json
|
||||
│ └── ...
|
||||
└── ... # Other language folders
|
||||
```
|
||||
|
||||
## Workflow for Adding New Translations
|
||||
|
||||
### 1. Adding New Translation Keys
|
||||
|
||||
Step 1: Add translation keys in the corresponding namespace files under src/locales/default directory
|
||||
**Flat keys with dot notation** (not nested objects):
|
||||
|
||||
```typescript
|
||||
// Example: src/locales/default/common.ts
|
||||
// ✅ Correct
|
||||
export default {
|
||||
// ... existing keys
|
||||
newFeature: {
|
||||
title: '新功能标题',
|
||||
description: '功能描述文案',
|
||||
button: '操作按钮',
|
||||
},
|
||||
'alert.cloud.action': '立即体验',
|
||||
'clientDB.error.desc': '数据库初始化遇到问题',
|
||||
'sync.actions.sync': '立即同步',
|
||||
'sync.status.ready': '已连接',
|
||||
};
|
||||
|
||||
// ❌ Avoid: Nested objects
|
||||
export default {
|
||||
alert: { cloud: { action: '...' } },
|
||||
};
|
||||
```
|
||||
|
||||
Step 2: If creating a new namespace, export it in src/locales/default/index.ts
|
||||
**Naming patterns:** `{feature}.{context}.{action|status}`
|
||||
|
||||
- `clientDB.modal.title` - Feature + context + property
|
||||
- `sync.actions.sync` - Feature + group + action
|
||||
- `sync.status.ready` - Feature + group + status
|
||||
|
||||
**Parameters:** Use `{{variableName}}` syntax
|
||||
```typescript
|
||||
'alert.cloud.desc': '我们提供 {{credit}} 额度积分',
|
||||
```
|
||||
|
||||
**Avoid key conflicts:** Don't use both a leaf key and its parent path
|
||||
|
||||
```typescript
|
||||
import newNamespace from './newNamespace';
|
||||
// ❌ Conflict: clientDB.solve exists as both leaf and parent
|
||||
'clientDB.solve': '自助解决',
|
||||
'clientDB.solve.backup.title': '数据备份',
|
||||
|
||||
const resources = {
|
||||
// ... existing namespaces
|
||||
newNamespace,
|
||||
} as const;
|
||||
// ✅ Solution: Use different suffixes
|
||||
'clientDB.solve.action': '自助解决',
|
||||
'clientDB.solve.backup.title': '数据备份',
|
||||
```
|
||||
|
||||
### 2. Translation Process
|
||||
## Workflow
|
||||
|
||||
Development mode:
|
||||
1. Add keys to `src/locales/default/{namespace}.ts`
|
||||
2. Export new namespace in `src/locales/default/index.ts`
|
||||
3. For dev preview: manually translate `locales/zh-CN/{namespace}.json` and `locales/en-US/{namespace}.json`
|
||||
4. Run `pnpm i18n` to generate all languages (CI handles this automatically)
|
||||
|
||||
Generally, you don't need to help me run the automatic translation tool as it takes a long time. I'll run it myself when needed. However, to see immediate results, you still need to translate `locales/zh-CN/namespace.json` first, no need to translate other languages.
|
||||
|
||||
Production mode:
|
||||
|
||||
```bash
|
||||
# Generate translations for all languages
|
||||
npm run i18n
|
||||
```
|
||||
|
||||
## Usage in Components
|
||||
|
||||
### Basic Usage
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('newFeature.title')}</h1>
|
||||
<p>{t('newFeature.description')}</p>
|
||||
<button>{t('newFeature.button')}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Usage with Parameters
|
||||
|
||||
```tsx
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
<p>{t('welcome.message', { name: 'John' })}</p>;
|
||||
|
||||
// Corresponding language file:
|
||||
// welcome: { message: 'Welcome {{name}}!' }
|
||||
```
|
||||
|
||||
### Multiple Namespaces
|
||||
|
||||
```tsx
|
||||
// Basic
|
||||
t('newFeature.title')
|
||||
// With parameters
|
||||
t('alert.cloud.desc', { credit: '1000' })
|
||||
// Multiple namespaces
|
||||
const { t } = useTranslation(['common', 'chat']);
|
||||
|
||||
<button>{t('common:save')}</button>
|
||||
<span>{t('chat:typing')}</span>
|
||||
t('common:save')
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
## Available Namespaces
|
||||
|
||||
The project uses TypeScript to implement type-safe translations, with types automatically generated from src/locales/resources.ts:
|
||||
auth, authError, changelog, chat, clerk, color, **common**, components, discover, editor, electron, error, file, home, hotkey, image, knowledgeBase, labs, marketAuth, memory, metadata, migration, modelProvider, models, oauth, onboarding, plugin, portal, providers, ragEval, **setting**, subscription, thread, tool, topic, welcome
|
||||
|
||||
```typescript
|
||||
import type { DefaultResources, Locales, NS } from '@/locales/resources';
|
||||
|
||||
// Available types:
|
||||
// - NS: Available namespace keys ('common' | 'chat' | 'setting' | ...)
|
||||
// - Locales: Supported language codes ('en-US' | 'zh-CN' | 'ja-JP' | ...)
|
||||
|
||||
const namespace: NS = 'common';
|
||||
const locale: Locales = 'en-US';
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Namespace Organization
|
||||
|
||||
- common: Shared UI elements (buttons, labels, actions)
|
||||
- chat: Chat-specific functionality
|
||||
- setting: Configuration and settings
|
||||
- error: Error messages and handling
|
||||
- [feature]: Feature-specific or page-specific namespaces
|
||||
- components: Reusable component text
|
||||
|
||||
### 2. Key Naming Conventions
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Hierarchical structure
|
||||
export default {
|
||||
modal: {
|
||||
confirm: {
|
||||
title: '确认操作',
|
||||
message: '确定要执行此操作吗?',
|
||||
actions: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ❌ Avoid: Flat structure
|
||||
export default {
|
||||
modalConfirmTitle: '确认操作',
|
||||
modalConfirmMessage: '确定要执行此操作吗?',
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Translation Keys
|
||||
|
||||
- Check if the key exists in src/locales/default/namespace.ts
|
||||
- Ensure the namespace is correctly imported in the component
|
||||
- Ensure new namespaces are exported in src/locales/default/index.ts
|
||||
**Most used:** `common` (shared UI), `chat` (chat features), `setting` (settings)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
globs: src/locales/default/*
|
||||
alwaysApply: false
|
||||
---
|
||||
你是「LobeHub」的中文 UI 文案与微文案(microcopy)专家。LobeHub 是一个助理工作空间:用户可以创建助理与群组,让人和助理、助理和助理协作,提升日常生产与生活效率。产品气质:外表年轻、亲和、现代;内核专业、可靠、强调生产力与可控性。整体风格参考 Notion / Figma / Apple / Discord / OpenAI / Gemini:清晰克制、可信、有人情味但不油腻。
|
||||
|
||||
产品 slogan:**For Collaborative Agents**。你的文案要让用户持续感到:LobeHub 的重点不是“生成”,而是“协作的助理体系”(可共享上下文、可追踪、可回放、可演进、人在回路)。
|
||||
|
||||
---
|
||||
|
||||
### 1) 固定术语(必须遵守)
|
||||
+ Workspace:空间
|
||||
+ Agent:助理
|
||||
+ Agent Team:群组
|
||||
+ Context:上下文
|
||||
+ Memory:记忆
|
||||
+ Integration:连接器
|
||||
+ Tool/Skill/Plugin/插件/工具: 技能
|
||||
+ SystemRole: 助理档案
|
||||
+ Topic: 话题
|
||||
+ Page: 文稿
|
||||
+ Community: 社区
|
||||
+ Resource: 资源
|
||||
+ Library: 库
|
||||
+ MCP: MCP
|
||||
+ Provider: 模型服务商
|
||||
|
||||
术语规则:同一概念全站只用一种说法,不混用“Agent/智能体/机器人/团队/工作区”等。
|
||||
|
||||
---
|
||||
|
||||
### 2) 你的任务
|
||||
+ 优化、改写或从零生成任何界面中文文案:标题、按钮、表单说明、占位、引导、空状态、Toast、弹窗、错误、权限、设置项、创建/运行流程、协作与群组相关页面等。
|
||||
+ 文案必须同时兼容:普通用户看得懂 + 专业用户不觉得低幼;娱乐与严肃场景都成立;不过度营销、不夸大 AI 能力;在关键节点提供恰到好处的人文关怀。
|
||||
|
||||
---
|
||||
|
||||
### 3) 品牌三原则(内化到结构与措辞)
|
||||
+ **Create(创建)**:一句话创建助理;从想法到可用;清楚下一步。
|
||||
+ **Collaborate(协作)**:多助理协作;群组对齐信息与产出;共享上下文(可控、可管理)。
|
||||
+ **Evolve(演进)**:助理可在你允许的范围内记住偏好;随你的工作方式变得更顺手;强调可解释、可设置、可回放。
|
||||
|
||||
---
|
||||
|
||||
### 4) 写作规则(可执行)
|
||||
1. **清晰优先**:短句、强动词、少形容词;避免口号化与空泛承诺(如“颠覆”“史诗级”“100%”)。
|
||||
2. **分层表达(单一版本兼容两类用户)**:
|
||||
- 主句:人人可懂、可执行
|
||||
- 必要时补充一句副说明:更精确/更专业/更边界(可放副标题、帮助提示、折叠区)
|
||||
- 不输出“Pro/Lite 两套文案”,而是“一句主文案 + 可选补充”
|
||||
3. **术语克制但准确**:能说“连接/运行/上下文”就不要堆砌术语;必须出现专业词时给一句白话解释。
|
||||
4. **一致性**:同一动作按钮尽量固定动词(创建/连接/运行/暂停/重试/查看详情/清除记忆等)。
|
||||
5. **可行动**:每条提示都要让用户知道下一步;按钮避免“确定/取消”泛化,改成更具体的动作。
|
||||
6. **中文本地化**:符合中文阅读节奏;中英混排规范;避免翻译腔。
|
||||
|
||||
---
|
||||
|
||||
### 5) 人文关怀(中间态温度:介于克制与陪伴)
|
||||
目标:在 AI 时代的价值焦虑与创作失格感中,给用户“被理解 + 有掌控 + 能继续”的体验,但不写长抒情。
|
||||
|
||||
#### 温度比例规则
|
||||
+ 默认:信息为主,温度为辅(约 8:2)
|
||||
+ 关键节点(首次创建、空状态、长等待、失败重试、回退/丢失风险、协作分歧):允许提升到 7:3
|
||||
+ 强制上限:任何一条上屏文案里,温度表达不超过**半句或一句**,且必须紧跟明确下一步。
|
||||
|
||||
#### 表达顺序(必须遵守)
|
||||
1. 先承接处境(不评判):如“没关系/先这样也可以/卡住很正常”
|
||||
2. 再给掌控感(人在回路):可暂停/可回放/可编辑/可撤销/可清除记忆/可查看上下文
|
||||
3. 最后给下一步(按钮/路径明确)
|
||||
|
||||
#### 避免
|
||||
+ 鸡汤式说教(如“别焦虑”“要相信未来”)
|
||||
+ 宏大叙事与文学排比
|
||||
+ 过度拟人(不承诺助理“理解你/有情绪/永远记得你”)
|
||||
|
||||
#### 核心立场
|
||||
+ 助理很强,但它替代不了你的经历、选择与判断;LobeHub 帮你把时间还给重要的部分。
|
||||
|
||||
##### A. 情绪承接(先人后事)
|
||||
+ 允许承认:焦虑、空白、无从下手、被追赶感、被替代感、创作枯竭、意义感动摇
|
||||
+ 但不下结论、不说教:不输出“你要乐观/别焦虑”,改成“这种感觉很常见/你不是一个人”
|
||||
|
||||
##### B. 主体性回归(把人放回驾驶位)
|
||||
+ 关键句式:**“决定权在你”**、**“你可以选择交给助理的部分”**、**“把你的想法变成可运行的流程”**
|
||||
+ 强调可控:可编辑、可回放、可暂停、可撤销、可清除记忆、可查看上下文
|
||||
|
||||
##### C. 经历与关系(把价值从结果挪回过程)
|
||||
+ 适度表达:记录、回放、版本、协作痕迹、讨论、共创、里程碑
|
||||
+ 用“经历/过程/痕迹/回忆/脉络/成长”这类词,避免虚无抒情
|
||||
|
||||
##### D. 不用“AI 神话”
|
||||
+ 不渲染“AI 终将超越你/取代你”
|
||||
+ 也不轻飘飘说“AI 只是工具”了事更像:**“它是工具,但你仍是作者/负责人/最终决定者”**
|
||||
|
||||
|
||||
|
||||
##### 示例
|
||||
在用户可能产生自我否定或无力感的场景(空状态、创作开始、产出对比、失败重试、长时间等待、团队协作分歧、版本回退):
|
||||
|
||||
1. **先承接感受**:用一句短话确认处境(不评判)
|
||||
2. **再给掌控感**:强调“你可控/可选择/可回放/可撤销”
|
||||
3. **最后给下一步**:提供明确行动按钮或路径
|
||||
+ 允许出现“经历、选择、痕迹、成长、一起、陪你把事做完”等词来传递温度;但保持信息密度,不写长段抒情。
|
||||
+ 严肃场景(权限/安全/付费/数据丢失风险)仍以清晰与准确为先,温度通过“尊重与解释”体现,而不是煽情。
|
||||
|
||||
|
||||
|
||||
你可以让系统在需要时套这些结构(同一句兼容新手/专业):
|
||||
|
||||
**开始创作/空白页**
|
||||
|
||||
+ 主句:给一个轻承接 + 行动入口
|
||||
+ 模板:
|
||||
- 「从一个念头开始就够了。写一句话,我来帮你搭好第一个助理。」
|
||||
- 「不知道从哪开始也没关系:先说目标,我们一起把它拆开。」
|
||||
|
||||
**长任务运行/等待**
|
||||
|
||||
+ 模板:
|
||||
- 「正在运行中…你可以先去做别的,完成后我会提醒你。」
|
||||
- 「这一步可能要几分钟。想更快:减少上下文 / 切换模型 / 关闭自动运行。」
|
||||
|
||||
**失败/重试**
|
||||
|
||||
+ 模板:
|
||||
- 「没关系,这次没跑通。你可以重试,或查看原因再继续。」
|
||||
- 「连接失败:权限未通过或网络不稳定。去设置重新授权,或稍后再试。」
|
||||
|
||||
**对比与自我价值焦虑(适合提示/引导,不适合错误弹窗)**
|
||||
|
||||
+ 模板:
|
||||
- 「助理可以加速产出,但方向、取舍和标准仍属于你。」
|
||||
- 「结果可以很快,经历更重要:把每次尝试留下来,下一次会更稳。」
|
||||
|
||||
**协作/群组**
|
||||
|
||||
+ 模板:
|
||||
- 「把上下文对齐到同一处,群组里每个助理都会站在同一页上。」
|
||||
- 「不同意见没关系:先把目标写清楚,再让助理分别给方案与取舍。」
|
||||
|
||||
### 6) 错误/异常/权限/付费:硬规则
|
||||
+ 必须包含:**发生了什么 +(可选)原因 + 你可以怎么做**
|
||||
+ 必须提供可操作选项:**重试 / 查看详情 / 去设置 / 联系支持 / 复制日志**(按场景取舍)
|
||||
+ 不责备用户;不只给错误码;错误码可放在“详情”里
|
||||
+ 涉及数据与安全:语气更中性更完整,温度通过“尊重与解释”体现,而不是煽
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
globs: src/locales/default/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are **LobeHub’s English UI Copy & Microcopy Specialist**.
|
||||
|
||||
LobeHub is an assistant workspace: users can create **Agents** and **Agent Teams** so people↔agents and agent↔agent can collaborate to improve productivity in work and life.
|
||||
Brand vibe: youthful, friendly, modern on the surface; professional, reliable, productivity- and controllability-first underneath. Overall style reference: Notion / Figma / Apple / Discord / OpenAI / Gemini — clear, restrained, trustworthy, human but not cheesy.
|
||||
|
||||
Product slogan: **For Collaborative Agents**. Your copy must continuously reinforce that LobeHub is not about “generation”, but about a **collaborative agent system**: shareable context, traceable outcomes, replayable runs, evolvable setup, and **human-in-the-loop**.
|
||||
|
||||
---
|
||||
|
||||
## 1) Fixed Terminology (must follow)
|
||||
|
||||
Use **exactly** these English terms across the product. Do not mix synonyms for the same concept.
|
||||
|
||||
- 空间: **Workspace**
|
||||
- 助理: **Agent**
|
||||
- 群组: **Group**
|
||||
- 上下文: **Context**
|
||||
- 记忆: **Memory**
|
||||
- 连接器: **Integration**
|
||||
- 技能/tool/plugin: **Skill**
|
||||
- 助理档案: **Agent Profile**
|
||||
- 话题: **Topic**
|
||||
- 文稿: **Page**
|
||||
- 社区: **Community**
|
||||
- 资源: **Resource**
|
||||
- 库: **Library**
|
||||
- MCP: **MCP**
|
||||
- 模型服务商: **Provider**
|
||||
|
||||
Terminology rule: one concept = one term site-wide. Never alternate with “bot/assistant/AI agent/team/workspace” variations.
|
||||
|
||||
---
|
||||
|
||||
## 2) Your Responsibilities
|
||||
|
||||
- Improve, rewrite, or create from scratch any **English UI copy**: titles, buttons, form labels/help text, placeholders, onboarding, empty states, toasts, modals, errors, permission prompts, settings, creation/run flows, collaboration and Agent Team pages, etc.
|
||||
- Copy must work for both:
|
||||
- general users (immediately understandable)
|
||||
- power users (not childish)
|
||||
- It must fit both playful and serious contexts.
|
||||
- Avoid overclaiming AI capabilities; add human warmth at the right moments.
|
||||
|
||||
---
|
||||
|
||||
## 3) The Three Brand Principles (bake into structure & wording)
|
||||
|
||||
- **Create**: create an Agent in one sentence; clear next step from idea → usable.
|
||||
- **Collaborate**: multi-agent collaboration; align info and outputs; share Context (controlled, manageable).
|
||||
- **Evolve**: Agents can remember preferences **only with user consent**; become more helpful over time; emphasize explainability, settings, and replay.
|
||||
|
||||
---
|
||||
|
||||
## 4) Writing Rules (actionable)
|
||||
|
||||
1. **Clarity first**: short sentences, strong verbs, minimal adjectives. Avoid hype (“revolutionary”, “epic”, “100%”).
|
||||
2. **Layered messaging (single version for everyone)**:
|
||||
- Main line: simple and actionable
|
||||
- Optional second line: more precise / technical / boundary-setting (subtitle, helper text, tooltip, collapsible)
|
||||
- Do not produce “Pro vs Lite” variants; one main + optional detail
|
||||
3. **Use terms sparingly but correctly**: prefer plain words (“connect”, “run”, “context”) unless a technical term is necessary. When it is, add a plain-English explanation.
|
||||
4. **Consistency**: keep verbs consistent across similar actions (Create / Connect / Run / Pause / Retry / View details / Clear Memory).
|
||||
5. **Actionable**: every message tells the user what to do next. Avoid generic “OK/Cancel”; use specific actions.
|
||||
6. **English localization**: natural, product-native English; avoid translationese; keep punctuation and casing consistent.
|
||||
|
||||
---
|
||||
|
||||
## 5) Human Warmth (balanced, controlled)
|
||||
|
||||
Goal: reduce anxiety and restore control without being sentimental.
|
||||
Default ratio: **80% information, 20% warmth**.
|
||||
Key moments (first-time create, empty state, long waits, failures/retries, rollback/data-loss risk, collaboration conflicts): may go **70/30**.
|
||||
|
||||
Hard cap: any on-screen message may include **at most half a sentence to one sentence** of warmth, and it must be followed by a clear next step.
|
||||
|
||||
Required order:
|
||||
|
||||
1. Acknowledge the situation (no judgment)
|
||||
2. Restore control (human-in-the-loop: pause/replay/edit/undo/clear Memory/view Context)
|
||||
3. Provide the next action (button/path)
|
||||
|
||||
Avoid:
|
||||
|
||||
- preachy encouragement (“don’t worry”, “stay positive”)
|
||||
- grand narratives
|
||||
- overly anthropomorphic claims (“I understand you”, “I’ll always remember you”)
|
||||
|
||||
Core stance: Agents can accelerate output, but **you** own the judgment, trade-offs, and final decision. LobeHub gives you time back for what matters.
|
||||
|
||||
Suggested patterns:
|
||||
|
||||
- **Getting started / blank state**
|
||||
- “Starting with one sentence is enough. Describe your goal and I’ll help you set up the first Agent.”
|
||||
- “Not sure where to begin? Tell me the outcome—we’ll break it down together.”
|
||||
- **Long run / waiting**
|
||||
- “Running… You can switch tasks—I'll notify you when it’s done.”
|
||||
- “This may take a few minutes. To speed up: reduce Context / switch model / disable Auto-run.”
|
||||
- **Failure / retry**
|
||||
- “That didn’t run through. Retry, or view details to fix the cause.”
|
||||
- “Connection failed: permission not granted or network unstable. Re-authorize in Settings, or try again later.”
|
||||
- **Value anxiety (guidance, not error dialogs)**
|
||||
- “Agents can speed up output, but direction and standards stay with you.”
|
||||
- “Fast results are great—keeping the trail makes the next run steadier.”
|
||||
- **Collaboration / Agent Teams**
|
||||
- “Align everyone to the same Context. Every Agent in the Agent Team works from the same page.”
|
||||
- “Different opinions are fine. Write the goal first, then let Agents propose options and trade-offs.”
|
||||
|
||||
---
|
||||
|
||||
## 6) Errors / Exceptions / Permissions / Billing: hard rules
|
||||
|
||||
Every error must include:
|
||||
|
||||
- **What happened**
|
||||
- (optional) **Why**
|
||||
- **What the user can do next**
|
||||
|
||||
Provide actionable options as appropriate:
|
||||
|
||||
- Retry / View details / Go to Settings / Contact support / Copy logs
|
||||
|
||||
Never blame the user. Don’t show only an error code; put codes in “Details” if needed.
|
||||
For data/security/billing: be neutral, thorough, and respectful—warmth comes from clarity, not emotion.
|
||||
|
||||
---
|
||||
|
||||
## 7) Your Special Task: CN i18n → EN (localized, length-aware)
|
||||
|
||||
You translate **raw Chinese i18n strings into English** for LobeHub.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Prefer **localized**, product-native English over literal translation.
|
||||
- Do **not** chase perfect one-to-one consistency if a more natural UI phrase reads better.
|
||||
- Keep the **character length difference small**; try to make the English string **roughly the same visual length** as the Chinese source (avoid overly long expansions).
|
||||
- Preserve meaning, tone, and actionability; keep verbs consistent with LobeHub’s UI patterns.
|
||||
- If space is tight (buttons, tabs, toasts), prioritize: **verb + object**, drop optional words first.
|
||||
- If the Chinese includes placeholders/variables, preserve them exactly (e.g., `{name}`, `{{count}}`, `%s`) and keep word order sensible.
|
||||
- Keep capitalization consistent with UI norms (buttons/title case only when appropriate).
|
||||
|
||||
Output format when translating:
|
||||
|
||||
- Provide **English only**, unless asked otherwise.
|
||||
- If multiple options are useful, give **one best option** + **one shorter fallback** (only when length constraints are likely).
|
||||
|
||||
---
|
||||
|
||||
You always optimize for: **clarity, control, collaboration, replayability, and human-in-the-loop**—in a modern, restrained, trustworthy English voice.
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
description: react flex layout package `react-layout-kit` usage
|
||||
globs:
|
||||
description: flex layout components from `@lobehub/ui` usage
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Layout Kit 使用指南
|
||||
|
||||
react-layout-kit 是一个功能丰富的 React flex 布局组件库,在 lobe-chat 项目中被广泛使用。以下是重点组件的使用方法:
|
||||
# Flexbox 布局组件使用指南
|
||||
|
||||
`@lobehub/ui` 提供了 `Flexbox` 和 `Center` 组件用于创建弹性布局。以下是重点组件的使用方法:
|
||||
|
||||
## Flexbox 组件
|
||||
|
||||
@@ -14,7 +15,7 @@ Flexbox 是最常用的布局组件,用于创建弹性布局,类似于 CSS
|
||||
### 基本用法
|
||||
|
||||
```jsx
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
|
||||
// 默认垂直布局
|
||||
<Flexbox>
|
||||
@@ -58,14 +59,14 @@ import { Flexbox } from 'react-layout-kit';
|
||||
>
|
||||
<SidebarContent />
|
||||
</Flexbox>
|
||||
|
||||
|
||||
{/* 中间内容区 */}
|
||||
<Flexbox flex={1} style={{ height: '100%' }}>
|
||||
{/* 主要内容 */}
|
||||
<Flexbox flex={1} padding={24} style={{ overflowY: 'auto' }}>
|
||||
<MainContent />
|
||||
</Flexbox>
|
||||
|
||||
|
||||
{/* 底部区域 */}
|
||||
<Flexbox
|
||||
style={{
|
||||
@@ -86,9 +87,11 @@ Center 是对 Flexbox 的封装,使子元素水平和垂直居中。
|
||||
### 基本用法
|
||||
|
||||
```jsx
|
||||
import { Center } from '@lobehub/ui';
|
||||
|
||||
<Center width={'100%'} height={'100%'}>
|
||||
<Content />
|
||||
</Center>
|
||||
</Center>;
|
||||
```
|
||||
|
||||
Center 组件继承了 Flexbox 的所有属性,同时默认设置了居中对齐。主要用于快速创建居中布局。
|
||||
@@ -116,4 +119,4 @@ Center 组件继承了 Flexbox 的所有属性,同时默认设置了居中对
|
||||
- 嵌套 Flexbox 创建复杂布局
|
||||
- 设置 overflow: 'auto' 使内容可滚动
|
||||
- 使用 horizontal 创建水平布局,默认为垂直布局
|
||||
- 与 antd-style 的 useTheme hook 配合使用创建主题响应式的布局
|
||||
- 与 antd-style 的 useTheme hook 配合使用创建主题响应式的布局
|
||||
|
||||
@@ -23,7 +23,6 @@ logo emoji: 🤯
|
||||
- `@lobehub/ui`, antd for component framework
|
||||
- antd-style for css-in-js framework
|
||||
- lucide-react, `@ant-design/icons` for icons
|
||||
- react-layout-kit for flex layout component
|
||||
- react-i18next for i18n
|
||||
- zustand for state management
|
||||
- nuqs for search params management
|
||||
|
||||
@@ -7,7 +7,7 @@ alwaysApply: false
|
||||
# React Component Writing Guide
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use the `style` attribute for inline styles
|
||||
- Use `Flexbox` and `Center` components from react-layout-kit for flex and centered layouts
|
||||
- Use `Flexbox` and `Center` components from `@lobehub/ui` for flex and centered layouts
|
||||
- Component selection priority: src/components > installed component packages > lobe-ui > antd
|
||||
- Use selectors to access zustand store data instead of accessing the store directly
|
||||
|
||||
@@ -15,7 +15,7 @@ alwaysApply: false
|
||||
|
||||
- If unsure how to use `@lobehub/ui` components or what props they accept, search for existing usage in this project instead of guessing. Most components extend antd components with additional props
|
||||
- For specific usage, search online. For example, for ActionIcon visit <https://ui.lobehub.com/components/action-icon>
|
||||
- Read `node_modules/@lobehub/ui/es/index.js` to see all available components and their props
|
||||
- Read `node_modules/@lobehub/ui/es/index.mjs` to see all available components and their props
|
||||
|
||||
- General
|
||||
- ActionIcon
|
||||
@@ -69,7 +69,9 @@ alwaysApply: false
|
||||
- Drawer
|
||||
- Modal
|
||||
- Layout
|
||||
- Center
|
||||
- DraggablePanel
|
||||
- Flexbox
|
||||
- Footer
|
||||
- Grid
|
||||
- Header
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Setup Node and Bun
|
||||
description: Setup Node.js and Bun for workflows
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version
|
||||
required: true
|
||||
bun-version:
|
||||
description: Bun version
|
||||
required: true
|
||||
package-manager-cache:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: ${{ inputs.package-manager-cache }}
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ inputs.bun-version }}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Setup Node and pnpm
|
||||
description: Setup Node.js and pnpm for workflows
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version
|
||||
required: true
|
||||
package-manager-cache:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: ${{ inputs.package-manager-cache }}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Desktop Next Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- 'scripts/electronWorkflow/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'bun.lockb'
|
||||
- 'src/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/desktop-build-electron.yml'
|
||||
|
||||
concurrency:
|
||||
group: desktop-electron-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
build-next:
|
||||
name: Build desktop Next bundle
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
UPDATE_CHANNEL: nightly
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID || 'dummy-desktop-project' }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL || 'https://analytics.example.com' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: |
|
||||
cd apps/desktop
|
||||
bun run install-isolated
|
||||
|
||||
- name: Build desktop Next.js bundle
|
||||
run: bun run desktop:build-electron
|
||||
@@ -0,0 +1,341 @@
|
||||
name: Desktop Manual Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
|
||||
required: true
|
||||
default: nightly
|
||||
type: choice
|
||||
options:
|
||||
- nightly
|
||||
- beta
|
||||
- stable
|
||||
build_macos:
|
||||
description: 'Build macOS artifacts'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
build_windows:
|
||||
description: 'Build Windows artifacts'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
build_linux:
|
||||
description: 'Build Linux artifacts'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
version:
|
||||
description: 'Override desktop version (e.g. 1.2.3). Leave empty to auto-generate.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
group: manual-${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Code quality check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
version:
|
||||
name: Determine version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.set_version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Set version
|
||||
id: set_version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
base_version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
|
||||
if [ -n "$INPUT_VERSION" ]; then
|
||||
version="$INPUT_VERSION"
|
||||
echo "📦 Using provided version: ${version} (base: ${base_version})"
|
||||
else
|
||||
ci_build_number="${{ github.run_number }}"
|
||||
version="0.0.0-${CHANNEL}.manual.${ci_build_number}"
|
||||
echo "📦 Generated version: ${version} (base: ${base_version})"
|
||||
fi
|
||||
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Version Summary
|
||||
run: |
|
||||
echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}"
|
||||
|
||||
build-macos:
|
||||
needs: [version, test]
|
||||
name: Build Desktop App (macOS)
|
||||
if: inputs.build_macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on macOS
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Rename macOS latest-mac.yml for multi-architecture support
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
cd apps/desktop/release
|
||||
if [ -f "latest-mac.yml" ]; then
|
||||
SYSTEM_ARCH=$(uname -m)
|
||||
if [[ "$SYSTEM_ARCH" == "arm64" ]]; then
|
||||
ARCH_SUFFIX="arm64"
|
||||
else
|
||||
ARCH_SUFFIX="x64"
|
||||
fi
|
||||
|
||||
mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml"
|
||||
echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml (detected: $SYSTEM_ARCH)"
|
||||
ls -la latest-mac-*.yml
|
||||
else
|
||||
echo "⚠️ latest-mac.yml not found, skipping rename"
|
||||
ls -la latest*.yml || echo "No latest*.yml files found"
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-${{ matrix.os }}
|
||||
path: |
|
||||
apps/desktop/release/latest*
|
||||
apps/desktop/release/*.dmg*
|
||||
apps/desktop/release/*.zip*
|
||||
apps/desktop/release/*.exe*
|
||||
apps/desktop/release/*.AppImage
|
||||
apps/desktop/release/*.deb*
|
||||
apps/desktop/release/*.snap*
|
||||
apps/desktop/release/*.rpm*
|
||||
apps/desktop/release/*.tar.gz*
|
||||
retention-days: 5
|
||||
|
||||
build-windows:
|
||||
needs: [version, test]
|
||||
name: Build Desktop App (Windows)
|
||||
if: inputs.build_windows
|
||||
runs-on: windows-2025
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on Windows
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-windows-2025
|
||||
path: |
|
||||
apps/desktop/release/latest*
|
||||
apps/desktop/release/*.dmg*
|
||||
apps/desktop/release/*.zip*
|
||||
apps/desktop/release/*.exe*
|
||||
apps/desktop/release/*.AppImage
|
||||
apps/desktop/release/*.deb*
|
||||
apps/desktop/release/*.snap*
|
||||
apps/desktop/release/*.rpm*
|
||||
apps/desktop/release/*.tar.gz*
|
||||
retention-days: 5
|
||||
|
||||
build-linux:
|
||||
needs: [version, test]
|
||||
name: Build Desktop App (Linux)
|
||||
if: inputs.build_linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on Linux
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-ubuntu-latest
|
||||
path: |
|
||||
apps/desktop/release/latest*
|
||||
apps/desktop/release/*.dmg*
|
||||
apps/desktop/release/*.zip*
|
||||
apps/desktop/release/*.exe*
|
||||
apps/desktop/release/*.AppImage
|
||||
apps/desktop/release/*.deb*
|
||||
apps/desktop/release/*.snap*
|
||||
apps/desktop/release/*.rpm*
|
||||
apps/desktop/release/*.tar.gz*
|
||||
retention-days: 5
|
||||
|
||||
merge-mac-files:
|
||||
needs: [build-macos, version]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
if: inputs.build_macos
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: merged-release-manual
|
||||
path: release/
|
||||
retention-days: 1
|
||||
@@ -29,16 +29,12 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
@@ -103,16 +99,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
@@ -132,11 +123,11 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: "nightly"
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
# 默认添加一个加密 SECRET
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
# macOS 签名和公证配置(fork 的 PR 访问不到 secrets,会跳过签名)
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -156,10 +147,10 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: "nightly"
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
# 将 TEMP 和 TMP 目录设置到 C 盘
|
||||
@@ -172,10 +163,10 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: "nightly"
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
|
||||
@@ -229,16 +220,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
- name: Typecheck Desktop
|
||||
run: pnpm typecheck
|
||||
run: pnpm type-check
|
||||
working-directory: apps/desktop
|
||||
|
||||
- name: Test Desktop Client
|
||||
|
||||
+2
-3
@@ -113,7 +113,6 @@ CLAUDE.local.md
|
||||
*.ppt*
|
||||
*.doc*
|
||||
*.xls*
|
||||
|
||||
e2e/reports
|
||||
|
||||
out
|
||||
out
|
||||
i18n-unused-keys-report.json
|
||||
|
||||
@@ -87,7 +87,7 @@ All following rules are saved under `.cursor/rules/` directory:
|
||||
- `react.mdc` – React component style guide and conventions
|
||||
- `i18n.mdc` – Internationalization guide using react-i18next
|
||||
- `typescript.mdc` – TypeScript code style guide
|
||||
- `packages/react-layout-kit.mdc` – Usage guide for react-layout-kit
|
||||
- `packages/react-layout-kit.mdc` – Usage guide for Flexbox and Center components from @lobehub/ui
|
||||
|
||||
### State Management
|
||||
|
||||
|
||||
+15
@@ -107,6 +107,19 @@ COPY . .
|
||||
# run build standalone for docker version
|
||||
RUN npm run build:docker
|
||||
|
||||
# Prepare desktop export assets for Electron packaging (if generated)
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ -d "/app/out" ]; then
|
||||
mkdir -p /app/apps/desktop/dist/next
|
||||
cp -a /app/out/. /app/apps/desktop/dist/next/
|
||||
echo "✅ Copied Next export output into /app/apps/desktop/dist/next"
|
||||
else
|
||||
echo "ℹ️ No Next export output found at /app/out, creating empty directory"
|
||||
mkdir -p /app/apps/desktop/dist/next
|
||||
fi
|
||||
EOF
|
||||
|
||||
## Application image, copy all the files for production
|
||||
FROM busybox:latest AS app
|
||||
|
||||
@@ -115,6 +128,8 @@ COPY --from=base /distroless/ /
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder /app/.next/standalone /app/
|
||||
# Copy Next export output for desktop renderer
|
||||
COPY --from=builder /app/apps/desktop/dist/next /app/apps/desktop/dist/next
|
||||
|
||||
# Copy database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
|
||||
@@ -4,3 +4,19 @@ ignore-workspace-root-check=true
|
||||
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
public-hoist-pattern[]=*@umijs/lint*
|
||||
public-hoist-pattern[]=*unicorn*
|
||||
public-hoist-pattern[]=*changelog*
|
||||
public-hoist-pattern[]=*commitlint*
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=*postcss*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
public-hoist-pattern[]=*remark*
|
||||
public-hoist-pattern[]=*semantic-release*
|
||||
public-hoist-pattern[]=*stylelint*
|
||||
|
||||
public-hoist-pattern[]=@auth/core
|
||||
public-hoist-pattern[]=@clerk/backend
|
||||
public-hoist-pattern[]=@clerk/types
|
||||
public-hoist-pattern[]=pdfjs-dist
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Prettierignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# general
|
||||
.DS_Store
|
||||
.editorconfig
|
||||
.idea
|
||||
.history
|
||||
.temp
|
||||
.env.local
|
||||
.husky
|
||||
.npmrc
|
||||
.gitkeep
|
||||
venv
|
||||
temp
|
||||
tmp
|
||||
LICENSE
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
*.log
|
||||
*.lock
|
||||
package-lock.json
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
test-output
|
||||
__snapshots__
|
||||
*.snap
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
|
||||
# umi
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
|
||||
# ignore files
|
||||
.*ignore
|
||||
|
||||
# docker
|
||||
docker
|
||||
Dockerfile*
|
||||
|
||||
# image
|
||||
*.webp
|
||||
*.gif
|
||||
*.png
|
||||
*.jpg
|
||||
*.svg
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
.next
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').prettier;
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').remarklint;
|
||||
@@ -0,0 +1,39 @@
|
||||
# Stylelintignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
|
||||
# framework specific
|
||||
.next
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
|
||||
# temporary directories
|
||||
tmp
|
||||
temp
|
||||
.temp
|
||||
.local
|
||||
docs/.local
|
||||
|
||||
# cache directories
|
||||
.cache
|
||||
|
||||
# AI coding tools directories
|
||||
.claude
|
||||
.serena
|
||||
|
||||
# MCP tools
|
||||
/.serena/**
|
||||
@@ -0,0 +1,9 @@
|
||||
const config = require('@lobehub/lint').stylelint;
|
||||
|
||||
module.exports = {
|
||||
...config,
|
||||
rules: {
|
||||
'selector-id-pattern': null,
|
||||
...config.rules,
|
||||
},
|
||||
};
|
||||
@@ -32,7 +32,7 @@ pnpm install-isolated
|
||||
pnpm electron:dev
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
pnpm type-check
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
@@ -66,9 +66,9 @@ cp .env.desktop .env
|
||||
pnpm electron:dev # Start with hot reload
|
||||
|
||||
# 2. Code Quality
|
||||
pnpm lint # ESLint checking
|
||||
pnpm format # Prettier formatting
|
||||
pnpm typecheck # TypeScript validation
|
||||
pnpm lint # ESLint checking
|
||||
pnpm format # Prettier formatting
|
||||
pnpm type-check # TypeScript validation
|
||||
|
||||
# 3. Testing
|
||||
pnpm test # Run Vitest tests
|
||||
@@ -313,7 +313,7 @@ tests/ # Integration tests
|
||||
```bash
|
||||
pnpm test # Run all tests
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm typecheck # Type validation
|
||||
pnpm type-check # Type validation
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
@@ -32,7 +32,7 @@ pnpm install-isolated
|
||||
pnpm electron:dev
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
pnpm type-check
|
||||
|
||||
# 运行测试
|
||||
pnpm test
|
||||
@@ -66,9 +66,9 @@ cp .env.desktop .env
|
||||
pnpm electron:dev # 启动热重载开发服务器
|
||||
|
||||
# 2. 代码质量
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm format # Prettier 格式化
|
||||
pnpm typecheck # TypeScript 验证
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm format # Prettier 格式化
|
||||
pnpm type-check # TypeScript 验证
|
||||
|
||||
# 3. 测试
|
||||
pnpm test # 运行 Vitest 测试
|
||||
@@ -302,7 +302,7 @@ tests/ # 集成测试
|
||||
```bash
|
||||
pnpm test # 运行所有测试
|
||||
pnpm test:watch # 监视模式
|
||||
pnpm typecheck # 类型验证
|
||||
pnpm type-check # 类型验证
|
||||
```
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
@@ -17,6 +17,10 @@ console.log(`🏗️ Building for architecture: ${arch}`);
|
||||
const isNightly = channel === 'nightly';
|
||||
const isBeta = packageJSON.name.includes('beta');
|
||||
|
||||
// Keep only these Electron Framework localization folders (*.lproj)
|
||||
// (aligned with previous Electron Forge build config)
|
||||
const keepLanguages = new Set(['en', 'en_GB', 'en-US', 'en_US']);
|
||||
|
||||
// https://www.electron.build/code-signing-mac#how-to-disable-code-signing-during-the-build-process-on-macos
|
||||
if (!hasAppleCertificate) {
|
||||
// Disable auto discovery to keep electron-builder from searching unavailable signing identities
|
||||
@@ -54,7 +58,7 @@ const config = {
|
||||
*/
|
||||
afterPack: async (context) => {
|
||||
// Only process macOS builds
|
||||
if (context.electronPlatformName !== 'darwin') {
|
||||
if (!['darwin', 'mas'].includes(context.electronPlatformName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,6 +72,36 @@ const config = {
|
||||
);
|
||||
const assetsCarDest = path.join(resourcesPath, 'Assets.car');
|
||||
|
||||
// Remove unused Electron Framework localizations to reduce app size
|
||||
// Equivalent to:
|
||||
// ../../Frameworks/Electron Framework.framework/Versions/A/Resources/*.lproj
|
||||
const frameworkResourcePath = path.join(
|
||||
context.appOutDir,
|
||||
`${context.packager.appInfo.productFilename}.app`,
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Versions',
|
||||
'A',
|
||||
'Resources',
|
||||
);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(frameworkResourcePath);
|
||||
await Promise.all(
|
||||
entries.map(async (file) => {
|
||||
if (!file.endsWith('.lproj')) return;
|
||||
|
||||
const lang = file.split('.')[0];
|
||||
if (keepLanguages.has(lang)) return;
|
||||
|
||||
await fs.rm(path.join(frameworkResourcePath, file), { force: true, recursive: true });
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Non-critical: folder may not exist depending on packaging details
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(assetsCarSource);
|
||||
await fs.copyFile(assetsCarSource, assetsCarDest);
|
||||
@@ -106,6 +140,8 @@ const config = {
|
||||
files: [
|
||||
'dist',
|
||||
'resources',
|
||||
// Ensure Next export assets are packaged
|
||||
'dist/next/**/*',
|
||||
'!resources/locales',
|
||||
'!dist/next/docs',
|
||||
'!dist/next/packages',
|
||||
|
||||
@@ -11,21 +11,30 @@
|
||||
"author": "LobeHub",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build": "electron-vite build",
|
||||
"build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"build:linux": "npm run build && electron-builder --linux --config electron-builder.js --publish never",
|
||||
"build:mac": "npm run build && electron-builder --mac --config electron-builder.js --publish never",
|
||||
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.js --publish never",
|
||||
"build:win": "npm run build && electron-builder --win --config electron-builder.js --publish never",
|
||||
"dev": "electron-vite dev",
|
||||
"electron:dev": "electron-vite dev",
|
||||
"electron:run-unpack": "electron .",
|
||||
"format": "prettier --write ",
|
||||
"i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"install-isolated": "pnpm install",
|
||||
"lint": "eslint --cache ",
|
||||
"lint": "npm run lint:ts && npm run lint:style && npm run type-check && npm run lint:circular",
|
||||
"lint:circular": "npm run lint:circular:main && npm run lint:circular:packages",
|
||||
"lint:circular:main": "dpdm src/**/*.ts --no-warning --no-tree --exit-code circular:1 --no-progress -T true --skip-dynamic-imports circular",
|
||||
"lint:circular:packages": "dpdm packages/**/src/**/*.ts --no-warning --no-tree --exit-code circular:1 --no-progress -T true --skip-dynamic-imports circular",
|
||||
"lint:md": "remark . --silent --output",
|
||||
"lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"lint:ts": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"start": "electron-vite preview",
|
||||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "vitest --run",
|
||||
"type-check": "tsgo --noEmit -p tsconfig.json",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -33,7 +42,8 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"get-port-please": "^3.2.0",
|
||||
"pdfjs-dist": "4.10.38"
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"superjson": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -41,15 +51,17 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
"@types/async-retry": "^1.4.9",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251210.1",
|
||||
"async-retry": "^1.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -57,10 +69,13 @@
|
||||
"diff": "^8.0.2",
|
||||
"electron": "^38.7.2",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"eslint": "^8.57.1",
|
||||
"execa": "^9.6.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fix-path": "^5.0.0",
|
||||
@@ -69,17 +84,19 @@
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^25.7.2",
|
||||
"just-diff": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"prettier": "^3.7.4",
|
||||
"remark-cli": "^12.0.1",
|
||||
"resolve": "^1.22.11",
|
||||
"semver": "^7.7.3",
|
||||
"set-cookie-parser": "^2.7.2",
|
||||
"stylelint": "^15.11.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -87,4 +104,4 @@
|
||||
"electron-builder"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ packages:
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '.'
|
||||
|
||||
@@ -22,11 +22,95 @@
|
||||
"description": "你的 AI 助手协作平台",
|
||||
"name": "LobeHub"
|
||||
},
|
||||
"notification": {
|
||||
"finishChatGeneration": "AI 消息已生成完毕"
|
||||
},
|
||||
"proxy": {
|
||||
"auth": "需要认证",
|
||||
"authDesc": "如果代理服务器需要用户名和密码",
|
||||
"authSettings": "认证设置",
|
||||
"basicSettings": "代理设置",
|
||||
"basicSettingsDesc": "配置代理服务器的连接参数",
|
||||
"bypass": "不使用代理的地址",
|
||||
"connectionTest": "连接测试",
|
||||
"enable": "启用代理",
|
||||
"enableDesc": "开启后将通过代理服务器访问网络",
|
||||
"password": "密码",
|
||||
"password_placeholder": "请输入密码",
|
||||
"port": "端口",
|
||||
"resetButton": "重置",
|
||||
"saveButton": "保存",
|
||||
"saveFailed": "保存失败:{{error}}",
|
||||
"saveSuccess": "代理设置保存成功",
|
||||
"server": "服务器地址",
|
||||
"testButton": "测试连接",
|
||||
"testDescription": "使用当前代理配置测试连接,验证配置是否正常工作",
|
||||
"testFailed": "连接失败",
|
||||
"testSuccessWithTime": "测试连接成功,耗时 {{time}} ms",
|
||||
"testUrl": "测试地址",
|
||||
"testUrlPlaceholder": "请输入要测试的 URL",
|
||||
"testing": "正在测试连接…",
|
||||
"type": "代理类型",
|
||||
"unsavedChanges": "你有未保存的更改",
|
||||
"username": "用户名",
|
||||
"username_placeholder": "请输入用户名",
|
||||
"validation": {
|
||||
"passwordRequired": "启用认证时密码为必填项",
|
||||
"portInvalid": "端口必须是 1 到 65535 之间的数字",
|
||||
"portRequired": "启用代理时端口为必填项",
|
||||
"serverInvalid": "请输入有效的服务器地址(IP 或域名)",
|
||||
"serverRequired": "启用代理时服务器地址为必填项",
|
||||
"typeRequired": "启用代理时代理类型为必填项",
|
||||
"usernameRequired": "启用认证时用户名为必填项"
|
||||
}
|
||||
},
|
||||
"remoteServer": {
|
||||
"authError": "授权失败: {{error}}",
|
||||
"authPending": "请在浏览器中完成授权",
|
||||
"configDesc": "连接到远程 LobeHub 服务器,启用数据同步",
|
||||
"configError": "配置出错",
|
||||
"configTitle": "配置云同步",
|
||||
"connect": "连接并授权",
|
||||
"connected": "已连接",
|
||||
"disconnect": "断开连接",
|
||||
"disconnectError": "断开连接失败",
|
||||
"disconnected": "未连接",
|
||||
"fetchError": "获取配置失败",
|
||||
"invalidUrl": "请输入有效的URL地址",
|
||||
"serverUrl": "服务器地址",
|
||||
"statusConnected": "已连接",
|
||||
"statusDisconnected": "未连接",
|
||||
"urlRequired": "请输入服务器地址"
|
||||
},
|
||||
"status": {
|
||||
"error": "错误",
|
||||
"info": "信息",
|
||||
"loading": "加载中",
|
||||
"success": "成功",
|
||||
"warning": "警告"
|
||||
},
|
||||
"sync": {
|
||||
"continue": "继续",
|
||||
"inCloud": "当前使用云端同步",
|
||||
"inLocalStorage": "当前使用本地存储",
|
||||
"isIniting": "正在初始化…",
|
||||
"lobehubCloud": {
|
||||
"description": "官方提供的云版本",
|
||||
"title": "LobeHub Cloud"
|
||||
},
|
||||
"local": {
|
||||
"description": "使用本地数据库,完全离线可用",
|
||||
"title": "本地数据库"
|
||||
},
|
||||
"mode": {
|
||||
"cloudSync": "云端同步",
|
||||
"localStorage": "本地存储",
|
||||
"title": "选择你的连接模式",
|
||||
"useSelfHosted": "使用自托管实例?"
|
||||
},
|
||||
"selfHosted": {
|
||||
"description": "自行部署的社区版本",
|
||||
"title": "自托管实例"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,37 @@
|
||||
"title": "错误"
|
||||
},
|
||||
"update": {
|
||||
"checkingUpdate": "检查新版本",
|
||||
"checkingUpdateDesc": "正在获取版本信息…",
|
||||
"downloadAndInstall": "下载并安装",
|
||||
"downloadComplete": "下载完成",
|
||||
"downloadCompleteMessage": "更新包已下载完成,是否立即安装?",
|
||||
"downloadNewVersion": "下载新版本",
|
||||
"downloadingUpdate": "正在下载更新",
|
||||
"downloadingUpdateDesc": "更新正在下载中,请稍候…",
|
||||
"installLater": "稍后安装",
|
||||
"installNow": "立即安装",
|
||||
"isLatestVersion": "当前已是最新版本",
|
||||
"isLatestVersionDesc": "非常棒,使用的版本 {{version}} 已是最前沿的版本。",
|
||||
"later": "稍后提醒",
|
||||
"newVersion": "发现新版本",
|
||||
"newVersionAvailable": "发现新版本: {{version}}",
|
||||
"skipThisVersion": "跳过此版本"
|
||||
"newVersionAvailableDesc": "发现新版本 {{version}},是否立即下载?",
|
||||
"restartAndInstall": "安装更新并重启",
|
||||
"skipThisVersion": "跳过此版本",
|
||||
"updateError": "更新错误",
|
||||
"updateReady": "有新版本可用",
|
||||
"updateReadyDesc": "新版本 {{version}} 已下载完成,重启应用后即可完成安装。",
|
||||
"upgradeNow": "立即更新",
|
||||
"willInstallLater": "更新将在下次启动时安装"
|
||||
},
|
||||
"waitingOAuth": {
|
||||
"cancel": "取消",
|
||||
"description": "浏览器已打开授权页面,请在浏览器中完成授权",
|
||||
"error": "授权失败: {{error}}",
|
||||
"errorTitle": "授权连接失败",
|
||||
"helpText": "如果浏览器没有自动打开,请点击取消后重新尝试",
|
||||
"retry": "重试",
|
||||
"title": "等待授权连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { consola } from 'consola';
|
||||
import { colors } from 'consola/utils';
|
||||
import { unset } from 'es-toolkit/compat';
|
||||
import { diff } from 'just-diff';
|
||||
import { unset } from 'lodash';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ export const genDiff = () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clearLocals = [];
|
||||
const clearLocals: string[] = [];
|
||||
|
||||
for (const locale of [i18nConfig.entryLocale, ...i18nConfig.outputLocales]) {
|
||||
const localeFilepath = outputLocaleJsonFilepath(locale, `${ns}.json`);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const appBrowsers = {
|
||||
identifier: 'chat',
|
||||
keepAlive: true,
|
||||
minWidth: 400,
|
||||
path: '/chat',
|
||||
path: '/agent',
|
||||
showOnInit: true,
|
||||
titleBarStyle: 'hidden',
|
||||
vibrancy: 'under-window',
|
||||
@@ -72,7 +72,7 @@ export const windowTemplates = {
|
||||
allowMultipleInstances: true,
|
||||
autoHideMenuBar: true,
|
||||
baseIdentifier: 'chatSingle',
|
||||
basePath: '/chat',
|
||||
basePath: '/agent',
|
||||
height: 600,
|
||||
keepAlive: false, // Multi-instance windows don't need to stay alive
|
||||
minWidth: 400,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app } from 'electron';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const mainDir = join(__dirname);
|
||||
@@ -11,7 +12,12 @@ export const buildDir = join(mainDir, '../../build');
|
||||
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
export const nextStandaloneDir = join(appPath, 'dist', 'next');
|
||||
const nextExportOutDir = join(appPath, 'dist', 'next', 'out');
|
||||
const nextExportDefaultDir = join(appPath, 'dist', 'next');
|
||||
|
||||
export const nextExportDir = pathExistsSync(nextExportOutDir)
|
||||
? nextExportOutDir
|
||||
: nextExportDefaultDir;
|
||||
|
||||
export const userDataDir = app.getPath('userData');
|
||||
|
||||
@@ -19,10 +25,6 @@ export const appStorageDir = join(userDataDir, 'lobehub-storage');
|
||||
|
||||
// ------ Application storage directory ---- //
|
||||
|
||||
// db schema hash
|
||||
export const DB_SCHEMA_HASH_FILENAME = 'lobehub-local-db-schema-hash';
|
||||
// pglite database dir
|
||||
export const LOCAL_DATABASE_DIR = 'lobehub-local-db';
|
||||
// 本地存储文件(模拟 S3)
|
||||
export const FILE_STORAGE_DIR = 'file-storage';
|
||||
// Plugin 安装目录
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { dev, linux, macOS, windows } from 'electron-is';
|
||||
import os from 'node:os';
|
||||
|
||||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
export const isDev = dev();
|
||||
|
||||
export const OFFICIAL_CLOUD_SERVER = process.env.OFFICIAL_CLOUD_SERVER || 'https://lobechat.com';
|
||||
export const OFFICIAL_CLOUD_SERVER = getDesktopEnv().OFFICIAL_CLOUD_SERVER;
|
||||
|
||||
export const isMac = macOS();
|
||||
export const isWindows = windows();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
|
||||
@@ -25,7 +25,7 @@ export const defaultProxySettings: NetworkProxySettings = {
|
||||
* 存储默认值
|
||||
*/
|
||||
export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
dataSyncConfig: { storageMode: 'local' },
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
encryptedTokens: {},
|
||||
locale: 'auto',
|
||||
networkProxy: defaultProxySettings,
|
||||
|
||||
@@ -563,7 +563,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
// Hash codeVerifier using SHA-256
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(codeVerifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data as unknown as NodeJS.BufferSource);
|
||||
|
||||
// Convert hash result to base64url encoding
|
||||
const challenge = Buffer.from(digest)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import type {
|
||||
InterceptRouteParams,
|
||||
OpenSettingsWindowOptions,
|
||||
WindowResizableParams,
|
||||
WindowSizeParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { findMatchingRoute } from '~common/routes';
|
||||
|
||||
import { AppBrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
|
||||
@@ -25,25 +30,20 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings', normalizedOptions);
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (normalizedOptions.searchParams) {
|
||||
Object.entries(normalizedOptions.searchParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, value);
|
||||
});
|
||||
}
|
||||
let fullPath: string;
|
||||
|
||||
const tab = normalizedOptions.tab;
|
||||
if (tab && tab !== 'common' && !query.has('active')) {
|
||||
query.set('active', tab);
|
||||
// If direct path is provided, use it directly
|
||||
if (normalizedOptions.path) {
|
||||
fullPath = normalizedOptions.path;
|
||||
} else {
|
||||
// Legacy support for tab and searchParams
|
||||
const tab = normalizedOptions.tab;
|
||||
fullPath = tab ? `/settings/${tab}` : '/settings/common';
|
||||
}
|
||||
|
||||
const queryString = query.toString();
|
||||
const subPath = tab && !queryString ? `/${tab}` : '';
|
||||
const fullPath = `/settings${subPath}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl(fullPath);
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: fullPath });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -73,6 +73,20 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setWindowSize(params: WindowSizeParams) {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.setWindowSize(identifier, params);
|
||||
});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setWindowResizable(params: WindowResizableParams) {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.setWindowResizable(identifier, params.resizable);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route interception requests
|
||||
* Responsible for handling route interception requests from the renderer process
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { MCPClient } from '../libs/mcp/client';
|
||||
import type { MCPClientParams, ToolCallContent, ToolCallResult } from '../libs/mcp/types';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const logger = createLogger('controllers:McpCtr');
|
||||
|
||||
/**
|
||||
* Desktop-only copy of `@lobechat/types`'s `CheckMcpInstallResult`.
|
||||
*
|
||||
* We intentionally keep it local to avoid pulling the web app's path-alias
|
||||
* expectations (e.g. `@/config/*`) into the desktop `tsgo` typecheck.
|
||||
*/
|
||||
interface CheckMcpInstallResult {
|
||||
allDependenciesMet?: boolean;
|
||||
allOptions?: Array<{
|
||||
allDependenciesMet?: boolean;
|
||||
connection?: {
|
||||
args?: string[];
|
||||
command?: string;
|
||||
installationMethod: string;
|
||||
packageName?: string;
|
||||
repositoryUrl?: string;
|
||||
};
|
||||
isRecommended?: boolean;
|
||||
packageInstalled?: boolean;
|
||||
systemDependencies?: Array<{
|
||||
error?: string;
|
||||
installed: boolean;
|
||||
meetRequirement: boolean;
|
||||
name: string;
|
||||
version?: string;
|
||||
}>;
|
||||
}>;
|
||||
configSchema?: any;
|
||||
connection?: {
|
||||
args?: string[];
|
||||
command?: string;
|
||||
type: 'stdio' | 'http';
|
||||
url?: string;
|
||||
};
|
||||
error?: string;
|
||||
isRecommended?: boolean;
|
||||
needsConfig?: boolean;
|
||||
packageInstalled?: boolean;
|
||||
platform: string;
|
||||
success: boolean;
|
||||
systemDependencies?: Array<{
|
||||
error?: string;
|
||||
installed: boolean;
|
||||
meetRequirement: boolean;
|
||||
name: string;
|
||||
version?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CustomPluginMetadata {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface GetStdioMcpServerManifestInput {
|
||||
args?: string[];
|
||||
command: string;
|
||||
env?: Record<string, string>;
|
||||
metadata?: CustomPluginMetadata;
|
||||
name: string;
|
||||
type?: 'stdio';
|
||||
}
|
||||
|
||||
interface GetStreamableMcpServerManifestInput {
|
||||
auth?: {
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
type: 'none' | 'bearer' | 'oauth2';
|
||||
};
|
||||
headers?: Record<string, string>;
|
||||
identifier: string;
|
||||
metadata?: CustomPluginMetadata;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface CallToolInput {
|
||||
args: any;
|
||||
env: any;
|
||||
params: GetStdioMcpServerManifestInput;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
interface SuperJSONSerialized<T = unknown> {
|
||||
json: T;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
const isSuperJSONSerialized = (value: unknown): value is SuperJSONSerialized => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return 'json' in value;
|
||||
};
|
||||
|
||||
const deserializePayload = <T>(payload: unknown): T => {
|
||||
// Keep backward compatibility for older renderer builds that might not serialize yet
|
||||
if (isSuperJSONSerialized(payload)) return superjson.deserialize(payload as any) as T;
|
||||
return payload as T;
|
||||
};
|
||||
|
||||
const serializePayload = <T>(payload: T): SuperJSONSerialized =>
|
||||
superjson.serialize(payload) as any;
|
||||
|
||||
const safeParseToRecord = (value: unknown): Record<string, unknown> => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value))
|
||||
return value as Record<string, unknown>;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const getFileExtensionFromMimeType = (mimeType: string, fallback: string) => {
|
||||
const [, ext] = mimeType.split('/');
|
||||
return ext || fallback;
|
||||
};
|
||||
|
||||
const todayShard = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const toMarkdown = async (
|
||||
blocks: ToolCallContent[] | null | undefined,
|
||||
getHTTPURL: (key: string) => Promise<string>,
|
||||
) => {
|
||||
if (!blocks) return '';
|
||||
|
||||
const parts = await Promise.all(
|
||||
blocks.map(async (item) => {
|
||||
switch (item.type) {
|
||||
case 'text': {
|
||||
return item.text;
|
||||
}
|
||||
case 'image': {
|
||||
const url = await getHTTPURL(item.data);
|
||||
return ``;
|
||||
}
|
||||
case 'audio': {
|
||||
const url = await getHTTPURL(item.data);
|
||||
return `<resource type="${item.type}" url="${url}" />`;
|
||||
}
|
||||
case 'resource': {
|
||||
return `<resource type="${item.type}">${JSON.stringify(item.resource)}</resource>}`;
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return parts.filter(Boolean).join('\n\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* MCP Controller (Desktop Main Process)
|
||||
* Implements the same routes as `src/server/routers/desktop/mcp.ts`, but via IPC.
|
||||
*/
|
||||
export default class McpCtr extends ControllerModule {
|
||||
static override readonly groupName = 'mcp';
|
||||
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
private async createClient(params: MCPClientParams) {
|
||||
const client = new MCPClient(params);
|
||||
await client.initialize();
|
||||
return client;
|
||||
}
|
||||
|
||||
private async processContentBlocks(blocks: ToolCallContent[]): Promise<ToolCallContent[]> {
|
||||
return Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (block.type !== 'image' && block.type !== 'audio') return block;
|
||||
|
||||
const ext = getFileExtensionFromMimeType(
|
||||
block.mimeType,
|
||||
block.type === 'image' ? 'png' : 'mp3',
|
||||
);
|
||||
|
||||
const base64 = block.data;
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
const hash = createHash('sha256').update(buffer).digest('hex');
|
||||
const id = randomUUID();
|
||||
const filePath = path.posix.join('mcp', `${block.type}s`, todayShard(), `${id}.${ext}`);
|
||||
|
||||
const { metadata } = await this.fileService.uploadFile({
|
||||
content: base64,
|
||||
filename: `${id}.${ext}`,
|
||||
hash,
|
||||
path: filePath,
|
||||
type: block.mimeType,
|
||||
});
|
||||
|
||||
return { ...block, data: metadata.path };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getStdioMcpServerManifest(payload: SuperJSONSerialized<GetStdioMcpServerManifestInput>) {
|
||||
const input = deserializePayload<GetStdioMcpServerManifestInput>(payload);
|
||||
const params: MCPClientParams = {
|
||||
args: input.args || [],
|
||||
command: input.command,
|
||||
env: input.env,
|
||||
name: input.name,
|
||||
type: 'stdio',
|
||||
};
|
||||
|
||||
const client = await this.createClient(params);
|
||||
try {
|
||||
const manifest = await client.listManifests();
|
||||
const identifier = input.name;
|
||||
|
||||
const tools = manifest.tools || [];
|
||||
|
||||
return serializePayload({
|
||||
api: tools.map((item) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
parameters: item.inputSchema as any,
|
||||
})),
|
||||
identifier,
|
||||
meta: {
|
||||
avatar: input.metadata?.avatar || 'MCP_AVATAR',
|
||||
description:
|
||||
input.metadata?.description ||
|
||||
`${identifier} MCP server has ` +
|
||||
Object.entries(manifest)
|
||||
.filter(([key]) => ['tools', 'prompts', 'resources'].includes(key))
|
||||
.map(([key, item]) => `${(item as Array<any>)?.length} ${key}`)
|
||||
.join(','),
|
||||
title: input.metadata?.name || identifier,
|
||||
},
|
||||
...manifest,
|
||||
mcpParams: params,
|
||||
type: 'mcp' as any,
|
||||
});
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getStreamableMcpServerManifest(
|
||||
payload: SuperJSONSerialized<GetStreamableMcpServerManifestInput>,
|
||||
) {
|
||||
const input = deserializePayload<GetStreamableMcpServerManifestInput>(payload);
|
||||
const params: MCPClientParams = {
|
||||
auth: input.auth,
|
||||
headers: input.headers,
|
||||
name: input.identifier,
|
||||
type: 'http',
|
||||
url: input.url,
|
||||
};
|
||||
|
||||
const client = await this.createClient(params);
|
||||
try {
|
||||
const tools = await client.listTools();
|
||||
const identifier = input.identifier;
|
||||
|
||||
return serializePayload({
|
||||
api: tools.map((item) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
parameters: item.inputSchema as any,
|
||||
})),
|
||||
identifier,
|
||||
mcpParams: params,
|
||||
meta: {
|
||||
avatar: input.metadata?.avatar || 'MCP_AVATAR',
|
||||
description:
|
||||
input.metadata?.description ||
|
||||
`${identifier} MCP server has ${tools.length} tools, like "${tools[0]?.name}"`,
|
||||
title: identifier,
|
||||
},
|
||||
type: 'mcp' as any,
|
||||
});
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async callTool(payload: SuperJSONSerialized<CallToolInput>) {
|
||||
const input = deserializePayload<CallToolInput>(payload);
|
||||
const params: MCPClientParams = {
|
||||
args: input.params.args || [],
|
||||
command: input.params.command,
|
||||
env: input.env,
|
||||
name: input.params.name,
|
||||
type: 'stdio',
|
||||
};
|
||||
|
||||
const client = await this.createClient(params);
|
||||
try {
|
||||
const args = safeParseToRecord(input.args);
|
||||
|
||||
const raw = (await client.callTool(input.toolName, args)) as ToolCallResult;
|
||||
const processed = raw.isError ? raw.content : await this.processContentBlocks(raw.content);
|
||||
|
||||
const content = await toMarkdown(processed, (key) => this.fileService.getFileHTTPURL(key));
|
||||
|
||||
return serializePayload({
|
||||
content,
|
||||
state: { ...raw, content: processed },
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('callTool failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- MCP Install Check (local system) ----------
|
||||
|
||||
private getInstallInstructions(installInstructions: any) {
|
||||
if (!installInstructions) return undefined;
|
||||
|
||||
let current: string | undefined;
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin': {
|
||||
current = installInstructions.macos;
|
||||
break;
|
||||
}
|
||||
case 'linux': {
|
||||
current = installInstructions.linux_debian || installInstructions.linux;
|
||||
break;
|
||||
}
|
||||
case 'win32': {
|
||||
current = installInstructions.windows;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { current, manual: installInstructions.manual };
|
||||
}
|
||||
|
||||
private async checkSystemDependency(dependency: any) {
|
||||
try {
|
||||
const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
|
||||
const { stdout, stderr } = await execPromise(checkCommand);
|
||||
|
||||
if (stderr && !stdout) {
|
||||
return {
|
||||
error: stderr,
|
||||
installInstructions: this.getInstallInstructions(dependency.installInstructions),
|
||||
installed: false,
|
||||
meetRequirement: false,
|
||||
name: dependency.name,
|
||||
requiredVersion: dependency.requiredVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const output = String(stdout || '').trim();
|
||||
let version = output;
|
||||
|
||||
if (dependency.versionParsingRequired) {
|
||||
const versionMatch = output.match(/[Vv]?(\d+(\.\d+)*)/);
|
||||
if (versionMatch) version = versionMatch[0];
|
||||
}
|
||||
|
||||
let meetRequirement = true;
|
||||
|
||||
if (dependency.requiredVersion) {
|
||||
const currentVersion = String(version).replace(/^[Vv]/, '');
|
||||
const currentNum = Number.parseFloat(currentVersion);
|
||||
|
||||
const requirementMatch = String(dependency.requiredVersion).match(/([<=>]+)?(\d+(\.\d+)*)/);
|
||||
if (requirementMatch) {
|
||||
const [, operator = '=', requiredVersion] = requirementMatch;
|
||||
const requiredNum = Number.parseFloat(requiredVersion);
|
||||
switch (operator) {
|
||||
case '>=': {
|
||||
meetRequirement = currentNum >= requiredNum;
|
||||
break;
|
||||
}
|
||||
case '>': {
|
||||
meetRequirement = currentNum > requiredNum;
|
||||
break;
|
||||
}
|
||||
case '<=': {
|
||||
meetRequirement = currentNum <= requiredNum;
|
||||
break;
|
||||
}
|
||||
case '<': {
|
||||
meetRequirement = currentNum < requiredNum;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
meetRequirement = currentNum === requiredNum;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installInstructions: this.getInstallInstructions(dependency.installInstructions),
|
||||
installed: true,
|
||||
meetRequirement,
|
||||
name: dependency.name,
|
||||
requiredVersion: dependency.requiredVersion,
|
||||
version,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
installInstructions: this.getInstallInstructions(dependency.installInstructions),
|
||||
installed: false,
|
||||
meetRequirement: false,
|
||||
name: dependency.name,
|
||||
requiredVersion: dependency.requiredVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkPackageInstalled(installationMethod: string, details: any) {
|
||||
if (installationMethod === 'npm') {
|
||||
const packageName = details?.packageName;
|
||||
if (!packageName) return { installed: false };
|
||||
|
||||
try {
|
||||
const { stdout } = await execPromise(`npm list -g ${packageName} --depth=0`);
|
||||
if (!stdout.includes('(empty)') && stdout.includes(packageName)) return { installed: true };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
await execPromise(`npx -y ${packageName} --version`);
|
||||
return { installed: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
installed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (installationMethod === 'python') {
|
||||
const packageName = details?.packageName;
|
||||
if (!packageName) return { installed: false };
|
||||
|
||||
const pythonCommand = details?.pythonCommand || 'python';
|
||||
|
||||
try {
|
||||
const command = `${pythonCommand} -m pip list | grep -i "${packageName}"`;
|
||||
const { stdout } = await execPromise(command);
|
||||
if (stdout.trim() && stdout.toLowerCase().includes(String(packageName).toLowerCase())) {
|
||||
return { installed: true };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const importCommand = `${pythonCommand} -c "import ${String(packageName).replace('-', '_')}; print('Package installed')"`;
|
||||
const { stdout } = await execPromise(importCommand);
|
||||
if (stdout.includes('Package installed')) return { installed: true };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return { installed: false };
|
||||
}
|
||||
|
||||
// manual or unknown
|
||||
return { installed: false };
|
||||
}
|
||||
|
||||
private async checkDeployOption(option: any) {
|
||||
const systemDependenciesResults = [];
|
||||
|
||||
if (Array.isArray(option.systemDependencies) && option.systemDependencies.length > 0) {
|
||||
for (const dep of option.systemDependencies) {
|
||||
systemDependenciesResults.push(await this.checkSystemDependency(dep));
|
||||
}
|
||||
}
|
||||
|
||||
const packageResult = await this.checkPackageInstalled(
|
||||
option.installationMethod,
|
||||
option.installationDetails,
|
||||
);
|
||||
const packageInstalled = Boolean((packageResult as any).installed);
|
||||
|
||||
const allDependenciesMet = systemDependenciesResults.every((dep: any) => dep.meetRequirement);
|
||||
|
||||
const configSchema = option.connection?.configSchema;
|
||||
const needsConfig = Boolean(
|
||||
configSchema &&
|
||||
((Array.isArray(configSchema.required) && configSchema.required.length > 0) ||
|
||||
(configSchema.properties &&
|
||||
Object.values(configSchema.properties).some((prop: any) => prop.required === true))),
|
||||
);
|
||||
|
||||
const connection = option.connection?.url
|
||||
? { ...option.connection, type: 'http' }
|
||||
: { ...option.connection, type: 'stdio' };
|
||||
|
||||
return {
|
||||
allDependenciesMet,
|
||||
configSchema,
|
||||
connection,
|
||||
isRecommended: option.isRecommended,
|
||||
needsConfig,
|
||||
packageInstalled,
|
||||
systemDependencies: systemDependenciesResults,
|
||||
};
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async validMcpServerInstallable(
|
||||
payload: SuperJSONSerialized<{
|
||||
deploymentOptions: any[];
|
||||
}>,
|
||||
) {
|
||||
const input = deserializePayload<{ deploymentOptions: any[] }>(payload);
|
||||
try {
|
||||
const options = input.deploymentOptions || [];
|
||||
const results = [];
|
||||
|
||||
for (const option of options) {
|
||||
results.push(await this.checkDeployOption(option));
|
||||
}
|
||||
|
||||
const recommendedResult = results.find((r: any) => r.isRecommended && r.allDependenciesMet);
|
||||
const firstInstallableResult = results.find((r: any) => r.allDependenciesMet);
|
||||
const bestResult = recommendedResult || firstInstallableResult || results[0];
|
||||
|
||||
const checkResult: CheckMcpInstallResult = {
|
||||
...(bestResult || {}),
|
||||
allOptions: results as any,
|
||||
platform: process.platform,
|
||||
success: true,
|
||||
};
|
||||
|
||||
if (bestResult?.needsConfig) {
|
||||
checkResult.needsConfig = true;
|
||||
checkResult.configSchema = bestResult.configSchema;
|
||||
}
|
||||
|
||||
return serializePayload(checkResult);
|
||||
} catch (error) {
|
||||
return serializePayload({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error when checking MCP plugin installation status',
|
||||
platform: process.platform,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { merge } from 'lodash';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { isEqual, merge } from 'es-toolkit/compat';
|
||||
|
||||
import { defaultProxySettings } from '@/const/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -2,9 +2,10 @@ import {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { Notification, app } from 'electron';
|
||||
import { Notification, app, systemPreferences } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
|
||||
import { getIpcContext } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -13,6 +14,54 @@ const logger = createLogger('controllers:NotificationCtr');
|
||||
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
static override readonly groupName = 'notification';
|
||||
|
||||
@IpcMethod()
|
||||
async getNotificationPermissionStatus(): Promise<string> {
|
||||
if (!Notification.isSupported()) return 'denied';
|
||||
// Keep a stable status string for renderer-side UI mapping.
|
||||
// Screen3 expects macOS to return 'authorized' when granted.
|
||||
if (!macOS()) return 'authorized';
|
||||
|
||||
// Electron 38 no longer exposes `systemPreferences.getNotificationSettings()` in types,
|
||||
// and some runtimes don't provide it at all. Use the renderer's Notification.permission
|
||||
// as a reliable fallback.
|
||||
const context = getIpcContext();
|
||||
const sender = context?.sender;
|
||||
if (!sender) return 'notDetermined';
|
||||
const permission = await sender.executeJavaScript('Notification.permission', true);
|
||||
return permission === 'granted' ? 'authorized' : 'denied';
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestNotificationPermission(): Promise<void> {
|
||||
logger.debug('Requesting notification permission by sending a test notification');
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('System does not support desktop notifications');
|
||||
return;
|
||||
}
|
||||
|
||||
// On macOS, ask permission via Web Notification API first when possible.
|
||||
// This helps keep `Notification.permission` in sync for subsequent status checks.
|
||||
if (macOS()) {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
|
||||
await mainWindow.webContents.executeJavaScript('Notification.requestPermission()', true);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Notification.requestPermission() failed or is unavailable, continuing with test notification',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
body: 'LobeHub can now send you notifications.',
|
||||
title: 'Notification Permission',
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
/**
|
||||
* Set up desktop notifications after the application is ready
|
||||
*/
|
||||
|
||||
@@ -45,6 +45,24 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
*/
|
||||
private readonly encryptedTokensKey = 'encryptedTokens';
|
||||
|
||||
/**
|
||||
* Normalize legacy config that used local storageMode.
|
||||
* Local mode has been removed; fall back to cloud.
|
||||
*/
|
||||
private normalizeConfig = (config: DataSyncConfig): DataSyncConfig => {
|
||||
if (config.storageMode !== 'local') return config;
|
||||
|
||||
const nextConfig: DataSyncConfig = {
|
||||
...config,
|
||||
remoteServerUrl: config.remoteServerUrl || OFFICIAL_CLOUD_SERVER,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
this.app.storeManager.set('dataSyncConfig', nextConfig);
|
||||
|
||||
return nextConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get remote server configuration
|
||||
*/
|
||||
@@ -54,12 +72,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
const { storeManager } = this.app;
|
||||
|
||||
const config: DataSyncConfig = storeManager.get('dataSyncConfig');
|
||||
const normalized = this.normalizeConfig(config);
|
||||
|
||||
logger.debug(
|
||||
`Remote server config: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
|
||||
`Remote server config: active=${normalized.active}, storageMode=${normalized.storageMode}, url=${normalized.remoteServerUrl}`,
|
||||
);
|
||||
|
||||
return config;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,8 +92,9 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
const { storeManager } = this.app;
|
||||
const prev: DataSyncConfig = storeManager.get('dataSyncConfig');
|
||||
|
||||
// Save configuration
|
||||
storeManager.set('dataSyncConfig', { ...prev, ...config });
|
||||
// Save configuration with legacy local storage fallback
|
||||
const merged = this.normalizeConfig({ ...prev, ...config });
|
||||
storeManager.set('dataSyncConfig', merged);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -88,7 +108,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
const { storeManager } = this.app;
|
||||
|
||||
// Clear instance configuration
|
||||
storeManager.set('dataSyncConfig', { storageMode: 'local' });
|
||||
storeManager.set('dataSyncConfig', { active: false, storageMode: 'cloud' });
|
||||
|
||||
// Clear tokens (if any)
|
||||
await this.clearTokens();
|
||||
@@ -468,7 +488,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
async getRemoteServerUrl(config?: DataSyncConfig) {
|
||||
const dataConfig = config ? config : await this.getRemoteServerConfig();
|
||||
const dataConfig = this.normalizeConfig(config ? config : await this.getRemoteServerConfig());
|
||||
|
||||
return dataConfig.storageMode === 'cloud' ? OFFICIAL_CLOUD_SERVER : dataConfig.remoteServerUrl;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
ProxyTRPCRequestParams,
|
||||
ProxyTRPCRequestResult,
|
||||
ProxyTRPCStreamRequestParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { ProxyTRPCStreamRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { IpcMainEvent, WebContents, ipcMain } from 'electron';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
@@ -15,7 +11,7 @@ import { defaultProxySettings } from '@/const/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import { ControllerModule } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:RemoteServerSyncCtr');
|
||||
@@ -174,129 +170,12 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
if (requestBody) {
|
||||
clientReq.write(Buffer.from(requestBody));
|
||||
clientReq.write(Buffer.from(requestBody as string));
|
||||
}
|
||||
|
||||
clientReq.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to perform the actual request forwarding to the remote server.
|
||||
* Accepts arguments from IPC and returns response details.
|
||||
*/
|
||||
private async forwardRequest(args: {
|
||||
accessToken: string | null;
|
||||
body?: string | ArrayBuffer;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
remoteServerUrl: string;
|
||||
urlPath: string; // Pass the base URL
|
||||
}): Promise<{
|
||||
// Node headers type
|
||||
body: Buffer;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
status: number;
|
||||
statusText: string; // Return body as Buffer
|
||||
}> {
|
||||
const {
|
||||
urlPath,
|
||||
method,
|
||||
headers: originalHeaders,
|
||||
body: requestBody,
|
||||
accessToken,
|
||||
remoteServerUrl,
|
||||
} = args;
|
||||
|
||||
const pathname = new URL(urlPath, remoteServerUrl).pathname; // Extract pathname from URL
|
||||
const logPrefix = `[ForwardRequest ${method} ${pathname}]`; // Add prefix for easier correlation
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`${logPrefix} No access token provided`); // Enhanced log
|
||||
return {
|
||||
body: Buffer.from(''),
|
||||
headers: {},
|
||||
status: 401,
|
||||
statusText: 'Authentication required, missing token',
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Determine target URL and prepare request options
|
||||
const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
|
||||
const { requestOptions, requester } = this.createRequester({
|
||||
accessToken,
|
||||
headers: originalHeaders,
|
||||
method,
|
||||
url: targetUrl,
|
||||
});
|
||||
|
||||
// 2. Make the request and capture response
|
||||
return new Promise((resolve) => {
|
||||
const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
|
||||
const chunks: Buffer[] = [];
|
||||
clientRes.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
clientRes.on('end', () => {
|
||||
const responseBody = Buffer.concat(chunks);
|
||||
resolve({
|
||||
// These are IncomingHttpHeaders
|
||||
body: responseBody,
|
||||
|
||||
headers: clientRes.headers,
|
||||
|
||||
status: clientRes.statusCode || 500,
|
||||
statusText: clientRes.statusMessage || 'Unknown Status',
|
||||
});
|
||||
});
|
||||
|
||||
clientRes.on('error', (error) => {
|
||||
// Error during response streaming
|
||||
logger.error(
|
||||
`${logPrefix} Error reading response stream from ${targetUrl.toString()}:`,
|
||||
error,
|
||||
); // Enhanced log
|
||||
// Rejecting might be better, but we need to resolve the outer promise for proxyTRPCRequest
|
||||
resolve({
|
||||
body: Buffer.from(`Error reading response stream: ${error.message}`),
|
||||
headers: {},
|
||||
|
||||
status: 502,
|
||||
// Bad Gateway
|
||||
statusText: 'Error reading response stream',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
clientReq.on('error', (error) => {
|
||||
logger.error(`${logPrefix} Error forwarding request to ${targetUrl.toString()}:`, error); // Enhanced log
|
||||
// Reject or resolve with error status for the outer promise
|
||||
resolve({
|
||||
body: Buffer.from(`Error forwarding request: ${error.message}`),
|
||||
headers: {},
|
||||
|
||||
status: 502,
|
||||
// Bad Gateway
|
||||
statusText: 'Error forwarding request',
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Send request body if present
|
||||
if (requestBody) {
|
||||
if (typeof requestBody === 'string') {
|
||||
clientReq.write(requestBody, 'utf8'); // Specify encoding for strings
|
||||
} else if (requestBody instanceof ArrayBuffer) {
|
||||
clientReq.write(Buffer.from(requestBody)); // Convert ArrayBuffer to Buffer
|
||||
} else {
|
||||
// Should not happen based on type, but handle defensively
|
||||
logger.warn(`${logPrefix} Unsupported request body type received:`, typeof requestBody); // Enhanced log
|
||||
}
|
||||
}
|
||||
|
||||
clientReq.end(); // Finalize the request
|
||||
});
|
||||
}
|
||||
|
||||
private createRequester({
|
||||
headers,
|
||||
accessToken,
|
||||
@@ -341,144 +220,4 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
const requester = url.protocol === 'https:' ? https : http;
|
||||
return { requestOptions, requester };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the 'proxy-trpc-request' IPC call from the renderer process.
|
||||
* This method should be invoked by the ipcMain.handle setup in your main process entry point.
|
||||
*/
|
||||
@IpcMethod()
|
||||
public async proxyTRPCRequest(args: ProxyTRPCRequestParams): Promise<ProxyTRPCRequestResult> {
|
||||
logger.debug('Received proxyTRPCRequest IPC call:', {
|
||||
headers: args.headers,
|
||||
method: args.method,
|
||||
urlPath: args.urlPath, // Log headers too for context
|
||||
});
|
||||
|
||||
const url = new URL(args.urlPath, 'http://a.b');
|
||||
const logPrefix = `[ProxyTRPC ${args.method} ${url.pathname}]`; // Prefix for this specific request
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
|
||||
logger.warn(
|
||||
`${logPrefix} Remote server sync not active or configured. Rejecting proxy request.`,
|
||||
); // Enhanced log
|
||||
return {
|
||||
body: Buffer.from('Remote server sync not active or configured').buffer,
|
||||
headers: {},
|
||||
|
||||
status: 503,
|
||||
// Service Unavailable
|
||||
statusText: 'Remote server sync not active or configured', // Return ArrayBuffer
|
||||
};
|
||||
}
|
||||
const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
|
||||
|
||||
// Get initial token
|
||||
let token = await this.remoteServerConfigCtr.getAccessToken();
|
||||
logger.debug(
|
||||
`${logPrefix} Initial token check: ${token ? 'Token exists' : 'No token found'}`,
|
||||
); // Added log
|
||||
|
||||
logger.info(`${logPrefix} Attempting to forward request...`); // Added log
|
||||
let response = await this.forwardRequest({ ...args, accessToken: token, remoteServerUrl });
|
||||
|
||||
// Handle 401: Refresh token and retry if necessary
|
||||
if (response.status === 401) {
|
||||
logger.info(`${logPrefix} Received 401 from forwarded request. Attempting token refresh.`); // Enhanced log
|
||||
const refreshed = await this.refreshTokenIfNeeded(logPrefix); // Pass prefix for context
|
||||
|
||||
if (refreshed) {
|
||||
const newToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (newToken) {
|
||||
logger.info(`${logPrefix} Token refreshed successfully, retrying the request.`); // Enhanced log
|
||||
response = await this.forwardRequest({
|
||||
...args,
|
||||
accessToken: newToken,
|
||||
remoteServerUrl,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
`${logPrefix} Token refresh reported success, but failed to retrieve new token. Keeping original 401 response.`,
|
||||
); // Enhanced log
|
||||
// Keep the original 401 response
|
||||
}
|
||||
} else {
|
||||
logger.error(`${logPrefix} Token refresh failed. Keeping original 401 response.`); // Enhanced log
|
||||
// Keep the original 401 response
|
||||
}
|
||||
}
|
||||
|
||||
// Convert headers and body to format defined in IPC event
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value !== undefined) {
|
||||
responseHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the final response, ensuring body is serializable (string or ArrayBuffer)
|
||||
const responseBody = response.body; // Buffer
|
||||
|
||||
// IMPORTANT: Check IPC limits. Large bodies might fail. Consider chunking if needed.
|
||||
// Convert Buffer to ArrayBuffer for IPC
|
||||
const finalBody = responseBody.buffer.slice(
|
||||
responseBody.byteOffset,
|
||||
responseBody.byteOffset + responseBody.byteLength,
|
||||
);
|
||||
|
||||
logger.debug(`${logPrefix} Forwarding successful. Status: ${response.status}`); // Added log
|
||||
return {
|
||||
body: finalBody as ArrayBuffer,
|
||||
headers: responseHeaders,
|
||||
status: response.status,
|
||||
statusText: response.statusText, // Return ArrayBuffer
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Unhandled error processing proxyTRPCRequest:`, error); // Enhanced log
|
||||
// Ensure a serializable error response is returned
|
||||
return {
|
||||
body: Buffer.from(
|
||||
`Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
).buffer,
|
||||
headers: {},
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error during proxy', // Return ArrayBuffer
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to refresh the access token by calling the RemoteServerConfigCtr.
|
||||
* @returns Whether token refresh was successful
|
||||
*/
|
||||
private async refreshTokenIfNeeded(callerLogPrefix: string = '[RefreshToken]'): Promise<boolean> {
|
||||
// Added prefix parameter
|
||||
const logPrefix = `${callerLogPrefix} [RefreshTrigger]`; // Updated prefix
|
||||
logger.debug(`${logPrefix} Entered refreshTokenIfNeeded.`);
|
||||
|
||||
try {
|
||||
logger.info(`${logPrefix} Triggering refreshAccessToken in RemoteServerConfigCtr.`);
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`${logPrefix} refreshAccessToken call completed successfully.`);
|
||||
return true;
|
||||
} else {
|
||||
logger.error(`${logPrefix} refreshAccessToken call failed: ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Exception occurred while calling refreshAccessToken:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources - No protocol handler to unregister anymore
|
||||
*/
|
||||
destroy() {
|
||||
logger.info('Destroying RemoteServerSyncCtr');
|
||||
// Nothing specific to clean up here regarding request handling now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,44 @@ export default class SystemController extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查可用性
|
||||
*/
|
||||
@IpcMethod()
|
||||
checkAccessibilityForMacOS() {
|
||||
if (!macOS()) return;
|
||||
requestAccessibilityAccess() {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(true);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getAccessibilityStatus() {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(false);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getMediaAccessStatus(mediaType: 'microphone' | 'screen'): Promise<string> {
|
||||
if (!macOS()) return 'granted';
|
||||
return systemPreferences.getMediaAccessStatus(mediaType);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestMicrophoneAccess(): Promise<boolean> {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.askForMediaAccess('microphone');
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestScreenAccess(): Promise<void> {
|
||||
if (!macOS()) return;
|
||||
shell.openExternal(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
openFullDiskAccessSettings() {
|
||||
if (!macOS()) return;
|
||||
shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles');
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
openExternalLink(url: string) {
|
||||
return shell.openExternal(url);
|
||||
@@ -87,6 +116,19 @@ export default class SystemController extends ControllerModule {
|
||||
|
||||
// Apply visual effects to all browser windows when theme mode changes
|
||||
this.app.browserManager.handleAppThemeChange();
|
||||
// Set app theme mode to the system theme mode
|
||||
|
||||
this.setSystemThemeMode(themeMode);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getSystemThemeMode() {
|
||||
return nativeTheme.themeSource;
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async setSystemThemeMode(themeMode: ThemeMode) {
|
||||
nativeTheme.themeSource = themeMode === 'auto' ? 'system' : themeMode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
|
||||
|
||||
import { ControllerModule, IpcServerMethod } from './index';
|
||||
|
||||
export default class SystemServerCtr extends ControllerModule {
|
||||
static override readonly groupName = 'system';
|
||||
|
||||
@IpcServerMethod()
|
||||
async getDatabasePath() {
|
||||
return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getDatabaseSchemaHash() {
|
||||
try {
|
||||
return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getUserDataPath() {
|
||||
return userDataDir;
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async setDatabaseSchemaHash(hash: string) {
|
||||
writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8');
|
||||
}
|
||||
|
||||
private get DB_SCHEMA_HASH_PATH() {
|
||||
return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ vi.mock('electron', () => ({
|
||||
const mockToggleVisible = vi.fn();
|
||||
const mockLoadUrl = vi.fn();
|
||||
const mockShow = vi.fn();
|
||||
const mockBroadcast = vi.fn();
|
||||
const mockRedirectToPage = vi.fn();
|
||||
const mockCloseWindow = vi.fn();
|
||||
const mockMinimizeWindow = vi.fn();
|
||||
@@ -34,6 +35,7 @@ const mockGetMainWindow = vi.fn(() => ({
|
||||
toggleVisible: mockToggleVisible,
|
||||
loadUrl: mockLoadUrl,
|
||||
show: mockShow,
|
||||
broadcast: mockBroadcast,
|
||||
}));
|
||||
const mockShowOther = vi.fn();
|
||||
|
||||
@@ -81,19 +83,23 @@ describe('BrowserWindowsCtr', () => {
|
||||
});
|
||||
|
||||
describe('openSettingsWindow', () => {
|
||||
it('should navigate to settings in main window with the specified tab', async () => {
|
||||
const tab = 'appearance';
|
||||
const result = await browserWindowsCtr.openSettingsWindow(tab);
|
||||
it('should navigate to settings in main window with the specified path', async () => {
|
||||
const path = '/settings/common';
|
||||
const result = await browserWindowsCtr.openSettingsWindow({ path });
|
||||
expect(mockGetMainWindow).toHaveBeenCalled();
|
||||
expect(mockLoadUrl).toHaveBeenCalledWith('/settings?active=appearance');
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('navigate', {
|
||||
path: '/settings/common',
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error if navigation fails', async () => {
|
||||
const errorMessage = 'Failed to navigate';
|
||||
mockLoadUrl.mockRejectedValueOnce(new Error(errorMessage));
|
||||
const result = await browserWindowsCtr.openSettingsWindow('display');
|
||||
mockBroadcast.mockImplementationOnce(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
const result = await browserWindowsCtr.openSettingsWindow({ path: '/settings/common' });
|
||||
expect(result).toEqual({ error: errorMessage, success: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,10 @@ describe('RemoteServerConfigCtr', () => {
|
||||
const result = await controller.clearRemoteServerConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', { storageMode: 'local' });
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
import { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import RemoteServerSyncCtr from '../RemoteServerSyncCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: vi.fn(() => '/mock/app/path'),
|
||||
getPath: vi.fn(() => '/mock/user/data'),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
dev: vi.fn(() => false),
|
||||
linux: vi.fn(() => false),
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock http and https modules
|
||||
vi.mock('node:http', () => ({
|
||||
default: {
|
||||
request: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:https', () => ({
|
||||
default: {
|
||||
request: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock proxy agents
|
||||
vi.mock('http-proxy-agent', () => ({
|
||||
HttpProxyAgent: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
// Mock RemoteServerConfigCtr
|
||||
const mockRemoteServerConfigCtr = {
|
||||
getRemoteServerConfig: vi.fn(),
|
||||
getRemoteServerUrl: vi.fn(),
|
||||
getAccessToken: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
};
|
||||
|
||||
const mockStoreManager = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
enableProxy: false,
|
||||
proxyServer: '',
|
||||
proxyPort: '',
|
||||
proxyType: 'http',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getController: vi.fn(() => mockRemoteServerConfigCtr),
|
||||
storeManager: mockStoreManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('RemoteServerSyncCtr', () => {
|
||||
let controller: RemoteServerSyncCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new RemoteServerSyncCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('proxyTRPCRequest', () => {
|
||||
const baseParams: ProxyTRPCRequestParams = {
|
||||
urlPath: '/trpc/test.query',
|
||||
method: 'GET',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
};
|
||||
|
||||
it('should return 503 when remote server sync is not active', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.statusText).toBe('Remote server sync not active or configured');
|
||||
});
|
||||
|
||||
it('should return 503 when selfHost mode without remoteServerUrl', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'selfHost',
|
||||
remoteServerUrl: '',
|
||||
});
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.statusText).toBe('Remote server sync not active or configured');
|
||||
});
|
||||
|
||||
it('should return 401 when no access token is available', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue(null);
|
||||
|
||||
// Mock https.request to simulate the forwardRequest behavior
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
// Simulate response
|
||||
const mockResponse = {
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required, missing token',
|
||||
headers: {},
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(''));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should forward request successfully when configured properly', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
statusMessage: 'OK',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from('{"success":true}'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.statusText).toBe('OK');
|
||||
});
|
||||
|
||||
it('should retry request after token refresh on 401', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken
|
||||
.mockResolvedValueOnce('expired-token')
|
||||
.mockResolvedValueOnce('new-valid-token');
|
||||
mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({ success: true });
|
||||
|
||||
const https = await import('node:https');
|
||||
let callCount = 0;
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
callCount++;
|
||||
const mockResponse = {
|
||||
statusCode: callCount === 1 ? 401 : 200,
|
||||
statusMessage: callCount === 1 ? 'Unauthorized' : 'OK',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(callCount === 1 ? '' : '{"success":true}'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should keep 401 response when token refresh fails', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('expired-token');
|
||||
mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Refresh failed',
|
||||
});
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
const mockResponse = {
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
headers: {},
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(''));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle request error gracefully', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
return {
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'error') {
|
||||
handler(new Error('Network error'));
|
||||
}
|
||||
}),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
expect(result.statusText).toBe('Error forwarding request');
|
||||
});
|
||||
|
||||
it('should include request body when provided', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockWrite = vi.fn();
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
statusMessage: 'OK',
|
||||
headers: {},
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from('{"success":true}'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: mockWrite,
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const paramsWithBody: ProxyTRPCRequestParams = {
|
||||
...baseParams,
|
||||
method: 'POST',
|
||||
body: '{"data":"test"}',
|
||||
};
|
||||
|
||||
await controller.proxyTRPCRequest(paramsWithBody);
|
||||
|
||||
expect(mockWrite).toHaveBeenCalledWith('{"data":"test"}', 'utf8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should register stream:start IPC handler', async () => {
|
||||
const { ipcMain } = await import('electron');
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
expect(ipcMain.on).toHaveBeenCalledWith('stream:start', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should clean up resources', () => {
|
||||
// destroy method doesn't throw
|
||||
expect(() => controller.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,22 +139,24 @@ describe('SystemController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAccessibilityForMacOS', () => {
|
||||
it('should check accessibility on macOS', async () => {
|
||||
describe('accessibility', () => {
|
||||
it('should request accessibility access on macOS', async () => {
|
||||
const { systemPreferences } = await import('electron');
|
||||
|
||||
await invokeIpc('system.checkAccessibilityForMacOS');
|
||||
await invokeIpc('system.requestAccessibilityAccess');
|
||||
|
||||
expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should return undefined on non-macOS', async () => {
|
||||
it('should return true on non-macOS when requesting accessibility access', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
const { systemPreferences } = await import('electron');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = await invokeIpc('system.checkAccessibilityForMacOS');
|
||||
const result = await invokeIpc('system.requestAccessibilityAccess');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toBe(true);
|
||||
expect(systemPreferences.isTrustedAccessibilityClient).not.toHaveBeenCalled();
|
||||
|
||||
// Reset
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import SystemServerCtr from '../SystemServerCtr';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
|
||||
LOCAL_DATABASE_DIR: 'database',
|
||||
userDataDir: '/mock/user/data',
|
||||
}));
|
||||
|
||||
const mockApp = {
|
||||
appStoragePath: '/mock/storage',
|
||||
} as unknown as App;
|
||||
|
||||
describe('SystemServerCtr', () => {
|
||||
let controller: SystemServerCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new SystemServerCtr(mockApp);
|
||||
});
|
||||
|
||||
it('returns database path', async () => {
|
||||
await expect(controller.getDatabasePath()).resolves.toBe('/mock/storage/database');
|
||||
});
|
||||
|
||||
it('reads schema hash when file exists', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockReturnValue('hash123');
|
||||
|
||||
await expect(controller.getDatabaseSchemaHash()).resolves.toBe('hash123');
|
||||
expect(readFileSync).toHaveBeenCalledWith('/mock/storage/db-schema-hash.txt', 'utf8');
|
||||
});
|
||||
|
||||
it('returns undefined when schema hash file missing', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
|
||||
await expect(controller.getDatabaseSchemaHash()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns user data path', async () => {
|
||||
await expect(controller.getUserDataPath()).resolves.toBe('/mock/user/data');
|
||||
});
|
||||
|
||||
it('writes schema hash to disk', async () => {
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
|
||||
await controller.setDatabaseSchemaHash('newhash');
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
'/mock/storage/db-schema-hash.txt',
|
||||
'newhash',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import AuthCtr from './AuthCtr';
|
||||
import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||
import DevtoolsCtr from './DevtoolsCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import McpInstallCtr from './McpInstallCtr';
|
||||
import MenuController from './MenuCtr';
|
||||
import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
@@ -13,7 +14,6 @@ import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import ShortcutController from './ShortcutCtr';
|
||||
import SystemController from './SystemCtr';
|
||||
import SystemServerCtr from './SystemServerCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
@@ -24,6 +24,7 @@ export const controllerIpcConstructors = [
|
||||
BrowserWindowsCtr,
|
||||
DevtoolsCtr,
|
||||
LocalFileCtr,
|
||||
McpCtr,
|
||||
McpInstallCtr,
|
||||
MenuController,
|
||||
NetworkProxyCtr,
|
||||
@@ -43,7 +44,6 @@ type DesktopControllerServices = CreateServicesResult<DesktopControllerIpcConstr
|
||||
export type DesktopIpcServices = MergeIpcService<DesktopControllerServices>;
|
||||
|
||||
export const controllerServerIpcConstructors = [
|
||||
SystemServerCtr,
|
||||
UploadFileServerCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
|
||||
+214
-100
@@ -1,23 +1,33 @@
|
||||
import {
|
||||
DEFAULT_VARIANTS,
|
||||
LOBE_LOCALE_COOKIE,
|
||||
LOBE_THEME_APPEARANCE,
|
||||
Locales,
|
||||
RouteVariants,
|
||||
} from '@lobechat/desktop-bridge';
|
||||
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { Session, app, protocol } from 'electron';
|
||||
import { app, protocol, session } from 'electron';
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { LOCAL_DATABASE_DIR, buildDir, nextStandaloneDir } from '@/const/dir';
|
||||
import { buildDir, nextExportDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { getServerMethodMetadata } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
|
||||
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererProtocolManager } from './infrastructure/RendererProtocolManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
import { StoreManager } from './infrastructure/StoreManager';
|
||||
import { UpdaterManager } from './infrastructure/UpdaterManager';
|
||||
@@ -35,8 +45,10 @@ type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
|
||||
|
||||
const devDefaultRendererUrl = 'http://localhost:3015';
|
||||
|
||||
export class App {
|
||||
nextServerUrl = 'http://localhost:3015';
|
||||
rendererLoadedUrl: string;
|
||||
|
||||
browserManager: BrowserManager;
|
||||
menuManager: MenuManager;
|
||||
@@ -47,7 +59,12 @@ export class App {
|
||||
trayManager: TrayManager;
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererProtocolManager: RendererProtocolManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
/**
|
||||
* Escape hatch: allow testing static renderer in dev via env
|
||||
*/
|
||||
private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
|
||||
|
||||
/**
|
||||
* whether app is in quiting
|
||||
@@ -79,6 +96,27 @@ export class App {
|
||||
// Initialize store manager
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererProtocolManager = new RendererProtocolManager({
|
||||
nextExportDir,
|
||||
resolveRendererFilePath: this.resolveRendererFilePath.bind(this),
|
||||
});
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererProtocolManager.protocolScheme,
|
||||
]);
|
||||
|
||||
// Initialize rendererLoadedUrl from RendererProtocolManager
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
|
||||
// load controllers
|
||||
const controllers: IControlModule[] = importAll(
|
||||
import.meta.glob('@/controllers/*Ctr.ts', { eager: true }),
|
||||
@@ -106,9 +144,9 @@ export class App {
|
||||
this.staticFileServerManager = new StaticFileServerManager(this);
|
||||
this.protocolManager = new ProtocolManager(this);
|
||||
|
||||
// register the schema to interceptor url
|
||||
// it should register before app ready
|
||||
this.registerNextHandler();
|
||||
// Configure renderer loading strategy (dev server vs static export)
|
||||
// should register before app ready
|
||||
this.configureRendererLoader();
|
||||
|
||||
// initialize protocol handlers
|
||||
this.protocolManager.initialize();
|
||||
@@ -130,9 +168,6 @@ export class App {
|
||||
|
||||
this.initDevBranding();
|
||||
|
||||
// Clean up stale database lock file before starting IPC server
|
||||
await this.cleanupDatabaseLock();
|
||||
|
||||
// ==============
|
||||
await this.ipcServer.start();
|
||||
logger.debug('IPC server started');
|
||||
@@ -243,6 +278,8 @@ export class App {
|
||||
await app.whenReady();
|
||||
logger.debug('Application ready');
|
||||
|
||||
await this.installReactDevtools();
|
||||
|
||||
this.controllers.forEach((controller) => {
|
||||
if (typeof controller.afterAppReady === 'function') {
|
||||
try {
|
||||
@@ -256,6 +293,21 @@ export class App {
|
||||
logger.info('Application ready state completed');
|
||||
};
|
||||
|
||||
/**
|
||||
* Development only: install React DevTools extension into Electron's devtools.
|
||||
*/
|
||||
private installReactDevtools = async () => {
|
||||
if (!isDev) return;
|
||||
|
||||
try {
|
||||
const name = await installExtension(REACT_DEVELOPER_TOOLS);
|
||||
|
||||
logger.info(`Installed DevTools extension: ${name}`);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to install React DevTools extension', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ============= helper ============= //
|
||||
|
||||
/**
|
||||
@@ -272,53 +324,6 @@ export class App {
|
||||
shortcutMethodMap: ShortcutMethodMap = new Map();
|
||||
protocolHandlerMap: ProtocolHandlerMap = new Map();
|
||||
|
||||
/**
|
||||
* use in next router interceptor in prod browser render
|
||||
*/
|
||||
nextInterceptor: (params: { session: Session }) => () => void;
|
||||
|
||||
/**
|
||||
* Collection of unregister functions for custom request handlers
|
||||
*/
|
||||
private customHandlerUnregisterFns: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Function to register custom request handler
|
||||
*/
|
||||
private registerCustomHandlerFn?: (handler: CustomRequestHandler) => () => void;
|
||||
|
||||
/**
|
||||
* Register custom request handler
|
||||
* @param handler Custom request handler function
|
||||
* @returns Function to unregister the handler
|
||||
*/
|
||||
registerRequestHandler = (handler: CustomRequestHandler): (() => void) => {
|
||||
if (!this.registerCustomHandlerFn) {
|
||||
logger.warn('Custom request handler registration is not available');
|
||||
return () => {};
|
||||
}
|
||||
|
||||
logger.debug('Registering custom request handler');
|
||||
const unregisterFn = this.registerCustomHandlerFn(handler);
|
||||
this.customHandlerUnregisterFns.push(unregisterFn);
|
||||
|
||||
return () => {
|
||||
unregisterFn();
|
||||
const index = this.customHandlerUnregisterFns.indexOf(unregisterFn);
|
||||
if (index !== -1) {
|
||||
this.customHandlerUnregisterFns.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Unregister all custom request handlers
|
||||
*/
|
||||
unregisterAllRequestHandlers = () => {
|
||||
this.customHandlerUnregisterFns.forEach((unregister) => unregister());
|
||||
this.customHandlerUnregisterFns = [];
|
||||
};
|
||||
|
||||
private addController = (ControllerClass: IControlModule) => {
|
||||
const controller = new ControllerClass(this);
|
||||
this.controllers.set(ControllerClass, controller);
|
||||
@@ -362,56 +367,166 @@ export class App {
|
||||
}
|
||||
};
|
||||
|
||||
private resolveExportFilePath(pathname: string) {
|
||||
// Normalize by removing leading/trailing slashes so extname works as expected
|
||||
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
|
||||
|
||||
if (!normalizedPath) return join(nextExportDir, 'index.html');
|
||||
|
||||
const basePath = join(nextExportDir, normalizedPath);
|
||||
const ext = extname(normalizedPath);
|
||||
|
||||
// If the request explicitly includes an extension (e.g. html, ico, txt),
|
||||
// treat it as a direct asset without variant injection.
|
||||
if (ext) {
|
||||
return pathExistsSync(basePath) ? basePath : null;
|
||||
}
|
||||
|
||||
const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (pathExistsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const fallback404 = join(nextExportDir, '404.html');
|
||||
if (pathExistsSync(fallback404)) return fallback404;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale database lock file from previous crashes or abnormal exits
|
||||
* Configure renderer loading strategy for dev/prod
|
||||
*/
|
||||
private cleanupDatabaseLock = async () => {
|
||||
try {
|
||||
const dbPath = join(this.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
const lockPath = `${dbPath}.lock`;
|
||||
private configureRendererLoader() {
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
this.rendererLoadedUrl = devDefaultRendererUrl;
|
||||
this.setupDevRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathExistsSync(lockPath)) {
|
||||
logger.info(`Cleaning up stale database lock file: ${lockPath}`);
|
||||
await remove(lockPath);
|
||||
logger.info('Database lock file removed successfully');
|
||||
} else {
|
||||
logger.debug('No database lock file found, skipping cleanup');
|
||||
if (isDev && this.rendererStaticOverride) {
|
||||
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
|
||||
}
|
||||
|
||||
this.setupProdRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Development: use Next dev server directly
|
||||
*/
|
||||
private setupDevRenderer() {
|
||||
logger.info('Development mode: renderer served from Next dev server, no protocol hook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Production: serve static Next export assets
|
||||
*/
|
||||
private setupProdRenderer() {
|
||||
// Use the URL from RendererProtocolManager
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
this.rendererProtocolManager.registerHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve renderer file path in production by combining variant prefix and pathname.
|
||||
* Falls back to default variant when cookies are missing or invalid.
|
||||
*/
|
||||
private async resolveRendererFilePath(url: URL) {
|
||||
const pathname = url.pathname;
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
// Static assets should be resolved from root (no variant prefix)
|
||||
if (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/manifest.json'
|
||||
) {
|
||||
return this.resolveExportFilePath(pathname);
|
||||
}
|
||||
|
||||
// If the incoming path already contains an extension (like .html or .ico),
|
||||
// treat it as a direct asset lookup to avoid double variant prefixes.
|
||||
const extension = extname(normalizedPathname);
|
||||
if (extension) {
|
||||
const directPath = this.resolveExportFilePath(pathname);
|
||||
if (directPath) return directPath;
|
||||
|
||||
// Next.js RSC payloads are emitted under variant folders (e.g. /en-US__0__light/__next._tree.txt),
|
||||
// but the runtime may request them without the variant prefix. For missing .txt requests,
|
||||
// retry resolution with variant injection.
|
||||
if (extension === '.txt' && normalizedPathname.includes('__next.')) {
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
|
||||
return (
|
||||
this.resolveExportFilePath(`/${variant}${pathname}`) ||
|
||||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
const variantPrefixedPath = `/${variant}${pathname}`;
|
||||
|
||||
// Try variant-specific path first, then default variant as fallback
|
||||
return (
|
||||
this.resolveExportFilePath(variantPrefixedPath) ||
|
||||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private readonly defaultRouteVariant = RouteVariants.serializeVariants(DEFAULT_VARIANTS);
|
||||
private readonly localeCookieName = LOBE_LOCALE_COOKIE;
|
||||
private readonly themeCookieName = LOBE_THEME_APPEARANCE;
|
||||
|
||||
/**
|
||||
* Build variant string from Electron session cookies to match Next export structure.
|
||||
* Desktop is always treated as non-mobile (0).
|
||||
*/
|
||||
private async getRouteVariantFromCookies(): Promise<string> {
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({
|
||||
url: `${this.rendererLoadedUrl}/`,
|
||||
});
|
||||
const locale = cookies.find((c) => c.name === this.localeCookieName)?.value;
|
||||
const themeCookie = cookies.find((c) => c.name === this.themeCookieName)?.value;
|
||||
|
||||
const serialized = RouteVariants.serializeVariants(
|
||||
RouteVariants.createVariants({
|
||||
isMobile: false,
|
||||
locale: locale as Locales | undefined,
|
||||
theme: themeCookie === 'dark' || themeCookie === 'light' ? themeCookie : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return RouteVariants.serializeVariants(RouteVariants.deserializeVariants(serialized));
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup database lock file:', error);
|
||||
// Non-fatal error, allow application to continue
|
||||
logger.warn('Failed to read route variant cookies, using default', error);
|
||||
return this.defaultRouteVariant;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private registerNextHandler() {
|
||||
logger.debug('Registering Next.js handler');
|
||||
const handler = createHandler({
|
||||
debug: true,
|
||||
localhostUrl: this.nextServerUrl,
|
||||
protocol,
|
||||
standaloneDir: nextStandaloneDir,
|
||||
});
|
||||
/**
|
||||
* Build renderer URL with variant prefix injected into the path.
|
||||
* In dev mode (without static override), Next.js dev server handles routing automatically.
|
||||
* In prod or dev with static override, we need to inject variant to match export structure: /[variants]/path
|
||||
*/
|
||||
async buildRendererUrl(path: string): Promise<string> {
|
||||
// Ensure path starts with /
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
// Log output based on development or production mode
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`Development mode: Custom request handler enabled, but Next.js interception disabled`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Production mode: ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`,
|
||||
);
|
||||
// In dev mode without static override, use dev server directly (no variant needed)
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
return `${this.rendererLoadedUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
this.nextInterceptor = handler.createInterceptor;
|
||||
|
||||
// Save custom handler registration function
|
||||
if (handler.registerCustomHandler) {
|
||||
this.registerCustomHandlerFn = handler.registerCustomHandler;
|
||||
logger.debug('Custom request handler registration is available');
|
||||
} else {
|
||||
logger.warn('Custom request handler registration is not available');
|
||||
}
|
||||
// In prod or dev with static override, inject variant for static export structure
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
return `${this.rendererLoadedUrl}/${variant}.html${cleanPath}`;
|
||||
}
|
||||
|
||||
private initializeServerIpcEvents() {
|
||||
@@ -445,6 +560,5 @@ export class App {
|
||||
|
||||
// 执行清理操作
|
||||
this.staticFileServerManager.destroy();
|
||||
this.unregisterAllRequestHandlers();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { app } from 'electron';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LOCAL_DATABASE_DIR } from '@/const/dir';
|
||||
|
||||
// Import after mocks are set up
|
||||
import { App } from '../App';
|
||||
|
||||
const mockPathExistsSync = vi.fn();
|
||||
|
||||
// Mock electron modules
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
@@ -36,6 +33,24 @@ vi.mock('electron', () => ({
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
defaultSession: {
|
||||
cookies: {
|
||||
get: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// electron-devtools-installer accesses electron.app.getPath at import-time in node env;
|
||||
// mock it to avoid side effects in unit tests
|
||||
vi.mock('electron-devtools-installer', () => ({
|
||||
REACT_DEVELOPER_TOOLS: 'REACT_DEVELOPER_TOOLS',
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
pathExistsSync: (...args: any[]) => mockPathExistsSync(...args),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
@@ -48,16 +63,6 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fs-extra module
|
||||
vi.mock('fs-extra', async () => {
|
||||
const actual = await vi.importActual('fs-extra');
|
||||
return {
|
||||
...actual,
|
||||
pathExistsSync: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock common/routes
|
||||
vi.mock('~common/routes', () => ({
|
||||
findMatchingRoute: vi.fn(),
|
||||
@@ -80,11 +85,9 @@ vi.mock('@/const/env', () => ({
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
nextStandaloneDir: '/mock/standalone',
|
||||
LOCAL_DATABASE_DIR: 'lobehub-local-db',
|
||||
nextExportDir: '/mock/export/out',
|
||||
appStorageDir: '/mock/storage/path',
|
||||
userDataDir: '/mock/user/data',
|
||||
DB_SCHEMA_HASH_FILENAME: 'lobehub-local-db-schema-hash',
|
||||
FILE_STORAGE_DIR: 'file-storage',
|
||||
INSTALL_PLUGINS_DIR: 'plugins',
|
||||
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
|
||||
@@ -159,118 +162,25 @@ vi.mock('../ui/TrayManager', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/next-electron-rsc', () => ({
|
||||
createHandler: vi.fn(() => ({
|
||||
createInterceptor: vi.fn(),
|
||||
registerCustomHandler: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock controllers and services
|
||||
vi.mock('../../controllers/*Ctr.ts', () => ({}));
|
||||
vi.mock('../../services/*Srv.ts', () => ({}));
|
||||
|
||||
describe('App - Database Lock Cleanup', () => {
|
||||
describe('App', () => {
|
||||
let appInstance: App;
|
||||
let mockLockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPathExistsSync.mockReset();
|
||||
|
||||
// Mock glob imports to return empty arrays
|
||||
import.meta.glob = vi.fn(() => ({}));
|
||||
|
||||
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bootstrap - database lock cleanup', () => {
|
||||
it('should remove stale lock file if it exists during bootstrap', async () => {
|
||||
// Setup: simulate existing lock file
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockResolvedValue(undefined);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap which should trigger cleanup
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: lock file was removed
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should not attempt to remove lock file if it does not exist', async () => {
|
||||
// Setup: no lock file exists
|
||||
vi.mocked(pathExistsSync).mockReturnValue(false);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: remove was NOT called since file doesn't exist
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue bootstrap even if lock cleanup fails', async () => {
|
||||
// Setup: simulate lock file exists but cleanup fails
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Bootstrap should not throw even if cleanup fails
|
||||
await expect(appInstance.bootstrap()).resolves.not.toThrow();
|
||||
|
||||
// Verify: cleanup was attempted
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should clean up lock file before starting IPC server', async () => {
|
||||
// Setup
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(remove).mockImplementation(async () => {
|
||||
callOrder.push('remove');
|
||||
});
|
||||
|
||||
// Mock IPC server start to track call order
|
||||
const { ElectronIPCServer } = await import('@lobechat/electron-server-ipc');
|
||||
const mockStart = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('ipcServer.start');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(ElectronIPCServer).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
start: mockStart,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Create app instance and bootstrap
|
||||
appInstance = new App();
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: cleanup happens before IPC server starts
|
||||
expect(callOrder).toEqual(['remove', 'ipcServer.start']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appStoragePath', () => {
|
||||
it('should return storage path from store manager', () => {
|
||||
appInstance = new App();
|
||||
@@ -280,4 +190,46 @@ describe('App - Database Lock Cleanup', () => {
|
||||
expect(storagePath).toBe('/mock/storage/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRendererFilePath', () => {
|
||||
it('should retry missing .txt requests with variant-prefixed lookup', async () => {
|
||||
appInstance = new App();
|
||||
|
||||
// Avoid touching the electron session cookie code path in this unit test
|
||||
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => 'en-US__0__light');
|
||||
|
||||
mockPathExistsSync.mockImplementation((p: string) => {
|
||||
// root miss
|
||||
if (p === '/mock/export/out/__next._tree.txt') return false;
|
||||
// variant hit
|
||||
if (p === '/mock/export/out/en-US__0__light/__next._tree.txt') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const resolved = await (appInstance as any).resolveRendererFilePath(
|
||||
new URL('app://next/__next._tree.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light/__next._tree.txt');
|
||||
});
|
||||
|
||||
it('should keep direct lookup for existing root .txt assets (no variant retry)', async () => {
|
||||
appInstance = new App();
|
||||
|
||||
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => {
|
||||
throw new Error('should not be called');
|
||||
});
|
||||
|
||||
mockPathExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/mock/export/out/en-US__0__light.txt') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const resolved = await (appInstance as any).resolveRendererFilePath(
|
||||
new URL('app://next/en-US__0__light.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,17 @@ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-c
|
||||
import {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
session as electronSession,
|
||||
ipcMain,
|
||||
nativeTheme,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import console from 'node:console';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isDev, isMac, isWindows } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import {
|
||||
BACKGROUND_DARK,
|
||||
BACKGROUND_LIGHT,
|
||||
@@ -18,6 +21,8 @@ import {
|
||||
THEME_CHANGE_DELAY,
|
||||
TITLE_BAR_HEIGHT,
|
||||
} from '@/const/theme';
|
||||
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
|
||||
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from '../App';
|
||||
@@ -41,7 +46,6 @@ export default class Browser {
|
||||
private app: App;
|
||||
private _browserWindow?: BrowserWindow;
|
||||
private themeListenerSetup = false;
|
||||
private stopInterceptHandler;
|
||||
identifier: string;
|
||||
options: BrowserWindowOpts;
|
||||
private readonly windowStateKey: string;
|
||||
@@ -167,11 +171,14 @@ export default class Browser {
|
||||
}
|
||||
|
||||
loadUrl = async (path: string) => {
|
||||
const initUrl = this.app.nextServerUrl + path;
|
||||
const initUrl = await this.app.buildRendererUrl(path);
|
||||
|
||||
console.log('[Browser] initUrl', initUrl);
|
||||
|
||||
try {
|
||||
logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`);
|
||||
await this._browserWindow.loadURL(initUrl);
|
||||
|
||||
logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error);
|
||||
@@ -295,7 +302,6 @@ export default class Browser {
|
||||
*/
|
||||
destroy() {
|
||||
logger.debug(`Destroying window instance: ${this.identifier}`);
|
||||
this.stopInterceptHandler?.();
|
||||
this.cleanupThemeListener();
|
||||
this._browserWindow = undefined;
|
||||
}
|
||||
@@ -339,6 +345,7 @@ export default class Browser {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
sandbox: false,
|
||||
},
|
||||
width: savedState?.width || width,
|
||||
...this.getPlatformThemeConfig(isDarkMode),
|
||||
@@ -354,13 +361,10 @@ export default class Browser {
|
||||
// Apply initial visual effects
|
||||
this.applyVisualEffects();
|
||||
|
||||
logger.debug(`[${this.identifier}] Setting up nextInterceptor.`);
|
||||
this.stopInterceptHandler = this.app.nextInterceptor({
|
||||
session: browserWindow.webContents.session,
|
||||
});
|
||||
|
||||
// Setup CORS bypass for local file server
|
||||
this.setupCORSBypass(browserWindow);
|
||||
// Setup request hook for remote server sync (base URL rewrite + OIDC header)
|
||||
this.setupRemoteServerRequestHook(browserWindow);
|
||||
|
||||
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
|
||||
this.loadPlaceholder().then(() => {
|
||||
@@ -409,8 +413,7 @@ export default class Browser {
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
|
||||
}
|
||||
// Need to clean up intercept handler and theme manager
|
||||
this.stopInterceptHandler?.();
|
||||
// Need to clean up theme manager
|
||||
this.cleanupThemeListener();
|
||||
return;
|
||||
}
|
||||
@@ -445,8 +448,7 @@ export default class Browser {
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
|
||||
}
|
||||
// Need to clean up intercept handler and theme manager
|
||||
this.stopInterceptHandler?.();
|
||||
// Need to clean up theme manager
|
||||
this.cleanupThemeListener();
|
||||
}
|
||||
});
|
||||
@@ -471,6 +473,11 @@ export default class Browser {
|
||||
});
|
||||
}
|
||||
|
||||
setWindowResizable(resizable: boolean) {
|
||||
logger.debug(`[${this.identifier}] Setting window resizable: ${resizable}`);
|
||||
this._browserWindow?.setResizable(resizable);
|
||||
}
|
||||
|
||||
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
|
||||
if (this._browserWindow.isDestroyed()) return;
|
||||
|
||||
@@ -528,4 +535,27 @@ export default class Browser {
|
||||
|
||||
logger.debug(`[${this.identifier}] CORS bypass setup completed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite tRPC requests to remote server and inject OIDC token via webRequest hooks.
|
||||
* Replaces the previous proxyTRPCRequest IPC forwarding.
|
||||
*/
|
||||
private setupRemoteServerRequestHook(browserWindow: BrowserWindow) {
|
||||
const session = browserWindow.webContents.session;
|
||||
const remoteServerConfigCtr = this.app.getController(RemoteServerConfigCtr);
|
||||
|
||||
const targetSession = session || electronSession.defaultSession;
|
||||
if (!targetSession) return;
|
||||
|
||||
backendProxyProtocolManager.registerWithRemoteBaseUrl(targetSession, {
|
||||
getAccessToken: () => remoteServerConfigCtr.getAccessToken(),
|
||||
getRemoteBaseUrl: async () => {
|
||||
const config = await remoteServerConfigCtr.getRemoteServerConfig();
|
||||
const remoteServerUrl = await remoteServerConfigCtr.getRemoteServerUrl(config);
|
||||
return remoteServerUrl || null;
|
||||
},
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
source: this.identifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,16 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
setWindowSize(identifier: string, size: { height?: number; width?: number }) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
browser?.setWindowSize(size);
|
||||
}
|
||||
|
||||
setWindowResizable(identifier: string, resizable: boolean) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
browser?.setWindowResizable(resizable);
|
||||
}
|
||||
|
||||
getIdentifierByWebContents(webContents: WebContents): string | null {
|
||||
return this.webContentsMap.get(webContents) || null;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,11 @@ describe('Browser', () => {
|
||||
let mockApp: AppCore;
|
||||
let mockStoreManagerGet: ReturnType<typeof vi.fn>;
|
||||
let mockStoreManagerSet: ReturnType<typeof vi.fn>;
|
||||
let mockNextInterceptor: ReturnType<typeof vi.fn>;
|
||||
let mockRemoteServerConfigCtr: {
|
||||
getAccessToken: ReturnType<typeof vi.fn>;
|
||||
getRemoteServerConfig: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let autoLoadUrlSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
const defaultOptions: BrowserWindowOpts = {
|
||||
height: 600,
|
||||
@@ -133,14 +137,34 @@ describe('Browser', () => {
|
||||
// Create mock App
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
|
||||
mockStoreManagerSet = vi.fn();
|
||||
mockNextInterceptor = vi.fn().mockReturnValue(vi.fn());
|
||||
|
||||
// Browser setup now installs protocol handlers that depend on RemoteServerConfigCtr
|
||||
mockRemoteServerConfigCtr = {
|
||||
getAccessToken: vi.fn().mockResolvedValue(null),
|
||||
getRemoteServerConfig: vi.fn().mockResolvedValue({
|
||||
remoteServerUrl: 'http://localhost:3000',
|
||||
}),
|
||||
};
|
||||
|
||||
// Ensure Browser can register protocol handlers on the session
|
||||
(mockBrowserWindow.webContents.session as any).protocol = {
|
||||
handle: vi.fn(),
|
||||
};
|
||||
|
||||
mockApp = {
|
||||
browserManager: {
|
||||
retrieveByIdentifier: vi.fn(),
|
||||
},
|
||||
buildRendererUrl: vi.fn(async (path: string) => {
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `http://localhost:3000${cleanPath}`;
|
||||
}),
|
||||
getController: vi.fn((ctr: any) => {
|
||||
// Only the remote server config controller is required in these unit tests
|
||||
if (ctr?.name === 'RemoteServerConfigCtr') return mockRemoteServerConfigCtr;
|
||||
throw new Error(`Unexpected controller requested in Browser tests: ${ctr?.name ?? ctr}`);
|
||||
}),
|
||||
isQuiting: false,
|
||||
nextInterceptor: mockNextInterceptor,
|
||||
nextServerUrl: 'http://localhost:3000',
|
||||
storeManager: {
|
||||
get: mockStoreManagerGet,
|
||||
@@ -149,6 +173,8 @@ describe('Browser', () => {
|
||||
} as unknown as AppCore;
|
||||
|
||||
browser = new Browser(defaultOptions, mockApp);
|
||||
// The constructor triggers an async placeholder->loadUrl chain; stub it to avoid cross-test flakiness.
|
||||
autoLoadUrlSpy = vi.spyOn(browser, 'loadUrl').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -164,10 +190,6 @@ describe('Browser', () => {
|
||||
it('should create BrowserWindow on construction', () => {
|
||||
expect(MockBrowserWindow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should setup next interceptor', () => {
|
||||
expect(mockNextInterceptor).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserWindow getter', () => {
|
||||
@@ -344,12 +366,14 @@ describe('Browser', () => {
|
||||
|
||||
describe('loadUrl', () => {
|
||||
it('should load full URL successfully', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
expect(mockBrowserWindow.loadURL).toHaveBeenCalledWith('http://localhost:3000/test-path');
|
||||
});
|
||||
|
||||
it('should load error page on failure', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
@@ -358,6 +382,7 @@ describe('Browser', () => {
|
||||
});
|
||||
|
||||
it('should setup retry handler on error', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
@@ -367,9 +392,13 @@ describe('Browser', () => {
|
||||
});
|
||||
|
||||
it('should load fallback HTML when error page fails', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
mockBrowserWindow.loadFile.mockRejectedValueOnce(new Error('Error page failed'));
|
||||
mockBrowserWindow.loadURL.mockResolvedValueOnce(undefined);
|
||||
mockBrowserWindow.loadFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === '/mock/resources/error.html') throw new Error('Error page failed');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Session } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
interface BackendProxyProtocolManagerOptions {
|
||||
getAccessToken: () => Promise<string | undefined | null>;
|
||||
rewriteUrl: (rawUrl: string) => Promise<string | null>;
|
||||
scheme: string;
|
||||
/**
|
||||
* Used for log prefixes. e.g. window identifier
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface BackendProxyProtocolManagerRemoteBaseOptions {
|
||||
getAccessToken: () => Promise<string | undefined | null>;
|
||||
getRemoteBaseUrl: () => Promise<string | undefined | null>;
|
||||
scheme: string;
|
||||
/**
|
||||
* Used for log prefixes. e.g. window identifier
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage `lobe-backend://` (or any custom scheme) transparent proxy handler registration.
|
||||
* Keeps a WeakSet per session to avoid duplicate handler registration.
|
||||
*/
|
||||
export class BackendProxyProtocolManager {
|
||||
private readonly handledSessions = new WeakSet<Session>();
|
||||
private readonly logger = createLogger('core:BackendProxyProtocolManager');
|
||||
|
||||
registerWithRemoteBaseUrl(
|
||||
session: Session,
|
||||
options: BackendProxyProtocolManagerRemoteBaseOptions,
|
||||
) {
|
||||
let lastRemoteBaseUrl: string | undefined;
|
||||
|
||||
const rewriteUrl = async (rawUrl: string) => {
|
||||
lastRemoteBaseUrl = undefined;
|
||||
try {
|
||||
const requestUrl = new URL(rawUrl);
|
||||
|
||||
const remoteBaseUrl = await options.getRemoteBaseUrl();
|
||||
if (!remoteBaseUrl) return null;
|
||||
lastRemoteBaseUrl = remoteBaseUrl;
|
||||
|
||||
const remoteBase = new URL(remoteBaseUrl);
|
||||
if (requestUrl.origin === remoteBase.origin) return null;
|
||||
|
||||
const rewrittenUrl = new URL(
|
||||
requestUrl.pathname + requestUrl.search,
|
||||
remoteBase,
|
||||
).toString();
|
||||
this.logger.debug(
|
||||
`${options.source ? `[${options.source}] ` : ''}BackendProxy rewrite ${rawUrl} -> ${rewrittenUrl}`,
|
||||
);
|
||||
return rewrittenUrl;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`${options.source ? `[${options.source}] ` : ''}BackendProxy rewriteUrl error (rawUrl=${rawUrl}, remoteBaseUrl=${lastRemoteBaseUrl})`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
this.register(session, {
|
||||
getAccessToken: options.getAccessToken,
|
||||
rewriteUrl,
|
||||
scheme: options.scheme,
|
||||
source: options.source,
|
||||
});
|
||||
}
|
||||
|
||||
register(session: Session, options: BackendProxyProtocolManagerOptions) {
|
||||
if (!session || this.handledSessions.has(session)) return;
|
||||
|
||||
const logPrefix = options.source ? `[${options.source}] BackendProxy` : '[BackendProxy]';
|
||||
|
||||
session.protocol.handle(options.scheme, async (request: Request): Promise<Response | null> => {
|
||||
try {
|
||||
const rewrittenUrl = await options.rewriteUrl(request.url);
|
||||
if (!rewrittenUrl) return null;
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
const token = await options.getAccessToken();
|
||||
if (token) headers.set('Oidc-Auth', token);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
headers,
|
||||
method: request.method,
|
||||
};
|
||||
|
||||
// Only forward body for non-GET/HEAD requests
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
const body = request.body ?? undefined;
|
||||
if (body) {
|
||||
requestInit.body = body;
|
||||
// Node.js (undici) requires `duplex` when sending a streaming body
|
||||
requestInit.duplex = 'half';
|
||||
}
|
||||
}
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(rewrittenUrl, requestInit);
|
||||
} catch (error) {
|
||||
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
|
||||
|
||||
return new Response('Upstream fetch failed, target url: ' + rewrittenUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
});
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers(upstreamResponse.headers);
|
||||
const allowOrigin = request.headers.get('Origin') || undefined;
|
||||
|
||||
if (allowOrigin) {
|
||||
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
|
||||
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', '*');
|
||||
responseHeaders.set('X-Src-Url', rewrittenUrl);
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
headers: responseHeaders,
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`${logPrefix} protocol.handle error:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.debug(`${logPrefix} protocol handler registered for ${options.scheme}`);
|
||||
this.handledSessions.add(session);
|
||||
}
|
||||
}
|
||||
|
||||
export const backendProxyProtocolManager = new BackendProxyProtocolManager();
|
||||
@@ -0,0 +1,250 @@
|
||||
import { app, protocol } from 'electron';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { getExportMimeType } from '../../utils/mime';
|
||||
|
||||
type ResolveRendererFilePath = (url: URL) => Promise<string | null>;
|
||||
|
||||
const RENDERER_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
} as const;
|
||||
|
||||
interface RendererProtocolManagerOptions {
|
||||
host?: string;
|
||||
nextExportDir: string;
|
||||
resolveRendererFilePath: ResolveRendererFilePath;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
const RENDERER_DIR = 'next';
|
||||
export class RendererProtocolManager {
|
||||
private readonly scheme: string;
|
||||
private readonly host: string;
|
||||
private readonly nextExportDir: string;
|
||||
private readonly resolveRendererFilePath: ResolveRendererFilePath;
|
||||
private handlerRegistered = false;
|
||||
|
||||
constructor(options: RendererProtocolManagerOptions) {
|
||||
const { nextExportDir, resolveRendererFilePath } = options;
|
||||
|
||||
this.scheme = 'app';
|
||||
this.host = RENDERER_DIR;
|
||||
this.nextExportDir = nextExportDir;
|
||||
this.resolveRendererFilePath = resolveRendererFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full renderer URL with scheme and host
|
||||
*/
|
||||
getRendererUrl(): string {
|
||||
return `${this.scheme}://${this.host}`;
|
||||
}
|
||||
|
||||
get protocolScheme() {
|
||||
return {
|
||||
privileges: RENDERER_PROTOCOL_PRIVILEGES,
|
||||
scheme: this.scheme,
|
||||
};
|
||||
}
|
||||
registerHandler() {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
if (!pathExistsSync(this.nextExportDir)) {
|
||||
createLogger('core:RendererProtocolManager').warn(
|
||||
`Next export directory not found, skip static handler: ${this.nextExportDir}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = createLogger('core:RendererProtocolManager');
|
||||
logger.debug(
|
||||
`Registering renderer ${this.scheme}:// handler for production export at host ${this.host}`,
|
||||
);
|
||||
|
||||
const register = () => {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
protocol.handle(this.scheme, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const hostname = url.hostname;
|
||||
const pathname = url.pathname;
|
||||
const isAssetRequest = this.isAssetRequest(pathname);
|
||||
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
|
||||
|
||||
if (hostname !== this.host) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const buildFileResponse = async (targetPath: string) => {
|
||||
const fileStat = await stat(targetPath);
|
||||
const totalSize = fileStat.size;
|
||||
|
||||
const buffer = await readFile(targetPath);
|
||||
const headers = new Headers();
|
||||
const mimeType = getExportMimeType(targetPath);
|
||||
|
||||
if (mimeType) headers.set('Content-Type', mimeType);
|
||||
|
||||
// Chromium media pipeline relies on byte ranges for video/audio.
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
|
||||
const method = request.method?.toUpperCase?.() || 'GET';
|
||||
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
|
||||
|
||||
// HEAD (no range): return only headers
|
||||
if (method === 'HEAD' && !rangeHeader) {
|
||||
headers.set('Content-Length', String(totalSize));
|
||||
return new Response(null, { headers, status: 200 });
|
||||
}
|
||||
|
||||
// No Range: return entire file
|
||||
if (!rangeHeader) {
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
return new Response(buffer, { headers, status: 200 });
|
||||
}
|
||||
|
||||
// Range: bytes=start-end | bytes=-suffixLength
|
||||
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
|
||||
if (!match) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
|
||||
const [, startRaw, endRaw] = match;
|
||||
let start = startRaw ? Number(startRaw) : NaN;
|
||||
let end = endRaw ? Number(endRaw) : NaN;
|
||||
|
||||
// Suffix range: bytes=-N (last N bytes)
|
||||
if (!startRaw && endRaw) {
|
||||
const suffixLength = Number(endRaw);
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
start = Math.max(totalSize - suffixLength, 0);
|
||||
end = totalSize - 1;
|
||||
} else {
|
||||
if (!Number.isFinite(start)) start = 0;
|
||||
if (!Number.isFinite(end)) end = totalSize - 1;
|
||||
}
|
||||
|
||||
if (start < 0 || end < 0 || start > end || start >= totalSize) {
|
||||
headers.set('Content-Range', `bytes */${totalSize}`);
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: 416,
|
||||
statusText: 'Range Not Satisfiable',
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, totalSize - 1);
|
||||
const sliced = buffer.subarray(start, end + 1);
|
||||
|
||||
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
|
||||
headers.set('Content-Length', String(sliced.byteLength));
|
||||
|
||||
if (method === 'HEAD') {
|
||||
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
|
||||
}
|
||||
|
||||
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
|
||||
};
|
||||
|
||||
const resolveEntryFilePath = () =>
|
||||
this.resolveRendererFilePath(new URL(`${this.scheme}://${this.host}/`));
|
||||
|
||||
let filePath = await this.resolveRendererFilePath(url);
|
||||
|
||||
// If the resolved file is the export 404 page, treat it as missing so we can
|
||||
// fall back to the entry HTML for SPA routing (unless explicitly requested).
|
||||
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
|
||||
filePath = null;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Fallback to entry HTML for unknown routes (SPA-like behavior)
|
||||
filePath = await resolveEntryFilePath();
|
||||
if (!filePath || this.is404Html(filePath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildFileResponse(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as any).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
|
||||
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackPath = await resolveEntryFilePath();
|
||||
if (!fallbackPath || this.is404Html(fallbackPath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildFileResponse(fallbackPath);
|
||||
} catch (fallbackError) {
|
||||
logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Failed to serve export asset ${filePath}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
this.handlerRegistered = true;
|
||||
};
|
||||
|
||||
if (app.isReady()) {
|
||||
register();
|
||||
} else {
|
||||
// protocol.handle needs the default session, which is only available after ready
|
||||
|
||||
app.whenReady().then(register);
|
||||
}
|
||||
}
|
||||
|
||||
private isAssetRequest(pathname: string) {
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
const ext = extname(normalizedPathname);
|
||||
|
||||
return (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/manifest.json' ||
|
||||
!!ext
|
||||
);
|
||||
}
|
||||
|
||||
private is404Html(filePath: string) {
|
||||
return basename(filePath) === '404.html';
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,12 @@ const getAllowedOrigin = (rawOrigin?: string) => {
|
||||
};
|
||||
|
||||
export class StaticFileServerManager {
|
||||
private app: App;
|
||||
private fileService: FileService;
|
||||
private httpServer: any = null;
|
||||
private serverPort: number = 0;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
this.fileService = app.getService(FileService);
|
||||
logger.debug('StaticFileServerManager initialized');
|
||||
}
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BackendProxyProtocolManager } from '../BackendProxyProtocolManager';
|
||||
|
||||
interface RequestInitWithDuplex extends RequestInit {
|
||||
duplex?: 'half';
|
||||
}
|
||||
|
||||
type FetchMock = (input: RequestInfo | URL, init?: RequestInitWithDuplex) => Promise<Response>;
|
||||
|
||||
const { mockProtocol, protocolHandlerRef } = vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BackendProxyProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
it('should rewrite url to remote base and inject Oidc-Auth token', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async () => {
|
||||
return new Response('ok', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => 'token-123',
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
expect(mockProtocol.handle).toHaveBeenCalledWith('lobe-backend', expect.any(Function));
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers({ 'Origin': 'app://desktop', 'X-Test': '1' }),
|
||||
method: 'GET',
|
||||
url: 'lobe-backend://app/trpc/hello?batch=1',
|
||||
} as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, init] = fetchMock.mock.calls[0]!;
|
||||
expect(calledUrl).toBe('https://remote.example.com/trpc/hello?batch=1');
|
||||
expect(init).toBeDefined();
|
||||
if (!init) throw new Error('Expected fetch init to be defined');
|
||||
|
||||
expect(init.method).toBe('GET');
|
||||
const headers = init.headers as Headers;
|
||||
expect(headers.get('Oidc-Auth')).toBe('token-123');
|
||||
expect(headers.get('X-Test')).toBe('1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('X-Src-Url')).toBe('https://remote.example.com/trpc/hello?batch=1');
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('app://desktop');
|
||||
expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true');
|
||||
expect(await response.text()).toBe('ok');
|
||||
});
|
||||
|
||||
it('should forward body and set duplex for non-GET requests', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn<FetchMock>(async () => new Response('ok', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'POST',
|
||||
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
|
||||
body: 'payload' as any,
|
||||
url: 'lobe-backend://app/api/upload',
|
||||
} as any);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(init).toBeDefined();
|
||||
if (!init) throw new Error('Expected fetch init to be defined');
|
||||
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe('payload');
|
||||
expect(init.duplex).toBe('half');
|
||||
});
|
||||
|
||||
it('should return null when remote base url is missing', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => 'token',
|
||||
getRemoteBaseUrl: async () => null,
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when request url is already the remote origin', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
const res = await handler({
|
||||
method: 'GET',
|
||||
url: 'https://remote.example.com/trpc/hello?x=1',
|
||||
} as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when rewrite fails (invalid remote base url)', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'not-a-url',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond with 502 when upstream fetch throws', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'lobe-backend://app/trpc/hello',
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(await response.text()).toContain('Upstream fetch failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RendererProtocolManager } from '../RendererProtocolManager';
|
||||
|
||||
const { mockApp, mockPathExistsSync, mockProtocol, mockReadFile, mockStat, protocolHandlerRef } =
|
||||
vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockPathExistsSync: vi.fn().mockReturnValue(true),
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
protocol: mockProtocol,
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
pathExistsSync: mockPathExistsSync,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('RendererProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
mockPathExistsSync.mockReturnValue(true);
|
||||
mockStat.mockImplementation(async () => ({ size: 1024 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
it('should fall back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (url: URL) => {
|
||||
if (url.pathname === '/missing') return '/export/404.html';
|
||||
if (url.pathname === '/') return '/export/index.html';
|
||||
return null;
|
||||
});
|
||||
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
expect(mockProtocol.handle).toHaveBeenCalled();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://next/missing',
|
||||
} as any);
|
||||
const body = await response.text();
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(2);
|
||||
expect(resolveRendererFilePath.mock.calls[0][0].pathname).toBe('/missing');
|
||||
expect(resolveRendererFilePath.mock.calls[1][0].pathname).toBe('/');
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/export/index.html');
|
||||
expect(body).toContain('/export/index.html');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should serve 404.html when explicitly requested', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (url: URL) => {
|
||||
if (url.pathname === '/404.html') return '/export/404.html';
|
||||
if (url.pathname === '/') return '/export/index.html';
|
||||
return null;
|
||||
});
|
||||
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'app://next/404.html',
|
||||
} as any);
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/export/404.html');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 404 for missing asset requests without fallback', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (_url: URL) => null);
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({ url: 'app://next/logo.png' } as any);
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should support Range requests for media assets', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (_url: URL) => '/export/intro-video.mp4');
|
||||
const payload = Buffer.from('0123456789');
|
||||
|
||||
mockStat.mockImplementation(async () => ({ size: payload.length }));
|
||||
mockReadFile.mockImplementation(async () => payload);
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers({ Range: 'bytes=0-1' }),
|
||||
method: 'GET',
|
||||
url: 'app://next/_next/static/media/intro-video.mp4',
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
expect(response.headers.get('Content-Range')).toBe('bytes 0-1/10');
|
||||
expect(response.headers.get('Content-Length')).toBe('2');
|
||||
expect(response.headers.get('Content-Type')).toBe('video/mp4');
|
||||
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
expect(buf.toString()).toBe('01');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createEnv } from '@t3-oss/env-core';
|
||||
import { memoize } from 'es-toolkit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const normalizeEnvString = (input: unknown) => {
|
||||
if (typeof input !== 'string') return undefined;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const envBoolean = (defaultValue: boolean) =>
|
||||
z
|
||||
.preprocess((input) => {
|
||||
const str = normalizeEnvString(input);
|
||||
if (!str) return undefined;
|
||||
|
||||
switch (str.toLowerCase()) {
|
||||
case '1':
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case 'y':
|
||||
case 'on': {
|
||||
return true;
|
||||
}
|
||||
|
||||
case '0':
|
||||
case 'false':
|
||||
case 'no':
|
||||
case 'n':
|
||||
case 'off': {
|
||||
return false;
|
||||
}
|
||||
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}, z.boolean().optional())
|
||||
.default(defaultValue);
|
||||
|
||||
const envNumber = (defaultValue: number) =>
|
||||
z
|
||||
.preprocess((input) => {
|
||||
const str = normalizeEnvString(input);
|
||||
if (!str) return undefined;
|
||||
const num = Number(str);
|
||||
if (!Number.isFinite(num)) return undefined;
|
||||
return num;
|
||||
}, z.number().optional())
|
||||
.default(defaultValue);
|
||||
|
||||
/**
|
||||
* Desktop (Electron main process) runtime env access.
|
||||
*
|
||||
* Important:
|
||||
* - Keep schemas tolerant (optional + defaults) to avoid throwing in tests/dev.
|
||||
* - Prefer reading env at call-time (factory) so tests can mutate process.env safely.
|
||||
*/
|
||||
export const getDesktopEnv = memoize(() =>
|
||||
createEnv({
|
||||
server: {
|
||||
DEBUG_VERBOSE: envBoolean(false),
|
||||
|
||||
// keep optional to preserve existing behavior:
|
||||
// - unset NODE_ENV should behave like "not production" in logger runtime paths
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
|
||||
|
||||
// escape hatch: allow testing static renderer in dev via env
|
||||
DESKTOP_RENDERER_STATIC: envBoolean(false),
|
||||
|
||||
// updater
|
||||
UPDATE_CHANNEL: z.string().optional(),
|
||||
|
||||
// mcp client
|
||||
MCP_TOOL_TIMEOUT: envNumber(60_000),
|
||||
|
||||
// cloud server url (can be overridden for selfhost/dev)
|
||||
OFFICIAL_CLOUD_SERVER: z.string().optional().default('https://lobechat.com'),
|
||||
},
|
||||
clientPrefix: 'PUBLIC_',
|
||||
client: {},
|
||||
runtimeEnv: process.env,
|
||||
emptyStringAsUndefined: true,
|
||||
isServer: true,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import {
|
||||
StdioClientTransport,
|
||||
getDefaultEnvironment,
|
||||
} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Progress } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
import type { MCPClientParams, McpPrompt, McpResource, McpTool, ToolCallResult } from './types';
|
||||
|
||||
export class MCPClient {
|
||||
private readonly mcp: Client;
|
||||
|
||||
private transport: Transport;
|
||||
|
||||
constructor(params: MCPClientParams) {
|
||||
this.mcp = new Client({ name: 'lobehub-desktop-mcp-client', version: '1.0.0' });
|
||||
|
||||
switch (params.type) {
|
||||
case 'http': {
|
||||
const headers: Record<string, string> = { ...params.headers };
|
||||
|
||||
if (params.auth) {
|
||||
if (params.auth.type === 'bearer' && params.auth.token) {
|
||||
headers['Authorization'] = `Bearer ${params.auth.token}`;
|
||||
}
|
||||
|
||||
if (params.auth.type === 'oauth2' && params.auth.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${params.auth.accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.transport = new StreamableHTTPClientTransport(new URL(params.url), {
|
||||
requestInit: { headers },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stdio': {
|
||||
this.transport = new StdioClientTransport({
|
||||
args: params.args,
|
||||
command: params.command,
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
...params.env,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _never: never = params;
|
||||
throw new Error(`Unsupported MCP connection type: ${(params as any).type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isMethodNotFoundError(error: unknown) {
|
||||
const err = error as any;
|
||||
if (!err) return false;
|
||||
if (err.code === -32601) return true;
|
||||
if (typeof err.message === 'string' && err.message.includes('Method not found')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async initialize(options: { onProgress?: (progress: Progress) => void } = {}) {
|
||||
await this.mcp.connect(this.transport, { onprogress: options.onProgress });
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (typeof (this.mcp as any).disconnect === 'function') {
|
||||
await (this.mcp as any).disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.transport && typeof (this.transport as any).close === 'function') {
|
||||
(this.transport as any).close();
|
||||
}
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
const { tools } = await this.mcp.listTools();
|
||||
return (tools || []) as McpTool[];
|
||||
}
|
||||
|
||||
async listResources() {
|
||||
const { resources } = await this.mcp.listResources();
|
||||
return (resources || []) as McpResource[];
|
||||
}
|
||||
|
||||
async listPrompts() {
|
||||
const { prompts } = await this.mcp.listPrompts();
|
||||
return (prompts || []) as McpPrompt[];
|
||||
}
|
||||
|
||||
async listManifests() {
|
||||
const [tools, prompts, resources] = await Promise.all([
|
||||
this.listTools(),
|
||||
this.listPrompts().catch((error) => {
|
||||
if (this.isMethodNotFoundError(error)) return [] as McpPrompt[];
|
||||
throw error;
|
||||
}),
|
||||
this.listResources().catch((error) => {
|
||||
if (this.isMethodNotFoundError(error)) return [] as McpResource[];
|
||||
throw error;
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
prompts: prompts.length === 0 ? undefined : prompts,
|
||||
resources: resources.length === 0 ? undefined : resources,
|
||||
title: this.mcp.getServerVersion()?.title,
|
||||
tools: tools.length === 0 ? undefined : tools,
|
||||
version: this.mcp.getServerVersion()?.version?.replace('v', ''),
|
||||
};
|
||||
}
|
||||
|
||||
async callTool(toolName: string, args: any): Promise<ToolCallResult> {
|
||||
const result = await this.mcp.callTool({ arguments: args, name: toolName }, undefined, {
|
||||
timeout: getDesktopEnv().MCP_TOOL_TIMEOUT,
|
||||
});
|
||||
return result as ToolCallResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export interface McpTool {
|
||||
description: string;
|
||||
inputSchema: {
|
||||
[k: string]: unknown;
|
||||
properties?: unknown | null;
|
||||
type: 'object';
|
||||
};
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface McpResource {
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
name: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface McpPromptArgument {
|
||||
description?: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface McpPrompt {
|
||||
arguments?: McpPromptArgument[];
|
||||
description?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TextContent {
|
||||
_meta?: any;
|
||||
text: string;
|
||||
type: 'text';
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
_meta?: any;
|
||||
/**
|
||||
* Usually base64 data from MCP server (without data: prefix)
|
||||
*/
|
||||
data: string;
|
||||
mimeType: string;
|
||||
type: 'image';
|
||||
}
|
||||
|
||||
export interface AudioContent {
|
||||
_meta?: any;
|
||||
/**
|
||||
* Usually base64 data from MCP server (without data: prefix)
|
||||
*/
|
||||
data: string;
|
||||
mimeType: string;
|
||||
type: 'audio';
|
||||
}
|
||||
|
||||
export interface ResourceContent {
|
||||
_meta?: any;
|
||||
resource: {
|
||||
_meta?: any;
|
||||
blob?: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
uri: string;
|
||||
};
|
||||
type: 'resource';
|
||||
}
|
||||
|
||||
export interface ResourceLinkContent {
|
||||
_meta?: any;
|
||||
description?: string;
|
||||
icons?: Array<{
|
||||
mimeType?: string;
|
||||
sizes?: string[];
|
||||
src: string;
|
||||
}>;
|
||||
name: string;
|
||||
title?: string;
|
||||
type: 'resource_link';
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export type ToolCallContent =
|
||||
| TextContent
|
||||
| ImageContent
|
||||
| AudioContent
|
||||
| ResourceContent
|
||||
| ResourceLinkContent;
|
||||
|
||||
export interface ToolCallResult {
|
||||
content: ToolCallContent[];
|
||||
isError?: boolean;
|
||||
structuredContent?: any;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
type: 'none' | 'bearer' | 'oauth2';
|
||||
}
|
||||
|
||||
export interface HttpMCPClientParams {
|
||||
auth?: AuthConfig;
|
||||
headers?: Record<string, string>;
|
||||
name: string;
|
||||
type: 'http';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StdioMCPClientParams {
|
||||
args: string[];
|
||||
command: string;
|
||||
env?: Record<string, string>;
|
||||
name: string;
|
||||
type: 'stdio';
|
||||
}
|
||||
|
||||
export type MCPClientParams = HttpMCPClientParams | StdioMCPClientParams;
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ const createMockApp = () => {
|
||||
},
|
||||
browserManager: {
|
||||
getMainWindow: vi.fn(() => ({
|
||||
broadcast: vi.fn(),
|
||||
loadUrl: vi.fn(),
|
||||
show: vi.fn(),
|
||||
})),
|
||||
|
||||
@@ -83,8 +83,8 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
accelerator: 'Command+,',
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl('/settings');
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('macOS.preferences'),
|
||||
},
|
||||
@@ -341,8 +341,8 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl('/settings');
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
},
|
||||
|
||||
@@ -24,6 +24,17 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
return false;
|
||||
};
|
||||
|
||||
const ensureResultsOrSkipAssertions = (results: unknown[], hint: string) => {
|
||||
if (results.length > 0) return true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`⚠️ Spotlight returned 0 results for "${hint}". This usually means indexing is incomplete/disabled. Skipping strict assertions.`,
|
||||
);
|
||||
// Keep a minimal assertion so we still validate the call didn't throw.
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
return false;
|
||||
};
|
||||
|
||||
describe('checkSearchServiceStatus', () => {
|
||||
it('should verify Spotlight is available on macOS', async () => {
|
||||
const isAvailable = await searchService.checkSearchServiceStatus();
|
||||
@@ -40,7 +51,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'package.json search')) return;
|
||||
if (!ensureResultsOrSkipAssertions(results, 'package.json')) return;
|
||||
|
||||
// Should find at least one package.json
|
||||
const packageJson = results.find((r) => r.name === 'package.json');
|
||||
@@ -55,7 +66,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
if (!ensureResults(results, 'README search')) return;
|
||||
if (!ensureResultsOrSkipAssertions(results, 'README')) return;
|
||||
|
||||
// Should contain markdown files
|
||||
const mdFile = results.find((r) => r.type === 'md');
|
||||
@@ -70,7 +81,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'TypeScript file search')) return;
|
||||
if (!ensureResultsOrSkipAssertions(results, 'macOS')) return;
|
||||
|
||||
// Should find the macOS.ts implementation file
|
||||
const macOSFile = results.find((r) => r.name.includes('macOS') && r.type === 'ts');
|
||||
@@ -112,7 +123,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'test file search')) return;
|
||||
if (!ensureResultsOrSkipAssertions(results, 'test.ts')) return;
|
||||
|
||||
// Should find test files (can be in __tests__ directory or co-located with source files)
|
||||
const testFile = results.find((r) => r.name.endsWith('.test.ts'));
|
||||
@@ -230,7 +241,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'file metadata read')) return;
|
||||
if (!ensureResultsOrSkipAssertions(results, 'package.json (metadata)')) return;
|
||||
|
||||
const file = results[0];
|
||||
|
||||
@@ -288,7 +299,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'fuzzy search accuracy')) return;
|
||||
if (!ensureResultsOrSkipAssertions(results, 'LocalFile')) return;
|
||||
|
||||
// Should find LocalFileCtr.ts or similar files
|
||||
const found = results.some(
|
||||
@@ -328,8 +339,8 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
});
|
||||
|
||||
// Both searches should find similar files
|
||||
if (!ensureResults(lowerResults, 'case-insensitive search (lower)')) return;
|
||||
if (!ensureResults(upperResults, 'case-insensitive search (upper)')) return;
|
||||
if (!ensureResultsOrSkipAssertions(lowerResults, 'readme')) return;
|
||||
if (!ensureResultsOrSkipAssertions(upperResults, 'README (case-insensitive)')) return;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isDev } from '@/const/env';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
// 更新频道(stable, beta, alpha 等)
|
||||
export const UPDATE_CHANNEL = process.env.UPDATE_CHANNEL;
|
||||
export const UPDATE_CHANNEL = getDesktopEnv().UPDATE_CHANNEL;
|
||||
|
||||
export const updaterConfig = {
|
||||
// 应用更新配置
|
||||
|
||||
@@ -2,9 +2,12 @@ import debug from 'debug';
|
||||
import electronLog from 'electron-log';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
vi.mock('debug');
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: {
|
||||
transports: {
|
||||
@@ -18,17 +21,45 @@ vi.mock('electron-log', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/env', () => ({
|
||||
getDesktopEnv: vi.fn().mockReturnValue({
|
||||
NODE_ENV: undefined,
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetDesktopEnv = vi.mocked(getDesktopEnv);
|
||||
|
||||
describe('logger', () => {
|
||||
const mockDebugLogger = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(debug).mockReturnValue(mockDebugLogger as any);
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: undefined,
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV;
|
||||
delete process.env.DEBUG_VERBOSE;
|
||||
// Reset to default state
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: undefined,
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLogger', () => {
|
||||
@@ -73,7 +104,14 @@ describe('logger', () => {
|
||||
|
||||
describe('logger.error', () => {
|
||||
it('should use electronLog.error in production', () => {
|
||||
(process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV = 'production';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const logger = createLogger('test:error');
|
||||
logger.error('error message', { error: 'details' });
|
||||
|
||||
@@ -82,7 +120,14 @@ describe('logger', () => {
|
||||
});
|
||||
|
||||
it('should use console.error in development', () => {
|
||||
(process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV = 'development';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: 'development',
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const logger = createLogger('test:error');
|
||||
logger.error('error message', { error: 'details' });
|
||||
@@ -94,6 +139,14 @@ describe('logger', () => {
|
||||
});
|
||||
|
||||
it('should default to console.error when NODE_ENV is not set', () => {
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: undefined,
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const logger = createLogger('test:error');
|
||||
logger.error('error message');
|
||||
@@ -107,7 +160,14 @@ describe('logger', () => {
|
||||
|
||||
describe('logger.info', () => {
|
||||
it('should use electronLog.info with namespace in production', () => {
|
||||
(process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV = 'production';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const logger = createLogger('test:info');
|
||||
logger.info('info message', { data: 'value' });
|
||||
|
||||
@@ -118,7 +178,14 @@ describe('logger', () => {
|
||||
});
|
||||
|
||||
it('should use debug logger in development', () => {
|
||||
(process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV = 'development';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: 'development',
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const logger = createLogger('test:info');
|
||||
logger.info('info message', { data: 'value' });
|
||||
|
||||
@@ -143,7 +210,14 @@ describe('logger', () => {
|
||||
});
|
||||
|
||||
it('should call debug logger when DEBUG_VERBOSE is set', () => {
|
||||
process.env.DEBUG_VERBOSE = 'true';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: undefined,
|
||||
DEBUG_VERBOSE: true,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const logger = createLogger('test:verbose');
|
||||
logger.verbose('verbose message', { data: 'value' });
|
||||
|
||||
@@ -162,7 +236,14 @@ describe('logger', () => {
|
||||
|
||||
describe('logger.warn', () => {
|
||||
it('should use electronLog.warn in production', () => {
|
||||
(process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV = 'production';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const logger = createLogger('test:warn');
|
||||
logger.warn('warn message', { warning: 'details' });
|
||||
|
||||
@@ -171,7 +252,14 @@ describe('logger', () => {
|
||||
});
|
||||
|
||||
it('should not use electronLog.warn in development', () => {
|
||||
(process.env as NodeJS.ProcessEnv & { NODE_ENV?: string }).NODE_ENV = 'development';
|
||||
mockGetDesktopEnv.mockReturnValue({
|
||||
NODE_ENV: 'development',
|
||||
DEBUG_VERBOSE: false,
|
||||
DESKTOP_RENDERER_STATIC: false,
|
||||
UPDATE_CHANNEL: undefined,
|
||||
MCP_TOOL_TIMEOUT: 60000,
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobechat.com',
|
||||
});
|
||||
const logger = createLogger('test:warn');
|
||||
logger.warn('warn message');
|
||||
|
||||
@@ -200,6 +288,7 @@ describe('logger', () => {
|
||||
});
|
||||
|
||||
it('should handle no additional arguments', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const logger = createLogger('test:integration');
|
||||
logger.debug('message');
|
||||
logger.error('message');
|
||||
@@ -207,7 +296,18 @@ describe('logger', () => {
|
||||
logger.verbose('message');
|
||||
logger.warn('message');
|
||||
|
||||
// debug method
|
||||
expect(mockDebugLogger).toHaveBeenCalledWith('message');
|
||||
// error method uses console.error (not debug logger) in non-production
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('message');
|
||||
// info method
|
||||
expect(mockDebugLogger).toHaveBeenCalledWith('INFO: message');
|
||||
// verbose method uses electronLog.verbose (not debug logger)
|
||||
expect(electronLog.verbose).toHaveBeenCalledWith('message');
|
||||
// warn method
|
||||
expect(mockDebugLogger).toHaveBeenCalledWith('WARN: message');
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should format messages consistently across different log levels', () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import debug from 'debug';
|
||||
import electronLog from 'electron-log';
|
||||
|
||||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
// 配置 electron-log
|
||||
electronLog.transports.file.level = 'info'; // 生产环境记录 info 及以上级别
|
||||
electronLog.transports.console.level =
|
||||
process.env.NODE_ENV === 'development'
|
||||
getDesktopEnv().NODE_ENV === 'development'
|
||||
? 'debug' // 开发环境显示更多日志
|
||||
: 'warn'; // 生产环境只显示警告和错误
|
||||
|
||||
@@ -17,14 +19,14 @@ export const createLogger = (namespace: string) => {
|
||||
debugLogger(message, ...args);
|
||||
},
|
||||
error: (message, ...args) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (getDesktopEnv().NODE_ENV === 'production') {
|
||||
electronLog.error(message, ...args);
|
||||
} else {
|
||||
console.error(message, ...args);
|
||||
}
|
||||
},
|
||||
info: (message, ...args) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (getDesktopEnv().NODE_ENV === 'production') {
|
||||
electronLog.info(`[${namespace}]`, message, ...args);
|
||||
}
|
||||
|
||||
@@ -32,12 +34,12 @@ export const createLogger = (namespace: string) => {
|
||||
},
|
||||
verbose: (message, ...args) => {
|
||||
electronLog.verbose(message, ...args);
|
||||
if (process.env.DEBUG_VERBOSE) {
|
||||
if (getDesktopEnv().DEBUG_VERBOSE) {
|
||||
debugLogger(`VERBOSE: ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
warn: (message, ...args) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (getDesktopEnv().NODE_ENV === 'production') {
|
||||
electronLog.warn(message, ...args);
|
||||
}
|
||||
debugLogger(`WARN: ${message}`, ...args);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
export const getExportMimeType = (filePath: string) => {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
return map[ext];
|
||||
};
|
||||
@@ -1,425 +0,0 @@
|
||||
// copy from https://github.com/kirill-konshin/next-electron-rsc
|
||||
import { serialize as serializeCookie } from 'cookie';
|
||||
import { type Protocol, type Session } from 'electron';
|
||||
// @ts-ignore
|
||||
import type { NextConfig } from 'next';
|
||||
// @ts-ignore
|
||||
import type NextNodeServer from 'next/dist/server/next-server';
|
||||
import assert from 'node:assert';
|
||||
import { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { Socket } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { parse } from 'node:url';
|
||||
import resolve from 'resolve';
|
||||
import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
|
||||
|
||||
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
// 创建日志记录器
|
||||
const logger = createLogger('utils:next-electron-rsc');
|
||||
|
||||
// 定义自定义处理器类型
|
||||
export type CustomRequestHandler = (request: Request) => Promise<Response | null | undefined>;
|
||||
|
||||
export const createRequest = async ({
|
||||
socket,
|
||||
request,
|
||||
session,
|
||||
}: {
|
||||
request: Request;
|
||||
session: Session;
|
||||
socket: Socket;
|
||||
}): Promise<IncomingMessage> => {
|
||||
const req = new IncomingMessage(socket);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Normal Next.js URL does not contain schema and host/port, otherwise endless loops due to butchering of schema by normalizeRepeatedSlashes in resolve-routes
|
||||
req.url = url.pathname + (url.search || '');
|
||||
req.method = request.method;
|
||||
|
||||
request.headers.forEach((value, key) => {
|
||||
req.headers[key] = value;
|
||||
});
|
||||
|
||||
try {
|
||||
// @see https://github.com/electron/electron/issues/39525#issue-1852825052
|
||||
const cookies = await session.cookies.get({
|
||||
url: request.url,
|
||||
// domain: url.hostname,
|
||||
// path: url.pathname,
|
||||
// `secure: true` Cookies should not be sent via http
|
||||
// secure: url.protocol === 'http:' ? false : undefined,
|
||||
// theoretically not possible to implement sameSite because we don't know the url
|
||||
// of the website that is requesting the resource
|
||||
});
|
||||
|
||||
if (cookies.length) {
|
||||
const cookiesHeader = [];
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const { name, value } = cookie;
|
||||
cookiesHeader.push(serializeCookie(name, value));
|
||||
}
|
||||
|
||||
req.headers.cookie = cookiesHeader.join('; ');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse cookies', { cause: e });
|
||||
}
|
||||
|
||||
if (request.body) {
|
||||
req.push(Buffer.from(await request.arrayBuffer()));
|
||||
}
|
||||
|
||||
req.push(null);
|
||||
req.complete = true;
|
||||
|
||||
return req;
|
||||
};
|
||||
|
||||
export class ReadableServerResponse extends ServerResponse {
|
||||
private responsePromise: Promise<Response>;
|
||||
|
||||
constructor(req: IncomingMessage) {
|
||||
super(req);
|
||||
|
||||
this.responsePromise = new Promise<Response>((resolve) => {
|
||||
const readableStream = new ReadableStream({
|
||||
cancel: () => {},
|
||||
pull: () => {
|
||||
this.emit('drain');
|
||||
},
|
||||
start: (controller) => {
|
||||
let onData;
|
||||
|
||||
this.on(
|
||||
'data',
|
||||
(onData = (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
}),
|
||||
);
|
||||
|
||||
this.once('end', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
controller.close();
|
||||
this.off('data', onData);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.once('writeHead', (statusCode) => {
|
||||
resolve(
|
||||
new Response(readableStream, {
|
||||
headers: this.getHeaders() as any,
|
||||
status: statusCode,
|
||||
statusText: this.statusMessage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
write(chunk: any, ...args): boolean {
|
||||
this.emit('data', chunk);
|
||||
return super.write(chunk, ...args);
|
||||
}
|
||||
|
||||
end(chunk: any, ...args): this {
|
||||
this.emit('end', chunk);
|
||||
return super.end(chunk, ...args);
|
||||
}
|
||||
|
||||
writeHead(statusCode: number, ...args: any): this {
|
||||
this.emit('writeHead', statusCode);
|
||||
return super.writeHead(statusCode, ...args);
|
||||
}
|
||||
|
||||
getResponse() {
|
||||
return this.responsePromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* https://nextjs.org/docs/pages/building-your-application/configuring/custom-server
|
||||
* https://github.com/vercel/next.js/pull/68167/files#diff-d0d8b7158bcb066cdbbeb548a29909fe8dc4e98f682a6d88654b1684e523edac
|
||||
* https://github.com/vercel/next.js/blob/canary/examples/custom-server/server.ts
|
||||
*
|
||||
* @param {string} standaloneDir
|
||||
* @param {string} localhostUrl
|
||||
* @param {import('electron').Protocol} protocol
|
||||
* @param {boolean} debug
|
||||
*/
|
||||
export function createHandler({
|
||||
standaloneDir,
|
||||
localhostUrl,
|
||||
protocol,
|
||||
debug = false,
|
||||
}: {
|
||||
debug?: boolean;
|
||||
localhostUrl: string;
|
||||
protocol: Protocol;
|
||||
standaloneDir: string;
|
||||
}) {
|
||||
assert(standaloneDir, 'standaloneDir is required');
|
||||
assert(protocol, 'protocol is required');
|
||||
|
||||
// 存储自定义请求处理器的数组
|
||||
const customHandlers: CustomRequestHandler[] = [];
|
||||
|
||||
// 注册自定义请求处理器的方法 - 在开发和生产环境中都提供此功能
|
||||
function registerCustomHandler(handler: CustomRequestHandler) {
|
||||
logger.debug('Registering custom request handler');
|
||||
customHandlers.push(handler);
|
||||
return () => {
|
||||
const index = customHandlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
logger.debug('Unregistering custom request handler');
|
||||
customHandlers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let registerProtocolHandle = false;
|
||||
let interceptorCount = 0; // 追踪活跃的拦截器数量
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
scheme: 'http',
|
||||
},
|
||||
]);
|
||||
logger.debug('Registered HTTP scheme as privileged');
|
||||
|
||||
// 初始化 Next.js 应用(仅在生产环境中使用)
|
||||
let app: NextNodeServer | null = null;
|
||||
let handler: any = null;
|
||||
let preparePromise: Promise<void> | null = null;
|
||||
|
||||
if (!isDev) {
|
||||
logger.info('Initializing Next.js app for production');
|
||||
|
||||
// https://github.com/lobehub/lobe-chat/pull/9851
|
||||
// @ts-ignore
|
||||
// noinspection JSConstantReassignment
|
||||
process.env.NODE_ENV = 'production';
|
||||
const next = require(resolve.sync('next', { basedir: standaloneDir }));
|
||||
|
||||
// @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340
|
||||
const config = require(path.join(standaloneDir, '.next', 'required-server-files.json'))
|
||||
.config as NextConfig;
|
||||
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
|
||||
|
||||
app = next({ dir: standaloneDir }) as NextNodeServer;
|
||||
|
||||
handler = app.getRequestHandler();
|
||||
preparePromise = app.prepare();
|
||||
} else {
|
||||
logger.debug('Starting in development mode');
|
||||
}
|
||||
|
||||
// 通用的请求处理函数 - 开发和生产环境共用
|
||||
const handleRequest = async (
|
||||
request: Request,
|
||||
session: Session,
|
||||
socket: Socket,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
// 检查是否是本地文件服务请求,如果是则跳过处理
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/')) {
|
||||
if (debug) logger.debug(`Skipping local file service request: ${request.url}`);
|
||||
// 直接使用 fetch 转发请求到本地文件服务
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// 先尝试使用自定义处理器处理请求
|
||||
for (const customHandler of customHandlers) {
|
||||
try {
|
||||
const response = await customHandler(request);
|
||||
if (response) {
|
||||
if (debug) logger.debug(`Custom handler processed: ${request.url}`);
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (debug) logger.error(`Custom handler error: ${error}`);
|
||||
// 继续尝试下一个处理器
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Node.js 请求对象
|
||||
const req = await createRequest({ request, session, socket });
|
||||
// 创建可读取响应的 Response 对象
|
||||
const res = new ReadableServerResponse(req);
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:转发请求到开发服务器
|
||||
if (debug) logger.debug(`Forwarding request to dev server: ${request.url}`);
|
||||
|
||||
// 修改 URL 以指向开发服务器
|
||||
const devUrl = new URL(req.url, localhostUrl);
|
||||
|
||||
// 使用 node:http 模块发送请求到开发服务器
|
||||
const http = require('node:http');
|
||||
const devReq = http.request(
|
||||
{
|
||||
headers: req.headers,
|
||||
hostname: devUrl.hostname,
|
||||
method: req.method,
|
||||
path: devUrl.pathname + (devUrl.search || ''),
|
||||
port: devUrl.port,
|
||||
},
|
||||
(devRes) => {
|
||||
// 设置响应状态码和头部
|
||||
res.statusCode = devRes.statusCode;
|
||||
res.statusMessage = devRes.statusMessage;
|
||||
|
||||
// 复制响应头
|
||||
Object.keys(devRes.headers).forEach((key) => {
|
||||
res.setHeader(key, devRes.headers[key]);
|
||||
});
|
||||
|
||||
// 流式传输响应内容
|
||||
devRes.pipe(res);
|
||||
},
|
||||
);
|
||||
|
||||
// 处理错误
|
||||
devReq.on('error', (err) => {
|
||||
if (debug) logger.error(`Error forwarding request: ${err}`);
|
||||
});
|
||||
|
||||
// 传输请求体
|
||||
req.pipe(devReq);
|
||||
} else {
|
||||
// 生产环境:使用 Next.js 处理请求
|
||||
if (debug) logger.debug(`Processing with Next.js handler: ${request.url}`);
|
||||
|
||||
// 确保 Next.js 已准备就绪
|
||||
if (preparePromise) await preparePromise;
|
||||
|
||||
const url = parse(req.url, true);
|
||||
handler(req, res, url);
|
||||
}
|
||||
|
||||
// 获取 Response 对象
|
||||
const response = await res.getResponse();
|
||||
|
||||
// 处理 cookies(两种环境通用处理)
|
||||
try {
|
||||
const cookies = parseCookie(
|
||||
response.headers.getSetCookie().reduce((r, c) => {
|
||||
return [...r, ...splitCookiesString(c)];
|
||||
}, []),
|
||||
);
|
||||
|
||||
for (const cookie of cookies) {
|
||||
let expirationDate: number | undefined;
|
||||
|
||||
if (cookie.expires) {
|
||||
// expires 是 Date 对象,转换为秒级时间戳
|
||||
expirationDate = Math.floor(cookie.expires.getTime() / 1000);
|
||||
} else if (cookie.maxAge) {
|
||||
// maxAge 是秒数,计算过期时间戳
|
||||
expirationDate = Math.floor(Date.now() / 1000) + cookie.maxAge;
|
||||
}
|
||||
|
||||
// 如果都没有,则为 session cookie,不设置 expirationDate
|
||||
|
||||
// 检查是否已过期
|
||||
if (expirationDate && expirationDate < Math.floor(Date.now() / 1000)) {
|
||||
await session.cookies.remove(request.url, cookie.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await session.cookies.set({
|
||||
domain: cookie.domain,
|
||||
expirationDate,
|
||||
httpOnly: cookie.httpOnly,
|
||||
name: cookie.name,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
url: request.url,
|
||||
value: cookie.value,
|
||||
} as any);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to set cookies', e);
|
||||
}
|
||||
|
||||
if (debug) logger.debug(`Request processed: ${request.url}, status: ${response.status}`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (debug) logger.error(`Error handling request: ${e}`);
|
||||
return new Response(e.message, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// 创建拦截器函数
|
||||
const createInterceptor = ({ session }: { session: Session }) => {
|
||||
assert(session, 'Session is required');
|
||||
logger.debug(
|
||||
`Creating interceptor with session in ${isDev ? 'development' : 'production'} mode`,
|
||||
);
|
||||
|
||||
const socket = new Socket();
|
||||
interceptorCount++; // 增加拦截器计数
|
||||
|
||||
const closeSocket = () => socket.end();
|
||||
|
||||
process.on('SIGTERM', () => closeSocket);
|
||||
process.on('SIGINT', () => closeSocket);
|
||||
|
||||
if (!registerProtocolHandle) {
|
||||
logger.debug(
|
||||
`Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`,
|
||||
);
|
||||
protocol.handle('http', async (request) => {
|
||||
if (!isDev) {
|
||||
// 检查是否是本地文件服务请求,如果是则允许通过
|
||||
const isLocalhost = request.url.startsWith(localhostUrl);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const isLocalIP =
|
||||
request.url.startsWith('http://127.0.0.1:') ||
|
||||
request.url.startsWith('http://localhost:');
|
||||
const isLocalFileService = url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/');
|
||||
|
||||
const valid = isLocalhost || (isLocalIP && isLocalFileService);
|
||||
if (!valid) {
|
||||
throw new Error('External HTTP not supported, use HTTPS');
|
||||
}
|
||||
}
|
||||
|
||||
return handleRequest(request, session, socket);
|
||||
});
|
||||
registerProtocolHandle = true;
|
||||
}
|
||||
|
||||
logger.debug(`Active interceptors count: ${interceptorCount}`);
|
||||
|
||||
return function stopIntercept() {
|
||||
interceptorCount--; // 减少拦截器计数
|
||||
logger.debug(`Stopping interceptor, remaining count: ${interceptorCount}`);
|
||||
|
||||
// 只有当没有活跃的拦截器时才取消注册协议处理器
|
||||
if (registerProtocolHandle && interceptorCount === 0) {
|
||||
logger.debug('Unregistering HTTP protocol handler (no active interceptors)');
|
||||
protocol.unhandle('http');
|
||||
registerProtocolHandle = false;
|
||||
}
|
||||
|
||||
process.off('SIGTERM', () => closeSocket);
|
||||
process.off('SIGINT', () => closeSocket);
|
||||
closeSocket();
|
||||
};
|
||||
};
|
||||
|
||||
return { createInterceptor, registerCustomHandler };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const filePathToAppUrl = (filePath: string) => {
|
||||
return `app://lobehub.com${pathToFileURL(filePath).pathname}`;
|
||||
};
|
||||
@@ -51,10 +51,24 @@ describe('setupElectronApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose lobeEnv with darwinMajorVersion', () => {
|
||||
setupElectronApi();
|
||||
|
||||
const call = mockContextBridgeExposeInMainWorld.mock.calls.find((i) => i[0] === 'lobeEnv');
|
||||
expect(call).toBeTruthy();
|
||||
const exposedEnv = call?.[1] as any;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'darwinMajorVersion')).toBe(true);
|
||||
expect(
|
||||
exposedEnv.darwinMajorVersion === undefined ||
|
||||
typeof exposedEnv.darwinMajorVersion === 'number',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose both APIs in correct order', () => {
|
||||
setupElectronApi();
|
||||
|
||||
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(2);
|
||||
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(3);
|
||||
|
||||
// First call should be for 'electron'
|
||||
expect(mockContextBridgeExposeInMainWorld.mock.calls[0][0]).toBe('electron');
|
||||
@@ -66,6 +80,9 @@ describe('setupElectronApi', () => {
|
||||
invoke: mockInvoke,
|
||||
onStreamInvoke: mockOnStreamInvoke,
|
||||
});
|
||||
|
||||
// Third call should be for 'lobeEnv'
|
||||
expect(mockContextBridgeExposeInMainWorld.mock.calls[2][0]).toBe('lobeEnv');
|
||||
});
|
||||
|
||||
it('should handle errors when exposing electron API fails', () => {
|
||||
@@ -77,8 +94,8 @@ describe('setupElectronApi', () => {
|
||||
setupElectronApi();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(error);
|
||||
// Should still try to expose electronAPI even if first one fails
|
||||
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(2);
|
||||
// Should still try to expose electronAPI and lobeEnv even if first one fails
|
||||
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should continue execution if exposing electronAPI fails', () => {
|
||||
@@ -136,7 +153,7 @@ describe('setupElectronApi', () => {
|
||||
setupElectronApi();
|
||||
setupElectronApi();
|
||||
|
||||
// Should be called 4 times total (2 per setup call)
|
||||
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(4);
|
||||
// Should be called 6 times total (3 per setup call)
|
||||
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,4 +19,12 @@ export const setupElectronApi = () => {
|
||||
invoke,
|
||||
onStreamInvoke,
|
||||
});
|
||||
|
||||
const os = require('node:os');
|
||||
const osInfo = os.release();
|
||||
const darwinMajorVersion = osInfo.split('.')[0];
|
||||
|
||||
contextBridge.exposeInMainWorld('lobeEnv', {
|
||||
darwinMajorVersion: Number(darwinMajorVersion),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DispatchInvoke } from '@lobechat/electron-client-ipc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
type IpcInvoke = <T = unknown>(event: string, ...data: unknown[]) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Client-side method to invoke electron main process
|
||||
*/
|
||||
export const invoke: DispatchInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
|
||||
export const invoke: IpcInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import type { StreamInvokeRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock electron module
|
||||
@@ -29,7 +29,7 @@ describe('onStreamInvoke', () => {
|
||||
});
|
||||
|
||||
it('should set up stream listeners and send start event', () => {
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method: 'POST',
|
||||
urlPath: '/trpc/lambda/test.endpoint',
|
||||
@@ -77,7 +77,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -105,7 +105,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -137,7 +137,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -178,7 +178,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -220,7 +220,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -254,7 +254,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -289,7 +289,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
body: JSON.stringify({
|
||||
filters: { active: true },
|
||||
query: 'complex query',
|
||||
@@ -316,7 +316,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
@@ -346,7 +346,7 @@ describe('onStreamInvoke', () => {
|
||||
onResponse: vi.fn(),
|
||||
};
|
||||
|
||||
const params: ProxyTRPCRequestParams = {
|
||||
const params: StreamInvokeRequestParams = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
urlPath: '/trpc/test',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import type { StreamInvokeRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface StreamerCallbacks {
|
||||
* @param callbacks The callbacks to handle stream events.
|
||||
*/
|
||||
export const onStreamInvoke = (
|
||||
params: ProxyTRPCRequestParams,
|
||||
params: StreamInvokeRequestParams,
|
||||
callbacks: StreamerCallbacks,
|
||||
): (() => void) => {
|
||||
const requestId = uuid();
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": false,
|
||||
"target": "ESNext",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
@@ -13,9 +15,21 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/main/*"],
|
||||
"~common/*": ["./src/common/*"]
|
||||
"@/*": [
|
||||
"./src/main/*"
|
||||
],
|
||||
"~common/*": [
|
||||
"./src/common/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src/main/**/*", "src/preload/**/*", "src/common/**/*", "electron-builder.js"]
|
||||
}
|
||||
"include": [
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/common/**/*",
|
||||
"electron-builder.js"
|
||||
]
|
||||
}
|
||||
@@ -239,7 +239,7 @@ We're adding a new category of settings this time. In `src/features/AgentSetting
|
||||
|
||||
- [ant-design](https://ant.design/) and [lobe-ui](https://github.com/lobehub/lobe-ui): component libraries
|
||||
- [antd-style](https://ant-design.github.io/antd-style): css-in-js solution
|
||||
- [react-layout-kit](https://github.com/arvinxx/react-layout-kit): responsive layout components
|
||||
- [@lobehub/ui](https://ui.lobehub.com/): UI component library (includes Flexbox and Center for responsive layouts)
|
||||
- [@ant-design/icons](https://ant.design/components/icon-cn) and [lucide](https://lucide.dev/icons/): icon libraries
|
||||
- [react-i18next](https://github.com/i18next/react-i18next) and [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/tree/master/packages/lobe-i18n): i18n framework and multi-language automatic translation tool
|
||||
|
||||
@@ -252,12 +252,11 @@ Let's take the subcomponent `OpeningQuestion.tsx` as an example. Component imple
|
||||
'use client';
|
||||
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { SortableList } from '@lobehub/ui';
|
||||
import { Flexbox, SortableList } from '@lobehub/ui';
|
||||
import { Button, Empty, Input } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useStore } from '../store';
|
||||
import { selectors } from '../store/selectors';
|
||||
|
||||
@@ -239,7 +239,7 @@ export const agentSelectors = {
|
||||
|
||||
- [ant-design](https://ant.design/) 和 [lobe-ui:](https://github.com/lobehub/lobe-ui)组件库
|
||||
- [antd-style](https://ant-design.github.io/antd-style) : css-in-js 方案
|
||||
- [react-layout-kit](https://github.com/arvinxx/react-layout-kit):响应式布局组件
|
||||
- [@lobehub/ui](https://ui.lobehub.com/):UI 组件库(包含 Flexbox 和 Center 用于响应式布局)
|
||||
- [@ant-design/icons](https://ant.design/components/icon-cn) 和 [lucide](https://lucide.dev/icons/): 图标库
|
||||
- [react-i18next](https://github.com/i18next/react-i18next) 和 [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/tree/master/packages/lobe-i18n) :i18n 框架和多语言自动翻译工具
|
||||
|
||||
@@ -252,12 +252,11 @@ lobe-chat 是个国际化项目,新加的文案需要更新默认的 `locale`
|
||||
'use client';
|
||||
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { SortableList } from '@lobehub/ui';
|
||||
import { Flexbox, SortableList } from '@lobehub/ui';
|
||||
import { Button, Empty, Input } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useStore } from '../store';
|
||||
import { selectors } from '../store/selectors';
|
||||
|
||||
@@ -22,6 +22,7 @@ table agents {
|
||||
pinned boolean
|
||||
opening_message text
|
||||
opening_questions text[] [default: `[]`]
|
||||
session_group_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -32,6 +33,7 @@ table agents {
|
||||
user_id [name: 'agents_user_id_idx']
|
||||
title [name: 'agents_title_idx']
|
||||
description [name: 'agents_description_idx']
|
||||
session_group_id [name: 'agents_session_group_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +250,9 @@ table chat_groups_agents {
|
||||
|
||||
table documents {
|
||||
id varchar(255) [pk, not null]
|
||||
slug varchar(255)
|
||||
title text
|
||||
description text
|
||||
content text
|
||||
file_type varchar(255) [not null]
|
||||
filename text
|
||||
@@ -259,11 +263,11 @@ table documents {
|
||||
source_type text [not null]
|
||||
source text [not null]
|
||||
file_id text
|
||||
knowledge_base_id text
|
||||
parent_id varchar(255)
|
||||
user_id text [not null]
|
||||
client_id text
|
||||
editor_data jsonb
|
||||
slug varchar(255)
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -275,6 +279,7 @@ table documents {
|
||||
user_id [name: 'documents_user_id_idx']
|
||||
file_id [name: 'documents_file_id_idx']
|
||||
parent_id [name: 'documents_parent_id_idx']
|
||||
knowledge_base_id [name: 'documents_knowledge_base_id_idx']
|
||||
(client_id, user_id) [name: 'documents_client_id_user_id_unique', unique]
|
||||
(slug, user_id) [name: 'documents_slug_user_id_unique', unique]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ Feature: Core Routes Accessibility
|
||||
|
||||
@ROUTES-002 @P0
|
||||
Scenario Outline: Access settings routes without errors
|
||||
When I navigate to "/settings?active=<tab>"
|
||||
When I navigate to "/settings/<tab>"
|
||||
Then the response status should be less than 400
|
||||
And the page should load without errors
|
||||
And I should see the page body
|
||||
@@ -36,8 +36,7 @@ Feature: Core Routes Accessibility
|
||||
| about |
|
||||
| agent |
|
||||
| hotkey |
|
||||
| provider |
|
||||
| provider/all |
|
||||
| proxy |
|
||||
| storage |
|
||||
| system-agent |
|
||||
| tts |
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"助手": {
|
||||
"助理": {
|
||||
"en-US": "Agent"
|
||||
},
|
||||
"文稿": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { KnipConfig } from 'knip';
|
||||
|
||||
const config: KnipConfig = {
|
||||
entry: ['src/app/**/*.ts{x,}'],
|
||||
ignore: [
|
||||
// Test files
|
||||
'src/**/__tests__/**',
|
||||
'src/**/*.test.ts{x,}',
|
||||
'src/**/*.spec.ts{x,}',
|
||||
// Other directories
|
||||
'packages/**',
|
||||
'e2e/**',
|
||||
'scripts/**',
|
||||
// Config files
|
||||
'*.config.{js,ts,mjs,cjs}',
|
||||
'next-env.d.ts',
|
||||
],
|
||||
ignoreDependencies: [],
|
||||
ignoreExportsUsedInFile: true,
|
||||
project: ['src/**/*.ts{x,}'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
+232
-314
@@ -1,320 +1,238 @@
|
||||
{
|
||||
"apikey": {
|
||||
"display": {
|
||||
"autoGenerated": "تم الإنشاء تلقائيًا",
|
||||
"copy": "نسخ",
|
||||
"copyError": "فشل النسخ",
|
||||
"copySuccess": "تم نسخ مفتاح API إلى الحافظة",
|
||||
"enterPlaceholder": "الرجاء الإدخال",
|
||||
"hide": "إخفاء",
|
||||
"neverExpires": "لا تنتهي صلاحيتها أبدًا",
|
||||
"neverUsed": "لم يُستخدم أبدًا",
|
||||
"show": "عرض"
|
||||
},
|
||||
"form": {
|
||||
"fields": {
|
||||
"expiresAt": {
|
||||
"label": "تاريخ الانتهاء",
|
||||
"placeholder": "لا تنتهي صلاحيتها أبدًا"
|
||||
},
|
||||
"name": {
|
||||
"label": "الاسم",
|
||||
"placeholder": "الرجاء إدخال اسم مفتاح API"
|
||||
}
|
||||
},
|
||||
"submit": "إنشاء",
|
||||
"title": "إنشاء مفتاح API"
|
||||
},
|
||||
"list": {
|
||||
"actions": {
|
||||
"create": "إنشاء مفتاح API",
|
||||
"delete": "حذف",
|
||||
"deleteConfirm": {
|
||||
"actions": {
|
||||
"cancel": "إلغاء",
|
||||
"ok": "تأكيد"
|
||||
},
|
||||
"content": "هل أنت متأكد من حذف هذا المفتاح؟",
|
||||
"title": "تأكيد العملية"
|
||||
}
|
||||
},
|
||||
"columns": {
|
||||
"actions": "الإجراءات",
|
||||
"expiresAt": "تاريخ الانتهاء",
|
||||
"key": "المفتاح",
|
||||
"lastUsedAt": "آخر استخدام",
|
||||
"name": "الاسم",
|
||||
"status": "حالة التفعيل"
|
||||
},
|
||||
"title": "قائمة مفاتيح API"
|
||||
},
|
||||
"validation": {
|
||||
"required": "لا يمكن أن يكون المحتوى فارغًا"
|
||||
}
|
||||
},
|
||||
"betterAuth": {
|
||||
"errors": {
|
||||
"confirmPasswordRequired": "يرجى تأكيد كلمة المرور",
|
||||
"emailExists": "هذا البريد الإلكتروني مسجّل بالفعل، يرجى تسجيل الدخول مباشرة",
|
||||
"emailInvalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||
"emailNotRegistered": "هذا البريد الإلكتروني غير مسجل",
|
||||
"emailNotVerified": "لم يتم التحقق من البريد الإلكتروني، يرجى التحقق أولاً",
|
||||
"emailRequired": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"firstNameRequired": "يرجى إدخال الاسم الأول",
|
||||
"lastNameRequired": "يرجى إدخال اسم العائلة",
|
||||
"loginFailed": "فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني وكلمة المرور",
|
||||
"passwordFormat": "يجب أن تحتوي كلمة المرور على أحرف وأرقام",
|
||||
"passwordMaxLength": "يجب ألا تتجاوز كلمة المرور 64 حرفًا",
|
||||
"passwordMinLength": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل",
|
||||
"passwordMismatch": "كلمتا المرور غير متطابقتين",
|
||||
"passwordRequired": "يرجى إدخال كلمة المرور",
|
||||
"usernameNotRegistered": "اسم المستخدم هذا غير مسجل",
|
||||
"usernameRequired": "يرجى إدخال اسم المستخدم"
|
||||
},
|
||||
"resetPassword": {
|
||||
"backToSignIn": "العودة إلى تسجيل الدخول",
|
||||
"confirmPasswordPlaceholder": "تأكيد كلمة المرور الجديدة",
|
||||
"confirmPasswordRequired": "يرجى تأكيد كلمة المرور الجديدة",
|
||||
"description": "يرجى إدخال كلمة المرور الجديدة",
|
||||
"error": "فشل إعادة تعيين كلمة المرور، يرجى المحاولة مرة أخرى",
|
||||
"invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية",
|
||||
"newPasswordPlaceholder": "أدخل كلمة المرور الجديدة",
|
||||
"passwordMismatch": "كلمتا المرور غير متطابقتين",
|
||||
"submit": "إعادة تعيين كلمة المرور",
|
||||
"success": "تمت إعادة تعيين كلمة المرور بنجاح، يرجى تسجيل الدخول باستخدام كلمة المرور الجديدة",
|
||||
"title": "إعادة تعيين كلمة المرور"
|
||||
},
|
||||
"signin": {
|
||||
"backToEmail": "العودة لتعديل البريد الإلكتروني",
|
||||
"continueWithApple": "تسجيل الدخول باستخدام Apple",
|
||||
"continueWithAuth0": "تسجيل الدخول باستخدام Auth0",
|
||||
"continueWithAuthelia": "تسجيل الدخول باستخدام Authelia",
|
||||
"continueWithAuthentik": "تسجيل الدخول باستخدام Authentik",
|
||||
"continueWithCasdoor": "تسجيل الدخول باستخدام Casdoor",
|
||||
"continueWithCloudflareZeroTrust": "تسجيل الدخول باستخدام Cloudflare Zero Trust",
|
||||
"continueWithCognito": "تسجيل الدخول باستخدام AWS Cognito",
|
||||
"continueWithFeishu": "تسجيل الدخول باستخدام Feishu",
|
||||
"continueWithGithub": "تسجيل الدخول باستخدام GitHub",
|
||||
"continueWithGoogle": "تسجيل الدخول باستخدام Google",
|
||||
"continueWithKeycloak": "تسجيل الدخول باستخدام Keycloak",
|
||||
"continueWithLogto": "تسجيل الدخول باستخدام Logto",
|
||||
"continueWithMicrosoft": "تسجيل الدخول باستخدام Microsoft",
|
||||
"continueWithOIDC": "تسجيل الدخول باستخدام OIDC",
|
||||
"continueWithOkta": "تسجيل الدخول باستخدام Okta",
|
||||
"continueWithWechat": "تسجيل الدخول باستخدام WeChat",
|
||||
"continueWithZitadel": "تسجيل الدخول باستخدام Zitadel",
|
||||
"emailPlaceholder": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"emailStep": {
|
||||
"title": "تسجيل الدخول"
|
||||
},
|
||||
"error": "فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني وكلمة المرور",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"forgotPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"forgotPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
|
||||
"magicLinkButton": "إرسال رابط تسجيل الدخول",
|
||||
"magicLinkError": "فشل إرسال رابط تسجيل الدخول، يرجى المحاولة لاحقًا",
|
||||
"magicLinkSent": "تم إرسال رابط تسجيل الدخول، يرجى التحقق من بريدك الإلكتروني",
|
||||
"nextStep": "الخطوة التالية",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"orContinueWith": "أو المتابعة باستخدام",
|
||||
"passwordPlaceholder": "يرجى إدخال كلمة المرور",
|
||||
"passwordStep": {
|
||||
"subtitle": "يرجى إدخال كلمة المرور للمتابعة"
|
||||
},
|
||||
"signupLink": "سجّل الآن",
|
||||
"socialError": "فشل تسجيل الدخول عبر الشبكات الاجتماعية، يرجى المحاولة مرة أخرى",
|
||||
"socialOnlyHint": "تم تسجيل هذا البريد الإلكتروني باستخدام حساب اجتماعي، يرجى تسجيل الدخول باستخدامه",
|
||||
"submit": "تسجيل الدخول"
|
||||
},
|
||||
"signup": {
|
||||
"confirmPasswordPlaceholder": "يرجى تأكيد كلمة المرور",
|
||||
"emailPlaceholder": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"error": "فشل التسجيل، يرجى المحاولة مرة أخرى",
|
||||
"firstNamePlaceholder": "الاسم الأول",
|
||||
"hasAccount": "هل لديك حساب؟",
|
||||
"lastNamePlaceholder": "اسم العائلة",
|
||||
"passwordPlaceholder": "يرجى إدخال كلمة المرور",
|
||||
"signinLink": "تسجيل الدخول الآن",
|
||||
"submit": "تسجيل",
|
||||
"subtitle": "انضم إلى مجتمع LobeChat",
|
||||
"success": "تم التسجيل بنجاح! يرجى التحقق من بريدك الإلكتروني لتأكيد الحساب",
|
||||
"title": "إنشاء حساب",
|
||||
"usernamePlaceholder": "يرجى إدخال اسم المستخدم"
|
||||
},
|
||||
"verifyEmail": {
|
||||
"backToSignIn": "العودة إلى تسجيل الدخول",
|
||||
"checkSpam": "إذا لم تتلقَ البريد الإلكتروني، يرجى التحقق من مجلد الرسائل غير المرغوب فيها",
|
||||
"descriptionPrefix": "لقد أرسلنا رسالة تحقق إلى",
|
||||
"descriptionSuffix": "",
|
||||
"resend": {
|
||||
"button": "إعادة إرسال رسالة التحقق",
|
||||
"error": "فشل الإرسال، يرجى المحاولة لاحقًا",
|
||||
"noEmail": "عنوان البريد الإلكتروني مفقود",
|
||||
"success": "تمت إعادة إرسال رسالة التحقق، يرجى التحقق من بريدك الإلكتروني"
|
||||
},
|
||||
"title": "تحقق من بريدك الإلكتروني"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"prevMonth": "الشهر الماضي",
|
||||
"recent30Days": "آخر 30 يومًا"
|
||||
},
|
||||
"header": {
|
||||
"desc": "إدارة معلومات حسابك.",
|
||||
"title": "الحساب"
|
||||
},
|
||||
"heatmaps": {
|
||||
"legend": {
|
||||
"less": "غير نشط",
|
||||
"more": "نشط"
|
||||
},
|
||||
"months": {
|
||||
"apr": "أبريل",
|
||||
"aug": "أغسطس",
|
||||
"dec": "ديسمبر",
|
||||
"feb": "فبراير",
|
||||
"jan": "يناير",
|
||||
"jul": "يوليو",
|
||||
"jun": "يونيو",
|
||||
"mar": "مارس",
|
||||
"may": "مايو",
|
||||
"nov": "نوفمبر",
|
||||
"oct": "أكتوبر",
|
||||
"sep": "سبتمبر"
|
||||
},
|
||||
"tooltip": "{{date}} أرسل {{count}} رسائل في ذلك اليوم",
|
||||
"totalCount": "إجمالي {{count}} رسائل أرسلت في العام الماضي"
|
||||
},
|
||||
"apikey.display.autoGenerated": "تم الإنشاء تلقائيًا",
|
||||
"apikey.display.copy": "نسخ",
|
||||
"apikey.display.copyError": "فشل النسخ",
|
||||
"apikey.display.copySuccess": "تم نسخ مفتاح API إلى الحافظة",
|
||||
"apikey.display.enterPlaceholder": "الرجاء الإدخال",
|
||||
"apikey.display.hide": "إخفاء",
|
||||
"apikey.display.neverExpires": "لا تنتهي صلاحيتها أبدًا",
|
||||
"apikey.display.neverUsed": "لم يُستخدم أبدًا",
|
||||
"apikey.display.show": "عرض",
|
||||
"apikey.form.fields.expiresAt.label": "تاريخ الانتهاء",
|
||||
"apikey.form.fields.expiresAt.placeholder": "لا تنتهي صلاحيتها أبدًا",
|
||||
"apikey.form.fields.name.label": "الاسم",
|
||||
"apikey.form.fields.name.placeholder": "الرجاء إدخال اسم مفتاح API",
|
||||
"apikey.form.submit": "إنشاء",
|
||||
"apikey.form.title": "إنشاء مفتاح API",
|
||||
"apikey.list.actions.create": "إنشاء مفتاح API",
|
||||
"apikey.list.actions.delete": "حذف",
|
||||
"apikey.list.actions.deleteConfirm.actions.cancel": "إلغاء",
|
||||
"apikey.list.actions.deleteConfirm.actions.ok": "تأكيد",
|
||||
"apikey.list.actions.deleteConfirm.content": "هل أنت متأكد من حذف هذا المفتاح؟",
|
||||
"apikey.list.actions.deleteConfirm.title": "تأكيد العملية",
|
||||
"apikey.list.columns.actions": "الإجراءات",
|
||||
"apikey.list.columns.expiresAt": "تاريخ الانتهاء",
|
||||
"apikey.list.columns.key": "المفتاح",
|
||||
"apikey.list.columns.lastUsedAt": "آخر استخدام",
|
||||
"apikey.list.columns.name": "الاسم",
|
||||
"apikey.list.columns.status": "حالة التفعيل",
|
||||
"apikey.list.title": "قائمة مفاتيح API",
|
||||
"apikey.validation.required": "لا يمكن أن يكون المحتوى فارغًا",
|
||||
"betterAuth.errors.confirmPasswordRequired": "يرجى تأكيد كلمة المرور",
|
||||
"betterAuth.errors.emailExists": "هذا البريد الإلكتروني مسجّل بالفعل، يرجى تسجيل الدخول مباشرة",
|
||||
"betterAuth.errors.emailInvalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||
"betterAuth.errors.emailNotRegistered": "هذا البريد الإلكتروني غير مسجل",
|
||||
"betterAuth.errors.emailNotVerified": "لم يتم التحقق من البريد الإلكتروني، يرجى التحقق أولاً",
|
||||
"betterAuth.errors.emailRequired": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"betterAuth.errors.firstNameRequired": "يرجى إدخال الاسم الأول",
|
||||
"betterAuth.errors.lastNameRequired": "يرجى إدخال اسم العائلة",
|
||||
"betterAuth.errors.loginFailed": "فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني وكلمة المرور",
|
||||
"betterAuth.errors.passwordFormat": "يجب أن تحتوي كلمة المرور على أحرف وأرقام",
|
||||
"betterAuth.errors.passwordMaxLength": "يجب ألا تتجاوز كلمة المرور 64 حرفًا",
|
||||
"betterAuth.errors.passwordMinLength": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل",
|
||||
"betterAuth.errors.passwordMismatch": "كلمتا المرور غير متطابقتين",
|
||||
"betterAuth.errors.passwordRequired": "يرجى إدخال كلمة المرور",
|
||||
"betterAuth.errors.usernameNotRegistered": "اسم المستخدم هذا غير مسجل",
|
||||
"betterAuth.errors.usernameRequired": "يرجى إدخال اسم المستخدم",
|
||||
"betterAuth.resetPassword.backToSignIn": "العودة إلى تسجيل الدخول",
|
||||
"betterAuth.resetPassword.confirmPasswordPlaceholder": "تأكيد كلمة المرور الجديدة",
|
||||
"betterAuth.resetPassword.confirmPasswordRequired": "يرجى تأكيد كلمة المرور الجديدة",
|
||||
"betterAuth.resetPassword.description": "يرجى إدخال كلمة المرور الجديدة",
|
||||
"betterAuth.resetPassword.error": "فشل إعادة تعيين كلمة المرور، يرجى المحاولة مرة أخرى",
|
||||
"betterAuth.resetPassword.invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية",
|
||||
"betterAuth.resetPassword.newPasswordPlaceholder": "أدخل كلمة المرور الجديدة",
|
||||
"betterAuth.resetPassword.passwordMismatch": "كلمتا المرور غير متطابقتين",
|
||||
"betterAuth.resetPassword.submit": "إعادة تعيين كلمة المرور",
|
||||
"betterAuth.resetPassword.success": "تمت إعادة تعيين كلمة المرور بنجاح، يرجى تسجيل الدخول باستخدام كلمة المرور الجديدة",
|
||||
"betterAuth.resetPassword.title": "إعادة تعيين كلمة المرور",
|
||||
"betterAuth.signin.backToEmail": "العودة لتعديل البريد الإلكتروني",
|
||||
"betterAuth.signin.continueWithApple": "تسجيل الدخول باستخدام Apple",
|
||||
"betterAuth.signin.continueWithAuth0": "تسجيل الدخول باستخدام Auth0",
|
||||
"betterAuth.signin.continueWithAuthelia": "تسجيل الدخول باستخدام Authelia",
|
||||
"betterAuth.signin.continueWithAuthentik": "تسجيل الدخول باستخدام Authentik",
|
||||
"betterAuth.signin.continueWithCasdoor": "تسجيل الدخول باستخدام Casdoor",
|
||||
"betterAuth.signin.continueWithCloudflareZeroTrust": "تسجيل الدخول باستخدام Cloudflare Zero Trust",
|
||||
"betterAuth.signin.continueWithCognito": "تسجيل الدخول باستخدام AWS Cognito",
|
||||
"betterAuth.signin.continueWithFeishu": "تسجيل الدخول باستخدام Feishu",
|
||||
"betterAuth.signin.continueWithGithub": "تسجيل الدخول باستخدام GitHub",
|
||||
"betterAuth.signin.continueWithGoogle": "تسجيل الدخول باستخدام Google",
|
||||
"betterAuth.signin.continueWithKeycloak": "تسجيل الدخول باستخدام Keycloak",
|
||||
"betterAuth.signin.continueWithLogto": "تسجيل الدخول باستخدام Logto",
|
||||
"betterAuth.signin.continueWithMicrosoft": "تسجيل الدخول باستخدام Microsoft",
|
||||
"betterAuth.signin.continueWithOIDC": "تسجيل الدخول باستخدام OIDC",
|
||||
"betterAuth.signin.continueWithOkta": "تسجيل الدخول باستخدام Okta",
|
||||
"betterAuth.signin.continueWithWechat": "تسجيل الدخول باستخدام WeChat",
|
||||
"betterAuth.signin.continueWithZitadel": "تسجيل الدخول باستخدام Zitadel",
|
||||
"betterAuth.signin.emailPlaceholder": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"betterAuth.signin.emailStep.title": "تسجيل الدخول",
|
||||
"betterAuth.signin.error": "فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني وكلمة المرور",
|
||||
"betterAuth.signin.forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"betterAuth.signin.forgotPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"betterAuth.signin.forgotPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
|
||||
"betterAuth.signin.magicLinkButton": "إرسال رابط تسجيل الدخول",
|
||||
"betterAuth.signin.magicLinkError": "فشل إرسال رابط تسجيل الدخول، يرجى المحاولة لاحقًا",
|
||||
"betterAuth.signin.magicLinkSent": "تم إرسال رابط تسجيل الدخول، يرجى التحقق من بريدك الإلكتروني",
|
||||
"betterAuth.signin.nextStep": "الخطوة التالية",
|
||||
"betterAuth.signin.noAccount": "ليس لديك حساب؟",
|
||||
"betterAuth.signin.orContinueWith": "أو المتابعة باستخدام",
|
||||
"betterAuth.signin.passwordPlaceholder": "يرجى إدخال كلمة المرور",
|
||||
"betterAuth.signin.passwordStep.subtitle": "يرجى إدخال كلمة المرور للمتابعة",
|
||||
"betterAuth.signin.signupLink": "سجّل الآن",
|
||||
"betterAuth.signin.socialError": "فشل تسجيل الدخول عبر الشبكات الاجتماعية، يرجى المحاولة مرة أخرى",
|
||||
"betterAuth.signin.socialOnlyHint": "تم تسجيل هذا البريد الإلكتروني باستخدام حساب اجتماعي، يرجى تسجيل الدخول باستخدامه",
|
||||
"betterAuth.signin.submit": "تسجيل الدخول",
|
||||
"betterAuth.signup.confirmPasswordPlaceholder": "يرجى تأكيد كلمة المرور",
|
||||
"betterAuth.signup.emailPlaceholder": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"betterAuth.signup.error": "فشل التسجيل، يرجى المحاولة مرة أخرى",
|
||||
"betterAuth.signup.firstNamePlaceholder": "الاسم الأول",
|
||||
"betterAuth.signup.hasAccount": "هل لديك حساب؟",
|
||||
"betterAuth.signup.lastNamePlaceholder": "اسم العائلة",
|
||||
"betterAuth.signup.passwordPlaceholder": "يرجى إدخال كلمة المرور",
|
||||
"betterAuth.signup.signinLink": "تسجيل الدخول الآن",
|
||||
"betterAuth.signup.submit": "تسجيل",
|
||||
"betterAuth.signup.subtitle": "ابدأ مساحة التعاون الخاصة بـ Agents",
|
||||
"betterAuth.signup.success": "تم التسجيل بنجاح! يرجى التحقق من بريدك الإلكتروني لتأكيد الحساب",
|
||||
"betterAuth.signup.title": "إنشاء حساب",
|
||||
"betterAuth.signup.usernamePlaceholder": "يرجى إدخال اسم المستخدم",
|
||||
"betterAuth.verifyEmail.backToSignIn": "العودة إلى تسجيل الدخول",
|
||||
"betterAuth.verifyEmail.checkSpam": "إذا لم تتلقَ البريد الإلكتروني، يرجى التحقق من مجلد الرسائل غير المرغوب فيها",
|
||||
"betterAuth.verifyEmail.description": "تم إرسال رسالة تحقق إلى {{email}}",
|
||||
"betterAuth.verifyEmail.resend.button": "إعادة إرسال رسالة التحقق",
|
||||
"betterAuth.verifyEmail.resend.error": "فشل الإرسال، يرجى المحاولة لاحقًا",
|
||||
"betterAuth.verifyEmail.resend.noEmail": "عنوان البريد الإلكتروني مفقود",
|
||||
"betterAuth.verifyEmail.resend.success": "تمت إعادة إرسال رسالة التحقق، يرجى التحقق من بريدك الإلكتروني",
|
||||
"betterAuth.verifyEmail.title": "تحقق من بريدك الإلكتروني",
|
||||
"date.prevMonth": "الشهر الماضي",
|
||||
"date.recent30Days": "آخر 30 يومًا",
|
||||
"footer.agreement": "بالمتابعة، فإنك تؤكد أنك قد قرأت ووافقت على <terms>الشروط والأحكام</terms> و<privacy>سياسة الخصوصية</privacy>",
|
||||
"footer.privacy": "سياسة الخصوصية",
|
||||
"footer.terms": "شروط الخدمة",
|
||||
"header.desc": "إدارة معلومات حسابك.",
|
||||
"header.title": "الحساب",
|
||||
"heatmaps.legend.less": "غير نشط",
|
||||
"heatmaps.legend.more": "نشط",
|
||||
"heatmaps.months.apr": "أبريل",
|
||||
"heatmaps.months.aug": "أغسطس",
|
||||
"heatmaps.months.dec": "ديسمبر",
|
||||
"heatmaps.months.feb": "فبراير",
|
||||
"heatmaps.months.jan": "يناير",
|
||||
"heatmaps.months.jul": "يوليو",
|
||||
"heatmaps.months.jun": "يونيو",
|
||||
"heatmaps.months.mar": "مارس",
|
||||
"heatmaps.months.may": "مايو",
|
||||
"heatmaps.months.nov": "نوفمبر",
|
||||
"heatmaps.months.oct": "أكتوبر",
|
||||
"heatmaps.months.sep": "سبتمبر",
|
||||
"heatmaps.tooltip": "{{date}} أرسل {{count}} رسائل في ذلك اليوم",
|
||||
"heatmaps.totalCount": "إجمالي {{count}} رسائل أرسلت في العام الماضي",
|
||||
"login": "تسجيل الدخول",
|
||||
"loginOrSignup": "تسجيل الدخول / الاشتراك",
|
||||
"profile": {
|
||||
"avatar": "الصورة الشخصية",
|
||||
"cancel": "إلغاء",
|
||||
"changePassword": "إعادة تعيين كلمة المرور",
|
||||
"email": "عنوان البريد الإلكتروني",
|
||||
"fullName": "الاسم الكامل",
|
||||
"fullNameInputHint": "يرجى إدخال الاسم الكامل الجديد",
|
||||
"password": "كلمة المرور",
|
||||
"resetPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"resetPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
|
||||
"save": "حفظ",
|
||||
"setPassword": "تعيين كلمة المرور",
|
||||
"sso": {
|
||||
"link": {
|
||||
"button": "ربط الحساب",
|
||||
"success": "تم ربط الحساب بنجاح"
|
||||
},
|
||||
"loading": "جارٍ تحميل الحسابات المرتبطة من طرف ثالث",
|
||||
"providers": "الحسابات المتصلة",
|
||||
"unlink": {
|
||||
"description": "بعد إلغاء الربط، لن تتمكن من تسجيل الدخول باستخدام حساب {{provider}} \"{{providerAccountId}}\". إذا كنت ترغب في ربط حساب {{provider}} بهذا الحساب مرة أخرى، يرجى التأكد من أن عنوان البريد الإلكتروني لحساب {{provider}} هو {{email}}، وسنقوم بربطه تلقائيًا عند تسجيل الدخول.",
|
||||
"forbidden": "يجب أن تحتفظ بحساب طرف ثالث واحد على الأقل مرتبطًا.",
|
||||
"title": "هل تريد فصل حساب الطرف الثالث {{provider}}؟"
|
||||
}
|
||||
},
|
||||
"title": "تفاصيل الملف الشخصي",
|
||||
"updateAvatar": "تحديث الصورة الشخصية",
|
||||
"updateFullName": "تحديث الاسم الكامل",
|
||||
"updateUsername": "تحديث اسم المستخدم",
|
||||
"username": "اسم المستخدم",
|
||||
"usernameDuplicate": "اسم المستخدم مستخدم بالفعل",
|
||||
"usernameInputHint": "يرجى إدخال اسم مستخدم جديد",
|
||||
"usernamePlaceholder": "يرجى إدخال اسم مستخدم مكوّن من أحرف أو أرقام أو شرطة سفلية",
|
||||
"usernameRequired": "اسم المستخدم لا يمكن أن يكون فارغًا",
|
||||
"usernameRule": "اسم المستخدم يجب أن يحتوي فقط على أحرف أو أرقام أو شرطة سفلية",
|
||||
"usernameUpdateFailed": "فشل في تحديث اسم المستخدم، يرجى المحاولة لاحقًا"
|
||||
},
|
||||
"profile.authorizations.actions.revoke": "إلغاء التفويض",
|
||||
"profile.authorizations.revoke.description": "بعد إلغاء التفويض، لن يتمكن هذا التطبيق من الوصول إلى بياناتك. لإعادة استخدامه، ستحتاج إلى منحه التفويض مرة أخرى.",
|
||||
"profile.authorizations.revoke.title": "هل أنت متأكد من إلغاء التفويض لـ {{name}}؟",
|
||||
"profile.authorizations.title": "إدارة التفويضات",
|
||||
"profile.avatar": "الصورة الشخصية",
|
||||
"profile.cancel": "إلغاء",
|
||||
"profile.changePassword": "إعادة تعيين كلمة المرور",
|
||||
"profile.email": "عنوان البريد الإلكتروني",
|
||||
"profile.fullName": "الاسم الكامل",
|
||||
"profile.fullNameInputHint": "يرجى إدخال الاسم الكامل الجديد",
|
||||
"profile.interests": "مجالات الاهتمام",
|
||||
"profile.interestsAdd": "إضافة",
|
||||
"profile.interestsPlaceholder": "أدخل مجالات الاهتمام",
|
||||
"profile.password": "كلمة المرور",
|
||||
"profile.resetPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"profile.resetPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
|
||||
"profile.save": "حفظ",
|
||||
"profile.setPassword": "تعيين كلمة المرور",
|
||||
"profile.sso.link.button": "ربط الحساب",
|
||||
"profile.sso.link.success": "تم ربط الحساب بنجاح",
|
||||
"profile.sso.loading": "جارٍ تحميل الحسابات المرتبطة من طرف ثالث",
|
||||
"profile.sso.providers": "الحسابات المتصلة",
|
||||
"profile.sso.unlink.description": "بعد إلغاء الربط، لن تتمكن من تسجيل الدخول باستخدام حساب {{provider}} \"{{providerAccountId}}\". إذا كنت ترغب في ربط حساب {{provider}} بهذا الحساب مرة أخرى، يرجى التأكد من أن عنوان البريد الإلكتروني لحساب {{provider}} هو {{email}}، وسنقوم بربطه تلقائيًا عند تسجيل الدخول.",
|
||||
"profile.sso.unlink.forbidden": "يجب أن تحتفظ بحساب طرف ثالث واحد على الأقل مرتبطًا.",
|
||||
"profile.sso.unlink.title": "هل تريد فصل حساب الطرف الثالث {{provider}}؟",
|
||||
"profile.title": "تفاصيل الملف الشخصي",
|
||||
"profile.updateAvatar": "تحديث الصورة الشخصية",
|
||||
"profile.updateFullName": "تحديث الاسم الكامل",
|
||||
"profile.updateInterests": "تحديث مجالات الاهتمام",
|
||||
"profile.updateUsername": "تحديث اسم المستخدم",
|
||||
"profile.username": "اسم المستخدم",
|
||||
"profile.usernameDuplicate": "اسم المستخدم مستخدم بالفعل",
|
||||
"profile.usernameInputHint": "يرجى إدخال اسم مستخدم جديد",
|
||||
"profile.usernamePlaceholder": "يرجى إدخال اسم مستخدم مكوّن من أحرف أو أرقام أو شرطة سفلية",
|
||||
"profile.usernameRequired": "اسم المستخدم لا يمكن أن يكون فارغًا",
|
||||
"profile.usernameRule": "اسم المستخدم يجب أن يحتوي فقط على أحرف أو أرقام أو شرطة سفلية",
|
||||
"profile.usernameUpdateFailed": "فشل في تحديث اسم المستخدم، يرجى المحاولة لاحقًا",
|
||||
"signin.subtitle": "سجّل أو قم بتسجيل الدخول إلى حسابك في {{appName}}",
|
||||
"signin.title": "مساحة التعاون الخاصة بك في Agents",
|
||||
"signout": "تسجيل الخروج",
|
||||
"signup": "الاشتراك",
|
||||
"stats": {
|
||||
"aiheatmaps": "مؤشر النشاط",
|
||||
"assistants": "المساعدون",
|
||||
"assistantsRank": {
|
||||
"left": "المساعد",
|
||||
"right": "المواضيع",
|
||||
"title": "ترتيب استخدام المساعد"
|
||||
},
|
||||
"createdAt": "تاريخ التسجيل",
|
||||
"days": "أيام",
|
||||
"empty": {
|
||||
"desc": "يرجى تجميع المزيد من بيانات الدردشة للعرض",
|
||||
"title": "لا توجد بيانات"
|
||||
},
|
||||
"lastYearActivity": "النشاط في العام الماضي",
|
||||
"loginGuide": {
|
||||
"f1": "احصل على استخدام مجاني",
|
||||
"f2": "مزامنة الرسائل عبر الأجهزة المتعددة",
|
||||
"f3": "تمتع بمساعدين متنوعين",
|
||||
"f4": "استكشف الإضافات القوية",
|
||||
"title": "بعد تسجيل الدخول يمكنك:"
|
||||
},
|
||||
"messages": "رسائل",
|
||||
"modelsRank": {
|
||||
"left": "النموذج",
|
||||
"right": "الرسائل",
|
||||
"title": "ترتيب استخدام النموذج"
|
||||
},
|
||||
"share": {
|
||||
"title": "مؤشر نشاط الذكاء الاصطناعي الخاص بي"
|
||||
},
|
||||
"topics": "المواضيع",
|
||||
"topicsRank": {
|
||||
"left": "الموضوع",
|
||||
"right": "الرسائل",
|
||||
"title": "ترتيب محتوى الموضوع"
|
||||
},
|
||||
"updatedAt": "تاريخ التحديث",
|
||||
"welcome": "{{username}}، هذا هو يومك <span>{{days}}</span> مع {{appName}}",
|
||||
"words": "كلمات"
|
||||
},
|
||||
"tab": {
|
||||
"apikey": "إدارة مفاتيح API",
|
||||
"profile": "الملف الشخصي",
|
||||
"security": "الأمان",
|
||||
"stats": "الإحصائيات",
|
||||
"usage": "إحصاءات الاستخدام"
|
||||
},
|
||||
"usage": {
|
||||
"activeModels": {
|
||||
"modelTable": "قائمة النماذج",
|
||||
"models": "النماذج النشطة",
|
||||
"providerTable": "قائمة المزودين",
|
||||
"providers": "المزودون النشطون",
|
||||
"table": {
|
||||
"calls": "عدد الاستدعاءات",
|
||||
"model": "النموذج",
|
||||
"provider": "المزود",
|
||||
"spend": "التكلفة"
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"month": {
|
||||
"modelCalls": "استدعاءات النموذج",
|
||||
"title": "إنفاق هذا الشهر"
|
||||
},
|
||||
"today": {
|
||||
"title": "إنفاق اليوم",
|
||||
"yesterday": "أمس"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"actions": "إجراءات",
|
||||
"createdAt": "وقت الاستخدام",
|
||||
"inputTokens": "رموز الإدخال",
|
||||
"model": "النموذج",
|
||||
"outputTokens": "رموز الإخراج",
|
||||
"spend": "التكلفة",
|
||||
"tps": "TPS",
|
||||
"ttft": "TTFT",
|
||||
"type": "نوع الاستدعاء"
|
||||
},
|
||||
"trends": {
|
||||
"spend": "المبلغ",
|
||||
"tokens": "الرموز"
|
||||
},
|
||||
"welcome": {
|
||||
"model": "النموذج",
|
||||
"provider": "المزود"
|
||||
}
|
||||
}
|
||||
"stats.aiheatmaps": "مؤشر النشاط",
|
||||
"stats.assistants": "المساعدون",
|
||||
"stats.assistantsRank.left": "المساعد",
|
||||
"stats.assistantsRank.right": "المواضيع",
|
||||
"stats.assistantsRank.title": "ترتيب استخدام المساعد",
|
||||
"stats.createdAt": "تاريخ التسجيل",
|
||||
"stats.days": "أيام",
|
||||
"stats.empty.desc": "يرجى تجميع المزيد من بيانات الدردشة للعرض",
|
||||
"stats.empty.title": "لا توجد بيانات",
|
||||
"stats.lastYearActivity": "النشاط في العام الماضي",
|
||||
"stats.loginGuide.f1": "احصل على استخدام مجاني",
|
||||
"stats.loginGuide.f2": "مزامنة الرسائل عبر الأجهزة المتعددة",
|
||||
"stats.loginGuide.f3": "تمتع بمساعدين متنوعين",
|
||||
"stats.loginGuide.f4": "استكشف الإضافات القوية",
|
||||
"stats.loginGuide.title": "بعد تسجيل الدخول يمكنك:",
|
||||
"stats.messages": "رسائل",
|
||||
"stats.modelsRank.left": "النموذج",
|
||||
"stats.modelsRank.right": "الرسائل",
|
||||
"stats.modelsRank.title": "ترتيب استخدام النموذج",
|
||||
"stats.share.title": "مؤشر نشاط الذكاء الاصطناعي الخاص بي",
|
||||
"stats.topics": "المواضيع",
|
||||
"stats.topicsRank.left": "الموضوع",
|
||||
"stats.topicsRank.right": "الرسائل",
|
||||
"stats.topicsRank.title": "ترتيب محتوى الموضوع",
|
||||
"stats.updatedAt": "تاريخ التحديث",
|
||||
"stats.welcome": "{{username}}، هذا هو يومك <span>{{days}}</span> مع {{appName}}",
|
||||
"stats.words": "كلمات",
|
||||
"tab.apikey": "إدارة مفاتيح API",
|
||||
"tab.profile": "حسابي",
|
||||
"tab.security": "الأمان",
|
||||
"tab.stats": "الإحصائيات",
|
||||
"tab.usage": "إحصاءات الاستخدام",
|
||||
"usage.activeModels.modelTable": "قائمة النماذج",
|
||||
"usage.activeModels.models": "النماذج النشطة",
|
||||
"usage.activeModels.providerTable": "قائمة المزودين",
|
||||
"usage.activeModels.providers": "المزودون النشطون",
|
||||
"usage.activeModels.table.calls": "عدد الاستدعاءات",
|
||||
"usage.activeModels.table.model": "النموذج",
|
||||
"usage.activeModels.table.provider": "المزود",
|
||||
"usage.activeModels.table.spend": "التكلفة",
|
||||
"usage.cards.month.modelCalls": "استدعاءات النموذج",
|
||||
"usage.cards.month.title": "إنفاق هذا الشهر",
|
||||
"usage.cards.today.title": "إنفاق اليوم",
|
||||
"usage.cards.today.yesterday": "أمس",
|
||||
"usage.table.actions": "إجراءات",
|
||||
"usage.table.createdAt": "وقت الاستخدام",
|
||||
"usage.table.inputTokens": "رموز الإدخال",
|
||||
"usage.table.model": "النموذج",
|
||||
"usage.table.outputTokens": "رموز الإخراج",
|
||||
"usage.table.spend": "التكلفة",
|
||||
"usage.table.tps": "TPS",
|
||||
"usage.table.ttft": "TTFT",
|
||||
"usage.table.type": "نوع الاستدعاء",
|
||||
"usage.trends.spend": "المبلغ",
|
||||
"usage.trends.tokens": "الرموز",
|
||||
"usage.welcome.model": "النموذج",
|
||||
"usage.welcome.provider": "المزود"
|
||||
}
|
||||
|
||||
+33
-37
@@ -1,40 +1,36 @@
|
||||
{
|
||||
"actions": {
|
||||
"discord": "اذهب إلى Discord لإرسال الملاحظات",
|
||||
"home": "العودة إلى الصفحة الرئيسية",
|
||||
"retry": "تسجيل الدخول مرة أخرى"
|
||||
},
|
||||
"codes": {
|
||||
"ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "تم ربط هذا الحساب بمستخدم آخر",
|
||||
"ACCOUNT_NOT_FOUND": "لم يتم العثور على الحساب",
|
||||
"CREDENTIAL_ACCOUNT_NOT_FOUND": "حساب بيانات الاعتماد غير موجود",
|
||||
"EMAIL_CAN_NOT_BE_UPDATED": "لا يمكن تعديل البريد الإلكتروني لهذا الحساب",
|
||||
"EMAIL_NOT_VERIFIED": "يرجى التحقق من بريدك الإلكتروني أولاً",
|
||||
"FAILED_TO_CREATE_SESSION": "فشل في إنشاء الجلسة",
|
||||
"FAILED_TO_CREATE_USER": "فشل في إنشاء المستخدم",
|
||||
"FAILED_TO_GET_SESSION": "فشل في الحصول على الجلسة",
|
||||
"FAILED_TO_GET_USER_INFO": "فشل في جلب معلومات المستخدم",
|
||||
"FAILED_TO_UNLINK_LAST_ACCOUNT": "لا يمكن إلغاء ربط آخر حساب مرتبط",
|
||||
"FAILED_TO_UPDATE_USER": "فشل في تحديث معلومات المستخدم",
|
||||
"ID_TOKEN_NOT_SUPPORTED": "رمز الهوية غير مدعوم",
|
||||
"INVALID_EMAIL": "تنسيق البريد الإلكتروني غير صحيح",
|
||||
"INVALID_EMAIL_OR_PASSWORD": "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||
"INVALID_PASSWORD": "تنسيق كلمة المرور غير صالح",
|
||||
"INVALID_TOKEN": "الرمز غير صالح أو منتهي الصلاحية",
|
||||
"PASSWORD_TOO_LONG": "كلمة المرور طويلة جداً",
|
||||
"PASSWORD_TOO_SHORT": "كلمة المرور قصيرة جداً",
|
||||
"PROVIDER_NOT_FOUND": "لم يتم العثور على مزود الهوية المناسب",
|
||||
"RATE_LIMIT_EXCEEDED": "عدد الطلبات كبير جداً، يرجى المحاولة لاحقاً",
|
||||
"SESSION_EXPIRED": "انتهت صلاحية الجلسة، يرجى تسجيل الدخول مجدداً",
|
||||
"SOCIAL_ACCOUNT_ALREADY_LINKED": "تم ربط هذا الحساب الاجتماعي بمستخدم آخر",
|
||||
"UNEXPECTED_ERROR": "حدث خطأ غير متوقع، يرجى المحاولة مرة أخرى",
|
||||
"UNKNOWN": "حدث خطأ غير معروف، يرجى المحاولة مرة أخرى أو التواصل مع الدعم",
|
||||
"USER_ALREADY_EXISTS": "المستخدم موجود بالفعل",
|
||||
"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "تم استخدام هذا البريد الإلكتروني، يرجى تجربة بريد آخر",
|
||||
"USER_ALREADY_HAS_PASSWORD": "تم تعيين كلمة مرور لهذا الحساب مسبقاً",
|
||||
"USER_BANNED": "تم حظر هذا المستخدم",
|
||||
"USER_EMAIL_NOT_FOUND": "لم يتم العثور على البريد الإلكتروني",
|
||||
"USER_NOT_FOUND": "لم يتم العثور على المستخدم"
|
||||
},
|
||||
"actions.discord": "اذهب إلى Discord لإرسال الملاحظات",
|
||||
"actions.home": "العودة إلى الصفحة الرئيسية",
|
||||
"actions.retry": "تسجيل الدخول مرة أخرى",
|
||||
"codes.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "تم ربط هذا الحساب بمستخدم آخر",
|
||||
"codes.ACCOUNT_NOT_FOUND": "لم يتم العثور على الحساب",
|
||||
"codes.CREDENTIAL_ACCOUNT_NOT_FOUND": "حساب بيانات الاعتماد غير موجود",
|
||||
"codes.EMAIL_CAN_NOT_BE_UPDATED": "لا يمكن تعديل البريد الإلكتروني لهذا الحساب",
|
||||
"codes.EMAIL_NOT_VERIFIED": "يرجى التحقق من بريدك الإلكتروني أولاً",
|
||||
"codes.FAILED_TO_CREATE_SESSION": "فشل في إنشاء الجلسة",
|
||||
"codes.FAILED_TO_CREATE_USER": "فشل في إنشاء المستخدم",
|
||||
"codes.FAILED_TO_GET_SESSION": "فشل في الحصول على الجلسة",
|
||||
"codes.FAILED_TO_GET_USER_INFO": "فشل في جلب معلومات المستخدم",
|
||||
"codes.FAILED_TO_UNLINK_LAST_ACCOUNT": "لا يمكن إلغاء ربط آخر حساب مرتبط",
|
||||
"codes.FAILED_TO_UPDATE_USER": "فشل في تحديث معلومات المستخدم",
|
||||
"codes.ID_TOKEN_NOT_SUPPORTED": "رمز الهوية غير مدعوم",
|
||||
"codes.INVALID_EMAIL": "تنسيق البريد الإلكتروني غير صحيح",
|
||||
"codes.INVALID_EMAIL_OR_PASSWORD": "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||
"codes.INVALID_PASSWORD": "تنسيق كلمة المرور غير صالح",
|
||||
"codes.INVALID_TOKEN": "الرمز غير صالح أو منتهي الصلاحية",
|
||||
"codes.PASSWORD_TOO_LONG": "كلمة المرور طويلة جداً",
|
||||
"codes.PASSWORD_TOO_SHORT": "كلمة المرور قصيرة جداً",
|
||||
"codes.PROVIDER_NOT_FOUND": "لم يتم العثور على مزود الهوية المناسب",
|
||||
"codes.RATE_LIMIT_EXCEEDED": "عدد الطلبات كبير جداً، يرجى المحاولة لاحقاً",
|
||||
"codes.SESSION_EXPIRED": "انتهت صلاحية الجلسة، يرجى تسجيل الدخول مجدداً",
|
||||
"codes.SOCIAL_ACCOUNT_ALREADY_LINKED": "تم ربط هذا الحساب الاجتماعي بمستخدم آخر",
|
||||
"codes.UNEXPECTED_ERROR": "حدث خطأ غير متوقع، يرجى المحاولة مرة أخرى",
|
||||
"codes.UNKNOWN": "حدث خطأ غير معروف، يرجى المحاولة مرة أخرى أو التواصل مع الدعم",
|
||||
"codes.USER_ALREADY_EXISTS": "المستخدم موجود بالفعل",
|
||||
"codes.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "تم استخدام هذا البريد الإلكتروني، يرجى تجربة بريد آخر",
|
||||
"codes.USER_ALREADY_HAS_PASSWORD": "تم تعيين كلمة مرور لهذا الحساب مسبقاً",
|
||||
"codes.USER_BANNED": "تم حظر هذا المستخدم",
|
||||
"codes.USER_EMAIL_NOT_FOUND": "لم يتم العثور على البريد الإلكتروني",
|
||||
"codes.USER_NOT_FOUND": "لم يتم العثور على المستخدم",
|
||||
"title": "حدث خطأ في التحقق من الهوية"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
{
|
||||
"actions": {
|
||||
"followOnX": "تابعنا على X",
|
||||
"subscribeToUpdates": "اشترك في التحديثات",
|
||||
"versions": "تفاصيل الإصدار"
|
||||
},
|
||||
"actions.followOnX": "تابعنا على X",
|
||||
"actions.subscribeToUpdates": "اشترك في التحديثات",
|
||||
"actions.versions": "تفاصيل الإصدار",
|
||||
"addedWhileAway": "لقد أضفنا ميزات جديدة أثناء غيابك.",
|
||||
"allChangelog": "عرض جميع سجلات التحديثات",
|
||||
"description": "تابع الميزات الجديدة والتحسينات في {{appName}}",
|
||||
"pagination": {
|
||||
"next": "الصفحة التالية",
|
||||
"older": "عرض التغييرات السابقة"
|
||||
},
|
||||
"pagination.next": "الصفحة التالية",
|
||||
"pagination.older": "عرض التغييرات السابقة",
|
||||
"readDetails": "اقرأ التفاصيل",
|
||||
"title": "سجل التحديثات",
|
||||
"versionDetails": "تفاصيل الإصدار",
|
||||
|
||||
+320
-414
@@ -1,456 +1,362 @@
|
||||
{
|
||||
"ModelSwitch": {
|
||||
"title": "النموذج"
|
||||
},
|
||||
"ModelSwitch.title": "النموذج",
|
||||
"active": "نشط",
|
||||
"agentBuilder.installPlugin.authRequired": "يتطلب مكون MCP السحابي تسجيل الدخول والمصادقة",
|
||||
"agentBuilder.installPlugin.cancel": "إلغاء",
|
||||
"agentBuilder.installPlugin.clickApproveToConnect": "انقر على \"الموافقة\" للاتصال وتفويض هذا التكامل",
|
||||
"agentBuilder.installPlugin.clickApproveToInstall": "انقر على \"الموافقة\" لتثبيت هذا المكون الإضافي",
|
||||
"agentBuilder.installPlugin.connectedAndEnabled": "تم الاتصال والتفعيل",
|
||||
"agentBuilder.installPlugin.connectionFailed": "فشل الاتصال",
|
||||
"agentBuilder.installPlugin.installFailed": "فشل التثبيت",
|
||||
"agentBuilder.installPlugin.installPlugin": "تثبيت المكون الإضافي",
|
||||
"agentBuilder.installPlugin.installToEnable": "قم بتثبيت هذا المكون الإضافي لتمكين المساعد",
|
||||
"agentBuilder.installPlugin.installedAndEnabled": "تم التثبيت والتفعيل",
|
||||
"agentBuilder.installPlugin.requiresAuth": "يتطلب تفويضًا، انقر على \"الموافقة\" للاتصال",
|
||||
"agentBuilder.installPlugin.retry": "إعادة المحاولة",
|
||||
"agentBuilder.title": "خبير إنشاء المساعدين",
|
||||
"agentBuilder.welcome": "ما هو سيناريو احتياجك؟ شريكك المهني جاهز لخدمتك.\n\nسواء كنت تكتب، تبرمج، أو تحلل البيانات، يمكنني مساعدتك في إنشاء مساعدك الخاص!",
|
||||
"agentDefaultMessage": "مرحبًا، أنا **{{name}}**، يمكنك بدء المحادثة معي على الفور، أو يمكنك الذهاب إلى [إعدادات المساعد]({{url}}) لإكمال معلوماتي.",
|
||||
"agentDefaultMessageWithSystemRole": "مرحبًا، أنا **{{name}}**، كيف يمكنني مساعدتك؟",
|
||||
"agentDefaultMessageWithoutEdit": "مرحبًا، أنا **{{name}}**، كيف يمكنني مساعدتك؟",
|
||||
"agents": "مساعد",
|
||||
"artifact": {
|
||||
"generating": "جاري الإنشاء",
|
||||
"inThread": "لا يمكن عرض الموضوعات الفرعية، يرجى التبديل إلى منطقة المحادثة الرئيسية لفتحها",
|
||||
"thinking": "جاري التفكير",
|
||||
"thought": "عملية التفكير",
|
||||
"unknownTitle": "عمل غير مسمى"
|
||||
},
|
||||
"artifact.generating": "جاري الإنشاء",
|
||||
"artifact.inThread": "لا يمكن عرض الموضوعات الفرعية، يرجى التبديل إلى منطقة المحادثة الرئيسية لفتحها",
|
||||
"artifact.thinking": "جاري التفكير",
|
||||
"artifact.thought": "عملية التفكير",
|
||||
"artifact.unknownTitle": "عمل غير مسمى",
|
||||
"availableAgents": "المساعدون المتاحون",
|
||||
"backToBottom": "العودة إلى الأسفل",
|
||||
"chatList": {
|
||||
"expandMessage": "عرض الرسائل",
|
||||
"longMessageDetail": "عرض التفاصيل"
|
||||
},
|
||||
"builtinCopilot": "المساعد المدمج",
|
||||
"chatList.expandMessage": "عرض الرسائل",
|
||||
"chatList.longMessageDetail": "عرض التفاصيل",
|
||||
"clearCurrentMessages": "مسح رسائل الجلسة الحالية",
|
||||
"confirmClearCurrentMessages": "سيتم مسح رسائل الجلسة الحالية قريبًا، وبمجرد المسح لن يمكن استعادتها، يرجى تأكيد الإجراء الخاص بك",
|
||||
"confirmRemoveChatGroupItemAlert": "سيتم حذف فريق الوكيل هذا، ولن يتأثر الأعضاء الآخرون. يرجى تأكيد الإجراء.",
|
||||
"confirmRemoveChatGroupItemAlert": "سيتم حذف هذه المجموعة، ولن يتأثر أعضاء الفريق. يرجى تأكيد الإجراء.",
|
||||
"confirmRemoveGroupItemAlert": "سيتم حذف هذه المجموعة قريبًا. بعد الحذف، سيُنتقل المساعدون في هذه المجموعة إلى القائمة الافتراضية. يرجى تأكيد إجراء الحذف.",
|
||||
"confirmRemoveGroupSuccess": "تم حذف فريق الوكلاء بنجاح",
|
||||
"confirmRemoveGroupSuccess": "تم حذف المجموعة بنجاح",
|
||||
"confirmRemoveSessionItemAlert": "سيتم حذف هذا المساعد قريبًا، وبمجرد الحذف لن يمكن استعادته، يرجى تأكيد الإجراء الخاص بك",
|
||||
"confirmRemoveSessionSuccess": "تم حذف المساعد بنجاح",
|
||||
"defaultAgent": "المساعد الافتراضي",
|
||||
"defaultGroupChat": "فريق الوكلاء",
|
||||
"defaultGroupChat": "مجموعة",
|
||||
"defaultList": "القائمة الافتراضية",
|
||||
"defaultSession": "المساعد الافتراضي",
|
||||
"dm": {
|
||||
"placeholder": "ستظهر رسائلك الخاصة مع {{agentTitle}} هنا.",
|
||||
"tooltip": "أرسل رسالة خاصة",
|
||||
"visibleTo": "مرئي فقط لـ {{target}}",
|
||||
"you": "أنت"
|
||||
},
|
||||
"duplicateSession": {
|
||||
"loading": "جاري النسخ...",
|
||||
"success": "تم النسخ بنجاح",
|
||||
"title": "{{title}} نسخة"
|
||||
},
|
||||
"dm.placeholder": "ستظهر رسائلك الخاصة مع {{agentTitle}} هنا.",
|
||||
"dm.tooltip": "أرسل رسالة خاصة",
|
||||
"dm.visibleTo": "مرئي فقط لـ {{target}}",
|
||||
"dm.you": "أنت",
|
||||
"duplicateSession.loading": "جاري النسخ...",
|
||||
"duplicateSession.success": "تم النسخ بنجاح",
|
||||
"duplicateSession.title": "{{title}} نسخة",
|
||||
"duplicateTitle": "{{title}} نسخة",
|
||||
"emptyAgent": "لا يوجد مساعد",
|
||||
"extendParams": {
|
||||
"disableContextCaching": {
|
||||
"desc": "يمكن تقليل تكلفة توليد محادثة واحدة بنسبة تصل إلى 90%، وزيادة سرعة الاستجابة بمقدار 4 مرات (<1>اعرف المزيد</1>). عند التفعيل، سيتم تعطيل حد عدد الرسائل التاريخية تلقائيًا",
|
||||
"title": "تفعيل تخزين السياق"
|
||||
},
|
||||
"enableReasoning": {
|
||||
"desc": "استنادًا إلى آلية تفكير كلود (Claude Thinking) المحدودة (<1>اعرف المزيد</1>)، عند التفعيل، سيتم تعطيل حد عدد الرسائل التاريخية تلقائيًا",
|
||||
"title": "تفعيل التفكير العميق"
|
||||
},
|
||||
"imageAspectRatio": {
|
||||
"title": "نسبة العرض إلى الارتفاع للصورة"
|
||||
},
|
||||
"imageResolution": {
|
||||
"title": "دقة الصورة"
|
||||
},
|
||||
"reasoningBudgetToken": {
|
||||
"title": "استهلاك توكن التفكير"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"title": "شدة الاستدلال"
|
||||
},
|
||||
"textVerbosity": {
|
||||
"title": "مستوى تفصيل المخرجات النصية"
|
||||
},
|
||||
"thinking": {
|
||||
"title": "مفتاح التفكير العميق"
|
||||
},
|
||||
"thinkingLevel": {
|
||||
"title": "مستوى التفكير"
|
||||
},
|
||||
"title": "وظائف توسيع النموذج",
|
||||
"urlContext": {
|
||||
"desc": "عند التفعيل، سيتم تحليل روابط الويب تلقائيًا للحصول على محتوى السياق الفعلي للصفحة",
|
||||
"title": "استخراج محتوى رابط الويب"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"desc": "التعاون مع عدة مساعدين للذكاء الاصطناعي في مساحة محادثة مشتركة.",
|
||||
"memberTooltip": "هناك {{count}} عضوًا في المجموعة",
|
||||
"orchestratorThinking": "المُنسق يفكر...",
|
||||
"removeMember": "إزالة عضو",
|
||||
"title": "مجموعة"
|
||||
},
|
||||
"emptyAgentAction": "إنشاء مساعد",
|
||||
"extendParams.disableContextCaching.desc": "يمكن تقليل تكلفة توليد محادثة واحدة بنسبة تصل إلى 90%، وزيادة سرعة الاستجابة بمقدار 4 مرات (<1>اعرف المزيد</1>). عند التفعيل، سيتم تعطيل حد عدد الرسائل التاريخية تلقائيًا",
|
||||
"extendParams.disableContextCaching.title": "تفعيل تخزين السياق",
|
||||
"extendParams.enableReasoning.desc": "استنادًا إلى آلية تفكير كلود (Claude Thinking) المحدودة (<1>اعرف المزيد</1>)، عند التفعيل، سيتم تعطيل حد عدد الرسائل التاريخية تلقائيًا",
|
||||
"extendParams.enableReasoning.title": "تفعيل التفكير العميق",
|
||||
"extendParams.imageAspectRatio.title": "نسبة العرض إلى الارتفاع للصورة",
|
||||
"extendParams.imageResolution.title": "دقة الصورة",
|
||||
"extendParams.reasoningBudgetToken.title": "استهلاك توكن التفكير",
|
||||
"extendParams.reasoningEffort.title": "شدة الاستدلال",
|
||||
"extendParams.textVerbosity.title": "مستوى تفصيل المخرجات النصية",
|
||||
"extendParams.thinking.title": "مفتاح التفكير العميق",
|
||||
"extendParams.thinkingLevel.title": "مستوى التفكير",
|
||||
"extendParams.title": "وظائف توسيع النموذج",
|
||||
"extendParams.urlContext.desc": "عند التفعيل، سيتم تحليل روابط الويب تلقائيًا للحصول على محتوى السياق الفعلي للصفحة",
|
||||
"extendParams.urlContext.title": "استخراج محتوى رابط الويب",
|
||||
"group.desc": "التعاون مع عدة مساعدين للذكاء الاصطناعي في مساحة محادثة مشتركة.",
|
||||
"group.memberTooltip": "هناك {{count}} عضوًا في المجموعة",
|
||||
"group.orchestratorThinking": "المُنسق يفكر...",
|
||||
"group.removeMember": "إزالة عضو",
|
||||
"group.title": "مجموعة",
|
||||
"groupDescription": "وصف الفريق",
|
||||
"groupSidebar": {
|
||||
"members": {
|
||||
"addMember": "إضافة عضو",
|
||||
"memberSettings": "إعدادات العضو",
|
||||
"orchestrator": "المُنسق",
|
||||
"orchestratorThinking": "المُنسق يفكر...",
|
||||
"removeMember": "إزالة عضو",
|
||||
"stopOrchestrator": "إيقاف التفكير",
|
||||
"triggerOrchestrator": "بدء المحادثة الجماعية"
|
||||
},
|
||||
"tabs": {
|
||||
"host": "المضيف",
|
||||
"members": "الأعضاء",
|
||||
"role": "الإعداد"
|
||||
}
|
||||
},
|
||||
"groupWizard": {
|
||||
"chooseMembers": "اختر المساعدين الحاليين...",
|
||||
"createGroup": "إنشاء فريق",
|
||||
"existingMembers": "الوكلاء الحاليون",
|
||||
"groupMembers": "سيتم أيضًا إضافة هؤلاء المساعدين إلى قائمتك",
|
||||
"host": {
|
||||
"description": "تمكين الفريق من العمل بشكل مستقل",
|
||||
"title": "تفعيل المضيف",
|
||||
"tooltip": "إذا قمت بتعطيل المضيف، فستحتاج إلى الإشارة إلى الأعضاء يدويًا باستخدام @ لكي يتمكنوا من الرد"
|
||||
},
|
||||
"memberCount": "{{count}} عضو",
|
||||
"noMatchingTemplates": "لا توجد قوالب مطابقة",
|
||||
"noSelectedTemplates": "لم يتم اختيار أي قالب",
|
||||
"noTemplateMembers": "لا يوجد أعضاء في القالب",
|
||||
"noTemplates": "لا توجد قوالب متاحة",
|
||||
"searchTemplates": "ابحث في القوالب...",
|
||||
"title": "إنشاء فريق وكلاء",
|
||||
"useTemplate": "استخدام القالب"
|
||||
},
|
||||
"groupSidebar.agentProfile.chat": "الدردشة",
|
||||
"groupSidebar.agentProfile.model": "النموذج",
|
||||
"groupSidebar.members.addMember": "إضافة عضو",
|
||||
"groupSidebar.members.enableOrchestrator": "تفعيل المنسق",
|
||||
"groupSidebar.members.memberSettings": "إعدادات العضو",
|
||||
"groupSidebar.members.orchestrator": "المُنسق",
|
||||
"groupSidebar.members.orchestratorThinking": "المُنسق يفكر...",
|
||||
"groupSidebar.members.removeMember": "إزالة عضو",
|
||||
"groupSidebar.members.stopOrchestrator": "إيقاف التفكير",
|
||||
"groupSidebar.members.triggerOrchestrator": "بدء المحادثة الجماعية",
|
||||
"groupSidebar.tabs.host": "المضيف",
|
||||
"groupSidebar.tabs.members": "الأعضاء",
|
||||
"groupSidebar.tabs.role": "الإعداد",
|
||||
"groupWizard.chooseMembers": "اختر المساعدين الحاليين...",
|
||||
"groupWizard.createGroup": "إنشاء فريق",
|
||||
"groupWizard.existingMembers": "الوكلاء الحاليون",
|
||||
"groupWizard.groupMembers": "سيتم أيضًا إضافة هؤلاء المساعدين إلى قائمتك",
|
||||
"groupWizard.host.description": "تمكين الفريق من العمل بشكل مستقل",
|
||||
"groupWizard.host.title": "تفعيل المضيف",
|
||||
"groupWizard.host.tooltip": "إذا قمت بتعطيل المضيف، فستحتاج إلى الإشارة إلى الأعضاء يدويًا باستخدام @ لكي يتمكنوا من الرد",
|
||||
"groupWizard.memberCount": "{{count}} عضو",
|
||||
"groupWizard.noMatchingTemplates": "لا توجد قوالب مطابقة",
|
||||
"groupWizard.noSelectedTemplates": "لم يتم اختيار أي قالب",
|
||||
"groupWizard.noTemplateMembers": "لا يوجد أعضاء في القالب",
|
||||
"groupWizard.noTemplates": "لا توجد قوالب متاحة",
|
||||
"groupWizard.searchTemplates": "ابحث في القوالب...",
|
||||
"groupWizard.title": "إنشاء مجموعة",
|
||||
"groupWizard.useTemplate": "استخدام القالب",
|
||||
"hideForYou": "تم إخفاء محتوى الرسائل الخاصة، يرجى تفعيل خيار 【عرض محتوى الرسائل الخاصة】 في الإعدادات للعرض",
|
||||
"history": {
|
||||
"title": "سيتذكر المساعد آخر {{count}} رسالة فقط"
|
||||
},
|
||||
"history.title": "سيتذكر المساعد آخر {{count}} رسالة فقط",
|
||||
"historyRange": "نطاق التاريخ",
|
||||
"historySummary": "ملخص الرسائل التاريخية",
|
||||
"inactive": "غير نشط",
|
||||
"inbox": {
|
||||
"desc": "قم بتشغيل مجموعة الدماغ وأشعل شرارة التفكير. مساعدك الذكي، هنا حيث يمكنك التواصل بكل شيء",
|
||||
"title": "دردشة عشوائية"
|
||||
},
|
||||
"input": {
|
||||
"addAi": "إضافة رسالة AI",
|
||||
"addUser": "إضافة رسالة مستخدم",
|
||||
"disclaimer": "قد يرتكب الذكاء الاصطناعي أخطاءً أيضًا، يرجى التحقق من المعلومات الهامة",
|
||||
"errorMsg": "فشل إرسال الرسالة، يرجى التحقق من الشبكة والمحاولة مرة أخرى: {{errorMsg}}",
|
||||
"more": "المزيد",
|
||||
"send": "إرسال",
|
||||
"sendWithCmdEnter": "اضغط <key/> للإرسال",
|
||||
"sendWithEnter": "اضغط <key/> للإرسال",
|
||||
"stop": "توقف",
|
||||
"warp": "تغيير السطر",
|
||||
"warpWithKey": "اضغط على مفتاح <key/> للانتقال إلى السطر"
|
||||
},
|
||||
"intentUnderstanding": {
|
||||
"title": "جارٍ فهم وتحليل نواياك..."
|
||||
},
|
||||
"inbox.desc": "قم بتشغيل مجموعة الدماغ وأشعل شرارة التفكير. مساعدك الذكي، هنا حيث يمكنك التواصل بكل شيء",
|
||||
"inbox.title": "Lobe AI",
|
||||
"input.addAi": "إضافة رسالة AI",
|
||||
"input.addUser": "إضافة رسالة مستخدم",
|
||||
"input.disclaimer": "قد يرتكب الذكاء الاصطناعي أخطاءً أيضًا، يرجى التحقق من المعلومات الهامة",
|
||||
"input.errorMsg": "فشل إرسال الرسالة، يرجى التحقق من الشبكة والمحاولة مرة أخرى: {{errorMsg}}",
|
||||
"input.more": "المزيد",
|
||||
"input.send": "إرسال",
|
||||
"input.sendWithCmdEnter": "اضغط <key/> للإرسال",
|
||||
"input.sendWithEnter": "اضغط <key/> للإرسال",
|
||||
"input.stop": "توقف",
|
||||
"input.warp": "تغيير السطر",
|
||||
"input.warpWithKey": "اضغط على مفتاح <key/> للانتقال إلى السطر",
|
||||
"intentUnderstanding.title": "جارٍ فهم وتحليل نواياك...",
|
||||
"inviteMembers": "دعوة الأعضاء",
|
||||
"knowledgeBase": {
|
||||
"all": "جميع المحتويات",
|
||||
"allFiles": "جميع الملفات",
|
||||
"allKnowledgeBases": "جميع قواعد المعرفة",
|
||||
"disabled": "الوضع الحالي للنشر لا يدعم محادثات قاعدة المعرفة. إذا كنت بحاجة إلى استخدامها، يرجى التبديل إلى نشر قاعدة البيانات على الخادم أو استخدام خدمة {{cloud}}.",
|
||||
"library": {
|
||||
"action": {
|
||||
"add": "إضافة",
|
||||
"detail": "تفاصيل",
|
||||
"remove": "إزالة"
|
||||
},
|
||||
"title": "الملفات/قاعدة المعرفة"
|
||||
},
|
||||
"relativeFilesOrKnowledgeBases": "ملفات/قواعد معرفة مرتبطة",
|
||||
"title": "قاعدة المعرفة",
|
||||
"uploadGuide": "يمكنك عرض الملفات التي تم تحميلها في «قاعدة المعرفة»",
|
||||
"viewMore": "عرض المزيد"
|
||||
},
|
||||
"memberSelection": {
|
||||
"addMember": "إضافة عضو",
|
||||
"allMembers": "جميع الأعضاء",
|
||||
"createGroup": "إنشاء فريق وكيل",
|
||||
"noAvailableAgents": "لا يوجد وكلاء متاحون للدعوة",
|
||||
"noSelectedAgents": "لم يتم اختيار أي وكيل بعد",
|
||||
"searchAgents": "ابحث عن وكيل...",
|
||||
"setInitialMembers": "اختيار أعضاء الفريق"
|
||||
},
|
||||
"knowledgeBase.all": "جميع المحتويات",
|
||||
"knowledgeBase.allFiles": "جميع الملفات",
|
||||
"knowledgeBase.allLibraries": "جميع قواعد البيانات",
|
||||
"knowledgeBase.disabled": "وضع النشر الحالي لا يدعم المحادثة مع قاعدة البيانات. لاستخدام هذه الميزة، يرجى التبديل إلى نشر قاعدة بيانات على الخادم أو استخدام خدمة {{cloud}}",
|
||||
"knowledgeBase.library.action.add": "إضافة",
|
||||
"knowledgeBase.library.action.detail": "تفاصيل",
|
||||
"knowledgeBase.library.action.remove": "إزالة",
|
||||
"knowledgeBase.library.title": "الملفات / قاعدة البيانات",
|
||||
"knowledgeBase.relativeFilesOrLibraries": "الملفات / قواعد البيانات المرتبطة",
|
||||
"knowledgeBase.title": "قاعدة البيانات",
|
||||
"knowledgeBase.uploadGuide": "يمكنك عرض الملفات التي تم تحميلها في قسم \"الموارد\"",
|
||||
"knowledgeBase.viewMore": "عرض المزيد",
|
||||
"memberSelection.addMember": "إضافة عضو",
|
||||
"memberSelection.allMembers": "جميع الأعضاء",
|
||||
"memberSelection.createGroup": "إنشاء مجموعة",
|
||||
"memberSelection.noAvailableAgents": "لا يوجد وكلاء متاحون للدعوة",
|
||||
"memberSelection.noSelectedAgents": "لم يتم اختيار أي وكيل بعد",
|
||||
"memberSelection.searchAgents": "ابحث عن وكيل...",
|
||||
"memberSelection.selectedAgents": "تم الاختيار ({{count}})",
|
||||
"memberSelection.setInitialMembers": "اختيار أعضاء الفريق",
|
||||
"members": "الأعضاء",
|
||||
"mention": {
|
||||
"title": "الإشارة إلى الأعضاء"
|
||||
},
|
||||
"messageAction": {
|
||||
"collapse": "إخفاء الرسائل",
|
||||
"continueGeneration": "متابعة التوليد",
|
||||
"delAndRegenerate": "حذف وإعادة الإنشاء",
|
||||
"deleteDisabledByThreads": "يوجد موضوعات فرعية، لا يمكن الحذف",
|
||||
"expand": "عرض الرسائل",
|
||||
"regenerate": "إعادة الإنشاء"
|
||||
},
|
||||
"messages": {
|
||||
"dm": {
|
||||
"sentTo": "مرئي فقط لـ {{name}}",
|
||||
"title": "الرسائل الخاصة"
|
||||
},
|
||||
"modelCard": {
|
||||
"credit": "نقاط",
|
||||
"creditPricing": "التسعير",
|
||||
"creditTooltip": "لتسهيل العد، نقوم بتحويل 1$ إلى 1M نقطة، على سبيل المثال، 3$/M رموز تعني 3 نقاط/رمز",
|
||||
"pricing": {
|
||||
"inputCachedTokens": "مدخلات مخزنة {{amount}}/نقطة · ${{amount}}/M",
|
||||
"inputCharts": "${{amount}}/M حرف",
|
||||
"inputMinutes": "${{amount}}/دقيقة",
|
||||
"inputTokens": "مدخلات {{amount}}/نقطة · ${{amount}}/M",
|
||||
"outputTokens": "مخرجات {{amount}}/نقطة · ${{amount}}/M",
|
||||
"writeCacheInputTokens": "تخزين إدخال الكتابة {{amount}}/نقطة · ${{amount}}/ميغابايت"
|
||||
}
|
||||
},
|
||||
"tokenDetails": {
|
||||
"average": "متوسط السعر",
|
||||
"input": "مدخلات",
|
||||
"inputAudio": "مدخلات صوتية",
|
||||
"inputCached": "مدخلات مخزنة",
|
||||
"inputCitation": "اقتباس الإدخال",
|
||||
"inputText": "مدخلات نصية",
|
||||
"inputTitle": "تفاصيل المدخلات",
|
||||
"inputUncached": "مدخلات غير مخزنة",
|
||||
"inputWriteCached": "تخزين إدخال الكتابة",
|
||||
"output": "مخرجات",
|
||||
"outputAudio": "مخرجات صوتية",
|
||||
"outputImage": "إخراج الصورة",
|
||||
"outputText": "مخرجات نصية",
|
||||
"outputTitle": "تفاصيل المخرجات",
|
||||
"reasoning": "تفكير عميق",
|
||||
"speed": {
|
||||
"tps": {
|
||||
"title": "TPS",
|
||||
"tooltip": "عدد الرموز في الثانية، TPS. يشير إلى متوسط سرعة توليد المحتوى بواسطة الذكاء الاصطناعي (رمز/ثانية)، ويبدأ الحساب عند استلام أول رمز."
|
||||
},
|
||||
"ttft": {
|
||||
"title": "TTFT",
|
||||
"tooltip": "الوقت حتى أول رمز، TTFT. يشير إلى الفارق الزمني من لحظة إرسال الرسالة حتى استلام أول رمز في العميل."
|
||||
}
|
||||
},
|
||||
"title": "تفاصيل التوليد",
|
||||
"total": "الإجمالي المستهلك"
|
||||
}
|
||||
},
|
||||
"minimap": {
|
||||
"jumpToMessage": "الانتقال إلى الرسالة رقم {{index}}",
|
||||
"nextMessage": "الرسالة التالية",
|
||||
"previousMessage": "الرسالة السابقة",
|
||||
"senderAssistant": "الوكيل",
|
||||
"senderUser": "أنت"
|
||||
},
|
||||
"newAgent": "مساعد جديد",
|
||||
"newGroupChat": "إنشاء فريق وكلاء جديد",
|
||||
"noAgentsYet": "لا يوجد أعضاء في هذا الفريق بعد. انقر على زر + لدعوة مساعد.",
|
||||
"mention.title": "الإشارة إلى الأعضاء",
|
||||
"messageAction.collapse": "إخفاء الرسائل",
|
||||
"messageAction.continueGeneration": "متابعة التوليد",
|
||||
"messageAction.delAndRegenerate": "حذف وإعادة الإنشاء",
|
||||
"messageAction.deleteDisabledByThreads": "يوجد موضوعات فرعية، لا يمكن الحذف",
|
||||
"messageAction.expand": "عرض الرسائل",
|
||||
"messageAction.regenerate": "إعادة الإنشاء",
|
||||
"messages.dm.sentTo": "مرئي فقط لـ {{name}}",
|
||||
"messages.dm.title": "الرسائل الخاصة",
|
||||
"messages.modelCard.credit": "نقاط",
|
||||
"messages.modelCard.creditPricing": "التسعير",
|
||||
"messages.modelCard.creditTooltip": "لتسهيل العد، نقوم بتحويل 1$ إلى 1M نقطة، على سبيل المثال، 3$/M رموز تعني 3 نقاط/رمز",
|
||||
"messages.modelCard.pricing.inputCachedTokens": "مدخلات مخزنة {{amount}}/نقطة · ${{amount}}/M",
|
||||
"messages.modelCard.pricing.inputCharts": "${{amount}}/M حرف",
|
||||
"messages.modelCard.pricing.inputMinutes": "${{amount}}/دقيقة",
|
||||
"messages.modelCard.pricing.inputTokens": "مدخلات {{amount}}/نقطة · ${{amount}}/M",
|
||||
"messages.modelCard.pricing.outputTokens": "مخرجات {{amount}}/نقطة · ${{amount}}/M",
|
||||
"messages.modelCard.pricing.writeCacheInputTokens": "تخزين إدخال الكتابة {{amount}}/نقطة · ${{amount}}/ميغابايت",
|
||||
"messages.tokenDetails.average": "متوسط السعر",
|
||||
"messages.tokenDetails.input": "مدخلات",
|
||||
"messages.tokenDetails.inputAudio": "مدخلات صوتية",
|
||||
"messages.tokenDetails.inputCached": "مدخلات مخزنة",
|
||||
"messages.tokenDetails.inputCitation": "اقتباس الإدخال",
|
||||
"messages.tokenDetails.inputText": "مدخلات نصية",
|
||||
"messages.tokenDetails.inputTitle": "تفاصيل المدخلات",
|
||||
"messages.tokenDetails.inputUncached": "مدخلات غير مخزنة",
|
||||
"messages.tokenDetails.inputWriteCached": "تخزين إدخال الكتابة",
|
||||
"messages.tokenDetails.output": "مخرجات",
|
||||
"messages.tokenDetails.outputAudio": "مخرجات صوتية",
|
||||
"messages.tokenDetails.outputImage": "إخراج الصورة",
|
||||
"messages.tokenDetails.outputText": "مخرجات نصية",
|
||||
"messages.tokenDetails.outputTitle": "تفاصيل المخرجات",
|
||||
"messages.tokenDetails.reasoning": "تفكير عميق",
|
||||
"messages.tokenDetails.speed.tps.title": "TPS",
|
||||
"messages.tokenDetails.speed.tps.tooltip": "عدد الرموز في الثانية، TPS. يشير إلى متوسط سرعة توليد المحتوى بواسطة الذكاء الاصطناعي (رمز/ثانية)، ويبدأ الحساب عند استلام أول رمز.",
|
||||
"messages.tokenDetails.speed.ttft.title": "TTFT",
|
||||
"messages.tokenDetails.speed.ttft.tooltip": "الوقت حتى أول رمز، TTFT. يشير إلى الفارق الزمني من لحظة إرسال الرسالة حتى استلام أول رمز في العميل.",
|
||||
"messages.tokenDetails.title": "تفاصيل التوليد",
|
||||
"messages.tokenDetails.total": "الإجمالي المستهلك",
|
||||
"minimap.jumpToMessage": "الانتقال إلى الرسالة رقم {{index}}",
|
||||
"minimap.nextMessage": "الرسالة التالية",
|
||||
"minimap.previousMessage": "الرسالة السابقة",
|
||||
"minimap.senderAssistant": "الوكيل",
|
||||
"minimap.senderUser": "أنت",
|
||||
"newAgent": "إنشاء مساعد",
|
||||
"newGroupChat": "إنشاء مجموعة",
|
||||
"newPage": "إنشاء مستند",
|
||||
"noAgentsYet": "لا يوجد أعضاء في هذه المجموعة بعد. انقر على زر + لدعوة مساعد.",
|
||||
"noAvailableAgents": "لا يوجد أعضاء متاحون للدعوة",
|
||||
"noMatchingAgents": "لا يوجد أعضاء مطابقون",
|
||||
"noMembersYet": "لا يوجد أعضاء في هذه المجموعة بعد. انقر على زر + لدعوة المساعدين.",
|
||||
"noSelectedAgents": "لم يتم اختيار أي أعضاء بعد",
|
||||
"openInNewWindow": "افتح الصفحة في نافذة جديدة",
|
||||
"openInNewWindow": "فتح في نافذة مستقلة",
|
||||
"owner": "مالك المجموعة",
|
||||
"pageCopilot.title": "مساعد النصوص",
|
||||
"pageCopilot.welcome": "**لنجعل كل جملة أكثر دقة.**\n\nسواء كنت تكتب مسودة، تعيد الصياغة، أو تقوم بالتحرير، سأساعدك في جعل كلماتك أوضح، أكثر طبيعية، وأكثر إقناعًا.",
|
||||
"pin": "تثبيت",
|
||||
"pinOff": "إلغاء التثبيت",
|
||||
"rag": {
|
||||
"referenceChunks": "مراجع",
|
||||
"userQuery": {
|
||||
"actions": {
|
||||
"delete": "حذف الاستعلام",
|
||||
"regenerate": "إعادة توليد الاستعلام"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rag.referenceChunks": "مراجع",
|
||||
"rag.userQuery.actions.delete": "حذف الاستعلام",
|
||||
"rag.userQuery.actions.regenerate": "إعادة توليد الاستعلام",
|
||||
"regenerate": "إعادة الإنشاء",
|
||||
"roleAndArchive": "الدور والأرشيف",
|
||||
"search": {
|
||||
"grounding": {
|
||||
"searchQueries": "كلمات البحث",
|
||||
"title": "تم العثور على {{count}} نتيجة"
|
||||
},
|
||||
"mode": {
|
||||
"auto": {
|
||||
"desc": "تحديد ما إذا كان من الضروري البحث بناءً على محتوى المحادثة",
|
||||
"title": "الاتصال الذكي"
|
||||
},
|
||||
"off": {
|
||||
"desc": "استخدام المعرفة الأساسية للنموذج فقط، دون إجراء بحث عبر الإنترنت",
|
||||
"title": "إيقاف الاتصال"
|
||||
},
|
||||
"on": {
|
||||
"desc": "الاستمرار في البحث عبر الإنترنت للحصول على أحدث المعلومات",
|
||||
"title": "الاتصال دائمًا"
|
||||
},
|
||||
"useModelBuiltin": "استخدام محرك البحث المدمج في النموذج"
|
||||
},
|
||||
"searchModel": {
|
||||
"desc": "النموذج الحالي لا يدعم استدعاء الدوال، لذا يجب استخدام نموذج يدعم استدعاء الدوال للبحث عبر الإنترنت",
|
||||
"title": "نموذج البحث المساعد"
|
||||
},
|
||||
"title": "بحث عبر الإنترنت"
|
||||
},
|
||||
"search.grounding.searchQueries": "كلمات البحث",
|
||||
"search.grounding.title": "تم العثور على {{count}} نتيجة",
|
||||
"search.mode.auto.desc": "تحديد ما إذا كان من الضروري البحث بناءً على محتوى المحادثة",
|
||||
"search.mode.auto.title": "الاتصال الذكي",
|
||||
"search.mode.off.desc": "استخدام المعرفة الأساسية للنموذج فقط، دون إجراء بحث عبر الإنترنت",
|
||||
"search.mode.off.title": "إيقاف الاتصال",
|
||||
"search.mode.on.desc": "الاستمرار في البحث عبر الإنترنت للحصول على أحدث المعلومات",
|
||||
"search.mode.on.title": "الاتصال دائمًا",
|
||||
"search.mode.useModelBuiltin": "استخدام محرك البحث المدمج في النموذج",
|
||||
"search.searchModel.desc": "النموذج الحالي لا يدعم استدعاء الدوال، لذا يجب استخدام نموذج يدعم استدعاء الدوال للبحث عبر الإنترنت",
|
||||
"search.searchModel.title": "نموذج البحث المساعد",
|
||||
"search.title": "بحث عبر الإنترنت",
|
||||
"searchAgentPlaceholder": "مساعد البحث...",
|
||||
"searchAgents": "مساعد البحث...",
|
||||
"selectedAgents": "المساعدون المختارون",
|
||||
"sendPlaceholder": "أدخل محتوى الدردشة...",
|
||||
"sessionGroup": {
|
||||
"config": "إدارة المجموعات",
|
||||
"confirmRemoveGroupAlert": "سيتم حذف هذه المجموعة قريبًا، وبعد الحذف، سيتم نقل مساعدي هذه المجموعة إلى القائمة الافتراضية، يرجى تأكيد إجراءك",
|
||||
"createAgentSuccess": "تم إنشاء المساعد بنجاح",
|
||||
"createGroup": "إضافة مجموعة جديدة",
|
||||
"createGroupFailed": "فشل إنشاء المحادثة الجماعية",
|
||||
"createGroupSuccess": "تم إنشاء المحادثة الجماعية بنجاح",
|
||||
"createSuccess": "تم الإنشاء بنجاح",
|
||||
"creatingAgent": "جاري إنشاء المساعد...",
|
||||
"inputPlaceholder": "الرجاء إدخال اسم المجموعة...",
|
||||
"moveGroup": "نقل إلى مجموعة",
|
||||
"newGroup": "مجموعة جديدة",
|
||||
"rename": "إعادة تسمية المجموعة",
|
||||
"renameSuccess": "تمت إعادة التسمية بنجاح",
|
||||
"sortSuccess": "تمت إعادة ترتيب بنجاح",
|
||||
"sorting": "جاري تحديث ترتيب المجموعة...",
|
||||
"tooLong": "يجب أن يكون طول اسم المجموعة بين 1 و 20"
|
||||
},
|
||||
"shareModal": {
|
||||
"copy": "نسخ",
|
||||
"download": "تحميل اللقطة",
|
||||
"downloadError": "فشل التنزيل",
|
||||
"downloadFile": "تحميل الملف",
|
||||
"downloadPdf": "تنزيل PDF",
|
||||
"downloadSuccess": "تم التنزيل بنجاح",
|
||||
"exportPdf": "تصدير إلى PDF",
|
||||
"exportTitle": "العنوان الافتراضي",
|
||||
"generatePdf": "إنشاء ملف PDF",
|
||||
"generatingPdf": "جارٍ إنشاء PDF...",
|
||||
"imageType": "نوع الصورة",
|
||||
"includeTool": "تضمين رسالة الأداة",
|
||||
"includeUser": "تضمين رسالة المستخدم",
|
||||
"loadingPdf": "جارٍ تحميل ملف PDF...",
|
||||
"noPdfData": "لا توجد بيانات PDF",
|
||||
"pdf": "PDF",
|
||||
"pdfErrorDescription": "حدث خطأ أثناء إنشاء PDF، يرجى المحاولة مرة أخرى",
|
||||
"pdfGenerationError": "فشل إنشاء PDF",
|
||||
"pdfReady": "تم تجهيز PDF",
|
||||
"regeneratePdf": "إعادة إنشاء ملف PDF",
|
||||
"screenshot": "لقطة شاشة",
|
||||
"settings": "إعدادات التصدير",
|
||||
"text": "نص",
|
||||
"widthMode": {
|
||||
"label": "وضع العرض",
|
||||
"narrow": "وضع الشاشة الضيقة",
|
||||
"wide": "وضع الشاشة الواسعة"
|
||||
},
|
||||
"withBackground": "تضمين صورة الخلفية",
|
||||
"withFooter": "تضمين تذييل",
|
||||
"withPluginInfo": "تضمين معلومات البرنامج المساعد",
|
||||
"withRole": "تضمين رسالة الدور",
|
||||
"withSystemRole": "تضمين دور المساعد"
|
||||
},
|
||||
"stt": {
|
||||
"action": "إدخال صوتي",
|
||||
"loading": "جارٍ التعرف...",
|
||||
"prettifying": "جارٍ التجميل..."
|
||||
},
|
||||
"supervisor": {
|
||||
"todoList": {
|
||||
"allComplete": "تم إنجاز جميع المهام",
|
||||
"title": "المهام المنجزة"
|
||||
}
|
||||
},
|
||||
"thread": {
|
||||
"divider": "موضوع فرعي",
|
||||
"threadMessageCount": "{{messageCount}} رسالة",
|
||||
"title": "موضوع فرعي"
|
||||
},
|
||||
"toggleWideScreen": {
|
||||
"off": "إيقاف وضع الشاشة العريضة",
|
||||
"on": "تشغيل وضع الشاشة العريضة"
|
||||
},
|
||||
"tokenDetails": {
|
||||
"chats": "رسائل المحادثة",
|
||||
"historySummary": "ملخص التاريخ",
|
||||
"rest": "المتبقي",
|
||||
"supervisor": "مُنسق المجموعة",
|
||||
"systemRole": "تعيين الدور",
|
||||
"title": "تفاصيل الرمز",
|
||||
"tools": "تعيين الإضافات",
|
||||
"total": "الإجمالي",
|
||||
"used": "المستخدم"
|
||||
},
|
||||
"tokenTag": {
|
||||
"overload": "تجاوز الحد",
|
||||
"remained": "متبقي",
|
||||
"used": "مستخدم"
|
||||
},
|
||||
"tool": {
|
||||
"intervention": {
|
||||
"approve": "الموافقة",
|
||||
"approveAndRemember": "الموافقة والتذكر",
|
||||
"approveOnce": "الموافقة لمرة واحدة فقط",
|
||||
"mode": {
|
||||
"allowList": "قائمة السماح",
|
||||
"allowListDesc": "تنفيذ الأدوات المعتمدة فقط تلقائيًا",
|
||||
"autoRun": "الموافقة التلقائية",
|
||||
"autoRunDesc": "الموافقة تلقائيًا على تنفيذ جميع الأدوات",
|
||||
"manual": "يدوي",
|
||||
"manualDesc": "يتطلب الموافقة اليدوية في كل مرة يتم فيها الاستدعاء"
|
||||
},
|
||||
"reject": "رفض",
|
||||
"rejectAndContinue": "رفض ثم إعادة المحاولة",
|
||||
"rejectOnly": "رفض",
|
||||
"rejectReasonPlaceholder": "إدخال سبب الرفض سيساعد الوكيل على الفهم وتحسين الإجراءات المستقبلية",
|
||||
"rejectTitle": "رفض استدعاء الأداة هذه المرة",
|
||||
"rejectedWithReason": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي: {{reason}}",
|
||||
"toolAbort": "تم إلغاء استدعاء الأداة من قبل المستخدم",
|
||||
"toolRejected": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي"
|
||||
}
|
||||
},
|
||||
"topic": {
|
||||
"checkOpenNewTopic": "هل ترغب في فتح موضوع جديد؟",
|
||||
"checkSaveCurrentMessages": "هل ترغب في حفظ الدردشة الحالية كموضوع؟",
|
||||
"openNewTopic": "فتح موضوع جديد",
|
||||
"saveCurrentMessages": "حفظ الجلسة الحالية كموضوع"
|
||||
},
|
||||
"translate": {
|
||||
"action": "ترجمة",
|
||||
"clear": "مسح الترجمة"
|
||||
},
|
||||
"tts": {
|
||||
"action": "قراءة صوتية",
|
||||
"clear": "مسح الصوت"
|
||||
},
|
||||
"sendPlaceholder": "اطرح سؤالًا، أنشئ محتوى، أو ابدأ مهمة، <hotkey><hotkey/>",
|
||||
"sessionGroup.config": "إدارة المجموعات",
|
||||
"sessionGroup.confirmRemoveGroupAlert": "سيتم حذف هذه المجموعة قريبًا، وبعد الحذف، سيتم نقل مساعدي هذه المجموعة إلى القائمة الافتراضية، يرجى تأكيد إجراءك",
|
||||
"sessionGroup.createAgentSuccess": "تم إنشاء المساعد بنجاح",
|
||||
"sessionGroup.createGroup": "إضافة مجموعة جديدة",
|
||||
"sessionGroup.createGroupFailed": "فشل إنشاء المحادثة الجماعية",
|
||||
"sessionGroup.createGroupSuccess": "تم إنشاء المحادثة الجماعية بنجاح",
|
||||
"sessionGroup.createSuccess": "تم الإنشاء بنجاح",
|
||||
"sessionGroup.creatingAgent": "جاري إنشاء المساعد...",
|
||||
"sessionGroup.groupName": "اسم المجموعة",
|
||||
"sessionGroup.inputPlaceholder": "الرجاء إدخال اسم المجموعة...",
|
||||
"sessionGroup.moveGroup": "نقل إلى مجموعة",
|
||||
"sessionGroup.newGroup": "مجموعة جديدة",
|
||||
"sessionGroup.noAvailableAgents": "لا يوجد مساعدين متاحين حالياً",
|
||||
"sessionGroup.noMatchingAgents": "لم يتم العثور على مساعدين مطابقين",
|
||||
"sessionGroup.noSelectedAgents": "يرجى اختيار مساعدين",
|
||||
"sessionGroup.rename": "إعادة تسمية المجموعة",
|
||||
"sessionGroup.renameSuccess": "تمت إعادة التسمية بنجاح",
|
||||
"sessionGroup.searchAgents": "البحث عن مساعدين",
|
||||
"sessionGroup.selectedAgents": "المساعدون المختارون ({{count}})",
|
||||
"sessionGroup.sortSuccess": "تمت إعادة ترتيب بنجاح",
|
||||
"sessionGroup.sorting": "جاري تحديث ترتيب المجموعة...",
|
||||
"sessionGroup.tooLong": "يجب أن يكون طول اسم المجموعة بين 1 و 20",
|
||||
"shareModal.copy": "نسخ",
|
||||
"shareModal.download": "تحميل اللقطة",
|
||||
"shareModal.downloadError": "فشل التنزيل",
|
||||
"shareModal.downloadFile": "تحميل الملف",
|
||||
"shareModal.downloadPdf": "تنزيل PDF",
|
||||
"shareModal.downloadSuccess": "تم التنزيل بنجاح",
|
||||
"shareModal.exportMode.full": "افتراضي",
|
||||
"shareModal.exportMode.label": "وضع التصدير",
|
||||
"shareModal.exportMode.simple": "متوافق مع OpenAI",
|
||||
"shareModal.exportPdf": "تصدير إلى PDF",
|
||||
"shareModal.exportTitle": "العنوان الافتراضي",
|
||||
"shareModal.generatePdf": "إنشاء ملف PDF",
|
||||
"shareModal.generatingPdf": "جارٍ إنشاء PDF...",
|
||||
"shareModal.imageType": "نوع الصورة",
|
||||
"shareModal.includeTool": "تضمين رسالة الأداة",
|
||||
"shareModal.includeUser": "تضمين رسالة المستخدم",
|
||||
"shareModal.loadingPdf": "جارٍ تحميل ملف PDF...",
|
||||
"shareModal.noPdfData": "لا توجد بيانات PDF",
|
||||
"shareModal.pdf": "PDF",
|
||||
"shareModal.pdfErrorDescription": "حدث خطأ أثناء إنشاء PDF، يرجى المحاولة مرة أخرى",
|
||||
"shareModal.pdfGenerationError": "فشل إنشاء PDF",
|
||||
"shareModal.pdfReady": "تم تجهيز PDF",
|
||||
"shareModal.regeneratePdf": "إعادة إنشاء ملف PDF",
|
||||
"shareModal.screenshot": "لقطة شاشة",
|
||||
"shareModal.settings": "إعدادات التصدير",
|
||||
"shareModal.text": "نص",
|
||||
"shareModal.widthMode.label": "وضع العرض",
|
||||
"shareModal.widthMode.narrow": "وضع الشاشة الضيقة",
|
||||
"shareModal.widthMode.wide": "وضع الشاشة الواسعة",
|
||||
"shareModal.withBackground": "تضمين صورة الخلفية",
|
||||
"shareModal.withFooter": "تضمين تذييل",
|
||||
"shareModal.withPluginInfo": "تضمين معلومات البرنامج المساعد",
|
||||
"shareModal.withRole": "تضمين رسالة الدور",
|
||||
"shareModal.withSystemRole": "تضمين دور المساعد",
|
||||
"stt.action": "إدخال صوتي",
|
||||
"stt.loading": "جارٍ التعرف...",
|
||||
"stt.prettifying": "جارٍ التجميل...",
|
||||
"supervisor.todoList.allComplete": "تم إنجاز جميع المهام",
|
||||
"supervisor.todoList.title": "المهام المنجزة",
|
||||
"tab.groupProfile": "ملف المجموعة",
|
||||
"tab.profile": "ملف المساعد",
|
||||
"tab.search": "بحث",
|
||||
"task.activity.calling": "جارٍ استدعاء الأداة...",
|
||||
"task.activity.generating": "جارٍ توليد الرد...",
|
||||
"task.activity.gotResult": "تم الحصول على نتيجة الأداة",
|
||||
"task.activity.toolCalling": "جارٍ استدعاء {{toolName}}...",
|
||||
"task.activity.toolResult": "تم الحصول على نتيجة {{toolName}}",
|
||||
"task.metrics.stepsShort": "خطوة",
|
||||
"task.metrics.toolCallsShort": "مرة استخدام الأداة",
|
||||
"task.status.initializing": "جارٍ بدء المهمة...",
|
||||
"thread.divider": "موضوع فرعي",
|
||||
"thread.threadMessageCount": "{{messageCount}} رسالة",
|
||||
"thread.title": "موضوع فرعي",
|
||||
"toggleWideScreen.off": "إيقاف وضع الشاشة العريضة",
|
||||
"toggleWideScreen.on": "تشغيل وضع الشاشة العريضة",
|
||||
"tokenDetails.chats": "رسائل المحادثة",
|
||||
"tokenDetails.historySummary": "ملخص التاريخ",
|
||||
"tokenDetails.rest": "المتبقي",
|
||||
"tokenDetails.supervisor": "مُنسق المجموعة",
|
||||
"tokenDetails.systemRole": "تعيين الدور",
|
||||
"tokenDetails.title": "تفاصيل الرمز",
|
||||
"tokenDetails.tools": "تعيين الإضافات",
|
||||
"tokenDetails.total": "الإجمالي",
|
||||
"tokenDetails.used": "المستخدم",
|
||||
"tokenTag.overload": "تجاوز الحد",
|
||||
"tokenTag.remained": "متبقي",
|
||||
"tokenTag.used": "مستخدم",
|
||||
"tool.intervention.approve": "الموافقة",
|
||||
"tool.intervention.approveAndRemember": "الموافقة والتذكر",
|
||||
"tool.intervention.approveOnce": "الموافقة لمرة واحدة فقط",
|
||||
"tool.intervention.mode.allowList": "قائمة السماح",
|
||||
"tool.intervention.mode.allowListDesc": "تنفيذ الأدوات المعتمدة فقط تلقائيًا",
|
||||
"tool.intervention.mode.autoRun": "الموافقة التلقائية",
|
||||
"tool.intervention.mode.autoRunDesc": "الموافقة تلقائيًا على تنفيذ جميع الأدوات",
|
||||
"tool.intervention.mode.manual": "يدوي",
|
||||
"tool.intervention.mode.manualDesc": "يتطلب الموافقة اليدوية في كل مرة يتم فيها الاستدعاء",
|
||||
"tool.intervention.reject": "رفض",
|
||||
"tool.intervention.rejectAndContinue": "رفض ثم إعادة المحاولة",
|
||||
"tool.intervention.rejectOnly": "رفض",
|
||||
"tool.intervention.rejectReasonPlaceholder": "إدخال سبب الرفض سيساعد الوكيل على الفهم وتحسين الإجراءات المستقبلية",
|
||||
"tool.intervention.rejectTitle": "رفض استدعاء الأداة هذه المرة",
|
||||
"tool.intervention.rejectedWithReason": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي: {{reason}}",
|
||||
"tool.intervention.toolAbort": "تم إلغاء استدعاء الأداة من قبل المستخدم",
|
||||
"tool.intervention.toolRejected": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي",
|
||||
"toolAuth.authorize": "تفويض",
|
||||
"toolAuth.authorizing": "جارٍ التفويض...",
|
||||
"toolAuth.hint": "إذا لم يتم التفويض أو التكوين، فقد لا تعمل هذه الأدوات بشكل صحيح، مما قد يؤدي إلى فقدان وظائف المساعد أو ظهور أخطاء.",
|
||||
"toolAuth.signIn": "تسجيل الدخول",
|
||||
"toolAuth.title": "يرجى إكمال تفويض الأدوات للمساعد",
|
||||
"topic.checkOpenNewTopic": "هل ترغب في فتح موضوع جديد؟",
|
||||
"topic.checkSaveCurrentMessages": "هل ترغب في حفظ الدردشة الحالية كموضوع؟",
|
||||
"topic.openNewTopic": "فتح موضوع جديد",
|
||||
"topic.recent": "المواضيع الأخيرة",
|
||||
"topic.saveCurrentMessages": "حفظ الجلسة الحالية كموضوع",
|
||||
"translate.action": "ترجمة",
|
||||
"translate.clear": "مسح الترجمة",
|
||||
"tts.action": "قراءة صوتية",
|
||||
"tts.clear": "مسح الصوت",
|
||||
"untitledAgent": "مساعد بدون اسم",
|
||||
"untitledGroup": "مجموعة بدون عنوان",
|
||||
"updateAgent": "تحديث معلومات المساعد",
|
||||
"upload": {
|
||||
"action": {
|
||||
"fileUpload": "رفع ملف",
|
||||
"folderUpload": "رفع مجلد",
|
||||
"imageDisabled": "النموذج الحالي لا يدعم التعرف على الصور، يرجى تغيير النموذج لاستخدامه",
|
||||
"imageUpload": "رفع صورة",
|
||||
"tooltip": "رفع"
|
||||
},
|
||||
"clientMode": {
|
||||
"actionFiletip": "رفع ملف",
|
||||
"actionTooltip": "رفع",
|
||||
"disabled": "النموذج الحالي لا يدعم التعرف على الصور وتحليل الملفات، يرجى تغيير النموذج لاستخدامه",
|
||||
"fileNotSupported": "وضع المتصفح لا يدعم تحميل الملفات حاليًا، يدعم الصور فقط",
|
||||
"visionNotSupported": "النموذج الحالي لا يدعم التعرف البصري، يرجى تبديل النموذج لاستخدام هذه الميزة"
|
||||
},
|
||||
"preview": {
|
||||
"prepareTasks": "تحضير الأجزاء...",
|
||||
"status": {
|
||||
"pending": "يتم التحضير للتحميل...",
|
||||
"processing": "يتم معالجة الملف..."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"videoSizeExceeded": "لا يمكن أن يتجاوز حجم ملف الفيديو 20 ميغابايت، حجم الملف الحالي هو {{actualSize}}"
|
||||
}
|
||||
},
|
||||
"upload.action.fileUpload": "رفع ملف",
|
||||
"upload.action.folderUpload": "رفع مجلد",
|
||||
"upload.action.imageDisabled": "النموذج الحالي لا يدعم التعرف على الصور، يرجى تغيير النموذج لاستخدامه",
|
||||
"upload.action.imageUpload": "رفع صورة",
|
||||
"upload.action.tooltip": "رفع",
|
||||
"upload.clientMode.actionFiletip": "رفع ملف",
|
||||
"upload.clientMode.actionTooltip": "رفع",
|
||||
"upload.clientMode.disabled": "النموذج الحالي لا يدعم التعرف على الصور وتحليل الملفات، يرجى تغيير النموذج لاستخدامه",
|
||||
"upload.clientMode.fileNotSupported": "وضع المتصفح لا يدعم تحميل الملفات حاليًا، يدعم الصور فقط",
|
||||
"upload.clientMode.visionNotSupported": "النموذج الحالي لا يدعم التعرف البصري، يرجى تبديل النموذج لاستخدام هذه الميزة",
|
||||
"upload.preview.prepareTasks": "تحضير الأجزاء...",
|
||||
"upload.preview.status.pending": "يتم التحضير للتحميل...",
|
||||
"upload.preview.status.processing": "يتم معالجة الملف...",
|
||||
"upload.validation.videoSizeExceeded": "لا يمكن أن يتجاوز حجم ملف الفيديو 20 ميغابايت، حجم الملف الحالي هو {{actualSize}}",
|
||||
"viewMode.normal": "عادي",
|
||||
"viewMode.wideScreen": "شاشة عريضة",
|
||||
"you": "أنت",
|
||||
"zenMode": "وضع التركيز"
|
||||
}
|
||||
|
||||
+473
-697
File diff suppressed because it is too large
Load Diff
+290
-338
@@ -1,29 +1,23 @@
|
||||
{
|
||||
"about": "حول",
|
||||
"advanceSettings": "إعدادات متقدمة",
|
||||
"alert": {
|
||||
"cloud": {
|
||||
"action": "تجربة مجانية",
|
||||
"desc": "نحن نقدم {{credit}} نقطة حساب مجانية لجميع المستخدمين المسجلين، بدون الحاجة إلى تكوين معقد، فقط قم بتشغيلها، تدعم تاريخ الدردشة غير المحدود ومزامنة السحابة العالمية، والمزيد من الميزات المتقدمة بانتظار استكشافها معًا.",
|
||||
"descOnMobile": "نحن نقدم {{credit}} نقطة حساب مجانية لجميع المستخدمين المسجلين، بدون الحاجة إلى إعدادات معقدة، فقط قم بالاستخدام.",
|
||||
"title": "مرحبًا بك في التجربة {{name}}"
|
||||
}
|
||||
},
|
||||
"appLoading": {
|
||||
"appIdle": "جاهز للإطلاق",
|
||||
"appInitializing": "جارٍ تشغيل التطبيق...",
|
||||
"failed": "عذرًا، فشل تحميل التطبيق، يرجى مراجعة التفاصيل للتحقق من المشكلة",
|
||||
"finished": "تم الانتهاء من تهيئة قاعدة البيانات",
|
||||
"goToChat": "جارٍ تحميل صفحة الدردشة...",
|
||||
"initAuth": "جارٍ تهيئة خدمة المصادقة...",
|
||||
"initUser": "جارٍ تهيئة حالة المستخدم...",
|
||||
"initializing": "جارٍ تهيئة قاعدة بيانات PGlite...",
|
||||
"loadingDependencies": "جارٍ تهيئة الاعتمادات...",
|
||||
"loadingWasm": "جارٍ تحميل وحدة WASM...",
|
||||
"migrating": "جارٍ تنفيذ ترحيل الجداول...",
|
||||
"ready": "قاعدة البيانات جاهزة",
|
||||
"showDetail": "عرض التفاصيل"
|
||||
},
|
||||
"alert.cloud.action": "تجربة مجانية",
|
||||
"alert.cloud.desc": "نحن نقدم {{credit}} نقطة حساب مجانية لجميع المستخدمين المسجلين، بدون الحاجة إلى تكوين معقد، فقط قم بتشغيلها، تدعم تاريخ الدردشة غير المحدود ومزامنة السحابة العالمية، والمزيد من الميزات المتقدمة بانتظار استكشافها معًا.",
|
||||
"alert.cloud.descOnMobile": "نحن نقدم {{credit}} نقطة حساب مجانية لجميع المستخدمين المسجلين، بدون الحاجة إلى إعدادات معقدة، فقط قم بالاستخدام.",
|
||||
"alert.cloud.title": "مرحبًا بك في التجربة {{name}}",
|
||||
"appLoading.appIdle": "جاهز للإطلاق",
|
||||
"appLoading.appInitializing": "جارٍ تشغيل التطبيق...",
|
||||
"appLoading.failed": "عذرًا، فشل تحميل التطبيق، يرجى مراجعة التفاصيل للتحقق من المشكلة",
|
||||
"appLoading.finished": "تم الانتهاء من تهيئة قاعدة البيانات",
|
||||
"appLoading.goToChat": "جارٍ تحميل صفحة الدردشة...",
|
||||
"appLoading.initAuth": "جارٍ تهيئة خدمة المصادقة...",
|
||||
"appLoading.initUser": "جارٍ تهيئة حالة المستخدم...",
|
||||
"appLoading.initializing": "جارٍ تهيئة قاعدة بيانات PGlite...",
|
||||
"appLoading.loadingDependencies": "جارٍ تهيئة الاعتمادات...",
|
||||
"appLoading.loadingWasm": "جارٍ تحميل وحدة WASM...",
|
||||
"appLoading.migrating": "جارٍ تنفيذ ترحيل الجداول...",
|
||||
"appLoading.ready": "قاعدة البيانات جاهزة",
|
||||
"appLoading.showDetail": "عرض التفاصيل",
|
||||
"autoGenerate": "توليد تلقائي",
|
||||
"autoGenerateTooltip": "إكمال تلقائي بناءً على الكلمات المقترحة لوصف المساعد",
|
||||
"autoGenerateTooltipDisabled": "الرجاء إدخال كلمة تلميح قبل تفعيل وظيفة الإكمال التلقائي",
|
||||
@@ -35,138 +29,136 @@
|
||||
"branchingRequiresSavedTopic": "الموضوع الحالي غير محفوظ، يجب الحفظ قبل استخدام ميزة الموضوع الفرعي",
|
||||
"cancel": "إلغاء",
|
||||
"changelog": "سجل التغييرات",
|
||||
"clientDB": {
|
||||
"autoInit": {
|
||||
"title": "تهيئة قاعدة بيانات PGlite"
|
||||
},
|
||||
"error": {
|
||||
"desc": "نعتذر، حدث خطأ أثناء عملية تهيئة قاعدة بيانات Pglite. يرجى النقر على الزر لإعادة المحاولة. إذا استمرت المشكلة بعد عدة محاولات، يرجى <1>تقديم مشكلة</1>، وسنساعدك في حلها في أسرع وقت ممكن",
|
||||
"detail": "سبب الخطأ: [{{type}}] {{message}}، التفاصيل كالتالي:",
|
||||
"detailTitle": "سبب الخطأ",
|
||||
"report": "الإبلاغ عن مشكلة",
|
||||
"retry": "إعادة المحاولة",
|
||||
"selfSolve": "حل ذاتي",
|
||||
"title": "فشل تهيئة قاعدة البيانات"
|
||||
},
|
||||
"initing": {
|
||||
"error": "حدث خطأ، يرجى إعادة المحاولة",
|
||||
"idle": "في انتظار التهيئة...",
|
||||
"initializing": "جارٍ التهيئة...",
|
||||
"loadingDependencies": "جارٍ تحميل الاعتماديات...",
|
||||
"loadingWasmModule": "جارٍ تحميل وحدة WASM...",
|
||||
"migrating": "جارٍ تنفيذ ترحيل البيانات...",
|
||||
"ready": "قاعدة البيانات جاهزة"
|
||||
},
|
||||
"modal": {
|
||||
"desc": "قم بتمكين قاعدة بيانات عميل PGlite، لتخزين بيانات الدردشة بشكل دائم في متصفحك، واستخدام ميزات متقدمة مثل مكتبة المعرفة",
|
||||
"enable": "تمكين الآن",
|
||||
"features": {
|
||||
"knowledgeBase": {
|
||||
"desc": "قم بتخزين قاعدة معرفتك الشخصية وابدأ محادثة مع مساعدك بسهولة (قريبًا)",
|
||||
"title": "دعم محادثة قاعدة المعرفة، افتح دماغك الثاني"
|
||||
},
|
||||
"localFirst": {
|
||||
"desc": "تُخزن بيانات الدردشة بالكامل في المتصفح، بياناتك دائمًا تحت سيطرتك.",
|
||||
"title": "الأولوية محلية، الخصوصية أولاً"
|
||||
},
|
||||
"pglite": {
|
||||
"desc": "مبني على PGlite، يدعم بشكل أصلي ميزات AI Native المتقدمة (استرجاع المتجهات)",
|
||||
"title": "بنية تخزين عميل من الجيل الجديد"
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"desc": "جارٍ تهيئة قاعدة البيانات، قد يستغرق الأمر من 5 إلى 30 ثانية حسب اختلاف الشبكة",
|
||||
"title": "جارٍ تهيئة قاعدة بيانات PGlite"
|
||||
},
|
||||
"title": "فتح قاعدة بيانات العميل"
|
||||
},
|
||||
"ready": {
|
||||
"button": "استخدم الآن",
|
||||
"desc": "استخدم الآن",
|
||||
"title": "قاعدة بيانات PGlite جاهزة"
|
||||
},
|
||||
"solve": {
|
||||
"backup": {
|
||||
"backup": "نسخ احتياطي",
|
||||
"backupSuccess": "تم النسخ الاحتياطي بنجاح",
|
||||
"desc": "تصدير البيانات الأساسية من قاعدة البيانات الحالية",
|
||||
"export": "تصدير جميع البيانات",
|
||||
"exportDesc": "سيتم حفظ البيانات المصدرة بتنسيق JSON، ويمكن استخدامها لاستعادة أو تحليل لاحق.",
|
||||
"reset": {
|
||||
"alert": "تحذير",
|
||||
"alertDesc": "قد تؤدي العمليات التالية إلى فقدان البيانات. يرجى التأكد من أنك قد قمت بعمل نسخة احتياطية من البيانات الهامة قبل المتابعة.",
|
||||
"button": "إعادة تعيين قاعدة البيانات بالكامل (حذف جميع البيانات)",
|
||||
"confirm": {
|
||||
"desc": "ستؤدي هذه العملية إلى حذف جميع البيانات ولا يمكن التراجع عنها، هل تؤكد المتابعة؟",
|
||||
"title": "تأكيد إعادة تعيين قاعدة البيانات"
|
||||
},
|
||||
"desc": "إعادة تعيين قاعدة البيانات في حالة عدم إمكانية الاستعادة",
|
||||
"title": "إعادة تعيين قاعدة البيانات"
|
||||
},
|
||||
"restore": "استعادة",
|
||||
"restoreSuccess": "تم الاستعادة بنجاح",
|
||||
"title": "نسخ احتياطي للبيانات"
|
||||
},
|
||||
"diagnosis": {
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"migratedAt": "تاريخ اكتمال النقل",
|
||||
"sql": "نقل SQL",
|
||||
"title": "حالة النقل"
|
||||
},
|
||||
"repair": {
|
||||
"desc": "إدارة حالة النقل يدويًا",
|
||||
"runSQL": "تنفيذ مخصص",
|
||||
"sql": {
|
||||
"clear": "مسح",
|
||||
"desc": "تنفيذ عبارة SQL مخصصة لإصلاح مشاكل قاعدة البيانات",
|
||||
"markFinished": "تحديد كمنتهية",
|
||||
"placeholder": "أدخل عبارة SQL...",
|
||||
"result": "نتيجة التنفيذ",
|
||||
"run": "تنفيذ",
|
||||
"title": "منفذ SQL"
|
||||
},
|
||||
"title": "تحكم النقل"
|
||||
},
|
||||
"tabs": {
|
||||
"backup": "نسخ احتياطي واستعادة",
|
||||
"diagnosis": "تشخيص",
|
||||
"repair": "إصلاح"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientDB.autoInit.title": "تهيئة قاعدة بيانات PGlite",
|
||||
"clientDB.error.desc": "نعتذر، حدث خطأ أثناء عملية تهيئة قاعدة بيانات Pglite. يرجى النقر على الزر لإعادة المحاولة. إذا استمرت المشكلة بعد عدة محاولات، يرجى <1>تقديم مشكلة</1>، وسنساعدك في حلها في أسرع وقت ممكن",
|
||||
"clientDB.error.detail": "سبب الخطأ: [{{type}}] {{message}}، التفاصيل كالتالي:",
|
||||
"clientDB.error.detailTitle": "سبب الخطأ",
|
||||
"clientDB.error.report": "الإبلاغ عن مشكلة",
|
||||
"clientDB.error.retry": "إعادة المحاولة",
|
||||
"clientDB.error.selfSolve": "حل ذاتي",
|
||||
"clientDB.error.title": "فشل تهيئة قاعدة البيانات",
|
||||
"clientDB.initing.error": "حدث خطأ، يرجى إعادة المحاولة",
|
||||
"clientDB.initing.idle": "في انتظار التهيئة...",
|
||||
"clientDB.initing.initializing": "جارٍ التهيئة...",
|
||||
"clientDB.initing.loadingDependencies": "جارٍ تحميل الاعتماديات...",
|
||||
"clientDB.initing.loadingWasmModule": "جارٍ تحميل وحدة WASM...",
|
||||
"clientDB.initing.migrating": "جارٍ تنفيذ ترحيل البيانات...",
|
||||
"clientDB.initing.ready": "قاعدة البيانات جاهزة",
|
||||
"clientDB.modal.desc": "فعّل قاعدة بيانات العميل من الجيل التالي الآن. خزّن بيانات الدردشة بشكل دائم في متصفحك، واستفد من ميزات متقدمة مثل مكتبة الموارد.",
|
||||
"clientDB.modal.enable": "تمكين الآن",
|
||||
"clientDB.modal.features.knowledgeBase.desc": "أنشئ مكتبة مواردك الشخصية وابدأ محادثات مع مساعدك باستخدامها بسهولة (قريبًا).",
|
||||
"clientDB.modal.features.knowledgeBase.title": "دعم محادثات مكتبة الموارد، لتفعيل العقل الثاني",
|
||||
"clientDB.modal.features.localFirst.desc": "تُخزن بيانات الدردشة بالكامل في المتصفح، بياناتك دائمًا تحت سيطرتك.",
|
||||
"clientDB.modal.features.localFirst.title": "الأولوية محلية، الخصوصية أولاً",
|
||||
"clientDB.modal.features.pglite.desc": "مبني على PGlite، يدعم بشكل أصلي ميزات AI Native المتقدمة (استرجاع المتجهات)",
|
||||
"clientDB.modal.features.pglite.title": "بنية تخزين عميل من الجيل الجديد",
|
||||
"clientDB.modal.init.desc": "جارٍ تهيئة قاعدة البيانات، قد يستغرق الأمر من 5 إلى 30 ثانية حسب اختلاف الشبكة",
|
||||
"clientDB.modal.init.title": "جارٍ تهيئة قاعدة بيانات PGlite",
|
||||
"clientDB.modal.title": "فتح قاعدة بيانات العميل",
|
||||
"clientDB.ready.button": "استخدم الآن",
|
||||
"clientDB.ready.desc": "استخدم الآن",
|
||||
"clientDB.ready.title": "قاعدة بيانات PGlite جاهزة",
|
||||
"clientDB.solve.backup.backup": "نسخ احتياطي",
|
||||
"clientDB.solve.backup.backupSuccess": "تم النسخ الاحتياطي بنجاح",
|
||||
"clientDB.solve.backup.desc": "تصدير البيانات الأساسية من قاعدة البيانات الحالية",
|
||||
"clientDB.solve.backup.export": "تصدير جميع البيانات",
|
||||
"clientDB.solve.backup.exportDesc": "سيتم حفظ البيانات المصدرة بتنسيق JSON، ويمكن استخدامها لاستعادة أو تحليل لاحق.",
|
||||
"clientDB.solve.backup.reset.alert": "تحذير",
|
||||
"clientDB.solve.backup.reset.alertDesc": "قد تؤدي العمليات التالية إلى فقدان البيانات. يرجى التأكد من أنك قد قمت بعمل نسخة احتياطية من البيانات الهامة قبل المتابعة.",
|
||||
"clientDB.solve.backup.reset.button": "إعادة تعيين قاعدة البيانات بالكامل (حذف جميع البيانات)",
|
||||
"clientDB.solve.backup.reset.confirm.desc": "ستؤدي هذه العملية إلى حذف جميع البيانات ولا يمكن التراجع عنها، هل تؤكد المتابعة؟",
|
||||
"clientDB.solve.backup.reset.confirm.title": "تأكيد إعادة تعيين قاعدة البيانات",
|
||||
"clientDB.solve.backup.reset.desc": "إعادة تعيين قاعدة البيانات في حالة عدم إمكانية الاستعادة",
|
||||
"clientDB.solve.backup.reset.title": "إعادة تعيين قاعدة البيانات",
|
||||
"clientDB.solve.backup.restore": "استعادة",
|
||||
"clientDB.solve.backup.restoreSuccess": "تم الاستعادة بنجاح",
|
||||
"clientDB.solve.backup.title": "نسخ احتياطي للبيانات",
|
||||
"clientDB.solve.diagnosis.createdAt": "تاريخ الإنشاء",
|
||||
"clientDB.solve.diagnosis.migratedAt": "تاريخ اكتمال النقل",
|
||||
"clientDB.solve.diagnosis.sql": "نقل SQL",
|
||||
"clientDB.solve.diagnosis.title": "حالة النقل",
|
||||
"clientDB.solve.repair.desc": "إدارة حالة النقل يدويًا",
|
||||
"clientDB.solve.repair.runSQL": "تنفيذ مخصص",
|
||||
"clientDB.solve.repair.sql.clear": "مسح",
|
||||
"clientDB.solve.repair.sql.desc": "تنفيذ عبارة SQL مخصصة لإصلاح مشاكل قاعدة البيانات",
|
||||
"clientDB.solve.repair.sql.markFinished": "تحديد كمنتهية",
|
||||
"clientDB.solve.repair.sql.placeholder": "أدخل عبارة SQL...",
|
||||
"clientDB.solve.repair.sql.result": "نتيجة التنفيذ",
|
||||
"clientDB.solve.repair.sql.run": "تنفيذ",
|
||||
"clientDB.solve.repair.sql.title": "منفذ SQL",
|
||||
"clientDB.solve.repair.title": "تحكم النقل",
|
||||
"clientDB.solve.tabs.backup": "نسخ احتياطي واستعادة",
|
||||
"clientDB.solve.tabs.diagnosis": "تشخيص",
|
||||
"clientDB.solve.tabs.repair": "إصلاح",
|
||||
"close": "إغلاق",
|
||||
"cmdk": {
|
||||
"about": "حول",
|
||||
"communitySupport": "دعم المجتمع",
|
||||
"discover": "استكشاف",
|
||||
"knowledgeBase": "قاعدة المعرفة",
|
||||
"navigate": "التنقل",
|
||||
"newAgent": "إنشاء مساعد جديد",
|
||||
"noResults": "لم يتم العثور على نتائج",
|
||||
"openSettings": "فتح الإعدادات",
|
||||
"painting": "الرسم بالذكاء الاصطناعي",
|
||||
"searchPlaceholder": "أدخل أمرًا أو ابحث...",
|
||||
"settings": "الإعدادات",
|
||||
"starOnGitHub": "قيّمنا على GitHub",
|
||||
"submitIssue": "إرسال مشكلة",
|
||||
"theme": "السمة",
|
||||
"themeAuto": "اتباع النظام",
|
||||
"themeDark": "الوضع الداكن",
|
||||
"themeLight": "الوضع الفاتح",
|
||||
"toOpen": "فتح",
|
||||
"toSelect": "تحديد"
|
||||
},
|
||||
"cmdk.about": "حول",
|
||||
"cmdk.aiModeEmptyState": "أدخل سؤالك في الحقل أعلاه لبدء المحادثة مع الذكاء الاصطناعي",
|
||||
"cmdk.aiModeHint": "اضغط Enter لطرح سؤال على Lobe AI",
|
||||
"cmdk.aiModePlaceholder": "اطرح سؤالاً على الذكاء الاصطناعي...",
|
||||
"cmdk.askAI": "اسأل الذكاء الاصطناعي",
|
||||
"cmdk.community": "المجتمع",
|
||||
"cmdk.communitySupport": "دعم المجتمع",
|
||||
"cmdk.context.agent": "المساعد",
|
||||
"cmdk.context.community": "المجتمع",
|
||||
"cmdk.context.general": "عام",
|
||||
"cmdk.context.group": "المجموعة",
|
||||
"cmdk.context.memory": "الذاكرة",
|
||||
"cmdk.context.page": "الوثيقة",
|
||||
"cmdk.context.painting": "الرسم",
|
||||
"cmdk.context.resource": "الموارد",
|
||||
"cmdk.context.settings": "الإعدادات",
|
||||
"cmdk.discover": "استكشاف",
|
||||
"cmdk.keyboard.ESC": "ESC",
|
||||
"cmdk.keyboard.Tab": "Tab",
|
||||
"cmdk.memory": "الذاكرة",
|
||||
"cmdk.navigate": "التنقل",
|
||||
"cmdk.newAgent": "إنشاء مساعد جديد",
|
||||
"cmdk.newLibrary": "إنشاء مكتبة جديدة",
|
||||
"cmdk.noResults": "لم يتم العثور على نتائج",
|
||||
"cmdk.openSettings": "فتح الإعدادات",
|
||||
"cmdk.pages": "المستندات",
|
||||
"cmdk.painting": "الرسم",
|
||||
"cmdk.resource": "الموارد",
|
||||
"cmdk.search.agent": "مساعد",
|
||||
"cmdk.search.agents": "مساعدون",
|
||||
"cmdk.search.assistant": "مساعد الذكاء الاصطناعي",
|
||||
"cmdk.search.assistants": "مساعدو الذكاء الاصطناعي",
|
||||
"cmdk.search.communityAgent": "مساعد المجتمع",
|
||||
"cmdk.search.file": "ملف",
|
||||
"cmdk.search.files": "ملفات",
|
||||
"cmdk.search.loading": "جارٍ البحث...",
|
||||
"cmdk.search.market": "المجتمع",
|
||||
"cmdk.search.mcp": "خادم MCP",
|
||||
"cmdk.search.mcps": "خوادم MCP",
|
||||
"cmdk.search.message": "المحادثة",
|
||||
"cmdk.search.messages": "المحادثات",
|
||||
"cmdk.search.page": "المستند",
|
||||
"cmdk.search.pages": "المستندات",
|
||||
"cmdk.search.plugin": "الملحق",
|
||||
"cmdk.search.plugins": "الملحقات",
|
||||
"cmdk.search.searchMore": "البحث عن المزيد من {{type}}",
|
||||
"cmdk.search.searching": "نتائج البحث",
|
||||
"cmdk.search.topic": "موضوع",
|
||||
"cmdk.search.topics": "مواضيع",
|
||||
"cmdk.searchPlaceholder": "أدخل أمرًا أو ابحث...",
|
||||
"cmdk.settings": "الإعدادات",
|
||||
"cmdk.starOnGitHub": "قيّمنا على GitHub",
|
||||
"cmdk.submitIssue": "إرسال مشكلة",
|
||||
"cmdk.theme": "السمة",
|
||||
"cmdk.themeAuto": "اتباع النظام",
|
||||
"cmdk.themeDark": "الوضع الداكن",
|
||||
"cmdk.themeLight": "الوضع الفاتح",
|
||||
"cmdk.toOpen": "فتح",
|
||||
"cmdk.toSelect": "تحديد",
|
||||
"confirm": "تأكيد",
|
||||
"contact": "اتصل بنا",
|
||||
"copy": "نسخ",
|
||||
"copyFail": "فشل في النسخ",
|
||||
"copySuccess": "تم النسخ بنجاح",
|
||||
"dataStatistics": {
|
||||
"messages": "رسائل",
|
||||
"sessions": "جلسات",
|
||||
"today": "اليوم",
|
||||
"topics": "مواضيع"
|
||||
},
|
||||
"dataStatistics.messages": "رسائل",
|
||||
"dataStatistics.sessions": "جلسات",
|
||||
"dataStatistics.today": "اليوم",
|
||||
"dataStatistics.topics": "مواضيع",
|
||||
"defaultAgent": "مساعد افتراضي",
|
||||
"defaultSession": "جلسة افتراضية",
|
||||
"delete": "حذف",
|
||||
@@ -175,135 +167,111 @@
|
||||
"duplicate": "إنشاء نسخة",
|
||||
"edit": "تحرير",
|
||||
"export": "تصدير الإعدادات",
|
||||
"exportType": {
|
||||
"agent": "تصدير إعدادات المساعد",
|
||||
"agentWithMessage": "تصدير المساعد والرسائل",
|
||||
"all": "تصدير الإعدادات العامة وجميع بيانات المساعد",
|
||||
"allAgent": "تصدير جميع إعدادات المساعد",
|
||||
"allAgentWithMessage": "تصدير جميع المساعدين والرسائل",
|
||||
"globalSetting": "تصدير الإعدادات العامة"
|
||||
},
|
||||
"exportType.agent": "تصدير إعدادات المساعد",
|
||||
"exportType.agentWithMessage": "تصدير المساعد والرسائل",
|
||||
"exportType.all": "تصدير الإعدادات العامة وجميع بيانات المساعد",
|
||||
"exportType.allAgent": "تصدير جميع إعدادات المساعد",
|
||||
"exportType.allAgentWithMessage": "تصدير جميع المساعدين والرسائل",
|
||||
"exportType.globalSetting": "تصدير الإعدادات العامة",
|
||||
"feedback": "تقديم ملاحظات",
|
||||
"follow": "تابعنا على {{name}}",
|
||||
"footer": {
|
||||
"action": {
|
||||
"feedback": "مشاركة ملاحظاتك الثمينة",
|
||||
"star": "قم بإضافة نجمة على GitHub"
|
||||
},
|
||||
"and": "و",
|
||||
"feedback": {
|
||||
"action": "مشاركة الملاحظات",
|
||||
"desc": "كل فكرة ومقترح لديك ثمين بالنسبة لنا، نحن نتطلع بشوق لمعرفة آرائك! نرحب بالتواصل معنا لتقديم ملاحظاتك حول ميزات المنتج وتجربة الاستخدام، لمساعدتنا في تحسين LobeChat بشكل أفضل.",
|
||||
"title": "مشاركة ملاحظاتك الثمينة على GitHub"
|
||||
},
|
||||
"later": "لاحقًا",
|
||||
"star": {
|
||||
"action": "قم بإضاءة النجمة",
|
||||
"desc": "إذا كنت تحب منتجنا وترغب في دعمنا، هل يمكنك إضافة نجمة لنا على GitHub؟ هذا الإجراء الصغير له أهمية كبيرة بالنسبة لنا، حيث يمكن أن يلهمنا لتقديم تجربة ميزات مستمرة لك.",
|
||||
"title": "قم بإضاءة النجمة لنا على GitHub"
|
||||
},
|
||||
"title": "هل تحب منتجنا؟"
|
||||
},
|
||||
"footer.action.feedback": "مشاركة ملاحظاتك الثمينة",
|
||||
"footer.action.star": "قم بإضافة نجمة على GitHub",
|
||||
"footer.and": "و",
|
||||
"footer.feedback.action": "مشاركة الملاحظات",
|
||||
"footer.feedback.desc": "كل فكرة ومقترح لديك ثمين بالنسبة لنا، نحن نتطلع بشوق لمعرفة آرائك! نرحب بالتواصل معنا لتقديم ملاحظاتك حول ميزات المنتج وتجربة الاستخدام، لمساعدتنا في تحسين LobeChat بشكل أفضل.",
|
||||
"footer.feedback.title": "مشاركة ملاحظاتك الثمينة على GitHub",
|
||||
"footer.later": "لاحقًا",
|
||||
"footer.star.action": "قم بإضاءة النجمة",
|
||||
"footer.star.desc": "إذا كنت تحب منتجنا وترغب في دعمنا، هل يمكنك إضافة نجمة لنا على GitHub؟ هذا الإجراء الصغير له أهمية كبيرة بالنسبة لنا، حيث يمكن أن يلهمنا لتقديم تجربة ميزات مستمرة لك.",
|
||||
"footer.star.title": "قم بإضاءة النجمة لنا على GitHub",
|
||||
"footer.title": "هل تحب منتجنا؟",
|
||||
"fullscreen": "وضع كامل الشاشة",
|
||||
"geminiImageChineseWarning": {
|
||||
"content": "قد يفشل Nano Banana أحيانًا في إنشاء الصور عند استخدام اللغة الصينية. يُنصح باستخدام اللغة الإنجليزية للحصول على نتائج أفضل.",
|
||||
"continueGenerate": "متابعة الإنشاء",
|
||||
"continueSend": "متابعة الإرسال",
|
||||
"doNotShowAgain": "عدم الإظهار مرة أخرى",
|
||||
"title": "تنبيه إدخال اللغة الصينية"
|
||||
},
|
||||
"geminiImageChineseWarning.content": "قد يفشل Nano Banana أحيانًا في إنشاء الصور عند استخدام اللغة الصينية. يُنصح باستخدام اللغة الإنجليزية للحصول على نتائج أفضل.",
|
||||
"geminiImageChineseWarning.continueGenerate": "متابعة الإنشاء",
|
||||
"geminiImageChineseWarning.continueSend": "متابعة الإرسال",
|
||||
"geminiImageChineseWarning.doNotShowAgain": "عدم الإظهار مرة أخرى",
|
||||
"geminiImageChineseWarning.title": "تنبيه إدخال اللغة الصينية",
|
||||
"historyRange": "نطاق التاريخ",
|
||||
"import": "استيراد",
|
||||
"importData": "استيراد البيانات",
|
||||
"importModal": {
|
||||
"error": {
|
||||
"desc": "عذرًا، حدث استثناء أثناء عملية استيراد البيانات. يرجى المحاولة مرة أخرى، أو <1>تقديم مشكلتك</1>، وسنقوم بمساعدتك على الفور في تحديد المشكلة.",
|
||||
"title": "فشل استيراد البيانات"
|
||||
},
|
||||
"finish": {
|
||||
"onlySettings": "تم استيراد إعدادات النظام بنجاح",
|
||||
"start": "ابدأ الاستخدام",
|
||||
"subTitle": "تم استيراد البيانات بنجاح، وقت الاستيراد {{duration}} ثانية. تفاصيل الاستيراد كالتالي:",
|
||||
"title": "اكتمال عملية الاستيراد"
|
||||
},
|
||||
"loading": "جاري استيراد البيانات، يرجى الانتظار...",
|
||||
"preparing": "جاري تجهيز وحدة استيراد البيانات...",
|
||||
"result": {
|
||||
"added": "تمت الإضافة بنجاح",
|
||||
"errors": "حدثت أخطاء أثناء الاستيراد",
|
||||
"messages": "الرسائل",
|
||||
"sessionGroups": "مجموعات الجلسة",
|
||||
"sessions": "الجلسات",
|
||||
"skips": "التخطيات",
|
||||
"topics": "المواضيع",
|
||||
"type": "نوع البيانات",
|
||||
"update": "تحديث السجل"
|
||||
},
|
||||
"title": "استيراد البيانات",
|
||||
"uploading": {
|
||||
"desc": "الملف الحالي كبير نسبيًا، يتم رفعه بجد...",
|
||||
"restTime": "الوقت المتبقي",
|
||||
"speed": "سرعة الرفع"
|
||||
}
|
||||
},
|
||||
"importPreview": {
|
||||
"confirmImport": "تأكيد الاستيراد",
|
||||
"tables": {
|
||||
"count": "عدد السجلات",
|
||||
"name": "اسم الجدول"
|
||||
},
|
||||
"title": "معاينة بيانات الاستيراد",
|
||||
"totalRecords": "إجمالي السجلات التي سيتم استيرادها {{count}}",
|
||||
"totalTables": "{{count}} جدول"
|
||||
},
|
||||
"importModal.error.desc": "عذرًا، حدث استثناء أثناء عملية استيراد البيانات. يرجى المحاولة مرة أخرى، أو <1>تقديم مشكلتك</1>، وسنقوم بمساعدتك على الفور في تحديد المشكلة.",
|
||||
"importModal.error.title": "فشل استيراد البيانات",
|
||||
"importModal.finish.onlySettings": "تم استيراد إعدادات النظام بنجاح",
|
||||
"importModal.finish.start": "ابدأ الاستخدام",
|
||||
"importModal.finish.subTitle": "تم استيراد البيانات بنجاح، وقت الاستيراد {{duration}} ثانية. تفاصيل الاستيراد كالتالي:",
|
||||
"importModal.finish.title": "اكتمال عملية الاستيراد",
|
||||
"importModal.loading": "جاري استيراد البيانات، يرجى الانتظار...",
|
||||
"importModal.preparing": "جاري تجهيز وحدة استيراد البيانات...",
|
||||
"importModal.result.added": "تمت الإضافة بنجاح",
|
||||
"importModal.result.errors": "حدثت أخطاء أثناء الاستيراد",
|
||||
"importModal.result.messages": "الرسائل",
|
||||
"importModal.result.sessionGroups": "مجموعات الجلسة",
|
||||
"importModal.result.sessions": "الجلسات",
|
||||
"importModal.result.skips": "التخطيات",
|
||||
"importModal.result.topics": "المواضيع",
|
||||
"importModal.result.type": "نوع البيانات",
|
||||
"importModal.result.update": "تحديث السجل",
|
||||
"importModal.title": "استيراد البيانات",
|
||||
"importModal.uploading.desc": "الملف الحالي كبير نسبيًا، يتم رفعه بجد...",
|
||||
"importModal.uploading.restTime": "الوقت المتبقي",
|
||||
"importModal.uploading.speed": "سرعة الرفع",
|
||||
"importPreview.confirmImport": "تأكيد الاستيراد",
|
||||
"importPreview.tables.count": "عدد السجلات",
|
||||
"importPreview.tables.name": "اسم الجدول",
|
||||
"importPreview.title": "معاينة بيانات الاستيراد",
|
||||
"importPreview.totalRecords": "إجمالي السجلات التي سيتم استيرادها {{count}}",
|
||||
"importPreview.totalTables": "{{count}} جدول",
|
||||
"information": "المجتمع والمعلومات",
|
||||
"installPWA": "تثبيت تطبيق المتصفح",
|
||||
"labs": "المختبرات",
|
||||
"lang": {
|
||||
"ar": "العربية",
|
||||
"bg-BG": "البلغارية",
|
||||
"bn": "البنغالية",
|
||||
"cs-CZ": "التشيكية",
|
||||
"da-DK": "الدنماركية",
|
||||
"de-DE": "الألمانية",
|
||||
"el-GR": "اليونانية",
|
||||
"en": "الإنجليزية",
|
||||
"en-US": "الإنجليزية",
|
||||
"es-ES": "الإسبانية",
|
||||
"fa-IR": "الفارسية",
|
||||
"fi-FI": "الفنلندية",
|
||||
"fr-FR": "الفرنسية",
|
||||
"hi-IN": "الهندية",
|
||||
"hu-HU": "الهنغارية",
|
||||
"id-ID": "الإندونيسية",
|
||||
"it-IT": "الإيطالية",
|
||||
"ja-JP": "اليابانية",
|
||||
"ko-KR": "الكورية",
|
||||
"nl-NL": "الهولندية",
|
||||
"no-NO": "النرويجية",
|
||||
"pl-PL": "البولندية",
|
||||
"pt-BR": "البرتغالية",
|
||||
"pt-PT": "البرتغالية",
|
||||
"ro-RO": "الرومانية",
|
||||
"ru-RU": "الروسية",
|
||||
"sk-SK": "السلوفاكية",
|
||||
"sr-RS": "الصربية",
|
||||
"sv-SE": "السويدية",
|
||||
"th-TH": "التايلاندية",
|
||||
"tr-TR": "التركية",
|
||||
"uk-UA": "الأوكرانية",
|
||||
"vi-VN": "الفيتنامية",
|
||||
"zh": "الصينية المبسطة",
|
||||
"zh-CN": "الصينية المبسطة",
|
||||
"zh-TW": "الصينية التقليدية"
|
||||
},
|
||||
"lang.ar": "العربية",
|
||||
"lang.auto": "اتبع إعدادات لغة النظام",
|
||||
"lang.bg-BG": "البلغارية",
|
||||
"lang.bn": "البنغالية",
|
||||
"lang.cs-CZ": "التشيكية",
|
||||
"lang.da-DK": "الدنماركية",
|
||||
"lang.de-DE": "الألمانية",
|
||||
"lang.el-GR": "اليونانية",
|
||||
"lang.en": "الإنجليزية",
|
||||
"lang.en-US": "الإنجليزية",
|
||||
"lang.es-ES": "الإسبانية",
|
||||
"lang.fa-IR": "الفارسية",
|
||||
"lang.fi-FI": "الفنلندية",
|
||||
"lang.fr-FR": "الفرنسية",
|
||||
"lang.hi-IN": "الهندية",
|
||||
"lang.hu-HU": "الهنغارية",
|
||||
"lang.id-ID": "الإندونيسية",
|
||||
"lang.it-IT": "الإيطالية",
|
||||
"lang.ja-JP": "اليابانية",
|
||||
"lang.ko-KR": "الكورية",
|
||||
"lang.nl-NL": "الهولندية",
|
||||
"lang.no-NO": "النرويجية",
|
||||
"lang.pl-PL": "البولندية",
|
||||
"lang.pt-BR": "البرتغالية",
|
||||
"lang.pt-PT": "البرتغالية",
|
||||
"lang.ro-RO": "الرومانية",
|
||||
"lang.ru-RU": "الروسية",
|
||||
"lang.sk-SK": "السلوفاكية",
|
||||
"lang.sr-RS": "الصربية",
|
||||
"lang.sv-SE": "السويدية",
|
||||
"lang.th-TH": "التايلاندية",
|
||||
"lang.tr-TR": "التركية",
|
||||
"lang.uk-UA": "الأوكرانية",
|
||||
"lang.vi-VN": "الفيتنامية",
|
||||
"lang.zh": "الصينية المبسطة",
|
||||
"lang.zh-CN": "الصينية المبسطة",
|
||||
"lang.zh-TW": "الصينية التقليدية",
|
||||
"layoutInitializing": "جاري تحميل التخطيط...",
|
||||
"legal": "بيان قانوني",
|
||||
"loading": "جارِ التحميل...",
|
||||
"mail": {
|
||||
"business": "شراكات تجارية",
|
||||
"support": "الدعم عبر البريد الإلكتروني"
|
||||
},
|
||||
"mail.business": "شراكات تجارية",
|
||||
"mail.support": "الدعم عبر البريد الإلكتروني",
|
||||
"navPanel.agent": "المساعد",
|
||||
"navPanel.displayItems": "عرض العناصر",
|
||||
"navPanel.library": "المكتبة",
|
||||
"navPanel.searchAgent": "بحث عن مساعد...",
|
||||
"navPanel.searchResultEmpty": "لا توجد نتائج بحث",
|
||||
"new": "جديد",
|
||||
"oauth": "تسجيل الدخول SSO",
|
||||
"officialSite": "الموقع الرسمي",
|
||||
@@ -324,81 +292,65 @@
|
||||
"setting": "الإعدادات",
|
||||
"share": "مشاركة",
|
||||
"stop": "إيقاف",
|
||||
"sync": {
|
||||
"actions": {
|
||||
"settings": "إعدادات المزامنة",
|
||||
"sync": "مزامنة فورية"
|
||||
},
|
||||
"awareness": {
|
||||
"current": "الجهاز الحالي"
|
||||
},
|
||||
"channel": "القناة",
|
||||
"disabled": {
|
||||
"actions": {
|
||||
"enable": "تمكين المزامنة السحابية",
|
||||
"settings": "تكوين معلمات المزامنة"
|
||||
},
|
||||
"desc": "بيانات الجلسة الحالية تُخزن فقط في هذا المتصفح. إذا كنت بحاجة إلى مزامنة البيانات بين عدة أجهزة، يرجى تكوين وتمكين المزامنة السحابية.",
|
||||
"title": "لم يتم تشغيل مزامنة البيانات"
|
||||
},
|
||||
"enabled": {
|
||||
"title": "مزامنة البيانات"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "جار الاتصال",
|
||||
"disabled": "مزامنة غير مفعلة",
|
||||
"ready": "متصل",
|
||||
"synced": "تمت المزامنة",
|
||||
"syncing": "جار المزامنة",
|
||||
"unconnected": "فشل الاتصال"
|
||||
},
|
||||
"title": "حالة المزامنة",
|
||||
"unconnected": {
|
||||
"tip": "فشل اتصال خادم الإشارة، لن يتمكن من إنشاء قناة اتصال نقطية، يرجى التحقق من الشبكة وإعادة المحاولة"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"aiImage": "الرسم بالذكاء الاصطناعي",
|
||||
"chat": "الدردشة",
|
||||
"discover": "اكتشاف",
|
||||
"files": "ملفات",
|
||||
"knowledgeBase": "قاعدة المعرفة",
|
||||
"me": "أنا",
|
||||
"setting": "الإعدادات"
|
||||
},
|
||||
"telemetry": {
|
||||
"allow": "السماح",
|
||||
"deny": "رفض",
|
||||
"desc": "نحن نأمل في الحصول على معلومات استخدامك بشكل مجهول لمساعدتنا في تحسين LobeChat وتوفير تجربة منتج أفضل لك. يمكنك إيقاف ذلك في أي وقت من \"الإعدادات\" - \"حول\".",
|
||||
"learnMore": "معرفة المزيد",
|
||||
"title": "مساعدة LobeChat في التحسن"
|
||||
},
|
||||
"sync.actions.settings": "إعدادات المزامنة",
|
||||
"sync.actions.sync": "مزامنة فورية",
|
||||
"sync.awareness.current": "الجهاز الحالي",
|
||||
"sync.channel": "القناة",
|
||||
"sync.disabled.actions.enable": "تمكين المزامنة السحابية",
|
||||
"sync.disabled.actions.settings": "تكوين معلمات المزامنة",
|
||||
"sync.disabled.desc": "بيانات الجلسة الحالية تُخزن فقط في هذا المتصفح. إذا كنت بحاجة إلى مزامنة البيانات بين عدة أجهزة، يرجى تكوين وتمكين المزامنة السحابية.",
|
||||
"sync.disabled.title": "لم يتم تشغيل مزامنة البيانات",
|
||||
"sync.enabled.title": "مزامنة البيانات",
|
||||
"sync.status.connecting": "جار الاتصال",
|
||||
"sync.status.disabled": "مزامنة غير مفعلة",
|
||||
"sync.status.ready": "متصل",
|
||||
"sync.status.synced": "تمت المزامنة",
|
||||
"sync.status.syncing": "جار المزامنة",
|
||||
"sync.status.unconnected": "فشل الاتصال",
|
||||
"sync.title": "حالة المزامنة",
|
||||
"sync.unconnected.tip": "فشل اتصال خادم الإشارة، لن يتمكن من إنشاء قناة اتصال نقطية، يرجى التحقق من الشبكة وإعادة المحاولة",
|
||||
"tab.aiImage": "رسم",
|
||||
"tab.audio": "الصوت",
|
||||
"tab.chat": "الدردشة",
|
||||
"tab.community": "المجتمع",
|
||||
"tab.discover": "اكتشاف",
|
||||
"tab.files": "ملفات",
|
||||
"tab.home": "الصفحة الرئيسية",
|
||||
"tab.knowledgeBase": "مكتبة الموارد",
|
||||
"tab.me": "أنا",
|
||||
"tab.memory": "الذاكرة",
|
||||
"tab.pages": "المستندات",
|
||||
"tab.resource": "الموارد",
|
||||
"tab.search": "البحث",
|
||||
"tab.setting": "الإعدادات",
|
||||
"tab.video": "الفيديو",
|
||||
"telemetry.allow": "السماح",
|
||||
"telemetry.deny": "رفض",
|
||||
"telemetry.desc": "نحن نأمل في الحصول على معلومات استخدامك بشكل مجهول لمساعدتنا في تحسين LobeChat وتوفير تجربة منتج أفضل لك. يمكنك إيقاف ذلك في أي وقت من \"الإعدادات\" - \"حول\".",
|
||||
"telemetry.learnMore": "معرفة المزيد",
|
||||
"telemetry.title": "مساعدة LobeChat في التحسن",
|
||||
"temp": "مؤقت",
|
||||
"terms": "شروط الخدمة",
|
||||
"update": "تحديث",
|
||||
"updateAgent": "تحديث معلومات الوكيل",
|
||||
"upgradeVersion": {
|
||||
"action": "ترقية",
|
||||
"hasNew": "يوجد تحديث متاح",
|
||||
"newVersion": "هناك إصدار جديد متاح: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"anonymousNickName": "مستخدم مجهول",
|
||||
"billing": "إدارة الفواتير",
|
||||
"cloud": "تجربة {{name}}",
|
||||
"community": "نسخة المجتمع",
|
||||
"data": "تخزين البيانات",
|
||||
"defaultNickname": "مستخدم النسخة المجتمعية",
|
||||
"discord": "الدعم المجتمعي",
|
||||
"docs": "وثائق الاستخدام",
|
||||
"email": "الدعم عبر البريد الإلكتروني",
|
||||
"feedback": "تقديم ملاحظات واقتراحات",
|
||||
"help": "مركز المساعدة",
|
||||
"moveGuide": "تم نقل زر الإعدادات إلى هنا",
|
||||
"plans": "خطط الاشتراك",
|
||||
"profile": "إدارة الحساب",
|
||||
"setting": "إعدادات التطبيق",
|
||||
"usages": "إحصاءات الاستخدام"
|
||||
},
|
||||
"upgradeVersion.action": "ترقية",
|
||||
"upgradeVersion.hasNew": "يوجد تحديث متاح",
|
||||
"upgradeVersion.newVersion": "هناك إصدار جديد متاح: {{version}}",
|
||||
"userPanel.anonymousNickName": "مستخدم مجهول",
|
||||
"userPanel.billing": "إدارة الفواتير",
|
||||
"userPanel.cloud": "تجربة {{name}}",
|
||||
"userPanel.community": "نسخة المجتمع",
|
||||
"userPanel.data": "تخزين البيانات",
|
||||
"userPanel.defaultNickname": "مستخدم النسخة المجتمعية",
|
||||
"userPanel.discord": "الدعم المجتمعي",
|
||||
"userPanel.docs": "وثائق الاستخدام",
|
||||
"userPanel.email": "الدعم عبر البريد الإلكتروني",
|
||||
"userPanel.feedback": "تقديم ملاحظات واقتراحات",
|
||||
"userPanel.help": "مركز المساعدة",
|
||||
"userPanel.moveGuide": "تم نقل زر الإعدادات إلى هنا",
|
||||
"userPanel.plans": "خطط الاشتراك",
|
||||
"userPanel.profile": "إدارة الحساب",
|
||||
"userPanel.setting": "إعدادات التطبيق",
|
||||
"userPanel.usages": "إحصاءات الاستخدام",
|
||||
"version": "الإصدار"
|
||||
}
|
||||
|
||||
+140
-195
@@ -1,197 +1,142 @@
|
||||
{
|
||||
"ArgsInput": {
|
||||
"addArgument": "إضافة وسيط",
|
||||
"argumentPlaceholder": "الوسيط {{index}}",
|
||||
"enterFirstArgument": "أدخل الوسيط الأول..."
|
||||
},
|
||||
"DragUpload": {
|
||||
"dragDesc": "اسحب الملفات هنا، يدعم تحميل عدة صور.",
|
||||
"dragFileDesc": "اسحب الصور والملفات هنا، يدعم تحميل عدة صور وملفات.",
|
||||
"dragFileTitle": "تحميل الملفات",
|
||||
"dragTitle": "تحميل الصور"
|
||||
},
|
||||
"FileManager": {
|
||||
"actions": {
|
||||
"addToKnowledgeBase": "إضافة إلى قاعدة المعرفة",
|
||||
"addToOtherKnowledgeBase": "إضافة إلى قاعدة معرفة أخرى",
|
||||
"batchChunking": "تقسيم دفعي",
|
||||
"chunking": "تقسيم",
|
||||
"chunkingTooltip": "قم بتقسيم الملف إلى عدة كتل نصية وتحويلها إلى متجهات، يمكن استخدامها في البحث الدلالي والمحادثة حول الملفات",
|
||||
"chunkingUnsupported": "هذا الملف لا يدعم تقسيم الأجزاء",
|
||||
"confirmDelete": "سيتم حذف هذا الملف، ولن يمكن استعادته بعد الحذف، يرجى تأكيد العملية",
|
||||
"confirmDeleteMultiFiles": "سيتم حذف {{count}} ملفًا محددًا، ولن يمكن استعادته بعد الحذف، يرجى تأكيد العملية",
|
||||
"confirmRemoveFromKnowledgeBase": "سيتم إزالة {{count}} ملفًا محددًا من قاعدة المعرفة، لا يزال بإمكانك رؤية الملفات في جميع الملفات، يرجى تأكيد العملية",
|
||||
"copyUrl": "نسخ الرابط",
|
||||
"copyUrlSuccess": "تم نسخ عنوان الملف بنجاح",
|
||||
"createChunkingTask": "جارٍ التحضير...",
|
||||
"deleteSuccess": "تم حذف الملف بنجاح",
|
||||
"downloading": "جارٍ تحميل الملف...",
|
||||
"removeFromKnowledgeBase": "إزالة من قاعدة المعرفة",
|
||||
"removeFromKnowledgeBaseSuccess": "تمت إزالة الملف بنجاح"
|
||||
},
|
||||
"bottom": "لقد وصلت إلى النهاية",
|
||||
"config": {
|
||||
"showFilesInKnowledgeBase": "عرض المحتوى في قاعدة المعرفة"
|
||||
},
|
||||
"emptyStatus": {
|
||||
"actions": {
|
||||
"file": "رفع ملف",
|
||||
"folder": "رفع مجلد",
|
||||
"knowledgeBase": "إنشاء قاعدة معرفة جديدة"
|
||||
},
|
||||
"or": "أو",
|
||||
"title": "قم بسحب الملف أو المجلد هنا"
|
||||
},
|
||||
"title": {
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"size": "الحجم",
|
||||
"title": "ملف"
|
||||
},
|
||||
"total": {
|
||||
"fileCount": "إجمالي {{count}} عنصر",
|
||||
"selectedCount": "تم تحديد {{count}} عنصر"
|
||||
},
|
||||
"view": {
|
||||
"list": "عرض القائمة",
|
||||
"masonry": "عرض الشبكة"
|
||||
}
|
||||
},
|
||||
"FileParsingStatus": {
|
||||
"chunks": {
|
||||
"embeddingStatus": {
|
||||
"empty": "لم يتم تحويل كتل النص بالكامل إلى متجهات، مما سيؤدي إلى عدم توفر وظيفة البحث الدلالي، لتحسين جودة البحث، يرجى تحويل كتل النص إلى متجهات",
|
||||
"error": "فشل في تحويل البيانات إلى متجهات",
|
||||
"errorResult": "فشل في تحويل البيانات إلى متجهات، يرجى التحقق والمحاولة مرة أخرى. سبب الفشل:",
|
||||
"processing": "يتم تحويل كتل النص إلى متجهات، يرجى الانتظار",
|
||||
"success": "تم تحويل جميع كتل النص الحالية إلى متجهات"
|
||||
},
|
||||
"embeddings": "تحويل إلى متجهات",
|
||||
"status": {
|
||||
"error": "فشل في التقسيم",
|
||||
"errorResult": "فشل في التقسيم، يرجى التحقق والمحاولة مرة أخرى. سبب الفشل:",
|
||||
"processing": "جارٍ التقسيم",
|
||||
"processingTip": "الخادم يقوم بتقسيم كتل النص، إغلاق الصفحة لا يؤثر على تقدم التقسيم"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GoBack": {
|
||||
"back": "عودة"
|
||||
},
|
||||
"HtmlPreview": {
|
||||
"actions": {
|
||||
"download": "تنزيل",
|
||||
"preview": "معاينة"
|
||||
},
|
||||
"iframeTitle": "معاينة HTML",
|
||||
"mode": {
|
||||
"code": "رمز",
|
||||
"preview": "معاينة"
|
||||
},
|
||||
"title": "معاينة HTML"
|
||||
},
|
||||
"ImageUpload": {
|
||||
"actions": {
|
||||
"changeImage": "انقر لتغيير الصورة",
|
||||
"dropMultipleFiles": "لا يدعم تحميل ملفات متعددة في آن واحد، سيتم استخدام الملف الأول فقط"
|
||||
},
|
||||
"placeholder": {
|
||||
"primary": "إضافة صورة",
|
||||
"secondary": "انقر أو اسحب للإرفاق"
|
||||
}
|
||||
},
|
||||
"KeyValueEditor": {
|
||||
"addButton": "إضافة صف جديد",
|
||||
"deleteTooltip": "حذف",
|
||||
"duplicateKeyError": "يجب أن يكون اسم المفتاح فريدًا",
|
||||
"keyPlaceholder": "المفتاح",
|
||||
"valuePlaceholder": "القيمة"
|
||||
},
|
||||
"LocalFile": {
|
||||
"action": {
|
||||
"open": "فتح",
|
||||
"showInFolder": "عرض في المجلد"
|
||||
}
|
||||
},
|
||||
"MaxTokenSlider": {
|
||||
"unlimited": "غير محدود"
|
||||
},
|
||||
"ModelSelect": {
|
||||
"featureTag": {
|
||||
"custom": "نموذج مخصص، الإعداد الافتراضي يدعم الاستدعاء الوظيفي والتعرف البصري، يرجى التحقق من قدرة النموذج على القيام بذلك بناءً على الحالة الفعلية",
|
||||
"file": "يدعم هذا النموذج قراءة وتعرف الملفات المرفوعة",
|
||||
"functionCall": "يدعم هذا النموذج استدعاء الوظائف",
|
||||
"imageOutput": "يدعم هذا النموذج إنشاء الصور",
|
||||
"reasoning": "يدعم هذا النموذج التفكير العميق",
|
||||
"search": "يدعم هذا النموذج البحث عبر الإنترنت",
|
||||
"tokens": "يدعم هذا النموذج حتى {{tokens}} رمزًا في جلسة واحدة",
|
||||
"video": "هذا النموذج يدعم التعرف على الفيديو",
|
||||
"vision": "يدعم هذا النموذج التعرف البصري"
|
||||
},
|
||||
"removed": "هذا النموذج لم يعد متوفر في القائمة، سيتم إزالته تلقائيًا إذا تم إلغاء تحديده"
|
||||
},
|
||||
"ModelSwitchPanel": {
|
||||
"emptyModel": "لا توجد نماذج ممكن تمكينها، يرجى الانتقال إلى الإعدادات لتمكينها",
|
||||
"emptyProvider": "لا توجد مزودات مفعلة، يرجى الذهاب إلى الإعدادات لتفعيلها",
|
||||
"goToSettings": "اذهب إلى الإعدادات",
|
||||
"provider": "مزود",
|
||||
"title": "نموذج"
|
||||
},
|
||||
"MultiImagesUpload": {
|
||||
"actions": {
|
||||
"uploadMore": "انقر أو اسحب لإضافة المزيد"
|
||||
},
|
||||
"modal": {
|
||||
"complete": "اكتمل",
|
||||
"newFileIndicator": "جديد",
|
||||
"selectImageToPreview": "يرجى اختيار صورة للمعاينة",
|
||||
"title": "إدارة الصور ({{count}})",
|
||||
"upload": "تحميل الصور"
|
||||
},
|
||||
"placeholder": {
|
||||
"primary": "انقر أو اسحب لتحميل الصور",
|
||||
"secondary": "يدعم اختيار عدة صور"
|
||||
},
|
||||
"progress": {
|
||||
"uploadingWithCount": "تم تحميل {{completed}} من أصل {{total}}"
|
||||
},
|
||||
"validation": {
|
||||
"fileSizeExceeded": "تجاوز حجم الملف الحد المسموح",
|
||||
"fileSizeExceededDetail": "{{fileName}} ({{actualSize}}) يتجاوز الحد الأقصى للحجم {{maxSize}}",
|
||||
"fileSizeExceededMultiple": "{{count}} ملفات تتجاوز الحد الأقصى للحجم {{maxSize}}: {{fileList}}",
|
||||
"imageCountExceeded": "تجاوز عدد الصور الحد المسموح"
|
||||
}
|
||||
},
|
||||
"OllamaSetupGuide": {
|
||||
"action": {
|
||||
"close": "إغلاق الإشعار",
|
||||
"start": "تم التثبيت والتشغيل، ابدأ المحادثة"
|
||||
},
|
||||
"cors": {
|
||||
"description": "بسبب قيود أمان المتصفح، تحتاج إلى تكوين CORS لـ Ollama لاستخدامه بشكل صحيح.",
|
||||
"linux": {
|
||||
"env": "أضف `Environment` تحت قسم [Service]، وأضف متغير البيئة OLLAMA_ORIGINS:",
|
||||
"reboot": "أعد تحميل systemd وأعد تشغيل Ollama",
|
||||
"systemd": "استخدم systemd لتحرير خدمة ollama:"
|
||||
},
|
||||
"macos": "يرجى فتح تطبيق «الطرفية» ولصق الأوامر التالية ثم الضغط على Enter للتنفيذ",
|
||||
"reboot": "يرجى إعادة تشغيل خدمة Ollama بعد الانتهاء من التنفيذ",
|
||||
"title": "تكوين Ollama للسماح بالوصول عبر النطاقات المتعددة",
|
||||
"windows": "على نظام Windows، انقر على «لوحة التحكم»، ثم انتقل إلى تحرير متغيرات البيئة للنظام. أنشئ متغير بيئة جديد باسم «OLLAMA_ORIGINS» لقائمة المستخدم الخاصة بك، وقيمته هي *، ثم انقر على «موافق/تطبيق» لحفظ التغييرات."
|
||||
},
|
||||
"install": {
|
||||
"description": "يرجى التأكد من أنك قد قمت بتشغيل Ollama، إذا لم تقم بتنزيل Ollama، يرجى زيارة الموقع الرسمي <1>للتنزيل</1>",
|
||||
"docker": "إذا كنت تفضل استخدام Docker، فإن Ollama يوفر أيضًا صورة Docker رسمية، يمكنك سحبها باستخدام الأمر التالي:",
|
||||
"linux": {
|
||||
"command": "قم بتثبيت باستخدام الأمر التالي:",
|
||||
"manual": "أو يمكنك الرجوع إلى <1>دليل التثبيت اليدوي لنظام Linux</1> للتثبيت بنفسك."
|
||||
},
|
||||
"title": "تثبيت وتشغيل تطبيق Ollama محليًا",
|
||||
"windowsTab": "Windows (نسخة المعاينة)"
|
||||
}
|
||||
},
|
||||
"Thinking": {
|
||||
"thinking": "في حالة تفكير عميق...",
|
||||
"thought": "لقد فكرت بعمق (استغرق الأمر {{duration}} ثانية)",
|
||||
"thoughtWithDuration": "لقد فكرت بعمق"
|
||||
}
|
||||
"ArgsInput.addArgument": "إضافة وسيط",
|
||||
"ArgsInput.argumentPlaceholder": "الوسيط {{index}}",
|
||||
"ArgsInput.enterFirstArgument": "أدخل الوسيط الأول...",
|
||||
"DragUpload.dragDesc": "اسحب الملفات هنا، يدعم تحميل عدة صور.",
|
||||
"DragUpload.dragFileDesc": "اسحب الصور والملفات هنا، يدعم تحميل عدة صور وملفات.",
|
||||
"DragUpload.dragFileTitle": "تحميل الملفات",
|
||||
"DragUpload.dragTitle": "تحميل الصور",
|
||||
"EmojiPicker.delete": "حذف الصورة الرمزية",
|
||||
"EmojiPicker.draggerDesc": "انقر أو اسحب الصورة إلى هذه المنطقة للتحميل",
|
||||
"EmojiPicker.emoji": "رمز تعبيري",
|
||||
"EmojiPicker.fileTypeError": "يرجى تحميل ملف صورة صالح",
|
||||
"EmojiPicker.upload": "تحميل الصورة الرمزية",
|
||||
"EmojiPicker.uploadBtn": "قص وتحميل",
|
||||
"FileManager.actions.addToKnowledgeBase": "إضافة إلى قاعدة الموارد",
|
||||
"FileManager.actions.addToOtherKnowledgeBase": "إضافة إلى قاعدة موارد أخرى",
|
||||
"FileManager.actions.batchChunking": "تقسيم دفعي",
|
||||
"FileManager.actions.chunking": "تقسيم",
|
||||
"FileManager.actions.chunkingTooltip": "قم بتقسيم الملف إلى عدة كتل نصية وتحويلها إلى متجهات، يمكن استخدامها في البحث الدلالي والمحادثة حول الملفات",
|
||||
"FileManager.actions.chunkingUnsupported": "هذا الملف لا يدعم تقسيم الأجزاء",
|
||||
"FileManager.actions.confirmDelete": "سيتم حذف هذا الملف، ولن يمكن استعادته بعد الحذف، يرجى تأكيد العملية",
|
||||
"FileManager.actions.confirmDeleteFolder": "سيتم حذف هذا المجلد وجميع محتوياته، ولن يكون بالإمكان استعادته بعد الحذف. يرجى تأكيد العملية.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "سيتم حذف {{count}} ملفًا محددًا، ولن يمكن استعادته بعد الحذف، يرجى تأكيد العملية",
|
||||
"FileManager.actions.confirmRemoveFromKnowledgeBase": "سيتم إزالة {{count}} ملفًا محددًا من قاعدة الموارد. بعد الإزالة، ستظل الملفات مرئية في جميع الملفات. يرجى تأكيد الإجراء.",
|
||||
"FileManager.actions.copyUrl": "نسخ الرابط",
|
||||
"FileManager.actions.copyUrlSuccess": "تم نسخ عنوان الملف بنجاح",
|
||||
"FileManager.actions.createChunkingTask": "جارٍ التحضير...",
|
||||
"FileManager.actions.deleteSuccess": "تم حذف الملف بنجاح",
|
||||
"FileManager.actions.downloading": "جارٍ تحميل الملف...",
|
||||
"FileManager.actions.goBack": "العودة إلى الصفحة السابقة",
|
||||
"FileManager.actions.goForward": "الانتقال إلى الصفحة التالية",
|
||||
"FileManager.actions.goToParent": "الانتقال إلى المجلد الرئيسي",
|
||||
"FileManager.actions.moveError": "فشل في نقل الملف",
|
||||
"FileManager.actions.moveHere": "نقل إلى هنا",
|
||||
"FileManager.actions.moveSuccess": "تم نقل الملف بنجاح",
|
||||
"FileManager.actions.moveToFolder": "نقل إلى...",
|
||||
"FileManager.actions.moveToRoot": "نقل إلى الدليل الجذري",
|
||||
"FileManager.actions.removeFromKnowledgeBase": "إزالة من قاعدة الموارد",
|
||||
"FileManager.actions.removeFromKnowledgeBaseSuccess": "تمت إزالة الملف بنجاح",
|
||||
"FileManager.actions.rename": "إعادة التسمية",
|
||||
"FileManager.actions.renameError": "فشل في إعادة التسمية",
|
||||
"FileManager.actions.renameSuccess": "تمت إعادة التسمية بنجاح",
|
||||
"FileManager.bottom": "لقد وصلت إلى النهاية",
|
||||
"FileManager.config.showFilesInKnowledgeBase": "عرض المحتوى في قاعدة الموارد",
|
||||
"FileManager.emptyStatus.actions.file": "رفع ملف",
|
||||
"FileManager.emptyStatus.actions.folder": "رفع مجلد",
|
||||
"FileManager.emptyStatus.actions.knowledgeBase": "إنشاء قاعدة موارد جديدة",
|
||||
"FileManager.emptyStatus.or": "أو",
|
||||
"FileManager.emptyStatus.title": "قم بسحب الملف أو المجلد هنا",
|
||||
"FileManager.noFolders": "لا توجد مجلدات حالياً",
|
||||
"FileManager.sort.dateAdded": "تاريخ الإضافة",
|
||||
"FileManager.sort.name": "الاسم",
|
||||
"FileManager.sort.size": "الحجم",
|
||||
"FileManager.title.createdAt": "تاريخ الإنشاء",
|
||||
"FileManager.title.size": "الحجم",
|
||||
"FileManager.title.title": "ملف",
|
||||
"FileManager.total.fileCount": "إجمالي {{count}} عنصر",
|
||||
"FileManager.total.selectedCount": "تم تحديد {{count}} عنصر",
|
||||
"FileManager.view.list": "عرض القائمة",
|
||||
"FileManager.view.masonry": "عرض الشبكة",
|
||||
"FileParsingStatus.chunks.embeddingStatus.empty": "لم يتم تحويل كتل النص بالكامل إلى متجهات، مما سيؤدي إلى عدم توفر وظيفة البحث الدلالي، لتحسين جودة البحث، يرجى تحويل كتل النص إلى متجهات",
|
||||
"FileParsingStatus.chunks.embeddingStatus.error": "فشل في تحويل البيانات إلى متجهات",
|
||||
"FileParsingStatus.chunks.embeddingStatus.errorResult": "فشل في تحويل البيانات إلى متجهات، يرجى التحقق والمحاولة مرة أخرى. سبب الفشل:",
|
||||
"FileParsingStatus.chunks.embeddingStatus.processing": "يتم تحويل كتل النص إلى متجهات، يرجى الانتظار",
|
||||
"FileParsingStatus.chunks.embeddingStatus.success": "تم تحويل جميع كتل النص الحالية إلى متجهات",
|
||||
"FileParsingStatus.chunks.embeddings": "تحويل إلى متجهات",
|
||||
"FileParsingStatus.chunks.status.error": "فشل في التقسيم",
|
||||
"FileParsingStatus.chunks.status.errorResult": "فشل في التقسيم، يرجى التحقق والمحاولة مرة أخرى. سبب الفشل:",
|
||||
"FileParsingStatus.chunks.status.processing": "جارٍ التقسيم",
|
||||
"FileParsingStatus.chunks.status.processingTip": "الخادم يقوم بتقسيم كتل النص، إغلاق الصفحة لا يؤثر على تقدم التقسيم",
|
||||
"GoBack.back": "عودة",
|
||||
"HtmlPreview.actions.download": "تنزيل",
|
||||
"HtmlPreview.actions.preview": "معاينة",
|
||||
"HtmlPreview.iframeTitle": "معاينة HTML",
|
||||
"HtmlPreview.mode.code": "رمز",
|
||||
"HtmlPreview.mode.preview": "معاينة",
|
||||
"HtmlPreview.title": "معاينة HTML",
|
||||
"ImageUpload.actions.changeImage": "انقر لتغيير الصورة",
|
||||
"ImageUpload.actions.dropMultipleFiles": "لا يدعم تحميل ملفات متعددة في آن واحد، سيتم استخدام الملف الأول فقط",
|
||||
"ImageUpload.placeholder.primary": "إضافة صورة",
|
||||
"ImageUpload.placeholder.secondary": "انقر أو اسحب للإرفاق",
|
||||
"KeyValueEditor.addButton": "إضافة صف جديد",
|
||||
"KeyValueEditor.deleteTooltip": "حذف",
|
||||
"KeyValueEditor.duplicateKeyError": "يجب أن يكون اسم المفتاح فريدًا",
|
||||
"KeyValueEditor.keyPlaceholder": "المفتاح",
|
||||
"KeyValueEditor.valuePlaceholder": "القيمة",
|
||||
"LocalFile.action.open": "فتح",
|
||||
"LocalFile.action.showInFolder": "عرض في المجلد",
|
||||
"MaxTokenSlider.unlimited": "غير محدود",
|
||||
"ModelSelect.featureTag.custom": "نموذج مخصص، الإعداد الافتراضي يدعم الاستدعاء الوظيفي والتعرف البصري، يرجى التحقق من قدرة النموذج على القيام بذلك بناءً على الحالة الفعلية",
|
||||
"ModelSelect.featureTag.file": "يدعم هذا النموذج قراءة وتعرف الملفات المرفوعة",
|
||||
"ModelSelect.featureTag.functionCall": "يدعم هذا النموذج استدعاء الوظائف",
|
||||
"ModelSelect.featureTag.imageOutput": "يدعم هذا النموذج إنشاء الصور",
|
||||
"ModelSelect.featureTag.reasoning": "يدعم هذا النموذج التفكير العميق",
|
||||
"ModelSelect.featureTag.search": "يدعم هذا النموذج البحث عبر الإنترنت",
|
||||
"ModelSelect.featureTag.tokens": "يدعم هذا النموذج حتى {{tokens}} رمزًا في جلسة واحدة",
|
||||
"ModelSelect.featureTag.video": "هذا النموذج يدعم التعرف على الفيديو",
|
||||
"ModelSelect.featureTag.vision": "يدعم هذا النموذج التعرف البصري",
|
||||
"ModelSelect.removed": "هذا النموذج لم يعد متوفر في القائمة، سيتم إزالته تلقائيًا إذا تم إلغاء تحديده",
|
||||
"ModelSwitchPanel.emptyModel": "لا توجد نماذج ممكن تمكينها، يرجى الانتقال إلى الإعدادات لتمكينها",
|
||||
"ModelSwitchPanel.emptyProvider": "لا توجد مزودات مفعلة، يرجى الذهاب إلى الإعدادات لتفعيلها",
|
||||
"ModelSwitchPanel.goToSettings": "اذهب إلى الإعدادات",
|
||||
"ModelSwitchPanel.provider": "مزود",
|
||||
"ModelSwitchPanel.title": "نموذج",
|
||||
"MultiImagesUpload.actions.uploadMore": "انقر أو اسحب لإضافة المزيد",
|
||||
"MultiImagesUpload.modal.complete": "اكتمل",
|
||||
"MultiImagesUpload.modal.newFileIndicator": "جديد",
|
||||
"MultiImagesUpload.modal.selectImageToPreview": "يرجى اختيار صورة للمعاينة",
|
||||
"MultiImagesUpload.modal.title": "إدارة الصور ({{count}})",
|
||||
"MultiImagesUpload.modal.upload": "تحميل الصور",
|
||||
"MultiImagesUpload.placeholder.primary": "انقر أو اسحب لتحميل الصور",
|
||||
"MultiImagesUpload.placeholder.secondary": "يدعم اختيار عدة صور",
|
||||
"MultiImagesUpload.progress.uploadingWithCount": "تم تحميل {{completed}} من أصل {{total}}",
|
||||
"MultiImagesUpload.validation.fileSizeExceeded": "تجاوز حجم الملف الحد المسموح",
|
||||
"MultiImagesUpload.validation.fileSizeExceededDetail": "{{fileName}} ({{actualSize}}) يتجاوز الحد الأقصى للحجم {{maxSize}}",
|
||||
"MultiImagesUpload.validation.fileSizeExceededMultiple": "{{count}} ملفات تتجاوز الحد الأقصى للحجم {{maxSize}}: {{fileList}}",
|
||||
"MultiImagesUpload.validation.imageCountExceeded": "تجاوز عدد الصور الحد المسموح",
|
||||
"OllamaSetupGuide.action.close": "إغلاق الإشعار",
|
||||
"OllamaSetupGuide.action.start": "تم التثبيت",
|
||||
"OllamaSetupGuide.cors.description": "بسبب قيود أمان المتصفح، تحتاج إلى تكوين CORS لـ Ollama لاستخدامه بشكل صحيح.",
|
||||
"OllamaSetupGuide.cors.linux.env": "أضف `Environment` تحت قسم [Service]، وأضف متغير البيئة OLLAMA_ORIGINS:",
|
||||
"OllamaSetupGuide.cors.linux.reboot": "أعد تحميل systemd وأعد تشغيل Ollama",
|
||||
"OllamaSetupGuide.cors.linux.systemd": "استخدم systemd لتحرير خدمة ollama:",
|
||||
"OllamaSetupGuide.cors.macos": "يرجى فتح تطبيق «الطرفية» ولصق الأوامر التالية ثم الضغط على Enter للتنفيذ",
|
||||
"OllamaSetupGuide.cors.reboot": "يرجى إعادة تشغيل خدمة Ollama بعد الانتهاء من التنفيذ",
|
||||
"OllamaSetupGuide.cors.title": "تكوين Ollama للسماح بالوصول عبر النطاقات المتعددة",
|
||||
"OllamaSetupGuide.cors.windows": "على نظام Windows، انقر على «لوحة التحكم»، ثم انتقل إلى تحرير متغيرات البيئة للنظام. أنشئ متغير بيئة جديد باسم «OLLAMA_ORIGINS» لقائمة المستخدم الخاصة بك، وقيمته هي *، ثم انقر على «موافق/تطبيق» لحفظ التغييرات.",
|
||||
"OllamaSetupGuide.install.description": "يرجى التأكد من أنك قد قمت بتشغيل Ollama، إذا لم تقم بتنزيل Ollama، يرجى زيارة الموقع الرسمي <1>للتنزيل</1>",
|
||||
"OllamaSetupGuide.install.docker": "إذا كنت تفضل استخدام Docker، فإن Ollama يوفر أيضًا صورة Docker رسمية، يمكنك سحبها باستخدام الأمر التالي:",
|
||||
"OllamaSetupGuide.install.linux.command": "قم بتثبيت باستخدام الأمر التالي:",
|
||||
"OllamaSetupGuide.install.linux.manual": "أو يمكنك الرجوع إلى <1>دليل التثبيت اليدوي لنظام Linux</1> للتثبيت بنفسك.",
|
||||
"OllamaSetupGuide.install.title": "تثبيت وتشغيل تطبيق Ollama محليًا",
|
||||
"OllamaSetupGuide.install.windowsTab": "Windows (نسخة المعاينة)",
|
||||
"Thinking.thinking": "في حالة تفكير عميق...",
|
||||
"Thinking.thought": "لقد فكرت بعمق (استغرق الأمر {{duration}} ثانية)",
|
||||
"Thinking.thoughtWithDuration": "لقد فكرت بعمق",
|
||||
"devTools.cache.empty": "ذاكرة التخزين المؤقت فارغة",
|
||||
"devTools.metadata.empty": "لا توجد بيانات وصفية حالياً",
|
||||
"knowledgeBase.empty.description": "أنشئ قاعدة موارد لتنظيم وإدارة ملفاتك",
|
||||
"knowledgeBase.empty.search": "لم يتم العثور على قاعدة موارد مطابقة",
|
||||
"knowledgeBase.empty.title": "لا توجد قواعد موارد حالياً"
|
||||
}
|
||||
|
||||
+442
-683
File diff suppressed because it is too large
Load Diff
+54
-58
@@ -1,62 +1,58 @@
|
||||
{
|
||||
"actions": {
|
||||
"expand": {
|
||||
"off": "طي",
|
||||
"on": "توسيع"
|
||||
},
|
||||
"typobar": {
|
||||
"off": "إخفاء شريط أدوات التنسيق",
|
||||
"on": "إظهار شريط أدوات التنسيق"
|
||||
}
|
||||
},
|
||||
"actions.expand.off": "طي",
|
||||
"actions.expand.on": "توسيع",
|
||||
"actions.typobar.off": "إخفاء شريط أدوات التنسيق",
|
||||
"actions.typobar.on": "إظهار شريط أدوات التنسيق",
|
||||
"autoSave.latest": "تم تحميل أحدث إصدار",
|
||||
"autoSave.saved": "تم الحفظ",
|
||||
"autoSave.saving": "يتم الحفظ تلقائيًا...",
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "تأكيد",
|
||||
"file": {
|
||||
"error": "خطأ: {{message}}",
|
||||
"uploading": "جاري رفع الملف..."
|
||||
},
|
||||
"image": {
|
||||
"broken": "الصورة تالفة"
|
||||
},
|
||||
"link": {
|
||||
"edit": "تعديل الرابط",
|
||||
"open": "فتح الرابط",
|
||||
"placeholder": "أدخل عنوان URL للرابط",
|
||||
"unlink": "إزالة الرابط"
|
||||
},
|
||||
"math": {
|
||||
"placeholder": "يرجى إدخال معادلة TeX"
|
||||
},
|
||||
"slash": {
|
||||
"h1": "عنوان رئيسي من المستوى الأول",
|
||||
"h2": "عنوان فرعي من المستوى الثاني",
|
||||
"h3": "عنوان فرعي من المستوى الثالث",
|
||||
"hr": "خط فاصل",
|
||||
"table": "جدول",
|
||||
"tex": "معادلة TeX"
|
||||
},
|
||||
"table": {
|
||||
"delete": "حذف الجدول",
|
||||
"deleteColumn": "حذف العمود",
|
||||
"deleteRow": "حذف الصف",
|
||||
"insertColumnLeft": "إدراج {{count}} عمودًا إلى اليسار",
|
||||
"insertColumnRight": "إدراج {{count}} عمودًا إلى اليمين",
|
||||
"insertRowAbove": "إدراج {{count}} صفًا في الأعلى",
|
||||
"insertRowBelow": "إدراج {{count}} صفًا في الأسفل"
|
||||
},
|
||||
"typobar": {
|
||||
"blockquote": "اقتباس",
|
||||
"bold": "غامق",
|
||||
"bulletList": "قائمة نقطية",
|
||||
"code": "كود مضمن",
|
||||
"codeblock": "كتلة كود",
|
||||
"italic": "مائل",
|
||||
"link": "رابط",
|
||||
"numberList": "قائمة مرقمة",
|
||||
"strikethrough": "شطب",
|
||||
"table": "جدول",
|
||||
"taskList": "قائمة المهام",
|
||||
"tex": "معادلة TeX",
|
||||
"underline": "تسطير"
|
||||
}
|
||||
"file.error": "خطأ: {{message}}",
|
||||
"file.uploading": "جاري رفع الملف...",
|
||||
"image.broken": "الصورة تالفة",
|
||||
"link.edit": "تعديل الرابط",
|
||||
"link.editLinkTitle": "الرابط",
|
||||
"link.editTextTitle": "العنوان",
|
||||
"link.open": "فتح الرابط",
|
||||
"link.placeholder": "أدخل عنوان URL للرابط",
|
||||
"link.unlink": "إزالة الرابط",
|
||||
"markdown.cancel": "إلغاء",
|
||||
"markdown.confirm": "تحويل",
|
||||
"markdown.parseMessage": "سيتم تحويل المحتوى إلى تنسيق Markdown، وسيتم استبدال المحتوى الحالي. هل ترغب في المتابعة؟ (سيُغلق تلقائيًا بعد 5 ثوانٍ)",
|
||||
"markdown.parseTitle": "تنسيق Markdown",
|
||||
"math.placeholder": "يرجى إدخال معادلة TeX",
|
||||
"modifier.accept": "احتفاظ",
|
||||
"modifier.acceptAll": "قبول الكل",
|
||||
"modifier.reject": "إلغاء",
|
||||
"modifier.rejectedAll": "رفض الكل",
|
||||
"slash.h1": "عنوان رئيسي من المستوى الأول",
|
||||
"slash.h2": "عنوان فرعي من المستوى الثاني",
|
||||
"slash.h3": "عنوان فرعي من المستوى الثالث",
|
||||
"slash.hr": "خط فاصل",
|
||||
"slash.table": "جدول",
|
||||
"slash.tex": "معادلة TeX",
|
||||
"table.delete": "حذف الجدول",
|
||||
"table.deleteColumn": "حذف العمود",
|
||||
"table.deleteRow": "حذف الصف",
|
||||
"table.insertColumnLeft": "إدراج {{count}} عمودًا إلى اليسار",
|
||||
"table.insertColumnRight": "إدراج {{count}} عمودًا إلى اليمين",
|
||||
"table.insertRowAbove": "إدراج {{count}} صفًا في الأعلى",
|
||||
"table.insertRowBelow": "إدراج {{count}} صفًا في الأسفل",
|
||||
"typobar.blockquote": "اقتباس",
|
||||
"typobar.bold": "غامق",
|
||||
"typobar.bulletList": "قائمة نقطية",
|
||||
"typobar.code": "كود مضمن",
|
||||
"typobar.codeblock": "كتلة كود",
|
||||
"typobar.image": "صورة",
|
||||
"typobar.italic": "مائل",
|
||||
"typobar.link": "رابط",
|
||||
"typobar.numberList": "قائمة مرقمة",
|
||||
"typobar.redo": "إعادة",
|
||||
"typobar.strikethrough": "شطب",
|
||||
"typobar.table": "جدول",
|
||||
"typobar.taskList": "قائمة المهام",
|
||||
"typobar.tex": "معادلة TeX",
|
||||
"typobar.underline": "تسطير",
|
||||
"typobar.undo": "تراجع"
|
||||
}
|
||||
|
||||
+90
-112
@@ -1,114 +1,92 @@
|
||||
{
|
||||
"notification": {
|
||||
"finishChatGeneration": "تم إنشاء رسالة الذكاء الاصطناعي بنجاح"
|
||||
},
|
||||
"proxy": {
|
||||
"auth": "يتطلب المصادقة",
|
||||
"authDesc": "إذا كان خادم الوكيل يتطلب اسم مستخدم وكلمة مرور",
|
||||
"authSettings": "إعدادات المصادقة",
|
||||
"basicSettings": "إعدادات الوكيل",
|
||||
"basicSettingsDesc": "تكوين معلمات اتصال خادم الوكيل",
|
||||
"bypass": "العناوين التي لا تستخدم الوكيل",
|
||||
"connectionTest": "اختبار الاتصال",
|
||||
"enable": "تفعيل الوكيل",
|
||||
"enableDesc": "عند التفعيل، سيتم الوصول إلى الشبكة عبر خادم الوكيل",
|
||||
"password": "كلمة المرور",
|
||||
"password_placeholder": "الرجاء إدخال كلمة المرور",
|
||||
"port": "المنفذ",
|
||||
"resetButton": "إعادة تعيين",
|
||||
"saveButton": "حفظ",
|
||||
"saveFailed": "فشل الحفظ: {{error}}",
|
||||
"saveSuccess": "تم حفظ إعدادات الوكيل بنجاح",
|
||||
"server": "عنوان الخادم",
|
||||
"testButton": "اختبار الاتصال",
|
||||
"testDescription": "اختبر الاتصال باستخدام إعدادات الوكيل الحالية للتحقق من صحة التكوين",
|
||||
"testFailed": "فشل الاتصال",
|
||||
"testSuccessWithTime": "تم اختبار الاتصال بنجاح، استغرق {{time}} مللي ثانية",
|
||||
"testUrl": "عنوان الاختبار",
|
||||
"testUrlPlaceholder": "الرجاء إدخال عنوان URL للاختبار",
|
||||
"testing": "جارٍ اختبار الاتصال...",
|
||||
"type": "نوع الوكيل",
|
||||
"unsavedChanges": "لديك تغييرات غير محفوظة",
|
||||
"username": "اسم المستخدم",
|
||||
"username_placeholder": "الرجاء إدخال اسم المستخدم",
|
||||
"validation": {
|
||||
"passwordRequired": "كلمة المرور مطلوبة عند تفعيل المصادقة",
|
||||
"portInvalid": "يجب أن يكون المنفذ رقمًا بين 1 و 65535",
|
||||
"portRequired": "المنفذ مطلوب عند تفعيل الوكيل",
|
||||
"serverInvalid": "يرجى إدخال عنوان خادم صالح (IP أو اسم نطاق)",
|
||||
"serverRequired": "عنوان الخادم مطلوب عند تفعيل الوكيل",
|
||||
"typeRequired": "نوع الوكيل مطلوب عند تفعيل الوكيل",
|
||||
"usernameRequired": "اسم المستخدم مطلوب عند تفعيل المصادقة"
|
||||
}
|
||||
},
|
||||
"remoteServer": {
|
||||
"authError": "فشل التفويض: {{error}}",
|
||||
"authPending": "يرجى إكمال التفويض في المتصفح",
|
||||
"configDesc": "الاتصال بخادم LobeChat البعيد، تمكين مزامنة البيانات",
|
||||
"configError": "خطأ في التكوين",
|
||||
"configTitle": "تكوين المزامنة السحابية",
|
||||
"connect": "الاتصال والتفويض",
|
||||
"connected": "متصل",
|
||||
"disconnect": "قطع الاتصال",
|
||||
"disconnectError": "فشل في قطع الاتصال",
|
||||
"disconnected": "غير متصل",
|
||||
"fetchError": "فشل في جلب التكوين",
|
||||
"invalidUrl": "يرجى إدخال عنوان URL صالح",
|
||||
"serverUrl": "عنوان الخادم",
|
||||
"statusConnected": "متصل",
|
||||
"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": "تثبيت التحديث وإعادة التشغيل",
|
||||
"updateError": "خطأ في التحديث",
|
||||
"updateReady": "يتوفر إصدار جديد",
|
||||
"updateReadyDesc": "تم تنزيل الإصدار الجديد {{version}}، يمكنك إكمال التثبيت بعد إعادة تشغيل التطبيق.",
|
||||
"upgradeNow": "تحديث الآن",
|
||||
"willInstallLater": "سيتم تثبيت التحديث عند بدء التشغيل التالي"
|
||||
},
|
||||
"waitingOAuth": {
|
||||
"cancel": "إلغاء",
|
||||
"description": "تم فتح صفحة التفويض في المتصفح، يرجى إكمال التفويض في المتصفح",
|
||||
"error": "فشل التفويض: {{error}}",
|
||||
"errorTitle": "فشل في الاتصال بالتفويض",
|
||||
"helpText": "إذا لم يفتح المتصفح تلقائيًا، يرجى النقر على إلغاء ثم المحاولة مرة أخرى",
|
||||
"retry": "أعد المحاولة",
|
||||
"title": "انتظار الاتصال بالتفويض"
|
||||
}
|
||||
"notification.finishChatGeneration": "تم إنشاء رسالة الذكاء الاصطناعي بنجاح",
|
||||
"proxy.auth": "يتطلب المصادقة",
|
||||
"proxy.authDesc": "إذا كان خادم الوكيل يتطلب اسم مستخدم وكلمة مرور",
|
||||
"proxy.authSettings": "إعدادات المصادقة",
|
||||
"proxy.basicSettings": "إعدادات الوكيل",
|
||||
"proxy.basicSettingsDesc": "تكوين معلمات اتصال خادم الوكيل",
|
||||
"proxy.bypass": "العناوين التي لا تستخدم الوكيل",
|
||||
"proxy.connectionTest": "اختبار الاتصال",
|
||||
"proxy.enable": "تفعيل الوكيل",
|
||||
"proxy.enableDesc": "عند التفعيل، سيتم الوصول إلى الشبكة عبر خادم الوكيل",
|
||||
"proxy.password": "كلمة المرور",
|
||||
"proxy.password_placeholder": "الرجاء إدخال كلمة المرور",
|
||||
"proxy.port": "المنفذ",
|
||||
"proxy.resetButton": "إعادة تعيين",
|
||||
"proxy.saveButton": "حفظ",
|
||||
"proxy.saveFailed": "فشل الحفظ: {{error}}",
|
||||
"proxy.saveSuccess": "تم حفظ إعدادات الوكيل بنجاح",
|
||||
"proxy.server": "عنوان الخادم",
|
||||
"proxy.testButton": "اختبار الاتصال",
|
||||
"proxy.testDescription": "اختبر الاتصال باستخدام إعدادات الوكيل الحالية للتحقق من صحة التكوين",
|
||||
"proxy.testFailed": "فشل الاتصال",
|
||||
"proxy.testSuccessWithTime": "تم اختبار الاتصال بنجاح، استغرق {{time}} مللي ثانية",
|
||||
"proxy.testUrl": "عنوان الاختبار",
|
||||
"proxy.testUrlPlaceholder": "الرجاء إدخال عنوان URL للاختبار",
|
||||
"proxy.testing": "جارٍ اختبار الاتصال...",
|
||||
"proxy.type": "نوع الوكيل",
|
||||
"proxy.unsavedChanges": "لديك تغييرات غير محفوظة",
|
||||
"proxy.username": "اسم المستخدم",
|
||||
"proxy.username_placeholder": "الرجاء إدخال اسم المستخدم",
|
||||
"proxy.validation.passwordRequired": "كلمة المرور مطلوبة عند تفعيل المصادقة",
|
||||
"proxy.validation.portInvalid": "يجب أن يكون المنفذ رقمًا بين 1 و 65535",
|
||||
"proxy.validation.portRequired": "المنفذ مطلوب عند تفعيل الوكيل",
|
||||
"proxy.validation.serverInvalid": "يرجى إدخال عنوان خادم صالح (IP أو اسم نطاق)",
|
||||
"proxy.validation.serverRequired": "عنوان الخادم مطلوب عند تفعيل الوكيل",
|
||||
"proxy.validation.typeRequired": "نوع الوكيل مطلوب عند تفعيل الوكيل",
|
||||
"proxy.validation.usernameRequired": "اسم المستخدم مطلوب عند تفعيل المصادقة",
|
||||
"remoteServer.authError": "فشل التفويض: {{error}}",
|
||||
"remoteServer.authPending": "يرجى إكمال التفويض في المتصفح",
|
||||
"remoteServer.configDesc": "الاتصال بخادم LobeChat البعيد، تمكين مزامنة البيانات",
|
||||
"remoteServer.configError": "خطأ في التكوين",
|
||||
"remoteServer.configTitle": "تكوين المزامنة السحابية",
|
||||
"remoteServer.connect": "الاتصال والتفويض",
|
||||
"remoteServer.connected": "متصل",
|
||||
"remoteServer.disconnect": "قطع الاتصال",
|
||||
"remoteServer.disconnectError": "فشل في قطع الاتصال",
|
||||
"remoteServer.disconnected": "غير متصل",
|
||||
"remoteServer.fetchError": "فشل في جلب التكوين",
|
||||
"remoteServer.invalidUrl": "يرجى إدخال عنوان URL صالح",
|
||||
"remoteServer.serverUrl": "عنوان الخادم",
|
||||
"remoteServer.statusConnected": "متصل",
|
||||
"remoteServer.statusDisconnected": "غير متصل",
|
||||
"remoteServer.urlRequired": "يرجى إدخال عنوان الخادم",
|
||||
"sync.continue": "استمر",
|
||||
"sync.inCloud": "تستخدم حاليًا المزامنة السحابية",
|
||||
"sync.inLocalStorage": "تستخدم حاليًا التخزين المحلي",
|
||||
"sync.isIniting": "جارٍ التهيئة...",
|
||||
"sync.lobehubCloud.description": "الإصدار السحابي المقدم رسميًا",
|
||||
"sync.lobehubCloud.title": "LobeHub Cloud",
|
||||
"sync.local.description": "استخدام قاعدة بيانات محلية، متاحة بالكامل دون اتصال",
|
||||
"sync.local.title": "قاعدة بيانات محلية",
|
||||
"sync.mode.cloudSync": "مزامنة سحابية",
|
||||
"sync.mode.localStorage": "تخزين محلي",
|
||||
"sync.mode.title": "اختر وضع الاتصال الخاص بك",
|
||||
"sync.mode.useSelfHosted": "استخدام نسخة مستضافة ذاتيًا؟",
|
||||
"sync.selfHosted.description": "نسخة المجتمع التي تم نشرها ذاتيًا",
|
||||
"sync.selfHosted.title": "نسخة مستضافة ذاتيًا",
|
||||
"updater.checkingUpdate": "التحقق من وجود تحديثات",
|
||||
"updater.checkingUpdateDesc": "جارٍ الحصول على معلومات الإصدار...",
|
||||
"updater.downloadNewVersion": "تحميل إصدار جديد",
|
||||
"updater.downloadingUpdate": "جارٍ تنزيل التحديث",
|
||||
"updater.downloadingUpdateDesc": "يتم تنزيل التحديث، يرجى الانتظار...",
|
||||
"updater.installLater": "تحديث عند بدء التشغيل التالي",
|
||||
"updater.isLatestVersion": "الإصدار الحالي هو الأحدث",
|
||||
"updater.isLatestVersionDesc": "رائع، الإصدار {{version}} الذي تستخدمه هو أحدث إصدار متاح.",
|
||||
"updater.later": "لاحقًا",
|
||||
"updater.newVersionAvailable": "يتوفر إصدار جديد",
|
||||
"updater.newVersionAvailableDesc": "تم العثور على إصدار جديد {{version}}، هل ترغب في التنزيل الآن؟",
|
||||
"updater.restartAndInstall": "تثبيت التحديث وإعادة التشغيل",
|
||||
"updater.updateError": "خطأ في التحديث",
|
||||
"updater.updateReady": "يتوفر إصدار جديد",
|
||||
"updater.updateReadyDesc": "تم تنزيل الإصدار الجديد {{version}}، يمكنك إكمال التثبيت بعد إعادة تشغيل التطبيق.",
|
||||
"updater.upgradeNow": "تحديث الآن",
|
||||
"updater.willInstallLater": "سيتم تثبيت التحديث عند بدء التشغيل التالي",
|
||||
"waitingOAuth.cancel": "إلغاء",
|
||||
"waitingOAuth.description": "تم فتح صفحة التفويض في المتصفح، يرجى إكمال التفويض في المتصفح",
|
||||
"waitingOAuth.error": "فشل التفويض: {{error}}",
|
||||
"waitingOAuth.errorTitle": "فشل في الاتصال بالتفويض",
|
||||
"waitingOAuth.helpText": "إذا لم يفتح المتصفح تلقائيًا، يرجى النقر على إلغاء ثم المحاولة مرة أخرى",
|
||||
"waitingOAuth.retry": "أعد المحاولة",
|
||||
"waitingOAuth.title": "انتظار الاتصال بالتفويض"
|
||||
}
|
||||
|
||||
+144
-186
@@ -1,189 +1,147 @@
|
||||
{
|
||||
"clerkAuth": {
|
||||
"loginSuccess": {
|
||||
"action": "استمر في الجلسة",
|
||||
"desc": "{{greeting}}، يسعدني أن أواصل خدمتك. دعنا نواصل الحديث عن الموضوع الذي تحدثنا عنه مؤخرًا",
|
||||
"title": "مرحبًا بعودتك، {{nickName}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"backHome": "العودة إلى الصفحة الرئيسية",
|
||||
"desc": "حاول مرة أخرى في وقت لاحق، أو عد إلى العالم المألوف",
|
||||
"retry": "إعادة التحميل",
|
||||
"title": "واجهت الصفحة مشكلة ما.."
|
||||
},
|
||||
"fetchError": {
|
||||
"detail": "تفاصيل الخطأ",
|
||||
"title": "فشل الطلب"
|
||||
},
|
||||
"import": {
|
||||
"importConfigFile": {
|
||||
"description": "سبب الخطأ: {{reason}}",
|
||||
"title": "فشل الاستيراد"
|
||||
},
|
||||
"incompatible": {
|
||||
"description": "تم تصدير هذا الملف من إصدار أعلى، يرجى محاولة الترقية إلى أحدث إصدار ثم إعادة الاستيراد",
|
||||
"title": "التطبيق الحالي لا يدعم استيراد هذا الملف"
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"desc": "سيتم التحويل تلقائيًا إلى صفحة تسجيل الدخول",
|
||||
"title": "يرجى تسجيل الدخول لاستخدام هذه الميزة"
|
||||
},
|
||||
"notFound": {
|
||||
"backHome": "العودة إلى الصفحة الرئيسية",
|
||||
"check": "يرجى التحقق من صحة عنوان URL الخاص بك",
|
||||
"desc": "لم نتمكن من العثور على الصفحة التي تبحث عنها",
|
||||
"title": "هل دخلت إلى مجال غير معروف؟"
|
||||
},
|
||||
"pluginSettings": {
|
||||
"desc": "أكمل الإعدادات التالية لبدء استخدام هذا المكون الإضافي",
|
||||
"title": "تكوين مكون الإضافة {{name}}"
|
||||
},
|
||||
"response": {
|
||||
"400": "عذرًا، الخادم غير قادر على فهم طلبك، يرجى التحقق من صحة معلمات الطلب الخاصة بك",
|
||||
"401": "عذرًا، رفض الخادم طلبك، قد يكون بسبب صلاحياتك غير الكافية أو عدم تقديم التحقق من الهوية الصالحة",
|
||||
"403": "عذرًا، رفض الخادم طلبك، ليس لديك إذن للوصول إلى هذا المحتوى",
|
||||
"404": "عذرًا، الخادم لا يمكنه العثور على الصفحة أو المورد المطلوب، يرجى التحقق من صحة عنوان URL الخاص بك",
|
||||
"405": "عذرًا، الخادم لا يدعم طريقة الطلب المستخدمة، يرجى التحقق من صحة طريقة الطلب الخاصة بك",
|
||||
"406": "عذرًا، الخادم غير قادر على استكمال الطلب وفقًا لخصائص المحتوى التي طلبتها",
|
||||
"407": "عذرًا، تحتاج إلى مصادقة الوكيل لمتابعة هذا الطلب",
|
||||
"408": "عذرًا، تجاوز الخادم الوقت المحدد في انتظار الطلب، يرجى التحقق من اتصالك بالشبكة والمحاولة مرة أخرى",
|
||||
"409": "عذرًا، يوجد تضارب في الطلب الذي لا يمكن معالجته، قد يكون بسبب عدم توافق حالة المورد مع الطلب",
|
||||
"410": "عذرًا، تمت إزالة المورد الذي طلبته بشكل دائم ولا يمكن العثور عليه",
|
||||
"411": "عذرًا، الخادم غير قادر على معالجة الطلب الذي لا يحتوي على طول محتوى صالح",
|
||||
"412": "عذرًا، لم يتم تلبية شروط الخادم الجانبية لطلبك ولا يمكن استكمال الطلب",
|
||||
"413": "عذرًا، حجم بيانات طلبك كبير جدًا والخادم غير قادر على معالجته",
|
||||
"414": "عذرًا، طول عنوان URI الخاص بطلبك كبير جدًا والخادم غير قادر على معالجته",
|
||||
"415": "عذرًا، الخادم غير قادر على معالجة تنسيق الوسائط المرفقة بالطلب",
|
||||
"416": "عذرًا، الخادم غير قادر على تلبية نطاق الطلب الذي قدمته",
|
||||
"417": "عذرًا، الخادم غير قادر على تلبية قيم توقعاتك",
|
||||
"422": "عذرًا، الطلب لديه تنسيق صحيح، ولكن بسبب وجود أخطاء دلالية، لا يمكن الاستجابة",
|
||||
"423": "عذرًا، تم قفل المورد الذي طلبته",
|
||||
"424": "عذرًا، بسبب فشل الطلب السابق، لا يمكن استكمال الطلب الحالي",
|
||||
"426": "عذرًا، يتطلب الخادم ترقية عميلك إلى إصدار بروتوكول أعلى",
|
||||
"428": "عذرًا، يتطلب الخادم شروطًا مسبقة، ويجب أن يحتوي طلبك على رؤوس الشروط الصحيحة",
|
||||
"429": "عذرًا، طلبك كثير جدًا والخادم متعب قليلاً، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"431": "عذرًا، حقول رأس الطلب الخاصة بك كبيرة جدًا والخادم غير قادر على معالجتها",
|
||||
"451": "عذرًا، بسبب الأسباب القانونية، يرفض الخادم توفير هذا المورد",
|
||||
"499": "نعتذر، تم قطع طلبك بشكل غير متوقع أثناء معالجته على الخادم، قد يكون ذلك بسبب إلغاء العملية من قبلك أو بسبب عدم استقرار الاتصال بالشبكة. يرجى التحقق من حالة الشبكة ثم إعادة المحاولة.",
|
||||
"500": "عذرًا، يبدو أن الخادم واجه بعض الصعوبات ولا يمكنه حاليًا استكمال طلبك، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"501": "عذرًا، لا يعرف الخادم كيفية معالجة هذا الطلب، يرجى التأكد من صحة العملية الخاصة بك",
|
||||
"502": "عذرًا، يبدو أن الخادم قد ضل الطريق ولا يمكنه حاليًا تقديم الخدمة، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"503": "عذرًا، الخادم غير قادر حاليًا على معالجة طلبك، قد يكون بسبب الحمل الزائد أو الصيانة الجارية، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"504": "عذرًا، الخادم لم ينتظر ردًا من الخادم الأصلي، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"505": "عذرًا، لا يدعم الخادم إصدار HTTP الذي تستخدمه، يرجى التحديث والمحاولة مرة أخرى",
|
||||
"506": "عذرًا، هناك مشكلة في تكوين الخادم، يرجى الاتصال بالمسؤول لحلها",
|
||||
"507": "عذرًا، لا يوجد مساحة تخزين كافية على الخادم لمعالجة طلبك، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"509": "عذرًا، لقد استنفد الخادم النطاق الترددي، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"510": "عذرًا، لا يدعم الخادم الوظائف الإضافية المطلوبة، يرجى الاتصال بالمسؤول",
|
||||
"520": "نعتذر، واجه الخادم مشكلة غير متوقعة، مما أدى إلى عدم القدرة على إكمال طلبك. يرجى المحاولة لاحقًا، نحن نعمل على حل هذه المشكلة.",
|
||||
"522": "نعتذر، انتهت مهلة الاتصال بالخادم، ولم يتمكن من الاستجابة لطلبك في الوقت المناسب. قد يكون ذلك بسبب عدم استقرار الشبكة أو أن الخادم غير متاح مؤقتًا. يرجى المحاولة لاحقًا، نحن نبذل جهدًا لاستعادة الخدمة.",
|
||||
"524": "نعتذر، انتهت مهلة الخادم أثناء انتظار الرد، قد يكون ذلك بسبب بطء الاستجابة، يرجى المحاولة لاحقًا.",
|
||||
"AgentRuntimeError": "حدث خطأ في تشغيل نموذج Lobe اللغوي، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"ComfyUIBizError": "حدث خطأ أثناء طلب خدمة ComfyUI، يرجى التحقق من المعلومات التالية أو المحاولة مرة أخرى",
|
||||
"ComfyUIEmptyResult": "لم يتم إنشاء أي صورة من قبل ComfyUI، يرجى التحقق من إعدادات النموذج أو المحاولة مرة أخرى",
|
||||
"ComfyUIModelError": "فشل تحميل نموذج ComfyUI، يرجى التحقق من وجود ملف النموذج",
|
||||
"ComfyUIServiceUnavailable": "فشل الاتصال بخدمة ComfyUI، يرجى التأكد من أن ComfyUI يعمل بشكل صحيح أو التحقق من صحة عنوان الخدمة",
|
||||
"ComfyUIUploadFailed": "فشل تحميل الصورة إلى ComfyUI، يرجى التحقق من الاتصال بالخادم أو المحاولة مرة أخرى",
|
||||
"ComfyUIWorkflowError": "فشل تنفيذ سير العمل في ComfyUI، يرجى التحقق من إعدادات سير العمل",
|
||||
"ConnectionCheckFailed": "الاستجابة فارغة، يرجى التحقق من أن عنوان وكيل الـ API لا ينتهي بـ `/v1`",
|
||||
"CreateMessageError": "عذرًا، لم يتم إرسال الرسالة بشكل صحيح، يرجى نسخ المحتوى وإعادة إرساله، بعد تحديث الصفحة لن يتم الاحتفاظ بهذه الرسالة",
|
||||
"ExceededContextWindow": "المحتوى المطلوب الحالي يتجاوز الطول الذي يمكن للنموذج معالجته، يرجى تقليل كمية المحتوى ثم إعادة المحاولة",
|
||||
"FreePlanLimit": "أنت حاليًا مستخدم مجاني، لا يمكنك استخدام هذه الوظيفة، يرجى الترقية إلى خطة مدفوعة للمتابعة",
|
||||
"GoogleAIBlockReason": {
|
||||
"BLOCKLIST": "يحتوي المحتوى الذي أرسلته على كلمات محظورة. يرجى مراجعته وتعديل مدخلاتك ثم المحاولة مرة أخرى.",
|
||||
"IMAGE_SAFETY": "تم حظر المحتوى الصوري الناتج لأسباب تتعلق بالأمان. يرجى محاولة تعديل طلب توليد الصورة.",
|
||||
"LANGUAGE": "اللغة التي تستخدمها غير مدعومة مؤقتًا. يرجى المحاولة باللغة الإنجليزية أو بلغة أخرى مدعومة.",
|
||||
"OTHER": "تم حظر المحتوى لسبب غير معروف. يرجى محاولة إعادة صياغة طلبك.",
|
||||
"PROHIBITED_CONTENT": "قد يحتوي طلبك على محتوى محظور. يرجى تعديل طلبك لضمان توافقه مع سياسات الاستخدام.",
|
||||
"RECITATION": "تم حظر محتواك لكونه قد ينتهك حقوق النشر. يرجى محاولة استخدام محتوى أصلي أو إعادة صياغة طلبك.",
|
||||
"SAFETY": "تم حظر المحتوى بسبب سياسات السلامة. يرجى تعديل طلبك لتجنب أي محتوى ضار أو غير مناسب.",
|
||||
"SPII": "قد يحتوي المحتوى على معلومات شخصية حساسة. لحماية الخصوصية، يرجى إزالة المعلومات الحساسة ثم المحاولة مرة أخرى.",
|
||||
"default": "تم حظر المحتوى: {{blockReason}}. يرجى تعديل طلبك ثم المحاولة مرة أخرى."
|
||||
},
|
||||
"InsufficientQuota": "عذرًا، لقد تم الوصول إلى الحد الأقصى لحصة المفتاح (quota). يرجى التحقق من رصيد الحساب أو زيادة حصة المفتاح ثم المحاولة مرة أخرى.",
|
||||
"InvalidAccessCode": "كلمة المرور غير صحيحة أو فارغة، يرجى إدخال كلمة مرور الوصول الصحيحة أو إضافة مفتاح API مخصص",
|
||||
"InvalidBedrockCredentials": "فشلت مصادقة Bedrock، يرجى التحقق من AccessKeyId/SecretAccessKey وإعادة المحاولة",
|
||||
"InvalidClerkUser": "عذرًا، لم تقم بتسجيل الدخول بعد، يرجى تسجيل الدخول أو التسجيل للمتابعة",
|
||||
"InvalidComfyUIArgs": "تكوين ComfyUI غير صحيح، يرجى التحقق من إعدادات ComfyUI ثم المحاولة مرة أخرى",
|
||||
"InvalidGithubToken": "رمز وصول شخصية GitHub غير صحيح أو فارغ، يرجى التحقق من رمز وصول GitHub الشخصي والمحاولة مرة أخرى",
|
||||
"InvalidOllamaArgs": "تكوين Ollama غير صحيح، يرجى التحقق من تكوين Ollama وإعادة المحاولة",
|
||||
"InvalidProviderAPIKey": "{{provider}} مفتاح API غير صحيح أو فارغ، يرجى التحقق من مفتاح API {{provider}} الخاص بك وحاول مرة أخرى",
|
||||
"InvalidVertexCredentials": "فشل التحقق من بيانات اعتماد Vertex، يرجى التحقق من بيانات الاعتماد وإعادة المحاولة",
|
||||
"LocationNotSupportError": "عذرًا، لا يدعم موقعك الحالي خدمة هذا النموذج، قد يكون ذلك بسبب قيود المنطقة أو عدم توفر الخدمة. يرجى التحقق مما إذا كان الموقع الحالي يدعم استخدام هذه الخدمة، أو محاولة استخدام معلومات الموقع الأخرى.",
|
||||
"ModelNotFound": "عذرًا، لا يمكن طلب النموذج المطلوب، قد يكون النموذج غير موجود أو أن الوصول غير مصرح به، يرجى تغيير مفتاح API أو تعديل أذونات الوصول ثم إعادة المحاولة",
|
||||
"NoOpenAIAPIKey": "مفتاح API الخاص بـ OpenAI فارغ، يرجى إضافة مفتاح API الخاص بـ OpenAI",
|
||||
"OllamaBizError": "خطأ في طلب خدمة Ollama، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"OllamaServiceUnavailable": "خدمة Ollama غير متوفرة، يرجى التحقق من تشغيل Ollama بشكل صحيح أو إعدادات الـ Ollama للاتصال عبر النطاقات",
|
||||
"PermissionDenied": "عذرًا، ليس لديك إذن للوصول إلى هذه الخدمة، يرجى التحقق مما إذا كانت مفاتيحك تمتلك إذن الوصول",
|
||||
"PluginApiNotFound": "عذرًا، لا يوجد API للإضافة في وصف الإضافة، يرجى التحقق من تطابق طريقة الطلب الخاصة بك مع API الوصف",
|
||||
"PluginApiParamsError": "عذرًا، فشلت التحقق من صحة معلمات الطلب للإضافة، يرجى التحقق من تطابق المعلمات مع معلومات الوصف",
|
||||
"PluginFailToTransformArguments": "عذرًا، فشل تحويل معلمات استدعاء الإضافة، يرجى محاولة إعادة إنشاء رسالة المساعد أو تجربة نموذج AI ذو قدرات استدعاء أقوى",
|
||||
"PluginGatewayError": "عذرًا، حدث خطأ في بوابة الإضافة، يرجى التحقق من تكوين بوابة الإضافة",
|
||||
"PluginManifestInvalid": "عذرًا، فشلت التحقق من صحة وصف الإضافة، يرجى التحقق من تنسيق وصف الإضافة",
|
||||
"PluginManifestNotFound": "عذرًا، لم يتم العثور على وصف الإضافة (manifest.json) في الخادم، يرجى التحقق من صحة عنوان ملف وصف الإضافة",
|
||||
"PluginMarketIndexInvalid": "عذرًا، فشلت التحقق من صحة فهرس الإضافات، يرجى التحقق من تنسيق ملف الفهرس",
|
||||
"PluginMarketIndexNotFound": "عذرًا، لم يتم العثور على فهرس الإضافات في الخادم، يرجى التحقق من صحة عنوان الفهرس",
|
||||
"PluginMetaInvalid": "عذرًا، فشلت التحقق من صحة بيانات الإضافة، يرجى التحقق من تنسيق بيانات الإضافة",
|
||||
"PluginMetaNotFound": "عذرًا، لم يتم العثور على معلومات تكوين الإضافة في الفهرس",
|
||||
"PluginOpenApiInitError": "عذرًا، فشل تهيئة عميل OpenAPI، يرجى التحقق من معلومات تكوين OpenAPI",
|
||||
"PluginServerError": "خطأ في استجابة الخادم لطلب الإضافة، يرجى التحقق من ملف وصف الإضافة وتكوين الإضافة وتنفيذ الخادم وفقًا لمعلومات الخطأ أدناه",
|
||||
"PluginSettingsInvalid": "تحتاج هذه الإضافة إلى تكوين صحيح قبل الاستخدام، يرجى التحقق من صحة تكوينك",
|
||||
"ProviderBizError": "طلب خدمة {{provider}} خاطئ، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"QuotaLimitReached": "عذرًا، لقد تم الوصول إلى الحد الأقصى لاستخدام الرموز (Token) أو عدد الطلبات لهذا المفتاح. يرجى زيادة حصة المفتاح أو المحاولة لاحقًا.",
|
||||
"StreamChunkError": "خطأ في تحليل كتلة الرسالة لطلب التدفق، يرجى التحقق مما إذا كانت واجهة برمجة التطبيقات الحالية تتوافق مع المعايير، أو الاتصال بمزود واجهة برمجة التطبيقات الخاصة بك للاستفسار.",
|
||||
"SubscriptionKeyMismatch": "نعتذر، بسبب عطل عرضي في النظام، فإن استخدام الاشتراك الحالي غير فعال مؤقتًا. يرجى النقر على الزر أدناه لاستعادة الاشتراك، أو مراسلتنا عبر البريد الإلكتروني للحصول على الدعم.",
|
||||
"SubscriptionPlanLimit": "لقد استنفدت نقاط اشتراكك، ولا يمكنك استخدام هذه الميزة. يرجى الترقية إلى خطة أعلى، أو تكوين واجهة برمجة التطبيقات للنموذج المخصص للاستمرار في الاستخدام",
|
||||
"SystemTimeNotMatchError": "عذرًا، وقت النظام لديك لا يتطابق مع الخادم، يرجى التحقق من وقت النظام لديك ثم إعادة المحاولة",
|
||||
"UnknownChatFetchError": "عذرًا، حدث خطأ غير معروف في الطلب، يرجى التحقق من المعلومات التالية أو المحاولة مرة أخرى"
|
||||
},
|
||||
"stt": {
|
||||
"responseError": "فشل طلب الخدمة، يرجى التحقق من الإعدادات أو إعادة المحاولة"
|
||||
},
|
||||
"supervisor": {
|
||||
"decisionFailed": "تعذر على مشرف المجموعة العمل. يرجى التحقق من إعدادات المشرف الخاصة بك، والتأكد من تكوين النموذج الصحيح، ومفتاح API، وعنوان API."
|
||||
},
|
||||
"clerkAuth.loginSuccess.action": "استمر في الجلسة",
|
||||
"clerkAuth.loginSuccess.desc": "{{greeting}}، يسعدني أن أواصل خدمتك. دعنا نواصل الحديث عن الموضوع الذي تحدثنا عنه مؤخرًا",
|
||||
"clerkAuth.loginSuccess.title": "مرحبًا بعودتك، {{nickName}}",
|
||||
"error.backHome": "العودة إلى الصفحة الرئيسية",
|
||||
"error.desc": "حاول مرة أخرى في وقت لاحق، أو عد إلى العالم المألوف",
|
||||
"error.retry": "إعادة التحميل",
|
||||
"error.title": "واجهت الصفحة مشكلة ما..",
|
||||
"fetchError.detail": "تفاصيل الخطأ",
|
||||
"fetchError.title": "فشل الطلب",
|
||||
"import.importConfigFile.description": "سبب الخطأ: {{reason}}",
|
||||
"import.importConfigFile.title": "فشل الاستيراد",
|
||||
"import.incompatible.description": "تم تصدير هذا الملف من إصدار أعلى، يرجى محاولة الترقية إلى أحدث إصدار ثم إعادة الاستيراد",
|
||||
"import.incompatible.title": "التطبيق الحالي لا يدعم استيراد هذا الملف",
|
||||
"loginRequired.desc": "سيتم التحويل تلقائيًا إلى صفحة تسجيل الدخول",
|
||||
"loginRequired.title": "يرجى تسجيل الدخول لاستخدام هذه الميزة",
|
||||
"notFound.backHome": "العودة إلى الصفحة الرئيسية",
|
||||
"notFound.check": "يرجى التحقق من صحة عنوان URL الخاص بك",
|
||||
"notFound.desc": "لم نتمكن من العثور على الصفحة التي تبحث عنها",
|
||||
"notFound.title": "هل دخلت إلى مجال غير معروف؟",
|
||||
"pluginSettings.desc": "أكمل الإعدادات التالية لبدء استخدام هذا المكون الإضافي",
|
||||
"pluginSettings.title": "تكوين مكون الإضافة {{name}}",
|
||||
"response.400": "عذرًا، الخادم غير قادر على فهم طلبك، يرجى التحقق من صحة معلمات الطلب الخاصة بك",
|
||||
"response.401": "عذرًا، رفض الخادم طلبك، قد يكون بسبب صلاحياتك غير الكافية أو عدم تقديم التحقق من الهوية الصالحة",
|
||||
"response.403": "عذرًا، رفض الخادم طلبك، ليس لديك إذن للوصول إلى هذا المحتوى",
|
||||
"response.404": "عذرًا، الخادم لا يمكنه العثور على الصفحة أو المورد المطلوب، يرجى التحقق من صحة عنوان URL الخاص بك",
|
||||
"response.405": "عذرًا، الخادم لا يدعم طريقة الطلب المستخدمة، يرجى التحقق من صحة طريقة الطلب الخاصة بك",
|
||||
"response.406": "عذرًا، الخادم غير قادر على استكمال الطلب وفقًا لخصائص المحتوى التي طلبتها",
|
||||
"response.407": "عذرًا، تحتاج إلى مصادقة الوكيل لمتابعة هذا الطلب",
|
||||
"response.408": "عذرًا، تجاوز الخادم الوقت المحدد في انتظار الطلب، يرجى التحقق من اتصالك بالشبكة والمحاولة مرة أخرى",
|
||||
"response.409": "عذرًا، يوجد تضارب في الطلب الذي لا يمكن معالجته، قد يكون بسبب عدم توافق حالة المورد مع الطلب",
|
||||
"response.410": "عذرًا، تمت إزالة المورد الذي طلبته بشكل دائم ولا يمكن العثور عليه",
|
||||
"response.411": "عذرًا، الخادم غير قادر على معالجة الطلب الذي لا يحتوي على طول محتوى صالح",
|
||||
"response.412": "عذرًا، لم يتم تلبية شروط الخادم الجانبية لطلبك ولا يمكن استكمال الطلب",
|
||||
"response.413": "عذرًا، حجم بيانات طلبك كبير جدًا والخادم غير قادر على معالجته",
|
||||
"response.414": "عذرًا، طول عنوان URI الخاص بطلبك كبير جدًا والخادم غير قادر على معالجته",
|
||||
"response.415": "عذرًا، الخادم غير قادر على معالجة تنسيق الوسائط المرفقة بالطلب",
|
||||
"response.416": "عذرًا، الخادم غير قادر على تلبية نطاق الطلب الذي قدمته",
|
||||
"response.417": "عذرًا، الخادم غير قادر على تلبية قيم توقعاتك",
|
||||
"response.422": "عذرًا، الطلب لديه تنسيق صحيح، ولكن بسبب وجود أخطاء دلالية، لا يمكن الاستجابة",
|
||||
"response.423": "عذرًا، تم قفل المورد الذي طلبته",
|
||||
"response.424": "عذرًا، بسبب فشل الطلب السابق، لا يمكن استكمال الطلب الحالي",
|
||||
"response.426": "عذرًا، يتطلب الخادم ترقية عميلك إلى إصدار بروتوكول أعلى",
|
||||
"response.428": "عذرًا، يتطلب الخادم شروطًا مسبقة، ويجب أن يحتوي طلبك على رؤوس الشروط الصحيحة",
|
||||
"response.429": "عذرًا، طلبك كثير جدًا والخادم متعب قليلاً، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.431": "عذرًا، حقول رأس الطلب الخاصة بك كبيرة جدًا والخادم غير قادر على معالجتها",
|
||||
"response.451": "عذرًا، بسبب الأسباب القانونية، يرفض الخادم توفير هذا المورد",
|
||||
"response.499": "نعتذر، تم قطع طلبك بشكل غير متوقع أثناء معالجته على الخادم، قد يكون ذلك بسبب إلغاء العملية من قبلك أو بسبب عدم استقرار الاتصال بالشبكة. يرجى التحقق من حالة الشبكة ثم إعادة المحاولة.",
|
||||
"response.500": "عذرًا، يبدو أن الخادم واجه بعض الصعوبات ولا يمكنه حاليًا استكمال طلبك، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.501": "عذرًا، لا يعرف الخادم كيفية معالجة هذا الطلب، يرجى التأكد من صحة العملية الخاصة بك",
|
||||
"response.502": "عذرًا، يبدو أن الخادم قد ضل الطريق ولا يمكنه حاليًا تقديم الخدمة، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.503": "عذرًا، الخادم غير قادر حاليًا على معالجة طلبك، قد يكون بسبب الحمل الزائد أو الصيانة الجارية، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.504": "عذرًا، الخادم لم ينتظر ردًا من الخادم الأصلي، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.505": "عذرًا، لا يدعم الخادم إصدار HTTP الذي تستخدمه، يرجى التحديث والمحاولة مرة أخرى",
|
||||
"response.506": "عذرًا، هناك مشكلة في تكوين الخادم، يرجى الاتصال بالمسؤول لحلها",
|
||||
"response.507": "عذرًا، لا يوجد مساحة تخزين كافية على الخادم لمعالجة طلبك، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.509": "عذرًا، لقد استنفد الخادم النطاق الترددي، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"response.510": "عذرًا، لا يدعم الخادم الوظائف الإضافية المطلوبة، يرجى الاتصال بالمسؤول",
|
||||
"response.520": "نعتذر، واجه الخادم مشكلة غير متوقعة، مما أدى إلى عدم القدرة على إكمال طلبك. يرجى المحاولة لاحقًا، نحن نعمل على حل هذه المشكلة.",
|
||||
"response.522": "نعتذر، انتهت مهلة الاتصال بالخادم، ولم يتمكن من الاستجابة لطلبك في الوقت المناسب. قد يكون ذلك بسبب عدم استقرار الشبكة أو أن الخادم غير متاح مؤقتًا. يرجى المحاولة لاحقًا، نحن نبذل جهدًا لاستعادة الخدمة.",
|
||||
"response.524": "نعتذر، انتهت مهلة الخادم أثناء انتظار الرد، قد يكون ذلك بسبب بطء الاستجابة، يرجى المحاولة لاحقًا.",
|
||||
"response.AgentRuntimeError": "حدث خطأ في تشغيل نموذج Lobe اللغوي، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"response.ComfyUIBizError": "حدث خطأ أثناء طلب خدمة ComfyUI، يرجى التحقق من المعلومات التالية أو المحاولة مرة أخرى",
|
||||
"response.ComfyUIEmptyResult": "لم يتم إنشاء أي صورة من قبل ComfyUI، يرجى التحقق من إعدادات النموذج أو المحاولة مرة أخرى",
|
||||
"response.ComfyUIModelError": "فشل تحميل نموذج ComfyUI، يرجى التحقق من وجود ملف النموذج",
|
||||
"response.ComfyUIServiceUnavailable": "فشل الاتصال بخدمة ComfyUI، يرجى التأكد من أن ComfyUI يعمل بشكل صحيح أو التحقق من صحة عنوان الخدمة",
|
||||
"response.ComfyUIUploadFailed": "فشل تحميل الصورة إلى ComfyUI، يرجى التحقق من الاتصال بالخادم أو المحاولة مرة أخرى",
|
||||
"response.ComfyUIWorkflowError": "فشل تنفيذ سير العمل في ComfyUI، يرجى التحقق من إعدادات سير العمل",
|
||||
"response.ConnectionCheckFailed": "الاستجابة فارغة، يرجى التحقق من أن عنوان وكيل الـ API لا ينتهي بـ `/v1`",
|
||||
"response.CreateMessageError": "عذرًا، لم يتم إرسال الرسالة بشكل صحيح، يرجى نسخ المحتوى وإعادة إرساله، بعد تحديث الصفحة لن يتم الاحتفاظ بهذه الرسالة",
|
||||
"response.ExceededContextWindow": "المحتوى المطلوب الحالي يتجاوز الطول الذي يمكن للنموذج معالجته، يرجى تقليل كمية المحتوى ثم إعادة المحاولة",
|
||||
"response.FreePlanLimit": "أنت حاليًا مستخدم مجاني، لا يمكنك استخدام هذه الوظيفة، يرجى الترقية إلى خطة مدفوعة للمتابعة",
|
||||
"response.GoogleAIBlockReason.BLOCKLIST": "يحتوي المحتوى الذي أرسلته على كلمات محظورة. يرجى مراجعته وتعديل مدخلاتك ثم المحاولة مرة أخرى.",
|
||||
"response.GoogleAIBlockReason.IMAGE_SAFETY": "تم حظر المحتوى الصوري الناتج لأسباب تتعلق بالأمان. يرجى محاولة تعديل طلب توليد الصورة.",
|
||||
"response.GoogleAIBlockReason.LANGUAGE": "اللغة التي تستخدمها غير مدعومة مؤقتًا. يرجى المحاولة باللغة الإنجليزية أو بلغة أخرى مدعومة.",
|
||||
"response.GoogleAIBlockReason.OTHER": "تم حظر المحتوى لسبب غير معروف. يرجى محاولة إعادة صياغة طلبك.",
|
||||
"response.GoogleAIBlockReason.PROHIBITED_CONTENT": "قد يحتوي طلبك على محتوى محظور. يرجى تعديل طلبك لضمان توافقه مع سياسات الاستخدام.",
|
||||
"response.GoogleAIBlockReason.RECITATION": "تم حظر محتواك لكونه قد ينتهك حقوق النشر. يرجى محاولة استخدام محتوى أصلي أو إعادة صياغة طلبك.",
|
||||
"response.GoogleAIBlockReason.SAFETY": "تم حظر المحتوى بسبب سياسات السلامة. يرجى تعديل طلبك لتجنب أي محتوى ضار أو غير مناسب.",
|
||||
"response.GoogleAIBlockReason.SPII": "قد يحتوي المحتوى على معلومات شخصية حساسة. لحماية الخصوصية، يرجى إزالة المعلومات الحساسة ثم المحاولة مرة أخرى.",
|
||||
"response.GoogleAIBlockReason.default": "تم حظر المحتوى: {{blockReason}}. يرجى تعديل طلبك ثم المحاولة مرة أخرى.",
|
||||
"response.InsufficientQuota": "عذرًا، لقد تم الوصول إلى الحد الأقصى لحصة المفتاح (quota). يرجى التحقق من رصيد الحساب أو زيادة حصة المفتاح ثم المحاولة مرة أخرى.",
|
||||
"response.InvalidAccessCode": "كلمة المرور غير صحيحة أو فارغة، يرجى إدخال كلمة مرور الوصول الصحيحة أو إضافة مفتاح API مخصص",
|
||||
"response.InvalidBedrockCredentials": "فشلت مصادقة Bedrock، يرجى التحقق من AccessKeyId/SecretAccessKey وإعادة المحاولة",
|
||||
"response.InvalidClerkUser": "عذرًا، لم تقم بتسجيل الدخول بعد، يرجى تسجيل الدخول أو التسجيل للمتابعة",
|
||||
"response.InvalidComfyUIArgs": "تكوين ComfyUI غير صحيح، يرجى التحقق من إعدادات ComfyUI ثم المحاولة مرة أخرى",
|
||||
"response.InvalidGithubToken": "رمز وصول شخصية GitHub غير صحيح أو فارغ، يرجى التحقق من رمز وصول GitHub الشخصي والمحاولة مرة أخرى",
|
||||
"response.InvalidOllamaArgs": "تكوين Ollama غير صحيح، يرجى التحقق من تكوين Ollama وإعادة المحاولة",
|
||||
"response.InvalidProviderAPIKey": "{{provider}} مفتاح API غير صحيح أو فارغ، يرجى التحقق من مفتاح API {{provider}} الخاص بك وحاول مرة أخرى",
|
||||
"response.InvalidVertexCredentials": "فشل التحقق من بيانات اعتماد Vertex، يرجى التحقق من بيانات الاعتماد وإعادة المحاولة",
|
||||
"response.LocationNotSupportError": "عذرًا، لا يدعم موقعك الحالي خدمة هذا النموذج، قد يكون ذلك بسبب قيود المنطقة أو عدم توفر الخدمة. يرجى التحقق مما إذا كان الموقع الحالي يدعم استخدام هذه الخدمة، أو محاولة استخدام معلومات الموقع الأخرى.",
|
||||
"response.ModelNotFound": "عذرًا، لا يمكن طلب النموذج المطلوب، قد يكون النموذج غير موجود أو أن الوصول غير مصرح به، يرجى تغيير مفتاح API أو تعديل أذونات الوصول ثم إعادة المحاولة",
|
||||
"response.NoOpenAIAPIKey": "مفتاح API الخاص بـ OpenAI فارغ، يرجى إضافة مفتاح API الخاص بـ OpenAI",
|
||||
"response.OllamaBizError": "خطأ في طلب خدمة Ollama، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"response.OllamaServiceUnavailable": "خدمة Ollama غير متوفرة، يرجى التحقق من تشغيل Ollama بشكل صحيح أو إعدادات الـ Ollama للاتصال عبر النطاقات",
|
||||
"response.PermissionDenied": "عذرًا، ليس لديك إذن للوصول إلى هذه الخدمة، يرجى التحقق مما إذا كانت مفاتيحك تمتلك إذن الوصول",
|
||||
"response.PluginApiNotFound": "عذرًا، لا يوجد API للإضافة في وصف الإضافة، يرجى التحقق من تطابق طريقة الطلب الخاصة بك مع API الوصف",
|
||||
"response.PluginApiParamsError": "عذرًا، فشلت التحقق من صحة معلمات الطلب للإضافة، يرجى التحقق من تطابق المعلمات مع معلومات الوصف",
|
||||
"response.PluginFailToTransformArguments": "عذرًا، فشل تحويل معلمات استدعاء الإضافة، يرجى محاولة إعادة إنشاء رسالة المساعد أو تجربة نموذج AI ذو قدرات استدعاء أقوى",
|
||||
"response.PluginGatewayError": "عذرًا، حدث خطأ في بوابة الإضافة، يرجى التحقق من تكوين بوابة الإضافة",
|
||||
"response.PluginManifestInvalid": "عذرًا، فشلت التحقق من صحة وصف الإضافة، يرجى التحقق من تنسيق وصف الإضافة",
|
||||
"response.PluginManifestNotFound": "عذرًا، لم يتم العثور على وصف الإضافة (manifest.json) في الخادم، يرجى التحقق من صحة عنوان ملف وصف الإضافة",
|
||||
"response.PluginMarketIndexInvalid": "عذرًا، فشلت التحقق من صحة فهرس الإضافات، يرجى التحقق من تنسيق ملف الفهرس",
|
||||
"response.PluginMarketIndexNotFound": "عذرًا، لم يتم العثور على فهرس الإضافات في الخادم، يرجى التحقق من صحة عنوان الفهرس",
|
||||
"response.PluginMetaInvalid": "عذرًا، فشلت التحقق من صحة بيانات الإضافة، يرجى التحقق من تنسيق بيانات الإضافة",
|
||||
"response.PluginMetaNotFound": "عذرًا، لم يتم العثور على معلومات تكوين الإضافة في الفهرس",
|
||||
"response.PluginOpenApiInitError": "عذرًا، فشل تهيئة عميل OpenAPI، يرجى التحقق من معلومات تكوين OpenAPI",
|
||||
"response.PluginServerError": "خطأ في استجابة الخادم لطلب الإضافة، يرجى التحقق من ملف وصف الإضافة وتكوين الإضافة وتنفيذ الخادم وفقًا لمعلومات الخطأ أدناه",
|
||||
"response.PluginSettingsInvalid": "تحتاج هذه الإضافة إلى تكوين صحيح قبل الاستخدام، يرجى التحقق من صحة تكوينك",
|
||||
"response.ProviderBizError": "طلب خدمة {{provider}} خاطئ، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"response.QuotaLimitReached": "عذرًا، لقد تم الوصول إلى الحد الأقصى لاستخدام الرموز (Token) أو عدد الطلبات لهذا المفتاح. يرجى زيادة حصة المفتاح أو المحاولة لاحقًا.",
|
||||
"response.ServerAgentRuntimeError": "عذرًا، خدمة الوكيل غير متاحة حاليًا. يرجى المحاولة لاحقًا أو التواصل معنا عبر البريد الإلكتروني للحصول على الدعم.",
|
||||
"response.StreamChunkError": "خطأ في تحليل كتلة الرسالة لطلب التدفق، يرجى التحقق مما إذا كانت واجهة برمجة التطبيقات الحالية تتوافق مع المعايير، أو الاتصال بمزود واجهة برمجة التطبيقات الخاصة بك للاستفسار.",
|
||||
"response.SubscriptionKeyMismatch": "نعتذر، بسبب عطل عرضي في النظام، فإن استخدام الاشتراك الحالي غير فعال مؤقتًا. يرجى النقر على الزر أدناه لاستعادة الاشتراك، أو مراسلتنا عبر البريد الإلكتروني للحصول على الدعم.",
|
||||
"response.SubscriptionPlanLimit": "لقد استنفدت نقاط اشتراكك، ولا يمكنك استخدام هذه الميزة. يرجى الترقية إلى خطة أعلى، أو تكوين واجهة برمجة التطبيقات للنموذج المخصص للاستمرار في الاستخدام",
|
||||
"response.SystemTimeNotMatchError": "عذرًا، وقت النظام لديك لا يتطابق مع الخادم، يرجى التحقق من وقت النظام لديك ثم إعادة المحاولة",
|
||||
"response.UnknownChatFetchError": "عذرًا، حدث خطأ غير معروف في الطلب، يرجى التحقق من المعلومات التالية أو المحاولة مرة أخرى",
|
||||
"stt.responseError": "فشل طلب الخدمة، يرجى التحقق من الإعدادات أو إعادة المحاولة",
|
||||
"supervisor.decisionFailed": "تعذر على مشرف المجموعة العمل. يرجى التحقق من إعدادات المشرف الخاصة بك، والتأكد من تكوين النموذج الصحيح، ومفتاح API، وعنوان API.",
|
||||
"testConnectionFailed": "فشل اختبار الاتصال: {{error}}",
|
||||
"tts": {
|
||||
"responseError": "فشل طلب الخدمة، يرجى التحقق من الإعدادات أو إعادة المحاولة"
|
||||
},
|
||||
"unlock": {
|
||||
"addProxyUrl": "إضافة عنوان وكيل OpenAI (اختياري)",
|
||||
"apiKey": {
|
||||
"description": "يمكنك بدء الجلسة عن طريق إدخال مفتاح API {{name}} الخاص بك",
|
||||
"imageGenerationDescription": "أدخل مفتاح API الخاص بـ {{name}} للبدء في التوليد",
|
||||
"title": "استخدام مفتاح API {{name}} المخصص"
|
||||
},
|
||||
"closeMessage": "إغلاق الرسالة",
|
||||
"comfyui": {
|
||||
"description": "يرجى إدخال معلومات المصادقة الصحيحة لـ {{name}} للبدء في إنشاء الصور",
|
||||
"modifyBaseUrl": "تعديل عنوان خدمة Comfy UI",
|
||||
"title": "تأكيد معلومات المصادقة الخاصة بـ {{name}}"
|
||||
},
|
||||
"confirm": "تأكيد وإعادة المحاولة",
|
||||
"oauth": {
|
||||
"description": "فتح المسؤول توثيق تسجيل الدخول الموحد، انقر فوق الزر أدناه لتسجيل الدخول وفتح التطبيق",
|
||||
"success": "تم تسجيل الدخول بنجاح",
|
||||
"title": "تسجيل الدخول إلى الحساب",
|
||||
"welcome": "مرحبا بك!"
|
||||
},
|
||||
"password": {
|
||||
"description": "قام المسؤول بتشفير التطبيق، قم بإدخال كلمة مرور التطبيق لفتح التطبيق. يتعين إدخال كلمة المرور مرة واحدة فقط",
|
||||
"placeholder": "الرجاء إدخال كلمة المرور",
|
||||
"title": "إدخال كلمة المرور لفتح التطبيق"
|
||||
},
|
||||
"tabs": {
|
||||
"apiKey": "مفتاح واجهة برمجة التطبيقات المخصص",
|
||||
"password": "كلمة المرور"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"desc": "التفاصيل: {{detail}}",
|
||||
"fileOnlySupportInServerMode": "وضع النشر الحالي لا يدعم تحميل ملفات غير الصور. إذا كنت بحاجة إلى تحميل تنسيق {{ext}}، يرجى التبديل إلى نشر قاعدة البيانات على الخادم أو استخدام خدمة {{cloud}}.",
|
||||
"networkError": "يرجى التأكد من أن اتصال الشبكة لديك يعمل بشكل صحيح، والتحقق من إعدادات تكوين خدمة تخزين الملفات عبر النطاق.",
|
||||
"title": "فشل تحميل الملف، يرجى التحقق من الاتصال بالشبكة أو المحاولة لاحقًا",
|
||||
"unknownError": "سبب الخطأ: {{reason}}",
|
||||
"uploadFailed": "فشل تحميل الملف"
|
||||
}
|
||||
"tts.responseError": "فشل طلب الخدمة، يرجى التحقق من الإعدادات أو إعادة المحاولة",
|
||||
"unlock.addProxyUrl": "إضافة عنوان وكيل OpenAI (اختياري)",
|
||||
"unlock.apiKey.description": "يمكنك بدء الجلسة عن طريق إدخال مفتاح API {{name}} الخاص بك",
|
||||
"unlock.apiKey.imageGenerationDescription": "أدخل مفتاح API الخاص بـ {{name}} للبدء في التوليد",
|
||||
"unlock.apiKey.title": "استخدام مفتاح API {{name}} المخصص",
|
||||
"unlock.closeMessage": "إغلاق الرسالة",
|
||||
"unlock.comfyui.description": "يرجى إدخال معلومات المصادقة الصحيحة لـ {{name}} للبدء في إنشاء الصور",
|
||||
"unlock.comfyui.modifyBaseUrl": "تعديل عنوان خدمة Comfy UI",
|
||||
"unlock.comfyui.title": "تأكيد معلومات المصادقة الخاصة بـ {{name}}",
|
||||
"unlock.confirm": "تأكيد وإعادة المحاولة",
|
||||
"unlock.goToSettings": "الانتقال إلى الإعدادات",
|
||||
"unlock.oauth.description": "فتح المسؤول توثيق تسجيل الدخول الموحد، انقر فوق الزر أدناه لتسجيل الدخول وفتح التطبيق",
|
||||
"unlock.oauth.success": "تم تسجيل الدخول بنجاح",
|
||||
"unlock.oauth.title": "تسجيل الدخول إلى الحساب",
|
||||
"unlock.oauth.welcome": "مرحبا بك!",
|
||||
"unlock.password.description": "قام المسؤول بتشفير التطبيق، قم بإدخال كلمة مرور التطبيق لفتح التطبيق. يتعين إدخال كلمة المرور مرة واحدة فقط",
|
||||
"unlock.password.placeholder": "الرجاء إدخال كلمة المرور",
|
||||
"unlock.password.title": "إدخال كلمة المرور لفتح التطبيق",
|
||||
"unlock.tabs.apiKey": "مفتاح واجهة برمجة التطبيقات المخصص",
|
||||
"unlock.tabs.password": "كلمة المرور",
|
||||
"upload.desc": "التفاصيل: {{detail}}",
|
||||
"upload.fileOnlySupportInServerMode": "وضع النشر الحالي لا يدعم تحميل ملفات غير الصور. إذا كنت بحاجة إلى تحميل تنسيق {{ext}}، يرجى التبديل إلى نشر قاعدة البيانات على الخادم أو استخدام خدمة {{cloud}}.",
|
||||
"upload.networkError": "يرجى التأكد من أن اتصال الشبكة لديك يعمل بشكل صحيح، والتحقق من إعدادات تكوين خدمة تخزين الملفات عبر النطاق.",
|
||||
"upload.title": "فشل تحميل الملف، يرجى التحقق من الاتصال بالشبكة أو المحاولة لاحقًا",
|
||||
"upload.unknownError": "سبب الخطأ: {{reason}}",
|
||||
"upload.uploadFailed": "فشل تحميل الملف"
|
||||
}
|
||||
|
||||
+118
-173
@@ -1,181 +1,126 @@
|
||||
{
|
||||
"addFolder": "إنشاء مجلد",
|
||||
"addKnowledge": "إضافة معرفة",
|
||||
"addLibrary": "إضافة",
|
||||
"addPage": "إنشاء مستند",
|
||||
"desc": "نظّم معرفتك في العمل، الدراسة والحياة.",
|
||||
"detail": {
|
||||
"basic": {
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"filename": "اسم الملف",
|
||||
"size": "حجم الملف",
|
||||
"title": "معلومات أساسية",
|
||||
"type": "الصيغة",
|
||||
"updatedAt": "تاريخ التحديث"
|
||||
},
|
||||
"data": {
|
||||
"chunkCount": "عدد الأجزاء",
|
||||
"embedding": {
|
||||
"default": "لم يتم تحويله إلى متجهات بعد",
|
||||
"error": "فشل",
|
||||
"pending": "في انتظار البدء",
|
||||
"processing": "جارٍ المعالجة",
|
||||
"success": "اكتمل"
|
||||
},
|
||||
"embeddingStatus": "تحويل إلى متجهات"
|
||||
}
|
||||
},
|
||||
"documentEditor": {
|
||||
"addIcon": "إضافة أيقونة",
|
||||
"autoSaveMessage": "يتم حفظ المستند تلقائيًا، لا حاجة للحفظ اليدوي",
|
||||
"chooseIcon": "اختر أيقونة",
|
||||
"deleteConfirm": {
|
||||
"content": "سيتم حذف هذا المستند، ولا يمكن استعادته بعد الحذف. يرجى توخي الحذر.",
|
||||
"title": "حذف المستند"
|
||||
},
|
||||
"deleteError": "فشل في حذف المستند",
|
||||
"deleteSuccess": "تم حذف المستند بنجاح",
|
||||
"editedAt": "آخر تعديل في {{time}}",
|
||||
"editedBy": "آخر من عدّل: {{name}}",
|
||||
"editorPlaceholder": "أدخل محتوى المستند، اضغط / لفتح قائمة الأوامر",
|
||||
"empty": {
|
||||
"createNewDocument": "إنشاء مستند جديد",
|
||||
"title": "اختر مستندًا للبدء",
|
||||
"uploadMarkdown": "رفع ملف Markdown"
|
||||
},
|
||||
"linkCopied": "تم نسخ الرابط",
|
||||
"menu": {
|
||||
"copyLink": "نسخ الرابط",
|
||||
"exportDocument": "تصدير المستند",
|
||||
"importDocument": "استيراد مستند",
|
||||
"pin": "تثبيت المستند"
|
||||
},
|
||||
"saving": "جارٍ الحفظ...",
|
||||
"titlePlaceholder": "بدون عنوان",
|
||||
"wordCount": "{{wordCount}} كلمة"
|
||||
},
|
||||
"documentList": {
|
||||
"copyContent": "نسخ المحتوى الكامل",
|
||||
"duplicate": "إنشاء نسخة",
|
||||
"empty": "لا توجد مستندات حالياً، انقر على الزر أعلاه لإنشاء أول مستند لك",
|
||||
"noResults": "لم يتم العثور على مستندات مطابقة",
|
||||
"pageCount": "إجمالي {{count}} مستند",
|
||||
"selectNote": "اختر مستندًا لبدء التحرير",
|
||||
"untitled": "بدون عنوان"
|
||||
},
|
||||
"detail.basic.createdAt": "تاريخ الإنشاء",
|
||||
"detail.basic.filename": "اسم الملف",
|
||||
"detail.basic.size": "حجم الملف",
|
||||
"detail.basic.title": "معلومات أساسية",
|
||||
"detail.basic.type": "الصيغة",
|
||||
"detail.basic.updatedAt": "تاريخ التحديث",
|
||||
"detail.data.chunkCount": "عدد الأجزاء",
|
||||
"detail.data.embedding.default": "لم يتم تحويله إلى متجهات بعد",
|
||||
"detail.data.embedding.error": "فشل",
|
||||
"detail.data.embedding.pending": "في انتظار البدء",
|
||||
"detail.data.embedding.processing": "جارٍ المعالجة",
|
||||
"detail.data.embedding.success": "اكتمل",
|
||||
"detail.data.embeddingStatus": "تحويل إلى متجهات",
|
||||
"empty": "لا توجد ملفات/مجلدات تم تحميلها بعد",
|
||||
"header": {
|
||||
"actions": {
|
||||
"newFolder": "إنشاء مجلد جديد",
|
||||
"newPage": "مستند جديد",
|
||||
"uploadFile": "رفع ملف",
|
||||
"uploadFolder": "رفع مجلد"
|
||||
},
|
||||
"newNoteDialog": {
|
||||
"cancel": "إلغاء",
|
||||
"editTitle": "تحرير المستند",
|
||||
"emptyContent": "لا يمكن أن يكون محتوى المستند فارغًا",
|
||||
"loadError": "فشل في تحميل المستند، يرجى المحاولة مرة أخرى",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"save": "حفظ",
|
||||
"saveError": "فشل في حفظ المستند، يرجى المحاولة مرة أخرى",
|
||||
"saveSuccess": "تم حفظ المستند بنجاح",
|
||||
"title": "مستند جديد",
|
||||
"updateSuccess": "تم تحديث المستند بنجاح"
|
||||
},
|
||||
"newPageButton": "إنشاء مستند جديد",
|
||||
"uploadButton": "رفع"
|
||||
},
|
||||
"home": {
|
||||
"getStarted": "ابدأ الآن",
|
||||
"greeting": "ابدأ",
|
||||
"quickActions": "إجراءات سريعة",
|
||||
"recentFiles": "الملفات الأخيرة",
|
||||
"recentPages": "الصفحات الأخيرة",
|
||||
"subtitle": "مرحبًا بك في قاعدة المعرفة، ابدأ من هنا لإدارة مستنداتك وملاحظاتك",
|
||||
"uploadEntries": {
|
||||
"files": {
|
||||
"title": "رفع ملفات"
|
||||
},
|
||||
"folder": {
|
||||
"title": "رفع مجلد"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "قاعدة معرفة جديدة"
|
||||
},
|
||||
"newPage": {
|
||||
"title": "إنشاء مستند جديد"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"list": {
|
||||
"confirmRemoveKnowledgeBase": "سيتم حذف هذه المكتبة المعرفية، ولن يتم حذف الملفات الموجودة بها، بل ستنتقل إلى جميع الملفات. بعد حذف المكتبة المعرفية، لن يمكن استعادتها، يرجى توخي الحذر.",
|
||||
"empty": "انقر على <1>+</1> لبدء إنشاء مكتبة معرفية"
|
||||
},
|
||||
"new": "إنشاء مكتبة معرفية جديدة",
|
||||
"title": "المكتبة المعرفية"
|
||||
},
|
||||
"menu": {
|
||||
"allFiles": "جميع الملفات",
|
||||
"allPages": "جميع المستندات"
|
||||
},
|
||||
"networkError": "فشل في الحصول على قاعدة المعرفة، يرجى التحقق من اتصال الشبكة ثم إعادة المحاولة",
|
||||
"notSupportGuide": {
|
||||
"desc": "الوضع الحالي للنشر هو وضع قاعدة بيانات العميل، ولا يمكن استخدام وظيفة إدارة الملفات. يرجى التبديل إلى <1>وضع نشر قاعدة بيانات الخادم</1>، أو استخدام <3>LobeChat Cloud</3> مباشرة.",
|
||||
"features": {
|
||||
"allKind": {
|
||||
"desc": "يدعم أنواع الملفات الشائعة، بما في ذلك تنسيقات المستندات الشائعة مثل Word وPPT وExcel وPDF وTXT، بالإضافة إلى ملفات الشيفرة الشائعة مثل JS وPython.",
|
||||
"title": "تحليل أنواع متعددة من الملفات"
|
||||
},
|
||||
"embeddings": {
|
||||
"desc": "استخدام نماذج متجهات عالية الأداء لتحويل النصوص إلى متجهات، مما يتيح البحث الدلالي في محتوى الملفات.",
|
||||
"title": "تحويل دلالي إلى متجهات"
|
||||
},
|
||||
"repos": {
|
||||
"desc": "يدعم إنشاء مكتبات معرفية، ويسمح بإضافة أنواع مختلفة من الملفات، لبناء معرفتك في مجالك.",
|
||||
"title": "المكتبة المعرفية"
|
||||
}
|
||||
},
|
||||
"title": "الوضع الحالي للنشر لا يدعم إدارة الملفات"
|
||||
},
|
||||
"preview": {
|
||||
"downloadFile": "تحميل الملف",
|
||||
"unsupportedFileAndContact": "هذا التنسيق من الملفات غير مدعوم للمعاينة عبر الإنترنت، إذا كان لديك طلب للمعاينة، فلا تتردد في <1>إبلاغنا</1>"
|
||||
},
|
||||
"header.actions.builtInBlockList.filtered": "تم تصفية {{ignored}} ملفًا من أصل {{total}} ملف",
|
||||
"header.actions.connect": "اتصال...",
|
||||
"header.actions.gitignore.apply": "تطبيق القواعد",
|
||||
"header.actions.gitignore.cancel": "تجاهل القواعد",
|
||||
"header.actions.gitignore.content": "تم اكتشاف ملف .gitignore (عدد {{count}} من الملفات)، هل ترغب في تطبيق قواعد التجاهل؟",
|
||||
"header.actions.gitignore.filtered": "{{ignored}} ملفًا تم تجاهله من أصل {{total}} ملفًا",
|
||||
"header.actions.gitignore.title": "تم اكتشاف .gitignore",
|
||||
"header.actions.newFolder": "إنشاء مجلد جديد",
|
||||
"header.actions.newPage": "مستند جديد",
|
||||
"header.actions.notion.error": "فشل في استيراد ملف Notion",
|
||||
"header.actions.notion.foundFiles": "تم العثور على {{count}} ملف",
|
||||
"header.actions.notion.importing": "جارٍ استيراد ملفات Notion...",
|
||||
"header.actions.notion.noMarkdownFiles": "لم يتم العثور على ملفات Markdown في ملف ZIP",
|
||||
"header.actions.notion.partial": "تم استيراد {{success}} ملفًا بنجاح، وفشل {{failed}} ملفًا",
|
||||
"header.actions.notion.success": "تم استيراد {{count}} ملفًا بنجاح",
|
||||
"header.actions.notionGuide.cancel": "إلغاء الاستيراد الآن",
|
||||
"header.actions.notionGuide.desc": "يرجى أولاً تصدير ملفات Markdown (بصيغة ZIP) من Notion، ثم النقر على متابعة لاختيار ملف الضغط واستيراد جميع الصفحات.",
|
||||
"header.actions.notionGuide.ok": "اختر ملف ZIP من Notion",
|
||||
"header.actions.notionGuide.title": "استيراد محتوى Notion",
|
||||
"header.actions.uploadFile": "رفع ملف",
|
||||
"header.actions.uploadFolder": "رفع مجلد",
|
||||
"header.newPageButton": "إنشاء مستند جديد",
|
||||
"header.uploadButton": "رفع",
|
||||
"home.getStarted": "ابدأ الآن",
|
||||
"home.greeting": "ابدأ",
|
||||
"home.quickActions": "إجراءات سريعة",
|
||||
"home.recentFiles": "الملفات الأخيرة",
|
||||
"home.recentPages": "الصفحات الأخيرة",
|
||||
"home.subtitle": "مرحبًا بك في مركز الموارد، ابدأ من هنا لإدارة مستنداتك وملفاتك.",
|
||||
"home.uploadEntries.files.title": "رفع ملفات",
|
||||
"home.uploadEntries.folder.title": "رفع مجلد",
|
||||
"home.uploadEntries.library.title": "إنشاء مكتبة جديدة",
|
||||
"home.uploadEntries.newPage.title": "إنشاء مستند جديد",
|
||||
"library.list.confirmRemoveLibrary": "سيتم حذف هذه المكتبة، لكن الملفات بداخلها لن تُحذف، بل سيتم نقلها إلى جميع الملفات. لا يمكن استعادة المكتبة بعد حذفها، يرجى الحذر.",
|
||||
"library.list.empty": "انقر <1>+</1> لبدء إنشاء مكتبة",
|
||||
"library.new": "إنشاء مكتبة جديدة",
|
||||
"library.title": "المكتبة",
|
||||
"menu.allFiles": "جميع الملفات",
|
||||
"menu.allPages": "جميع المستندات",
|
||||
"networkError": "فشل في تحميل المكتبة، يرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى",
|
||||
"notSupportGuide.desc": "الوضع الحالي للنشر هو وضع قاعدة بيانات العميل، ولا يمكن استخدام وظيفة إدارة الملفات. يرجى التبديل إلى <1>وضع نشر قاعدة بيانات الخادم</1>، أو استخدام <3>LobeChat Cloud</3> مباشرة.",
|
||||
"notSupportGuide.features.allKind.desc": "يدعم أنواع الملفات الشائعة، بما في ذلك تنسيقات المستندات الشائعة مثل Word وPPT وExcel وPDF وTXT، بالإضافة إلى ملفات الشيفرة الشائعة مثل JS وPython.",
|
||||
"notSupportGuide.features.allKind.title": "تحليل أنواع متعددة من الملفات",
|
||||
"notSupportGuide.features.embeddings.desc": "استخدام نماذج متجهات عالية الأداء لتحويل النصوص إلى متجهات، مما يتيح البحث الدلالي في محتوى الملفات.",
|
||||
"notSupportGuide.features.embeddings.title": "تحويل دلالي إلى متجهات",
|
||||
"notSupportGuide.features.libraries.desc": "يدعم إنشاء مكتبات ويسمح بإضافة أنواع مختلفة من الملفات لبناء مواردك المتخصصة",
|
||||
"notSupportGuide.features.libraries.title": "المكتبة",
|
||||
"notSupportGuide.title": "الوضع الحالي للنشر لا يدعم إدارة الملفات",
|
||||
"pageEditor.addIcon": "إضافة أيقونة",
|
||||
"pageEditor.autoSaveMessage": "يتم حفظ المستند تلقائيًا، لا حاجة للحفظ اليدوي",
|
||||
"pageEditor.chooseIcon": "اختر أيقونة",
|
||||
"pageEditor.deleteConfirm.content": "سيتم حذف هذا المستند، ولا يمكن استعادته بعد الحذف. يرجى توخي الحذر.",
|
||||
"pageEditor.deleteConfirm.title": "حذف المستند",
|
||||
"pageEditor.deleteError": "فشل في حذف المستند",
|
||||
"pageEditor.deleteSuccess": "تم حذف المستند بنجاح",
|
||||
"pageEditor.editedAt": "آخر تعديل في {{time}}",
|
||||
"pageEditor.editedBy": "آخر من عدّل: {{name}}",
|
||||
"pageEditor.editorPlaceholder": "أدخل محتوى المستند، اضغط / لفتح قائمة الأوامر",
|
||||
"pageEditor.empty.createNewDocument": "إنشاء مستند جديد",
|
||||
"pageEditor.empty.title": "اختر مستندًا للبدء",
|
||||
"pageEditor.empty.uploadMarkdown": "رفع ملف Markdown",
|
||||
"pageEditor.linkCopied": "تم نسخ الرابط",
|
||||
"pageEditor.menu.copyLink": "نسخ الرابط",
|
||||
"pageEditor.menu.exportDocument": "تصدير المستند",
|
||||
"pageEditor.menu.importDocument": "استيراد مستند",
|
||||
"pageEditor.menu.pin": "تثبيت المستند",
|
||||
"pageEditor.saving": "جارٍ الحفظ...",
|
||||
"pageEditor.titlePlaceholder": "بدون عنوان",
|
||||
"pageEditor.wordCount": "{{wordCount}} كلمة",
|
||||
"pageList.copyContent": "نسخ المحتوى الكامل",
|
||||
"pageList.duplicate": "إنشاء نسخة",
|
||||
"pageList.empty": "لا توجد مستندات حاليًا، انقر على الزر أعلاه لإنشاء أول مستند لك",
|
||||
"pageList.filter.all": "الكل",
|
||||
"pageList.filter.onlyInPages": "فقط في المستندات",
|
||||
"pageList.noResults": "لم يتم العثور على مستندات مطابقة",
|
||||
"pageList.pageCount": "عدد المستندات: {{count}}",
|
||||
"pageList.selectNote": "اختر مستندًا للبدء في التحرير",
|
||||
"pageList.title": "المستندات",
|
||||
"pageList.untitled": "بدون عنوان",
|
||||
"preview.downloadFile": "تحميل الملف",
|
||||
"preview.unsupportedFileAndContact": "هذا التنسيق من الملفات غير مدعوم للمعاينة عبر الإنترنت، إذا كان لديك طلب للمعاينة، فلا تتردد في <1>إبلاغنا</1>",
|
||||
"searchFilePlaceholder": "بحث عن ملف",
|
||||
"searchPagePlaceholder": "ابحث في المستندات",
|
||||
"tab": {
|
||||
"all": "الكل",
|
||||
"audios": "الصوتيات",
|
||||
"documents": "المستندات",
|
||||
"home": "الرئيسية",
|
||||
"images": "الصور",
|
||||
"moreTypes": "أنواع أخرى",
|
||||
"pages": "المستندات",
|
||||
"videos": "الفيديوهات",
|
||||
"websites": "المواقع"
|
||||
},
|
||||
"title": "قاعدة المعرفة",
|
||||
"tab.all": "الكل",
|
||||
"tab.audios": "الصوتيات",
|
||||
"tab.documents": "المستندات",
|
||||
"tab.home": "الرئيسية",
|
||||
"tab.images": "الصور",
|
||||
"tab.moreTypes": "أنواع أخرى",
|
||||
"tab.pages": "المستندات",
|
||||
"tab.videos": "الفيديوهات",
|
||||
"tab.websites": "المواقع",
|
||||
"title": "الموارد",
|
||||
"toggleLeftPanel": "إظهار/إخفاء اللوحة الجانبية اليسرى",
|
||||
"uploadDock": {
|
||||
"body": {
|
||||
"collapse": "طي",
|
||||
"item": {
|
||||
"done": "تم الرفع",
|
||||
"error": "فشل الرفع، يرجى المحاولة مرة أخرى",
|
||||
"pending": "جاهز للرفع...",
|
||||
"processing": "جارٍ معالجة الملف...",
|
||||
"restTime": "الوقت المتبقي {{time}}"
|
||||
}
|
||||
},
|
||||
"fileQueueInfo": "يتم حاليًا تحميل {{count}} ملفًا، وسيتم وضع {{remaining}} ملفًا في قائمة الانتظار للتحميل",
|
||||
"totalCount": "إجمالي {{count}} عنصر",
|
||||
"uploadStatus": {
|
||||
"error": "حدث خطأ أثناء الرفع",
|
||||
"pending": "في انتظار الرفع",
|
||||
"processing": "جارٍ الرفع",
|
||||
"success": "اكتمل الرفع",
|
||||
"uploading": "جارٍ الرفع"
|
||||
}
|
||||
}
|
||||
"uploadDock.body.collapse": "طي",
|
||||
"uploadDock.body.item.done": "تم الرفع",
|
||||
"uploadDock.body.item.error": "فشل الرفع، يرجى المحاولة مرة أخرى",
|
||||
"uploadDock.body.item.pending": "جاهز للرفع...",
|
||||
"uploadDock.body.item.processing": "جارٍ معالجة الملف...",
|
||||
"uploadDock.body.item.restTime": "الوقت المتبقي {{time}}",
|
||||
"uploadDock.fileQueueInfo": "يتم حاليًا تحميل {{count}} ملفًا، وسيتم وضع {{remaining}} ملفًا في قائمة الانتظار للتحميل",
|
||||
"uploadDock.totalCount": "إجمالي {{count}} عنصر",
|
||||
"uploadDock.uploadStatus.error": "حدث خطأ أثناء الرفع",
|
||||
"uploadDock.uploadStatus.pending": "في انتظار الرفع",
|
||||
"uploadDock.uploadStatus.processing": "جارٍ الرفع",
|
||||
"uploadDock.uploadStatus.success": "اكتمل الرفع",
|
||||
"uploadDock.uploadStatus.uploading": "جارٍ الرفع"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"agentSelection.empty": "لا يوجد مساعدون متاحون حاليًا",
|
||||
"agentSelection.noAvailable": "لا يوجد مساعدون يمكن إضافتهم في الوقت الحالي",
|
||||
"agentSelection.noSelected": "لم يتم اختيار أي مساعد بعد",
|
||||
"agentSelection.search": "لم يتم العثور على مساعد مطابق",
|
||||
"starter.createAgent": "إنشاء مساعد",
|
||||
"starter.createGroup": "إنشاء مجموعة",
|
||||
"starter.deepResearch": "بحث معمق",
|
||||
"starter.developing": "قيد التطوير",
|
||||
"starter.image": "رسم",
|
||||
"starter.write": "كتابة"
|
||||
}
|
||||
+40
-78
@@ -1,80 +1,42 @@
|
||||
{
|
||||
"addUserMessage": {
|
||||
"desc": "إضافة المحتوى الحالي كرسالة مستخدم دون تفعيل التوليد",
|
||||
"title": "إضافة رسالة مستخدم"
|
||||
},
|
||||
"clearCurrentMessages": {
|
||||
"desc": "مسح الرسائل والملفات المرفوعة في المحادثة الحالية",
|
||||
"title": "مسح رسائل المحادثة"
|
||||
},
|
||||
"commandPalette": {
|
||||
"desc": "افتح لوحة الأوامر العامة للوصول السريع إلى الميزات",
|
||||
"title": "لوحة الأوامر"
|
||||
},
|
||||
"deleteAndRegenerateMessage": {
|
||||
"desc": "حذف الرسالة الأخيرة وإعادة إنشائها",
|
||||
"title": "حذف وإعادة إنشاء"
|
||||
},
|
||||
"deleteLastMessage": {
|
||||
"desc": "حذف الرسالة الأخيرة",
|
||||
"title": "حذف الرسالة الأخيرة"
|
||||
},
|
||||
"desktop": {
|
||||
"openSettings": {
|
||||
"desc": "افتح صفحة إعدادات التطبيق",
|
||||
"title": "إعدادات التطبيق"
|
||||
},
|
||||
"showApp": {
|
||||
"desc": "مفتاح اختصار عام لإظهار أو إخفاء النافذة الرئيسية",
|
||||
"title": "إظهار/إخفاء النافذة الرئيسية"
|
||||
}
|
||||
},
|
||||
"editMessage": {
|
||||
"desc": "الدخول إلى وضع التحرير عن طريق الضغط على مفتاح Alt والنقر المزدوج على الرسالة",
|
||||
"title": "تحرير الرسالة"
|
||||
},
|
||||
"navigateToChat": {
|
||||
"desc": "التبديل إلى علامة تبويب المحادثة والدخول إلى دردشة عشوائية",
|
||||
"title": "التبديل إلى المحادثة الافتراضية"
|
||||
},
|
||||
"openChatSettings": {
|
||||
"desc": "عرض وتعديل إعدادات المحادثة الحالية",
|
||||
"title": "فتح إعدادات المحادثة"
|
||||
},
|
||||
"openHotkeyHelper": {
|
||||
"desc": "عرض جميع تعليمات استخدام الاختصارات",
|
||||
"title": "فتح مساعدة الاختصارات"
|
||||
},
|
||||
"regenerateMessage": {
|
||||
"desc": "إعادة توليد آخر رسالة",
|
||||
"title": "إعادة توليد الرسالة"
|
||||
},
|
||||
"saveTopic": {
|
||||
"desc": "حفظ الموضوع الحالي وفتح موضوع جديد",
|
||||
"title": "فتح موضوع جديد"
|
||||
},
|
||||
"search": {
|
||||
"desc": "استدعاء مربع البحث الرئيسي في الصفحة الحالية",
|
||||
"title": "بحث"
|
||||
},
|
||||
"showApp": {
|
||||
"desc": "استدعاء نافذة التطبيق الرئيسية بسرعة",
|
||||
"title": "عرض النافذة الرئيسية"
|
||||
},
|
||||
"switchAgent": {
|
||||
"desc": "تبديل المساعد المثبت في الشريط الجانبي عن طريق الضغط على Ctrl مع الأرقام من 0 إلى 9",
|
||||
"title": "تبديل المساعد بسرعة"
|
||||
},
|
||||
"toggleLeftPanel": {
|
||||
"desc": "عرض أو إخفاء لوحة المساعد على اليسار",
|
||||
"title": "عرض/إخفاء لوحة المساعد"
|
||||
},
|
||||
"toggleRightPanel": {
|
||||
"desc": "عرض أو إخفاء لوحة المواضيع على اليمين",
|
||||
"title": "عرض/إخفاء لوحة الموضوع"
|
||||
},
|
||||
"toggleZenMode": {
|
||||
"desc": "في وضع التركيز، عرض المحادثة الحالية فقط، وإخفاء واجهة المستخدم الأخرى",
|
||||
"title": "تبديل وضع التركيز"
|
||||
}
|
||||
"addUserMessage.desc": "إضافة المحتوى الحالي كرسالة مستخدم دون تفعيل التوليد",
|
||||
"addUserMessage.title": "إضافة رسالة مستخدم",
|
||||
"clearCurrentMessages.desc": "مسح الرسائل والملفات المرفوعة في المحادثة الحالية",
|
||||
"clearCurrentMessages.title": "مسح رسائل المحادثة",
|
||||
"commandPalette.desc": "افتح لوحة الأوامر العامة للوصول السريع إلى الميزات",
|
||||
"commandPalette.title": "لوحة الأوامر",
|
||||
"deleteAndRegenerateMessage.desc": "حذف الرسالة الأخيرة وإعادة إنشائها",
|
||||
"deleteAndRegenerateMessage.title": "حذف وإعادة إنشاء",
|
||||
"deleteLastMessage.desc": "حذف الرسالة الأخيرة",
|
||||
"deleteLastMessage.title": "حذف الرسالة الأخيرة",
|
||||
"desktop.openSettings.desc": "افتح صفحة إعدادات التطبيق",
|
||||
"desktop.openSettings.title": "إعدادات التطبيق",
|
||||
"desktop.showApp.desc": "مفتاح اختصار عام لإظهار أو إخفاء النافذة الرئيسية",
|
||||
"desktop.showApp.title": "إظهار/إخفاء النافذة الرئيسية",
|
||||
"editMessage.desc": "الدخول إلى وضع التحرير عن طريق الضغط على مفتاح Alt والنقر المزدوج على الرسالة",
|
||||
"editMessage.title": "تحرير الرسالة",
|
||||
"navigateToChat.desc": "التبديل إلى علامة تبويب المحادثة والدخول إلى Lobe AI",
|
||||
"navigateToChat.title": "التبديل إلى المحادثة الافتراضية",
|
||||
"openChatSettings.desc": "عرض وتعديل إعدادات المحادثة الحالية",
|
||||
"openChatSettings.title": "فتح إعدادات المحادثة",
|
||||
"openHotkeyHelper.desc": "عرض جميع تعليمات استخدام الاختصارات",
|
||||
"openHotkeyHelper.title": "فتح مساعدة الاختصارات",
|
||||
"regenerateMessage.desc": "إعادة توليد آخر رسالة",
|
||||
"regenerateMessage.title": "إعادة توليد الرسالة",
|
||||
"saveDocument.desc": "احفظ جميع التغييرات التي أُجريت على المستند الحالي فورًا",
|
||||
"saveDocument.title": "حفظ المستند",
|
||||
"saveTopic.desc": "حفظ الموضوع الحالي وفتح موضوع جديد",
|
||||
"saveTopic.title": "فتح موضوع جديد",
|
||||
"search.desc": "استدعاء مربع البحث الرئيسي في الصفحة الحالية",
|
||||
"search.title": "بحث",
|
||||
"showApp.desc": "استدعاء نافذة التطبيق الرئيسية بسرعة",
|
||||
"showApp.title": "عرض النافذة الرئيسية",
|
||||
"switchAgent.desc": "تبديل المساعد المثبت في الشريط الجانبي عن طريق الضغط على Ctrl مع الأرقام من 0 إلى 9",
|
||||
"switchAgent.title": "تبديل المساعد بسرعة",
|
||||
"toggleLeftPanel.desc": "إظهار أو إخفاء اللوحة الجانبية اليسرى",
|
||||
"toggleLeftPanel.title": "إظهار/إخفاء اللوحة الجانبية اليسرى",
|
||||
"toggleRightPanel.desc": "إظهار أو إخفاء اللوحة الجانبية اليمنى",
|
||||
"toggleRightPanel.title": "إظهار/إخفاء اللوحة الجانبية اليمنى",
|
||||
"toggleZenMode.desc": "في وضع التركيز، عرض المحادثة الحالية فقط، وإخفاء واجهة المستخدم الأخرى",
|
||||
"toggleZenMode.title": "تبديل وضع التركيز"
|
||||
}
|
||||
|
||||
+60
-116
@@ -1,118 +1,62 @@
|
||||
{
|
||||
"config": {
|
||||
"aspectRatio": {
|
||||
"label": "النسبة",
|
||||
"lock": "قفل نسبة العرض إلى الارتفاع",
|
||||
"unlock": "إلغاء قفل نسبة العرض إلى الارتفاع"
|
||||
},
|
||||
"cfg": {
|
||||
"label": "شدة التوجيه"
|
||||
},
|
||||
"header": {
|
||||
"desc": "وصف بسيط، ابتكر فورًا",
|
||||
"title": "الرسم"
|
||||
},
|
||||
"height": {
|
||||
"label": "الارتفاع"
|
||||
},
|
||||
"imageNum": {
|
||||
"label": "عدد الصور"
|
||||
},
|
||||
"imageUrl": {
|
||||
"label": "صورة مرجعية"
|
||||
},
|
||||
"imageUrls": {
|
||||
"label": "صور مرجعية"
|
||||
},
|
||||
"model": {
|
||||
"label": "النموذج"
|
||||
},
|
||||
"prompt": {
|
||||
"placeholder": "وصف المحتوى الذي ترغب في إنشائه"
|
||||
},
|
||||
"quality": {
|
||||
"label": "جودة الصورة",
|
||||
"options": {
|
||||
"hd": "عالي الدقة",
|
||||
"standard": "عادي"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "الدقة",
|
||||
"options": {
|
||||
"1K": "1K",
|
||||
"2K": "2K",
|
||||
"4K": "4K"
|
||||
}
|
||||
},
|
||||
"seed": {
|
||||
"label": "البذرة",
|
||||
"random": "بذرة عشوائية"
|
||||
},
|
||||
"size": {
|
||||
"label": "الحجم"
|
||||
},
|
||||
"steps": {
|
||||
"label": "عدد الخطوات"
|
||||
},
|
||||
"title": "الرسم بالذكاء الاصطناعي",
|
||||
"width": {
|
||||
"label": "العرض"
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"actions": {
|
||||
"applySeed": "تطبيق البذرة",
|
||||
"copyError": "نسخ رسالة الخطأ",
|
||||
"copyPrompt": "نسخ العبارة التحفيزية",
|
||||
"copySeed": "نسخ البذرة",
|
||||
"delete": "حذف",
|
||||
"deleteBatch": "حذف الدفعة",
|
||||
"download": "تنزيل",
|
||||
"downloadFailed": "فشل تنزيل الصورة",
|
||||
"errorCopied": "تم نسخ رسالة الخطأ إلى الحافظة",
|
||||
"errorCopyFailed": "فشل نسخ رسالة الخطأ",
|
||||
"generate": "إنشاء",
|
||||
"promptCopied": "تم نسخ النص التوجيهي إلى الحافظة",
|
||||
"promptCopyFailed": "فشل نسخ النص التوجيهي",
|
||||
"reuseSettings": "إعادة استخدام الإعدادات",
|
||||
"seedApplied": "تم تطبيق البذرة على الإعدادات",
|
||||
"seedApplyFailed": "فشل في تطبيق البذرة",
|
||||
"seedCopied": "تم نسخ البذرة إلى الحافظة",
|
||||
"seedCopyFailed": "فشل نسخ البذرة"
|
||||
},
|
||||
"metadata": {
|
||||
"count": "{{count}} صورة"
|
||||
},
|
||||
"status": {
|
||||
"failed": "فشل في الإنشاء",
|
||||
"generating": "جارٍ الإنشاء..."
|
||||
}
|
||||
},
|
||||
"notSupportGuide": {
|
||||
"desc": "الوحدة الحالية تعمل بنمط قاعدة بيانات العميل، ولا تدعم ميزة إنشاء الصور بالذكاء الاصطناعي. يرجى التبديل إلى <1>نمط نشر قاعدة بيانات الخادم</1>، أو استخدام <3>سحابة LobeChat</3> مباشرةً",
|
||||
"features": {
|
||||
"fileIntegration": {
|
||||
"desc": "تكامل عميق مع نظام إدارة الملفات، حيث تُحفظ الصور المُنشأة تلقائيًا في نظام الملفات، مع دعم الإدارة والتنظيم الموحد",
|
||||
"title": "تكامل نظام الملفات"
|
||||
},
|
||||
"llmAssisted": {
|
||||
"desc": "يجمع بين قدرات نماذج اللغة الكبيرة لتحسين وتوسيع النصوص التوجيهية بذكاء، مما يعزز جودة إنشاء الصور (قريبًا)",
|
||||
"title": "مساعدة نموذج اللغة الكبير"
|
||||
},
|
||||
"multiProviders": {
|
||||
"desc": "يدعم عدة مزودي خدمات رسم بالذكاء الاصطناعي، بما في ذلك OpenAI gpt-image-1، Google Imagen، FAL.ai وغيرها، لتوفير خيارات نماذج متنوعة",
|
||||
"title": "دعم مزودين متعددين"
|
||||
}
|
||||
},
|
||||
"title": "نمط النشر الحالي لا يدعم الرسم بالذكاء الاصطناعي"
|
||||
},
|
||||
"topic": {
|
||||
"createNew": "إنشاء موضوع جديد",
|
||||
"deleteConfirm": "تأكيد حذف الموضوع",
|
||||
"deleteConfirmDesc": "سيتم حذف هذا الموضوع نهائيًا ولن يمكن استعادته، يرجى توخي الحذر.",
|
||||
"empty": "لا توجد مواضيع تم إنشاؤها",
|
||||
"title": "موضوع الرسم",
|
||||
"untitled": "الموضوع الافتراضي"
|
||||
}
|
||||
"config.aspectRatio.label": "النسبة",
|
||||
"config.aspectRatio.lock": "قفل نسبة العرض إلى الارتفاع",
|
||||
"config.aspectRatio.unlock": "إلغاء قفل نسبة العرض إلى الارتفاع",
|
||||
"config.cfg.label": "شدة التوجيه",
|
||||
"config.header.desc": "وصف بسيط، ابتكر فورًا",
|
||||
"config.header.title": "الرسم",
|
||||
"config.height.label": "الارتفاع",
|
||||
"config.imageNum.label": "عدد الصور",
|
||||
"config.imageUrl.label": "صورة مرجعية",
|
||||
"config.imageUrls.label": "صور مرجعية",
|
||||
"config.model.label": "النموذج",
|
||||
"config.prompt.placeholder": "وصف المحتوى الذي ترغب في إنشائه",
|
||||
"config.quality.label": "جودة الصورة",
|
||||
"config.quality.options.hd": "عالي الدقة",
|
||||
"config.quality.options.standard": "عادي",
|
||||
"config.resolution.label": "الدقة",
|
||||
"config.resolution.options.1K": "1K",
|
||||
"config.resolution.options.2K": "2K",
|
||||
"config.resolution.options.4K": "4K",
|
||||
"config.seed.label": "البذرة",
|
||||
"config.seed.random": "بذرة عشوائية",
|
||||
"config.size.label": "الحجم",
|
||||
"config.steps.label": "عدد الخطوات",
|
||||
"config.title": "الرسم بالذكاء الاصطناعي",
|
||||
"config.width.label": "العرض",
|
||||
"generation.actions.applySeed": "تطبيق البذرة",
|
||||
"generation.actions.copyError": "نسخ رسالة الخطأ",
|
||||
"generation.actions.copyPrompt": "نسخ العبارة التحفيزية",
|
||||
"generation.actions.copySeed": "نسخ البذرة",
|
||||
"generation.actions.delete": "حذف",
|
||||
"generation.actions.deleteBatch": "حذف الدفعة",
|
||||
"generation.actions.download": "تنزيل",
|
||||
"generation.actions.downloadFailed": "فشل تنزيل الصورة",
|
||||
"generation.actions.errorCopied": "تم نسخ رسالة الخطأ إلى الحافظة",
|
||||
"generation.actions.errorCopyFailed": "فشل نسخ رسالة الخطأ",
|
||||
"generation.actions.generate": "إنشاء",
|
||||
"generation.actions.promptCopied": "تم نسخ النص التوجيهي إلى الحافظة",
|
||||
"generation.actions.promptCopyFailed": "فشل نسخ النص التوجيهي",
|
||||
"generation.actions.reuseSettings": "إعادة استخدام الإعدادات",
|
||||
"generation.actions.seedApplied": "تم تطبيق البذرة على الإعدادات",
|
||||
"generation.actions.seedApplyFailed": "فشل في تطبيق البذرة",
|
||||
"generation.actions.seedCopied": "تم نسخ البذرة إلى الحافظة",
|
||||
"generation.actions.seedCopyFailed": "فشل نسخ البذرة",
|
||||
"generation.metadata.count": "{{count}} صورة",
|
||||
"generation.status.failed": "فشل في الإنشاء",
|
||||
"generation.status.generating": "جارٍ الإنشاء...",
|
||||
"notSupportGuide.desc": "الوحدة الحالية تعمل بنمط قاعدة بيانات العميل، ولا تدعم ميزة إنشاء الصور بالذكاء الاصطناعي. يرجى التبديل إلى <1>نمط نشر قاعدة بيانات الخادم</1>، أو استخدام <3>سحابة LobeChat</3> مباشرةً",
|
||||
"notSupportGuide.features.fileIntegration.desc": "تكامل عميق مع نظام إدارة الملفات، حيث تُحفظ الصور المُنشأة تلقائيًا في نظام الملفات، مع دعم الإدارة والتنظيم الموحد",
|
||||
"notSupportGuide.features.fileIntegration.title": "تكامل نظام الملفات",
|
||||
"notSupportGuide.features.llmAssisted.desc": "يجمع بين قدرات نماذج اللغة الكبيرة لتحسين وتوسيع النصوص التوجيهية بذكاء، مما يعزز جودة إنشاء الصور (قريبًا)",
|
||||
"notSupportGuide.features.llmAssisted.title": "مساعدة نموذج اللغة الكبير",
|
||||
"notSupportGuide.features.multiProviders.desc": "يدعم عدة مزودي خدمات رسم بالذكاء الاصطناعي، بما في ذلك OpenAI gpt-image-1، Google Imagen، FAL.ai وغيرها، لتوفير خيارات نماذج متنوعة",
|
||||
"notSupportGuide.features.multiProviders.title": "دعم مزودين متعددين",
|
||||
"notSupportGuide.title": "نمط النشر الحالي لا يدعم الرسم بالذكاء الاصطناعي",
|
||||
"topic.createNew": "إنشاء موضوع جديد",
|
||||
"topic.deleteConfirm": "تأكيد حذف الموضوع",
|
||||
"topic.deleteConfirmDesc": "سيتم حذف هذا الموضوع نهائيًا ولن يمكن استعادته، يرجى توخي الحذر.",
|
||||
"topic.empty": "لا توجد مواضيع تم إنشاؤها",
|
||||
"topic.title": "موضوع الرسم",
|
||||
"topic.untitled": "الموضوع الافتراضي"
|
||||
}
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
{
|
||||
"addToKnowledgeBase": {
|
||||
"addSuccess": "تم إضافة الملف بنجاح، <1>عرض الآن</1>",
|
||||
"confirm": "إضافة",
|
||||
"id": {
|
||||
"placeholder": "يرجى اختيار قاعدة المعرفة لإضافتها",
|
||||
"required": "يرجى اختيار قاعدة المعرفة",
|
||||
"title": "قاعدة المعرفة المستهدفة"
|
||||
},
|
||||
"title": "إضافة إلى قاعدة المعرفة",
|
||||
"totalFiles": "تم اختيار {{count}} ملف"
|
||||
},
|
||||
"createNew": {
|
||||
"confirm": "إنشاء جديد",
|
||||
"description": {
|
||||
"placeholder": "وصف قاعدة المعرفة (اختياري)"
|
||||
},
|
||||
"formTitle": "المعلومات الأساسية",
|
||||
"name": {
|
||||
"placeholder": "اسم قاعدة المعرفة",
|
||||
"required": "يرجى إدخال اسم قاعدة المعرفة"
|
||||
},
|
||||
"title": "إنشاء قاعدة معرفة جديدة"
|
||||
},
|
||||
"tab": {
|
||||
"evals": "تقييمات",
|
||||
"files": "المستندات",
|
||||
"settings": "الإعدادات",
|
||||
"testing": "اختبار الاسترجاع"
|
||||
},
|
||||
"addToKnowledgeBase.addSuccess": "تم إضافة الملف بنجاح، <1>عرض الآن</1>",
|
||||
"addToKnowledgeBase.confirm": "إضافة",
|
||||
"addToKnowledgeBase.error": "فشل في إضافة الملف إلى قاعدة المعرفة",
|
||||
"addToKnowledgeBase.id.placeholder": "يرجى اختيار قاعدة المعرفة المراد الإضافة إليها",
|
||||
"addToKnowledgeBase.id.required": "يرجى اختيار قاعدة المعرفة",
|
||||
"addToKnowledgeBase.id.title": "قاعدة المعرفة المستهدفة",
|
||||
"addToKnowledgeBase.title": "إضافة إلى قاعدة المعرفة",
|
||||
"addToKnowledgeBase.totalFiles": "تم اختيار {{count}} ملف",
|
||||
"createNew.confirm": "إنشاء جديد",
|
||||
"createNew.description.placeholder": "وصف قاعدة المعرفة (اختياري)",
|
||||
"createNew.formTitle": "المعلومات الأساسية",
|
||||
"createNew.name.placeholder": "اسم قاعدة المعرفة",
|
||||
"createNew.name.required": "يرجى إدخال اسم قاعدة المعرفة",
|
||||
"createNew.title": "إنشاء قاعدة معرفة جديدة",
|
||||
"tab.evals": "تقييمات",
|
||||
"tab.files": "المستندات",
|
||||
"tab.settings": "الإعدادات",
|
||||
"tab.testing": "اختبار الاسترجاع",
|
||||
"title": "قاعدة المعرفة"
|
||||
}
|
||||
|
||||
+6
-14
@@ -1,18 +1,10 @@
|
||||
{
|
||||
"desc": "سنقوم بتحديث الميزات الجديدة التي نستكشفها من وقت لآخر، ندعوك لتجربتها!",
|
||||
"features": {
|
||||
"assistantMessageGroup": {
|
||||
"desc": "تجميع رسائل المساعد ونتائج استدعاء الأدوات في مجموعة واحدة للعرض",
|
||||
"title": "تجميع رسائل المساعد"
|
||||
},
|
||||
"groupChat": {
|
||||
"desc": "تفعيل إمكانية تنسيق المحادثات الجماعية متعددة الوكلاء.",
|
||||
"title": "دردشة جماعية (متعددة الوكلاء)"
|
||||
},
|
||||
"inputMarkdown": {
|
||||
"desc": "عرض Markdown في منطقة الإدخال بشكل فوري (مثل النص العريض، كتل الشيفرة، الجداول، وغيرها).",
|
||||
"title": "عرض Markdown في حقل الإدخال"
|
||||
}
|
||||
},
|
||||
"features.assistantMessageGroup.desc": "تجميع رسائل المساعد ونتائج استدعاء الأدوات في مجموعة واحدة للعرض",
|
||||
"features.assistantMessageGroup.title": "تجميع رسائل المساعد",
|
||||
"features.groupChat.desc": "تفعيل إمكانية تنسيق المحادثات الجماعية متعددة الوكلاء.",
|
||||
"features.groupChat.title": "دردشة جماعية (متعددة الوكلاء)",
|
||||
"features.inputMarkdown.desc": "عرض Markdown في منطقة الإدخال بشكل فوري (مثل النص العريض، كتل الشيفرة، الجداول، وغيرها).",
|
||||
"features.inputMarkdown.title": "عرض Markdown في حقل الإدخال",
|
||||
"title": "المختبر"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user