mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 19:50:09 +00:00
Compare commits
284 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3a3e07e6 | |||
| 9498cc6026 | |||
| 9edb7adfa7 | |||
| 66abd805ac | |||
| fa492b48fa | |||
| c5d1b0494a | |||
| 2e3fa41a0f | |||
| b8a9ad421a | |||
| 2ab88c5dcf | |||
| f0e05b4868 | |||
| bb7561468f | |||
| 3be78f04e8 | |||
| 7b5a58b6b9 | |||
| 83ae71ad05 | |||
| bd9a38cda7 | |||
| ed85cb51ca | |||
| dc8eca9952 | |||
| 9b5b234571 | |||
| 28a56e96ce | |||
| c674434636 | |||
| 423bdee43b | |||
| 5483d91452 | |||
| d37b398427 | |||
| 65a5c41f59 | |||
| d7db99e41f | |||
| 8da7cc4418 | |||
| dff82f4093 | |||
| 22ab9bab20 | |||
| bfe36c8dfe | |||
| 5b1999c3bc | |||
| 1a6f808d35 | |||
| 1523f5f6ca | |||
| d8454512a6 | |||
| 2625a4daca | |||
| 103d70caf3 | |||
| b5bf8ad407 | |||
| e2c0c2893a | |||
| 58027bb29b | |||
| 7babdc18fc | |||
| c76cfd5c1e | |||
| b81ffd488b | |||
| 766a2616c0 | |||
| d181f718c9 | |||
| 1674cc94f2 | |||
| 5777e195ef | |||
| 1c4b3556dd | |||
| 74554c664f | |||
| ab5db5042b | |||
| 836060068e | |||
| 8aff3ab70c | |||
| e4ca75acf9 | |||
| 06cd54518b | |||
| 69898185f3 | |||
| e1c99a068b | |||
| 5b8f7279c0 | |||
| 3c7eb69933 | |||
| 37bd67a539 | |||
| 7f40f15cbb | |||
| 285a05059e | |||
| 36750adc3a | |||
| 1193568f73 | |||
| 95d34aea4f | |||
| 37266c0244 | |||
| e1670758ce | |||
| 1e2c12460e | |||
| 5facc05852 | |||
| 1375314555 | |||
| 224b3f0506 | |||
| fdec35449a | |||
| dc62cc969d | |||
| ef6809461b | |||
| 57dcb48b33 | |||
| 85b45cb8cd | |||
| c58f37ad96 | |||
| 3f95d1c34a | |||
| 513f2d36e7 | |||
| fcd781824c | |||
| 4942bc91ae | |||
| 341690eb22 | |||
| b9ca265b54 | |||
| fda8119967 | |||
| 81860fef0f | |||
| e38d37eee5 | |||
| a197b4b433 | |||
| b6dca900e3 | |||
| 6a235f22bf | |||
| a547e9e5b4 | |||
| f9d2c3f07f | |||
| 0c831527ba | |||
| fb8f977292 | |||
| acbb72a752 | |||
| e95ed341b4 | |||
| 6553544fed | |||
| a5a8bde483 | |||
| 64af7b12ce | |||
| 6924b81a38 | |||
| e508f8abd2 | |||
| 5ef00aeb73 | |||
| c7c1757e44 | |||
| 20ca43cc4f | |||
| 8b41638755 | |||
| 5bab1a4bcf | |||
| 1f42b9beec | |||
| a93cfcd703 | |||
| b78f24c67f | |||
| 78a0efad8b | |||
| 042005a5ea | |||
| 728cd02404 | |||
| 25898eb497 | |||
| 864e3d5aa3 | |||
| d77288f925 | |||
| 3a50003228 | |||
| 83aff86dd7 | |||
| 3ed81539d0 | |||
| 021f955aeb | |||
| 1d59c27aa6 | |||
| cf28c87d3e | |||
| bd2e8387dc | |||
| 57208ee8a5 | |||
| 9383d42a81 | |||
| 760105adb2 | |||
| e1c813a301 | |||
| 9caacde1c1 | |||
| 2711450436 | |||
| da9ca7e921 | |||
| 63e4b3d731 | |||
| 27c1154210 | |||
| 1fb7b292ca | |||
| 3e820fd6b7 | |||
| 9d6a8faaa1 | |||
| ed707af91c | |||
| 5bfe36d28f | |||
| 8104c774d5 | |||
| c5fb6c8288 | |||
| 5b235891f3 | |||
| 224f9998df | |||
| f8a24d22e3 | |||
| f32b0d9ff8 | |||
| 7645475640 | |||
| cb34757743 | |||
| cdc71b26c6 | |||
| 3aa39a651e | |||
| 5349bdcabf | |||
| 5bbb303806 | |||
| 29f19637d3 | |||
| c568369c69 | |||
| 19f7d74652 | |||
| ee6b2ea3b9 | |||
| 5518b822ca | |||
| 9f20ec4135 | |||
| 89a0fa5337 | |||
| 1cb9c5a3f2 | |||
| 5cb0c2a2d0 | |||
| 3482d38ae5 | |||
| 12d29d9a4d | |||
| 530c328816 | |||
| 90354ebde3 | |||
| 40751393d1 | |||
| 5b1a9340fa | |||
| 1f00351815 | |||
| 7afbf36f9d | |||
| f2291e4fc8 | |||
| ac4d102bef | |||
| b0f71e774b | |||
| beb9471e15 | |||
| 8b63246491 | |||
| 9195ba922a | |||
| 89e296a1c3 | |||
| 9a799ec6a8 | |||
| ef7b5b6730 | |||
| 291ff3cc42 | |||
| 0286d1e15a | |||
| c316414277 | |||
| 3bfc1d2dcf | |||
| e600d471b2 | |||
| ed193e096b | |||
| eea41dcb82 | |||
| 871d1416cc | |||
| 6d96dec672 | |||
| fd93f6d0c7 | |||
| c0542e80a3 | |||
| 4c7ebd5b39 | |||
| e893886082 | |||
| bca70e2057 | |||
| 1ed9424166 | |||
| 9c8cf81759 | |||
| e7657cf5bc | |||
| e83561dffa | |||
| a9aed0bc44 | |||
| 9472001461 | |||
| c8c28f2f1a | |||
| 5777977ff1 | |||
| 4ae407844e | |||
| ba3c7e6068 | |||
| deab4d0386 | |||
| a41230ea11 | |||
| f6dbc1eb2f | |||
| e025fec9f0 | |||
| 4d64d9d045 | |||
| 3730b89f7d | |||
| 8fb9890737 | |||
| 02d2121355 | |||
| fe352ff330 | |||
| c7f0a38b57 | |||
| 5d8648c7d6 | |||
| 094cdff097 | |||
| 83e0cea322 | |||
| 21c67d6700 | |||
| 340aa2a9e9 | |||
| a7d1878630 | |||
| 6a2d439f5c | |||
| b5ae53ab30 | |||
| 474af231b5 | |||
| 7ec5594e1c | |||
| ffff700c6c | |||
| 7114fc10c4 | |||
| 973367c7ac | |||
| d1c57a1f97 | |||
| 6545ef863c | |||
| de60a6732e | |||
| d178d4f931 | |||
| 0a056f3f0b | |||
| c5d71fe165 | |||
| 741f588cae | |||
| 092506906a | |||
| e8c7d1c568 | |||
| 61bb8aeaf2 | |||
| caaa331002 | |||
| fcda0b50f1 | |||
| 53a2c30a75 | |||
| 203fdc4b22 | |||
| 25c43587de | |||
| 2cd2ca9a23 | |||
| 7636344e07 | |||
| 1c9f0d9b72 | |||
| d0ee3df579 | |||
| 3ad336fa28 | |||
| 92b65f7b7a | |||
| 9ea680c96d | |||
| 457e7c130d | |||
| 4d8053bebe | |||
| d91fb73f68 | |||
| 14fe7c5736 | |||
| 4c68fc3e3a | |||
| 10e44dfb6b | |||
| 5889e8e85c | |||
| 5e41d9a39c | |||
| be096eb9ff | |||
| 39e88196d7 | |||
| ceadd61ce3 | |||
| c5e0ecd31e | |||
| 21c6eb015f | |||
| 031d6f44dc | |||
| 5ce5532a0e | |||
| a53b3a5ca1 | |||
| 9c5341e098 | |||
| 9d067534ae | |||
| 6c095a6652 | |||
| d74f424518 | |||
| 992f4e5ad7 | |||
| 13ca8e18c8 | |||
| fbcd04696e | |||
| 037c8b5fae | |||
| 7563b62b80 | |||
| 3edeb21bb7 | |||
| 9c4780c82e | |||
| 3785a7109a | |||
| 3f4313095f | |||
| 05aeae1b14 | |||
| 2cedca58fe | |||
| 02eba3ce64 | |||
| 7461d4e486 | |||
| f445ab013c | |||
| f88e01e59b | |||
| 8b5fc3656b | |||
| 06af7939e4 | |||
| e12965c7df | |||
| 7afd1318db | |||
| 6a374d2f32 | |||
| cec034721f | |||
| 2d70632d3e | |||
| 41c554d748 | |||
| 4e4933d861 | |||
| a5bb31b844 |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": ["drizzle.config.ts"],
|
||||
"patterns": [
|
||||
"scripts/**",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/examples/**",
|
||||
"e2e/**",
|
||||
".github/scripts/**",
|
||||
"apps/desktop/**"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,22 @@ alwaysApply: false
|
||||
|
||||
# Database Migrations Guide
|
||||
|
||||
## Defensive Programming - Use Idempotent Clauses
|
||||
## Step1: Generate migrations:
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
this step will generate or update the following files:
|
||||
|
||||
- packages/database/migrations/0046_xxx.sql
|
||||
- packages/database/migrations/meta/\_journal.json
|
||||
|
||||
## Step2: optimize the migration sql fileName
|
||||
|
||||
the migration sql file name is randomly generated, we need to optimize the file name to make it more readable and meaningful. For example, `0046_xxx.sql` -> `0046_better_auth.sql`
|
||||
|
||||
## Step3: Defensive Programming - Use Idempotent Clauses
|
||||
|
||||
Always use defensive clauses to make migrations idempotent:
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ logo emoji: 🤯
|
||||
|
||||
## Project Technologies Stack
|
||||
|
||||
- Next.js 15
|
||||
- Next.js 16
|
||||
- react 19
|
||||
- TypeScript
|
||||
- `@lobehub/ui`, antd for component framework
|
||||
|
||||
@@ -16,17 +16,28 @@ lobe-chat/
|
||||
├── apps/
|
||||
│ └── desktop/
|
||||
├── docs/
|
||||
│ ├── changelog/
|
||||
│ ├── development/
|
||||
│ ├── self-hosting/
|
||||
│ └── usage/
|
||||
├── locales/
|
||||
│ ├── en-US/
|
||||
│ └── zh-CN/
|
||||
├── packages/
|
||||
│ ├── agent-runtime/
|
||||
│ ├── const/
|
||||
│ ├── context-engine/
|
||||
│ ├── conversation-flow/
|
||||
│ ├── database/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ └── repositories/
|
||||
│ ├── electron-client-ipc/
|
||||
│ ├── electron-server-ipc/
|
||||
│ ├── fetch-sse/
|
||||
│ ├── file-loaders/
|
||||
│ ├── memory-extract/
|
||||
│ ├── model-bank/
|
||||
│ │ └── src/
|
||||
│ │ └── aiModels/
|
||||
@@ -34,11 +45,16 @@ lobe-chat/
|
||||
│ │ └── src/
|
||||
│ │ ├── core/
|
||||
│ │ └── providers/
|
||||
│ ├── obervability-otel/
|
||||
│ ├── prompts/
|
||||
│ ├── python-interpreter/
|
||||
│ ├── ssrf-safe-fetch/
|
||||
│ ├── types/
|
||||
│ │ └── src/
|
||||
│ │ ├── message/
|
||||
│ │ └── user/
|
||||
│ └── utils/
|
||||
│ ├── utils/
|
||||
│ └── web-crawler/
|
||||
├── public/
|
||||
├── scripts/
|
||||
├── src/
|
||||
@@ -68,7 +84,9 @@ lobe-chat/
|
||||
│ │ ├── AuthProvider/
|
||||
│ │ └── GlobalProvider/
|
||||
│ ├── libs/
|
||||
│ │ └── oidc-provider/
|
||||
│ │ ├── better-auth/
|
||||
│ │ ├── oidc-provider/
|
||||
│ │ └── trpc/
|
||||
│ ├── locales/
|
||||
│ │ └── default/
|
||||
│ ├── server/
|
||||
|
||||
+165
-62
@@ -4,9 +4,9 @@
|
||||
# Specify your API Key selection method, currently supporting `random` and `turn`.
|
||||
# API_KEY_SELECT_MODE=random
|
||||
|
||||
########################################
|
||||
########### Security Settings ###########
|
||||
########################################
|
||||
# #######################################
|
||||
# ########## Security Settings ###########
|
||||
# #######################################
|
||||
|
||||
# Control Content Security Policy headers
|
||||
# Set to '1' to enable X-Frame-Options and Content-Security-Policy headers
|
||||
@@ -24,11 +24,31 @@
|
||||
# Example: Allow specific internal servers while keeping SSRF protection
|
||||
# SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
|
||||
|
||||
########################################
|
||||
############ Redis Settings ############
|
||||
########################################
|
||||
|
||||
# Connection string for self-hosted Redis (Docker/K8s/managed). Use container hostname when running via docker-compose.
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Optional database index.
|
||||
# REDIS_DATABASE=0
|
||||
|
||||
# Optional authentication for managed Redis.
|
||||
# REDIS_USERNAME=default
|
||||
# REDIS_PASSWORD=yourpassword
|
||||
|
||||
# Set to '1' to enforce TLS when connecting to managed Redis or rediss:// endpoints.
|
||||
# REDIS_TLS=0
|
||||
|
||||
# Namespace prefix for cache/queue keys.
|
||||
# REDIS_PREFIX=lobechat
|
||||
|
||||
########################################
|
||||
########## AI Provider Service #########
|
||||
########################################
|
||||
|
||||
### OpenAI ###
|
||||
# ## OpenAI ###
|
||||
|
||||
# you openai api key
|
||||
OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
@@ -40,7 +60,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# OPENAI_MODEL_LIST=gpt-3.5-turbo
|
||||
|
||||
|
||||
### Azure OpenAI ###
|
||||
# ## Azure OpenAI ###
|
||||
|
||||
# you can learn azure OpenAI Service on https://learn.microsoft.com/en-us/azure/ai-services/openai/overview
|
||||
# use Azure OpenAI Service by uncomment the following line
|
||||
@@ -55,7 +75,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# AZURE_API_VERSION=2024-10-21
|
||||
|
||||
|
||||
### Anthropic Service ####
|
||||
# ## Anthropic Service ####
|
||||
|
||||
# ANTHROPIC_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
@@ -63,19 +83,19 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
|
||||
|
||||
|
||||
### Google AI ####
|
||||
# ## Google AI ####
|
||||
|
||||
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
### AWS Bedrock ###
|
||||
# ## AWS Bedrock ###
|
||||
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxx
|
||||
# AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
### Ollama AI ####
|
||||
# ## Ollama AI ####
|
||||
|
||||
# You can use ollama to get and run LLM locally, learn more about it via https://github.com/ollama/ollama
|
||||
|
||||
@@ -85,132 +105,132 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# OLLAMA_MODEL_LIST=your_ollama_model_names
|
||||
|
||||
|
||||
### OpenRouter Service ###
|
||||
# ## OpenRouter Service ###
|
||||
|
||||
# OPENROUTER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# OPENROUTER_MODEL_LIST=model1,model2,model3
|
||||
|
||||
|
||||
### Mistral AI ###
|
||||
# ## Mistral AI ###
|
||||
|
||||
# MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### Perplexity Service ###
|
||||
# ## Perplexity Service ###
|
||||
|
||||
# PERPLEXITY_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### Groq Service ####
|
||||
# ## Groq Service ####
|
||||
|
||||
# GROQ_API_KEY=gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
#### 01.AI Service ####
|
||||
# ### 01.AI Service ####
|
||||
|
||||
# ZEROONE_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### TogetherAI Service ###
|
||||
# ## TogetherAI Service ###
|
||||
|
||||
# TOGETHERAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### ZhiPu AI ###
|
||||
# ## ZhiPu AI ###
|
||||
|
||||
# ZHIPU_API_KEY=xxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxx
|
||||
|
||||
### Moonshot AI ####
|
||||
# ## Moonshot AI ####
|
||||
|
||||
# MOONSHOT_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### Minimax AI ####
|
||||
# ## Minimax AI ####
|
||||
|
||||
# MINIMAX_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### DeepSeek AI ####
|
||||
# ## DeepSeek AI ####
|
||||
|
||||
# DEEPSEEK_PROXY_URL=https://api.deepseek.com/v1
|
||||
# DEEPSEEK_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### Qiniu AI ####
|
||||
# ## Qiniu AI ####
|
||||
|
||||
# QINIU_PROXY_URL=https://api.qnaigc.com/v1
|
||||
# QINIU_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### Qwen AI ####
|
||||
# ## Qwen AI ####
|
||||
|
||||
# QWEN_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### Cloudflare Workers AI ####
|
||||
# ## Cloudflare Workers AI ####
|
||||
|
||||
# CLOUDFLARE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### SiliconCloud AI ####
|
||||
# ## SiliconCloud AI ####
|
||||
|
||||
# SILICONCLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
### TencentCloud AI ####
|
||||
# ## TencentCloud AI ####
|
||||
|
||||
# TENCENT_CLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### PPIO ####
|
||||
# ## PPIO ####
|
||||
|
||||
# PPIO_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### INFINI-AI ###
|
||||
# ## INFINI-AI ###
|
||||
|
||||
# INFINIAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
### 302.AI ###
|
||||
# ## 302.AI ###
|
||||
|
||||
# AI302_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### ModelScope ###
|
||||
# ## ModelScope ###
|
||||
|
||||
# MODELSCOPE_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### AiHubMix ###
|
||||
# ## AiHubMix ###
|
||||
|
||||
# AIHUBMIX_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### BFL ###
|
||||
# ## BFL ###
|
||||
|
||||
# BFL_API_KEY=bfl-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### FAL ###
|
||||
# ## FAL ###
|
||||
|
||||
# FAL_API_KEY=fal-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
########################################
|
||||
######### AI Image Settings ############
|
||||
########################################
|
||||
# #######################################
|
||||
# ######## AI Image Settings ############
|
||||
# #######################################
|
||||
|
||||
# Default image generation count (range: 1-20, default: 4)
|
||||
# AI_IMAGE_DEFAULT_IMAGE_NUM=4
|
||||
|
||||
### Nebius ###
|
||||
# ## Nebius ###
|
||||
|
||||
# NEBIUS_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
### NewAPI Service ###
|
||||
# ## NewAPI Service ###
|
||||
|
||||
# NEWAPI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# NEWAPI_PROXY_URL=https://your-newapi-server.com
|
||||
|
||||
### Vercel AI Gateway ###
|
||||
# ## Vercel AI Gateway ###
|
||||
|
||||
# VERCELAIGATEWAY_API_KEY=your_vercel_ai_gateway_api_key
|
||||
|
||||
|
||||
########################################
|
||||
############ Market Service ############
|
||||
########################################
|
||||
# #######################################
|
||||
# ########### Market Service ############
|
||||
# #######################################
|
||||
|
||||
# The LobeChat agents market index url
|
||||
# AGENTS_INDEX_URL=https://chat-agents.lobehub.com
|
||||
|
||||
########################################
|
||||
############ Plugin Service ############
|
||||
########################################
|
||||
# #######################################
|
||||
# ########### Plugin Service ############
|
||||
# #######################################
|
||||
|
||||
# The LobeChat plugins store index url
|
||||
# PLUGINS_INDEX_URL=https://chat-plugins.lobehub.com
|
||||
@@ -219,9 +239,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# the format is `plugin-identifier:key1=value1;key2=value2`, multiple settings fields are separated by semicolons `;`, multiple plugin settings are separated by commas `,`.
|
||||
# PLUGIN_SETTINGS=search-engine:SERPAPI_API_KEY=xxxxx
|
||||
|
||||
########################################
|
||||
####### Doc / Changelog Service ########
|
||||
########################################
|
||||
# #######################################
|
||||
# ###### Doc / Changelog Service ########
|
||||
# #######################################
|
||||
|
||||
# Use in Changelog / Document service cdn url prefix
|
||||
# DOC_S3_PUBLIC_DOMAIN=https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -231,9 +251,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# DOC_S3_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
########################################
|
||||
##### S3 Object Storage Service ########
|
||||
########################################
|
||||
# #######################################
|
||||
# #### S3 Object Storage Service ########
|
||||
# #######################################
|
||||
|
||||
# S3 keys
|
||||
# S3_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -253,19 +273,19 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# S3_REGION=us-west-1
|
||||
|
||||
|
||||
########################################
|
||||
############ Auth Service ##############
|
||||
########################################
|
||||
# #######################################
|
||||
# ########### Auth Service ##############
|
||||
# #######################################
|
||||
|
||||
|
||||
# Clerk related configurations
|
||||
|
||||
# Clerk public key and secret key
|
||||
#NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxxxxxxxx
|
||||
#CLERK_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxx
|
||||
# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxxxxxxxx
|
||||
# CLERK_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# you need to config the clerk webhook secret key if you want to use the clerk with database
|
||||
#CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxx
|
||||
# CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Clear allow origin https://clerk.com/docs/guides/dashboard/dns-domains/satellite-domains
|
||||
# Authentication across different domains , use,to splite different origin
|
||||
@@ -280,23 +300,106 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# AUTH_AUTH0_SECRET=
|
||||
# AUTH_AUTH0_ISSUER=https://your-domain.auth0.com
|
||||
|
||||
########################################
|
||||
########## Server Database #############
|
||||
########################################
|
||||
# Better-Auth related configurations
|
||||
# NEXT_PUBLIC_ENABLE_BETTER_AUTH=1
|
||||
|
||||
# Auth Secret (use `openssl rand -base64 32` to generate)
|
||||
# Shared between Better-Auth and Next-Auth
|
||||
# AUTH_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Auth URL (accessible from browser, optional if same domain)
|
||||
# NEXT_PUBLIC_AUTH_URL=http://localhost:3210
|
||||
|
||||
# Require email verification before allowing users to sign in (default: false)
|
||||
# Set to '1' to force users to verify their email before signing in
|
||||
# NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0
|
||||
|
||||
# SSO Providers Configuration (for Better-Auth)
|
||||
# Comma-separated list of enabled OAuth providers
|
||||
# Supported providers: auth0, authelia, authentik, casdoor, cloudflare-zero-trust, cognito, generic-oidc, github, google, keycloak, logto, microsoft, microsoft-entra-id, okta, zitadel
|
||||
# Example: AUTH_SSO_PROVIDERS=google,github,auth0,microsoft-entra-id
|
||||
# AUTH_SSO_PROVIDERS=
|
||||
|
||||
# Google OAuth Configuration (for Better-Auth)
|
||||
# Get credentials from: https://console.cloud.google.com/apis/credentials
|
||||
# Authorized redirect URIs:
|
||||
# - Development: http://localhost:3210/api/auth/callback/google
|
||||
# - Production: https://yourdomain.com/api/auth/callback/google
|
||||
# GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||
# GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# GitHub OAuth Configuration (for Better-Auth)
|
||||
# Get credentials from: https://github.com/settings/developers
|
||||
# Create a new OAuth App with:
|
||||
# Authorized callback URL:
|
||||
# - Development: http://localhost:3210/api/auth/callback/github
|
||||
# - Production: https://yourdomain.com/api/auth/callback/github
|
||||
# GITHUB_CLIENT_ID=Ov23xxxxxxxxxxxxx
|
||||
# GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# AWS Cognito OAuth Configuration (for Better-Auth)
|
||||
# Get credentials from: https://console.aws.amazon.com/cognito
|
||||
# Setup steps:
|
||||
# 1. Create a User Pool with App Client
|
||||
# 2. Configure Hosted UI domain
|
||||
# 3. Enable "Authorization code grant" OAuth flow
|
||||
# 4. Set OAuth scopes: openid, profile, email
|
||||
# Authorized callback URL:
|
||||
# - Development: http://localhost:3210/api/auth/callback/cognito
|
||||
# - Production: https://yourdomain.com/api/auth/callback/cognito
|
||||
# COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxx
|
||||
# COGNITO_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# COGNITO_DOMAIN=your-app.auth.us-east-1.amazoncognito.com
|
||||
# COGNITO_REGION=us-east-1
|
||||
# COGNITO_USERPOOL_ID=us-east-1_xxxxxxxxx
|
||||
|
||||
# Microsoft OAuth Configuration (for Better-Auth)
|
||||
# Get credentials from: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
|
||||
# Create a new App Registration in Microsoft Entra ID (Azure AD)
|
||||
# Authorized redirect URL:
|
||||
# - Development: http://localhost:3210/api/auth/callback/microsoft
|
||||
# - Production: https://yourdomain.com/api/auth/callback/microsoft
|
||||
# MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
# MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# #######################################
|
||||
# ########## Email Service ##############
|
||||
# #######################################
|
||||
|
||||
# SMTP Server Configuration (required for email verification with Better-Auth)
|
||||
|
||||
# SMTP server hostname (e.g., smtp.gmail.com, smtp.office365.com)
|
||||
# SMTP_HOST=smtp.example.com
|
||||
|
||||
# SMTP server port (usually 587 for TLS, or 465 for SSL)
|
||||
# SMTP_PORT=587
|
||||
|
||||
# Use secure connection (set to 'true' for port 465, 'false' for port 587)
|
||||
# SMTP_SECURE=false
|
||||
|
||||
# SMTP authentication username (usually your email address)
|
||||
# SMTP_USER=your-email@example.com
|
||||
|
||||
# SMTP authentication password (use app-specific password for Gmail)
|
||||
# SMTP_PASS=your-password-or-app-specific-password
|
||||
|
||||
# #######################################
|
||||
# ######### Server Database #############
|
||||
# #######################################
|
||||
|
||||
# Postgres database URL
|
||||
# DATABASE_URL=postgres://username:password@host:port/database
|
||||
|
||||
# use `openssl rand -base64 32` to generate a key for the encryption of the database
|
||||
# we use this key to encrypt the user api key and proxy url
|
||||
#KEY_VAULTS_SECRET=xxxxx/xxxxxxxxxxxxxx=
|
||||
# KEY_VAULTS_SECRET=xxxxx/xxxxxxxxxxxxxx=
|
||||
|
||||
# Specify the Embedding model and Reranker model(unImplemented)
|
||||
# DEFAULT_FILES_CONFIG="embedding_model=openai/embedding-text-3-small,reranker_model=cohere/rerank-english-v3.0,query_mode=full_text"
|
||||
|
||||
########################################
|
||||
########## MCP Service Config ##########
|
||||
########################################
|
||||
# #######################################
|
||||
# ######### MCP Service Config ##########
|
||||
# #######################################
|
||||
|
||||
# MCP tool call timeout (milliseconds)
|
||||
# MCP_TOOL_TIMEOUT=60000
|
||||
|
||||
@@ -31,20 +31,23 @@ DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/${LOBE_DB
|
||||
# Database driver type
|
||||
DATABASE_DRIVER=node
|
||||
|
||||
# Redis Cache/Queue Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_PREFIX=lobechat
|
||||
REDIS_TLS=0
|
||||
|
||||
# Authentication Configuration
|
||||
# Enable NextAuth authentication
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
|
||||
# Enable Better Auth authentication
|
||||
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1
|
||||
|
||||
# NextAuth secret for JWT signing (generate with: openssl rand -base64 32)
|
||||
NEXT_AUTH_SECRET=${UNSAFE_SECRET}
|
||||
|
||||
NEXTAUTH_URL=${APP_URL}
|
||||
# Better Auth secret for JWT signing (generate with: openssl rand -base64 32)
|
||||
AUTH_SECRET=${UNSAFE_SECRET}
|
||||
|
||||
# Authentication URL
|
||||
AUTH_URL=${APP_URL}/api/auth
|
||||
NEXT_PUBLIC_AUTH_URL=${APP_URL}
|
||||
|
||||
# SSO providers configuration - using Casdoor for development
|
||||
NEXT_AUTH_SSO_PROVIDERS=casdoor
|
||||
AUTH_SSO_PROVIDERS=casdoor
|
||||
|
||||
# Casdoor Configuration
|
||||
# Casdoor service port
|
||||
|
||||
@@ -20,15 +20,6 @@ jobs:
|
||||
pull-requests: write # for actions-cool/issues-helper to update PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Auto Comment on Issues Opened
|
||||
uses: wow-actions/auto-comment@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN}}
|
||||
issuesOpened: |
|
||||
👀 @{{ author }}
|
||||
|
||||
Thank you for raising an issue. We will investigate into the matter and get back to you as soon as possible.
|
||||
Please make sure you have given us as much context as possible.
|
||||
- name: Auto Comment on Issues Closed
|
||||
uses: wow-actions/auto-comment@v1
|
||||
with:
|
||||
@@ -37,16 +28,6 @@ jobs:
|
||||
✅ @{{ author }}
|
||||
|
||||
This issue is closed, If you have any questions, you can comment and reply.
|
||||
- name: Auto Comment on Pull Request Opened
|
||||
uses: wow-actions/auto-comment@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN}}
|
||||
pullRequestOpened: |
|
||||
👍 @{{ author }}
|
||||
|
||||
Thank you for raising your pull request and contributing to our Community
|
||||
Please make sure you have followed our contributing guidelines. We will review it as soon as possible.
|
||||
If you encounter any problems, please feel free to connect with us.
|
||||
- name: Auto Comment on Pull Request Merged
|
||||
uses: actions-cool/pr-welcome@main
|
||||
if: github.event.pull_request.merged == true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Desktop PR Build
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发
|
||||
|
||||
# 确保同一 PR 同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
@@ -126,6 +126,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} nightly
|
||||
|
||||
# macOS 构建处理
|
||||
# 注意:fork 的 PR 无法访问 secrets,会构建未签名版本
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
@@ -136,7 +137,7 @@ jobs:
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
# 默认添加一个加密 SECRET
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
# macOS 签名和公证配置
|
||||
# macOS 签名和公证配置(fork 的 PR 访问不到 secrets,会跳过签名)
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
@@ -148,7 +149,8 @@ jobs:
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Windows 平台构建处理
|
||||
# Windows 平台构建处理
|
||||
# 注意:fork 的 PR 无法访问 secrets,会构建未签名版本
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
@@ -230,7 +232,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -275,6 +277,8 @@ jobs:
|
||||
publish-pr:
|
||||
needs: [merge-mac-files, version]
|
||||
name: Publish PR Build
|
||||
# 只为非 fork 的 PR 发布(fork 的 PR 没有写权限)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
# Grant write permissions for creating release and commenting on PR
|
||||
permissions:
|
||||
@@ -1,33 +1,28 @@
|
||||
name: Publish Docker Image
|
||||
name: Docker PR Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发
|
||||
|
||||
# 确保同一 PR 同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
concurrency:
|
||||
group: pr-${{ github.event.pull_request.number }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Add default permissions
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
pull_request_target:
|
||||
types: [synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
# PR 构建时取消旧的运行,但 release 构建不取消
|
||||
cancel-in-progress: ${{ github.event_name != 'release' }}
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: lobehub/lobehub
|
||||
PR_TAG_PREFIX: pr-
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 添加 PR label 触发条件
|
||||
if: |
|
||||
github.event_name == 'release' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'trigger:build-docker'))
|
||||
|
||||
name: Build ${{ matrix.platform }} Docker Image
|
||||
# 添加 PR label 触发条件,只有添加了 trigger:build-docker 标签的 PR 才会触发构建
|
||||
if: contains(github.event.pull_request.labels.*.name, 'trigger:build-docker')
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -36,14 +31,13 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build ${{ matrix.platform }} Image
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout base
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -51,15 +45,17 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
# 为 PR 生成特殊的 tag,使用 PR 的实际 commit SHA
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: pr_meta
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
sanitized_branch=$(echo "${BRANCH_NAME}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
commit_sha=$(git rev-parse --short HEAD)
|
||||
echo "pr_tag=${sanitized_branch}-${commit_sha}" >> $GITHUB_OUTPUT
|
||||
echo "commit_sha=${commit_sha}" >> $GITHUB_OUTPUT
|
||||
echo "📦 Docker Tag: ${sanitized_branch}-${commit_sha}"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -67,11 +63,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
# PR 构建使用特殊的 tag
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request_target' }}
|
||||
# release 构建使用版本号
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
@@ -79,11 +71,6 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and export
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -93,7 +80,7 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
SHA=${{ steps.pr_meta.outputs.commit_sha }}
|
||||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
@@ -112,11 +99,13 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge
|
||||
name: Merge and Publish
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# 只为非 fork 的 PR 发布(fork 的 PR 没有写权限)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Checkout base
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -133,13 +122,14 @@ jobs:
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: pr_meta
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
sanitized_branch=$(echo "${BRANCH_NAME}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
commit_sha=$(git rev-parse --short HEAD)
|
||||
echo "pr_tag=${sanitized_branch}-${commit_sha}" >> $GITHUB_OUTPUT
|
||||
echo "commit_sha=${commit_sha}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -147,9 +137,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request_target' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
@@ -168,7 +156,6 @@ jobs:
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Comment on PR with Docker build info
|
||||
if: github.event_name == 'pull_request_target'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Publish Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: lobehub/lobehub
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
os: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build ${{ matrix.platform }} Image
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and export
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
rm -rf /tmp/digests
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: lobehub/lobe-chat
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
upstream_sync_branch: next
|
||||
target_sync_branch: next
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pnpm
|
||||
|
||||
+1
-3
@@ -103,8 +103,8 @@ vertex-ai-key.json
|
||||
.local/
|
||||
.claude/
|
||||
.mcp.json
|
||||
|
||||
CLAUDE.local.md
|
||||
.agent/
|
||||
|
||||
# MCP tools
|
||||
.serena/**
|
||||
@@ -115,6 +115,4 @@ CLAUDE.local.md
|
||||
*.doc*
|
||||
*.xls*
|
||||
|
||||
prd
|
||||
GEMINI.md
|
||||
e2e/reports
|
||||
|
||||
@@ -6,13 +6,12 @@ This document serves as a comprehensive guide for all team members when developi
|
||||
|
||||
Built with modern technologies:
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||
- **Frontend**: Next.js 16, React 19, TypeScript
|
||||
- **UI Components**: Ant Design, @lobehub/ui, antd-style
|
||||
- **State Management**: Zustand, SWR
|
||||
- **Database**: PostgreSQL, PGLite, Drizzle ORM
|
||||
- **Testing**: Vitest, Testing Library
|
||||
- **Package Manager**: pnpm (monorepo structure)
|
||||
- **Build Tools**: Next.js (Turbopack in dev, Webpack in prod)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -28,6 +27,7 @@ The project follows a well-organized monorepo structure:
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- The current release branch is `next` instead of `main` until v2.0.0 is officially released
|
||||
- Use rebase for git pull
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `username/feat/feature-name`
|
||||
@@ -38,7 +38,6 @@ The project follows a well-organized monorepo structure:
|
||||
- Use `pnpm` as the primary package manager
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
- Navigate to specific packages using `cd packages/<package-name>`
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
|
||||
+1844
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ read @.cursor/rules/project-structure.mdc
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- The current release branch is `next` instead of `main` until v2.0.0 is officially released
|
||||
- use rebase for git pull
|
||||
- git commit message should prefix with gitmoji
|
||||
- git branch name format example: tj/feat/feature-name
|
||||
@@ -54,6 +55,44 @@ see @.cursor/rules/typescript.mdc
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` and `locales/en-US/namespace.json` locales file only for dev preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
|
||||
## Linear Issue Management
|
||||
|
||||
When working with Linear issues:
|
||||
|
||||
1. **Retrieve issue details** before starting work using `mcp__linear-server__get_issue`
|
||||
2. **Check for sub-issues**: If the issue has sub-issues, retrieve and review ALL sub-issues using `mcp__linear-server__list_issues` with `parentId` filter before starting work
|
||||
3. **Update issue status** when completing tasks using `mcp__linear-server__update_issue`
|
||||
4. **MUST add completion comment** using `mcp__linear-server__create_comment`
|
||||
|
||||
### Completion Comment (REQUIRED)
|
||||
|
||||
**Every time you complete an issue, you MUST add a comment summarizing the work done.** This is critical for:
|
||||
|
||||
- Team visibility and knowledge sharing
|
||||
- Code review context
|
||||
- Future reference and debugging
|
||||
|
||||
### IMPORTANT: Per-Issue Completion Rule
|
||||
|
||||
**When working on multiple issues (e.g., parent issue with sub-issues), you MUST update status and add comment for EACH issue IMMEDIATELY after completing it.** Do NOT wait until all issues are done to update them in batch.
|
||||
|
||||
**Workflow for EACH individual issue:**
|
||||
|
||||
1. Complete the implementation for this specific issue
|
||||
2. Run type check: `bun run type-check`
|
||||
3. Run related tests if applicable
|
||||
4. **IMMEDIATELY** update issue status to "Done": `mcp__linear-server__update_issue`
|
||||
5. **IMMEDIATELY** add completion comment: `mcp__linear-server__create_comment`
|
||||
6. Only then move on to the next issue
|
||||
|
||||
**❌ Wrong approach:**
|
||||
|
||||
- Complete Issue A → Complete Issue B → Complete Issue C → Update all statuses → Add all comments
|
||||
|
||||
**✅ Correct approach:**
|
||||
|
||||
- Complete Issue A → Update A status → Add A comment → Complete Issue B → Update B status → Add B comment → ...
|
||||
|
||||
## Rules Index
|
||||
|
||||
Some useful project rules are listed in @.cursor/rules/rules-index.mdc
|
||||
|
||||
+9
-5
@@ -37,6 +37,7 @@ FROM base AS builder
|
||||
|
||||
ARG USE_CN_MIRROR
|
||||
ARG NEXT_PUBLIC_BASE_PATH
|
||||
ARG NEXT_PUBLIC_ENABLE_BETTER_AUTH
|
||||
ARG NEXT_PUBLIC_ENABLE_NEXT_AUTH
|
||||
ARG NEXT_PUBLIC_ENABLE_CLERK_AUTH
|
||||
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
@@ -52,7 +53,8 @@ ARG FEATURE_FLAGS
|
||||
ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" \
|
||||
FEATURE_FLAGS="${FEATURE_FLAGS}"
|
||||
|
||||
ENV NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-1}" \
|
||||
ENV NEXT_PUBLIC_ENABLE_BETTER_AUTH="${NEXT_PUBLIC_ENABLE_BETTER_AUTH:-0}" \
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-1}" \
|
||||
NEXT_PUBLIC_ENABLE_CLERK_AUTH="${NEXT_PUBLIC_ENABLE_CLERK_AUTH:-0}" \
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}" \
|
||||
CLERK_WEBHOOK_SECRET="whsec_xxx" \
|
||||
@@ -177,10 +179,10 @@ ENV KEY_VAULTS_SECRET="" \
|
||||
DATABASE_DRIVER="node" \
|
||||
DATABASE_URL=""
|
||||
|
||||
# Next Auth
|
||||
ENV NEXT_AUTH_SECRET="" \
|
||||
NEXT_AUTH_SSO_PROVIDERS="" \
|
||||
NEXTAUTH_URL=""
|
||||
# Better Auth
|
||||
ENV AUTH_SECRET="" \
|
||||
AUTH_SSO_PROVIDERS="" \
|
||||
NEXT_PUBLIC_AUTH_URL=""
|
||||
|
||||
# Clerk
|
||||
ENV CLERK_SECRET_KEY="" \
|
||||
@@ -229,6 +231,8 @@ ENV \
|
||||
GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
|
||||
# Google
|
||||
GOOGLE_API_KEY="" GOOGLE_MODEL_LIST="" GOOGLE_PROXY_URL="" \
|
||||
# Vertex AI
|
||||
VERTEXAI_CREDENTIALS="" VERTEXAI_PROJECT="" VERTEXAI_LOCATION="" VERTEXAI_MODEL_LIST="" \
|
||||
# Groq
|
||||
GROQ_API_KEY="" GROQ_MODEL_LIST="" GROQ_PROXY_URL="" \
|
||||
# Higress
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# GEMINI.md
|
||||
|
||||
This document serves as a shared guideline for all team members when using Gemini CLI in this repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
read @.cursor/rules/project-introduce.mdc
|
||||
|
||||
## Directory Structure
|
||||
|
||||
read @.cursor/rules/project-structure.mdc
|
||||
|
||||
## Development
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- use rebase for git pull
|
||||
- git commit message should prefix with gitmoji
|
||||
- git branch name format example: tj/feat/feature-name
|
||||
- use .github/PULL_REQUEST_TEMPLATE.md to generate pull request description
|
||||
|
||||
### Package Management
|
||||
|
||||
This repository adopts a monorepo structure.
|
||||
|
||||
- Use `pnpm` as the primary package manager for dependency management
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
|
||||
### TypeScript Code Style Guide
|
||||
|
||||
see @.cursor/rules/typescript.mdc
|
||||
|
||||
### Testing
|
||||
|
||||
- **Required Rule**: read `@.cursor/rules/testing-guide/testing-guide.mdc` before writing tests
|
||||
- **Command**:
|
||||
- web: `bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
- packages(eg: database): `cd packages/database && bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
|
||||
**Important**:
|
||||
|
||||
- wrap the file path in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` etc to run tests, this will run all tests and cost about 10mins
|
||||
- If trying to fix the same test twice, but still failed, stop and ask for help.
|
||||
|
||||
### Typecheck
|
||||
|
||||
- use `bun run type-check` to check type errors.
|
||||
|
||||
### i18n
|
||||
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` and `locales/en-US/namespace.json` locales file only for dev preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
|
||||
## 🚨 Quality Checks
|
||||
|
||||
**MANDATORY**: After completing code changes, always run `mcp__vscode-mcp__get_diagnostics` on the modified files to identify any errors introduced by your changes and fix them.
|
||||
|
||||
## Rules Index
|
||||
|
||||
Some useful project rules are listed in @.cursor/rules/rules-index.mdc
|
||||
@@ -347,12 +347,12 @@ In addition, these plugins are not limited to news aggregation, but can also ext
|
||||
|
||||
| Recent Submits | Description |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-11-28**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [SEO](https://lobechat.com/discover/plugin/SEO)<br/><sup>By **orrenprunckun** on **2025-11-14**</sup> | Enter any URL and keyword and get an On-Page SEO analysis & insights!<br/>`seo` |
|
||||
| [Shopping tools](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2025-10-27**</sup> | Search for products on eBay & AliExpress, find eBay events & coupons. Get prompt examples.<br/>`shopping` `e-bay` `ali-express` `coupons` |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-09-27**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [Web](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | Smart web search that reads and analyzes pages to deliver comprehensive answers from Google results.<br/>`web` `search` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
|
||||
+2
-2
@@ -340,12 +340,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-11-28**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [SEO](https://lobechat.com/discover/plugin/SEO)<br/><sup>By **orrenprunckun** on **2025-11-14**</sup> | 输入任何 URL 和关键词,获取页面 SEO 分析和见解!<br/>`seo` |
|
||||
| [购物工具](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2025-10-27**</sup> | 在 eBay 和 AliExpress 上搜索产品,查找 eBay 活动和优惠券。获取快速示例。<br/>`购物` `e-bay` `ali-express` `优惠券` |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-09-27**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
|
||||
@@ -45,20 +45,21 @@
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@typescript/native-preview": "7.0.0-dev.20250711.1",
|
||||
"consola": "^3.4.2",
|
||||
"cookie": "^1.0.2",
|
||||
"electron": "^38.7.0",
|
||||
"cookie": "^1.1.1",
|
||||
"diff": "^8.0.2",
|
||||
"electron": "^38.7.2",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-vite": "^3.1.0",
|
||||
"execa": "^9.6.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
"execa": "^9.6.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
@@ -72,7 +73,7 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -33,12 +33,6 @@ export interface RouteInterceptConfig {
|
||||
* 定义了所有需要特殊处理的路由
|
||||
*/
|
||||
export const interceptRoutes: RouteInterceptConfig[] = [
|
||||
{
|
||||
description: '设置页面',
|
||||
enabled: true,
|
||||
pathPrefix: '/settings',
|
||||
targetWindow: 'settings',
|
||||
},
|
||||
{
|
||||
description: '开发者工具',
|
||||
enabled: true,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { BrowserWindowOpts } from './core/browser/Browser';
|
||||
export const BrowsersIdentifiers = {
|
||||
chat: 'chat',
|
||||
devtools: 'devtools',
|
||||
settings: 'settings',
|
||||
};
|
||||
|
||||
export const appBrowsers = {
|
||||
@@ -32,18 +31,6 @@ export const appBrowsers = {
|
||||
vibrancy: 'under-window',
|
||||
width: 1000,
|
||||
},
|
||||
settings: {
|
||||
autoHideMenuBar: true,
|
||||
height: 800,
|
||||
identifier: 'settings',
|
||||
keepAlive: true,
|
||||
minWidth: 600,
|
||||
parentIdentifier: 'chat',
|
||||
path: '/settings',
|
||||
titleBarStyle: 'hidden',
|
||||
vibrancy: 'under-window',
|
||||
width: 1000,
|
||||
},
|
||||
} satisfies Record<string, BrowserWindowOpts>;
|
||||
|
||||
// Window templates for multi-instance windows
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { extractSubPath, findMatchingRoute } from '~common/routes';
|
||||
import { findMatchingRoute } from '~common/routes';
|
||||
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
BrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
} from '@/appBrowsers';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
@@ -24,14 +23,32 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
? { tab: typeof options === 'string' ? options : undefined }
|
||||
: options;
|
||||
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings window', normalizedOptions);
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings', normalizedOptions);
|
||||
|
||||
try {
|
||||
await this.app.browserManager.showSettingsWindowWithTab(normalizedOptions);
|
||||
const query = new URLSearchParams();
|
||||
if (normalizedOptions.searchParams) {
|
||||
Object.entries(normalizedOptions.searchParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
const tab = normalizedOptions.tab;
|
||||
if (tab && tab !== 'common' && !query.has('active')) {
|
||||
query.set('active', tab);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[BrowserWindowsCtr] Failed to open settings window:', error);
|
||||
console.error('[BrowserWindowsCtr] Failed to open settings:', error);
|
||||
return { error: error.message, success: false };
|
||||
}
|
||||
}
|
||||
@@ -76,50 +93,14 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
);
|
||||
|
||||
try {
|
||||
if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) {
|
||||
const extractedSubPath = extractSubPath(path, matchedRoute.pathPrefix);
|
||||
const sanitizedSubPath =
|
||||
extractedSubPath && !extractedSubPath.startsWith('?') ? extractedSubPath : undefined;
|
||||
let searchParams: Record<string, string> | undefined;
|
||||
try {
|
||||
const url = new URL(params.url);
|
||||
const entries = Array.from(url.searchParams.entries());
|
||||
if (entries.length > 0) {
|
||||
searchParams = entries.reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[BrowserWindowsCtr] Failed to parse URL for settings route interception:',
|
||||
params.url,
|
||||
error,
|
||||
);
|
||||
}
|
||||
await this.openTargetWindow(matchedRoute.targetWindow as AppBrowsersIdentifiers);
|
||||
|
||||
await this.app.browserManager.showSettingsWindowWithTab({
|
||||
searchParams,
|
||||
tab: sanitizedSubPath,
|
||||
});
|
||||
|
||||
return {
|
||||
intercepted: true,
|
||||
path,
|
||||
source,
|
||||
subPath: sanitizedSubPath,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
};
|
||||
} else {
|
||||
await this.openTargetWindow(matchedRoute.targetWindow as AppBrowsersIdentifiers);
|
||||
|
||||
return {
|
||||
intercepted: true,
|
||||
path,
|
||||
source,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
};
|
||||
}
|
||||
return {
|
||||
intercepted: true,
|
||||
path,
|
||||
source,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BrowserWindowsCtr] Error while processing route interception:', error);
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WriteLocalFileParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
|
||||
import { createPatch } from 'diff';
|
||||
import { shell } from 'electron';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats, constants } from 'node:fs';
|
||||
@@ -94,26 +95,45 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@ipcClientEvent('readLocalFile')
|
||||
async readFile({ path: filePath, loc }: LocalReadFileParams): Promise<LocalReadFileResult> {
|
||||
const effectiveLoc = loc ?? [0, 200];
|
||||
logger.debug('Starting to read file:', { filePath, loc: effectiveLoc });
|
||||
async readFile({
|
||||
path: filePath,
|
||||
loc,
|
||||
fullContent,
|
||||
}: LocalReadFileParams): Promise<LocalReadFileResult> {
|
||||
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
|
||||
logger.debug('Starting to read file:', { filePath, fullContent, loc: effectiveLoc });
|
||||
|
||||
try {
|
||||
const fileDocument = await loadFile(filePath);
|
||||
|
||||
const [startLine, endLine] = effectiveLoc;
|
||||
const lines = fileDocument.content.split('\n');
|
||||
const totalLineCount = lines.length;
|
||||
const totalCharCount = fileDocument.content.length;
|
||||
|
||||
// Adjust slice indices to be 0-based and inclusive/exclusive
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
const content = selectedLines.join('\n');
|
||||
const charCount = content.length;
|
||||
const lineCount = selectedLines.length;
|
||||
let content: string;
|
||||
let charCount: number;
|
||||
let lineCount: number;
|
||||
let actualLoc: [number, number];
|
||||
|
||||
if (effectiveLoc === undefined) {
|
||||
// Return full content
|
||||
content = fileDocument.content;
|
||||
charCount = totalCharCount;
|
||||
lineCount = totalLineCount;
|
||||
actualLoc = [0, totalLineCount];
|
||||
} else {
|
||||
// Return specified range
|
||||
const [startLine, endLine] = effectiveLoc;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
content = selectedLines.join('\n');
|
||||
charCount = content.length;
|
||||
lineCount = selectedLines.length;
|
||||
actualLoc = effectiveLoc;
|
||||
}
|
||||
|
||||
logger.debug('File read successfully:', {
|
||||
filePath,
|
||||
fullContent,
|
||||
selectedLineCount: lineCount,
|
||||
totalCharCount,
|
||||
totalLineCount,
|
||||
@@ -128,7 +148,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
fileType: fileDocument.fileType,
|
||||
filename: fileDocument.filename,
|
||||
lineCount,
|
||||
loc: effectiveLoc,
|
||||
loc: actualLoc,
|
||||
// Line count for the selected range
|
||||
modifiedTime: fileDocument.modifiedTime,
|
||||
|
||||
@@ -711,8 +731,32 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
// Write back to file
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
|
||||
logger.info(`${logPrefix} File edited successfully`, { replacements });
|
||||
// Generate diff for UI display
|
||||
const patch = createPatch(filePath, content, newContent, '', '');
|
||||
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
|
||||
|
||||
// Calculate lines added and deleted from patch
|
||||
const patchLines = patch.split('\n');
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patchLines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
linesAdded++;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
linesDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} File edited successfully`, {
|
||||
linesAdded,
|
||||
linesDeleted,
|
||||
replacements,
|
||||
});
|
||||
return {
|
||||
diffText,
|
||||
linesAdded,
|
||||
linesDeleted,
|
||||
replacements,
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -9,36 +9,40 @@ import BrowserWindowsCtr from '../BrowserWindowsCtr';
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockToggleVisible = vi.fn();
|
||||
const mockShowSettingsWindowWithTab = vi.fn();
|
||||
const mockLoadUrl = vi.fn();
|
||||
const mockShow = vi.fn();
|
||||
const mockRedirectToPage = vi.fn();
|
||||
const mockCloseWindow = vi.fn();
|
||||
const mockMinimizeWindow = vi.fn();
|
||||
const mockMaximizeWindow = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn();
|
||||
const mockGetMainWindow = vi.fn(() => ({
|
||||
toggleVisible: mockToggleVisible,
|
||||
loadUrl: mockLoadUrl,
|
||||
show: mockShow,
|
||||
}));
|
||||
const mockShow = vi.fn();
|
||||
const mockShowOther = vi.fn();
|
||||
|
||||
// mock findMatchingRoute and extractSubPath
|
||||
vi.mock('~common/routes', async () => ({
|
||||
findMatchingRoute: vi.fn(),
|
||||
extractSubPath: vi.fn(),
|
||||
}));
|
||||
const { findMatchingRoute, extractSubPath } = await import('~common/routes');
|
||||
const { findMatchingRoute } = await import('~common/routes');
|
||||
|
||||
const mockApp = {
|
||||
browserManager: {
|
||||
getMainWindow: mockGetMainWindow,
|
||||
showSettingsWindowWithTab: mockShowSettingsWindowWithTab,
|
||||
redirectToPage: mockRedirectToPage,
|
||||
closeWindow: mockCloseWindow,
|
||||
minimizeWindow: mockMinimizeWindow,
|
||||
maximizeWindow: mockMaximizeWindow,
|
||||
retrieveByIdentifier: mockRetrieveByIdentifier.mockImplementation(
|
||||
(identifier: AppBrowsersIdentifiers | string) => {
|
||||
if (identifier === BrowsersIdentifiers.settings || identifier === 'some-other-window') {
|
||||
return { show: mockShow };
|
||||
if (identifier === 'some-other-window') {
|
||||
return { show: mockShowOther };
|
||||
}
|
||||
return { show: mockShow }; // Default mock for other identifiers
|
||||
return { show: mockShowOther }; // Default mock for other identifiers
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -61,16 +65,18 @@ describe('BrowserWindowsCtr', () => {
|
||||
});
|
||||
|
||||
describe('openSettingsWindow', () => {
|
||||
it('should show the settings window with the specified tab', async () => {
|
||||
it('should navigate to settings in main window with the specified tab', async () => {
|
||||
const tab = 'appearance';
|
||||
const result = await browserWindowsCtr.openSettingsWindow(tab);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({ tab });
|
||||
expect(mockGetMainWindow).toHaveBeenCalled();
|
||||
expect(mockLoadUrl).toHaveBeenCalledWith('/settings?active=appearance');
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error if showing settings window fails', async () => {
|
||||
const errorMessage = 'Failed to show';
|
||||
mockShowSettingsWindowWithTab.mockRejectedValueOnce(new Error(errorMessage));
|
||||
it('should return error if navigation fails', async () => {
|
||||
const errorMessage = 'Failed to navigate';
|
||||
mockLoadUrl.mockRejectedValueOnce(new Error(errorMessage));
|
||||
const result = await browserWindowsCtr.openSettingsWindow('display');
|
||||
expect(result).toEqual({ error: errorMessage, success: false });
|
||||
});
|
||||
@@ -117,36 +123,7 @@ describe('BrowserWindowsCtr', () => {
|
||||
expect(result).toEqual({ intercepted: false, path: params.path, source: params.source });
|
||||
});
|
||||
|
||||
it('should show settings window if matched route target is settings', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/settings/provider',
|
||||
url: 'app://host/settings/provider?active=provider&provider=ollama',
|
||||
};
|
||||
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
|
||||
const subPath = 'provider';
|
||||
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
|
||||
(extractSubPath as Mock).mockReturnValue(subPath);
|
||||
|
||||
const result = await browserWindowsCtr.interceptRoute(params);
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(params.path);
|
||||
expect(extractSubPath).toHaveBeenCalledWith(params.path, matchedRoute.pathPrefix);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
|
||||
searchParams: { active: 'provider', provider: 'ollama' },
|
||||
tab: subPath,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
intercepted: true,
|
||||
path: params.path,
|
||||
source: params.source,
|
||||
subPath,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
});
|
||||
expect(mockShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open target window if matched route target is not settings', async () => {
|
||||
it('should open target window if matched route is found', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/other/page',
|
||||
@@ -160,44 +137,16 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(params.path);
|
||||
expect(mockRetrieveByIdentifier).toHaveBeenCalledWith(targetWindowIdentifier);
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockShowOther).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
intercepted: true,
|
||||
path: params.path,
|
||||
source: params.source,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
});
|
||||
expect(mockShowSettingsWindowWithTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if processing route interception fails for settings', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/settings',
|
||||
url: 'app://host/settings?active=general',
|
||||
};
|
||||
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
|
||||
const subPath = undefined;
|
||||
const errorMessage = 'Processing error for settings';
|
||||
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
|
||||
(extractSubPath as Mock).mockReturnValue(subPath);
|
||||
mockShowSettingsWindowWithTab.mockRejectedValueOnce(new Error(errorMessage));
|
||||
|
||||
const result = await browserWindowsCtr.interceptRoute(params);
|
||||
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
|
||||
searchParams: { active: 'general' },
|
||||
tab: subPath,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
error: errorMessage,
|
||||
intercepted: false,
|
||||
path: params.path,
|
||||
source: params.source,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if processing route interception fails for other window', async () => {
|
||||
it('should return error if processing route interception fails', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/another/custom',
|
||||
|
||||
@@ -183,6 +183,26 @@ describe('LocalFileCtr', () => {
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should read full file content when fullContent is true', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt', fullContent: true });
|
||||
|
||||
expect(result.content).toBe(mockFileContent);
|
||||
expect(result.lineCount).toBe(5);
|
||||
expect(result.charCount).toBe(mockFileContent.length);
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
expect(result.totalCharCount).toBe(mockFileContent.length);
|
||||
expect(result.loc).toEqual([0, 5]);
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
@@ -392,4 +412,137 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEditFile', () => {
|
||||
it('should replace first occurrence successfully', async () => {
|
||||
const originalContent = 'Hello world\nHello again\nGoodbye world';
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(originalContent);
|
||||
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.txt',
|
||||
old_string: 'Hello',
|
||||
new_string: 'Hi',
|
||||
replace_all: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
expect(result.linesAdded).toBe(1);
|
||||
expect(result.linesDeleted).toBe(1);
|
||||
expect(result.diffText).toContain('diff --git a/test/file.txt b/test/file.txt');
|
||||
expect(mockFsPromises.writeFile).toHaveBeenCalledWith(
|
||||
'/test/file.txt',
|
||||
'Hi world\nHello again\nGoodbye world',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace all occurrences when replace_all is true', async () => {
|
||||
const originalContent = 'Hello world\nHello again\nHello there';
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(originalContent);
|
||||
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.txt',
|
||||
old_string: 'Hello',
|
||||
new_string: 'Hi',
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(3);
|
||||
expect(result.linesAdded).toBe(3);
|
||||
expect(result.linesDeleted).toBe(3);
|
||||
expect(mockFsPromises.writeFile).toHaveBeenCalledWith(
|
||||
'/test/file.txt',
|
||||
'Hi world\nHi again\nHi there',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiline replacement correctly', async () => {
|
||||
const originalContent = 'function test() {\n console.log("old");\n}';
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(originalContent);
|
||||
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.js',
|
||||
old_string: 'console.log("old");',
|
||||
new_string: 'console.log("new");\n console.log("added");',
|
||||
replace_all: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
expect(result.linesAdded).toBe(2);
|
||||
expect(result.linesDeleted).toBe(1);
|
||||
});
|
||||
|
||||
it('should return error when old_string is not found', async () => {
|
||||
const originalContent = 'Hello world';
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(originalContent);
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.txt',
|
||||
old_string: 'NonExistent',
|
||||
new_string: 'New',
|
||||
replace_all: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('The specified old_string was not found in the file');
|
||||
expect(result.replacements).toBe(0);
|
||||
expect(mockFsPromises.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
vi.mocked(mockFsPromises.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.txt',
|
||||
old_string: 'Hello',
|
||||
new_string: 'Hi',
|
||||
replace_all: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Permission denied');
|
||||
expect(result.replacements).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle file write error', async () => {
|
||||
const originalContent = 'Hello world';
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(originalContent);
|
||||
vi.mocked(mockFsPromises.writeFile).mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.txt',
|
||||
old_string: 'Hello',
|
||||
new_string: 'Hi',
|
||||
replace_all: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Disk full');
|
||||
});
|
||||
|
||||
it('should generate correct diff format', async () => {
|
||||
const originalContent = 'line 1\nline 2\nline 3';
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue(originalContent);
|
||||
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleEditFile({
|
||||
file_path: '/test/file.txt',
|
||||
old_string: 'line 2',
|
||||
new_string: 'modified line 2',
|
||||
replace_all: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.diffText).toContain('diff --git a/test/file.txt b/test/file.txt');
|
||||
expect(result.diffText).toContain('-line 2');
|
||||
expect(result.diffText).toContain('+modified line 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import McpInstallController from '../McpInstallCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserManager = {
|
||||
broadcastToWindow: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
browserManager: mockBrowserManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('McpInstallController', () => {
|
||||
let controller: McpInstallController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new McpInstallController(mockApp);
|
||||
});
|
||||
|
||||
describe('handleInstallRequest', () => {
|
||||
const validStdioSchema = {
|
||||
identifier: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
author: 'Test Author',
|
||||
description: 'A test plugin',
|
||||
version: '1.0.0',
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'test-mcp-server'],
|
||||
},
|
||||
};
|
||||
|
||||
const validHttpSchema = {
|
||||
identifier: 'test-http-plugin',
|
||||
name: 'Test HTTP Plugin',
|
||||
author: 'Test Author',
|
||||
description: 'A test HTTP plugin',
|
||||
version: '1.0.0',
|
||||
config: {
|
||||
type: 'http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
},
|
||||
};
|
||||
|
||||
it('should return false when id is missing', async () => {
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: '',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when schema is missing for third-party marketplace', async () => {
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should succeed for official market without schema', async () => {
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'lobehub',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockBrowserManager.broadcastToWindow).toHaveBeenCalledWith(
|
||||
'chat',
|
||||
'mcpInstallRequest',
|
||||
{
|
||||
marketId: 'lobehub',
|
||||
pluginId: 'test-plugin',
|
||||
schema: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when schema is invalid JSON', async () => {
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: 'invalid json {',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when schema structure is invalid', async () => {
|
||||
const invalidSchema = {
|
||||
identifier: 'test-plugin',
|
||||
// missing required fields
|
||||
};
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(invalidSchema),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when schema identifier does not match id', async () => {
|
||||
const schema = { ...validStdioSchema, identifier: 'different-id' };
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(schema),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should succeed with valid stdio schema', async () => {
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(validStdioSchema),
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockBrowserManager.broadcastToWindow).toHaveBeenCalledWith(
|
||||
'chat',
|
||||
'mcpInstallRequest',
|
||||
{
|
||||
marketId: 'third-party',
|
||||
pluginId: 'test-plugin',
|
||||
schema: validStdioSchema,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed with valid http schema', async () => {
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-http-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(validHttpSchema),
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockBrowserManager.broadcastToWindow).toHaveBeenCalledWith(
|
||||
'chat',
|
||||
'mcpInstallRequest',
|
||||
{
|
||||
marketId: 'third-party',
|
||||
pluginId: 'test-http-plugin',
|
||||
schema: validHttpSchema,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when http schema has invalid URL', async () => {
|
||||
const invalidHttpSchema = {
|
||||
...validHttpSchema,
|
||||
config: {
|
||||
type: 'http',
|
||||
url: 'not-a-valid-url',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-http-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(invalidHttpSchema),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when config type is unknown', async () => {
|
||||
const unknownTypeSchema = {
|
||||
...validStdioSchema,
|
||||
config: {
|
||||
type: 'unknown',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(unknownTypeSchema),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockBrowserManager.broadcastToWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when browserManager is not available', async () => {
|
||||
const controllerWithoutBrowserManager = new McpInstallController({} as App);
|
||||
|
||||
const result = await controllerWithoutBrowserManager.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'lobehub',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle schema with optional fields', async () => {
|
||||
const schemaWithOptionalFields = {
|
||||
...validStdioSchema,
|
||||
homepage: 'https://example.com',
|
||||
icon: 'https://example.com/icon.png',
|
||||
};
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(schemaWithOptionalFields),
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockBrowserManager.broadcastToWindow).toHaveBeenCalledWith(
|
||||
'chat',
|
||||
'mcpInstallRequest',
|
||||
expect.objectContaining({
|
||||
schema: schemaWithOptionalFields,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when stdio config missing command', async () => {
|
||||
const invalidStdioSchema = {
|
||||
...validStdioSchema,
|
||||
config: {
|
||||
type: 'stdio',
|
||||
// missing command
|
||||
},
|
||||
};
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(invalidStdioSchema),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle schema with env configuration', async () => {
|
||||
const schemaWithEnv = {
|
||||
...validStdioSchema,
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'test-mcp-server'],
|
||||
env: {
|
||||
API_KEY: 'test-key',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await controller.handleInstallRequest({
|
||||
id: 'test-plugin',
|
||||
marketId: 'third-party',
|
||||
schema: JSON.stringify(schemaWithEnv),
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { ShowDesktopNotificationParams } from '@lobechat/electron-client-ipc';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import NotificationCtr from '../NotificationCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => {
|
||||
const mockNotificationInstance = {
|
||||
on: vi.fn(),
|
||||
show: vi.fn(),
|
||||
};
|
||||
const MockNotification = vi.fn(() => mockNotificationInstance) as any;
|
||||
MockNotification.isSupported = vi.fn(() => true);
|
||||
|
||||
return {
|
||||
Notification: MockNotification,
|
||||
app: {
|
||||
setAppUserModelId: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserWindow = {
|
||||
focus: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isFocused: vi.fn(() => true),
|
||||
isMinimized: vi.fn(() => false),
|
||||
isVisible: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockMainWindow = {
|
||||
browserWindow: mockBrowserWindow,
|
||||
show: vi.fn(),
|
||||
};
|
||||
|
||||
const mockBrowserManager = {
|
||||
getMainWindow: vi.fn(() => mockMainWindow),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
browserManager: mockBrowserManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('NotificationCtr', () => {
|
||||
let controller: NotificationCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
controller = new NotificationCtr(mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should setup notifications when supported', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
expect(Notification.isSupported).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not setup when notifications are not supported', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(false);
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
expect(Notification.isSupported).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set app user model ID on Windows', async () => {
|
||||
const { windows } = await import('electron-is');
|
||||
const { app, Notification } = await import('electron');
|
||||
vi.mocked(windows).mockReturnValue(true);
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
expect(app.setAppUserModelId).toHaveBeenCalledWith('com.lobehub.chat');
|
||||
|
||||
vi.mocked(windows).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should handle macOS platform', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
|
||||
// Should not throw
|
||||
expect(() => controller.afterAppReady()).not.toThrow();
|
||||
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showDesktopNotification', () => {
|
||||
const params: ShowDesktopNotificationParams = {
|
||||
body: 'Test body',
|
||||
title: 'Test title',
|
||||
};
|
||||
|
||||
it('should return error when notifications are not supported', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = await controller.showDesktopNotification(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Desktop notifications not supported',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip notification when window is visible and focused', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(false);
|
||||
|
||||
const result = await controller.showDesktopNotification(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
reason: 'Window is visible',
|
||||
skipped: true,
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show notification when window is hidden', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
const promise = controller.showDesktopNotification(params);
|
||||
vi.advanceTimersByTime(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(Notification).toHaveBeenCalledWith({
|
||||
body: 'Test body',
|
||||
hasReply: false,
|
||||
silent: false,
|
||||
timeoutType: 'default',
|
||||
title: 'Test title',
|
||||
urgency: 'normal',
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should show notification when window is minimized', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(true);
|
||||
|
||||
const promise = controller.showDesktopNotification(params);
|
||||
vi.advanceTimersByTime(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(Notification).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should show notification when window is not focused', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(false);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(false);
|
||||
|
||||
const promise = controller.showDesktopNotification(params);
|
||||
vi.advanceTimersByTime(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(Notification).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should pass silent option to notification', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
const paramsWithSilent: ShowDesktopNotificationParams = {
|
||||
...params,
|
||||
silent: true,
|
||||
};
|
||||
|
||||
const promise = controller.showDesktopNotification(paramsWithSilent);
|
||||
vi.advanceTimersByTime(100);
|
||||
await promise;
|
||||
|
||||
expect(Notification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register click handler to show main window', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
// Get the mock instance that will be created
|
||||
const mockInstance = { on: vi.fn(), show: vi.fn() };
|
||||
vi.mocked(Notification).mockReturnValue(mockInstance as any);
|
||||
|
||||
const promise = controller.showDesktopNotification(params);
|
||||
vi.advanceTimersByTime(100);
|
||||
await promise;
|
||||
|
||||
// Find the click handler
|
||||
const clickHandler = mockInstance.on.mock.calls.find((call) => call[0] === 'click')?.[1];
|
||||
|
||||
expect(clickHandler).toBeDefined();
|
||||
|
||||
// Simulate click
|
||||
clickHandler();
|
||||
|
||||
expect(mockMainWindow.show).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle notification error', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
vi.mocked(Notification).mockImplementationOnce(() => {
|
||||
throw new Error('Notification error');
|
||||
});
|
||||
|
||||
const result = await controller.showDesktopNotification(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Notification error',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown error type', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
vi.mocked(Notification).mockImplementationOnce(() => {
|
||||
throw 'string error';
|
||||
});
|
||||
|
||||
const result = await controller.showDesktopNotification(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Unknown error',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMainWindowHidden', () => {
|
||||
it('should return false when window is visible and focused', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(false);
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
const result = controller.isMainWindowHidden();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when window is not visible', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(false);
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
const result = controller.isMainWindowHidden();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when window is minimized', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(true);
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
const result = controller.isMainWindowHidden();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when window is not focused', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(false);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(false);
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
const result = controller.isMainWindowHidden();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when window is destroyed', () => {
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(true);
|
||||
|
||||
const result = controller.isMainWindowHidden();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true on error', () => {
|
||||
mockBrowserManager.getMainWindow.mockImplementationOnce(() => {
|
||||
throw new Error('Window not available');
|
||||
});
|
||||
|
||||
const result = controller.isMainWindowHidden();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
||||
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock @/const/env
|
||||
vi.mock('@/const/env', () => ({
|
||||
OFFICIAL_CLOUD_SERVER: 'https://cloud.lobehub.com',
|
||||
}));
|
||||
|
||||
// Mock storeManager
|
||||
const mockStoreManager = {
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
storeManager: mockStoreManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('RemoteServerConfigCtr', () => {
|
||||
let controller: RemoteServerConfigCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'local',
|
||||
});
|
||||
controller = new RemoteServerConfigCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('getRemoteServerConfig', () => {
|
||||
it('should return stored configuration', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://my-server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(config);
|
||||
|
||||
const result = await controller.getRemoteServerConfig();
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(mockStoreManager.get).toHaveBeenCalledWith('dataSyncConfig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRemoteServerConfig', () => {
|
||||
it('should update configuration', async () => {
|
||||
const prevConfig: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'local',
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(prevConfig);
|
||||
|
||||
const newConfig: Partial<DataSyncConfig> = {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://my-server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
|
||||
const result = await controller.setRemoteServerConfig(newConfig);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', {
|
||||
...prevConfig,
|
||||
...newConfig,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearRemoteServerConfig', () => {
|
||||
it('should clear configuration and tokens', async () => {
|
||||
const result = await controller.clearRemoteServerConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', { storageMode: 'local' });
|
||||
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTokens', () => {
|
||||
it('should save encrypted tokens with expiration', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
await controller.saveTokens('access-token', 'refresh-token', 3600);
|
||||
|
||||
expect(safeStorage.encryptString).toHaveBeenCalledWith('access-token');
|
||||
expect(safeStorage.encryptString).toHaveBeenCalledWith('refresh-token');
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
||||
'encryptedTokens',
|
||||
expect.objectContaining({
|
||||
accessToken: expect.any(String),
|
||||
expiresAt: expect.any(Number),
|
||||
refreshToken: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should save tokens without expiration', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
await controller.saveTokens('access-token', 'refresh-token');
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
||||
'encryptedTokens',
|
||||
expect.objectContaining({
|
||||
accessToken: expect.any(String),
|
||||
expiresAt: undefined,
|
||||
refreshToken: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should save unencrypted tokens when encryption is not available', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
|
||||
|
||||
await controller.saveTokens('access-token', 'refresh-token', 3600);
|
||||
|
||||
expect(safeStorage.encryptString).not.toHaveBeenCalled();
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
||||
'encryptedTokens',
|
||||
expect.objectContaining({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('should return decrypted access token', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
// First save a token
|
||||
await controller.saveTokens('test-access-token', 'test-refresh-token');
|
||||
|
||||
const result = await controller.getAccessToken();
|
||||
|
||||
expect(result).toBe('test-access-token');
|
||||
});
|
||||
|
||||
it('should load token from store if not in memory', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockReturnValue('stored-access-token');
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return {
|
||||
accessToken: Buffer.from('stored-access-token').toString('base64'),
|
||||
refreshToken: Buffer.from('stored-refresh-token').toString('base64'),
|
||||
};
|
||||
}
|
||||
return { active: false, storageMode: 'local' };
|
||||
});
|
||||
|
||||
// Create new controller to test loading from store
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
const result = await newController.getAccessToken();
|
||||
|
||||
expect(result).toBe('stored-access-token');
|
||||
});
|
||||
|
||||
it('should return null when no token exists', async () => {
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return null;
|
||||
}
|
||||
return { active: false, storageMode: 'local' };
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
const result = await newController.getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return raw token when encryption is not available', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
|
||||
|
||||
await controller.saveTokens('raw-access-token', 'raw-refresh-token');
|
||||
const result = await controller.getAccessToken();
|
||||
|
||||
expect(result).toBe('raw-access-token');
|
||||
});
|
||||
|
||||
it('should return null on decryption error', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
||||
throw new Error('Decryption failed');
|
||||
});
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return {
|
||||
accessToken: 'invalid-encrypted-token',
|
||||
refreshToken: 'invalid-encrypted-token',
|
||||
};
|
||||
}
|
||||
return { active: false, storageMode: 'local' };
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
const result = await newController.getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefreshToken', () => {
|
||||
it('should return decrypted refresh token', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
buffer.toString(),
|
||||
);
|
||||
|
||||
await controller.saveTokens('test-access-token', 'test-refresh-token');
|
||||
|
||||
const result = await controller.getRefreshToken();
|
||||
|
||||
expect(result).toBe('test-refresh-token');
|
||||
});
|
||||
|
||||
it('should return null when no token exists', async () => {
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return null;
|
||||
}
|
||||
return { active: false, storageMode: 'local' };
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
const result = await newController.getRefreshToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearTokens', () => {
|
||||
it('should clear all tokens from memory and store', async () => {
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
await controller.clearTokens();
|
||||
|
||||
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
|
||||
|
||||
// Verify tokens are cleared from memory
|
||||
const accessToken = await controller.getAccessToken();
|
||||
expect(accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenExpiresAt', () => {
|
||||
it('should return expiration time after saving tokens with expiration', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
const beforeSave = Date.now();
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
const afterSave = Date.now();
|
||||
|
||||
const expiresAt = controller.getTokenExpiresAt();
|
||||
|
||||
expect(expiresAt).toBeDefined();
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(beforeSave + 3600 * 1000);
|
||||
expect(expiresAt).toBeLessThanOrEqual(afterSave + 3600 * 1000);
|
||||
});
|
||||
|
||||
it('should return undefined when no expiration is set', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
await controller.saveTokens('access', 'refresh');
|
||||
|
||||
const expiresAt = controller.getTokenExpiresAt();
|
||||
|
||||
expect(expiresAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTokenExpiringSoon', () => {
|
||||
it('should return false when no expiration is set', () => {
|
||||
const result = controller.isTokenExpiringSoon();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when token is not expiring soon', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
// Token expires in 1 hour
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
|
||||
// Default buffer is 5 minutes
|
||||
const result = controller.isTokenExpiringSoon();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when token is within buffer time', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
// Token expires in 2 minutes
|
||||
await controller.saveTokens('access', 'refresh', 120);
|
||||
|
||||
// Default buffer is 5 minutes, so token is expiring soon
|
||||
const result = controller.isTokenExpiringSoon();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect custom buffer time', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
// Token expires in 10 minutes
|
||||
await controller.saveTokens('access', 'refresh', 600);
|
||||
|
||||
// With 15 minute buffer, should be expiring soon
|
||||
const result = controller.isTokenExpiringSoon(15 * 60 * 1000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
it('should return error when remote server is not active', async () => {
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return { active: false, storageMode: 'local' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await controller.refreshAccessToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not active');
|
||||
});
|
||||
|
||||
it('should return error when no refresh token available', async () => {
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
}
|
||||
if (key === 'encryptedTokens') {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
const result = await newController.refreshAccessToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No refresh token');
|
||||
});
|
||||
|
||||
it('should refresh token successfully', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
buffer.toString(),
|
||||
);
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Save initial tokens
|
||||
await controller.saveTokens('old-access', 'old-refresh');
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token',
|
||||
}),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await controller.refreshAccessToken();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://server.com/oidc/token',
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('grant_type=refresh_token'),
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle refresh failure', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
buffer.toString(),
|
||||
);
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await controller.saveTokens('old-access', 'old-refresh');
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ error: 'invalid_grant' }),
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
});
|
||||
|
||||
const result = await controller.refreshAccessToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Token refresh failed');
|
||||
});
|
||||
|
||||
it('should handle missing tokens in response', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
buffer.toString(),
|
||||
);
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await controller.saveTokens('old-access', 'old-refresh');
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({}), // Missing tokens
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await controller.refreshAccessToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Missing tokens');
|
||||
});
|
||||
|
||||
it('should handle concurrent refresh requests by returning same result', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
buffer.toString(),
|
||||
);
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await controller.saveTokens('old-access', 'old-refresh');
|
||||
|
||||
let resolvePromise: (value: any) => void;
|
||||
const delayedResponse = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
mockFetch.mockReturnValue(delayedResponse);
|
||||
|
||||
// Start two concurrent refresh requests
|
||||
const promise1 = controller.refreshAccessToken();
|
||||
const promise2 = controller.refreshAccessToken();
|
||||
|
||||
// Resolve the fetch
|
||||
resolvePromise!({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh',
|
||||
}),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
// Both results should be equal (same success)
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
buffer.toString(),
|
||||
);
|
||||
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
return {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await controller.saveTokens('old-access', 'old-refresh');
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await controller.refreshAccessToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should load tokens from store', () => {
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return {
|
||||
accessToken: 'stored-access',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
refreshToken: 'stored-refresh',
|
||||
};
|
||||
}
|
||||
return { active: false, storageMode: 'local' };
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
newController.afterAppReady();
|
||||
|
||||
// Verify tokens were loaded by checking getTokenExpiresAt
|
||||
expect(newController.getTokenExpiresAt()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteServerUrl', () => {
|
||||
it('should return official cloud server for cloud mode', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
const result = await controller.getRemoteServerUrl();
|
||||
|
||||
expect(result).toBe('https://cloud.lobehub.com');
|
||||
});
|
||||
|
||||
it('should return custom URL for selfHost mode', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
remoteServerUrl: 'https://my-server.com',
|
||||
storageMode: 'selfHost',
|
||||
});
|
||||
|
||||
const result = await controller.getRemoteServerUrl();
|
||||
|
||||
expect(result).toBe('https://my-server.com');
|
||||
});
|
||||
|
||||
it('should use provided config instead of stored config', async () => {
|
||||
const customConfig: DataSyncConfig = {
|
||||
active: true,
|
||||
remoteServerUrl: 'https://custom-server.com',
|
||||
storageMode: 'selfHost',
|
||||
};
|
||||
|
||||
const result = await controller.getRemoteServerUrl(customConfig);
|
||||
|
||||
expect(result).toBe('https://custom-server.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
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: {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import SystemController from '../SystemCtr';
|
||||
|
||||
// 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: {
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn((name: string) => `/mock/path/${name}`),
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
systemPreferences: {
|
||||
isTrustedAccessibilityClient: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @/const/dir
|
||||
vi.mock('@/const/dir', () => ({
|
||||
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
|
||||
LOCAL_DATABASE_DIR: 'database',
|
||||
userDataDir: '/mock/user/data',
|
||||
}));
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserManager = {
|
||||
broadcastToAllWindows: vi.fn(),
|
||||
handleAppThemeChange: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock storeManager
|
||||
const mockStoreManager = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock i18n
|
||||
const mockI18n = {
|
||||
changeLanguage: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
appStoragePath: '/mock/storage',
|
||||
browserManager: mockBrowserManager,
|
||||
i18n: mockI18n,
|
||||
storeManager: mockStoreManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('SystemController', () => {
|
||||
let controller: SystemController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new SystemController(mockApp);
|
||||
});
|
||||
|
||||
describe('getAppState', () => {
|
||||
it('should return app state with system info', async () => {
|
||||
const result = await controller.getAppState();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
arch: expect.any(String),
|
||||
platform: expect.any(String),
|
||||
systemAppearance: 'light',
|
||||
userPath: {
|
||||
desktop: '/mock/path/desktop',
|
||||
documents: '/mock/path/documents',
|
||||
downloads: '/mock/path/downloads',
|
||||
home: '/mock/path/home',
|
||||
music: '/mock/path/music',
|
||||
pictures: '/mock/path/pictures',
|
||||
userData: '/mock/path/userData',
|
||||
videos: '/mock/path/videos',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dark appearance when nativeTheme is dark', async () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
|
||||
|
||||
const result = await controller.getAppState();
|
||||
|
||||
expect(result.systemAppearance).toBe('dark');
|
||||
|
||||
// Reset
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAccessibilityForMacOS', () => {
|
||||
it('should check accessibility on macOS', async () => {
|
||||
const { systemPreferences } = await import('electron');
|
||||
|
||||
controller.checkAccessibilityForMacOS();
|
||||
|
||||
expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should return undefined on non-macOS', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = controller.checkAccessibilityForMacOS();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Reset
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExternalLink', () => {
|
||||
it('should open external link', async () => {
|
||||
const { shell } = await import('electron');
|
||||
|
||||
await controller.openExternalLink('https://example.com');
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLocale', () => {
|
||||
it('should update locale and broadcast change', async () => {
|
||||
const result = await controller.updateLocale('zh-CN');
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('locale', 'zh-CN');
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('zh-CN');
|
||||
expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('localeChanged', {
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should use system locale when set to auto', async () => {
|
||||
await controller.updateLocale('auto');
|
||||
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('en-US');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateThemeModeHandler', () => {
|
||||
it('should update theme mode and broadcast change', async () => {
|
||||
const themeMode: ThemeMode = 'dark';
|
||||
|
||||
await controller.updateThemeModeHandler(themeMode);
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('themeMode', 'dark');
|
||||
expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('themeChanged', {
|
||||
themeMode: 'dark',
|
||||
});
|
||||
expect(mockBrowserManager.handleAppThemeChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabasePath', () => {
|
||||
it('should return database path', async () => {
|
||||
const result = await controller.getDatabasePath();
|
||||
|
||||
expect(result).toBe('/mock/storage/database');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabaseSchemaHash', () => {
|
||||
it('should return schema hash when file exists', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockReturnValue('abc123');
|
||||
|
||||
const result = await controller.getDatabaseSchemaHash();
|
||||
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
const result = await controller.getDatabaseSchemaHash();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserDataPath', () => {
|
||||
it('should return user data path', async () => {
|
||||
const result = await controller.getUserDataPath();
|
||||
|
||||
expect(result).toBe('/mock/user/data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDatabaseSchemaHash', () => {
|
||||
it('should write schema hash to file', async () => {
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
|
||||
await controller.setDatabaseSchemaHash('newhash123');
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
'/mock/storage/db-schema-hash.txt',
|
||||
'newhash123',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should initialize system theme listener', async () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
expect(nativeTheme.on).toHaveBeenCalledWith('updated', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should not initialize listener twice', async () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
|
||||
controller.afterAppReady();
|
||||
controller.afterAppReady();
|
||||
|
||||
// Should only be called once
|
||||
expect(nativeTheme.on).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should broadcast system theme change when theme updates', async () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
// Get the callback that was registered
|
||||
const callback = vi.mocked(nativeTheme.on).mock.calls[0][1] as () => void;
|
||||
|
||||
// Simulate theme change to dark
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
|
||||
callback();
|
||||
|
||||
expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('systemThemeChanged', {
|
||||
themeMode: 'dark',
|
||||
});
|
||||
|
||||
// Reset
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
getFilePath: vi.fn(),
|
||||
getFileHTTPURL: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileCtr', () => {
|
||||
let controller: UploadFileCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
dataType: 'base64' as const,
|
||||
data: 'dGVzdCBjb250ZW50',
|
||||
filename: 'file.txt',
|
||||
fileType: 'txt',
|
||||
};
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.uploadFile(params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
dataType: 'base64' as const,
|
||||
data: 'dGVzdCBjb250ZW50',
|
||||
filename: 'file.txt',
|
||||
fileType: 'txt',
|
||||
};
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.uploadFile(params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileUrlById', () => {
|
||||
it('should get file path by id successfully', async () => {
|
||||
const fileId = 'file-id-123';
|
||||
const expectedPath = '/files/abc123.txt';
|
||||
mockFileService.getFilePath.mockResolvedValue(expectedPath);
|
||||
|
||||
const result = await controller.getFileUrlById(fileId);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
expect(mockFileService.getFilePath).toHaveBeenCalledWith(fileId);
|
||||
});
|
||||
|
||||
it('should handle get file path error', async () => {
|
||||
const fileId = 'non-existent-id';
|
||||
const error = new Error('File not found');
|
||||
mockFileService.getFilePath.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getFileUrlById(fileId)).rejects.toThrow('File not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileHTTPURL', () => {
|
||||
it('should get file HTTP URL successfully', async () => {
|
||||
const filePath = '/files/abc123.txt';
|
||||
const expectedUrl = 'http://localhost:3000/files/abc123.txt';
|
||||
mockFileService.getFileHTTPURL.mockResolvedValue(expectedUrl);
|
||||
|
||||
const result = await controller.getFileHTTPURL(filePath);
|
||||
|
||||
expect(result).toBe(expectedUrl);
|
||||
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it('should handle get HTTP URL error', async () => {
|
||||
const filePath = '/files/abc123.txt';
|
||||
const error = new Error('Failed to generate URL');
|
||||
mockFileService.getFileHTTPURL.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getFileHTTPURL(filePath)).rejects.toThrow('Failed to generate URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
it('should delete files successfully', async () => {
|
||||
const paths = ['/files/file1.txt', '/files/file2.txt'];
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteFiles(paths);
|
||||
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(paths);
|
||||
});
|
||||
|
||||
it('should handle delete files error', async () => {
|
||||
const paths = ['/files/file1.txt'];
|
||||
const error = new Error('Delete failed');
|
||||
mockFileService.deleteFiles.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.deleteFiles(paths)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
|
||||
it('should handle empty paths array', async () => {
|
||||
const paths: string[] = [];
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteFiles(paths);
|
||||
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFile', () => {
|
||||
it('should create file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'xyz789',
|
||||
path: '/test/newfile.txt',
|
||||
dataType: 'base64' as const,
|
||||
data: 'bmV3IGZpbGUgY29udGVudA==',
|
||||
filename: 'newfile.txt',
|
||||
fileType: 'txt',
|
||||
};
|
||||
const expectedResult = { id: 'new-file-id', url: '/files/new-file-id' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.createFile(params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle create file error', async () => {
|
||||
const params = {
|
||||
hash: 'xyz789',
|
||||
path: '/test/newfile.txt',
|
||||
dataType: 'base64' as const,
|
||||
data: 'bmV3IGZpbGUgY29udGVudA==',
|
||||
filename: 'newfile.txt',
|
||||
fileType: 'txt',
|
||||
};
|
||||
const error = new Error('Create failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.createFile(params)).rejects.toThrow('Create failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -336,6 +336,7 @@ export default class Browser {
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
},
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
MainBroadcastEventKey,
|
||||
MainBroadcastParams,
|
||||
OpenSettingsWindowOptions,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import { WebContents } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -42,13 +38,6 @@ export class BrowserManager {
|
||||
window.show();
|
||||
}
|
||||
|
||||
showSettingsWindow() {
|
||||
logger.debug('Showing settings window');
|
||||
const window = this.retrieveByIdentifier('settings');
|
||||
window.show();
|
||||
return window;
|
||||
}
|
||||
|
||||
broadcastToAllWindows = <T extends MainBroadcastEventKey>(
|
||||
event: T,
|
||||
data: MainBroadcastParams<T>,
|
||||
@@ -68,50 +57,6 @@ export class BrowserManager {
|
||||
this.browsers.get(identifier)?.broadcast(event, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Display the settings window and navigate to a specific tab
|
||||
* @param tab Settings window sub-path tab
|
||||
*/
|
||||
async showSettingsWindowWithTab(options?: OpenSettingsWindowOptions) {
|
||||
const tab = options?.tab;
|
||||
const searchParams = options?.searchParams;
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (tab && tab !== 'common' && !query.has('active')) {
|
||||
query.set('active', tab);
|
||||
}
|
||||
|
||||
const queryString = query.toString();
|
||||
const activeTab = query.get('active') ?? tab;
|
||||
|
||||
logger.debug(
|
||||
`Showing settings window with navigation: active=${activeTab || 'default'}, query=${
|
||||
queryString || 'none'
|
||||
}`,
|
||||
);
|
||||
|
||||
if (queryString) {
|
||||
const browser = await this.redirectToPage('settings', undefined, queryString);
|
||||
|
||||
// make provider page more large
|
||||
if (activeTab?.startsWith('provider')) {
|
||||
logger.debug('Resizing window for provider settings');
|
||||
browser.setWindowSize({ height: 1000, width: 1400 });
|
||||
browser.moveToCenter();
|
||||
}
|
||||
|
||||
return browser;
|
||||
} else {
|
||||
return this.showSettingsWindow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate window to specific sub-path
|
||||
* @param identifier Window identifier
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import Browser, { BrowserWindowOpts } from '../Browser';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockBrowserWindow, mockNativeTheme, mockIpcMain, mockScreen, MockBrowserWindow } =
|
||||
vi.hoisted(() => {
|
||||
const mockBrowserWindow = {
|
||||
center: vi.fn(),
|
||||
close: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
|
||||
getContentBounds: vi.fn().mockReturnValue({ height: 600, width: 800 }),
|
||||
hide: vi.fn(),
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
isFocused: vi.fn().mockReturnValue(true),
|
||||
isFullScreen: vi.fn().mockReturnValue(false),
|
||||
isMaximized: vi.fn().mockReturnValue(false),
|
||||
isVisible: vi.fn().mockReturnValue(true),
|
||||
loadFile: vi.fn().mockResolvedValue(undefined),
|
||||
loadURL: vi.fn().mockResolvedValue(undefined),
|
||||
maximize: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
setFullScreen: vi.fn(),
|
||||
setPosition: vi.fn(),
|
||||
setTitleBarOverlay: vi.fn(),
|
||||
show: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
webContents: {
|
||||
openDevTools: vi.fn(),
|
||||
send: vi.fn(),
|
||||
session: {
|
||||
webRequest: {
|
||||
onHeadersReceived: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
|
||||
mockBrowserWindow,
|
||||
mockIpcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
},
|
||||
mockNativeTheme: {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
},
|
||||
mockScreen: {
|
||||
getDisplayNearestPoint: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: MockBrowserWindow,
|
||||
ipcMain: mockIpcMain,
|
||||
nativeTheme: mockNativeTheme,
|
||||
screen: mockScreen,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
preloadDir: '/mock/preload',
|
||||
resourcesDir: '/mock/resources',
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
isMac: false,
|
||||
isWindows: true,
|
||||
}));
|
||||
|
||||
vi.mock('@/const/theme', () => ({
|
||||
BACKGROUND_DARK: '#1a1a1a',
|
||||
BACKGROUND_LIGHT: '#ffffff',
|
||||
SYMBOL_COLOR_DARK: '#ffffff',
|
||||
SYMBOL_COLOR_LIGHT: '#000000',
|
||||
THEME_CHANGE_DELAY: 0,
|
||||
TITLE_BAR_HEIGHT: 32,
|
||||
}));
|
||||
|
||||
describe('Browser', () => {
|
||||
let browser: Browser;
|
||||
let mockApp: AppCore;
|
||||
let mockStoreManagerGet: ReturnType<typeof vi.fn>;
|
||||
let mockStoreManagerSet: ReturnType<typeof vi.fn>;
|
||||
let mockNextInterceptor: ReturnType<typeof vi.fn>;
|
||||
|
||||
const defaultOptions: BrowserWindowOpts = {
|
||||
height: 600,
|
||||
identifier: 'test-window',
|
||||
path: '/test',
|
||||
title: 'Test Window',
|
||||
width: 800,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Reset mock behaviors
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isFullScreen.mockReturnValue(false);
|
||||
mockBrowserWindow.loadURL.mockResolvedValue(undefined);
|
||||
mockBrowserWindow.loadFile.mockResolvedValue(undefined);
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
|
||||
// Create mock App
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
|
||||
mockStoreManagerSet = vi.fn();
|
||||
mockNextInterceptor = vi.fn().mockReturnValue(vi.fn());
|
||||
|
||||
mockApp = {
|
||||
browserManager: {
|
||||
retrieveByIdentifier: vi.fn(),
|
||||
},
|
||||
isQuiting: false,
|
||||
nextInterceptor: mockNextInterceptor,
|
||||
nextServerUrl: 'http://localhost:3000',
|
||||
storeManager: {
|
||||
get: mockStoreManagerGet,
|
||||
set: mockStoreManagerSet,
|
||||
},
|
||||
} as unknown as AppCore;
|
||||
|
||||
browser = new Browser(defaultOptions, mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should set identifier and options', () => {
|
||||
expect(browser.identifier).toBe('test-window');
|
||||
expect(browser.options).toEqual(defaultOptions);
|
||||
});
|
||||
|
||||
it('should create BrowserWindow on construction', () => {
|
||||
expect(MockBrowserWindow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should setup next interceptor', () => {
|
||||
expect(mockNextInterceptor).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserWindow getter', () => {
|
||||
it('should return existing window if not destroyed', () => {
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
const win1 = browser.browserWindow;
|
||||
const win2 = browser.browserWindow;
|
||||
|
||||
// Should not create a new window
|
||||
expect(MockBrowserWindow).toHaveBeenCalledTimes(1);
|
||||
expect(win1).toBe(win2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webContents getter', () => {
|
||||
it('should return webContents when window not destroyed', () => {
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
|
||||
expect(browser.webContents).toBe(mockBrowserWindow.webContents);
|
||||
});
|
||||
|
||||
it('should return null when window is destroyed', () => {
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(true);
|
||||
|
||||
expect(browser.webContents).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveOrInitialize', () => {
|
||||
it('should restore window size from store', () => {
|
||||
mockStoreManagerGet.mockImplementation((key: string) => {
|
||||
if (key === 'windowSize_test-window') {
|
||||
return { height: 700, width: 900 };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Create new browser to trigger initialization with saved state
|
||||
const newBrowser = new Browser(defaultOptions, mockApp);
|
||||
|
||||
expect(MockBrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
height: 700,
|
||||
width: 900,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default size when no saved state', () => {
|
||||
mockStoreManagerGet.mockReturnValue(undefined);
|
||||
|
||||
expect(MockBrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
height: 600,
|
||||
width: 800,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup theme listener', () => {
|
||||
expect(mockNativeTheme.on).toHaveBeenCalledWith('updated', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should setup CORS bypass', () => {
|
||||
expect(mockBrowserWindow.webContents.session.webRequest.onHeadersReceived).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open devTools when devTools option is true', () => {
|
||||
const optionsWithDevTools: BrowserWindowOpts = {
|
||||
...defaultOptions,
|
||||
devTools: true,
|
||||
};
|
||||
|
||||
new Browser(optionsWithDevTools, mockApp);
|
||||
|
||||
expect(mockBrowserWindow.webContents.openDevTools).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme management', () => {
|
||||
describe('getPlatformThemeConfig', () => {
|
||||
it('should return Windows dark theme config', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = true;
|
||||
|
||||
// Create browser with dark mode
|
||||
const darkBrowser = new Browser(defaultOptions, mockApp);
|
||||
|
||||
expect(MockBrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backgroundColor: '#1a1a1a',
|
||||
titleBarOverlay: expect.objectContaining({
|
||||
color: '#1a1a1a',
|
||||
symbolColor: '#ffffff',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return Windows light theme config', () => {
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
|
||||
expect(MockBrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backgroundColor: '#ffffff',
|
||||
titleBarOverlay: expect.objectContaining({
|
||||
color: '#ffffff',
|
||||
symbolColor: '#000000',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleThemeChange', () => {
|
||||
it('should reapply visual effects on theme change', () => {
|
||||
// Get the theme change handler
|
||||
const themeHandler = mockNativeTheme.on.mock.calls.find(
|
||||
(call) => call[0] === 'updated',
|
||||
)?.[1];
|
||||
|
||||
expect(themeHandler).toBeDefined();
|
||||
|
||||
// Trigger theme change
|
||||
themeHandler();
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// Should update window background and title bar
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAppThemeChange', () => {
|
||||
it('should reapply visual effects', () => {
|
||||
browser.handleAppThemeChange();
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDarkMode', () => {
|
||||
it('should return true when themeMode is dark', () => {
|
||||
mockStoreManagerGet.mockImplementation((key: string) => {
|
||||
if (key === 'themeMode') return 'dark';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const darkBrowser = new Browser(defaultOptions, mockApp);
|
||||
// Access private getter through handleAppThemeChange which uses isDarkMode
|
||||
darkBrowser.handleAppThemeChange();
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#1a1a1a');
|
||||
});
|
||||
|
||||
it('should use system theme when themeMode is auto', () => {
|
||||
mockStoreManagerGet.mockImplementation((key: string) => {
|
||||
if (key === 'themeMode') return 'auto';
|
||||
return undefined;
|
||||
});
|
||||
mockNativeTheme.shouldUseDarkColors = true;
|
||||
|
||||
const autoBrowser = new Browser(defaultOptions, mockApp);
|
||||
autoBrowser.handleAppThemeChange();
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#1a1a1a');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadUrl', () => {
|
||||
it('should load full URL successfully', async () => {
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
expect(mockBrowserWindow.loadURL).toHaveBeenCalledWith('http://localhost:3000/test-path');
|
||||
});
|
||||
|
||||
it('should load error page on failure', async () => {
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
expect(mockBrowserWindow.loadFile).toHaveBeenCalledWith('/mock/resources/error.html');
|
||||
});
|
||||
|
||||
it('should setup retry handler on error', async () => {
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
expect(mockIpcMain.removeHandler).toHaveBeenCalledWith('retry-connection');
|
||||
expect(mockIpcMain.handle).toHaveBeenCalledWith('retry-connection', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should load fallback HTML when error page fails', async () => {
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
mockBrowserWindow.loadFile.mockRejectedValueOnce(new Error('Error page failed'));
|
||||
mockBrowserWindow.loadURL.mockResolvedValueOnce(undefined);
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
expect(mockBrowserWindow.loadURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('data:text/html'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPlaceholder', () => {
|
||||
it('should load splash screen', async () => {
|
||||
await browser.loadPlaceholder();
|
||||
|
||||
expect(mockBrowserWindow.loadFile).toHaveBeenCalledWith('/mock/resources/splash.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window operations', () => {
|
||||
describe('show', () => {
|
||||
it('should show window', () => {
|
||||
browser.show();
|
||||
|
||||
expect(mockBrowserWindow.show).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hide', () => {
|
||||
it('should hide window', () => {
|
||||
mockBrowserWindow.isFullScreen.mockReturnValue(false);
|
||||
|
||||
browser.hide();
|
||||
|
||||
expect(mockBrowserWindow.hide).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should close window', () => {
|
||||
browser.close();
|
||||
|
||||
expect(mockBrowserWindow.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveToCenter', () => {
|
||||
it('should center window', () => {
|
||||
browser.moveToCenter();
|
||||
|
||||
expect(mockBrowserWindow.center).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWindowSize', () => {
|
||||
it('should set window bounds', () => {
|
||||
browser.setWindowSize({ height: 700, width: 900 });
|
||||
|
||||
expect(mockBrowserWindow.setBounds).toHaveBeenCalledWith({
|
||||
height: 700,
|
||||
width: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use current size for missing dimensions', () => {
|
||||
mockBrowserWindow.getBounds.mockReturnValue({ height: 600, width: 800 });
|
||||
|
||||
browser.setWindowSize({ width: 900 });
|
||||
|
||||
expect(mockBrowserWindow.setBounds).toHaveBeenCalledWith({
|
||||
height: 600,
|
||||
width: 900,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleVisible', () => {
|
||||
it('should hide when visible and focused', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
|
||||
browser.toggleVisible();
|
||||
|
||||
expect(mockBrowserWindow.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show and focus when not visible', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
browser.toggleVisible();
|
||||
|
||||
expect(mockBrowserWindow.show).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show and focus when visible but not focused', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(false);
|
||||
|
||||
browser.toggleVisible();
|
||||
|
||||
expect(mockBrowserWindow.show).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast', () => {
|
||||
it('should send message to webContents', () => {
|
||||
browser.broadcast('updateAvailable' as any, { version: '1.0.0' } as any);
|
||||
|
||||
expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('updateAvailable', {
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not send when window is destroyed', () => {
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(true);
|
||||
|
||||
browser.broadcast('updateAvailable' as any);
|
||||
|
||||
expect(mockBrowserWindow.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should cleanup theme listener', () => {
|
||||
browser.destroy();
|
||||
|
||||
expect(mockNativeTheme.off).toHaveBeenCalledWith('updated', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('close event handling', () => {
|
||||
let closeHandler: (e: any) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get the close handler registered during initialization
|
||||
closeHandler = mockBrowserWindow.on.mock.calls.find((call) => call[0] === 'close')?.[1];
|
||||
});
|
||||
|
||||
it('should save window size and allow close when app is quitting', () => {
|
||||
(mockApp as any).isQuiting = true;
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
closeHandler(mockEvent);
|
||||
|
||||
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
||||
height: 600,
|
||||
width: 800,
|
||||
});
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide instead of close when keepAlive is true', () => {
|
||||
const keepAliveOptions: BrowserWindowOpts = {
|
||||
...defaultOptions,
|
||||
keepAlive: true,
|
||||
};
|
||||
const keepAliveBrowser = new Browser(keepAliveOptions, mockApp);
|
||||
|
||||
// Get the new close handler
|
||||
const keepAliveCloseHandler = mockBrowserWindow.on.mock.calls
|
||||
.filter((call) => call[0] === 'close')
|
||||
.pop()?.[1];
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
keepAliveCloseHandler(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save size and allow close when keepAlive is false', () => {
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
|
||||
closeHandler(mockEvent);
|
||||
|
||||
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
||||
height: 600,
|
||||
width: 800,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reapplyVisualEffects', () => {
|
||||
it('should apply visual effects', () => {
|
||||
browser.reapplyVisualEffects();
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not apply when window is destroyed', () => {
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(true);
|
||||
mockBrowserWindow.setBackgroundColor.mockClear();
|
||||
|
||||
browser.reapplyVisualEffects();
|
||||
|
||||
expect(mockBrowserWindow.setBackgroundColor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { BrowserManager } from '../BrowserManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { MockBrowser, mockAppBrowsers, mockWindowTemplates } = vi.hoisted(() => {
|
||||
const createMockBrowserWindow = () => ({
|
||||
isMaximized: vi.fn().mockReturnValue(false),
|
||||
maximize: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
on: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
webContents: { id: Math.random() },
|
||||
});
|
||||
|
||||
const MockBrowser = vi.fn().mockImplementation((options: any) => {
|
||||
const browserWindow = createMockBrowserWindow();
|
||||
return {
|
||||
broadcast: vi.fn(),
|
||||
browserWindow,
|
||||
close: vi.fn(),
|
||||
handleAppThemeChange: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
identifier: options.identifier,
|
||||
loadUrl: vi.fn().mockResolvedValue(undefined),
|
||||
options,
|
||||
show: vi.fn(),
|
||||
webContents: browserWindow.webContents,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
MockBrowser,
|
||||
mockAppBrowsers: {
|
||||
chat: {
|
||||
identifier: 'chat',
|
||||
keepAlive: true,
|
||||
path: '/chat',
|
||||
},
|
||||
settings: {
|
||||
identifier: 'settings',
|
||||
keepAlive: false,
|
||||
path: '/settings',
|
||||
},
|
||||
},
|
||||
mockWindowTemplates: {
|
||||
popup: {
|
||||
baseIdentifier: 'popup',
|
||||
height: 400,
|
||||
width: 600,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Browser class
|
||||
vi.mock('../Browser', () => ({
|
||||
default: MockBrowser,
|
||||
}));
|
||||
|
||||
// Mock appBrowsers config
|
||||
vi.mock('../../../appBrowsers', () => ({
|
||||
appBrowsers: mockAppBrowsers,
|
||||
windowTemplates: mockWindowTemplates,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BrowserManager', () => {
|
||||
let manager: BrowserManager;
|
||||
let mockApp: AppCore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset MockBrowser
|
||||
MockBrowser.mockClear();
|
||||
|
||||
// Create mock App
|
||||
mockApp = {} as unknown as AppCore;
|
||||
|
||||
manager = new BrowserManager(mockApp);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with empty browsers Map', () => {
|
||||
expect(manager.browsers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should store app reference', () => {
|
||||
expect(manager.app).toBe(mockApp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMainWindow', () => {
|
||||
it('should return chat window', () => {
|
||||
const mainWindow = manager.getMainWindow();
|
||||
|
||||
expect(mainWindow.identifier).toBe('chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showMainWindow', () => {
|
||||
it('should show the main window', () => {
|
||||
manager.showMainWindow();
|
||||
|
||||
const chatBrowser = manager.browsers.get('chat');
|
||||
expect(chatBrowser?.show).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveByIdentifier', () => {
|
||||
it('should return existing browser', () => {
|
||||
// First call creates the browser
|
||||
const browser1 = manager.retrieveByIdentifier('chat');
|
||||
// Second call should return same instance
|
||||
const browser2 = manager.retrieveByIdentifier('chat');
|
||||
|
||||
expect(browser1).toBe(browser2);
|
||||
expect(MockBrowser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should create static browser when not exists', () => {
|
||||
const browser = manager.retrieveByIdentifier('chat');
|
||||
|
||||
expect(MockBrowser).toHaveBeenCalledWith(mockAppBrowsers.chat, mockApp);
|
||||
expect(browser.identifier).toBe('chat');
|
||||
});
|
||||
|
||||
it('should throw error for non-static browser that does not exist', () => {
|
||||
expect(() => manager.retrieveByIdentifier('non-existent')).toThrow(
|
||||
'Browser non-existent not found and is not a static browser',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMultiInstanceWindow', () => {
|
||||
it('should create window from template', () => {
|
||||
const result = manager.createMultiInstanceWindow('popup' as any, '/popup/path');
|
||||
|
||||
expect(result.browser).toBeDefined();
|
||||
expect(result.identifier).toMatch(/^popup_/);
|
||||
expect(MockBrowser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseIdentifier: 'popup',
|
||||
height: 400,
|
||||
path: '/popup/path',
|
||||
width: 600,
|
||||
}),
|
||||
mockApp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided uniqueId', () => {
|
||||
const result = manager.createMultiInstanceWindow(
|
||||
'popup' as any,
|
||||
'/popup/path',
|
||||
'my-custom-id',
|
||||
);
|
||||
|
||||
expect(result.identifier).toBe('my-custom-id');
|
||||
});
|
||||
|
||||
it('should throw error for non-existent template', () => {
|
||||
expect(() => manager.createMultiInstanceWindow('nonexistent' as any, '/path')).toThrow(
|
||||
'Window template nonexistent not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate unique identifier when not provided', () => {
|
||||
const result1 = manager.createMultiInstanceWindow('popup' as any, '/path1');
|
||||
const result2 = manager.createMultiInstanceWindow('popup' as any, '/path2');
|
||||
|
||||
expect(result1.identifier).not.toBe(result2.identifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWindowsByTemplate', () => {
|
||||
it('should return windows matching template prefix', () => {
|
||||
manager.createMultiInstanceWindow('popup' as any, '/path1', 'popup_1');
|
||||
manager.createMultiInstanceWindow('popup' as any, '/path2', 'popup_2');
|
||||
manager.retrieveByIdentifier('chat'); // This should not be included
|
||||
|
||||
const popupWindows = manager.getWindowsByTemplate('popup');
|
||||
|
||||
expect(popupWindows).toContain('popup_1');
|
||||
expect(popupWindows).toContain('popup_2');
|
||||
expect(popupWindows).not.toContain('chat');
|
||||
});
|
||||
|
||||
it('should return empty array when no matching windows', () => {
|
||||
const windows = manager.getWindowsByTemplate('nonexistent');
|
||||
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeWindowsByTemplate', () => {
|
||||
it('should close all windows matching template', () => {
|
||||
const { browser: browser1 } = manager.createMultiInstanceWindow(
|
||||
'popup' as any,
|
||||
'/path1',
|
||||
'popup_1',
|
||||
);
|
||||
const { browser: browser2 } = manager.createMultiInstanceWindow(
|
||||
'popup' as any,
|
||||
'/path2',
|
||||
'popup_2',
|
||||
);
|
||||
|
||||
manager.closeWindowsByTemplate('popup');
|
||||
|
||||
expect(browser1.close).toHaveBeenCalled();
|
||||
expect(browser2.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeBrowsers', () => {
|
||||
it('should initialize keepAlive browsers', () => {
|
||||
manager.initializeBrowsers();
|
||||
|
||||
// chat has keepAlive: true, settings has keepAlive: false
|
||||
expect(manager.browsers.has('chat')).toBe(true);
|
||||
expect(manager.browsers.has('settings')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastToAllWindows', () => {
|
||||
it('should broadcast to all browsers', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
manager.retrieveByIdentifier('settings');
|
||||
|
||||
manager.broadcastToAllWindows('updateAvailable' as any, { version: '1.0.0' } as any);
|
||||
|
||||
const chatBrowser = manager.browsers.get('chat');
|
||||
const settingsBrowser = manager.browsers.get('settings');
|
||||
|
||||
expect(chatBrowser?.broadcast).toHaveBeenCalledWith('updateAvailable', { version: '1.0.0' });
|
||||
expect(settingsBrowser?.broadcast).toHaveBeenCalledWith('updateAvailable', {
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastToWindow', () => {
|
||||
it('should broadcast to specific window', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
manager.retrieveByIdentifier('settings');
|
||||
|
||||
const chatBrowser = manager.browsers.get('chat');
|
||||
const settingsBrowser = manager.browsers.get('settings');
|
||||
|
||||
manager.broadcastToWindow('chat', 'updateAvailable' as any, { version: '1.0.0' } as any);
|
||||
|
||||
expect(chatBrowser?.broadcast).toHaveBeenCalledWith('updateAvailable', { version: '1.0.0' });
|
||||
expect(settingsBrowser?.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should safely handle non-existent window', () => {
|
||||
expect(() =>
|
||||
manager.broadcastToWindow('nonexistent', 'updateAvailable' as any, {} as any),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectToPage', () => {
|
||||
it('should load URL and show window', async () => {
|
||||
const browser = await manager.redirectToPage('chat', 'agent');
|
||||
|
||||
expect(browser.hide).toHaveBeenCalled();
|
||||
expect(browser.loadUrl).toHaveBeenCalledWith('/chat/agent');
|
||||
expect(browser.show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle subPath correctly', async () => {
|
||||
const browser = await manager.redirectToPage('chat', 'settings/profile');
|
||||
|
||||
expect(browser.loadUrl).toHaveBeenCalledWith('/chat/settings/profile');
|
||||
});
|
||||
|
||||
it('should handle search parameters', async () => {
|
||||
const browser = await manager.redirectToPage('chat', 'agent', 'id=123');
|
||||
|
||||
expect(browser.loadUrl).toHaveBeenCalledWith('/chat/agent?id=123');
|
||||
});
|
||||
|
||||
it('should handle search parameters starting with ?', async () => {
|
||||
const browser = await manager.redirectToPage('chat', undefined, '?id=123');
|
||||
|
||||
expect(browser.loadUrl).toHaveBeenCalledWith('/chat?id=123');
|
||||
});
|
||||
|
||||
it('should handle no subPath', async () => {
|
||||
const browser = await manager.redirectToPage('chat');
|
||||
|
||||
expect(browser.loadUrl).toHaveBeenCalledWith('/chat');
|
||||
});
|
||||
|
||||
it('should throw error on failure', async () => {
|
||||
const mockError = new Error('Load failed');
|
||||
MockBrowser.mockImplementationOnce((options: any) => ({
|
||||
broadcast: vi.fn(),
|
||||
browserWindow: { on: vi.fn(), webContents: { id: 1 } },
|
||||
close: vi.fn(),
|
||||
handleAppThemeChange: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
identifier: options.identifier,
|
||||
loadUrl: vi.fn().mockRejectedValue(mockError),
|
||||
options: { path: '/chat' },
|
||||
show: vi.fn(),
|
||||
webContents: { id: 1 },
|
||||
}));
|
||||
|
||||
// Clear the browser cache
|
||||
manager.browsers.clear();
|
||||
|
||||
await expect(manager.redirectToPage('chat', 'agent')).rejects.toThrow('Load failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window operations', () => {
|
||||
describe('closeWindow', () => {
|
||||
it('should close specified window', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
|
||||
manager.closeWindow('chat');
|
||||
|
||||
const browser = manager.browsers.get('chat');
|
||||
expect(browser?.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should safely handle non-existent window', () => {
|
||||
expect(() => manager.closeWindow('nonexistent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('minimizeWindow', () => {
|
||||
it('should minimize specified window', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
|
||||
manager.minimizeWindow('chat');
|
||||
|
||||
const browser = manager.browsers.get('chat');
|
||||
expect(browser?.browserWindow.minimize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maximizeWindow', () => {
|
||||
it('should maximize when not maximized', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
const browser = manager.browsers.get('chat');
|
||||
browser!.browserWindow.isMaximized = vi.fn().mockReturnValue(false);
|
||||
|
||||
manager.maximizeWindow('chat');
|
||||
|
||||
expect(browser?.browserWindow.maximize).toHaveBeenCalled();
|
||||
expect(browser?.browserWindow.unmaximize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unmaximize when already maximized', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
const browser = manager.browsers.get('chat');
|
||||
browser!.browserWindow.isMaximized = vi.fn().mockReturnValue(true);
|
||||
|
||||
manager.maximizeWindow('chat');
|
||||
|
||||
expect(browser?.browserWindow.unmaximize).toHaveBeenCalled();
|
||||
expect(browser?.browserWindow.maximize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdentifierByWebContents', () => {
|
||||
it('should return identifier for known webContents', () => {
|
||||
const browser = manager.retrieveByIdentifier('chat');
|
||||
const webContents = browser.browserWindow.webContents;
|
||||
|
||||
const identifier = manager.getIdentifierByWebContents(webContents as any);
|
||||
|
||||
expect(identifier).toBe('chat');
|
||||
});
|
||||
|
||||
it('should return null for unknown webContents', () => {
|
||||
const unknownWebContents = { id: 999 };
|
||||
|
||||
const identifier = manager.getIdentifierByWebContents(unknownWebContents as any);
|
||||
|
||||
expect(identifier).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAppThemeChange', () => {
|
||||
it('should notify all browsers of theme change', () => {
|
||||
manager.retrieveByIdentifier('chat');
|
||||
manager.retrieveByIdentifier('settings');
|
||||
|
||||
manager.handleAppThemeChange();
|
||||
|
||||
const chatBrowser = manager.browsers.get('chat');
|
||||
const settingsBrowser = manager.browsers.get('settings');
|
||||
|
||||
expect(chatBrowser?.handleAppThemeChange).toHaveBeenCalled();
|
||||
expect(settingsBrowser?.handleAppThemeChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { I18nManager } from '../I18nManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockApp, mockI18nextInstance, mockLoadResources, mockCreateInstance } = vi.hoisted(() => {
|
||||
const mockI18nextInstance = {
|
||||
addResourceBundle: vi.fn(),
|
||||
changeLanguage: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
language: 'en-US',
|
||||
on: vi.fn(),
|
||||
t: vi.fn().mockImplementation((key: string) => key),
|
||||
};
|
||||
|
||||
const mockCreateInstance = vi.fn().mockReturnValue(mockI18nextInstance);
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
getLocale: vi.fn().mockReturnValue('en-US'),
|
||||
},
|
||||
mockCreateInstance,
|
||||
mockI18nextInstance,
|
||||
mockLoadResources: vi.fn().mockResolvedValue({ key: 'value' }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock electron app
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
}));
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('i18next', () => ({
|
||||
default: {
|
||||
createInstance: mockCreateInstance,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock loadResources
|
||||
vi.mock('@/locales/resources', () => ({
|
||||
loadResources: mockLoadResources,
|
||||
}));
|
||||
|
||||
describe('I18nManager', () => {
|
||||
let manager: I18nManager;
|
||||
let mockAppCore: AppCore;
|
||||
let mockStoreManagerGet: ReturnType<typeof vi.fn>;
|
||||
let mockRefreshMenus: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset i18next mock state
|
||||
mockI18nextInstance.language = 'en-US';
|
||||
mockI18nextInstance.t.mockImplementation((key: string) => key);
|
||||
mockI18nextInstance.init.mockResolvedValue(undefined);
|
||||
mockI18nextInstance.changeLanguage.mockResolvedValue(undefined);
|
||||
|
||||
// Reset loadResources mock
|
||||
mockLoadResources.mockResolvedValue({ key: 'value' });
|
||||
|
||||
// Reset electron app mock
|
||||
mockApp.getLocale.mockReturnValue('en-US');
|
||||
|
||||
// Create mock App core
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue('auto');
|
||||
mockRefreshMenus = vi.fn();
|
||||
|
||||
mockAppCore = {
|
||||
menuManager: {
|
||||
refreshMenus: mockRefreshMenus,
|
||||
},
|
||||
storeManager: {
|
||||
get: mockStoreManagerGet,
|
||||
},
|
||||
} as unknown as AppCore;
|
||||
|
||||
manager = new I18nManager(mockAppCore);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create i18next instance', () => {
|
||||
expect(mockCreateInstance).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize i18next with default settings', async () => {
|
||||
await manager.init();
|
||||
|
||||
expect(mockI18nextInstance.init).toHaveBeenCalledWith({
|
||||
defaultNS: 'menu',
|
||||
fallbackLng: 'en-US',
|
||||
initAsync: true,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
lng: 'en-US',
|
||||
ns: ['menu', 'dialog', 'common'],
|
||||
partialBundledLanguages: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use provided language parameter', async () => {
|
||||
await manager.init('zh-CN');
|
||||
|
||||
expect(mockI18nextInstance.init).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lng: 'zh-CN',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use stored locale when not auto', async () => {
|
||||
mockStoreManagerGet.mockReturnValue('ja-JP');
|
||||
|
||||
await manager.init();
|
||||
|
||||
expect(mockI18nextInstance.init).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lng: 'ja-JP',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use system locale when stored locale is auto', async () => {
|
||||
mockStoreManagerGet.mockReturnValue('auto');
|
||||
mockApp.getLocale.mockReturnValue('fr-FR');
|
||||
|
||||
await manager.init();
|
||||
|
||||
expect(mockI18nextInstance.init).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lng: 'fr-FR',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip initialization if already initialized', async () => {
|
||||
await manager.init();
|
||||
vi.clearAllMocks();
|
||||
|
||||
await manager.init();
|
||||
|
||||
expect(mockI18nextInstance.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should load locale resources after init', async () => {
|
||||
await manager.init();
|
||||
|
||||
// Should load menu, dialog, common namespaces
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('en-US', 'menu');
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('en-US', 'dialog');
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('en-US', 'common');
|
||||
});
|
||||
|
||||
it('should refresh main UI after init', async () => {
|
||||
await manager.init();
|
||||
|
||||
expect(mockRefreshMenus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register languageChanged listener', async () => {
|
||||
await manager.init();
|
||||
|
||||
expect(mockI18nextInstance.on).toHaveBeenCalledWith('languageChanged', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('t', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
});
|
||||
|
||||
it('should call i18next t function', () => {
|
||||
mockI18nextInstance.t.mockReturnValue('translated');
|
||||
|
||||
const result = manager.t('test.key');
|
||||
|
||||
expect(mockI18nextInstance.t).toHaveBeenCalledWith('test.key', undefined);
|
||||
expect(result).toBe('translated');
|
||||
});
|
||||
|
||||
it('should pass options to i18next', () => {
|
||||
mockI18nextInstance.t.mockReturnValue('translated with options');
|
||||
|
||||
const result = manager.t('test.key', { count: 5 });
|
||||
|
||||
expect(mockI18nextInstance.t).toHaveBeenCalledWith('test.key', { count: 5 });
|
||||
expect(result).toBe('translated with options');
|
||||
});
|
||||
|
||||
it('should warn when translation key is not found', () => {
|
||||
// When translation is not found, i18next returns the key itself
|
||||
mockI18nextInstance.t.mockImplementation((key: string) => key);
|
||||
|
||||
manager.t('missing.key');
|
||||
|
||||
// The warn should be logged (we can't verify the log content with our mock setup)
|
||||
expect(mockI18nextInstance.t).toHaveBeenCalledWith('missing.key', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNamespacedT', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
});
|
||||
|
||||
it('should return a function that adds namespace to options', () => {
|
||||
mockI18nextInstance.t.mockReturnValue('namespaced translation');
|
||||
|
||||
const menuT = manager.createNamespacedT('menu');
|
||||
const result = menuT('test.key');
|
||||
|
||||
expect(mockI18nextInstance.t).toHaveBeenCalledWith('test.key', { ns: 'menu' });
|
||||
expect(result).toBe('namespaced translation');
|
||||
});
|
||||
|
||||
it('should merge provided options with namespace', () => {
|
||||
mockI18nextInstance.t.mockReturnValue('merged translation');
|
||||
|
||||
const menuT = manager.createNamespacedT('dialog');
|
||||
const result = menuT('test.key', { count: 3 });
|
||||
|
||||
expect(mockI18nextInstance.t).toHaveBeenCalledWith('test.key', { count: 3, ns: 'dialog' });
|
||||
expect(result).toBe('merged translation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ns', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
});
|
||||
|
||||
it('should be an alias for createNamespacedT', () => {
|
||||
mockI18nextInstance.t.mockReturnValue('ns translation');
|
||||
|
||||
const dialogT = manager.ns('dialog');
|
||||
const result = dialogT('test.key');
|
||||
|
||||
expect(mockI18nextInstance.t).toHaveBeenCalledWith('test.key', { ns: 'dialog' });
|
||||
expect(result).toBe('ns translation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentLanguage', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
});
|
||||
|
||||
it('should return current i18next language', () => {
|
||||
mockI18nextInstance.language = 'de-DE';
|
||||
|
||||
expect(manager.getCurrentLanguage()).toBe('de-DE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeLanguage', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
});
|
||||
|
||||
it('should call i18next changeLanguage', async () => {
|
||||
await manager.changeLanguage('zh-CN');
|
||||
|
||||
expect(mockI18nextInstance.changeLanguage).toHaveBeenCalledWith('zh-CN');
|
||||
});
|
||||
|
||||
it('should initialize if not already initialized', async () => {
|
||||
// Create a new manager that is not initialized
|
||||
const uninitializedManager = new I18nManager(mockAppCore);
|
||||
|
||||
await uninitializedManager.changeLanguage('zh-CN');
|
||||
|
||||
expect(mockI18nextInstance.init).toHaveBeenCalled();
|
||||
expect(mockI18nextInstance.changeLanguage).toHaveBeenCalledWith('zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLanguageChanged', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
});
|
||||
|
||||
it('should load locale and refresh UI on language change', async () => {
|
||||
// Get the languageChanged handler
|
||||
const languageChangedHandler = mockI18nextInstance.on.mock.calls.find(
|
||||
(call) => call[0] === 'languageChanged',
|
||||
)?.[1];
|
||||
|
||||
expect(languageChangedHandler).toBeDefined();
|
||||
|
||||
// Clear mocks to check only the handler's behavior
|
||||
mockLoadResources.mockClear();
|
||||
mockRefreshMenus.mockClear();
|
||||
|
||||
// Trigger language change
|
||||
await languageChangedHandler('ja-JP');
|
||||
|
||||
// Should load resources for new language
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('ja-JP', 'menu');
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('ja-JP', 'dialog');
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('ja-JP', 'common');
|
||||
|
||||
// Should refresh menus
|
||||
expect(mockRefreshMenus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadNamespace', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.init();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load resources and add to i18next', async () => {
|
||||
mockLoadResources.mockResolvedValue({ hello: 'world' });
|
||||
|
||||
// Access private method
|
||||
const result = await manager['loadNamespace']('en-US', 'menu');
|
||||
|
||||
expect(mockLoadResources).toHaveBeenCalledWith('en-US', 'menu');
|
||||
expect(mockI18nextInstance.addResourceBundle).toHaveBeenCalledWith(
|
||||
'en-US',
|
||||
'menu',
|
||||
{ hello: 'world' },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
mockLoadResources.mockRejectedValue(new Error('Load failed'));
|
||||
|
||||
const result = await manager['loadNamespace']('en-US', 'menu');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IoCContainer } from '../IoCContainer';
|
||||
|
||||
describe('IoCContainer', () => {
|
||||
// Sample class targets for testing WeakMap storage
|
||||
class TestController {}
|
||||
class AnotherController {}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset static WeakMaps by creating new instances
|
||||
// WeakMaps can't be cleared, but we can verify they work correctly
|
||||
// For each test, use fresh class instances
|
||||
});
|
||||
|
||||
describe('controllers WeakMap', () => {
|
||||
it('should store controller metadata', () => {
|
||||
const metadata = [{ methodName: 'handleMessage', mode: 'client' as const, name: 'message' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should allow multiple controllers', () => {
|
||||
const metadata1 = [{ methodName: 'method1', mode: 'client' as const, name: 'action1' }];
|
||||
const metadata2 = [{ methodName: 'method2', mode: 'server' as const, name: 'action2' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata1);
|
||||
IoCContainer.controllers.set(AnotherController, metadata2);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata1);
|
||||
expect(IoCContainer.controllers.get(AnotherController)).toEqual(metadata2);
|
||||
});
|
||||
|
||||
it('should allow overwriting controller metadata', () => {
|
||||
const oldMetadata = [{ methodName: 'oldMethod', mode: 'client' as const, name: 'old' }];
|
||||
const newMetadata = [{ methodName: 'newMethod', mode: 'server' as const, name: 'new' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, oldMetadata);
|
||||
IoCContainer.controllers.set(TestController, newMetadata);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(newMetadata);
|
||||
});
|
||||
|
||||
it('should support multiple methods per controller', () => {
|
||||
const metadata = [
|
||||
{ methodName: 'method1', mode: 'client' as const, name: 'action1' },
|
||||
{ methodName: 'method2', mode: 'server' as const, name: 'action2' },
|
||||
{ methodName: 'method3', mode: 'client' as const, name: 'action3' },
|
||||
];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata);
|
||||
|
||||
const stored = IoCContainer.controllers.get(TestController);
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored?.[0].mode).toBe('client');
|
||||
expect(stored?.[1].mode).toBe('server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts WeakMap', () => {
|
||||
it('should store shortcut metadata', () => {
|
||||
const metadata = [{ methodName: 'toggleDarkMode', name: 'CmdOrCtrl+Shift+D' }];
|
||||
|
||||
IoCContainer.shortcuts.set(TestController, metadata);
|
||||
|
||||
expect(IoCContainer.shortcuts.get(TestController)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should allow multiple shortcuts per class', () => {
|
||||
const metadata = [
|
||||
{ methodName: 'toggleDarkMode', name: 'CmdOrCtrl+Shift+D' },
|
||||
{ methodName: 'openSettings', name: 'CmdOrCtrl+,' },
|
||||
{ methodName: 'newChat', name: 'CmdOrCtrl+N' },
|
||||
];
|
||||
|
||||
IoCContainer.shortcuts.set(TestController, metadata);
|
||||
|
||||
const stored = IoCContainer.shortcuts.get(TestController);
|
||||
expect(stored).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered class', () => {
|
||||
class UnregisteredClass {}
|
||||
|
||||
expect(IoCContainer.shortcuts.get(UnregisteredClass)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('protocolHandlers WeakMap', () => {
|
||||
it('should store protocol handler metadata', () => {
|
||||
const metadata = [{ action: 'install', methodName: 'handleInstall', urlType: 'plugin' }];
|
||||
|
||||
IoCContainer.protocolHandlers.set(TestController, metadata);
|
||||
|
||||
expect(IoCContainer.protocolHandlers.get(TestController)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should support multiple protocol handlers', () => {
|
||||
const metadata = [
|
||||
{ action: 'install', methodName: 'handleInstall', urlType: 'plugin' },
|
||||
{ action: 'uninstall', methodName: 'handleUninstall', urlType: 'plugin' },
|
||||
{ action: 'open', methodName: 'handleOpen', urlType: 'chat' },
|
||||
];
|
||||
|
||||
IoCContainer.protocolHandlers.set(TestController, metadata);
|
||||
|
||||
const stored = IoCContainer.protocolHandlers.get(TestController);
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored?.map((h) => h.urlType)).toContain('plugin');
|
||||
expect(stored?.map((h) => h.urlType)).toContain('chat');
|
||||
});
|
||||
|
||||
it('should allow different classes to have different handlers', () => {
|
||||
const metadata1 = [{ action: 'install', methodName: 'handleInstall', urlType: 'plugin' }];
|
||||
const metadata2 = [{ action: 'open', methodName: 'handleOpen', urlType: 'chat' }];
|
||||
|
||||
IoCContainer.protocolHandlers.set(TestController, metadata1);
|
||||
IoCContainer.protocolHandlers.set(AnotherController, metadata2);
|
||||
|
||||
expect(IoCContainer.protocolHandlers.get(TestController)?.[0].urlType).toBe('plugin');
|
||||
expect(IoCContainer.protocolHandlers.get(AnotherController)?.[0].urlType).toBe('chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should be callable without error', () => {
|
||||
const container = new IoCContainer();
|
||||
|
||||
expect(() => container.init()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return undefined', () => {
|
||||
const container = new IoCContainer();
|
||||
|
||||
const result = container.init();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('static properties', () => {
|
||||
it('should have controllers as a WeakMap', () => {
|
||||
expect(IoCContainer.controllers).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
|
||||
it('should have shortcuts as a WeakMap', () => {
|
||||
expect(IoCContainer.shortcuts).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
|
||||
it('should have protocolHandlers as a WeakMap', () => {
|
||||
expect(IoCContainer.protocolHandlers).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { app } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getProtocolScheme, parseProtocolUrl } from '@/utils/protocol';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { ProtocolManager } from '../ProtocolManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockApp, mockGetProtocolScheme, mockParseProtocolUrl } = vi.hoisted(() => ({
|
||||
mockApp: {
|
||||
getPath: vi.fn().mockReturnValue('/mock/exe/path'),
|
||||
isDefaultProtocolClient: vi.fn().mockReturnValue(true),
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
name: 'LobeHub',
|
||||
on: vi.fn(),
|
||||
setAsDefaultProtocolClient: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
mockGetProtocolScheme: vi.fn().mockReturnValue('lobehub'),
|
||||
mockParseProtocolUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock electron app
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock protocol utils
|
||||
vi.mock('@/utils/protocol', () => ({
|
||||
getProtocolScheme: mockGetProtocolScheme,
|
||||
parseProtocolUrl: mockParseProtocolUrl,
|
||||
}));
|
||||
|
||||
// Mock isDev
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
describe('ProtocolManager', () => {
|
||||
let manager: ProtocolManager;
|
||||
let mockAppCore: AppCore;
|
||||
let mockShowMainWindow: ReturnType<typeof vi.fn>;
|
||||
let mockHandleProtocolRequest: ReturnType<typeof vi.fn>;
|
||||
|
||||
// Store event handlers
|
||||
let openUrlHandler: ((event: any, url: string) => void) | undefined;
|
||||
let secondInstanceHandler: ((event: any, commandLine: string[]) => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset electron app mock
|
||||
mockApp.isDefaultProtocolClient.mockReturnValue(true);
|
||||
mockApp.setAsDefaultProtocolClient.mockReturnValue(true);
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
|
||||
// Capture event handlers
|
||||
openUrlHandler = undefined;
|
||||
secondInstanceHandler = undefined;
|
||||
mockApp.on.mockImplementation((event: string, handler: any) => {
|
||||
if (event === 'open-url') {
|
||||
openUrlHandler = handler;
|
||||
} else if (event === 'second-instance') {
|
||||
secondInstanceHandler = handler;
|
||||
}
|
||||
return mockApp;
|
||||
});
|
||||
|
||||
// Reset protocol utils mock
|
||||
mockGetProtocolScheme.mockReturnValue('lobehub');
|
||||
mockParseProtocolUrl.mockReturnValue({
|
||||
action: 'install',
|
||||
params: { url: 'https://example.com' },
|
||||
urlType: 'plugin',
|
||||
});
|
||||
|
||||
// Create mock App core
|
||||
mockShowMainWindow = vi.fn();
|
||||
mockHandleProtocolRequest = vi.fn().mockResolvedValue(true);
|
||||
|
||||
mockAppCore = {
|
||||
browserManager: {
|
||||
showMainWindow: mockShowMainWindow,
|
||||
},
|
||||
handleProtocolRequest: mockHandleProtocolRequest,
|
||||
} as unknown as AppCore;
|
||||
|
||||
manager = new ProtocolManager(mockAppCore);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with protocol scheme from getProtocolScheme', () => {
|
||||
expect(getProtocolScheme).toHaveBeenCalled();
|
||||
expect(manager.getScheme()).toBe('lobehub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should register protocol handlers', () => {
|
||||
manager.initialize();
|
||||
|
||||
expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('lobehub');
|
||||
});
|
||||
|
||||
it('should set up event listeners', () => {
|
||||
manager.initialize();
|
||||
|
||||
expect(app.on).toHaveBeenCalledWith('open-url', expect.any(Function));
|
||||
expect(app.on).toHaveBeenCalledWith('second-instance', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('protocol registration', () => {
|
||||
it('should use simple registration in production mode', () => {
|
||||
manager.initialize();
|
||||
|
||||
expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('lobehub');
|
||||
});
|
||||
|
||||
it('should use explicit parameters in development mode', async () => {
|
||||
vi.doMock('@/const/env', () => ({ isDev: true }));
|
||||
vi.resetModules();
|
||||
|
||||
const { ProtocolManager: DevProtocolManager } = await import('../ProtocolManager');
|
||||
const devManager = new DevProtocolManager(mockAppCore);
|
||||
devManager.initialize();
|
||||
|
||||
// In dev mode, should be called with additional arguments
|
||||
expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith(
|
||||
'lobehub',
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify registration status after registering', () => {
|
||||
manager.initialize();
|
||||
|
||||
expect(app.isDefaultProtocolClient).toHaveBeenCalledWith('lobehub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProtocolUrlFromArgs', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize();
|
||||
});
|
||||
|
||||
it('should extract protocol URL from command line arguments', () => {
|
||||
// Access private method through prototype
|
||||
const result = manager['getProtocolUrlFromArgs']([
|
||||
'/path/to/app',
|
||||
'lobehub://plugin/install?url=https://example.com',
|
||||
]);
|
||||
|
||||
expect(result).toBe('lobehub://plugin/install?url=https://example.com');
|
||||
});
|
||||
|
||||
it('should return null when no matching URL found', () => {
|
||||
const result = manager['getProtocolUrlFromArgs'](['/path/to/app', '--some-flag']);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return first matching URL when multiple exist', () => {
|
||||
const result = manager['getProtocolUrlFromArgs']([
|
||||
'lobehub://first/action',
|
||||
'lobehub://second/action',
|
||||
]);
|
||||
|
||||
expect(result).toBe('lobehub://first/action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleProtocolUrl', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize();
|
||||
});
|
||||
|
||||
it('should store URL when app is not ready', () => {
|
||||
mockApp.isReady.mockReturnValue(false);
|
||||
|
||||
manager['handleProtocolUrl']('lobehub://plugin/install');
|
||||
|
||||
expect(manager['pendingUrls']).toContain('lobehub://plugin/install');
|
||||
expect(mockShowMainWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process URL immediately when app is ready', async () => {
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
|
||||
manager['handleProtocolUrl']('lobehub://plugin/install');
|
||||
|
||||
// Allow async processing
|
||||
await vi.waitFor(() => {
|
||||
expect(mockShowMainWindow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore URLs with invalid protocol scheme', async () => {
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
|
||||
manager['handleProtocolUrl']('invalid://plugin/install');
|
||||
|
||||
await Promise.resolve(); // Allow any async work
|
||||
|
||||
expect(mockHandleProtocolRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event listeners', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize();
|
||||
});
|
||||
|
||||
it('should handle open-url event', async () => {
|
||||
expect(openUrlHandler).toBeDefined();
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() };
|
||||
openUrlHandler!(mockEvent, 'lobehub://plugin/install');
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockShowMainWindow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle second-instance event with protocol URL', async () => {
|
||||
expect(secondInstanceHandler).toBeDefined();
|
||||
|
||||
const mockEvent = {};
|
||||
secondInstanceHandler!(mockEvent, ['/path/to/app', 'lobehub://plugin/install']);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockShowMainWindow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show main window even without protocol URL in second-instance', () => {
|
||||
expect(secondInstanceHandler).toBeDefined();
|
||||
|
||||
const mockEvent = {};
|
||||
secondInstanceHandler!(mockEvent, ['/path/to/app', '--some-flag']);
|
||||
|
||||
expect(mockShowMainWindow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processPendingUrls', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize();
|
||||
});
|
||||
|
||||
it('should process all pending URLs', async () => {
|
||||
// Add pending URLs
|
||||
manager['pendingUrls'] = ['lobehub://action1', 'lobehub://action2'];
|
||||
|
||||
await manager.processPendingUrls();
|
||||
|
||||
// Should have shown main window for each URL
|
||||
expect(mockShowMainWindow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should clear pending URLs after processing', async () => {
|
||||
manager['pendingUrls'] = ['lobehub://action1'];
|
||||
|
||||
await manager.processPendingUrls();
|
||||
|
||||
expect(manager['pendingUrls']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip when no pending URLs', async () => {
|
||||
manager['pendingUrls'] = [];
|
||||
|
||||
await manager.processPendingUrls();
|
||||
|
||||
expect(mockShowMainWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScheme', () => {
|
||||
it('should return the protocol scheme', () => {
|
||||
expect(manager.getScheme()).toBe('lobehub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRegistered', () => {
|
||||
it('should return true when registered', () => {
|
||||
mockApp.isDefaultProtocolClient.mockReturnValue(true);
|
||||
|
||||
expect(manager.isRegistered()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when not registered', () => {
|
||||
mockApp.isDefaultProtocolClient.mockReturnValue(false);
|
||||
|
||||
expect(manager.isRegistered()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processProtocolUrl', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize();
|
||||
});
|
||||
|
||||
it('should show main window and dispatch to handler', async () => {
|
||||
vi.mocked(parseProtocolUrl).mockReturnValue({
|
||||
action: 'install',
|
||||
params: { url: 'https://example.com' },
|
||||
urlType: 'plugin',
|
||||
});
|
||||
|
||||
await manager['processProtocolUrl']('lobehub://plugin/install');
|
||||
|
||||
expect(mockShowMainWindow).toHaveBeenCalled();
|
||||
expect(mockHandleProtocolRequest).toHaveBeenCalledWith('plugin', 'install', {
|
||||
url: 'https://example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn and return when parseProtocolUrl returns null', async () => {
|
||||
vi.mocked(parseProtocolUrl).mockReturnValue(null);
|
||||
|
||||
await manager['processProtocolUrl']('lobehub://invalid');
|
||||
|
||||
expect(mockShowMainWindow).toHaveBeenCalled();
|
||||
expect(mockHandleProtocolRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockHandleProtocolRequest.mockRejectedValue(new Error('Handler error'));
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
manager['processProtocolUrl']('lobehub://plugin/install'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
import { getPort } from 'get-port-please';
|
||||
import { createServer } from 'node:http';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '../../App';
|
||||
import { StaticFileServerManager } from '../StaticFileServerManager';
|
||||
|
||||
// Mock get-port-please
|
||||
vi.mock('get-port-please', () => ({
|
||||
getPort: vi.fn().mockResolvedValue(33250),
|
||||
}));
|
||||
|
||||
// Create mock server and handler storage
|
||||
const mockServerHandler = { current: null as any };
|
||||
const mockServer = {
|
||||
close: vi.fn((cb?: () => void) => cb?.()),
|
||||
listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock node:http
|
||||
vi.mock('node:http', () => ({
|
||||
createServer: vi.fn((handler: any) => {
|
||||
mockServerHandler.current = handler;
|
||||
return mockServer;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock LOCAL_STORAGE_URL_PREFIX
|
||||
vi.mock('@/const/dir', () => ({
|
||||
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
|
||||
}));
|
||||
|
||||
describe('StaticFileServerManager', () => {
|
||||
let manager: StaticFileServerManager;
|
||||
let mockApp: App;
|
||||
let mockFileService: { getFile: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset server handler
|
||||
mockServerHandler.current = null;
|
||||
|
||||
// Reset getPort mock to default behavior
|
||||
vi.mocked(getPort).mockResolvedValue(33250);
|
||||
|
||||
// Reset server mock behaviors
|
||||
mockServer.listen.mockImplementation((_port: number, _host: string, cb: () => void) => cb());
|
||||
mockServer.close.mockImplementation((cb?: () => void) => cb?.());
|
||||
mockServer.on.mockReset();
|
||||
|
||||
// Create mock FileService
|
||||
mockFileService = {
|
||||
getFile: vi.fn().mockResolvedValue({
|
||||
content: new ArrayBuffer(10),
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
};
|
||||
|
||||
// Create mock App
|
||||
mockApp = {
|
||||
getService: vi.fn().mockReturnValue(mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
manager = new StaticFileServerManager(mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure cleanup
|
||||
if ((manager as any).isInitialized) {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with app reference and get FileService', () => {
|
||||
expect(mockApp.getService).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should get available port and start HTTP server', async () => {
|
||||
await manager.initialize();
|
||||
|
||||
expect(getPort).toHaveBeenCalledWith({
|
||||
host: '127.0.0.1',
|
||||
port: 33_250,
|
||||
ports: [33_251, 33_252, 33_253, 33_254, 33_255],
|
||||
});
|
||||
expect(createServer).toHaveBeenCalled();
|
||||
expect(mockServer.listen).toHaveBeenCalledWith(33250, '127.0.0.1', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should skip initialization if already initialized', async () => {
|
||||
await manager.initialize();
|
||||
|
||||
// Clear mocks after first initialization
|
||||
vi.mocked(getPort).mockClear();
|
||||
vi.mocked(createServer).mockClear();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(getPort).not.toHaveBeenCalled();
|
||||
expect(createServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when port acquisition fails', async () => {
|
||||
const error = new Error('No available port');
|
||||
vi.mocked(getPort).mockRejectedValue(error);
|
||||
|
||||
await expect(manager.initialize()).rejects.toThrow('No available port');
|
||||
});
|
||||
|
||||
it('should handle server startup error', async () => {
|
||||
const serverError = new Error('Address in use');
|
||||
|
||||
// Mock server.on to capture error handler
|
||||
let errorHandler: ((err: Error) => void) | undefined;
|
||||
mockServer.on.mockImplementation((event: string, handler: any) => {
|
||||
if (event === 'error') {
|
||||
errorHandler = handler;
|
||||
}
|
||||
return mockServer;
|
||||
});
|
||||
|
||||
// Mock listen to not call callback but trigger error
|
||||
mockServer.listen.mockImplementation(() => {
|
||||
// Trigger error after a tick
|
||||
setTimeout(() => {
|
||||
if (errorHandler) {
|
||||
errorHandler(serverError);
|
||||
}
|
||||
}, 0);
|
||||
return mockServer;
|
||||
});
|
||||
|
||||
await expect(manager.initialize()).rejects.toThrow('Address in use');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP request handling', () => {
|
||||
beforeEach(async () => {
|
||||
// Reset mock server behavior
|
||||
mockServer.listen.mockImplementation((_port, _host, cb) => cb());
|
||||
await manager.initialize();
|
||||
});
|
||||
|
||||
it('should handle OPTIONS request with CORS headers', async () => {
|
||||
const req = {
|
||||
headers: { origin: 'http://localhost:3000' },
|
||||
method: 'OPTIONS',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/test.png',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(204, {
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Origin': 'http://localhost:3000',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
});
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should serve file with correct content type and CORS headers', async () => {
|
||||
const fileContent = new ArrayBuffer(100);
|
||||
mockFileService.getFile.mockResolvedValue({
|
||||
content: fileContent,
|
||||
mimeType: 'image/jpeg',
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { origin: 'http://127.0.0.1:3000' },
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/images/test.jpg',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(mockFileService.getFile).toHaveBeenCalledWith('desktop://images/test.jpg');
|
||||
expect(res.writeHead).toHaveBeenCalledWith(200, {
|
||||
'Access-Control-Allow-Origin': 'http://127.0.0.1:3000',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Length': expect.any(Number),
|
||||
'Content-Type': 'image/jpeg',
|
||||
});
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for empty file path', async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(400, { 'Content-Type': 'text/plain' });
|
||||
expect(res.end).toHaveBeenCalledWith('Bad Request: Empty file path');
|
||||
});
|
||||
|
||||
it('should return 404 when file not found', async () => {
|
||||
const notFoundError = new Error('File not found');
|
||||
notFoundError.name = 'FileNotFoundError';
|
||||
mockFileService.getFile.mockRejectedValue(notFoundError);
|
||||
|
||||
const req = {
|
||||
headers: { origin: 'http://localhost:3000' },
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/nonexistent.png',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(404, {
|
||||
'Access-Control-Allow-Origin': 'http://localhost:3000',
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
expect(res.end).toHaveBeenCalledWith('File Not Found');
|
||||
});
|
||||
|
||||
it('should return 500 for server errors', async () => {
|
||||
mockFileService.getFile.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/test.png',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(500, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
expect(res.end).toHaveBeenCalledWith('Internal Server Error');
|
||||
});
|
||||
|
||||
it('should skip processing if response is already destroyed', async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/test.png',
|
||||
};
|
||||
const res = {
|
||||
destroyed: true,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).not.toHaveBeenCalled();
|
||||
expect(res.end).not.toHaveBeenCalled();
|
||||
expect(mockFileService.getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle URL-encoded file paths', async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/lobe-desktop-file/path%20with%20spaces/file%20name.png',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(mockFileService.getFile).toHaveBeenCalledWith(
|
||||
'desktop://path with spaces/file name.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORS handling', () => {
|
||||
beforeEach(async () => {
|
||||
mockServer.listen.mockImplementation((_port, _host, cb) => cb());
|
||||
await manager.initialize();
|
||||
});
|
||||
|
||||
it('should return specific origin for localhost', async () => {
|
||||
const req = {
|
||||
headers: { origin: 'http://localhost:3000' },
|
||||
method: 'OPTIONS',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/test',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(
|
||||
204,
|
||||
expect.objectContaining({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:3000',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return specific origin for 127.0.0.1', async () => {
|
||||
const req = {
|
||||
headers: { origin: 'http://127.0.0.1:8080' },
|
||||
method: 'OPTIONS',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/test',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(
|
||||
204,
|
||||
expect.objectContaining({
|
||||
'Access-Control-Allow-Origin': 'http://127.0.0.1:8080',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return * for other origins', async () => {
|
||||
const req = {
|
||||
headers: { origin: 'https://example.com' },
|
||||
method: 'OPTIONS',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/test',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(
|
||||
204,
|
||||
expect.objectContaining({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return * for no origin', async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
method: 'OPTIONS',
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
url: '/test',
|
||||
};
|
||||
const res = {
|
||||
destroyed: false,
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writeHead: vi.fn(),
|
||||
};
|
||||
|
||||
await mockServerHandler.current(req, res);
|
||||
|
||||
expect(res.writeHead).toHaveBeenCalledWith(
|
||||
204,
|
||||
expect.objectContaining({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileServerDomain', () => {
|
||||
it('should return correct domain when initialized', async () => {
|
||||
mockServer.listen.mockImplementation((_port, _host, cb) => cb());
|
||||
await manager.initialize();
|
||||
|
||||
const domain = manager.getFileServerDomain();
|
||||
|
||||
expect(domain).toBe('http://127.0.0.1:33250');
|
||||
});
|
||||
|
||||
it('should throw error when not initialized', () => {
|
||||
expect(() => manager.getFileServerDomain()).toThrow(
|
||||
'StaticFileServerManager not initialized or server not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should close server and reset state', async () => {
|
||||
mockServer.listen.mockImplementation((_port, _host, cb) => cb());
|
||||
await manager.initialize();
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(mockServer.close).toHaveBeenCalled();
|
||||
expect((manager as any).httpServer).toBeNull();
|
||||
expect((manager as any).serverPort).toBe(0);
|
||||
expect((manager as any).isInitialized).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing if not initialized', () => {
|
||||
manager.destroy();
|
||||
|
||||
expect(mockServer.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { StoreManager } from '../StoreManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockStoreInstance, mockMakeSureDirExist, MockStore } = vi.hoisted(() => {
|
||||
const mockStoreInstance = {
|
||||
clear: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn().mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'storagePath') return '/mock/storage/path';
|
||||
return defaultValue;
|
||||
}),
|
||||
has: vi.fn().mockReturnValue(false),
|
||||
openInEditor: vi.fn().mockResolvedValue(undefined),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
const MockStore = vi.fn().mockImplementation(() => mockStoreInstance);
|
||||
|
||||
return {
|
||||
MockStore,
|
||||
mockMakeSureDirExist: vi.fn(),
|
||||
mockStoreInstance,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock electron-store
|
||||
vi.mock('electron-store', () => ({
|
||||
default: MockStore,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock file-system utils
|
||||
vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: mockMakeSureDirExist,
|
||||
}));
|
||||
|
||||
// Mock store constants
|
||||
vi.mock('@/const/store', () => ({
|
||||
STORE_DEFAULTS: {
|
||||
locale: 'auto',
|
||||
storagePath: '/default/storage/path',
|
||||
},
|
||||
STORE_NAME: 'test-config',
|
||||
}));
|
||||
|
||||
describe('StoreManager', () => {
|
||||
let manager: StoreManager;
|
||||
let mockAppCore: AppCore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset store mock behaviors
|
||||
mockStoreInstance.get.mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'storagePath') return '/mock/storage/path';
|
||||
return defaultValue;
|
||||
});
|
||||
mockStoreInstance.has.mockReturnValue(false);
|
||||
|
||||
// Create mock App core
|
||||
mockAppCore = {} as unknown as AppCore;
|
||||
|
||||
manager = new StoreManager(mockAppCore);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create electron-store with correct options', () => {
|
||||
expect(MockStore).toHaveBeenCalledWith({
|
||||
defaults: {
|
||||
locale: 'auto',
|
||||
storagePath: '/default/storage/path',
|
||||
},
|
||||
name: 'test-config',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ensure storage directory exists', () => {
|
||||
expect(mockMakeSureDirExist).toHaveBeenCalledWith('/mock/storage/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should call store.get with key', () => {
|
||||
mockStoreInstance.get.mockReturnValue('test-value');
|
||||
|
||||
const result = manager.get('locale' as any);
|
||||
|
||||
expect(mockStoreInstance.get).toHaveBeenCalledWith('locale', undefined);
|
||||
expect(result).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should call store.get with key and default value', () => {
|
||||
mockStoreInstance.get.mockImplementation((_key: string, defaultValue?: any) => defaultValue);
|
||||
|
||||
const result = manager.get('locale' as any, 'en-US' as any);
|
||||
|
||||
expect(mockStoreInstance.get).toHaveBeenCalledWith('locale', 'en-US');
|
||||
expect(result).toBe('en-US');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should call store.set with key and value', () => {
|
||||
manager.set('locale' as any, 'zh-CN' as any);
|
||||
|
||||
expect(mockStoreInstance.set).toHaveBeenCalledWith('locale', 'zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should call store.delete with key', () => {
|
||||
manager.delete('locale' as any);
|
||||
|
||||
expect(mockStoreInstance.delete).toHaveBeenCalledWith('locale');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should call store.clear', () => {
|
||||
manager.clear();
|
||||
|
||||
expect(mockStoreInstance.clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('has', () => {
|
||||
it('should return true when key exists', () => {
|
||||
mockStoreInstance.has.mockReturnValue(true);
|
||||
|
||||
const result = manager.has('locale' as any);
|
||||
|
||||
expect(mockStoreInstance.has).toHaveBeenCalledWith('locale');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when key does not exist', () => {
|
||||
mockStoreInstance.has.mockReturnValue(false);
|
||||
|
||||
const result = manager.has('nonExistent' as any);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openInEditor', () => {
|
||||
it('should call store.openInEditor', async () => {
|
||||
await manager.openInEditor();
|
||||
|
||||
expect(mockStoreInstance.openInEditor).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { UpdaterManager } from '../UpdaterManager';
|
||||
|
||||
// Use vi.hoisted to ensure mocks work with require()
|
||||
const { mockGetAllWindows, mockReleaseSingleInstanceLock } = vi.hoisted(() => ({
|
||||
mockGetAllWindows: vi.fn().mockReturnValue([]),
|
||||
mockReleaseSingleInstanceLock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock electron-log
|
||||
vi.mock('electron-log', () => ({
|
||||
default: {
|
||||
transports: {
|
||||
file: {
|
||||
level: 'info',
|
||||
getFile: vi.fn().mockReturnValue({ path: '/mock/log/path' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-updater
|
||||
vi.mock('electron-updater', () => ({
|
||||
autoUpdater: {
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
channel: 'stable',
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
forceDevUpdateConfig: false,
|
||||
logger: null as any,
|
||||
on: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron - uses hoisted functions for require() compatibility
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: mockGetAllWindows,
|
||||
},
|
||||
app: {
|
||||
releaseSingleInstanceLock: mockReleaseSingleInstanceLock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock updater configs
|
||||
vi.mock('@/modules/updater/configs', () => ({
|
||||
UPDATE_CHANNEL: 'stable',
|
||||
updaterConfig: {
|
||||
app: {
|
||||
autoCheckUpdate: false,
|
||||
autoDownloadUpdate: true,
|
||||
checkUpdateInterval: 60 * 60 * 1000,
|
||||
},
|
||||
enableAppUpdate: true,
|
||||
enableRenderHotUpdate: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock isDev
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
describe('UpdaterManager', () => {
|
||||
let updaterManager: UpdaterManager;
|
||||
let mockApp: AppCore;
|
||||
let mockBroadcast: ReturnType<typeof vi.fn>;
|
||||
let registeredEvents: Map<string, (...args: any[]) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Reset autoUpdater state
|
||||
(autoUpdater as any).autoDownload = false;
|
||||
(autoUpdater as any).autoInstallOnAppQuit = false;
|
||||
(autoUpdater as any).channel = 'stable';
|
||||
(autoUpdater as any).allowPrerelease = false;
|
||||
(autoUpdater as any).allowDowngrade = false;
|
||||
(autoUpdater as any).forceDevUpdateConfig = false;
|
||||
|
||||
// Capture registered events
|
||||
registeredEvents = new Map();
|
||||
vi.mocked(autoUpdater.on).mockImplementation((event: string, handler: any) => {
|
||||
registeredEvents.set(event, handler);
|
||||
return autoUpdater;
|
||||
});
|
||||
|
||||
// Mock broadcast function
|
||||
mockBroadcast = vi.fn();
|
||||
|
||||
// Create mock App
|
||||
mockApp = {
|
||||
browserManager: {
|
||||
getMainWindow: vi.fn().mockReturnValue({
|
||||
broadcast: mockBroadcast,
|
||||
}),
|
||||
},
|
||||
isQuiting: false,
|
||||
} as unknown as AppCore;
|
||||
|
||||
updaterManager = new UpdaterManager(mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should set up electron-log for autoUpdater', () => {
|
||||
expect(autoUpdater.logger).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should configure autoUpdater properties', async () => {
|
||||
await updaterManager.initialize();
|
||||
|
||||
expect(autoUpdater.autoDownload).toBe(false);
|
||||
expect(autoUpdater.autoInstallOnAppQuit).toBe(false);
|
||||
expect(autoUpdater.channel).toBe('stable');
|
||||
expect(autoUpdater.allowPrerelease).toBe(false);
|
||||
expect(autoUpdater.allowDowngrade).toBe(false);
|
||||
});
|
||||
|
||||
it('should register all event listeners', async () => {
|
||||
await updaterManager.initialize();
|
||||
|
||||
expect(autoUpdater.on).toHaveBeenCalledWith('checking-for-update', expect.any(Function));
|
||||
expect(autoUpdater.on).toHaveBeenCalledWith('update-available', expect.any(Function));
|
||||
expect(autoUpdater.on).toHaveBeenCalledWith('update-not-available', expect.any(Function));
|
||||
expect(autoUpdater.on).toHaveBeenCalledWith('error', expect.any(Function));
|
||||
expect(autoUpdater.on).toHaveBeenCalledWith('download-progress', expect.any(Function));
|
||||
expect(autoUpdater.on).toHaveBeenCalledWith('update-downloaded', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForUpdates', () => {
|
||||
beforeEach(async () => {
|
||||
await updaterManager.initialize();
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it('should call autoUpdater.checkForUpdates', async () => {
|
||||
await updaterManager.checkForUpdates();
|
||||
|
||||
expect(autoUpdater.checkForUpdates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should broadcast manualUpdateCheckStart when manual check', async () => {
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('manualUpdateCheckStart');
|
||||
});
|
||||
|
||||
it('should not broadcast when auto check', async () => {
|
||||
await updaterManager.checkForUpdates({ manual: false });
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith('manualUpdateCheckStart');
|
||||
});
|
||||
|
||||
it('should ignore duplicate check requests while checking', async () => {
|
||||
// Start first check but don't resolve
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 1000)) as any,
|
||||
);
|
||||
|
||||
const firstCheck = updaterManager.checkForUpdates();
|
||||
const secondCheck = updaterManager.checkForUpdates();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await Promise.all([firstCheck, secondCheck]);
|
||||
|
||||
expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should broadcast updateError when check fails during manual check', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockRejectedValue(error);
|
||||
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateError', 'Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadUpdate', () => {
|
||||
beforeEach(async () => {
|
||||
await updaterManager.initialize();
|
||||
vi.mocked(autoUpdater.downloadUpdate).mockResolvedValue([] as any);
|
||||
|
||||
// Simulate update available
|
||||
const updateAvailableHandler = registeredEvents.get('update-available');
|
||||
updateAvailableHandler?.({ version: '2.0.0' });
|
||||
});
|
||||
|
||||
it('should call autoUpdater.downloadUpdate', async () => {
|
||||
await updaterManager.downloadUpdate();
|
||||
|
||||
expect(autoUpdater.downloadUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore download request when no update available', async () => {
|
||||
// Create fresh manager without update available
|
||||
const freshManager = new UpdaterManager(mockApp);
|
||||
await freshManager.initialize();
|
||||
|
||||
await freshManager.downloadUpdate();
|
||||
|
||||
// Reset call count since downloadUpdate might have been called in beforeEach
|
||||
vi.mocked(autoUpdater.downloadUpdate).mockClear();
|
||||
await freshManager.downloadUpdate();
|
||||
|
||||
// downloadUpdate should not be called on autoUpdater for fresh manager
|
||||
expect(autoUpdater.downloadUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore duplicate download requests while downloading', async () => {
|
||||
vi.mocked(autoUpdater.downloadUpdate).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 1000)) as any,
|
||||
);
|
||||
|
||||
const firstDownload = updaterManager.downloadUpdate();
|
||||
const secondDownload = updaterManager.downloadUpdate();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await Promise.all([firstDownload, secondDownload]);
|
||||
|
||||
expect(autoUpdater.downloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should broadcast updateDownloadStart when isManualCheck is true', async () => {
|
||||
// Create a fresh manager to avoid state pollution from beforeEach
|
||||
const freshManager = new UpdaterManager(mockApp);
|
||||
|
||||
// Setup fresh event capture
|
||||
const freshEvents = new Map<string, (...args: any[]) => void>();
|
||||
vi.mocked(autoUpdater.on).mockImplementation((event: string, handler: any) => {
|
||||
freshEvents.set(event, handler);
|
||||
return autoUpdater;
|
||||
});
|
||||
await freshManager.initialize();
|
||||
|
||||
// Trigger a manual check to set isManualCheck = true
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await freshManager.checkForUpdates({ manual: true });
|
||||
|
||||
// Manually set updateAvailable without triggering auto-download
|
||||
// Access private property to set state
|
||||
(freshManager as any).updateAvailable = true;
|
||||
|
||||
// Clear previous broadcast calls
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
// Now download should broadcast updateDownloadStart because isManualCheck is true
|
||||
vi.mocked(autoUpdater.downloadUpdate).mockResolvedValue([] as any);
|
||||
await freshManager.downloadUpdate();
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateDownloadStart');
|
||||
});
|
||||
|
||||
it('should broadcast updateError when download fails with isManualCheck true', async () => {
|
||||
// Create a fresh manager to avoid state pollution from beforeEach
|
||||
const freshManager = new UpdaterManager(mockApp);
|
||||
|
||||
// Setup fresh event capture
|
||||
const freshEvents = new Map<string, (...args: any[]) => void>();
|
||||
vi.mocked(autoUpdater.on).mockImplementation((event: string, handler: any) => {
|
||||
freshEvents.set(event, handler);
|
||||
return autoUpdater;
|
||||
});
|
||||
await freshManager.initialize();
|
||||
|
||||
// Trigger a manual check to set isManualCheck = true
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await freshManager.checkForUpdates({ manual: true });
|
||||
|
||||
// Manually set updateAvailable without triggering auto-download
|
||||
(freshManager as any).updateAvailable = true;
|
||||
|
||||
// Clear previous broadcast calls
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
// Setup error
|
||||
const error = new Error('Download failed');
|
||||
vi.mocked(autoUpdater.downloadUpdate).mockRejectedValue(error);
|
||||
|
||||
await freshManager.downloadUpdate();
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateError', 'Download failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('installNow', () => {
|
||||
// Note: installNow uses require('electron') which is difficult to mock in vitest.
|
||||
// These tests are skipped because vi.mock doesn't work with dynamic require().
|
||||
// The functionality should be tested in integration tests or E2E tests.
|
||||
|
||||
it.skip('should set app.isQuiting to true', () => {
|
||||
updaterManager.installNow();
|
||||
expect(mockApp.isQuiting).toBe(true);
|
||||
});
|
||||
|
||||
it.skip('should close all windows', () => {
|
||||
const mockWindow1 = { close: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false) };
|
||||
const mockWindow2 = { close: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false) };
|
||||
mockGetAllWindows.mockReturnValue([mockWindow1, mockWindow2]);
|
||||
updaterManager.installNow();
|
||||
expect(mockWindow1.close).toHaveBeenCalled();
|
||||
expect(mockWindow2.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('should not close destroyed windows', () => {
|
||||
const mockWindow = { close: vi.fn(), isDestroyed: vi.fn().mockReturnValue(true) };
|
||||
mockGetAllWindows.mockReturnValue([mockWindow]);
|
||||
updaterManager.installNow();
|
||||
expect(mockWindow.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('should release single instance lock', () => {
|
||||
updaterManager.installNow();
|
||||
expect(mockReleaseSingleInstanceLock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('should call quitAndInstall with correct parameters after delay', async () => {
|
||||
updaterManager.installNow();
|
||||
expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(autoUpdater.quitAndInstall).toHaveBeenCalledWith(true, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installLater', () => {
|
||||
it('should set autoInstallOnAppQuit to true', () => {
|
||||
updaterManager.installLater();
|
||||
|
||||
expect(autoUpdater.autoInstallOnAppQuit).toBe(true);
|
||||
});
|
||||
|
||||
it('should broadcast updateWillInstallLater', () => {
|
||||
updaterManager.installLater();
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateWillInstallLater');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event handlers', () => {
|
||||
beforeEach(async () => {
|
||||
await updaterManager.initialize();
|
||||
});
|
||||
|
||||
describe('update-available', () => {
|
||||
it('should broadcast manualUpdateAvailable when manual check', async () => {
|
||||
// Trigger manual check first
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
const updateInfo = { version: '2.0.0' };
|
||||
const handler = registeredEvents.get('update-available');
|
||||
handler?.(updateInfo);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('manualUpdateAvailable', updateInfo);
|
||||
});
|
||||
|
||||
it('should auto download when auto check finds update', async () => {
|
||||
// Trigger auto check first
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: false });
|
||||
|
||||
vi.mocked(autoUpdater.downloadUpdate).mockResolvedValue([] as any);
|
||||
|
||||
const handler = registeredEvents.get('update-available');
|
||||
handler?.({ version: '2.0.0' });
|
||||
|
||||
expect(autoUpdater.downloadUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-not-available', () => {
|
||||
it('should broadcast manualUpdateNotAvailable when manual check', async () => {
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
const info = { version: '1.0.0' };
|
||||
const handler = registeredEvents.get('update-not-available');
|
||||
handler?.(info);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('manualUpdateNotAvailable', info);
|
||||
});
|
||||
|
||||
it('should not broadcast when auto check', async () => {
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: false });
|
||||
|
||||
const handler = registeredEvents.get('update-not-available');
|
||||
handler?.({ version: '1.0.0' });
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith(
|
||||
'manualUpdateNotAvailable',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download-progress', () => {
|
||||
it('should broadcast progress when manual check', async () => {
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
const progressObj = {
|
||||
bytesPerSecond: 1024,
|
||||
percent: 50,
|
||||
total: 1024 * 1024,
|
||||
transferred: 512 * 1024,
|
||||
};
|
||||
const handler = registeredEvents.get('download-progress');
|
||||
handler?.(progressObj);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateDownloadProgress', progressObj);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-downloaded', () => {
|
||||
it('should broadcast updateDownloaded', async () => {
|
||||
await updaterManager.initialize();
|
||||
|
||||
const info = { version: '2.0.0' };
|
||||
const handler = registeredEvents.get('update-downloaded');
|
||||
handler?.(info);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateDownloaded', info);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
it('should broadcast updateError when manual check', async () => {
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
const error = new Error('Update error');
|
||||
const handler = registeredEvents.get('error');
|
||||
handler?.(error);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateError', 'Update error');
|
||||
});
|
||||
|
||||
it('should not broadcast when auto check', async () => {
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: false });
|
||||
|
||||
const error = new Error('Update error');
|
||||
const handler = registeredEvents.get('error');
|
||||
handler?.(error);
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith('updateError', expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulation methods (dev mode)', () => {
|
||||
it('simulateUpdateAvailable should do nothing when not in dev mode', () => {
|
||||
// Current mock has isDev = false
|
||||
updaterManager.simulateUpdateAvailable();
|
||||
|
||||
// Should not broadcast anything since isDev is false
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith(
|
||||
'manualUpdateAvailable',
|
||||
expect.objectContaining({ version: '1.0.0' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('simulateUpdateDownloaded should do nothing when not in dev mode', () => {
|
||||
updaterManager.simulateUpdateDownloaded();
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith(
|
||||
'updateDownloaded',
|
||||
expect.objectContaining({ version: '1.0.0' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('simulateDownloadProgress should do nothing when not in dev mode', () => {
|
||||
updaterManager.simulateDownloadProgress();
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith('updateDownloadStart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mainWindow getter', () => {
|
||||
it('should return main window from browserManager', () => {
|
||||
const mainWindow = updaterManager['mainWindow'];
|
||||
|
||||
expect(mockApp.browserManager.getMainWindow).toHaveBeenCalled();
|
||||
expect(mainWindow.broadcast).toBe(mockBroadcast);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,10 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Command+,',
|
||||
click: () => {
|
||||
this.app.browserManager.showSettingsWindow();
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl('/settings');
|
||||
mainWindow.show();
|
||||
},
|
||||
label: t('macOS.preferences'),
|
||||
},
|
||||
@@ -337,7 +339,11 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
label: t('tray.show', { appName }),
|
||||
},
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl('/settings');
|
||||
mainWindow.show();
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
||||
@@ -10,14 +10,14 @@ import { ProxyUrlBuilder } from './urlBuilder';
|
||||
const logger = createLogger('modules:networkProxy:dispatcher');
|
||||
|
||||
/**
|
||||
* 代理管理器
|
||||
* Proxy dispatcher manager
|
||||
*/
|
||||
export class ProxyDispatcherManager {
|
||||
private static isChanging = false;
|
||||
private static changeQueue: Array<() => Promise<void>> = [];
|
||||
|
||||
/**
|
||||
* 应用代理设置(带并发控制)
|
||||
* Apply proxy settings (with concurrency control)
|
||||
*/
|
||||
static async applyProxySettings(config: NetworkProxySettings): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -31,17 +31,17 @@ export class ProxyDispatcherManager {
|
||||
};
|
||||
|
||||
if (this.isChanging) {
|
||||
// 如果正在切换,加入队列
|
||||
// If currently switching, add to queue
|
||||
this.changeQueue.push(operation);
|
||||
} else {
|
||||
// 立即执行
|
||||
// Execute immediately
|
||||
operation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行代理设置应用
|
||||
* Execute proxy settings application
|
||||
*/
|
||||
private static async doApplyProxySettings(config: NetworkProxySettings): Promise<void> {
|
||||
this.isChanging = true;
|
||||
@@ -49,22 +49,22 @@ export class ProxyDispatcherManager {
|
||||
try {
|
||||
const currentDispatcher = getGlobalDispatcher();
|
||||
|
||||
// 禁用代理,恢复默认连接
|
||||
// Disable proxy, restore default connection
|
||||
if (!config.enableProxy) {
|
||||
await this.safeDestroyDispatcher(currentDispatcher);
|
||||
// 创建一个新的默认 Agent 来替代代理
|
||||
// Create a new default Agent to replace the proxy
|
||||
setGlobalDispatcher(new Agent());
|
||||
logger.debug('Proxy disabled, reset to direct connection mode');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建代理 URL
|
||||
// Build proxy URL
|
||||
const proxyUrl = ProxyUrlBuilder.build(config);
|
||||
|
||||
// 创建代理 agent
|
||||
// Create proxy agent
|
||||
const agent = this.createProxyAgent(config.proxyType, proxyUrl);
|
||||
|
||||
// 切换代理前销毁旧 dispatcher
|
||||
// Destroy old dispatcher before switching proxy
|
||||
await this.safeDestroyDispatcher(currentDispatcher);
|
||||
setGlobalDispatcher(agent);
|
||||
|
||||
@@ -77,7 +77,7 @@ export class ProxyDispatcherManager {
|
||||
} finally {
|
||||
this.isChanging = false;
|
||||
|
||||
// 处理队列中的下一个操作
|
||||
// Process next operation in queue
|
||||
if (this.changeQueue.length > 0) {
|
||||
const nextOperation = this.changeQueue.shift();
|
||||
if (nextOperation) {
|
||||
@@ -88,12 +88,12 @@ export class ProxyDispatcherManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建代理 agent
|
||||
* Create proxy agent
|
||||
*/
|
||||
static createProxyAgent(proxyType: string, proxyUrl: string) {
|
||||
try {
|
||||
if (proxyType === 'socks5') {
|
||||
// 解析 SOCKS5 代理 URL
|
||||
// Parse SOCKS5 proxy URL
|
||||
const url = new URL(proxyUrl);
|
||||
const socksProxies: SocksProxies = [
|
||||
{
|
||||
@@ -109,10 +109,10 @@ export class ProxyDispatcherManager {
|
||||
},
|
||||
];
|
||||
|
||||
// 使用 fetch-socks 处理 SOCKS5 代理
|
||||
// Use fetch-socks to handle SOCKS5 proxy
|
||||
return socksDispatcher(socksProxies);
|
||||
} else {
|
||||
// undici 的 ProxyAgent 支持 http, https
|
||||
// undici's ProxyAgent supports http, https
|
||||
return new ProxyAgent({ uri: proxyUrl });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -124,7 +124,7 @@ export class ProxyDispatcherManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全销毁 dispatcher
|
||||
* Safely destroy dispatcher
|
||||
*/
|
||||
private static async safeDestroyDispatcher(dispatcher: any): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ProxyConfigValidator } from './validator';
|
||||
const logger = createLogger('modules:networkProxy:tester');
|
||||
|
||||
/**
|
||||
* 代理连接测试结果
|
||||
* Proxy connection test result
|
||||
*/
|
||||
export interface ProxyTestResult {
|
||||
message?: string;
|
||||
@@ -20,14 +20,14 @@ export interface ProxyTestResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理连接测试器
|
||||
* Proxy connection tester
|
||||
*/
|
||||
export class ProxyConnectionTester {
|
||||
private static readonly DEFAULT_TIMEOUT = 10_000; // 10秒超时
|
||||
private static readonly DEFAULT_TIMEOUT = 10_000; // 10 seconds timeout
|
||||
private static readonly DEFAULT_TEST_URL = 'https://www.google.com';
|
||||
|
||||
/**
|
||||
* 测试代理连接
|
||||
* Test proxy connection
|
||||
*/
|
||||
static async testConnection(
|
||||
url: string = this.DEFAULT_TEST_URL,
|
||||
@@ -77,13 +77,13 @@ export class ProxyConnectionTester {
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试指定代理配置的连接
|
||||
* Test connection with specified proxy configuration
|
||||
*/
|
||||
static async testProxyConfig(
|
||||
config: NetworkProxySettings,
|
||||
testUrl: string = this.DEFAULT_TEST_URL,
|
||||
): Promise<ProxyTestResult> {
|
||||
// 验证配置
|
||||
// Validate configuration
|
||||
const validation = ProxyConfigValidator.validate(config);
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
@@ -92,12 +92,12 @@ export class ProxyConnectionTester {
|
||||
};
|
||||
}
|
||||
|
||||
// 如果未启用代理,直接测试
|
||||
// If proxy is not enabled, test directly
|
||||
if (!config.enableProxy) {
|
||||
return this.testConnection(testUrl);
|
||||
}
|
||||
|
||||
// 创建临时代理 agent 进行测试
|
||||
// Create temporary proxy agent for testing
|
||||
try {
|
||||
const proxyUrl = ProxyUrlBuilder.build(config);
|
||||
logger.debug(`Testing proxy with URL: ${proxyUrl}`);
|
||||
@@ -108,7 +108,7 @@ export class ProxyConnectionTester {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.DEFAULT_TIMEOUT);
|
||||
|
||||
// 临时设置代理进行测试
|
||||
// Temporarily set proxy for testing
|
||||
const originalDispatcher = getGlobalDispatcher();
|
||||
setGlobalDispatcher(agent);
|
||||
|
||||
@@ -138,9 +138,9 @@ export class ProxyConnectionTester {
|
||||
clearTimeout(timeoutId);
|
||||
throw fetchError;
|
||||
} finally {
|
||||
// 恢复原来的 dispatcher
|
||||
// Restore original dispatcher
|
||||
setGlobalDispatcher(originalDispatcher);
|
||||
// 清理临时创建的代理 agent
|
||||
// Clean up temporary proxy agent
|
||||
if (agent && typeof agent.destroy === 'function') {
|
||||
try {
|
||||
await agent.destroy();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
|
||||
/**
|
||||
* 代理 URL 构建器
|
||||
* Proxy URL builder
|
||||
*/
|
||||
export const ProxyUrlBuilder = {
|
||||
/**
|
||||
* 构建代理 URL
|
||||
* Build proxy URL
|
||||
*/
|
||||
build(config: NetworkProxySettings): string {
|
||||
const { proxyType, proxyServer, proxyPort, proxyRequireAuth, proxyUsername, proxyPassword } =
|
||||
@@ -13,7 +13,7 @@ export const ProxyUrlBuilder = {
|
||||
|
||||
let proxyUrl = `${proxyType}://${proxyServer}:${proxyPort}`;
|
||||
|
||||
// 添加认证信息
|
||||
// Add authentication information
|
||||
if (proxyRequireAuth && proxyUsername && proxyPassword) {
|
||||
const encodedUsername = encodeURIComponent(proxyUsername);
|
||||
const encodedPassword = encodeURIComponent(proxyPassword);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
|
||||
/**
|
||||
* 代理配置验证结果
|
||||
* Proxy configuration validation result
|
||||
*/
|
||||
export interface ProxyValidationResult {
|
||||
errors: string[];
|
||||
@@ -9,38 +9,38 @@ export interface ProxyValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理配置验证器
|
||||
* Proxy configuration validator
|
||||
*/
|
||||
export class ProxyConfigValidator {
|
||||
private static readonly SUPPORTED_TYPES = ['http', 'https', 'socks5'] as const;
|
||||
private static readonly DEFAULT_BYPASS = 'localhost,127.0.0.1,::1';
|
||||
|
||||
/**
|
||||
* 验证代理配置
|
||||
* Validate proxy configuration
|
||||
*/
|
||||
static validate(config: NetworkProxySettings): ProxyValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 如果未启用代理,跳过验证
|
||||
// If proxy is not enabled, skip validation
|
||||
if (!config.enableProxy) {
|
||||
return { errors: [], isValid: true };
|
||||
}
|
||||
|
||||
// 验证代理类型
|
||||
// Validate proxy type
|
||||
if (!this.SUPPORTED_TYPES.includes(config.proxyType as any)) {
|
||||
errors.push(
|
||||
`Unsupported proxy type: ${config.proxyType}. Supported types: ${this.SUPPORTED_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 验证代理服务器
|
||||
// Validate proxy server
|
||||
if (!config.proxyServer?.trim()) {
|
||||
errors.push('Proxy server is required when proxy is enabled');
|
||||
} else if (!this.isValidHost(config.proxyServer)) {
|
||||
errors.push('Invalid proxy server format');
|
||||
}
|
||||
|
||||
// 验证代理端口
|
||||
// Validate proxy port
|
||||
if (!config.proxyPort?.trim()) {
|
||||
errors.push('Proxy port is required when proxy is enabled');
|
||||
} else {
|
||||
@@ -50,7 +50,7 @@ export class ProxyConfigValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证认证信息
|
||||
// Validate authentication information
|
||||
if (config.proxyRequireAuth) {
|
||||
if (!config.proxyUsername?.trim()) {
|
||||
errors.push('Proxy username is required when authentication is enabled');
|
||||
@@ -67,10 +67,10 @@ export class ProxyConfigValidator {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证主机名格式
|
||||
* Validate host format
|
||||
*/
|
||||
private static isValidHost(host: string): boolean {
|
||||
// 简单的主机名验证(IP 地址或域名)
|
||||
// Simple host validation (IP address or domain name)
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
const domainRegex =
|
||||
/^[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?(\.[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?)*$/;
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import FileService, { FileNotFoundError } from '../fileSrv';
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: vi.fn(() => '/mock/app/path'),
|
||||
getPath: vi.fn(() => '/mock/user/data'),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants that depend on electron
|
||||
vi.mock('@/const/dir', () => ({
|
||||
FILE_STORAGE_DIR: 'file-storage',
|
||||
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock file-system utilities
|
||||
vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock('node:fs', () => ({
|
||||
default: {
|
||||
constants: { F_OK: 0 },
|
||||
promises: { access: vi.fn() },
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
constants: { F_OK: 0 },
|
||||
promises: { access: vi.fn() },
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:util promisify
|
||||
vi.mock('node:util', () => ({
|
||||
promisify: vi.fn((fn: any) => {
|
||||
return vi.fn(async (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(...args, (err: any, data: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('FileService', () => {
|
||||
let fileService: FileService;
|
||||
let mockApp: App;
|
||||
let mockMakeSureDirExist: any;
|
||||
let mockWriteFile: any;
|
||||
let mockReadFile: any;
|
||||
let mockAccess: any;
|
||||
let mockFsReadFile: any;
|
||||
let mockFsUnlink: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup mock app
|
||||
mockApp = {
|
||||
appStoragePath: '/mock/app/storage',
|
||||
staticFileServerManager: {
|
||||
getFileServerDomain: vi.fn().mockReturnValue('http://localhost:3000'),
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
// Import mocks
|
||||
mockMakeSureDirExist = (await import('@/utils/file-system')).makeSureDirExist;
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
mockWriteFile = fsPromises.writeFile;
|
||||
mockReadFile = fsPromises.readFile;
|
||||
mockAccess = fsPromises.access;
|
||||
|
||||
const fs = await import('node:fs');
|
||||
mockFsReadFile = fs.readFile;
|
||||
mockFsUnlink = fs.unlink;
|
||||
|
||||
fileService = new FileService(mockApp);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file with ArrayBuffer content successfully', async () => {
|
||||
const content = new ArrayBuffer(10);
|
||||
const params = {
|
||||
content,
|
||||
filename: 'test.png',
|
||||
hash: 'abc123',
|
||||
path: 'user_uploads/images/test.png',
|
||||
type: 'image/png',
|
||||
};
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.uploadFile(params);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata.filename).toBe('test.png');
|
||||
expect(result.metadata.dirname).toBe('user_uploads/images');
|
||||
expect(result.metadata.path).toBe('desktop://user_uploads/images/test.png');
|
||||
expect(mockMakeSureDirExist).toHaveBeenCalled();
|
||||
expect(mockWriteFile).toHaveBeenCalledTimes(2); // file + metadata
|
||||
});
|
||||
|
||||
it('should upload file with Base64 string content successfully', async () => {
|
||||
const base64Content = Buffer.from('test content').toString('base64');
|
||||
const params = {
|
||||
content: base64Content,
|
||||
filename: 'test.txt',
|
||||
hash: 'def456',
|
||||
path: 'documents/test.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.uploadFile(params);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata.filename).toBe('test.txt');
|
||||
expect(result.metadata.path).toBe('desktop://documents/test.txt');
|
||||
});
|
||||
|
||||
it('should create metadata file with correct structure', async () => {
|
||||
const content = new ArrayBuffer(100);
|
||||
const params = {
|
||||
content,
|
||||
filename: 'image.jpg',
|
||||
hash: 'xyz789',
|
||||
path: 'photos/image.jpg',
|
||||
type: 'image/jpeg',
|
||||
};
|
||||
|
||||
let metadataContent: string = '';
|
||||
mockWriteFile.mockImplementation(async (path: any, data: any) => {
|
||||
if (path.toString().endsWith('.meta')) {
|
||||
metadataContent = data;
|
||||
}
|
||||
});
|
||||
|
||||
await fileService.uploadFile(params);
|
||||
|
||||
expect(metadataContent).toBeTruthy();
|
||||
const metadata = JSON.parse(metadataContent);
|
||||
expect(metadata.filename).toBe('image.jpg');
|
||||
expect(metadata.hash).toBe('xyz789');
|
||||
expect(metadata.type).toBe('image/jpeg');
|
||||
expect(metadata.size).toBe(100);
|
||||
expect(metadata.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle upload failure and throw error', async () => {
|
||||
const params = {
|
||||
content: new ArrayBuffer(10),
|
||||
filename: 'test.png',
|
||||
hash: 'abc123',
|
||||
path: 'uploads/test.png',
|
||||
type: 'image/png',
|
||||
};
|
||||
|
||||
mockWriteFile.mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
await expect(fileService.uploadFile(params)).rejects.toThrow('File upload failed: Disk full');
|
||||
});
|
||||
|
||||
it('should handle file path with no directory', async () => {
|
||||
const params = {
|
||||
content: new ArrayBuffer(10),
|
||||
filename: 'test.txt',
|
||||
hash: 'abc',
|
||||
path: 'test.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.uploadFile(params);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata.dirname).toBe('');
|
||||
expect(result.metadata.filename).toBe('test.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFile', () => {
|
||||
it('should get file from new path format successfully', async () => {
|
||||
const mockContent = Buffer.from('test content');
|
||||
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
callback(null, mockContent);
|
||||
});
|
||||
|
||||
// Mock metadata read failure, will infer from extension
|
||||
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
||||
|
||||
const result = await fileService.getFile('desktop://documents/test.txt');
|
||||
|
||||
// Since metadata fails, it will use default or infer from extension
|
||||
expect(result.mimeType).toBeDefined();
|
||||
expect(result.content).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get file from legacy path format (timestamp directory)', async () => {
|
||||
const mockContent = Buffer.from('legacy content');
|
||||
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
callback(null, mockContent);
|
||||
});
|
||||
|
||||
// Mock metadata read to succeed this time
|
||||
mockReadFile.mockResolvedValue(JSON.stringify({ type: 'image/png' }));
|
||||
|
||||
const result = await fileService.getFile('desktop://1234567890/abc123.png');
|
||||
|
||||
// Check that result is returned
|
||||
expect(result.mimeType).toBeDefined();
|
||||
expect(result.content).toBeDefined();
|
||||
});
|
||||
|
||||
it('should fallback from legacy to new path on failure', async () => {
|
||||
const mockContent = Buffer.from('fallback content');
|
||||
|
||||
let callCount = 0;
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First read (legacy) fails
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
callback(error, null);
|
||||
} else {
|
||||
// Second read (fallback) succeeds
|
||||
callback(null, mockContent);
|
||||
}
|
||||
});
|
||||
|
||||
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
||||
|
||||
const result = await fileService.getFile('desktop://1234567890/fallback.jpg');
|
||||
|
||||
// Check that fallback worked and result is returned
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.mimeType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should infer MIME type from file extension when metadata missing', async () => {
|
||||
const mockContent = Buffer.from('image data');
|
||||
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
callback(null, mockContent);
|
||||
});
|
||||
|
||||
mockReadFile.mockRejectedValue(new Error('Metadata not found'));
|
||||
|
||||
const result = await fileService.getFile('desktop://images/photo.png');
|
||||
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should infer correct MIME types for various image formats', async () => {
|
||||
const mockContent = Buffer.from('image');
|
||||
|
||||
const testCases = [
|
||||
{ path: 'desktop://test.jpg', expected: 'image/jpeg' },
|
||||
{ path: 'desktop://test.jpeg', expected: 'image/jpeg' },
|
||||
{ path: 'desktop://test.gif', expected: 'image/gif' },
|
||||
{ path: 'desktop://test.webp', expected: 'image/webp' },
|
||||
{ path: 'desktop://test.svg', expected: 'image/svg+xml' },
|
||||
{ path: 'desktop://test.pdf', expected: 'application/pdf' },
|
||||
];
|
||||
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
callback(null, mockContent);
|
||||
});
|
||||
|
||||
for (const testCase of testCases) {
|
||||
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
||||
|
||||
const result = await fileService.getFile(testCase.path);
|
||||
expect(result.mimeType).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use default MIME type for unknown extensions', async () => {
|
||||
const mockContent = Buffer.from('unknown');
|
||||
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
callback(null, mockContent);
|
||||
});
|
||||
|
||||
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
||||
|
||||
const result = await fileService.getFile('desktop://file.unknown');
|
||||
|
||||
expect(result.mimeType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('should throw FileNotFoundError when file does not exist', async () => {
|
||||
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
||||
const error: any = new Error('ENOENT: no such file');
|
||||
error.code = 'ENOENT';
|
||||
error.message = 'ENOENT: no such file';
|
||||
callback(error, null);
|
||||
});
|
||||
|
||||
await expect(fileService.getFile('desktop://missing/file.txt')).rejects.toThrow(
|
||||
FileNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid path without desktop:// prefix', async () => {
|
||||
await expect(fileService.getFile('/invalid/path.txt')).rejects.toThrow(
|
||||
'Invalid desktop file path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should delete file from new path format successfully', async () => {
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const result = await fileService.deleteFile('desktop://documents/test.txt');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete file from legacy path format', async () => {
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const result = await fileService.deleteFile('desktop://1234567890/file.png');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fallback from legacy to new path on deletion failure', async () => {
|
||||
let callCount = 0;
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First attempt (legacy file) fails
|
||||
callback(new Error('ENOENT'));
|
||||
} else {
|
||||
// All subsequent attempts succeed
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await fileService.deleteFile('desktop://1234567890/fallback.txt');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle metadata deletion failure gracefully', async () => {
|
||||
let callCount = 0;
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// File deletion succeeds
|
||||
callback(null);
|
||||
} else {
|
||||
// Metadata deletion fails (but doesn't throw)
|
||||
callback(new Error('Metadata not found'));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await fileService.deleteFile('desktop://files/test.txt');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when file deletion fails', async () => {
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callback(new Error('Permission denied'));
|
||||
});
|
||||
|
||||
await expect(fileService.deleteFile('desktop://protected/file.txt')).rejects.toThrow(
|
||||
'File deletion failed: Permission denied',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid path without desktop:// prefix', async () => {
|
||||
await expect(fileService.deleteFile('/invalid/path.txt')).rejects.toThrow(
|
||||
'Invalid desktop file path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
it('should delete multiple files successfully', async () => {
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const paths = [
|
||||
'desktop://files/file1.txt',
|
||||
'desktop://files/file2.txt',
|
||||
'desktop://files/file3.txt',
|
||||
];
|
||||
|
||||
const result = await fileService.deleteFiles(paths);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle partial failures in batch deletion', async () => {
|
||||
let callCount = 0;
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
callCount++;
|
||||
// Fail on a specific file
|
||||
if (path.includes('file2.txt') && !path.includes('.meta')) {
|
||||
callback(new Error('Permission denied'));
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
|
||||
const paths = [
|
||||
'desktop://files/file1.txt',
|
||||
'desktop://files/file2.txt',
|
||||
'desktop://files/file3.txt',
|
||||
];
|
||||
|
||||
const result = await fileService.deleteFiles(paths);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return errors array with failed file paths', async () => {
|
||||
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
||||
if (path.includes('file2') && !path.includes('.meta')) {
|
||||
callback(new Error('Access denied'));
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
|
||||
const paths = ['desktop://files/file1.txt', 'desktop://files/file2.txt'];
|
||||
|
||||
const result = await fileService.deleteFiles(paths);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors?.[0].path).toBe('desktop://files/file2.txt');
|
||||
expect(result.errors?.[0].message).toContain('Access denied');
|
||||
});
|
||||
|
||||
it('should handle empty paths array', async () => {
|
||||
const result = await fileService.deleteFiles([]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilePath', () => {
|
||||
it('should return correct path for new format', async () => {
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.getFilePath('desktop://documents/test.txt');
|
||||
|
||||
expect(result).toBe('/mock/app/storage/file-storage/documents/test.txt');
|
||||
});
|
||||
|
||||
it('should return legacy path when file exists in uploads directory', async () => {
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.getFilePath('desktop://1234567890/legacy.png');
|
||||
|
||||
expect(result).toBe('/mock/app/storage/file-storage/uploads/1234567890/legacy.png');
|
||||
});
|
||||
|
||||
it('should fallback to new path when legacy path does not exist', async () => {
|
||||
mockAccess
|
||||
.mockRejectedValueOnce(new Error('Not found')) // legacy fails
|
||||
.mockResolvedValueOnce(undefined); // fallback succeeds
|
||||
|
||||
const result = await fileService.getFilePath('desktop://1234567890/migrated.png');
|
||||
|
||||
// When legacy path doesn't exist and fallback exists, it returns the fallback path
|
||||
// But since isLegacyPath returns true for timestamps, and the fallback succeeds,
|
||||
// it should update to the fallback path
|
||||
expect(result).toContain('1234567890/migrated.png');
|
||||
});
|
||||
|
||||
it('should return legacy path when both paths do not exist', async () => {
|
||||
mockAccess
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockRejectedValueOnce(new Error('Not found'));
|
||||
|
||||
const result = await fileService.getFilePath('desktop://1234567890/missing.png');
|
||||
|
||||
expect(result).toBe('/mock/app/storage/file-storage/uploads/1234567890/missing.png');
|
||||
});
|
||||
|
||||
it('should throw error for invalid path', async () => {
|
||||
await expect(fileService.getFilePath('/invalid/path')).rejects.toThrow(
|
||||
'Invalid desktop file path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileHTTPURL', () => {
|
||||
it('should generate correct HTTP URL for new format', async () => {
|
||||
const result = await fileService.getFileHTTPURL('desktop://documents/photo.jpg');
|
||||
|
||||
expect(result).toBe('http://localhost:3000/lobe-desktop-file/documents/photo.jpg');
|
||||
});
|
||||
|
||||
it('should generate correct HTTP URL for legacy format', async () => {
|
||||
const result = await fileService.getFileHTTPURL('desktop://1234567890/image.png');
|
||||
|
||||
expect(result).toBe('http://localhost:3000/lobe-desktop-file/1234567890/image.png');
|
||||
});
|
||||
|
||||
it('should throw error for invalid path', async () => {
|
||||
await expect(fileService.getFileHTTPURL('/invalid/path')).rejects.toThrow(
|
||||
'Invalid desktop file path',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paths with special characters', async () => {
|
||||
const result = await fileService.getFileHTTPURL('desktop://user/my%20file.txt');
|
||||
|
||||
expect(result).toBe('http://localhost:3000/lobe-desktop-file/user/my%20file.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLegacyPath (via behavior testing)', () => {
|
||||
it('should treat timestamp-based paths as legacy', async () => {
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.getFilePath('desktop://1234567890/file.txt');
|
||||
|
||||
// Legacy paths go to uploads directory
|
||||
expect(result).toContain('uploads/1234567890/file.txt');
|
||||
});
|
||||
|
||||
it('should treat custom paths as new format', async () => {
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.getFilePath('desktop://custom/path/file.txt');
|
||||
|
||||
expect(result).toContain('file-storage/custom/path/file.txt');
|
||||
expect(result).not.toContain('uploads');
|
||||
});
|
||||
|
||||
it('should handle single-level paths correctly', async () => {
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
const result = await fileService.getFilePath('desktop://file.txt');
|
||||
|
||||
expect(result).toContain('file-storage/file.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPLOADS_DIR getter', () => {
|
||||
it('should return correct uploads directory path', () => {
|
||||
expect(fileService.UPLOADS_DIR).toBe('/mock/app/storage/file-storage/uploads');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileNotFoundError', () => {
|
||||
it('should create error with correct properties', () => {
|
||||
const error = new FileNotFoundError('File not found', 'desktop://missing.txt');
|
||||
|
||||
expect(error.name).toBe('FileNotFoundError');
|
||||
expect(error.message).toBe('File not found');
|
||||
expect(error.path).toBe('desktop://missing.txt');
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { setupRouteInterceptors } from './routeInterceptor';
|
||||
const setupPreload = () => {
|
||||
setupElectronApi();
|
||||
|
||||
// 设置路由拦截逻辑
|
||||
// Setup route interception logic
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
// 设置客户端路由拦截器
|
||||
// Setup client-side route interceptor
|
||||
setupRouteInterceptors();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ClientDispatchEventKey, DispatchInvoke } from '@lobechat/electron-clien
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
/**
|
||||
* client 端请求 electron main 端方法
|
||||
* Client-side method to invoke electron main process
|
||||
*/
|
||||
export const invoke: DispatchInvoke = async <T extends ClientDispatchEventKey>(
|
||||
event: T,
|
||||
|
||||
@@ -9,7 +9,7 @@ const interceptRoute = async (
|
||||
) => {
|
||||
console.log(`[preload] Intercepted ${source} and prevented default behavior:`, path);
|
||||
|
||||
// 使用electron-client-ipc的dispatch方法
|
||||
// Use electron-client-ipc's dispatch method
|
||||
try {
|
||||
await invoke('interceptRoute', { path, source, url });
|
||||
} catch (e) {
|
||||
@@ -17,15 +17,15 @@ const interceptRoute = async (
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 路由拦截器 - 负责捕获和拦截客户端路由导航
|
||||
* Route interceptor - Responsible for capturing and intercepting client-side route navigation
|
||||
*/
|
||||
export const setupRouteInterceptors = function () {
|
||||
console.log('[preload] Setting up route interceptors');
|
||||
|
||||
// 存储被阻止的路径,避免pushState重复触发
|
||||
// Store prevented paths to avoid pushState duplicate triggers
|
||||
const preventedPaths = new Set<string>();
|
||||
|
||||
// 重写 window.open 方法来拦截 JavaScript 调用
|
||||
// Override window.open method to intercept JavaScript calls
|
||||
const originalWindowOpen = window.open;
|
||||
window.open = function (url?: string | URL, target?: string, features?: string) {
|
||||
if (url) {
|
||||
@@ -33,15 +33,15 @@ export const setupRouteInterceptors = function () {
|
||||
const urlString = typeof url === 'string' ? url : url.toString();
|
||||
const urlObj = new URL(urlString, window.location.href);
|
||||
|
||||
// 检查是否为外部链接
|
||||
// Check if it's an external link
|
||||
if (urlObj.origin !== window.location.origin) {
|
||||
console.log(`[preload] Intercepted window.open for external URL:`, urlString);
|
||||
// 调用主进程处理外部链接
|
||||
// Call main process to handle external link
|
||||
invoke('openExternalLink', urlString);
|
||||
return null; // 返回 null 表示没有打开新窗口
|
||||
return null; // Return null to indicate no window was opened
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理无效 URL 或特殊协议
|
||||
// Handle invalid URL or special protocol
|
||||
console.error(`[preload] Intercepted window.open for special protocol:`, url);
|
||||
console.error(error);
|
||||
invoke('openExternalLink', typeof url === 'string' ? url : url.toString());
|
||||
@@ -49,11 +49,11 @@ export const setupRouteInterceptors = function () {
|
||||
}
|
||||
}
|
||||
|
||||
// 对于内部链接,调用原始的 window.open
|
||||
// For internal links, call original window.open
|
||||
return originalWindowOpen.call(window, url, target, features);
|
||||
};
|
||||
|
||||
// 拦截所有a标签的点击事件 - 针对Next.js的Link组件
|
||||
// Intercept all a tag click events - For Next.js Link component
|
||||
document.addEventListener(
|
||||
'click',
|
||||
async (e) => {
|
||||
@@ -62,30 +62,30 @@ export const setupRouteInterceptors = function () {
|
||||
try {
|
||||
const url = new URL(link.href);
|
||||
|
||||
// 检查是否为外部链接
|
||||
// Check if it's an external link
|
||||
if (url.origin !== window.location.origin) {
|
||||
console.log(`[preload] Intercepted external link click:`, url.href);
|
||||
// 阻止默认的链接跳转行为
|
||||
// Prevent default link navigation behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 调用主进程处理外部链接
|
||||
// Call main process to handle external link
|
||||
await invoke('openExternalLink', url.href);
|
||||
return false; // 明确阻止后续处理
|
||||
return false; // Explicitly prevent subsequent processing
|
||||
}
|
||||
|
||||
// 如果不是外部链接,则继续处理内部路由拦截逻辑
|
||||
// 使用共享配置检查是否需要拦截
|
||||
// If not external link, continue with internal route interception logic
|
||||
// Use shared config to check if interception is needed
|
||||
const matchedRoute = findMatchingRoute(url.pathname);
|
||||
|
||||
// 如果是需要拦截的路径
|
||||
// If it's a path that needs interception
|
||||
if (matchedRoute) {
|
||||
const currentPath = window.location.pathname;
|
||||
const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix);
|
||||
|
||||
// 如果已经在目标页面下,则不拦截,让默认导航继续
|
||||
// If already in target page, don't intercept, let default navigation continue
|
||||
if (isAlreadyInTargetPage) return;
|
||||
|
||||
// 立即阻止默认行为,避免Next.js接管路由
|
||||
// Immediately prevent default behavior to avoid Next.js taking over routing
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -94,15 +94,15 @@ export const setupRouteInterceptors = function () {
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
// 处理可能的 URL 解析错误或其他问题
|
||||
// 例如 mailto:, tel: 等协议会导致 new URL() 抛出错误
|
||||
// Handle possible URL parsing errors or other issues
|
||||
// For example mailto:, tel: protocols will cause new URL() to throw error
|
||||
if (err instanceof TypeError && err.message.includes('Invalid URL')) {
|
||||
console.log(
|
||||
'[preload] Non-HTTP link clicked, allowing default browser behavior:',
|
||||
link.href,
|
||||
);
|
||||
// 对于非 HTTP/HTTPS 链接,允许浏览器默认处理
|
||||
// 不需要 e.preventDefault() 或 invoke
|
||||
// For non-HTTP/HTTPS links, allow browser default handling
|
||||
// No need for e.preventDefault() or invoke
|
||||
} else {
|
||||
console.error('[preload] Link interception error:', err);
|
||||
}
|
||||
@@ -112,28 +112,28 @@ export const setupRouteInterceptors = function () {
|
||||
true,
|
||||
);
|
||||
|
||||
// 拦截 history API (用于捕获Next.js的useRouter().push/replace等)
|
||||
// Intercept history API (for capturing Next.js useRouter().push/replace etc.)
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
// 重写pushState
|
||||
// Override pushState
|
||||
history.pushState = function () {
|
||||
const url = arguments[2];
|
||||
if (typeof url === 'string') {
|
||||
try {
|
||||
// 只处理相对路径或当前域的URL
|
||||
// Only handle relative paths or current domain URLs
|
||||
const parsedUrl = new URL(url, window.location.origin);
|
||||
|
||||
// 使用共享配置检查是否需要拦截
|
||||
// Use shared config to check if interception is needed
|
||||
const matchedRoute = findMatchingRoute(parsedUrl.pathname);
|
||||
|
||||
// 检查是否需要拦截这个导航
|
||||
// Check if this navigation needs interception
|
||||
if (matchedRoute) {
|
||||
// 检查当前页面是否已经在目标路径下,如果是则不拦截
|
||||
// Check if current page is already under target path, if so don't intercept
|
||||
const currentPath = window.location.pathname;
|
||||
const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix);
|
||||
|
||||
// 如果已经在目标页面下,则不拦截,让默认导航继续
|
||||
// If already in target page, don't intercept, let default navigation continue
|
||||
if (isAlreadyInTargetPage) {
|
||||
console.log(
|
||||
`[preload] Skip pushState interception for ${parsedUrl.pathname} because already in target page ${matchedRoute.pathPrefix}`,
|
||||
@@ -141,13 +141,13 @@ export const setupRouteInterceptors = function () {
|
||||
return Reflect.apply(originalPushState, this, arguments);
|
||||
}
|
||||
|
||||
// 将此路径添加到已阻止集合中
|
||||
// Add this path to prevented set
|
||||
preventedPaths.add(parsedUrl.pathname);
|
||||
|
||||
interceptRoute(parsedUrl.pathname, 'push-state', parsedUrl.href);
|
||||
|
||||
// 不执行原始的pushState操作,阻止导航发生
|
||||
// 但返回undefined以避免错误
|
||||
// Don't execute original pushState operation, prevent navigation
|
||||
// But return undefined to avoid errors
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -157,23 +157,23 @@ export const setupRouteInterceptors = function () {
|
||||
return Reflect.apply(originalPushState, this, arguments);
|
||||
};
|
||||
|
||||
// 重写replaceState
|
||||
// Override replaceState
|
||||
history.replaceState = function () {
|
||||
const url = arguments[2];
|
||||
if (typeof url === 'string') {
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin);
|
||||
|
||||
// 使用共享配置检查是否需要拦截
|
||||
// Use shared config to check if interception is needed
|
||||
const matchedRoute = findMatchingRoute(parsedUrl.pathname);
|
||||
|
||||
// 检查是否需要拦截这个导航
|
||||
// Check if this navigation needs interception
|
||||
if (matchedRoute) {
|
||||
// 检查当前页面是否已经在目标路径下,如果是则不拦截
|
||||
// Check if current page is already under target path, if so don't intercept
|
||||
const currentPath = window.location.pathname;
|
||||
const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix);
|
||||
|
||||
// 如果已经在目标页面下,则不拦截,让默认导航继续
|
||||
// If already in target page, don't intercept, let default navigation continue
|
||||
if (isAlreadyInTargetPage) {
|
||||
console.log(
|
||||
`[preload] Skip replaceState interception for ${parsedUrl.pathname} because already in target page ${matchedRoute.pathPrefix}`,
|
||||
@@ -181,12 +181,12 @@ export const setupRouteInterceptors = function () {
|
||||
return Reflect.apply(originalReplaceState, this, arguments);
|
||||
}
|
||||
|
||||
// 添加到已阻止集合
|
||||
// Add to prevented set
|
||||
preventedPaths.add(parsedUrl.pathname);
|
||||
|
||||
interceptRoute(parsedUrl.pathname, 'replace-state', parsedUrl.href);
|
||||
|
||||
// 阻止导航
|
||||
// Prevent navigation
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -196,7 +196,7 @@ export const setupRouteInterceptors = function () {
|
||||
return Reflect.apply(originalReplaceState, this, arguments);
|
||||
};
|
||||
|
||||
// 监听并拦截路由错误 - 有时Next.js会在路由错误时尝试恢复导航
|
||||
// Listen and intercept routing errors - Sometimes Next.js tries to recover navigation on routing errors
|
||||
window.addEventListener(
|
||||
'error',
|
||||
function (e) {
|
||||
|
||||
@@ -1,4 +1,507 @@
|
||||
[
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Drop user.phoneNumber and reuse user.phone."]
|
||||
},
|
||||
"date": "2025-12-01",
|
||||
"version": "2.0.0-next.141"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Integrate better-auth admin plugin."]
|
||||
},
|
||||
"date": "2025-12-01",
|
||||
"version": "2.0.0-next.140"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-12-01",
|
||||
"version": "2.0.0-next.139"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-30",
|
||||
"version": "2.0.0-next.138"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Update apiMode handling in ChatService to prioritize user preferences."]
|
||||
},
|
||||
"date": "2025-11-30",
|
||||
"version": "2.0.0-next.137"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Refresh custom AI provider on selection."]
|
||||
},
|
||||
"date": "2025-11-30",
|
||||
"version": "2.0.0-next.136"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix BetterAuth Unable to link account - untrusted provider."]
|
||||
},
|
||||
"date": "2025-11-30",
|
||||
"version": "2.0.0-next.135"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Betterauth public url auto detect from VERCEL_URL."]
|
||||
},
|
||||
"date": "2025-11-29",
|
||||
"version": "2.0.0-next.134"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Betterauth name should mapped to fullName."]
|
||||
},
|
||||
"date": "2025-11-29",
|
||||
"version": "2.0.0-next.133"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Unable to switch to default topic."]
|
||||
},
|
||||
"date": "2025-11-29",
|
||||
"version": "2.0.0-next.132"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Implement uniform callback URL for SSO providers."]
|
||||
},
|
||||
"date": "2025-11-28",
|
||||
"version": "2.0.0-next.131"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Add handling for content_part and reasoning_part events in fetchSSE."]
|
||||
},
|
||||
"date": "2025-11-28",
|
||||
"version": "2.0.0-next.130"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Filter out file with sourceType = file."]
|
||||
},
|
||||
"date": "2025-11-28",
|
||||
"version": "2.0.0-next.129"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-28",
|
||||
"version": "2.0.0-next.128"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Better-auth fallback next-auth providers env."]
|
||||
},
|
||||
"date": "2025-11-27",
|
||||
"version": "2.0.0-next.127"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Align docker auth defaults and better-auth docs."]
|
||||
},
|
||||
"date": "2025-11-27",
|
||||
"version": "2.0.0-next.126"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support better-auth."]
|
||||
},
|
||||
"date": "2025-11-27",
|
||||
"version": "2.0.0-next.125"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": [
|
||||
"Fixed the agent settings plugins pages error problem, improve topic item interaction and editing behavior."
|
||||
]
|
||||
},
|
||||
"date": "2025-11-27",
|
||||
"version": "2.0.0-next.124"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-27",
|
||||
"version": "2.0.0-next.123"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Slove the publish to market the agent config error."]
|
||||
},
|
||||
"date": "2025-11-26",
|
||||
"version": "2.0.0-next.122"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add image aspect ratio and resolution settings for Nano Banana Pro."]
|
||||
},
|
||||
"date": "2025-11-26",
|
||||
"version": "2.0.0-next.121"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Try to fix “TypeError: Response body object should not be disturbed or locked”."]
|
||||
},
|
||||
"date": "2025-11-26",
|
||||
"version": "2.0.0-next.120"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-26",
|
||||
"version": "2.0.0-next.119"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Showing compatibility with both new and old versions of Plugins."]
|
||||
},
|
||||
"date": "2025-11-26",
|
||||
"version": "2.0.0-next.118"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Bedrock claude model thinking support."]
|
||||
},
|
||||
"date": "2025-11-25",
|
||||
"version": "2.0.0-next.117"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support nano banana pro."]
|
||||
},
|
||||
"date": "2025-11-25",
|
||||
"version": "2.0.0-next.116"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add Claude Opus 4.5 model."]
|
||||
},
|
||||
"date": "2025-11-25",
|
||||
"version": "2.0.0-next.115"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed the topic link dropdown error."]
|
||||
},
|
||||
"date": "2025-11-25",
|
||||
"version": "2.0.0-next.114"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed when desktop userId was change manytimes the aimodel not right."]
|
||||
},
|
||||
"date": "2025-11-25",
|
||||
"version": "2.0.0-next.113"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add Kimi K2 Thinking to Qwen Provider."]
|
||||
},
|
||||
"date": "2025-11-24",
|
||||
"version": "2.0.0-next.112"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": [
|
||||
"Fix db migration snapshot not align with db schema, Separate agent file injection from knowledge base RAG search."
|
||||
]
|
||||
},
|
||||
"date": "2025-11-24",
|
||||
"version": "2.0.0-next.111"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
"Add hyperlink to each topic & pinned agent, support ContextMenu on ChatItem."
|
||||
]
|
||||
},
|
||||
"date": "2025-11-24",
|
||||
"version": "2.0.0-next.110"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed the knowledge files cant open error."],
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-24",
|
||||
"version": "2.0.0-next.109"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed the pinned session not work."]
|
||||
},
|
||||
"date": "2025-11-24",
|
||||
"version": "2.0.0-next.108"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Optimize nana banana pro error message."]
|
||||
},
|
||||
"date": "2025-11-23",
|
||||
"version": "2.0.0-next.107"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add nano-banana-pro model support and optimization."]
|
||||
},
|
||||
"date": "2025-11-23",
|
||||
"version": "2.0.0-next.106"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-23",
|
||||
"version": "2.0.0-next.105"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-22",
|
||||
"version": "2.0.0-next.104"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Hide ai image config item in settings category."]
|
||||
},
|
||||
"date": "2025-11-22",
|
||||
"version": "2.0.0-next.103"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add new provider ZenMux & Gemini 3 Pro Image Preview."]
|
||||
},
|
||||
"date": "2025-11-22",
|
||||
"version": "2.0.0-next.102"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support bedrok prompt cache and usage compute."]
|
||||
},
|
||||
"date": "2025-11-22",
|
||||
"version": "2.0.0-next.101"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Gemini 3 Pro does not display thought summaries."]
|
||||
},
|
||||
"date": "2025-11-21",
|
||||
"version": "2.0.0-next.100"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Refactor to use kb search tool."]
|
||||
},
|
||||
"date": "2025-11-21",
|
||||
"version": "2.0.0-next.99"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed changelog pages and open again."],
|
||||
"improvements": ["Fix some translations."]
|
||||
},
|
||||
"date": "2025-11-21",
|
||||
"version": "2.0.0-next.98"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-21",
|
||||
"version": "2.0.0-next.97"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support Command Menu (CMD + J)."]
|
||||
},
|
||||
"date": "2025-11-20",
|
||||
"version": "2.0.0-next.96"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add Security Blacklist for agent runtime."]
|
||||
},
|
||||
"date": "2025-11-20",
|
||||
"version": "2.0.0-next.95"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Provider settings button unable to redirect."]
|
||||
},
|
||||
"date": "2025-11-20",
|
||||
"version": "2.0.0-next.94"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-20",
|
||||
"version": "2.0.0-next.93"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove debug console logs and add loading state."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.92"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed the hydrated false problem."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.91"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Extract StatusIndicator component and improve tools display."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.90"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support gemini 3.0 tools calling."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.89"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fully support Gemini 3.0 model."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.88"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor chat selectors."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.87"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support user abort in the agent runtime."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.86"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Slove discover pagination router."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.85"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add Gemini 3.0 Pro Preview to Google Provider."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.84"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["New API support switch Responses API mode."],
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-19",
|
||||
"version": "2.0.0-next.83"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix noisy error notification."]
|
||||
},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.82"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Slove when logout always show loading."]
|
||||
},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.81"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.80"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed the discover page categray sider link error."]
|
||||
},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.79"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.78"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Delete /settings/newapi pages in nextjs build."]
|
||||
},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.77"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support Interleaved thinking in MiniMax."]
|
||||
},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.76"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-18",
|
||||
"version": "2.0.0-next.75"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Edit local file render & intervention."]
|
||||
},
|
||||
"date": "2025-11-17",
|
||||
"version": "2.0.0-next.74"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support parallel topic agent runtime."]
|
||||
},
|
||||
"date": "2025-11-17",
|
||||
"version": "2.0.0-next.73"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add model information for the Qiniu provider."]
|
||||
},
|
||||
"date": "2025-11-17",
|
||||
"version": "2.0.0-next.72"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix desktop user panel."]
|
||||
},
|
||||
"date": "2025-11-17",
|
||||
"version": "2.0.0-next.71"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-17",
|
||||
"version": "2.0.0-next.70"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove language_model_settings and remove isDeprecatedEdition."]
|
||||
|
||||
@@ -32,6 +32,7 @@ coverage:
|
||||
app:
|
||||
flags:
|
||||
- app
|
||||
threshold: 0.5
|
||||
patch: off
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ services:
|
||||
extends:
|
||||
file: docker-compose/local/docker-compose.yml
|
||||
service: postgresql
|
||||
redis:
|
||||
extends:
|
||||
file: docker-compose/local/docker-compose.yml
|
||||
service: redis
|
||||
minio:
|
||||
extends:
|
||||
file: docker-compose/local/docker-compose.yml
|
||||
@@ -78,6 +82,8 @@ volumes:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
lobe-network:
|
||||
|
||||
@@ -35,6 +35,23 @@ services:
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lobe-redis
|
||||
ports:
|
||||
- '6379:6379'
|
||||
command: redis-server --save 60 1000 --appendonly yes
|
||||
volumes:
|
||||
- 'redis_data:/data'
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
container_name: lobe-minio
|
||||
@@ -107,6 +124,8 @@ services:
|
||||
condition: service_started
|
||||
casdoor:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
environment:
|
||||
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
|
||||
@@ -121,6 +140,9 @@ services:
|
||||
- 'LLM_VISION_IMAGE_USE_BASE64=1'
|
||||
- 'S3_SET_ACL=0'
|
||||
- 'SEARXNG_URL=http://searxng:8080'
|
||||
- 'REDIS_URL=redis://redis:6379'
|
||||
- 'REDIS_PREFIX=lobechat'
|
||||
- 'REDIS_TLS=0'
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
@@ -248,7 +270,8 @@ volumes:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
driver: local
|
||||
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
lobe-network:
|
||||
|
||||
@@ -32,10 +32,27 @@ services:
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lobe-redis
|
||||
ports:
|
||||
- '6379:6379'
|
||||
command: redis-server --save 60 1000 --appendonly yes
|
||||
volumes:
|
||||
- 'redis_data:/data'
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
container_name: lobe-minio
|
||||
network_mode: 'service:network-service'
|
||||
network_mode: "service:network-service"
|
||||
volumes:
|
||||
- './s3_data:/etc/minio/data'
|
||||
environment:
|
||||
@@ -46,7 +63,6 @@ services:
|
||||
command: >
|
||||
server /etc/minio/data --address ":${MINIO_PORT}" --console-address ":9001"
|
||||
|
||||
|
||||
logto:
|
||||
image: svhd/logto
|
||||
container_name: lobe-logto
|
||||
@@ -75,6 +91,8 @@ services:
|
||||
condition: service_started
|
||||
logto:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
environment:
|
||||
- 'APP_URL=http://localhost:3210'
|
||||
@@ -88,6 +106,9 @@ services:
|
||||
- 'S3_BUCKET=${MINIO_LOBE_BUCKET}'
|
||||
- 'S3_PUBLIC_DOMAIN=http://localhost:${MINIO_PORT}'
|
||||
- 'S3_ENABLE_PATH_STYLE=1'
|
||||
- 'REDIS_URL=redis://redis:6379'
|
||||
- 'REDIS_PREFIX=lobechat'
|
||||
- 'REDIS_TLS=0'
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
@@ -97,6 +118,8 @@ volumes:
|
||||
driver: local
|
||||
s3_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
lobe-network:
|
||||
|
||||
@@ -4,6 +4,7 @@ table agents {
|
||||
title varchar(255)
|
||||
description varchar(1000)
|
||||
tags jsonb [default: `[]`]
|
||||
editor_data jsonb
|
||||
avatar text
|
||||
background_color text
|
||||
market_identifier text
|
||||
@@ -136,6 +137,67 @@ table async_tasks {
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
}
|
||||
|
||||
table accounts {
|
||||
access_token text
|
||||
access_token_expires_at timestamp
|
||||
account_id text [not null]
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
id text [pk, not null]
|
||||
id_token text
|
||||
password text
|
||||
provider_id text [not null]
|
||||
refresh_token text
|
||||
refresh_token_expires_at timestamp
|
||||
scope text
|
||||
updated_at timestamp [not null]
|
||||
user_id text [not null]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'account_userId_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table auth_sessions {
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
expires_at timestamp [not null]
|
||||
id text [pk, not null]
|
||||
impersonated_by text
|
||||
ip_address text
|
||||
token text [not null, unique]
|
||||
updated_at timestamp [not null]
|
||||
user_agent text
|
||||
user_id text [not null]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'auth_session_userId_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table two_factor {
|
||||
backup_codes text [not null]
|
||||
id text [pk, not null]
|
||||
secret text [not null]
|
||||
user_id text [not null]
|
||||
|
||||
indexes {
|
||||
secret [name: 'two_factor_secret_idx']
|
||||
user_id [name: 'two_factor_user_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table verifications {
|
||||
created_at timestamp [not null, default: `now()`]
|
||||
expires_at timestamp [not null]
|
||||
id text [pk, not null]
|
||||
identifier text [not null]
|
||||
updated_at timestamp [not null, default: `now()`]
|
||||
value text [not null]
|
||||
|
||||
indexes {
|
||||
identifier [name: 'verification_identifier_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table chat_groups {
|
||||
id text [pk, not null]
|
||||
title text
|
||||
@@ -170,20 +232,8 @@ table chat_groups_agents {
|
||||
}
|
||||
}
|
||||
|
||||
table document_chunks {
|
||||
document_id varchar(30) [not null]
|
||||
chunk_id uuid [not null]
|
||||
page_index integer
|
||||
user_id text [not null]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(document_id, chunk_id) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
table documents {
|
||||
id varchar(30) [pk, not null]
|
||||
id varchar(255) [pk, not null]
|
||||
title text
|
||||
content text
|
||||
file_type varchar(255) [not null]
|
||||
@@ -195,9 +245,11 @@ table documents {
|
||||
source_type text [not null]
|
||||
source text [not null]
|
||||
file_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()`]
|
||||
@@ -206,7 +258,9 @@ table documents {
|
||||
source [name: 'documents_source_idx']
|
||||
file_type [name: 'documents_file_type_idx']
|
||||
file_id [name: 'documents_file_id_idx']
|
||||
parent_id [name: 'documents_parent_id_idx']
|
||||
(client_id, user_id) [name: 'documents_client_id_user_id_unique', unique]
|
||||
(slug, user_id) [name: 'documents_slug_user_id_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +273,7 @@ table files {
|
||||
size integer [not null]
|
||||
url text [not null]
|
||||
source text
|
||||
parent_id varchar(255)
|
||||
client_id text
|
||||
metadata jsonb
|
||||
chunk_task_id uuid
|
||||
@@ -229,6 +284,7 @@ table files {
|
||||
|
||||
indexes {
|
||||
file_hash [name: 'file_hash_idx']
|
||||
parent_id [name: 'files_parent_id_idx']
|
||||
(client_id, user_id) [name: 'files_client_id_user_id_unique', unique]
|
||||
}
|
||||
}
|
||||
@@ -416,6 +472,7 @@ table messages {
|
||||
id text [pk, not null]
|
||||
role varchar(255) [not null]
|
||||
content text
|
||||
editor_data jsonb
|
||||
reasoning jsonb
|
||||
search jsonb
|
||||
metadata jsonb
|
||||
@@ -475,7 +532,7 @@ table nextauth_accounts {
|
||||
session_state text
|
||||
token_type text
|
||||
type text [not null]
|
||||
userId text [not null]
|
||||
user_id text [not null]
|
||||
|
||||
indexes {
|
||||
(provider, providerAccountId) [pk]
|
||||
@@ -490,17 +547,17 @@ table nextauth_authenticators {
|
||||
credentialPublicKey text [not null]
|
||||
providerAccountId text [not null]
|
||||
transports text
|
||||
userId text [not null]
|
||||
user_id text [not null]
|
||||
|
||||
indexes {
|
||||
(userId, credentialID) [pk]
|
||||
(user_id, credentialID) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
table nextauth_sessions {
|
||||
expires timestamp [not null]
|
||||
sessionToken text [pk, not null]
|
||||
userId text [not null]
|
||||
user_id text [not null]
|
||||
}
|
||||
|
||||
table nextauth_verificationtokens {
|
||||
@@ -660,6 +717,18 @@ table chunks {
|
||||
}
|
||||
}
|
||||
|
||||
table document_chunks {
|
||||
document_id varchar(30) [not null]
|
||||
chunk_id uuid [not null]
|
||||
page_index integer
|
||||
user_id text [not null]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(document_id, chunk_id) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
table embeddings {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
chunk_id uuid [unique]
|
||||
@@ -882,10 +951,12 @@ table sessions {
|
||||
table threads {
|
||||
id text [pk, not null]
|
||||
title text
|
||||
content text
|
||||
editor_data jsonb
|
||||
type text [not null]
|
||||
status text [default: 'active']
|
||||
status text
|
||||
topic_id text [not null]
|
||||
source_message_id text [not null]
|
||||
source_message_id text
|
||||
parent_thread_id text
|
||||
client_id text
|
||||
user_id text [not null]
|
||||
@@ -916,6 +987,9 @@ table topics {
|
||||
title text
|
||||
favorite boolean [default: false]
|
||||
session_id text
|
||||
content text
|
||||
editor_data jsonb
|
||||
agent_id text
|
||||
group_id text
|
||||
user_id text [not null]
|
||||
client_id text
|
||||
@@ -931,6 +1005,7 @@ table topics {
|
||||
(id, user_id) [name: 'topics_id_user_id_idx']
|
||||
session_id [name: 'topics_session_id_idx']
|
||||
group_id [name: 'topics_group_id_idx']
|
||||
agent_id [name: 'topics_agent_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +1034,7 @@ table user_settings {
|
||||
language_model jsonb
|
||||
system_agent jsonb
|
||||
default_agent jsonb
|
||||
market jsonb
|
||||
tool jsonb
|
||||
image jsonb
|
||||
}
|
||||
@@ -966,16 +1042,23 @@ table user_settings {
|
||||
table users {
|
||||
id text [pk, not null]
|
||||
username text [unique]
|
||||
email text
|
||||
email text [unique]
|
||||
avatar text
|
||||
phone text
|
||||
phone text [unique]
|
||||
first_name text
|
||||
last_name text
|
||||
full_name text
|
||||
is_onboarded boolean [default: false]
|
||||
clerk_created_at "timestamp with time zone"
|
||||
email_verified boolean [not null, default: false]
|
||||
email_verified_at "timestamp with time zone"
|
||||
preference jsonb
|
||||
role text
|
||||
banned boolean [default: false]
|
||||
ban_reason text
|
||||
ban_expires "timestamp with time zone"
|
||||
two_factor_enabled boolean [default: false]
|
||||
phone_number_verified boolean
|
||||
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()`]
|
||||
@@ -1104,6 +1187,12 @@ table user_memories_preferences {
|
||||
}
|
||||
}
|
||||
|
||||
ref: accounts.user_id > users.id
|
||||
|
||||
ref: auth_sessions.user_id > users.id
|
||||
|
||||
ref: two_factor.user_id > users.id
|
||||
|
||||
ref: agents_files.file_id > files.id
|
||||
|
||||
ref: agents_files.agent_id > agents.id
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
title: LobeChat Authentication Service Configuration
|
||||
description: >-
|
||||
Learn how to configure external authentication services using Clerk or Next Auth for centralized user authorization management. Supported authentication services include Auth0, Azure ID, etc.
|
||||
Learn how to configure external authentication services using Better Auth, Clerk, or Next Auth for centralized user authorization management. Supported authentication services include Auth0, Azure ID, etc.
|
||||
|
||||
tags:
|
||||
- Authentication Service
|
||||
- Better Auth
|
||||
- Next Auth
|
||||
- SSO
|
||||
- Clerk
|
||||
@@ -12,7 +13,7 @@ tags:
|
||||
|
||||
# Authentication Service
|
||||
|
||||
LobeChat supports the configuration of external authentication services using Clerk or Next Auth for internal use within enterprises/organizations to centrally manage user authorization.
|
||||
LobeChat supports the configuration of external authentication services using Better Auth, Clerk, or Next Auth for internal use within enterprises/organizations to centrally manage user authorization.
|
||||
|
||||
## Clerk
|
||||
|
||||
@@ -22,6 +23,91 @@ LobeChat has deeply integrated with Clerk to provide users with a more secure an
|
||||
|
||||
By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` in LobeChat's environment, you can enable and use Clerk.
|
||||
|
||||
## Better Auth
|
||||
|
||||
[Better Auth](https://www.better-auth.com) is a modern, framework-agnostic authentication library designed to provide comprehensive, secure, and flexible authentication solutions. It supports various authentication methods including email/password, magic links, and multiple OAuth/SSO providers.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Email/Password Authentication**: Built-in support for traditional email and password login with secure password hashing
|
||||
- **Email Verification**: Optional email verification flow with customizable email templates
|
||||
- **Magic Link Login**: Passwordless authentication via email magic links
|
||||
- **OAuth/SSO Support**: Integration with popular identity providers including Google, GitHub, Microsoft, AWS Cognito, and more
|
||||
- **Generic OIDC/OAuth**: Support for any OpenID Connect or OAuth 2.0 compliant provider
|
||||
|
||||
### Getting Started
|
||||
|
||||
To enable Better Auth in LobeChat, set the following environment variables:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | Required | Set to `1` to enable Better Auth service |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt session tokens. Generate using: `openssl rand -base64 32` |
|
||||
| `NEXT_PUBLIC_AUTH_URL` | Required | The browser-accessible base URL for Better Auth (e.g., `http://localhost:3010`, `https://lobechat.com`). Optional for Vercel deployments (auto-detected from `VERCEL_URL`) |
|
||||
| `AUTH_SSO_PROVIDERS` | Optional | Comma-separated list of enabled SSO providers, e.g., `google,github,microsoft` |
|
||||
|
||||
<Callout type={'error'}>
|
||||
**Important**: Better Auth is currently only suitable for **fresh deployments**. If you are already using NextAuth or Clerk and have existing user data in your database, **do not switch to Better Auth yet**, otherwise existing users will not be able to log in.
|
||||
|
||||
We are developing user data migration tools from NextAuth/Clerk to Better Auth. Documentation will be updated once the migration solution is complete. For progress updates, please follow [GitHub Issue #10456](https://github.com/lobehub/lobe-chat/issues/10456).
|
||||
</Callout>
|
||||
|
||||
<Callout type={'warning'}>
|
||||
If you build/deploy with the official Docker image, the defaults keep **NextAuth enabled** and **Better
|
||||
Auth disabled** (`NEXT_PUBLIC_ENABLE_NEXT_AUTH=1`, `NEXT_PUBLIC_ENABLE_BETTER_AUTH=0`) to avoid unexpected
|
||||
login redirects. To switch to Better Auth, set both build args and runtime envs explicitly:
|
||||
`NEXT_PUBLIC_ENABLE_BETTER_AUTH=1` and `NEXT_PUBLIC_ENABLE_NEXT_AUTH=0`, then rebuild the image.
|
||||
</Callout>
|
||||
|
||||
### Supported SSO Providers
|
||||
|
||||
| Provider | Value | Environment Variables |
|
||||
| --------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Google | `google` | `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET` |
|
||||
| GitHub | `github` | `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET` |
|
||||
| Microsoft | `microsoft` | `AUTH_MICROSOFT_ID`, `AUTH_MICROSOFT_SECRET` |
|
||||
| AWS Cognito | `cognito` | `AUTH_COGNITO_ID`, `AUTH_COGNITO_SECRET`, `AUTH_COGNITO_ISSUER` |
|
||||
| Auth0 | `auth0` | `AUTH_AUTH0_ID`, `AUTH_AUTH0_SECRET`, `AUTH_AUTH0_ISSUER` |
|
||||
| Authelia | `authelia` | `AUTH_AUTHELIA_ID`, `AUTH_AUTHELIA_SECRET`, `AUTH_AUTHELIA_ISSUER` |
|
||||
| Authentik | `authentik` | `AUTH_AUTHENTIK_ID`, `AUTH_AUTHENTIK_SECRET`, `AUTH_AUTHENTIK_ISSUER` |
|
||||
| Casdoor | `casdoor` | `AUTH_CASDOOR_ID`, `AUTH_CASDOOR_SECRET`, `AUTH_CASDOOR_ISSUER` |
|
||||
| Cloudflare Zero Trust | `cloudflare-zero-trust` | `AUTH_CLOUDFLARE_ZERO_TRUST_ID`, `AUTH_CLOUDFLARE_ZERO_TRUST_SECRET`, `AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER` |
|
||||
| Keycloak | `keycloak` | `AUTH_KEYCLOAK_ID`, `AUTH_KEYCLOAK_SECRET`, `AUTH_KEYCLOAK_ISSUER` |
|
||||
| Logto | `logto` | `AUTH_LOGTO_ID`, `AUTH_LOGTO_SECRET`, `AUTH_LOGTO_ISSUER` |
|
||||
| Okta | `okta` | `AUTH_OKTA_ID`, `AUTH_OKTA_SECRET`, `AUTH_OKTA_ISSUER` |
|
||||
| ZITADEL | `zitadel` | `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET`, `AUTH_ZITADEL_ISSUER` |
|
||||
| Generic OIDC | `generic-oidc` | `AUTH_GENERIC_OIDC_ID`, `AUTH_GENERIC_OIDC_SECRET`, `AUTH_GENERIC_OIDC_ISSUER` |
|
||||
| Feishu | `feishu` | `AUTH_FEISHU_APP_ID`, `AUTH_FEISHU_APP_SECRET` |
|
||||
| WeChat | `wechat` | `AUTH_WECHAT_ID`, `AUTH_WECHAT_SECRET` |
|
||||
|
||||
### Callback URL Format
|
||||
|
||||
When configuring OAuth providers, use the following callback URL format:
|
||||
|
||||
- **Development**: `http://localhost:3210/api/auth/callback/{provider}`
|
||||
- **Production**: `https://yourdomain.com/api/auth/callback/{provider}`
|
||||
|
||||
### Email Service Configuration
|
||||
|
||||
If you want to enable email verification or password reset features, you need to configure SMTP settings:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------------------- | -------- | ----------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification before users can sign in |
|
||||
| `SMTP_HOST` | Required | SMTP server hostname (e.g., `smtp.gmail.com`) |
|
||||
| `SMTP_PORT` | Required | SMTP server port (usually `587` for TLS, `465` for SSL) |
|
||||
| `SMTP_SECURE` | Optional | Set to `true` for SSL (port 465), `false` for TLS (port 587) |
|
||||
| `SMTP_USER` | Required | SMTP authentication username |
|
||||
| `SMTP_PASS` | Required | SMTP authentication password |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
For detailed provider configuration, refer to the [Next Auth provider documentation](/docs/self-hosting/advanced/auth/next-auth) as most configurations are compatible, or visit the official [Better Auth documentation](https://www.better-auth.com/docs/introduction).
|
||||
</Callout>
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Go to [📘 Environment Variables](/docs/self-hosting/environment-variables/auth#better-auth) for detailed information on all Better Auth variables.
|
||||
</Callout>
|
||||
|
||||
## Next Auth
|
||||
|
||||
Before using NextAuth, please set the following variables in LobeChat's environment variables:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
title: LobeChat 身份验证服务配置
|
||||
description: 了解如何使用 Clerk 或 Next Auth 配置外部身份验证服务,以统一管理用户授权。支持的身份验证服务包括 Auth0、 Azure ID 等。
|
||||
description: 了解如何使用 Better Auth、Clerk 或 Next Auth 配置外部身份验证服务,以统一管理用户授权。支持的身份验证服务包括 Auth0、 Azure ID 等。
|
||||
tags:
|
||||
- 身份验证服务
|
||||
- Better Auth
|
||||
- LobeChat
|
||||
- SSO
|
||||
- Clerk
|
||||
@@ -10,7 +11,7 @@ tags:
|
||||
|
||||
# 身份验证服务
|
||||
|
||||
LobeChat 支持使用 Clerk 或者 Next Auth 配置外部身份验证服务,供企业 / 组织内部使用,统一管理用户授权。
|
||||
LobeChat 支持使用 Better Auth、Clerk 或者 Next Auth 配置外部身份验证服务,供企业 / 组织内部使用,统一管理用户授权。
|
||||
|
||||
## Clerk
|
||||
|
||||
@@ -20,6 +21,91 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
|
||||
|
||||
在 LobeChat 的环境变量中设置 `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` 和 `CLERK_SECRET_KEY`,即可开启和使用 Clerk。
|
||||
|
||||
## Better Auth
|
||||
|
||||
[Better Auth](https://www.better-auth.com) 是一个现代化、框架无关的身份验证库,旨在提供全面、安全、灵活的身份验证解决方案。它支持多种认证方式,包括邮箱 / 密码登录、魔法链接登录以及多种 OAuth/SSO 提供商。
|
||||
|
||||
### 主要特性
|
||||
|
||||
- **邮箱 / 密码认证**:内置支持传统的邮箱和密码登录,采用安全的密码哈希算法
|
||||
- **邮箱验证**:可选的邮箱验证流程,支持自定义邮件模板
|
||||
- **魔法链接登录**:通过邮件魔法链接实现无密码认证
|
||||
- **OAuth/SSO 支持**:集成 Google、GitHub、Microsoft、AWS Cognito 等主流身份提供商
|
||||
- **通用 OIDC/OAuth**:支持任何符合 OpenID Connect 或 OAuth 2.0 标准的提供商
|
||||
|
||||
### 快速开始
|
||||
|
||||
要在 LobeChat 中启用 Better Auth,请设置以下环境变量:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| -------------------------------- | -- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | 必选 | 设置为 `1` 以启用 Better Auth 服务 |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密会话令牌的密钥。使用以下命令生成:`openssl rand -base64 32` |
|
||||
| `NEXT_PUBLIC_AUTH_URL` | 必选 | 浏览器可访问的 Better Auth 基础 URL(例如 `http://localhost:3010`、`https://lobechat.com`)。Vercel 部署时可选(会自动从 `VERCEL_URL` 获取) |
|
||||
| `AUTH_SSO_PROVIDERS` | 可选 | 启用的 SSO 提供商列表,以逗号分隔,例如 `google,github,microsoft` |
|
||||
|
||||
<Callout type={'error'}>
|
||||
**重要提示**:Better Auth 目前仅适用于**全新部署**的场景。如果你已经使用 NextAuth 或 Clerk 并且数据库中存在用户数据,**请暂时不要切换到 Better Auth**,否则现有用户将无法登录。
|
||||
|
||||
我们正在开发从 NextAuth/Clerk 到 Better Auth 的用户数据迁移工具,迁移方案完成后会更新文档。相关进度请关注 [GitHub Issue #10456](https://github.com/lobehub/lobe-chat/issues/10456)。
|
||||
</Callout>
|
||||
|
||||
<Callout type={'warning'}>
|
||||
若使用官方 Docker 镜像构建 / 部署,默认是 **开启 NextAuth、关闭 Better Auth**
|
||||
(`NEXT_PUBLIC_ENABLE_NEXT_AUTH=1`、`NEXT_PUBLIC_ENABLE_BETTER_AUTH=0`),以避免意外跳转到新版登录页。
|
||||
如果要切换到 Better Auth,请同时显式设置构建参数和运行时环境变量:
|
||||
`NEXT_PUBLIC_ENABLE_BETTER_AUTH=1`、`NEXT_PUBLIC_ENABLE_NEXT_AUTH=0`,并重新构建镜像。
|
||||
</Callout>
|
||||
|
||||
### 支持的 SSO 提供商
|
||||
|
||||
| 提供商 | 值 | 环境变量 |
|
||||
| --------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Google | `google` | `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET` |
|
||||
| GitHub | `github` | `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET` |
|
||||
| Microsoft | `microsoft` | `AUTH_MICROSOFT_ID`, `AUTH_MICROSOFT_SECRET` |
|
||||
| AWS Cognito | `cognito` | `AUTH_COGNITO_ID`, `AUTH_COGNITO_SECRET`, `AUTH_COGNITO_ISSUER` |
|
||||
| Auth0 | `auth0` | `AUTH_AUTH0_ID`, `AUTH_AUTH0_SECRET`, `AUTH_AUTH0_ISSUER` |
|
||||
| Authelia | `authelia` | `AUTH_AUTHELIA_ID`, `AUTH_AUTHELIA_SECRET`, `AUTH_AUTHELIA_ISSUER` |
|
||||
| Authentik | `authentik` | `AUTH_AUTHENTIK_ID`, `AUTH_AUTHENTIK_SECRET`, `AUTH_AUTHENTIK_ISSUER` |
|
||||
| Casdoor | `casdoor` | `AUTH_CASDOOR_ID`, `AUTH_CASDOOR_SECRET`, `AUTH_CASDOOR_ISSUER` |
|
||||
| Cloudflare Zero Trust | `cloudflare-zero-trust` | `AUTH_CLOUDFLARE_ZERO_TRUST_ID`, `AUTH_CLOUDFLARE_ZERO_TRUST_SECRET`, `AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER` |
|
||||
| Keycloak | `keycloak` | `AUTH_KEYCLOAK_ID`, `AUTH_KEYCLOAK_SECRET`, `AUTH_KEYCLOAK_ISSUER` |
|
||||
| Logto | `logto` | `AUTH_LOGTO_ID`, `AUTH_LOGTO_SECRET`, `AUTH_LOGTO_ISSUER` |
|
||||
| Okta | `okta` | `AUTH_OKTA_ID`, `AUTH_OKTA_SECRET`, `AUTH_OKTA_ISSUER` |
|
||||
| ZITADEL | `zitadel` | `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET`, `AUTH_ZITADEL_ISSUER` |
|
||||
| Generic OIDC | `generic-oidc` | `AUTH_GENERIC_OIDC_ID`, `AUTH_GENERIC_OIDC_SECRET`, `AUTH_GENERIC_OIDC_ISSUER` |
|
||||
| 飞书 | `feishu` | `AUTH_FEISHU_APP_ID`, `AUTH_FEISHU_APP_SECRET` |
|
||||
| 微信 | `wechat` | `AUTH_WECHAT_ID`, `AUTH_WECHAT_SECRET` |
|
||||
|
||||
### 回调 URL 格式
|
||||
|
||||
配置 OAuth 提供商时,请使用以下回调 URL 格式:
|
||||
|
||||
- **开发环境**:`http://localhost:3210/api/auth/callback/{provider}`
|
||||
- **生产环境**:`https://yourdomain.com/api/auth/callback/{provider}`
|
||||
|
||||
### 邮件服务配置
|
||||
|
||||
如果需要启用邮箱验证或密码重置功能,需要配置 SMTP 设置:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------------------- | -- | ---------------------------------------------- |
|
||||
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱 |
|
||||
| `SMTP_HOST` | 必选 | SMTP 服务器主机名(例如 `smtp.gmail.com`) |
|
||||
| `SMTP_PORT` | 必选 | SMTP 服务器端口(TLS 通常为 `587`,SSL 为 `465`) |
|
||||
| `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465),TLS 设置为 `false`(端口 587) |
|
||||
| `SMTP_USER` | 必选 | SMTP 认证用户名 |
|
||||
| `SMTP_PASS` | 必选 | SMTP 认证密码 |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
详细的提供商配置可参考 [Next Auth 提供商文档](/zh/docs/self-hosting/advanced/auth/next-auth)(大部分配置兼容),或访问官方 [Better Auth 文档](https://www.better-auth.com/docs/introduction)。
|
||||
</Callout>
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#better-auth) 可查阅所有 Better Auth 相关变量详情。
|
||||
</Callout>
|
||||
|
||||
## Next Auth
|
||||
|
||||
在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
title: LobeChat Authentication Service Environment Variables
|
||||
description: >-
|
||||
Explore the essential environment variables for configuring authentication services in LobeChat, including OAuth SSO, NextAuth settings, and provider-specific details.
|
||||
Explore the essential environment variables for configuring authentication services in LobeChat, including Better Auth, OAuth SSO, NextAuth settings, and provider-specific details.
|
||||
|
||||
|
||||
tags:
|
||||
- Authentication Service
|
||||
- Better Auth
|
||||
- OAuth SSO
|
||||
- Clerk
|
||||
- NextAuth
|
||||
@@ -15,6 +16,191 @@ tags:
|
||||
|
||||
LobeChat provides a complete authentication service capability when deployed. The following are the relevant environment variables. You can use these environment variables to easily define the identity verification services that need to be enabled in LobeChat.
|
||||
|
||||
## Better Auth
|
||||
|
||||
### General Settings
|
||||
|
||||
#### `NEXT_PUBLIC_ENABLE_BETTER_AUTH`
|
||||
|
||||
- Type: Required
|
||||
- Description: Set to `1` to enable Better Auth service. When enabled, Better Auth will be used for authentication instead of Next Auth or Clerk.
|
||||
- Default: `-`
|
||||
- Example: `1`
|
||||
|
||||
#### `AUTH_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: Key used to encrypt session tokens. Shared between Better Auth and Next Auth. You can generate the key using the command: `openssl rand -base64 32`.
|
||||
- Default: `-`
|
||||
- Example: `Tfhi2t2pelSMEA8eaV61KaqPNEndFFdMIxDaJnS1CUI=`
|
||||
|
||||
#### `NEXT_PUBLIC_AUTH_URL`
|
||||
|
||||
- Type: Optional
|
||||
- Description: The URL accessible from the browser for Better Auth callbacks. Only set this if the default generated URL is incorrect.
|
||||
- Default: `-`
|
||||
- Example: `https://example.com`
|
||||
|
||||
#### `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Set to `1` to require email verification before users can sign in. Users must verify their email address after registration.
|
||||
- Default: `0`
|
||||
- Example: `1`
|
||||
|
||||
#### `AUTH_SSO_PROVIDERS`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Comma-separated list of enabled SSO providers. The order determines the display order of providers on the login page.
|
||||
- Default: `-`
|
||||
- Example: `google,github,microsoft,cognito`
|
||||
|
||||
### Email Service (SMTP)
|
||||
|
||||
These settings are required for email verification and password reset features.
|
||||
|
||||
#### `SMTP_HOST`
|
||||
|
||||
- Type: Required (for email features)
|
||||
- Description: SMTP server hostname.
|
||||
- Default: `-`
|
||||
- Example: `smtp.gmail.com`
|
||||
|
||||
#### `SMTP_PORT`
|
||||
|
||||
- Type: Required (for email features)
|
||||
- Description: SMTP server port. Usually `587` for TLS or `465` for SSL.
|
||||
- Default: `-`
|
||||
- Example: `587`
|
||||
|
||||
#### `SMTP_SECURE`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Use secure connection. Set to `true` for port 465 (SSL), `false` for port 587 (TLS).
|
||||
- Default: `false`
|
||||
- Example: `false`
|
||||
|
||||
#### `SMTP_USER`
|
||||
|
||||
- Type: Required (for email features)
|
||||
- Description: SMTP authentication username, usually your email address.
|
||||
- Default: `-`
|
||||
- Example: `your-email@example.com`
|
||||
|
||||
#### `SMTP_PASS`
|
||||
|
||||
- Type: Required (for email features)
|
||||
- Description: SMTP authentication password. For Gmail, use an app-specific password.
|
||||
- Default: `-`
|
||||
- Example: `your-app-specific-password`
|
||||
|
||||
### Google
|
||||
|
||||
#### `AUTH_GOOGLE_ID`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client ID of the Google OAuth application. Get it from [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
||||
- Default: `-`
|
||||
- Example: `123456789.apps.googleusercontent.com`
|
||||
|
||||
#### `AUTH_GOOGLE_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client Secret of the Google OAuth application.
|
||||
- Default: `-`
|
||||
- Example: `GOCSPX-xxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### GitHub
|
||||
|
||||
#### `AUTH_GITHUB_ID`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client ID of the GitHub OAuth application. Get it from [GitHub Developer Settings](https://github.com/settings/developers).
|
||||
- Default: `-`
|
||||
- Example: `Ov23xxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_GITHUB_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client Secret of the GitHub OAuth application.
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### Microsoft
|
||||
|
||||
#### `AUTH_MICROSOFT_ID`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client ID of the Microsoft Entra ID (Azure AD) application. Get it from [Azure Portal](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade).
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_MICROSOFT_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client Secret of the Microsoft Entra ID application.
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### AWS Cognito
|
||||
|
||||
#### `AUTH_COGNITO_ID`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client ID of the AWS Cognito User Pool App Client. Get it from [AWS Cognito Console](https://console.aws.amazon.com/cognito).
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_COGNITO_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: Client Secret of the AWS Cognito App Client.
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_COGNITO_ISSUER`
|
||||
|
||||
- Type: Required
|
||||
- Description: The Cognito User Pool issuer URL. Format: `https://cognito-idp.{region}.amazonaws.com/{userPoolId}`
|
||||
- Default: `-`
|
||||
- Example: `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxxxxx`
|
||||
|
||||
### Feishu
|
||||
|
||||
#### `AUTH_FEISHU_APP_ID`
|
||||
|
||||
- Type: Required
|
||||
- Description: App ID of the Feishu application. Get it from [Feishu Open Platform](https://open.feishu.cn/app).
|
||||
- Default: `-`
|
||||
- Example: `cli_xxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_FEISHU_APP_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: App Secret of the Feishu application.
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### WeChat
|
||||
|
||||
#### `AUTH_WECHAT_ID`
|
||||
|
||||
- Type: Required
|
||||
- Description: App ID of the WeChat Open Platform application. Get it from [WeChat Open Platform](https://open.weixin.qq.com/).
|
||||
- Default: `-`
|
||||
- Example: `wxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_WECHAT_SECRET`
|
||||
|
||||
- Type: Required
|
||||
- Description: App Secret of the WeChat application.
|
||||
- Default: `-`
|
||||
- Example: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
<Callout type={'info'}>
|
||||
For other OIDC-based providers (Auth0, Authelia, Authentik, Casdoor, Cloudflare Zero Trust, Keycloak, Logto, Okta, ZITADEL, Generic OIDC), the environment variables follow the same pattern as Next Auth. See the [Next Auth section](#next-auth) below for details.
|
||||
</Callout>
|
||||
|
||||
## Next Auth
|
||||
|
||||
### General Settings
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
title: LobeChat 身份验证服务设置
|
||||
description: 了解如何配置 LobeChat 的身份验证服务环境变量。
|
||||
description: 了解如何配置 LobeChat 的身份验证服务环境变量,包括 Better Auth、OAuth SSO、NextAuth 设置等。
|
||||
tags:
|
||||
- LobeChat
|
||||
- 身份验证服务
|
||||
- Better Auth
|
||||
- 单点登录
|
||||
- Next Auth
|
||||
- Clerk
|
||||
@@ -13,6 +14,191 @@ tags:
|
||||
|
||||
LobeChat 在部署时提供了完善的身份验证服务能力,以下是相关的环境变量,你可以使用这些环境变量轻松定义需要在 LobeChat 中开启的身份验证服务。
|
||||
|
||||
## Better Auth
|
||||
|
||||
### 通用设置
|
||||
|
||||
#### `NEXT_PUBLIC_ENABLE_BETTER_AUTH`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:设置为 `1` 以启用 Better Auth 服务。启用后,将使用 Better Auth 进行身份验证,而非 Next Auth 或 Clerk。
|
||||
- 默认值:`-`
|
||||
- 示例:`1`
|
||||
|
||||
#### `AUTH_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:用于加密会话令牌的密钥,Better Auth 和 Next Auth 共享。使用以下命令生成:`openssl rand -base64 32`
|
||||
- 默认值:`-`
|
||||
- 示例:`Tfhi2t2pelSMEA8eaV61KaqPNEndFFdMIxDaJnS1CUI=`
|
||||
|
||||
#### `NEXT_PUBLIC_AUTH_URL`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:浏览器可访问的 Better Auth 回调 URL。仅在默认生成的 URL 不正确时设置。
|
||||
- 默认值:`-`
|
||||
- 示例:`https://example.com`
|
||||
|
||||
#### `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:设置为 `1` 以要求用户在登录前验证邮箱。用户注册后必须验证邮箱地址。
|
||||
- 默认值:`0`
|
||||
- 示例:`1`
|
||||
|
||||
#### `AUTH_SSO_PROVIDERS`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:启用的 SSO 提供商列表,以逗号分隔。顺序决定了登录页面上提供商的显示顺序。
|
||||
- 默认值:`-`
|
||||
- 示例:`google,github,microsoft,cognito`
|
||||
|
||||
### 邮件服务(SMTP)
|
||||
|
||||
启用邮箱验证和密码重置功能需要配置以下设置。
|
||||
|
||||
#### `SMTP_HOST`
|
||||
|
||||
- 类型:必选(用于邮件功能)
|
||||
- 描述:SMTP 服务器主机名。
|
||||
- 默认值:`-`
|
||||
- 示例:`smtp.gmail.com`
|
||||
|
||||
#### `SMTP_PORT`
|
||||
|
||||
- 类型:必选(用于邮件功能)
|
||||
- 描述:SMTP 服务器端口。TLS 通常为 `587`,SSL 为 `465`。
|
||||
- 默认值:`-`
|
||||
- 示例:`587`
|
||||
|
||||
#### `SMTP_SECURE`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:是否使用安全连接。端口 465(SSL)设置为 `true`,端口 587(TLS)设置为 `false`。
|
||||
- 默认值:`false`
|
||||
- 示例:`false`
|
||||
|
||||
#### `SMTP_USER`
|
||||
|
||||
- 类型:必选(用于邮件功能)
|
||||
- 描述:SMTP 认证用户名,通常是您的邮箱地址。
|
||||
- 默认值:`-`
|
||||
- 示例:`your-email@example.com`
|
||||
|
||||
#### `SMTP_PASS`
|
||||
|
||||
- 类型:必选(用于邮件功能)
|
||||
- 描述:SMTP 认证密码。Gmail 需使用应用专用密码。
|
||||
- 默认值:`-`
|
||||
- 示例:`your-app-specific-password`
|
||||
|
||||
### Google
|
||||
|
||||
#### `AUTH_GOOGLE_ID`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:Google OAuth 应用的 Client ID。在 [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 获取。
|
||||
- 默认值:`-`
|
||||
- 示例:`123456789.apps.googleusercontent.com`
|
||||
|
||||
#### `AUTH_GOOGLE_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:Google OAuth 应用的 Client Secret。
|
||||
- 默认值:`-`
|
||||
- 示例:`GOCSPX-xxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### GitHub
|
||||
|
||||
#### `AUTH_GITHUB_ID`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:GitHub OAuth 应用的 Client ID。在 [GitHub Developer Settings](https://github.com/settings/developers) 获取。
|
||||
- 默认值:`-`
|
||||
- 示例:`Ov23xxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_GITHUB_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:GitHub OAuth 应用的 Client Secret。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### Microsoft
|
||||
|
||||
#### `AUTH_MICROSOFT_ID`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:Microsoft Entra ID(Azure AD)应用的 Client ID。在 [Azure 门户](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) 获取。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_MICROSOFT_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:Microsoft Entra ID 应用的 Client Secret。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### AWS Cognito
|
||||
|
||||
#### `AUTH_COGNITO_ID`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:AWS Cognito 用户池应用客户端的 Client ID。在 [AWS Cognito 控制台](https://console.aws.amazon.com/cognito) 获取。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_COGNITO_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:AWS Cognito 应用客户端的 Client Secret。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_COGNITO_ISSUER`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:Cognito 用户池的颁发者 URL。格式:`https://cognito-idp.{region}.amazonaws.com/{userPoolId}`
|
||||
- 默认值:`-`
|
||||
- 示例:`https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxxxxx`
|
||||
|
||||
### 飞书
|
||||
|
||||
#### `AUTH_FEISHU_APP_ID`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:飞书应用的 App ID。在 [飞书开放平台](https://open.feishu.cn/app) 获取。
|
||||
- 默认值:`-`
|
||||
- 示例:`cli_xxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_FEISHU_APP_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:飞书应用的 App Secret。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
### 微信
|
||||
|
||||
#### `AUTH_WECHAT_ID`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:微信开放平台应用的 App ID。在 [微信开放平台](https://open.weixin.qq.com/) 获取。
|
||||
- 默认值:`-`
|
||||
- 示例:`wxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
#### `AUTH_WECHAT_SECRET`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:微信应用的 App Secret。
|
||||
- 默认值:`-`
|
||||
- 示例:`xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
|
||||
<Callout type={'info'}>
|
||||
其他基于 OIDC 的提供商(Auth0、Authelia、Authentik、Casdoor、Cloudflare Zero Trust、Keycloak、Logto、Okta、ZITADEL、Generic OIDC)的环境变量配置与 Next Auth 相同。详情请参阅下方的 [Next Auth 章节](#next-auth)。
|
||||
</Callout>
|
||||
|
||||
## Next Auth
|
||||
|
||||
### 通用设置
|
||||
|
||||
@@ -121,6 +121,37 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
|
||||
- Default: `-`
|
||||
- Example: `-all,+gemini-1.5-flash-latest,+gemini-1.5-pro-latest`
|
||||
|
||||
## Vertex AI
|
||||
|
||||
### `VERTEXAI_CREDENTIALS`
|
||||
|
||||
- Type: Required
|
||||
- Description: A JSON string of your Google Cloud service account key, you can get the key from [here](/docs/usage/providers/vertexai).
|
||||
- Default: -
|
||||
- Example: `{"type": "service_account", "project_id": "your-gcp-project-id", ...}`
|
||||
|
||||
### `VERTEXAI_PROJECT`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Your Google Cloud project ID. If not set, it will be obtained from the `project_id` field in `VERTEXAI_CREDENTIALS`.
|
||||
- Default: -
|
||||
- Example: `your-gcp-project-id`
|
||||
|
||||
### `VERTEXAI_LOCATION`
|
||||
|
||||
- Type: Optional
|
||||
- Description: The region where your Vertex AI model is located.
|
||||
- Default: `global`
|
||||
- Example: `us-central1`
|
||||
|
||||
|
||||
### `VERTEXAI_MODEL_LIST`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Used to control the model list, use `+` to add a model, use `-` to hide a model, use `model_name=display_name` to customize the display name of a model, separated by commas. Definition syntax rules see [model-list][model-list]
|
||||
- Default: `-`
|
||||
- Example: `-all,+gemini-1.5-flash-latest,+gemini-1.5-pro-latest`
|
||||
|
||||
## Anthropic AI
|
||||
|
||||
### `ANTHROPIC_API_KEY`
|
||||
|
||||
@@ -119,6 +119,36 @@ LobeChat 在部署时提供了丰富的模型服务商相关的环境变量,
|
||||
- 默认值:`-`
|
||||
- 示例:`-all,+gemini-1.5-flash-latest,+gemini-1.5-pro-latest`
|
||||
|
||||
## Vertex AI
|
||||
|
||||
### `VERTEXAI_CREDENTIALS`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:Google Cloud 服务账号密钥的 JSON 字符串。用于认证和授权访问 Vertex AI 服务,获取方法请参考 [这里](/zh/docs/usage/providers/vertexai)
|
||||
- 默认值:-
|
||||
- 示例:`{"type": "service_account", "project_id": "your-gcp-project-id", ...}`
|
||||
|
||||
### `VERTEXAI_PROJECT`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:你的 Google Cloud 项目 ID。如果未设置,将从 `VERTEXAI_CREDENTIALS` 中的 `project_id` 字段获取。
|
||||
- 默认值:-
|
||||
- 示例:`your-gcp-project-id`
|
||||
|
||||
### `VERTEXAI_LOCATION`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:你的 Vertex AI 模型所在的区域。
|
||||
- 默认值:`global`
|
||||
- 示例:`us-central1`
|
||||
|
||||
### `VERTEXAI_MODEL_LIST`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名<扩展配置>` 来自定义模型的展示名,用英文逗号隔开。模型定义语法规则见 [模型列表][model-list]
|
||||
- 默认值:`-`
|
||||
- 示例:`-all,+gemini-1.5-flash-latest,+gemini-1.5-pro-latest`
|
||||
|
||||
## Anthropic AI
|
||||
|
||||
### `ANTHROPIC_API_KEY`
|
||||
|
||||
@@ -11,7 +11,7 @@ tags:
|
||||
|
||||
# Using ComfyUI in LobeChat
|
||||
|
||||
<Image alt={'Using ComfyUI in LobeChat'} cover src={'https://github.com/lobehub/lobe-chat/assets/17870709/c9e5eafc-ca22-496b-a88d-cc0ae53bf720'} />
|
||||
<Image alt={'Using ComfyUI in LobeChat'} cover src={'https://hub-apac-1.lobeobjects.space/docs/e9b811f248a1db2bd1be1af888cf9b9d.png'} />
|
||||
|
||||
This documentation will guide you on how to use [ComfyUI](https://github.com/comfyanonymous/ComfyUI) in LobeChat for high-quality AI image generation and editing.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ tags:
|
||||
|
||||
# 在 LobeChat 中使用 ComfyUI
|
||||
|
||||
<Image alt={'在 LobeChat 中使用 ComfyUI'} cover src={'https://github.com/lobehub/lobe-chat/assets/17870709/c9e5eafc-ca22-496b-a88d-cc0ae53bf720'} />
|
||||
<Image alt={'在 LobeChat 中使用 ComfyUI'} cover src={'https://hub-apac-1.lobeobjects.space/docs/e9b811f248a1db2bd1be1af888cf9b9d.png'} />
|
||||
|
||||
本文档将指导你如何在 LobeChat 中使用 [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 进行高质量的 AI 图像生成和编辑。
|
||||
|
||||
|
||||
+3
-3
@@ -13,11 +13,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cucumber/cucumber": "^12.2.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"playwright": "^1.56.1"
|
||||
"@playwright/test": "^1.57.0",
|
||||
"playwright": "^1.57.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
+114
-1
@@ -52,6 +52,104 @@
|
||||
"required": "لا يمكن أن يكون المحتوى فارغًا"
|
||||
}
|
||||
},
|
||||
"betterAuth": {
|
||||
"errors": {
|
||||
"emailInvalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||
"emailNotRegistered": "هذا البريد الإلكتروني غير مسجل",
|
||||
"emailNotVerified": "لم يتم التحقق من البريد الإلكتروني، يرجى التحقق أولاً",
|
||||
"emailRequired": "يرجى إدخال عنوان البريد الإلكتروني",
|
||||
"firstNameRequired": "يرجى إدخال الاسم الأول",
|
||||
"lastNameRequired": "يرجى إدخال اسم العائلة",
|
||||
"loginFailed": "فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني وكلمة المرور",
|
||||
"passwordFormat": "يجب أن تحتوي كلمة المرور على أحرف وأرقام",
|
||||
"passwordMaxLength": "يجب ألا تتجاوز كلمة المرور 64 حرفًا",
|
||||
"passwordMinLength": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل",
|
||||
"passwordRequired": "يرجى إدخال كلمة المرور",
|
||||
"usernameRequired": "يرجى إدخال اسم المستخدم"
|
||||
},
|
||||
"resetPassword": {
|
||||
"backToSignIn": "العودة إلى تسجيل الدخول",
|
||||
"confirmPasswordPlaceholder": "تأكيد كلمة المرور الجديدة",
|
||||
"confirmPasswordRequired": "يرجى تأكيد كلمة المرور الجديدة",
|
||||
"description": "يرجى إدخال كلمة المرور الجديدة",
|
||||
"error": "فشل إعادة تعيين كلمة المرور، يرجى المحاولة مرة أخرى",
|
||||
"invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية",
|
||||
"newPasswordPlaceholder": "أدخل كلمة المرور الجديدة",
|
||||
"passwordMismatch": "كلمتا المرور غير متطابقتين",
|
||||
"submit": "إعادة تعيين كلمة المرور",
|
||||
"success": "تمت إعادة تعيين كلمة المرور بنجاح، يرجى تسجيل الدخول باستخدام كلمة المرور الجديدة",
|
||||
"title": "إعادة تعيين كلمة المرور"
|
||||
},
|
||||
"signin": {
|
||||
"backToEmail": "العودة لتعديل البريد الإلكتروني",
|
||||
"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": {
|
||||
"subtitle": "يرجى إدخال بريدك الإلكتروني للمتابعة",
|
||||
"title": "تسجيل الدخول"
|
||||
},
|
||||
"error": "فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني وكلمة المرور",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"forgotPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"forgotPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
|
||||
"magicLinkButton": "إرسال رابط تسجيل الدخول",
|
||||
"magicLinkError": "فشل إرسال رابط تسجيل الدخول، يرجى المحاولة لاحقًا",
|
||||
"magicLinkSent": "تم إرسال رابط تسجيل الدخول، يرجى التحقق من بريدك الإلكتروني",
|
||||
"nextStep": "الخطوة التالية",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"orContinueWith": "أو المتابعة باستخدام",
|
||||
"passwordPlaceholder": "يرجى إدخال كلمة المرور",
|
||||
"passwordStep": {
|
||||
"subtitle": "يرجى إدخال كلمة المرور للمتابعة"
|
||||
},
|
||||
"signupLink": "سجّل الآن",
|
||||
"socialError": "فشل تسجيل الدخول عبر الشبكات الاجتماعية، يرجى المحاولة مرة أخرى",
|
||||
"socialOnlyHint": "تم تسجيل هذا البريد الإلكتروني باستخدام حساب اجتماعي، يرجى تسجيل الدخول باستخدامه",
|
||||
"submit": "تسجيل الدخول"
|
||||
},
|
||||
"signup": {
|
||||
"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 يومًا"
|
||||
@@ -86,16 +184,31 @@
|
||||
"loginOrSignup": "تسجيل الدخول / الاشتراك",
|
||||
"profile": {
|
||||
"avatar": "الصورة الشخصية",
|
||||
"cancel": "إلغاء",
|
||||
"changePassword": "إعادة تعيين كلمة المرور",
|
||||
"email": "عنوان البريد الإلكتروني",
|
||||
"fullName": "الاسم الكامل",
|
||||
"fullNameInputHint": "يرجى إدخال الاسم الكامل الجديد",
|
||||
"password": "كلمة المرور",
|
||||
"resetPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"resetPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
|
||||
"save": "حفظ",
|
||||
"sso": {
|
||||
"link": {
|
||||
"button": "ربط الحساب",
|
||||
"success": "تم ربط الحساب بنجاح"
|
||||
},
|
||||
"loading": "جارٍ تحميل الحسابات المرتبطة من طرف ثالث",
|
||||
"providers": "الحسابات المتصلة",
|
||||
"unlink": {
|
||||
"description": "بعد unlink ، لن تتمكن من تسجيل الدخول باستخدام حساب {{provider}} \"{{providerAccountId}}\". إذا كنت بحاجة إلى إعادة ربط حساب {{provider}} بالحساب الحالي، يرجى التأكد من أن عنوان البريد الإلكتروني لحساب {{provider}} هو {{email}}، وسنقوم بربطه تلقائيًا بالحساب المسجل الدخول عند تسجيل الدخول.",
|
||||
"description": "بعد إلغاء الربط، لن تتمكن من تسجيل الدخول باستخدام حساب {{provider}} \"{{providerAccountId}}\". إذا كنت ترغب في ربط حساب {{provider}} بهذا الحساب مرة أخرى، يرجى التأكد من أن عنوان البريد الإلكتروني لحساب {{provider}} هو {{email}}، وسنقوم بربطه تلقائيًا عند تسجيل الدخول.",
|
||||
"forbidden": "يجب أن تحتفظ بحساب طرف ثالث واحد على الأقل مرتبطًا.",
|
||||
"title": "هل تريد فصل حساب الطرف الثالث {{provider}}؟"
|
||||
}
|
||||
},
|
||||
"title": "تفاصيل الملف الشخصي",
|
||||
"updateAvatar": "تحديث الصورة الشخصية",
|
||||
"updateFullName": "تحديث الاسم الكامل",
|
||||
"username": "اسم المستخدم"
|
||||
},
|
||||
"signout": "تسجيل الخروج",
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
"desc": "استنادًا إلى آلية تفكير كلود (Claude Thinking) المحدودة (<1>اعرف المزيد</1>)، عند التفعيل، سيتم تعطيل حد عدد الرسائل التاريخية تلقائيًا",
|
||||
"title": "تفعيل التفكير العميق"
|
||||
},
|
||||
"imageAspectRatio": {
|
||||
"title": "نسبة العرض إلى الارتفاع للصورة"
|
||||
},
|
||||
"imageResolution": {
|
||||
"title": "دقة الصورة"
|
||||
},
|
||||
"reasoningBudgetToken": {
|
||||
"title": "استهلاك توكن التفكير"
|
||||
},
|
||||
@@ -65,6 +71,9 @@
|
||||
"thinking": {
|
||||
"title": "مفتاح التفكير العميق"
|
||||
},
|
||||
"thinkingLevel": {
|
||||
"title": "مستوى التفكير"
|
||||
},
|
||||
"title": "وظائف توسيع النموذج",
|
||||
"urlContext": {
|
||||
"desc": "عند التفعيل، سيتم تحليل روابط الويب تلقائيًا للحصول على محتوى السياق الفعلي للصفحة",
|
||||
@@ -330,6 +339,11 @@
|
||||
"screenshot": "لقطة شاشة",
|
||||
"settings": "إعدادات التصدير",
|
||||
"text": "نص",
|
||||
"widthMode": {
|
||||
"label": "وضع العرض",
|
||||
"narrow": "وضع الشاشة الضيقة",
|
||||
"wide": "وضع الشاشة الواسعة"
|
||||
},
|
||||
"withBackground": "تضمين صورة الخلفية",
|
||||
"withFooter": "تضمين تذييل",
|
||||
"withPluginInfo": "تضمين معلومات البرنامج المساعد",
|
||||
@@ -391,6 +405,7 @@
|
||||
"rejectReasonPlaceholder": "إدخال سبب الرفض سيساعد الوكيل على الفهم وتحسين الإجراءات المستقبلية",
|
||||
"rejectTitle": "رفض استدعاء الأداة هذه المرة",
|
||||
"rejectedWithReason": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي: {{reason}}",
|
||||
"toolAbort": "تم إلغاء استدعاء الأداة من قبل المستخدم",
|
||||
"toolRejected": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -135,6 +135,27 @@
|
||||
}
|
||||
},
|
||||
"close": "إغلاق",
|
||||
"cmdk": {
|
||||
"about": "حول",
|
||||
"communitySupport": "دعم المجتمع",
|
||||
"discover": "استكشاف",
|
||||
"knowledgeBase": "قاعدة المعرفة",
|
||||
"navigate": "التنقل",
|
||||
"newAgent": "إنشاء مساعد جديد",
|
||||
"noResults": "لم يتم العثور على نتائج",
|
||||
"openSettings": "فتح الإعدادات",
|
||||
"painting": "الرسم بالذكاء الاصطناعي",
|
||||
"searchPlaceholder": "أدخل أمرًا أو ابحث...",
|
||||
"settings": "الإعدادات",
|
||||
"starOnGitHub": "قيّمنا على GitHub",
|
||||
"submitIssue": "إرسال مشكلة",
|
||||
"theme": "السمة",
|
||||
"themeAuto": "اتباع النظام",
|
||||
"themeDark": "الوضع الداكن",
|
||||
"themeLight": "الوضع الفاتح",
|
||||
"toOpen": "فتح",
|
||||
"toSelect": "تحديد"
|
||||
},
|
||||
"confirm": "تأكيد",
|
||||
"contact": "اتصل بنا",
|
||||
"copy": "نسخ",
|
||||
@@ -283,6 +304,7 @@
|
||||
"business": "شراكات تجارية",
|
||||
"support": "الدعم عبر البريد الإلكتروني"
|
||||
},
|
||||
"new": "جديد",
|
||||
"oauth": "تسجيل الدخول SSO",
|
||||
"officialSite": "الموقع الرسمي",
|
||||
"ok": "موافق",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"SPII": "قد يحتوي المحتوى على معلومات شخصية حساسة. لحماية الخصوصية، يرجى إزالة المعلومات الحساسة ثم المحاولة مرة أخرى.",
|
||||
"default": "تم حظر المحتوى: {{blockReason}}. يرجى تعديل طلبك ثم المحاولة مرة أخرى."
|
||||
},
|
||||
"InsufficientQuota": "عذرًا، لقد reached الحد الأقصى للحصة (quota) لهذه المفتاح، يرجى التحقق من رصيد الحساب الخاص بك أو زيادة حصة المفتاح ثم المحاولة مرة أخرى",
|
||||
"InsufficientQuota": "عذرًا، لقد تم الوصول إلى الحد الأقصى لحصة المفتاح (quota). يرجى التحقق من رصيد الحساب أو زيادة حصة المفتاح ثم المحاولة مرة أخرى.",
|
||||
"InvalidAccessCode": "كلمة المرور غير صحيحة أو فارغة، يرجى إدخال كلمة مرور الوصول الصحيحة أو إضافة مفتاح API مخصص",
|
||||
"InvalidBedrockCredentials": "فشلت مصادقة Bedrock، يرجى التحقق من AccessKeyId/SecretAccessKey وإعادة المحاولة",
|
||||
"InvalidClerkUser": "عذرًا، لم تقم بتسجيل الدخول بعد، يرجى تسجيل الدخول أو التسجيل للمتابعة",
|
||||
@@ -131,7 +131,7 @@
|
||||
"PluginServerError": "خطأ في استجابة الخادم لطلب الإضافة، يرجى التحقق من ملف وصف الإضافة وتكوين الإضافة وتنفيذ الخادم وفقًا لمعلومات الخطأ أدناه",
|
||||
"PluginSettingsInvalid": "تحتاج هذه الإضافة إلى تكوين صحيح قبل الاستخدام، يرجى التحقق من صحة تكوينك",
|
||||
"ProviderBizError": "طلب خدمة {{provider}} خاطئ، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
|
||||
"QuotaLimitReached": "عذرًا، لقد reached الحد الأقصى من استخدام الرموز أو عدد الطلبات لهذا المفتاح. يرجى زيادة حصة هذا المفتاح أو المحاولة لاحقًا.",
|
||||
"QuotaLimitReached": "عذرًا، لقد تم الوصول إلى الحد الأقصى لاستخدام الرموز (Token) أو عدد الطلبات لهذا المفتاح. يرجى زيادة حصة المفتاح أو المحاولة لاحقًا.",
|
||||
"StreamChunkError": "خطأ في تحليل كتلة الرسالة لطلب التدفق، يرجى التحقق مما إذا كانت واجهة برمجة التطبيقات الحالية تتوافق مع المعايير، أو الاتصال بمزود واجهة برمجة التطبيقات الخاصة بك للاستفسار.",
|
||||
"SubscriptionKeyMismatch": "نعتذر، بسبب عطل عرضي في النظام، فإن استخدام الاشتراك الحالي غير فعال مؤقتًا. يرجى النقر على الزر أدناه لاستعادة الاشتراك، أو مراسلتنا عبر البريد الإلكتروني للحصول على الدعم.",
|
||||
"SubscriptionPlanLimit": "لقد استنفدت نقاط اشتراكك، ولا يمكنك استخدام هذه الميزة. يرجى الترقية إلى خطة أعلى، أو تكوين واجهة برمجة التطبيقات للنموذج المخصص للاستمرار في الاستخدام",
|
||||
|
||||
+11
-13
@@ -55,11 +55,11 @@
|
||||
},
|
||||
"documentList": {
|
||||
"copyContent": "نسخ المحتوى الكامل",
|
||||
"documentCount": "إجمالي {{count}} مستند",
|
||||
"duplicate": "إنشاء نسخة",
|
||||
"empty": "لا توجد مستندات حاليًا، انقر على الزر أعلاه لإنشاء أول مستند لك",
|
||||
"empty": "لا توجد مستندات حالياً، انقر على الزر أعلاه لإنشاء أول مستند لك",
|
||||
"noResults": "لم يتم العثور على مستندات مطابقة",
|
||||
"selectNote": "اختر مستندًا للبدء في التحرير",
|
||||
"pageCount": "إجمالي {{count}} مستند",
|
||||
"selectNote": "اختر مستندًا لبدء التحرير",
|
||||
"untitled": "بدون عنوان"
|
||||
},
|
||||
"empty": "لا توجد ملفات/مجلدات تم تحميلها بعد",
|
||||
@@ -70,7 +70,6 @@
|
||||
"uploadFile": "رفع ملف",
|
||||
"uploadFolder": "رفع مجلد"
|
||||
},
|
||||
"newDocumentButton": "مستند جديد",
|
||||
"newNoteDialog": {
|
||||
"cancel": "إلغاء",
|
||||
"editTitle": "تحرير المستند",
|
||||
@@ -83,14 +82,15 @@
|
||||
"title": "مستند جديد",
|
||||
"updateSuccess": "تم تحديث المستند بنجاح"
|
||||
},
|
||||
"newPageButton": "إنشاء مستند جديد",
|
||||
"uploadButton": "رفع"
|
||||
},
|
||||
"home": {
|
||||
"getStarted": "ابدأ الآن",
|
||||
"greeting": "ابدأ",
|
||||
"quickActions": "إجراءات سريعة",
|
||||
"recentDocuments": "المستندات الأخيرة",
|
||||
"recentFiles": "الملفات الأخيرة",
|
||||
"recentPages": "الصفحات الأخيرة",
|
||||
"subtitle": "مرحبًا بك في قاعدة المعرفة، ابدأ من هنا لإدارة مستنداتك وملاحظاتك",
|
||||
"uploadEntries": {
|
||||
"files": {
|
||||
@@ -102,8 +102,8 @@
|
||||
"knowledgeBase": {
|
||||
"title": "قاعدة معرفة جديدة"
|
||||
},
|
||||
"newDocument": {
|
||||
"title": "مستند جديد"
|
||||
"newPage": {
|
||||
"title": "إنشاء مستند جديد"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -116,8 +116,8 @@
|
||||
"title": "المكتبة المعرفية"
|
||||
},
|
||||
"menu": {
|
||||
"allDocuments": "جميع المستندات",
|
||||
"allFiles": "جميع الملفات"
|
||||
"allFiles": "جميع الملفات",
|
||||
"allPages": "جميع المستندات"
|
||||
},
|
||||
"networkError": "فشل في الحصول على قاعدة المعرفة، يرجى التحقق من اتصال الشبكة ثم إعادة المحاولة",
|
||||
"notSupportGuide": {
|
||||
@@ -142,8 +142,8 @@
|
||||
"downloadFile": "تحميل الملف",
|
||||
"unsupportedFileAndContact": "هذا التنسيق من الملفات غير مدعوم للمعاينة عبر الإنترنت، إذا كان لديك طلب للمعاينة، فلا تتردد في <1>إبلاغنا</1>"
|
||||
},
|
||||
"searchDocumentPlaceholder": "ابحث في المستندات",
|
||||
"searchFilePlaceholder": "بحث عن ملف",
|
||||
"searchPagePlaceholder": "ابحث في المستندات",
|
||||
"tab": {
|
||||
"all": "الكل",
|
||||
"audios": "الصوتيات",
|
||||
@@ -156,9 +156,7 @@
|
||||
"websites": "المواقع"
|
||||
},
|
||||
"title": "قاعدة المعرفة",
|
||||
"toggleLeftPanel": {
|
||||
"title": "عرض/إخفاء اللوحة الجانبية اليسرى"
|
||||
},
|
||||
"toggleLeftPanel": "إظهار/إخفاء اللوحة الجانبية اليسرى",
|
||||
"uploadDock": {
|
||||
"body": {
|
||||
"collapse": "طي",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"desc": "مسح الرسائل والملفات المرفوعة في المحادثة الحالية",
|
||||
"title": "مسح رسائل المحادثة"
|
||||
},
|
||||
"commandPalette": {
|
||||
"desc": "افتح لوحة الأوامر العامة للوصول السريع إلى الميزات",
|
||||
"title": "لوحة الأوامر"
|
||||
},
|
||||
"deleteAndRegenerateMessage": {
|
||||
"desc": "حذف الرسالة الأخيرة وإعادة إنشائها",
|
||||
"title": "حذف وإعادة إنشاء"
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
"standard": "عادي"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "الدقة",
|
||||
"options": {
|
||||
"1K": "1K",
|
||||
"2K": "2K",
|
||||
"4K": "4K"
|
||||
}
|
||||
},
|
||||
"seed": {
|
||||
"label": "البذرة",
|
||||
"random": "بذرة عشوائية"
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
},
|
||||
"list": {
|
||||
"title": {
|
||||
"custom": "لم يتم تفعيل مزود الخدمة المخصص",
|
||||
"disabled": "مزود الخدمة غير مفعل",
|
||||
"enabled": "مزود الخدمة مفعل"
|
||||
}
|
||||
@@ -198,6 +199,7 @@
|
||||
"addCustomProvider": "إضافة مزود خدمة مخصص",
|
||||
"all": "الكل",
|
||||
"list": {
|
||||
"custom": "المزود المخصص غير مفعل",
|
||||
"disabled": "غير مفعل",
|
||||
"disabledActions": {
|
||||
"sort": "طريقة الترتيب",
|
||||
@@ -295,7 +297,7 @@
|
||||
},
|
||||
"helpDoc": "دليل التكوين",
|
||||
"responsesApi": {
|
||||
"desc": "استخدام معيار طلبات الجيل الجديد من OpenAI، لفتح ميزات متقدمة مثل سلسلة التفكير",
|
||||
"desc": "يعتمد تنسيق طلب الجيل الجديد من OpenAI، لتمكين ميزات متقدمة مثل سلسلة التفكير (مدعومة فقط من نماذج OpenAI)",
|
||||
"title": "استخدام معيار Responses API"
|
||||
},
|
||||
"waitingForMore": "المزيد من النماذج قيد <1>التخطيط للإدماج</1>، يرجى الانتظار"
|
||||
|
||||
+214
-40
@@ -236,6 +236,9 @@
|
||||
"MiniMaxAI/MiniMax-M1-80k": {
|
||||
"description": "MiniMax-M1 هو نموذج استدلال كبير الحجم مفتوح المصدر يعتمد على الانتباه المختلط، يحتوي على 456 مليار معلمة، حيث يمكن لكل رمز تفعيل حوالي 45.9 مليار معلمة. يدعم النموذج أصلاً سياقًا فائق الطول يصل إلى مليون رمز، ومن خلال آلية الانتباه السريع، يوفر 75% من العمليات الحسابية العائمة في مهام التوليد التي تصل إلى 100 ألف رمز مقارنة بـ DeepSeek R1. بالإضافة إلى ذلك، يعتمد MiniMax-M1 على بنية MoE (الخبراء المختلطون)، ويجمع بين خوارزمية CISPO وتصميم الانتباه المختلط لتدريب تعلم معزز فعال، محققًا أداءً رائدًا في الصناعة في استدلال الإدخالات الطويلة وسيناريوهات هندسة البرمجيات الحقيقية."
|
||||
},
|
||||
"MiniMaxAI/MiniMax-M2": {
|
||||
"description": "MiniMax-M2 يعيد تعريف الكفاءة للوكيل الذكي. إنه نموذج MoE مدمج وسريع وفعّال من حيث التكلفة، يحتوي على 230 مليار معلمة إجمالية و10 مليارات معلمة نشطة، وقد صُمم لتحقيق أداء رفيع المستوى في مهام الترميز والوكالة، مع الحفاظ على ذكاء عام قوي. بفضل 10 مليارات معلمة نشطة فقط، يقدم MiniMax-M2 أداءً يُضاهي النماذج الضخمة، مما يجعله خيارًا مثاليًا للتطبيقات عالية الكفاءة."
|
||||
},
|
||||
"Moonshot-Kimi-K2-Instruct": {
|
||||
"description": "يحتوي على 1 تريليون معلمة و32 مليار معلمة مفعلة. من بين النماذج غير المعتمدة على التفكير، يحقق مستويات متقدمة في المعرفة الحديثة، الرياضيات والبرمجة، ويتفوق في مهام الوكيل العامة. تم تحسينه بعناية لمهام الوكيل، لا يجيب فقط على الأسئلة بل يتخذ إجراءات. مثالي للدردشة العفوية، التجارب العامة والوكيل، وهو نموذج سريع الاستجابة لا يتطلب تفكيرًا طويلًا."
|
||||
},
|
||||
@@ -717,25 +720,31 @@
|
||||
"description": "Claude 3 Opus هو أذكى نموذج من Anthropic، يقدم أداءً رائدًا في السوق للمهام المعقدة للغاية. يتميز بسلاسة استثنائية وفهم شبيه بالبشر للتعامل مع المطالبات المفتوحة والسيناريوهات غير المسبوقة."
|
||||
},
|
||||
"anthropic/claude-3.5-haiku": {
|
||||
"description": "Claude 3.5 Haiku هو الجيل التالي من أسرع نماذجنا. يتمتع بسرعة مماثلة لـ Claude 3 Haiku، مع تحسينات في كل مجموعة مهارات، وتفوق في العديد من اختبارات الذكاء على أكبر نموذج لدينا من الجيل السابق Claude 3 Opus."
|
||||
"description": "يتميز Claude 3.5 Haiku بقدرات محسّنة في السرعة ودقة البرمجة واستخدام الأدوات. مناسب للسيناريوهات التي تتطلب سرعة عالية وتفاعلًا فعالًا مع الأدوات."
|
||||
},
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"description": "Claude 3.5 Sonnet يحقق توازنًا مثاليًا بين الذكاء والسرعة، خاصة لأعباء العمل المؤسسية. يقدم أداءً قويًا بتكلفة أقل مقارنة بالمنافسين، ومصمم لتحمل عالي في نشرات الذكاء الاصطناعي على نطاق واسع."
|
||||
"description": "Claude 3.5 Sonnet هو نموذج سريع وفعّال من عائلة Sonnet، يوفر أداءً أفضل في البرمجة والاستدلال، وسيتم استبدال بعض نسخه تدريجيًا بـ Sonnet 3.7 وما بعده."
|
||||
},
|
||||
"anthropic/claude-3.7-sonnet": {
|
||||
"description": "Claude 3.7 Sonnet هو أول نموذج استدلال مختلط، وأذكى نموذج حتى الآن من Anthropic. يقدم أداءً متقدمًا في الترميز، وتوليد المحتوى، وتحليل البيانات، ومهام التخطيط، مبنيًا على قدرات الهندسة البرمجية واستخدام الحاسوب في سلفه Claude 3.5 Sonnet."
|
||||
"description": "Claude 3.7 Sonnet هو إصدار مطوّر من سلسلة Sonnet، يتمتع بقدرات استدلال وبرمجة أقوى، ومناسب للمهام المعقدة على مستوى المؤسسات."
|
||||
},
|
||||
"anthropic/claude-haiku-4.5": {
|
||||
"description": "Claude Haiku 4.5 هو نموذج عالي الأداء من Anthropic يتميز بزمن استجابة منخفض جدًا مع الحفاظ على دقة عالية."
|
||||
},
|
||||
"anthropic/claude-opus-4": {
|
||||
"description": "Claude Opus 4 هو أقوى نموذج حتى الآن من Anthropic، وأفضل نموذج ترميز في العالم، متصدرًا في اختبارات SWE-bench (72.5%) وTerminal-bench (43.2%). يوفر أداءً مستمرًا للمهام الطويلة التي تتطلب تركيزًا وجهدًا وآلاف الخطوات، قادرًا على العمل لساعات متواصلة، مما يوسع بشكل كبير قدرات وكلاء الذكاء الاصطناعي."
|
||||
"description": "Opus 4 هو النموذج الرائد من Anthropic، مصمم خصيصًا للمهام المعقدة والتطبيقات المؤسسية."
|
||||
},
|
||||
"anthropic/claude-opus-4.1": {
|
||||
"description": "Claude Opus 4.1 هو بديل جاهز للاستخدام لـ Opus 4، يقدم أداءً ودقة ممتازة في مهام الترميز والوكالة العملية. يرفع أداء الترميز المتقدم إلى 74.5% في SWE-bench Verified، ويتعامل مع المشكلات المعقدة متعددة الخطوات بدقة واهتمام أكبر بالتفاصيل."
|
||||
"description": "Opus 4.1 هو نموذج متقدم من Anthropic، محسن للبرمجة، الاستدلال المعقد، والمهام المستمرة."
|
||||
},
|
||||
"anthropic/claude-opus-4.5": {
|
||||
"description": "Claude Opus 4.5 هو النموذج الرائد من Anthropic، يجمع بين الذكاء الفائق والأداء القابل للتوسع، مما يجعله مناسبًا للمهام المعقدة التي تتطلب أعلى مستويات الجودة في الاستجابة والقدرة على الاستدلال."
|
||||
},
|
||||
"anthropic/claude-sonnet-4": {
|
||||
"description": "Claude Sonnet 4 يحسن بشكل كبير على قدرات Sonnet 3.7 الرائدة في الصناعة، ويظهر أداءً ممتازًا في الترميز، محققًا 72.7% في SWE-bench. يوازن النموذج بين الأداء والكفاءة، مناسب للحالات الداخلية والخارجية، ويحقق تحكمًا أكبر في التنفيذ من خلال قابلية تحكم محسنة."
|
||||
"description": "Claude Sonnet 4 هو إصدار الاستدلال الهجين من Anthropic، يجمع بين قدرات التفكير وغير التفكير."
|
||||
},
|
||||
"anthropic/claude-sonnet-4.5": {
|
||||
"description": "كلود سونيت 4.5 هو أذكى نموذج قدمته شركة أنثروبيك حتى الآن."
|
||||
"description": "Claude Sonnet 4.5 هو أحدث نموذج استدلال هجين من Anthropic، محسن للاستدلال المعقد والبرمجة."
|
||||
},
|
||||
"ascend-tribe/pangu-pro-moe": {
|
||||
"description": "Pangu-Pro-MoE 72B-A16B هو نموذج لغة ضخم نادر التنشيط يحتوي على 72 مليار معلمة و16 مليار معلمة نشطة، يعتمد على بنية الخبراء المختلطين المجمعة (MoGE). في مرحلة اختيار الخبراء، يتم تجميع الخبراء وتقيد تنشيط عدد متساوٍ من الخبراء داخل كل مجموعة لكل رمز، مما يحقق توازنًا في تحميل الخبراء ويعزز بشكل كبير كفاءة نشر النموذج على منصة Ascend."
|
||||
@@ -758,6 +767,9 @@
|
||||
"baidu/ERNIE-4.5-300B-A47B": {
|
||||
"description": "ERNIE-4.5-300B-A47B هو نموذج لغة ضخم يعتمد على بنية الخبراء المختلطين (MoE) تم تطويره بواسطة شركة بايدو. يحتوي النموذج على 300 مليار معلمة إجمالاً، لكنه ينشط فقط 47 مليار معلمة لكل رمز أثناء الاستدلال، مما يوازن بين الأداء القوي والكفاءة الحسابية. كأحد النماذج الأساسية في سلسلة ERNIE 4.5، يظهر أداءً متميزًا في مهام فهم النصوص، التوليد، الاستدلال، والبرمجة. يستخدم النموذج طريقة تدريب مسبق مبتكرة متعددة الوسائط ومتغايرة تعتمد على MoE، من خلال التدريب المشترك للنصوص والوسائط البصرية، مما يعزز قدراته الشاملة، خاصة في الالتزام بالتعليمات وتذكر المعرفة العالمية."
|
||||
},
|
||||
"baidu/ernie-5.0-thinking-preview": {
|
||||
"description": "ERNIE 5.0 Thinking Preview هو نموذج Wenxin متعدد الوسائط من الجيل الجديد من Baidu، بارع في الفهم متعدد الوسائط، اتباع التعليمات، الإبداع، الأسئلة والأجوبة الواقعية، واستخدام الأدوات."
|
||||
},
|
||||
"c4ai-aya-expanse-32b": {
|
||||
"description": "Aya Expanse هو نموذج متعدد اللغات عالي الأداء بسعة 32B، يهدف إلى تحدي أداء النماذج أحادية اللغة من خلال تحسين التعليمات، وتداول البيانات، وتدريب التفضيلات، وابتكارات دمج النماذج. يدعم 23 لغة."
|
||||
},
|
||||
@@ -818,6 +830,9 @@
|
||||
"claude-opus-4-20250514": {
|
||||
"description": "Claude Opus 4 هو أقوى نموذج من Anthropic لمعالجة المهام المعقدة للغاية. إنه يتفوق في الأداء والذكاء والسلاسة والفهم."
|
||||
},
|
||||
"claude-opus-4-5-20251101": {
|
||||
"description": "Claude Opus 4.5 هو النموذج الرائد من Anthropic، يجمع بين الذكاء الفائق والأداء القابل للتوسع، مما يجعله مناسبًا للمهام المعقدة التي تتطلب أعلى مستويات الجودة في الاستجابة والقدرة على الاستدلال."
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"description": "كلود سونيت 4 يمكنه إنتاج استجابات شبه فورية أو تفكير تدريجي مطول، حيث يمكن للمستخدم رؤية هذه العمليات بوضوح."
|
||||
},
|
||||
@@ -866,6 +881,9 @@
|
||||
"codex-mini-latest": {
|
||||
"description": "codex-mini-latest هو نسخة محسنة من o4-mini، مخصصة لـ Codex CLI. بالنسبة للاستخدام المباشر عبر API، نوصي بالبدء من gpt-4.1."
|
||||
},
|
||||
"cogito-2.1:671b": {
|
||||
"description": "Cogito v2.1 671B هو نموذج لغة مفتوح المصدر من الولايات المتحدة ومتاح للاستخدام التجاري المجاني، يتميز بأداء يقارن بأفضل النماذج، وكفاءة استدلال أعلى، وسياق طويل يصل إلى 128k، وقدرات شاملة قوية."
|
||||
},
|
||||
"cogview-4": {
|
||||
"description": "CogView-4 هو أول نموذج مفتوح المصدر من Zhipu يدعم توليد الحروف الصينية، مع تحسينات شاملة في فهم المعاني، وجودة توليد الصور، وقدرات توليد النصوص باللغتين الصينية والإنجليزية، ويدعم إدخال ثنائي اللغة بأي طول، وقادر على توليد صور بأي دقة ضمن النطاق المحدد."
|
||||
},
|
||||
@@ -1136,6 +1154,9 @@
|
||||
"deepseek-vl2-small": {
|
||||
"description": "DeepSeek VL2 Small، نسخة خفيفة متعددة الوسائط، مناسبة للبيئات ذات الموارد المحدودة وسيناريوهات الحمل العالي."
|
||||
},
|
||||
"deepseek/deepseek-chat": {
|
||||
"description": "DeepSeek-V3 هو نموذج استدلال هجين عالي الأداء من فريق DeepSeek، مناسب للمهام المعقدة وتكامل الأدوات."
|
||||
},
|
||||
"deepseek/deepseek-chat-v3-0324": {
|
||||
"description": "DeepSeek V3 هو نموذج مختلط خبير يحتوي على 685B من المعلمات، وهو أحدث إصدار من سلسلة نماذج الدردشة الرائدة لفريق DeepSeek.\n\nيستفيد من نموذج [DeepSeek V3](/deepseek/deepseek-chat-v3) ويظهر أداءً ممتازًا في مجموعة متنوعة من المهام."
|
||||
},
|
||||
@@ -1143,19 +1164,19 @@
|
||||
"description": "DeepSeek V3 هو نموذج مختلط خبير يحتوي على 685B من المعلمات، وهو أحدث إصدار من سلسلة نماذج الدردشة الرائدة لفريق DeepSeek.\n\nيستفيد من نموذج [DeepSeek V3](/deepseek/deepseek-chat-v3) ويظهر أداءً ممتازًا في مجموعة متنوعة من المهام."
|
||||
},
|
||||
"deepseek/deepseek-chat-v3.1": {
|
||||
"description": "DeepSeek-V3.1 هو نموذج استدلال هجين كبير يدعم سياق طويل يصل إلى 128K وتبديل أوضاع فعال، ويحقق أداءً وسرعة ممتازة في استدعاء الأدوات، وتوليد الأكواد، والمهام الاستدلالية المعقدة."
|
||||
"description": "DeepSeek-V3.1 هو نموذج استدلال هجين طويل السياق من DeepSeek، يدعم أوضاع التفكير وغير التفكير وتكامل الأدوات."
|
||||
},
|
||||
"deepseek/deepseek-r1": {
|
||||
"description": "تم ترقية نموذج DeepSeek R1 إلى إصدار صغير جديد، الإصدار الحالي هو DeepSeek-R1-0528. في التحديث الأخير، حسّن DeepSeek R1 عمق الاستدلال وقدرته بشكل ملحوظ من خلال استغلال موارد حسابية متزايدة وإدخال آليات تحسين خوارزمية بعد التدريب. النموذج يحقق أداءً ممتازًا في تقييمات معيارية متعددة مثل الرياضيات، والبرمجة، والمنطق العام، وأداؤه العام يقترب الآن من النماذج الرائدة مثل O3 وGemini 2.5 Pro."
|
||||
},
|
||||
"deepseek/deepseek-r1-0528": {
|
||||
"description": "DeepSeek-R1 يعزز بشكل كبير قدرة الاستدلال للنموذج حتى مع وجود بيانات تعليمية قليلة جدًا. قبل إخراج الإجابة النهائية، يقوم النموذج أولاً بإخراج سلسلة من التفكير لتحسين دقة الإجابة النهائية."
|
||||
"description": "DeepSeek R1 0528 هو إصدار محدث من DeepSeek، يركز على المصدر المفتوح وعمق الاستدلال."
|
||||
},
|
||||
"deepseek/deepseek-r1-0528:free": {
|
||||
"description": "DeepSeek-R1 يعزز بشكل كبير قدرة الاستدلال للنموذج حتى مع وجود بيانات تعليمية قليلة جدًا. قبل إخراج الإجابة النهائية، يقوم النموذج أولاً بإخراج سلسلة من التفكير لتحسين دقة الإجابة النهائية."
|
||||
},
|
||||
"deepseek/deepseek-r1-distill-llama-70b": {
|
||||
"description": "DeepSeek-R1-Distill-Llama-70B هو نسخة مكثفة وأكثر كفاءة من نموذج Llama 70B. يحافظ على أداء قوي في مهام توليد النصوص مع تقليل استهلاك الحوسبة لتسهيل النشر والبحث. يتم تشغيله بواسطة Groq باستخدام وحدة معالجة اللغة المخصصة (LPU) لتوفير استدلال سريع وفعال."
|
||||
"description": "DeepSeek R1 Distill Llama 70B هو نموذج لغوي ضخم مبني على Llama3.3 70B، وقد تم تحسينه باستخدام نتائج DeepSeek R1، ليحقق أداءً تنافسيًا يعادل النماذج الرائدة الكبيرة."
|
||||
},
|
||||
"deepseek/deepseek-r1-distill-llama-8b": {
|
||||
"description": "DeepSeek R1 Distill Llama 8B هو نموذج لغوي كبير مكرر يعتمد على Llama-3.1-8B-Instruct، تم تدريبه باستخدام مخرجات DeepSeek R1."
|
||||
@@ -1172,6 +1193,9 @@
|
||||
"deepseek/deepseek-r1:free": {
|
||||
"description": "DeepSeek-R1 يعزز بشكل كبير من قدرة النموذج على الاستدلال في ظل وجود بيانات محدودة جدًا. قبل تقديم الإجابة النهائية، يقوم النموذج أولاً بإخراج سلسلة من التفكير لتحسين دقة الإجابة النهائية."
|
||||
},
|
||||
"deepseek/deepseek-reasoner": {
|
||||
"description": "DeepSeek-V3 Thinking (reasoner) هو نموذج استدلال تجريبي من DeepSeek، مناسب للمهام الاستدلالية عالية التعقيد."
|
||||
},
|
||||
"deepseek/deepseek-v3": {
|
||||
"description": "نموذج لغة كبير عام سريع مع قدرات استدلال محسنة."
|
||||
},
|
||||
@@ -1478,9 +1502,6 @@
|
||||
"gemini-2.0-flash-lite-001": {
|
||||
"description": "نموذج جمنّي 2.0 فلاش هو نسخة معدلة، تم تحسينها لتحقيق الكفاءة من حيث التكلفة والحد من التأخير."
|
||||
},
|
||||
"gemini-2.0-flash-preview-image-generation": {
|
||||
"description": "نموذج معاينة Gemini 2.0 Flash، يدعم توليد الصور"
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"description": "Gemini 2.5 Flash هو نموذج Google الأكثر فعالية من حيث التكلفة، ويوفر وظائف شاملة."
|
||||
},
|
||||
@@ -1508,9 +1529,6 @@
|
||||
"gemini-2.5-flash-preview-04-17": {
|
||||
"description": "معاينة فلاش جمنّي 2.5 هي النموذج الأكثر كفاءة من جوجل، حيث تقدم مجموعة شاملة من الميزات."
|
||||
},
|
||||
"gemini-2.5-flash-preview-05-20": {
|
||||
"description": "Gemini 2.5 Flash Preview هو نموذج Google الأكثر فعالية من حيث التكلفة، يقدم وظائف شاملة."
|
||||
},
|
||||
"gemini-2.5-flash-preview-09-2025": {
|
||||
"description": "إصدار معاينة (25 سبتمبر 2025) من Gemini 2.5 Flash"
|
||||
},
|
||||
@@ -1526,6 +1544,15 @@
|
||||
"gemini-2.5-pro-preview-06-05": {
|
||||
"description": "جيميني 2.5 برو بريڤيو هو أحدث نموذج تفكيري من جوجل، قادر على استنتاج حلول للمشكلات المعقدة في مجالات البرمجة، الرياضيات، والعلوم والتكنولوجيا والهندسة والرياضيات (STEM)، بالإضافة إلى تحليل مجموعات بيانات كبيرة، قواعد بيانات البرمجة، والوثائق باستخدام سياق طويل."
|
||||
},
|
||||
"gemini-3-pro-image-preview": {
|
||||
"description": "Gemini 3 Pro Image (Nano Banana Pro) هو نموذج توليد صور من Google، يدعم أيضًا المحادثات متعددة الوسائط."
|
||||
},
|
||||
"gemini-3-pro-image-preview:image": {
|
||||
"description": "Gemini 3 Pro Image (Nano Banana Pro) هو نموذج توليد صور من Google، يدعم أيضًا المحادثات متعددة الوسائط."
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
"description": "Gemini 3 Pro هو أفضل نموذج لفهم الوسائط المتعددة عالميًا، وأقوى نموذج ذكي من Google حتى الآن، يوفر تأثيرات بصرية غنية وتفاعلية عميقة، وكل ذلك مبني على قدرات استدلال متقدمة."
|
||||
},
|
||||
"gemini-flash-latest": {
|
||||
"description": "أحدث إصدار من Gemini Flash"
|
||||
},
|
||||
@@ -1649,8 +1676,11 @@
|
||||
"glm-zero-preview": {
|
||||
"description": "يمتلك GLM-Zero-Preview قدرة قوية على الاستدلال المعقد، ويظهر أداءً ممتازًا في مجالات الاستدلال المنطقي، والرياضيات، والبرمجة."
|
||||
},
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
"description": "Claude Opus 4.5 هو النموذج الرائد من Anthropic، يجمع بين الذكاء الفائق والأداء القابل للتوسع، مما يجعله مناسبًا للمهام المعقدة التي تتطلب أعلى مستويات الجودة في الاستجابة والقدرة على الاستدلال."
|
||||
},
|
||||
"google/gemini-2.0-flash": {
|
||||
"description": "Gemini 2.0 Flash يقدم ميزات الجيل التالي وتحسينات تشمل سرعة فائقة، استخدام أدوات مدمجة، توليد متعدد الوسائط، ونافذة سياق تصل إلى مليون رمز."
|
||||
"description": "Gemini 2.0 Flash هو نموذج استدلال عالي الأداء من Google، مناسب للمهام متعددة الوسائط الممتدة."
|
||||
},
|
||||
"google/gemini-2.0-flash-001": {
|
||||
"description": "Gemini 2.0 Flash يقدم ميزات وتحسينات من الجيل التالي، بما في ذلك سرعة فائقة، واستخدام أدوات أصلية، وتوليد متعدد الوسائط، ونافذة سياق تصل إلى 1M توكن."
|
||||
@@ -1661,14 +1691,23 @@
|
||||
"google/gemini-2.0-flash-lite": {
|
||||
"description": "Gemini 2.0 Flash Lite يقدم ميزات الجيل التالي وتحسينات تشمل سرعة فائقة، استخدام أدوات مدمجة، توليد متعدد الوسائط، ونافذة سياق تصل إلى مليون رمز."
|
||||
},
|
||||
"google/gemini-2.0-flash-lite-001": {
|
||||
"description": "Gemini 2.0 Flash Lite هو إصدار خفيف من عائلة Gemini، لا يفعل وضع التفكير افتراضيًا لتحسين الأداء من حيث التأخير والتكلفة، ويمكن تفعيله عبر المعلمات."
|
||||
},
|
||||
"google/gemini-2.5-flash": {
|
||||
"description": "Gemini 2.5 Flash هو نموذج تفكيري يقدم قدرات شاملة ممتازة. مصمم لتحقيق توازن بين السعر والأداء، ويدعم متعدد الوسائط ونافذة سياق تصل إلى مليون رمز."
|
||||
"description": "سلسلة Gemini 2.5 Flash (Lite/Pro/Flash) هي نماذج استدلال من Google تتراوح من تأخير منخفض إلى أداء عالٍ."
|
||||
},
|
||||
"google/gemini-2.5-flash-image": {
|
||||
"description": "Gemini 2.5 Flash Image (Nano Banana) هو نموذج توليد صور من Google، يدعم أيضًا المحادثات متعددة الوسائط."
|
||||
},
|
||||
"google/gemini-2.5-flash-image-free": {
|
||||
"description": "الإصدار المجاني من Gemini 2.5 Flash Image، يدعم توليد الوسائط المتعددة بحدود استخدام معينة."
|
||||
},
|
||||
"google/gemini-2.5-flash-image-preview": {
|
||||
"description": "نموذج تجريبي Gemini 2.5 Flash، يدعم توليد الصور."
|
||||
},
|
||||
"google/gemini-2.5-flash-lite": {
|
||||
"description": "Gemini 2.5 Flash-Lite هو نموذج متوازن ومنخفض التأخير مع ميزانية تفكير قابلة للتكوين واتصال بالأدوات (مثل البحث في Google والتنفيذ البرمجي). يدعم مدخلات متعددة الوسائط ويوفر نافذة سياق تصل إلى مليون رمز."
|
||||
"description": "Gemini 2.5 Flash Lite هو إصدار خفيف من Gemini 2.5، محسن من حيث التأخير والتكلفة، مناسب للسيناريوهات ذات الإنتاجية العالية."
|
||||
},
|
||||
"google/gemini-2.5-flash-preview": {
|
||||
"description": "Gemini 2.5 Flash هو النموذج الرائد الأكثر تقدمًا من Google، مصمم للاستدلال المتقدم، الترميز، المهام الرياضية والعلمية. يحتوي على قدرة \"التفكير\" المدمجة، مما يمكّنه من تقديم استجابات بدقة أعلى ومعالجة سياقات أكثر تفصيلاً.\n\nملاحظة: يحتوي هذا النموذج على نوعين: التفكير وغير التفكير. تختلف تسعير الإخراج بشكل ملحوظ بناءً على ما إذا كانت قدرة التفكير مفعلة. إذا اخترت النوع القياسي (بدون لاحقة \" :thinking \")، سيتجنب النموذج بشكل صريح توليد رموز التفكير.\n\nلاستغلال قدرة التفكير واستقبال رموز التفكير، يجب عليك اختيار النوع \" :thinking \"، مما سيؤدي إلى تسعير إخراج تفكير أعلى.\n\nبالإضافة إلى ذلك، يمكن تكوين Gemini 2.5 Flash من خلال معلمة \"الحد الأقصى لعدد رموز الاستدلال\"، كما هو موضح في الوثائق (https://openrouter.ai/docs/use-cases/reasoning-tokens#max-tokens-for-reasoning)."
|
||||
@@ -1677,11 +1716,26 @@
|
||||
"description": "Gemini 2.5 Flash هو النموذج الرائد الأكثر تقدمًا من Google، مصمم للاستدلال المتقدم، الترميز، المهام الرياضية والعلمية. يحتوي على قدرة \"التفكير\" المدمجة، مما يمكّنه من تقديم استجابات بدقة أعلى ومعالجة سياقات أكثر تفصيلاً.\n\nملاحظة: يحتوي هذا النموذج على نوعين: التفكير وغير التفكير. تختلف تسعير الإخراج بشكل ملحوظ بناءً على ما إذا كانت قدرة التفكير مفعلة. إذا اخترت النوع القياسي (بدون لاحقة \" :thinking \")، سيتجنب النموذج بشكل صريح توليد رموز التفكير.\n\nلاستغلال قدرة التفكير واستقبال رموز التفكير، يجب عليك اختيار النوع \" :thinking \"، مما سيؤدي إلى تسعير إخراج تفكير أعلى.\n\nبالإضافة إلى ذلك، يمكن تكوين Gemini 2.5 Flash من خلال معلمة \"الحد الأقصى لعدد رموز الاستدلال\"، كما هو موضح في الوثائق (https://openrouter.ai/docs/use-cases/reasoning-tokens#max-tokens-for-reasoning)."
|
||||
},
|
||||
"google/gemini-2.5-pro": {
|
||||
"description": "Gemini 2.5 Pro هو نموذج Gemini المتقدم للاستدلال، قادر على حل المشكلات المعقدة. يحتوي على نافذة سياق تصل إلى مليوني رمز، ويدعم مدخلات متعددة الوسائط تشمل النصوص، الصور، الصوت، الفيديو، ومستندات PDF."
|
||||
"description": "Gemini 2.5 Pro هو نموذج استدلال رائد من Google، يدعم السياق الطويل والمهام المعقدة."
|
||||
},
|
||||
"google/gemini-2.5-pro-free": {
|
||||
"description": "الإصدار المجاني من Gemini 2.5 Pro، يدعم سياق طويل متعدد الوسائط بحدود استخدام معينة، مناسب للتجربة وسير العمل الخفيف."
|
||||
},
|
||||
"google/gemini-2.5-pro-preview": {
|
||||
"description": "معاينة Gemini 2.5 Pro هي أحدث نموذج تفكيري من Google، قادر على استنتاج المشكلات المعقدة في مجالات البرمجة والرياضيات والعلوم والتكنولوجيا والهندسة والرياضيات (STEM)، بالإضافة إلى استخدام سياق طويل لتحليل مجموعات البيانات الكبيرة، وقواعد الشيفرة، والوثائق."
|
||||
},
|
||||
"google/gemini-3-pro-image-preview": {
|
||||
"description": "Gemini 3 Pro Image (Nano Banana Pro) هو نموذج توليد الصور من Google، ويدعم المحادثات متعددة الوسائط."
|
||||
},
|
||||
"google/gemini-3-pro-image-preview-free": {
|
||||
"description": "الإصدار المجاني من Gemini 3 Pro Image، يدعم توليد الوسائط المتعددة بحدود استخدام معينة."
|
||||
},
|
||||
"google/gemini-3-pro-preview": {
|
||||
"description": "Gemini 3 Pro هو الجيل التالي من نماذج الاستدلال متعددة الوسائط من سلسلة Gemini، قادر على فهم النصوص، الصوت، الصور، الفيديو وغيرها، ويعالج المهام المعقدة ومستودعات الشيفرة الكبيرة."
|
||||
},
|
||||
"google/gemini-3-pro-preview-free": {
|
||||
"description": "الإصدار المجاني من Gemini 3 Pro Preview، يتمتع بنفس قدرات الفهم والاستدلال متعددة الوسائط مثل الإصدار القياسي، لكنه يخضع لقيود الاستخدام المجاني ومعدل الاستجابة، مما يجعله مناسبًا للتجربة والاستخدام منخفض التكرار."
|
||||
},
|
||||
"google/gemini-embedding-001": {
|
||||
"description": "نموذج تضمين متقدم يقدم أداءً ممتازًا في مهام اللغة الإنجليزية، متعددة اللغات، والبرمجة."
|
||||
},
|
||||
@@ -1907,6 +1961,12 @@
|
||||
"grok-4-0709": {
|
||||
"description": "Grok 4 من xAI، يتمتع بقدرات استدلال قوية."
|
||||
},
|
||||
"grok-4-1-fast-non-reasoning": {
|
||||
"description": "نموذج متعدد الوسائط متقدم، مُحسَّن خصيصًا لاستدعاء أدوات الوكلاء عالية الأداء."
|
||||
},
|
||||
"grok-4-1-fast-reasoning": {
|
||||
"description": "نموذج متعدد الوسائط متقدم، مُحسَّن خصيصًا لاستدعاء أدوات الوكلاء عالية الأداء."
|
||||
},
|
||||
"grok-4-fast-non-reasoning": {
|
||||
"description": "نحن سعداء بإصدار Grok 4 Fast، وهو أحدث تقدم لدينا في نماذج الاستدلال ذات التكلفة الفعالة."
|
||||
},
|
||||
@@ -2051,21 +2111,36 @@
|
||||
"inception/mercury-coder-small": {
|
||||
"description": "Mercury Coder Small هو الخيار المثالي لمهام توليد الكود، وتصحيح الأخطاء، وإعادة الهيكلة، مع أدنى تأخير."
|
||||
},
|
||||
"inclusionAI/Ling-1T": {
|
||||
"description": "Ling-1T هو أول نموذج رائد من سلسلة \"Ling 2.0\" غير المعتمد على التفكير، يحتوي على تريليون معلمة إجمالية و50 مليار معلمة نشطة لكل رمز. تم بناؤه على بنية Ling 2.0، ويهدف إلى تجاوز حدود الاستدلال الفعال والإدراك القابل للتوسع. تم تدريب Ling-1T-base على أكثر من 200 تريليون رمز عالي الجودة وغني بالاستدلال."
|
||||
},
|
||||
"inclusionAI/Ling-flash-2.0": {
|
||||
"description": "Ling-flash-2.0 هو النموذج الثالث في سلسلة بنية Ling 2.0 التي أصدرها فريق Bailing في مجموعة Ant. هو نموذج خبراء مختلط (MoE) بحجم إجمالي 100 مليار معلمة، لكنه ينشط فقط 6.1 مليار معلمة لكل رمز (غير متضمنة تمثيلات الكلمات 4.8 مليار). كنموذج خفيف الوزن، أظهر Ling-flash-2.0 أداءً يضاهي أو يتفوق على نماذج كثيفة بحجم 40 مليار معلمة ونماذج MoE أكبر في عدة تقييمات موثوقة. يهدف النموذج إلى استكشاف مسارات عالية الكفاءة من خلال تصميم معماري واستراتيجيات تدريب متقدمة، في ظل القناعة بأن \"النموذج الكبير يعني معلمات كثيرة\"."
|
||||
},
|
||||
"inclusionAI/Ling-mini-2.0": {
|
||||
"description": "Ling-mini-2.0 هو نموذج لغة كبير صغير الحجم وعالي الأداء مبني على بنية MoE. يحتوي على 16 مليار معلمة إجمالية، لكنه ينشط فقط 1.4 مليار معلمة لكل رمز (غير متضمنة التضمين 789 مليون)، مما يحقق سرعة توليد عالية جدًا. بفضل تصميم MoE الفعال وبيانات تدريب ضخمة وعالية الجودة، رغم تنشيط معلمات قليلة، يظهر Ling-mini-2.0 أداءً متقدمًا في المهام اللاحقة يضاهي نماذج LLM كثيفة أقل من 10 مليارات معلمة ونماذج MoE أكبر."
|
||||
},
|
||||
"inclusionAI/Ring-1T": {
|
||||
"description": "Ring-1T هو نموذج تفكير مفتوح المصدر بحجم تريليون معلمة، أطلقه فريق Bailing. يعتمد على بنية Ling 2.0 ونموذج Ling-1T-base، ويحتوي على تريليون معلمة إجمالية و50 مليار معلمة نشطة، ويدعم نافذة سياق تصل إلى 128 ألف. تم تحسينه من خلال تعلم التعزيز القابل للتحقق على نطاق واسع."
|
||||
},
|
||||
"inclusionAI/Ring-flash-2.0": {
|
||||
"description": "Ring-flash-2.0 هو نموذج تفكير عالي الأداء محسّن بعمق بناءً على Ling-flash-2.0-base. يستخدم بنية خبراء مختلط (MoE) بحجم إجمالي 100 مليار معلمة، لكنه ينشط فقط 6.1 مليار معلمة في كل استدلال. يحل النموذج من خلال خوارزمية icepop المبتكرة مشكلة عدم استقرار نماذج MoE الكبيرة في تدريب التعلم المعزز (RL)، مما يسمح بتحسين مستمر لقدرات الاستدلال المعقدة خلال التدريب طويل الأمد. حقق Ring-flash-2.0 تقدمًا ملحوظًا في مسابقات الرياضيات، توليد الشيفرة، والاستدلال المنطقي، متفوقًا على أفضل النماذج الكثيفة التي تقل عن 40 مليار معلمة، وقريبًا من نماذج MoE مفتوحة المصدر الأكبر ونماذج التفكير عالية الأداء المغلقة المصدر. رغم تركيزه على الاستدلال المعقد، يظهر أداءً ممتازًا في مهام الكتابة الإبداعية. بالإضافة إلى ذلك، وبفضل تصميمه المعماري الفعال، يوفر Ring-flash-2.0 أداءً قويًا مع استدلال عالي السرعة، مما يقلل بشكل كبير من تكلفة نشر نماذج التفكير في بيئات ذات حمل عالٍ."
|
||||
},
|
||||
"inclusionai/ling-1t": {
|
||||
"description": "Ling-1T هو نموذج MoE بسعة 1 تريليون من inclusionAI، محسن للمهام الاستدلالية المكثفة والسياقات الواسعة."
|
||||
},
|
||||
"inclusionai/ling-flash-2.0": {
|
||||
"description": "Ling-flash-2.0 هو نموذج MoE من inclusionAI، محسن من حيث الكفاءة وأداء الاستدلال، مناسب للمهام المتوسطة إلى الكبيرة."
|
||||
},
|
||||
"inclusionai/ling-mini-2.0": {
|
||||
"description": "Ling-mini-2.0 هو نموذج MoE خفيف الوزن من inclusionAI، يقلل التكاليف بشكل كبير مع الحفاظ على قدرات الاستدلال."
|
||||
},
|
||||
"inclusionai/ming-flash-omini-preview": {
|
||||
"description": "Ming-flash-omni Preview هو نموذج متعدد الوسائط من inclusionAI، يدعم إدخال الصوت، الصور والفيديو، مع تحسينات في عرض الصور والتعرف على الصوت."
|
||||
},
|
||||
"inclusionai/ring-1t": {
|
||||
"description": "Ring-1T هو نموذج MoE بسعة تريليون من inclusionAI، مصمم للمهام الاستدلالية واسعة النطاق والبحثية."
|
||||
},
|
||||
"inclusionai/ring-flash-2.0": {
|
||||
"description": "Ring-flash-2.0 هو إصدار من نموذج Ring من inclusionAI موجه لسيناريوهات الإنتاجية العالية، يركز على السرعة وكفاءة التكلفة."
|
||||
},
|
||||
"inclusionai/ring-mini-2.0": {
|
||||
"description": "Ring-mini-2.0 هو إصدار خفيف الوزن عالي الإنتاجية من نموذج MoE من inclusionAI، مخصص لسيناريوهات التوازي العالي."
|
||||
},
|
||||
"internlm/internlm2_5-7b-chat": {
|
||||
"description": "InternLM2.5 يوفر حلول حوار ذكية في عدة سيناريوهات."
|
||||
},
|
||||
@@ -2117,6 +2192,12 @@
|
||||
"kimi-k2-instruct": {
|
||||
"description": "Kimi K2 Instruct، نموذج الاستدلال الرسمي من Kimi، يدعم السياق الطويل، البرمجة، الأسئلة والأجوبة، وغيرها من السيناريوهات."
|
||||
},
|
||||
"kimi-k2-thinking": {
|
||||
"description": "نموذج kimi-k2-thinking من تطوير Moonshot AI، يتمتع بقدرات عامة في التفكير والاستدلال، ويتقن الاستدلال العميق ويمكنه استخدام الأدوات عبر خطوات متعددة لحل مختلف المشكلات المعقدة."
|
||||
},
|
||||
"kimi-k2-thinking-turbo": {
|
||||
"description": "الإصدار السريع من نموذج التفكير طويل المدى K2، يدعم سياق 256k، بارع في الاستدلال العميق، وسرعة إخراج تصل إلى 60-100 رمز في الثانية."
|
||||
},
|
||||
"kimi-k2-turbo-preview": {
|
||||
"description": "kimi-k2 هو نموذج أساسي بمعمارية MoE يتمتع بقدرات قوية للغاية في البرمجة وقدرات الوكيل (Agent)، بإجمالي معلمات يبلغ 1 تريليون والمعلمات المُفعَّلة 32 مليار. في اختبارات الأداء المعيارية للفئات الرئيسية مثل الاستدلال المعرفي العام والبرمجة والرياضيات والوكلاء (Agent)، تفوق أداء نموذج K2 على النماذج المفتوحة المصدر السائدة الأخرى."
|
||||
},
|
||||
@@ -2129,6 +2210,9 @@
|
||||
"kimi-thinking-preview": {
|
||||
"description": "نموذج kimi-thinking-preview هو نموذج تفكير متعدد الوسائط يتمتع بقدرات استدلال متعددة الوسائط وعامة، مقدم من الجانب المظلم للقمر، يتقن الاستدلال العميق ويساعد في حل المزيد من المسائل الصعبة."
|
||||
},
|
||||
"kuaishou/kat-coder-pro-v1": {
|
||||
"description": "KAT-Coder-Pro-V1 (مجاني لفترة محدودة) يركز على فهم الشيفرة والبرمجة التلقائية، مخصص لمهام البرمجة الفعالة."
|
||||
},
|
||||
"learnlm-1.5-pro-experimental": {
|
||||
"description": "LearnLM هو نموذج لغوي تجريبي محدد المهام، تم تدريبه ليتماشى مع مبادئ علوم التعلم، يمكنه اتباع التعليمات النظامية في سيناريوهات التعليم والتعلم، ويعمل كمدرب خبير."
|
||||
},
|
||||
@@ -2225,6 +2309,9 @@
|
||||
"megrez-3b-instruct": {
|
||||
"description": "Megrez 3B Instruct هو نموذج صغير الحجم وعالي الكفاءة أطلقته شركة Wuwen Xinqiong."
|
||||
},
|
||||
"meituan/longcat-flash-chat": {
|
||||
"description": "نموذج أساسي غير تأملي مفتوح المصدر من Meituan، مُحسَّن للتفاعل الحواري ومهام الوكلاء الذكيين، ويتميز في استدعاء الأدوات وسيناريوهات التفاعل المعقدة متعددة الجولات."
|
||||
},
|
||||
"meta-llama-3-70b-instruct": {
|
||||
"description": "نموذج قوي بحجم 70 مليار معلمة يتفوق في التفكير، والترميز، وتطبيقات اللغة الواسعة."
|
||||
},
|
||||
@@ -2456,6 +2543,12 @@
|
||||
"minimax-m2": {
|
||||
"description": "MiniMax M2 هو نموذج لغوي كبير وفعّال، تم تطويره خصيصًا لتلبية احتياجات الترميز وتدفقات عمل الوكلاء."
|
||||
},
|
||||
"minimax/minimax-m2": {
|
||||
"description": "MiniMax-M2 هو نموذج عالي الكفاءة في البرمجة ومهام الوكلاء، مناسب لمجموعة متنوعة من السيناريوهات الهندسية."
|
||||
},
|
||||
"minimaxai/minimax-m2": {
|
||||
"description": "MiniMax-M2 هو نموذج خبراء مختلط (MoE) مدمج وسريع وفعّال من حيث التكلفة، يحتوي على 230 مليار معلمة إجمالية و10 مليارات معلمة نشطة، صُمم لتحقيق أداء فائق في مهام الترميز والوكالة، مع الحفاظ على ذكاء عام قوي. يتميز هذا النموذج بأداء ممتاز في تحرير الملفات المتعددة، ودورة الترميز-التنفيذ-الإصلاح، والتحقق من الاختبارات والإصلاح، وسلاسل الأدوات المعقدة ذات الروابط الطويلة، مما يجعله خيارًا مثاليًا لسير عمل المطورين."
|
||||
},
|
||||
"ministral-3b-latest": {
|
||||
"description": "Ministral 3B هو نموذج حافة عالمي المستوى من Mistral."
|
||||
},
|
||||
@@ -2600,12 +2693,21 @@
|
||||
"moonshotai/kimi-k2": {
|
||||
"description": "Kimi K2 هو نموذج لغة كبير مختلط الخبراء (MoE) ضخم طورته Moonshot AI، يحتوي على تريليون معلمة إجمالية و32 مليار معلمة نشطة في كل تمرير أمامي. مُحسّن لقدرات الوكيل، بما في ذلك استخدام الأدوات المتقدمة، الاستدلال، وتركيب الكود."
|
||||
},
|
||||
"moonshotai/kimi-k2-0711": {
|
||||
"description": "Kimi K2 0711 هو إصدار Instruct من سلسلة Kimi، مناسب لسيناريوهات الشيفرة عالية الجودة واستدعاء الأدوات."
|
||||
},
|
||||
"moonshotai/kimi-k2-0905": {
|
||||
"description": "نموذج kimi-k2-0905-preview يدعم طول سياق 256k، يتمتع بقدرات ترميز وكيل أقوى، وجمالية وعملية أفضل في الشيفرة الأمامية، وفهم سياق محسن."
|
||||
"description": "Kimi K2 0905 هو تحديث لسلسلة Kimi، يعزز السياق وقدرات الاستدلال، ومحسن لسيناريوهات البرمجة."
|
||||
},
|
||||
"moonshotai/kimi-k2-instruct-0905": {
|
||||
"description": "نموذج kimi-k2-0905-preview يدعم طول سياق 256k، يتمتع بقدرات ترميز وكيل أقوى، وجمالية وعملية أفضل في الشيفرة الأمامية، وفهم سياق محسن."
|
||||
},
|
||||
"moonshotai/kimi-k2-thinking": {
|
||||
"description": "Kimi K2 Thinking هو نموذج تفكير من Moonshot محسن لمهام الاستدلال العميق، يتمتع بقدرات وكيل عامة."
|
||||
},
|
||||
"moonshotai/kimi-k2-thinking-turbo": {
|
||||
"description": "Kimi K2 Thinking Turbo هو الإصدار السريع من Kimi K2 Thinking، يقلل بشكل كبير من زمن الاستجابة مع الحفاظ على قدرات الاستدلال العميق."
|
||||
},
|
||||
"morph/morph-v3-fast": {
|
||||
"description": "Morph يقدم نموذج ذكاء اصطناعي مخصص يطبق تغييرات الكود المقترحة من نماذج متقدمة مثل Claude أو GPT-4o على ملفات الكود الحالية بسرعة فائقة - أكثر من 4500 رمز في الثانية. يعمل كخطوة نهائية في سير عمل الترميز بالذكاء الاصطناعي. يدعم 16k رمز إدخال و16k رمز إخراج."
|
||||
},
|
||||
@@ -2688,28 +2790,49 @@
|
||||
"description": "gpt-4-turbo من OpenAI يمتلك معرفة عامة واسعة وخبرة ميدانية، مما يمكنه من اتباع تعليمات اللغة الطبيعية المعقدة وحل المشكلات بدقة. تاريخ المعرفة حتى أبريل 2023، ونافذة سياق تصل إلى 128,000 رمز."
|
||||
},
|
||||
"openai/gpt-4.1": {
|
||||
"description": "GPT 4.1 هو النموذج الرائد من OpenAI، مناسب للمهام المعقدة. مثالي لحل المشكلات متعددة المجالات."
|
||||
"description": "سلسلة GPT-4.1 توفر سياقًا أوسع وقدرات أقوى في الهندسة والاستدلال."
|
||||
},
|
||||
"openai/gpt-4.1-mini": {
|
||||
"description": "GPT 4.1 mini يوازن بين الذكاء والسرعة والتكلفة، مما يجعله نموذجًا جذابًا للعديد من حالات الاستخدام."
|
||||
"description": "GPT-4.1 Mini يقدم زمن استجابة أقل وتكلفة أفضل، مناسب للمهام ذات السياق المتوسط."
|
||||
},
|
||||
"openai/gpt-4.1-nano": {
|
||||
"description": "GPT-4.1 nano هو أسرع وأكفأ نموذج GPT 4.1 من حيث التكلفة."
|
||||
"description": "GPT-4.1 Nano هو خيار منخفض التكلفة وزمن استجابة منخفض جدًا، مناسب للمحادثات القصيرة المتكررة أو سيناريوهات التصنيف."
|
||||
},
|
||||
"openai/gpt-4o": {
|
||||
"description": "GPT-4o من OpenAI يمتلك معرفة عامة واسعة وخبرة ميدانية، قادر على اتباع تعليمات اللغة الطبيعية المعقدة وحل المشكلات بدقة. يقدم أداءً مماثلًا لـ GPT-4 Turbo عبر API أسرع وأرخص."
|
||||
"description": "سلسلة GPT-4o هي نموذج Omni من OpenAI، يدعم إدخال نصوص + صور وإخراج نصي."
|
||||
},
|
||||
"openai/gpt-4o-mini": {
|
||||
"description": "GPT-4o mini من OpenAI هو أصغر نموذج متقدم وأكثر كفاءة من حيث التكلفة. متعدد الوسائط (يقبل نصوصًا أو صورًا ويخرج نصًا)، وأكثر ذكاءً من gpt-3.5-turbo، مع سرعة مماثلة."
|
||||
"description": "GPT-4o-mini هو النسخة الصغيرة والسريعة من GPT-4o، مناسب لسيناريوهات الوسائط المختلطة منخفضة التأخير."
|
||||
},
|
||||
"openai/gpt-5": {
|
||||
"description": "GPT-5 هو النموذج الرائد من OpenAI، يتفوق في الاستدلال المعقد، المعرفة الواقعية الواسعة، المهام المكثفة للكود، والوكالة متعددة الخطوات."
|
||||
"description": "GPT-5 هو نموذج عالي الأداء من OpenAI، مناسب لمجموعة واسعة من مهام الإنتاج والبحث."
|
||||
},
|
||||
"openai/gpt-5-chat": {
|
||||
"description": "GPT-5 Chat هو إصدار فرعي من GPT-5 مُحسَّن لسيناريوهات المحادثة، يقلل من التأخير لتحسين تجربة التفاعل."
|
||||
},
|
||||
"openai/gpt-5-codex": {
|
||||
"description": "GPT-5-Codex هو إصدار مُحسَّن من GPT-5 مخصص لسيناريوهات البرمجة، مناسب لسير عمل البرمجة على نطاق واسع."
|
||||
},
|
||||
"openai/gpt-5-mini": {
|
||||
"description": "GPT-5 mini هو نموذج محسّن من حيث التكلفة، يقدم أداءً ممتازًا في مهام الاستدلال والدردشة. يوفر توازنًا مثاليًا بين السرعة والتكلفة والقدرة."
|
||||
"description": "GPT-5 Mini هو النسخة المصغرة من عائلة GPT-5، مناسب للسيناريوهات منخفضة التكلفة وزمن الاستجابة."
|
||||
},
|
||||
"openai/gpt-5-nano": {
|
||||
"description": "GPT-5 nano هو نموذج عالي الإنتاجية، يتفوق في المهام البسيطة مثل التعليمات أو التصنيف."
|
||||
"description": "GPT-5 Nano هو النسخة فائقة الصغر من العائلة، مناسب للسيناريوهات التي تتطلب تكلفة وزمن استجابة منخفضين للغاية."
|
||||
},
|
||||
"openai/gpt-5-pro": {
|
||||
"description": "GPT-5 Pro هو النموذج الرائد من OpenAI، يوفر قدرات استدلال وتوليد أكواد متقدمة ووظائف على مستوى المؤسسات، ويدعم التوجيه أثناء الاختبار واستراتيجيات أمان أكثر صرامة."
|
||||
},
|
||||
"openai/gpt-5.1": {
|
||||
"description": "GPT-5.1 هو أحدث نموذج رائد في سلسلة GPT-5، يتميز بتحسينات ملحوظة في الاستدلال العام، اتباع التعليمات، وطبيعية الحوار، ومناسب لمجموعة واسعة من المهام."
|
||||
},
|
||||
"openai/gpt-5.1-chat": {
|
||||
"description": "GPT-5.1 Chat هو عضو خفيف من عائلة GPT-5.1، مُحسَّن للمحادثات منخفضة التأخير مع الحفاظ على قدرات استدلال وتنفيذ تعليمات قوية."
|
||||
},
|
||||
"openai/gpt-5.1-codex": {
|
||||
"description": "GPT-5.1-Codex هو إصدار مُحسَّن من GPT-5.1 مخصص لهندسة البرمجيات وسير عمل البرمجة، مناسب لإعادة هيكلة واسعة النطاق، تصحيح الأخطاء المعقدة، ومهام البرمجة الذاتية طويلة الأمد."
|
||||
},
|
||||
"openai/gpt-5.1-codex-mini": {
|
||||
"description": "GPT-5.1-Codex-Mini هو النسخة الصغيرة والمعجلة من GPT-5.1-Codex، أكثر ملاءمة لسيناريوهات البرمجة الحساسة للتكلفة والتأخير."
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
"description": "نموذج لغة كبير عام عالي الكفاءة، يتمتع بقدرات استدلال قوية وقابلة للتحكم."
|
||||
@@ -2736,7 +2859,7 @@
|
||||
"description": "o3-mini عالي المستوى من حيث الاستدلال، يقدم ذكاءً عاليًا بنفس تكلفة وأهداف التأخير مثل o1-mini."
|
||||
},
|
||||
"openai/o4-mini": {
|
||||
"description": "o4-mini من OpenAI يقدم استدلالًا سريعًا وفعالًا من حيث التكلفة، مع أداء ممتاز بالنسبة لحجمه، خاصة في الرياضيات (الأفضل في اختبار AIME)، الترميز، والمهام البصرية."
|
||||
"description": "OpenAI o4-mini هو نموذج استدلال صغير وفعال من OpenAI، مناسب لسيناريوهات منخفضة التأخير."
|
||||
},
|
||||
"openai/o4-mini-high": {
|
||||
"description": "o4-mini إصدار عالي من حيث مستوى الاستدلال، تم تحسينه للاستدلال السريع والفعال، ويظهر كفاءة وأداء عاليين في المهام البرمجية والرؤية."
|
||||
@@ -2940,7 +3063,7 @@
|
||||
"description": "نموذج قوي للبرمجة متوسطة الحجم، يدعم طول سياق يصل إلى 32K، بارع في البرمجة متعددة اللغات."
|
||||
},
|
||||
"qwen/qwen3-14b": {
|
||||
"description": "Qwen3-14B هو نموذج لغوي سببي مكثف يحتوي على 14.8 مليار معلمة، مصمم للاستدلال المعقد والحوار الفعال. يدعم التبديل بسلاسة بين نمط \"التفكير\" المستخدم في الرياضيات، والبرمجة، والاستدلال المنطقي، ونمط \"غير التفكير\" المستخدم في الحوار العام. تم ضبط هذا النموذج ليكون مناسبًا للامتثال للتعليمات، واستخدام أدوات الوكلاء، والكتابة الإبداعية، واستخدامه عبر أكثر من 100 لغة ولهجة. يدعم بشكل أصلي معالجة 32K رمز، ويمكن توسيعها باستخدام التمديد القائم على YaRN إلى 131K رمز."
|
||||
"description": "Qwen3-14B هو إصدار 14B من سلسلة Qwen، مناسب للاستدلال العام وسيناريوهات المحادثة."
|
||||
},
|
||||
"qwen/qwen3-14b:free": {
|
||||
"description": "Qwen3-14B هو نموذج لغوي سببي مكثف يحتوي على 14.8 مليار معلمة، مصمم للاستدلال المعقد والحوار الفعال. يدعم التبديل بسلاسة بين نمط \"التفكير\" المستخدم في الرياضيات، والبرمجة، والاستدلال المنطقي، ونمط \"غير التفكير\" المستخدم في الحوار العام. تم ضبط هذا النموذج ليكون مناسبًا للامتثال للتعليمات، واستخدام أدوات الوكلاء، والكتابة الإبداعية، واستخدامه عبر أكثر من 100 لغة ولهجة. يدعم بشكل أصلي معالجة 32K رمز، ويمكن توسيعها باستخدام التمديد القائم على YaRN إلى 131K رمز."
|
||||
@@ -2948,6 +3071,12 @@
|
||||
"qwen/qwen3-235b-a22b": {
|
||||
"description": "Qwen3-235B-A22B هو نموذج مختلط خبير (MoE) يحتوي على 235 مليار معلمة تم تطويره بواسطة Qwen، حيث يتم تنشيط 22 مليار معلمة في كل تمرير للأمام. يدعم التبديل بسلاسة بين نمط \"التفكير\" المستخدم في الاستدلال المعقد، والرياضيات، ومهام البرمجة، ونمط \"غير التفكير\" المستخدم في الحوار العام. يظهر هذا النموذج قدرات استدلال قوية، ودعمًا للغات المتعددة (أكثر من 100 لغة ولهجة)، وقدرات متقدمة في الامتثال للتعليمات واستدعاء أدوات الوكلاء. يدعم بشكل أصلي معالجة نافذة سياق من 32K رمز، ويمكن توسيعها باستخدام التمديد القائم على YaRN إلى 131K رمز."
|
||||
},
|
||||
"qwen/qwen3-235b-a22b-2507": {
|
||||
"description": "Qwen3-235B-A22B-Instruct-2507 هو إصدار Instruct من سلسلة Qwen3، يجمع بين دعم التعليمات متعددة اللغات وسيناريوهات السياق الطويل."
|
||||
},
|
||||
"qwen/qwen3-235b-a22b-thinking-2507": {
|
||||
"description": "Qwen3-235B-A22B-Thinking-2507 هو إصدار Thinking من Qwen3، مُعزز لمهام الرياضيات المعقدة والاستدلال."
|
||||
},
|
||||
"qwen/qwen3-235b-a22b:free": {
|
||||
"description": "Qwen3-235B-A22B هو نموذج مختلط خبير (MoE) يحتوي على 235 مليار معلمة تم تطويره بواسطة Qwen، حيث يتم تنشيط 22 مليار معلمة في كل تمرير للأمام. يدعم التبديل بسلاسة بين نمط \"التفكير\" المستخدم في الاستدلال المعقد، والرياضيات، ومهام البرمجة، ونمط \"غير التفكير\" المستخدم في الحوار العام. يظهر هذا النموذج قدرات استدلال قوية، ودعمًا للغات المتعددة (أكثر من 100 لغة ولهجة)، وقدرات متقدمة في الامتثال للتعليمات واستدعاء أدوات الوكلاء. يدعم بشكل أصلي معالجة نافذة سياق من 32K رمز، ويمكن توسيعها باستخدام التمديد القائم على YaRN إلى 131K رمز."
|
||||
},
|
||||
@@ -2966,6 +3095,21 @@
|
||||
"qwen/qwen3-8b:free": {
|
||||
"description": "Qwen3-8B هو نموذج لغوي سببي مكثف يحتوي على 8.2 مليار معلمة، مصمم للمهام التي تتطلب استدلالًا مكثفًا والحوار الفعال. يدعم التبديل بسلاسة بين نمط \"التفكير\" المستخدم في الرياضيات والترميز والاستدلال المنطقي، ونمط \"غير التفكير\" المستخدم في الحوار العام. تم ضبط هذا النموذج ليكون مناسبًا للامتثال للتعليمات، ودمج الوكلاء، والكتابة الإبداعية، واستخدامه عبر أكثر من 100 لغة ولهجة. يدعم بشكل أصلي نافذة سياق من 32K رمز، ويمكن توسيعها إلى 131K رمز عبر YaRN."
|
||||
},
|
||||
"qwen/qwen3-coder": {
|
||||
"description": "Qwen3-Coder هو جزء من عائلة مولدات الأكواد في Qwen3، بارع في فهم وتوليد الأكواد داخل المستندات الطويلة."
|
||||
},
|
||||
"qwen/qwen3-coder-plus": {
|
||||
"description": "Qwen3-Coder-Plus هو نموذج وكيل برمجي مُحسَّن خصيصًا من سلسلة Qwen، يدعم استدعاء أدوات أكثر تعقيدًا وجلسات طويلة الأمد."
|
||||
},
|
||||
"qwen/qwen3-max": {
|
||||
"description": "Qwen3 Max هو النموذج المتقدم من سلسلة Qwen3، مناسب للاستدلال متعدد اللغات وتكامل الأدوات."
|
||||
},
|
||||
"qwen/qwen3-max-preview": {
|
||||
"description": "Qwen3 Max (معاينة) هو إصدار Max من سلسلة Qwen، موجه للاستدلال المتقدم وتكامل الأدوات (نسخة تجريبية)."
|
||||
},
|
||||
"qwen/qwen3-vl-plus": {
|
||||
"description": "Qwen3 VL-Plus هو الإصدار المعزز بصريًا من Qwen3، يعزز قدرات الاستدلال متعدد الوسائط ومعالجة الفيديو."
|
||||
},
|
||||
"qwen2": {
|
||||
"description": "Qwen2 هو نموذج لغوي كبير من الجيل الجديد من Alibaba، يدعم أداءً ممتازًا لتلبية احتياجات التطبيقات المتنوعة."
|
||||
},
|
||||
@@ -3260,9 +3404,6 @@
|
||||
"step-r1-v-mini": {
|
||||
"description": "هذا النموذج هو نموذج استدلال كبير يتمتع بقدرة قوية على فهم الصور، يمكنه معالجة المعلومات النصية والصورية، ويخرج نصوصًا بعد تفكير عميق. يظهر هذا النموذج أداءً بارزًا في مجال الاستدلال البصري، كما يمتلك قدرات رياضية، برمجية، ونصية من الدرجة الأولى. طول السياق هو 100k."
|
||||
},
|
||||
"step3": {
|
||||
"description": "Step3 هو نموذج متعدد الوسائط أطلقته Jiexue Xingchen، يتمتع بقدرات قوية في الفهم البصري."
|
||||
},
|
||||
"stepfun-ai/step3": {
|
||||
"description": "Step3 هو نموذج استدلال متعدد الوسائط متقدم أصدرته شركة 阶跃星辰 (StepFun). بُني على بنية مزيج الخبراء (MoE) التي تضم 321 مليار معلمة إجمالية و38 مليار معلمة تنشيط. صُمم النموذج بنهج من الطرف إلى الطرف ليقلل تكلفة فك الترميز، مع تقديم أداء رائد في الاستدلال البصري-اللغوي. من خلال التصميم التعاوني لآلية انتباه تفكيك متعدد المصفوفات (MFA) وفصل الانتباه عن شبكة التغذية الأمامية (AFD)، يحافظ Step3 على كفاءة ممتازة على كل من المسرعات الرائدة والمسرعات منخفضة التكلفة. في مرحلة ما قبل التدريب عالج Step3 أكثر من 20 تريليون توكن نصي و4 تريليون توكن مختلط نص-صورة، مغطياً أكثر من عشر لغات. حقق النموذج أداءً متقدماً بين نماذج المصدر المفتوح في عدة معايير قياسية تشمل الرياضيات والبرمجة والمهام متعددة الوسائط."
|
||||
},
|
||||
@@ -3344,6 +3485,9 @@
|
||||
"vercel/v0-1.5-md": {
|
||||
"description": "الوصول إلى النموذج خلف v0 لتوليد، إصلاح، وتحسين تطبيقات الويب الحديثة، مع استدلال مخصص للأطر المعينة ومعرفة حديثة."
|
||||
},
|
||||
"volcengine/doubao-seed-code": {
|
||||
"description": "Doubao-Seed-Code هو نموذج كبير من محرك Byte Volcano مُحسَّن لبرمجة الوكلاء (Agentic Programming)، ويؤدي أداءً ممتازًا في معايير متعددة للبرمجة والوكلاء، ويدعم سياقًا يصل إلى 256K."
|
||||
},
|
||||
"wan2.2-t2i-flash": {
|
||||
"description": "نسخة Wanxiang 2.2 فائقة السرعة، أحدث نموذج حاليًا. تم تحسين الإبداع، الاستقرار، والواقعية بشكل شامل، مع سرعة توليد عالية وقيمة ممتازة مقابل التكلفة."
|
||||
},
|
||||
@@ -3371,6 +3515,24 @@
|
||||
"wizardlm2:8x22b": {
|
||||
"description": "WizardLM 2 هو نموذج لغوي تقدمه Microsoft AI، يتميز بأداء ممتاز في الحوار المعقد، واللغات المتعددة، والاستدلال، والمساعدين الذكيين."
|
||||
},
|
||||
"x-ai/grok-4": {
|
||||
"description": "Grok 4 هو النموذج الرائد من xAI، يوفر قدرات استدلال قوية ودعمًا متعدد الوسائط."
|
||||
},
|
||||
"x-ai/grok-4-fast": {
|
||||
"description": "Grok 4 Fast هو نموذج عالي الإنتاجية ومنخفض التكلفة من xAI (يدعم نافذة سياق 2M)، مناسب للسيناريوهات التي تتطلب تزامنًا عاليًا وسياقًا طويلًا."
|
||||
},
|
||||
"x-ai/grok-4-fast-non-reasoning": {
|
||||
"description": "Grok 4 Fast (بدون استدلال) هو نموذج متعدد الوسائط عالي الإنتاجية ومنخفض التكلفة من xAI (يدعم نافذة سياق 2M)، موجه للسيناريوهات الحساسة للتأخير والتكلفة والتي لا تتطلب تفعيل الاستدلال داخل النموذج. يتوفر بجانب إصدار Grok 4 Fast مع الاستدلال، ويمكن تفعيل الاستدلال عند الحاجة عبر معامل reasoning enable في واجهة API. قد يتم استخدام المطالبات والإكمالات من قبل xAI أو OpenRouter لتحسين النماذج المستقبلية."
|
||||
},
|
||||
"x-ai/grok-4.1-fast": {
|
||||
"description": "Grok 4.1 Fast هو نموذج عالي الإنتاجية ومنخفض التكلفة من xAI (يدعم نافذة سياق 2M)، مناسب للسيناريوهات التي تتطلب تزامنًا عاليًا وسياقًا طويلًا."
|
||||
},
|
||||
"x-ai/grok-4.1-fast-non-reasoning": {
|
||||
"description": "Grok 4.1 Fast (بدون استدلال) هو نموذج متعدد الوسائط عالي الإنتاجية ومنخفض التكلفة من xAI (يدعم نافذة سياق 2M)، موجه للسيناريوهات الحساسة للتأخير والتكلفة والتي لا تتطلب تفعيل الاستدلال داخل النموذج. يتوفر بجانب إصدار Grok 4.1 Fast مع الاستدلال، ويمكن تفعيل الاستدلال عند الحاجة عبر معامل reasoning enable في واجهة API. قد يتم استخدام المطالبات والإكمالات من قبل xAI أو OpenRouter لتحسين النماذج المستقبلية."
|
||||
},
|
||||
"x-ai/grok-code-fast-1": {
|
||||
"description": "Grok Code Fast 1 هو نموذج سريع للبرمجة من xAI، ينتج أكواد قابلة للقراءة ومتوافقة مع متطلبات الهندسة."
|
||||
},
|
||||
"x1": {
|
||||
"description": "سيتم ترقية نموذج Spark X1 بشكل أكبر، حيث ستحقق المهام العامة مثل الاستدلال، وتوليد النصوص، وفهم اللغة نتائج تتماشى مع OpenAI o1 و DeepSeek R1."
|
||||
},
|
||||
@@ -3431,6 +3593,15 @@
|
||||
"yi-vision-v2": {
|
||||
"description": "نموذج مهام بصرية معقدة، يوفر فهمًا عالي الأداء وقدرات تحليلية بناءً على صور متعددة."
|
||||
},
|
||||
"z-ai/glm-4.5": {
|
||||
"description": "GLM 4.5 هو النموذج الرائد من Z.AI، يدعم أوضاع استدلال هجينة ومُحسَّن للمهام الهندسية وسياقات طويلة."
|
||||
},
|
||||
"z-ai/glm-4.5-air": {
|
||||
"description": "GLM 4.5 Air هو النسخة الخفيفة من GLM 4.5، مناسب للسيناريوهات الحساسة للتكلفة مع الحفاظ على قدرات استدلال قوية."
|
||||
},
|
||||
"z-ai/glm-4.6": {
|
||||
"description": "GLM 4.6 هو النموذج الرائد من Z.AI، يعزز طول السياق وقدرات الترميز."
|
||||
},
|
||||
"zai-org/GLM-4.5": {
|
||||
"description": "GLM-4.5 هو نموذج أساسي مصمم لتطبيقات الوكلاء الذكية، يستخدم بنية Mixture-of-Experts (MoE). تم تحسينه بعمق في مجالات استدعاء الأدوات، تصفح الويب، هندسة البرمجيات، وبرمجة الواجهة الأمامية، ويدعم التكامل السلس مع وكلاء الكود مثل Claude Code وRoo Code. يستخدم وضع استدلال مختلط ليتكيف مع سيناريوهات الاستدلال المعقدة والاستخدام اليومي."
|
||||
},
|
||||
@@ -3451,5 +3622,8 @@
|
||||
},
|
||||
"zai/glm-4.5v": {
|
||||
"description": "GLM-4.5V مبني على نموذج GLM-4.5-Air الأساسي، يرث التقنيات المثبتة من GLM-4.1V-Thinking، ويوسعها بفعالية من خلال بنية MoE القوية التي تضم 106 مليار معلمة."
|
||||
},
|
||||
"zenmux/auto": {
|
||||
"description": "ميزة التوجيه التلقائي من ZenMux تختار تلقائيًا أفضل نموذج من حيث الأداء والتكلفة من بين النماذج المدعومة بناءً على محتوى طلبك."
|
||||
}
|
||||
}
|
||||
|
||||
+34
-22
@@ -1,4 +1,38 @@
|
||||
{
|
||||
"builtins": {
|
||||
"lobe-knowledge-base": {
|
||||
"apiName": {
|
||||
"readKnowledge": "قراءة محتوى قاعدة المعرفة",
|
||||
"searchKnowledgeBase": "البحث في قاعدة المعرفة"
|
||||
},
|
||||
"title": "قاعدة المعرفة"
|
||||
},
|
||||
"lobe-local-system": {
|
||||
"apiName": {
|
||||
"editLocalFile": "تحرير الملف",
|
||||
"getCommandOutput": "الحصول على مخرجات الكود",
|
||||
"globLocalFiles": "البحث عن الملفات",
|
||||
"grepContent": "البحث في المحتوى",
|
||||
"killCommand": "إيقاف تنفيذ الكود",
|
||||
"listLocalFiles": "عرض قائمة الملفات",
|
||||
"moveLocalFiles": "نقل الملفات",
|
||||
"readLocalFile": "قراءة محتوى الملف",
|
||||
"renameLocalFile": "إعادة تسمية",
|
||||
"runCommand": "تنفيذ الكود",
|
||||
"searchLocalFiles": "البحث في الملفات",
|
||||
"writeLocalFile": "كتابة إلى الملف"
|
||||
},
|
||||
"title": "النظام المحلي"
|
||||
},
|
||||
"lobe-web-browsing": {
|
||||
"apiName": {
|
||||
"crawlMultiPages": "قراءة محتوى عدة صفحات",
|
||||
"crawlSinglePage": "قراءة محتوى الصفحة",
|
||||
"search": "البحث في الصفحات"
|
||||
},
|
||||
"title": "البحث عبر الإنترنت"
|
||||
}
|
||||
},
|
||||
"confirm": "تأكيد",
|
||||
"debug": {
|
||||
"arguments": "معلمات الاستدعاء",
|
||||
@@ -251,23 +285,6 @@
|
||||
"content": "جارٍ استدعاء الإضافة...",
|
||||
"plugin": "تشغيل الإضافة..."
|
||||
},
|
||||
"localSystem": {
|
||||
"apiName": {
|
||||
"editLocalFile": "تحرير الملف",
|
||||
"getCommandOutput": "الحصول على مخرجات الأوامر",
|
||||
"globLocalFiles": "البحث عن الملفات المطابقة",
|
||||
"grepContent": "البحث في المحتوى",
|
||||
"killCommand": "إيقاف تنفيذ الأمر",
|
||||
"listLocalFiles": "عرض قائمة الملفات",
|
||||
"moveLocalFiles": "نقل الملفات",
|
||||
"readLocalFile": "قراءة محتوى الملف",
|
||||
"renameLocalFile": "إعادة تسمية",
|
||||
"runCommand": "تشغيل الأمر",
|
||||
"searchLocalFiles": "بحث في الملفات",
|
||||
"writeLocalFile": "كتابة في الملف"
|
||||
},
|
||||
"title": "النظام المحلي"
|
||||
},
|
||||
"mcpInstall": {
|
||||
"CHECKING_INSTALLATION": "جارٍ فحص بيئة التثبيت...",
|
||||
"COMPLETED": "اكتمل التثبيت",
|
||||
@@ -375,11 +392,6 @@
|
||||
"warning": "⚠️ يرجى التأكد من ثقتك بمصدر هذه الإضافة، الإضافات الخبيثة قد تضر بأمان نظامك."
|
||||
},
|
||||
"search": {
|
||||
"apiName": {
|
||||
"crawlMultiPages": "قراءة محتوى عدة صفحات",
|
||||
"crawlSinglePage": "قراءة محتوى الصفحة",
|
||||
"search": "البحث في الصفحة"
|
||||
},
|
||||
"config": {
|
||||
"addKey": "إضافة مفتاح",
|
||||
"close": "حذف",
|
||||
|
||||
@@ -191,6 +191,9 @@
|
||||
"xinference": {
|
||||
"description": "Xorbits Inference (Xinference) هو منصة مفتوحة المصدر مصممة لتبسيط تشغيل ودمج نماذج الذكاء الاصطناعي المتنوعة. باستخدام Xinference، يمكنك تشغيل الاستدلال على نماذج LLM مفتوحة المصدر، ونماذج التضمين، والنماذج متعددة الوسائط سواء في السحابة أو في البيئات المحلية، وإنشاء تطبيقات ذكاء اصطناعي قوية."
|
||||
},
|
||||
"zenmux": {
|
||||
"description": "ZenMux هو منصة موحدة لتجميع خدمات الذكاء الاصطناعي، تدعم العديد من واجهات خدمات الذكاء الاصطناعي الرائدة مثل OpenAI وAnthropic وGoogle VertexAI. توفر قدرة توجيه مرنة تتيح لك التبديل وإدارة نماذج الذكاء الاصطناعي المختلفة بسهولة."
|
||||
},
|
||||
"zeroone": {
|
||||
"description": "01.AI تركز على تقنيات الذكاء الاصطناعي في عصر الذكاء الاصطناعي 2.0، وتعزز الابتكار والتطبيقات \"الإنسان + الذكاء الاصطناعي\"، باستخدام نماذج قوية وتقنيات ذكاء اصطناعي متقدمة لتعزيز إنتاجية البشر وتحقيق تمكين التكنولوجيا."
|
||||
},
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "أنيق",
|
||||
"title": "حركة الاستجابة"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "افتراضي",
|
||||
"desc": "اختر طريقة عرض قائمة النقر بزر الماوس الأيمن لرسائل الدردشة",
|
||||
"disabled": "عدم الاستخدام",
|
||||
"title": "خطة قائمة النقر بزر الماوس الأيمن"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "تخصيص تدرجات الرمادي ذات الاتجاهات اللونية المختلفة",
|
||||
"title": "لون محايد"
|
||||
|
||||
+20
-1
@@ -14,7 +14,21 @@
|
||||
"images": "الصور:",
|
||||
"prompt": "كلمة تلميح"
|
||||
},
|
||||
"lobe-knowledge-base": {
|
||||
"readKnowledge": {
|
||||
"meta": {
|
||||
"chars": "عدد الأحرف",
|
||||
"lines": "عدد السطور"
|
||||
}
|
||||
}
|
||||
},
|
||||
"localFiles": {
|
||||
"editFile": {
|
||||
"newString": "استبدال بـ",
|
||||
"oldString": "البحث عن",
|
||||
"replaceAll": "استبدال جميع المطابقات",
|
||||
"replaceFirst": "استبدال أول مطابقة فقط"
|
||||
},
|
||||
"file": "ملف",
|
||||
"folder": "مجلد",
|
||||
"moveFiles": {
|
||||
@@ -34,7 +48,12 @@
|
||||
"readFile": "قراءة الملف",
|
||||
"readFileError": "فشل في قراءة الملف، يرجى التحقق من صحة مسار الملف",
|
||||
"readFiles": "قراءة الملفات",
|
||||
"readFilesError": "فشل في قراءة الملفات، يرجى التحقق من صحة مسار الملف"
|
||||
"readFilesError": "فشل في قراءة الملفات، يرجى التحقق من صحة مسار الملف",
|
||||
"writeFile": {
|
||||
"characters": "أحرف",
|
||||
"preview": "معاينة المحتوى",
|
||||
"truncated": "تم الاقتطاع"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"createNewSearch": "إنشاء سجل بحث جديد",
|
||||
|
||||
+114
-1
@@ -52,6 +52,104 @@
|
||||
"required": "Полето не може да бъде празно"
|
||||
}
|
||||
},
|
||||
"betterAuth": {
|
||||
"errors": {
|
||||
"emailInvalid": "Моля, въведете валиден имейл адрес",
|
||||
"emailNotRegistered": "Този имейл все още не е регистриран",
|
||||
"emailNotVerified": "Имейлът не е потвърден, моля първо го потвърдете",
|
||||
"emailRequired": "Моля, въведете имейл адрес",
|
||||
"firstNameRequired": "Моля, въведете собствено име",
|
||||
"lastNameRequired": "Моля, въведете фамилно име",
|
||||
"loginFailed": "Входът не бе успешен, моля проверете имейла и паролата",
|
||||
"passwordFormat": "Паролата трябва да съдържа както букви, така и цифри",
|
||||
"passwordMaxLength": "Паролата не може да надвишава 64 знака",
|
||||
"passwordMinLength": "Паролата трябва да бъде поне 8 знака",
|
||||
"passwordRequired": "Моля, въведете парола",
|
||||
"usernameRequired": "Моля, въведете потребителско име"
|
||||
},
|
||||
"resetPassword": {
|
||||
"backToSignIn": "Обратно към вход",
|
||||
"confirmPasswordPlaceholder": "Потвърдете новата парола",
|
||||
"confirmPasswordRequired": "Моля, потвърдете новата парола",
|
||||
"description": "Моля, въведете новата си парола",
|
||||
"error": "Неуспешно нулиране на паролата, моля опитайте отново",
|
||||
"invalidToken": "Невалиден или изтекъл линк за нулиране",
|
||||
"newPasswordPlaceholder": "Въведете нова парола",
|
||||
"passwordMismatch": "Двете пароли не съвпадат",
|
||||
"submit": "Нулирай паролата",
|
||||
"success": "Паролата е успешно нулирана, моля влезте с новата парола",
|
||||
"title": "Нулиране на парола"
|
||||
},
|
||||
"signin": {
|
||||
"backToEmail": "Обратно към редактиране на имейл",
|
||||
"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": {
|
||||
"subtitle": "Моля, въведете имейл адреса си, за да продължите",
|
||||
"title": "Вход"
|
||||
},
|
||||
"error": "Входът не бе успешен, моля проверете имейла и паролата",
|
||||
"forgotPassword": "Забравена парола?",
|
||||
"forgotPasswordError": "Неуспешно изпращане на линк за нулиране на парола",
|
||||
"forgotPasswordSent": "Линк за нулиране на парола е изпратен, моля проверете имейла си",
|
||||
"magicLinkButton": "Изпрати линк за вход",
|
||||
"magicLinkError": "Неуспешно изпращане на линк за вход, моля опитайте по-късно",
|
||||
"magicLinkSent": "Линк за вход е изпратен, моля проверете имейла си",
|
||||
"nextStep": "Следваща стъпка",
|
||||
"noAccount": "Нямате акаунт?",
|
||||
"orContinueWith": "или",
|
||||
"passwordPlaceholder": "Моля, въведете парола",
|
||||
"passwordStep": {
|
||||
"subtitle": "Моля, въведете паролата си, за да продължите"
|
||||
},
|
||||
"signupLink": "Регистрирайте се сега",
|
||||
"socialError": "Неуспешен социален вход, моля опитайте отново",
|
||||
"socialOnlyHint": "Този имейл е регистриран чрез социален акаунт, моля влезте чрез него",
|
||||
"submit": "Вход"
|
||||
},
|
||||
"signup": {
|
||||
"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 дни"
|
||||
@@ -86,16 +184,31 @@
|
||||
"loginOrSignup": "Вход / Регистрация",
|
||||
"profile": {
|
||||
"avatar": "Аватар",
|
||||
"cancel": "Отказ",
|
||||
"changePassword": "Нулиране на парола",
|
||||
"email": "Имейл адрес",
|
||||
"fullName": "Пълно име",
|
||||
"fullNameInputHint": "Моля, въведете новото си пълно име",
|
||||
"password": "Парола",
|
||||
"resetPasswordError": "Неуспешно изпращане на линк за нулиране на парола",
|
||||
"resetPasswordSent": "Линк за нулиране на парола е изпратен, моля проверете имейла си",
|
||||
"save": "Запази",
|
||||
"sso": {
|
||||
"link": {
|
||||
"button": "Свържи акаунт",
|
||||
"success": "Акаунтът е успешно свързан"
|
||||
},
|
||||
"loading": "Зареждане на свързаните трети страни акаунти",
|
||||
"providers": "Свързани акаунти",
|
||||
"unlink": {
|
||||
"description": "След като свържете, няма да можете да използвате акаунта на {{provider}} „{{providerAccountId}}“ за вход. Ако искате отново да свържете акаунта на {{provider}} с текущия акаунт, уверете се, че имейл адресът на акаунта на {{provider}} е {{email}}, а ние автоматично ще ви свържем с текущия влезлия акаунт при вход.",
|
||||
"description": "След като прекъснете връзката, няма да можете да влизате с акаунта {{provider}} \"{{providerAccountId}}\". Ако искате отново да свържете акаунта {{provider}} с текущия акаунт, уверете се, че имейл адресът на акаунта {{provider}} е {{email}}. Ще го свържем автоматично при следващ вход.",
|
||||
"forbidden": "Трябва да имате поне един свързан акаунт на трета страна.",
|
||||
"title": "Наистина ли искате да свържете акаунта на трета страна {{provider}}?"
|
||||
}
|
||||
},
|
||||
"title": "Детайли на профила",
|
||||
"updateAvatar": "Актуализирай аватара",
|
||||
"updateFullName": "Актуализирай пълното име",
|
||||
"username": "Потребителско име"
|
||||
},
|
||||
"signout": "Изход",
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
"desc": "Ограничения на механизма Claude Thinking (<1>Научете повече</1>), при активиране автоматично ще се деактивира ограничението на броя на историческите съобщения",
|
||||
"title": "Активиране на дълбочинно мислене"
|
||||
},
|
||||
"imageAspectRatio": {
|
||||
"title": "Съотношение на ширина и височина на изображението"
|
||||
},
|
||||
"imageResolution": {
|
||||
"title": "Разделителна способност на изображението"
|
||||
},
|
||||
"reasoningBudgetToken": {
|
||||
"title": "Токени за разходи при мислене"
|
||||
},
|
||||
@@ -65,6 +71,9 @@
|
||||
"thinking": {
|
||||
"title": "Превключвател за дълбоко мислене"
|
||||
},
|
||||
"thinkingLevel": {
|
||||
"title": "Ниво на мислене"
|
||||
},
|
||||
"title": "Разширени функции на модела",
|
||||
"urlContext": {
|
||||
"desc": "Когато е включено, автоматично ще се анализират уеб връзки, за да се получи реалното съдържание на уеб страницата",
|
||||
@@ -330,6 +339,11 @@
|
||||
"screenshot": "Екранна снимка",
|
||||
"settings": "Настройки за експортиране",
|
||||
"text": "Текст",
|
||||
"widthMode": {
|
||||
"label": "Режим на ширина",
|
||||
"narrow": "Режим за тесен екран",
|
||||
"wide": "Режим за широк екран"
|
||||
},
|
||||
"withBackground": "Включи фоново изображение",
|
||||
"withFooter": "Включи долен колонтитул",
|
||||
"withPluginInfo": "Включи информация за плъгина",
|
||||
@@ -391,6 +405,7 @@
|
||||
"rejectReasonPlaceholder": "Въведете причина за отхвърляне, за да помогнете на агента да разбере и подобри бъдещите действия",
|
||||
"rejectTitle": "Отхвърляне на това извикване на инструмент",
|
||||
"rejectedWithReason": "Това извикване на инструмент беше умишлено отхвърлено: {{reason}}",
|
||||
"toolAbort": "Този инструмент беше отменен от потребителя",
|
||||
"toolRejected": "Това извикване на инструмент беше умишлено отхвърлено"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -135,6 +135,27 @@
|
||||
}
|
||||
},
|
||||
"close": "Затвори",
|
||||
"cmdk": {
|
||||
"about": "Относно",
|
||||
"communitySupport": "Общностна поддръжка",
|
||||
"discover": "Открий",
|
||||
"knowledgeBase": "База знания",
|
||||
"navigate": "Навигация",
|
||||
"newAgent": "Създай агент",
|
||||
"noResults": "Няма намерени резултати",
|
||||
"openSettings": "Отвори настройките",
|
||||
"painting": "AI Рисуване",
|
||||
"searchPlaceholder": "Въведете команда или търсене...",
|
||||
"settings": "Настройки",
|
||||
"starOnGitHub": "Дайте ни звезда в GitHub",
|
||||
"submitIssue": "Подайте проблем",
|
||||
"theme": "Тема",
|
||||
"themeAuto": "Следвай системата",
|
||||
"themeDark": "Тъмен режим",
|
||||
"themeLight": "Светъл режим",
|
||||
"toOpen": "Отвори",
|
||||
"toSelect": "Избери"
|
||||
},
|
||||
"confirm": "Потвърди",
|
||||
"contact": "Свържете се с нас",
|
||||
"copy": "Копирай",
|
||||
@@ -283,6 +304,7 @@
|
||||
"business": "Бизнес сътрудничество",
|
||||
"support": "Поддръжка по имейл"
|
||||
},
|
||||
"new": "Нов",
|
||||
"oauth": "SSO Вход",
|
||||
"officialSite": "Официален сайт",
|
||||
"ok": "Добре",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"SPII": "Вашето съдържание може да съдържа чувствителна лична информация. За да защитите поверителността, моля, премахнете съответната чувствителна информация и опитайте отново.",
|
||||
"default": "Съдържанието е блокирано: {{blockReason}}。请调整您的请求内容后重试。"
|
||||
},
|
||||
"InsufficientQuota": "Съжаляваме, квотата за този ключ е достигнала лимита. Моля, проверете баланса на акаунта си или увеличете квотата на ключа и опитайте отново.",
|
||||
"InsufficientQuota": "Съжаляваме, но квотата за този ключ е изчерпана. Моля, проверете дали имате достатъчен баланс в акаунта си или увеличете квотата на ключа и опитайте отново.",
|
||||
"InvalidAccessCode": "Невалиден или празен код за достъп. Моля, въведете правилния код за достъп или добавете персонализиран API ключ.",
|
||||
"InvalidBedrockCredentials": "Удостоверяването на Bedrock е неуспешно. Моля, проверете AccessKeyId/SecretAccessKey и опитайте отново.",
|
||||
"InvalidClerkUser": "很抱歉,你当前尚未登录,请先登录或注册账号后继续操作",
|
||||
@@ -131,7 +131,7 @@
|
||||
"PluginServerError": "Заявката към сървъра на плъгина върна грешка. Моля, проверете файла на манифеста на плъгина, конфигурацията на плъгина или изпълнението на сървъра въз основа на информацията за грешката по-долу",
|
||||
"PluginSettingsInvalid": "Този плъгин трябва да бъде конфигуриран правилно, преди да може да се използва. Моля, проверете дали конфигурацията ви е правилна",
|
||||
"ProviderBizError": "Грешка в услугата на {{provider}}, моля проверете следната информация или опитайте отново",
|
||||
"QuotaLimitReached": "Съжаляваме, но текущото използване на токени или брой на заявките е достигнало лимита на квотата за този ключ. Моля, увеличете квотата на ключа или опитайте отново по-късно.",
|
||||
"QuotaLimitReached": "Съжаляваме, но текущото използване на токени или броят на заявките е достигнало лимита на квотата за този ключ. Моля, увеличете квотата на ключа или опитайте отново по-късно.",
|
||||
"StreamChunkError": "Грешка при парсирането на съобщение от потокова заявка. Моля, проверете дали текущият API интерфейс отговаря на стандартите или се свържете с вашия доставчик на API за консултация.",
|
||||
"SubscriptionKeyMismatch": "Съжаляваме, но поради случайна системна грешка, текущото използване на абонамента временно е невалидно. Моля, кликнете върху бутона по-долу, за да възстановите абонамента, или се свържете с нас по имейл за поддръжка.",
|
||||
"SubscriptionPlanLimit": "Вашият абонаментен план е изчерпан, не можете да използвате тази функция. Моля, надстройте до по-висок план или конфигурирайте персонализиран модел API, за да продължите да използвате.",
|
||||
|
||||
+10
-12
@@ -55,10 +55,10 @@
|
||||
},
|
||||
"documentList": {
|
||||
"copyContent": "Копиране на цялото съдържание",
|
||||
"documentCount": "Общо {{count}} документа",
|
||||
"duplicate": "Създаване на копие",
|
||||
"empty": "Все още няма документи. Натиснете бутона по-горе, за да създадете първия си документ",
|
||||
"empty": "Все още няма документи. Щракнете върху бутона по-горе, за да създадете първия си документ.",
|
||||
"noResults": "Няма намерени съвпадащи документи",
|
||||
"pageCount": "Общо {{count}} документа",
|
||||
"selectNote": "Изберете документ, за да започнете редактиране",
|
||||
"untitled": "Без заглавие"
|
||||
},
|
||||
@@ -70,7 +70,6 @@
|
||||
"uploadFile": "Качване на файл",
|
||||
"uploadFolder": "Качване на папка"
|
||||
},
|
||||
"newDocumentButton": "Нов документ",
|
||||
"newNoteDialog": {
|
||||
"cancel": "Отказ",
|
||||
"editTitle": "Редактиране на документ",
|
||||
@@ -83,14 +82,15 @@
|
||||
"title": "Нов документ",
|
||||
"updateSuccess": "Документът беше обновен успешно"
|
||||
},
|
||||
"newPageButton": "Създай нов документ",
|
||||
"uploadButton": "Качване"
|
||||
},
|
||||
"home": {
|
||||
"getStarted": "Започнете",
|
||||
"greeting": "Начало",
|
||||
"quickActions": "Бързи действия",
|
||||
"recentDocuments": "Скорошни документи",
|
||||
"recentFiles": "Скорошни файлове",
|
||||
"recentPages": "Скорошни документи",
|
||||
"subtitle": "Добре дошли в базата знания. Започнете да управлявате вашите документи оттук",
|
||||
"uploadEntries": {
|
||||
"files": {
|
||||
@@ -102,8 +102,8 @@
|
||||
"knowledgeBase": {
|
||||
"title": "Създай база знания"
|
||||
},
|
||||
"newDocument": {
|
||||
"title": "Създай нов документ"
|
||||
"newPage": {
|
||||
"title": "Създаване на нов документ"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -116,8 +116,8 @@
|
||||
"title": "База знания"
|
||||
},
|
||||
"menu": {
|
||||
"allDocuments": "Всички документи",
|
||||
"allFiles": "Всички файлове"
|
||||
"allFiles": "Всички файлове",
|
||||
"allPages": "Всички документи"
|
||||
},
|
||||
"networkError": "Неуспешно получаване на базата от знания, моля, проверете интернет връзката и опитайте отново",
|
||||
"notSupportGuide": {
|
||||
@@ -142,8 +142,8 @@
|
||||
"downloadFile": "Изтеглете файла",
|
||||
"unsupportedFileAndContact": "Този формат на файла не поддържа онлайн преглед. Ако имате нужда от преглед, моля, <1>свържете се с нас</1>."
|
||||
},
|
||||
"searchDocumentPlaceholder": "Търсене на документи",
|
||||
"searchFilePlaceholder": "Търсене на файл",
|
||||
"searchPagePlaceholder": "Търсене на документи",
|
||||
"tab": {
|
||||
"all": "Всички",
|
||||
"audios": "Аудио",
|
||||
@@ -156,9 +156,7 @@
|
||||
"websites": "Уебсайтове"
|
||||
},
|
||||
"title": "База знания",
|
||||
"toggleLeftPanel": {
|
||||
"title": "Покажи/Скрий лявото панел"
|
||||
},
|
||||
"toggleLeftPanel": "Показване/скриване на лявия панел",
|
||||
"uploadDock": {
|
||||
"body": {
|
||||
"collapse": "Скрий",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"desc": "Изтриване на текущите съобщения и качените файлове в сесията",
|
||||
"title": "Изтриване на съобщенията в сесията"
|
||||
},
|
||||
"commandPalette": {
|
||||
"desc": "Отворете глобалния панел с команди за бърз достъп до функции",
|
||||
"title": "Панел с команди"
|
||||
},
|
||||
"deleteAndRegenerateMessage": {
|
||||
"desc": "Изтриване на последното съобщение и повторно генериране",
|
||||
"title": "Изтрий и генерирай отново"
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
"standard": "Стандартно"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Резолюция",
|
||||
"options": {
|
||||
"1K": "1K",
|
||||
"2K": "2K",
|
||||
"4K": "4K"
|
||||
}
|
||||
},
|
||||
"seed": {
|
||||
"label": "Семена",
|
||||
"random": "Случаен семенен код"
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
},
|
||||
"list": {
|
||||
"title": {
|
||||
"custom": "Персонализираният доставчик на услуги не е активиран",
|
||||
"disabled": "Неактивен доставчик",
|
||||
"enabled": "Активен доставчик"
|
||||
}
|
||||
@@ -198,6 +199,7 @@
|
||||
"addCustomProvider": "Добавяне на персонализиран доставчик",
|
||||
"all": "Всички",
|
||||
"list": {
|
||||
"custom": "Персонализираният не е активиран",
|
||||
"disabled": "Неактивиран",
|
||||
"disabledActions": {
|
||||
"sort": "Сортиране",
|
||||
@@ -295,7 +297,7 @@
|
||||
},
|
||||
"helpDoc": "Ръководство за конфигуриране",
|
||||
"responsesApi": {
|
||||
"desc": "Използва новия формат на заявките на OpenAI, отключващ функции като вериги на мислене и други усъвършенствани възможности",
|
||||
"desc": "Използва новия формат за заявки на OpenAI, отключвайки разширени функции като вериги на мисълта (поддържа се само от моделите на OpenAI)",
|
||||
"title": "Използване на Responses API стандарта"
|
||||
},
|
||||
"waitingForMore": "Още модели са в <1>планиране</1>, моля, очаквайте"
|
||||
|
||||
+214
-40
@@ -236,6 +236,9 @@
|
||||
"MiniMaxAI/MiniMax-M1-80k": {
|
||||
"description": "MiniMax-M1 е мащабен модел за разсъждение с отворени тегла и смесено внимание, с 456 милиарда параметри, като всеки токен активира около 45.9 милиарда параметри. Моделът поддържа естествено контекст с дължина до 1 милион токена и чрез механизма за светкавично внимание спестява 75% от изчисленията при задачи с генериране на 100 хиляди токена в сравнение с DeepSeek R1. Освен това MiniMax-M1 използва MoE (смесен експертен) архитектура, комбинирайки CISPO алгоритъм и ефективно обучение с подсилване с дизайн на смесено внимание, постигащи водещи в индустрията резултати при дълги входни разсъждения и реални софтуерни инженерни сценарии."
|
||||
},
|
||||
"MiniMaxAI/MiniMax-M2": {
|
||||
"description": "MiniMax-M2 преосмисля ефективността на интелигентните агенти. Това е компактен, бърз и икономичен MoE модел с общо 230 милиарда параметъра и 10 милиарда активни параметъра, създаден за постигане на върхова производителност при кодиране и задачи, свързани с интелигентни агенти, като същевременно поддържа силен общ интелект. Със само 10 милиарда активни параметъра, MiniMax-M2 предлага производителност, сравнима с тази на мащабни модели, което го прави идеален избор за приложения с висока ефективност."
|
||||
},
|
||||
"Moonshot-Kimi-K2-Instruct": {
|
||||
"description": "Общ брой параметри 1 трилион, активирани параметри 32 милиарда. Сред немисловните модели постига водещи резултати в областта на актуални знания, математика и кодиране, с по-добри възможности за универсални агентски задачи. Специално оптимизиран за агентски задачи, не само отговаря на въпроси, но и може да предприема действия. Най-подходящ за импровизирани, универсални разговори и агентски преживявания, модел с рефлексна скорост без нужда от дълго мислене."
|
||||
},
|
||||
@@ -717,25 +720,31 @@
|
||||
"description": "Claude 3 Opus е най-интелигентният модел на Anthropic с водещи на пазара резултати при изключително сложни задачи. Той се справя с отворени подсказки и непознати сценарии с изключителна плавност и човешко разбиране."
|
||||
},
|
||||
"anthropic/claude-3.5-haiku": {
|
||||
"description": "Claude 3.5 Haiku е следващото поколение на нашия най-бърз модел. Със скорост, подобна на Claude 3 Haiku, той подобрява всяка компетентност и надминава предишния ни най-голям модел Claude 3 Opus в много интелигентни бенчмаркове."
|
||||
"description": "Claude 3.5 Haiku предлага подобрени възможности за скорост, точност при програмиране и използване на инструменти. Подходящ за сценарии с високи изисквания към бързодействие и взаимодействие с инструменти."
|
||||
},
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"description": "Claude 3.5 Sonnet постига идеален баланс между интелигентност и скорост — особено за корпоративни натоварвания. Той предлага мощна производителност на по-ниска цена в сравнение с конкурентите и е проектиран за висока издръжливост при мащабни AI внедрявания."
|
||||
"description": "Claude 3.5 Sonnet е бърз и ефективен модел от семейството Sonnet, предлагащ по-добра производителност при програмиране и логическо разсъждение. Някои версии постепенно ще бъдат заменени от Sonnet 3.7 и други."
|
||||
},
|
||||
"anthropic/claude-3.7-sonnet": {
|
||||
"description": "Claude 3.7 Sonnet е първият хибриден разсъдъчен модел и най-интелигентният модел на Anthropic досега. Той предлага водещи резултати в кодиране, генериране на съдържание, анализ на данни и планиране, изграждайки се върху софтуерните инженерни и компютърни умения на предшественика си Claude 3.5 Sonnet."
|
||||
"description": "Claude 3.7 Sonnet е надградена версия от серията Sonnet, предлагаща по-силни възможности за логическо разсъждение и програмиране, подходяща за сложни корпоративни задачи."
|
||||
},
|
||||
"anthropic/claude-haiku-4.5": {
|
||||
"description": "Claude Haiku 4.5 е високопроизводителен и бърз модел на Anthropic, който съчетава висока точност с изключително ниска латентност."
|
||||
},
|
||||
"anthropic/claude-opus-4": {
|
||||
"description": "Claude Opus 4 е най-мощният модел на Anthropic досега и най-добрият кодов модел в света, водещ в SWE-bench (72.5%) и Terminal-bench (43.2%). Той осигурява устойчива производителност за дългосрочни задачи, изискващи фокус и хиляди стъпки, като може да работи непрекъснато часове — значително разширявайки възможностите на AI агентите."
|
||||
"description": "Opus 4 е флагманският модел на Anthropic, проектиран за сложни задачи и корпоративни приложения."
|
||||
},
|
||||
"anthropic/claude-opus-4.1": {
|
||||
"description": "Claude Opus 4.1 е plug-and-play алтернатива на Opus 4, осигуряваща изключителна производителност и точност за реални кодови и агентски задачи. Opus 4.1 повишава водещата кодова производителност до 74.5% в SWE-bench Verified и обработва сложни многостъпкови проблеми с по-голяма прецизност и внимание към детайлите."
|
||||
"description": "Opus 4.1 е висок клас модел на Anthropic, оптимизиран за програмиране, сложни логически задачи и продължителни процеси."
|
||||
},
|
||||
"anthropic/claude-opus-4.5": {
|
||||
"description": "Claude Opus 4.5 е водещият модел на Anthropic, който съчетава изключителен интелект и мащабируема производителност, подходящ за сложни задачи, изискващи най-високо качество на отговорите и способност за разсъждение."
|
||||
},
|
||||
"anthropic/claude-sonnet-4": {
|
||||
"description": "Claude Sonnet 4 значително подобрява водещите в индустрията възможности на Sonnet 3.7, с отлични резултати в кодиране и постига водещи 72.7% в SWE-bench. Моделът балансира производителност и ефективност, подходящ е за вътрешни и външни случаи и предлага по-голям контрол чрез подобрена управляемост."
|
||||
"description": "Claude Sonnet 4 е хибриден модел за разсъждение от Anthropic, предлагащ комбинирани възможности за мисловни и немисловни задачи."
|
||||
},
|
||||
"anthropic/claude-sonnet-4.5": {
|
||||
"description": "Claude Sonnet 4.5 е най-интелигентният модел на Anthropic досега."
|
||||
"description": "Claude Sonnet 4.5 е най-новият хибриден модел за разсъждение на Anthropic, оптимизиран за сложни логически задачи и програмиране."
|
||||
},
|
||||
"ascend-tribe/pangu-pro-moe": {
|
||||
"description": "Pangu-Pro-MoE 72B-A16B е голям езиков модел с 72 милиарда параметри и 16 милиарда активирани параметри, базиран на архитектурата с групирани смесени експерти (MoGE). Той групира експертите по време на избора им и ограничава активацията на токените да активират равен брой експерти във всяка група, което осигурява балансирано натоварване на експертите и значително подобрява ефективността на разгръщане на модела на платформата Ascend."
|
||||
@@ -758,6 +767,9 @@
|
||||
"baidu/ERNIE-4.5-300B-A47B": {
|
||||
"description": "ERNIE-4.5-300B-A47B е голям езиков модел, разработен от Baidu, базиран на архитектурата с хибридни експерти (MoE). Моделът има общо 300 милиарда параметри, но при инференция активира само 47 милиарда параметри на токен, което осигурява висока производителност и изчислителна ефективност. Като един от основните модели в серията ERNIE 4.5, той демонстрира изключителни способности в задачи като разбиране на текст, генериране, разсъждение и програмиране. Моделът използва иновативен мултимодален хетерогенен MoE метод за предварително обучение, който чрез съвместно обучение на текстови и визуални модалности значително подобрява цялостните му възможности, особено в следването на инструкции и запаметяването на световни знания."
|
||||
},
|
||||
"baidu/ernie-5.0-thinking-preview": {
|
||||
"description": "ERNIE 5.0 Thinking Preview е ново поколение мултимодален модел на Baidu, способен на разбиране на различни модалности, следване на инструкции, творчество, отговаряне на фактически въпроси и използване на инструменти."
|
||||
},
|
||||
"c4ai-aya-expanse-32b": {
|
||||
"description": "Aya Expanse е високопроизводителен многоезичен модел с 32B, проектиран да предизвика представянето на едноезични модели чрез иновации в настройката на инструкции, арбитраж на данни, обучение на предпочитания и комбиниране на модели. Той поддържа 23 езика."
|
||||
},
|
||||
@@ -818,6 +830,9 @@
|
||||
"claude-opus-4-20250514": {
|
||||
"description": "Claude Opus 4 е най-мощният модел на Anthropic, предназначен за обработка на изключително сложни задачи. Той се отличава с изключителна производителност, интелигентност, плавност и разбиране."
|
||||
},
|
||||
"claude-opus-4-5-20251101": {
|
||||
"description": "Claude Opus 4.5 е водещият модел на Anthropic, който съчетава изключителен интелект и мащабируема производителност, подходящ за сложни задачи, изискващи най-високо качество на отговорите и способност за разсъждение."
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"description": "Claude Sonnet 4 може да генерира почти мигновени отговори или удължено стъпково мислене, като потребителите могат ясно да проследят тези процеси."
|
||||
},
|
||||
@@ -866,6 +881,9 @@
|
||||
"codex-mini-latest": {
|
||||
"description": "codex-mini-latest е фина настройка на o4-mini, специално предназначена за Codex CLI. За директна употреба чрез API препоръчваме да започнете с gpt-4.1."
|
||||
},
|
||||
"cogito-2.1:671b": {
|
||||
"description": "Cogito v2.1 671B е американски отворен езиков модел с безплатна търговска употреба, отличаващ се с производителност, съпоставима с водещите модели, по-висока ефективност при обработка на токени, 128k дълъг контекст и мощни общи способности."
|
||||
},
|
||||
"cogview-4": {
|
||||
"description": "CogView-4 е първият отворен модел за генериране на изображения с текст на китайски, разработен от Zhipu, който значително подобрява разбирането на семантиката, качеството на генериране на изображения и способността за генериране на текст на китайски и английски език. Поддържа двуезичен вход на произволна дължина на китайски и английски и може да генерира изображения с произволна резолюция в зададения диапазон."
|
||||
},
|
||||
@@ -1136,6 +1154,9 @@
|
||||
"deepseek-vl2-small": {
|
||||
"description": "DeepSeek VL2 Small, олекотена мултимодална версия, подходяща за среди с ограничени ресурси и висока едновременност."
|
||||
},
|
||||
"deepseek/deepseek-chat": {
|
||||
"description": "DeepSeek-V3 е високопроизводителен хибриден модел за разсъждение от екипа на DeepSeek, подходящ за сложни задачи и интеграция с инструменти."
|
||||
},
|
||||
"deepseek/deepseek-chat-v3-0324": {
|
||||
"description": "DeepSeek V3 е експертен смесен модел с 685B параметри, последната итерация на флагманската серия чат модели на екипа DeepSeek.\n\nТой наследява модела [DeepSeek V3](/deepseek/deepseek-chat-v3) и показва отлични резултати в различни задачи."
|
||||
},
|
||||
@@ -1143,19 +1164,19 @@
|
||||
"description": "DeepSeek V3 е експертен смесен модел с 685B параметри, последната итерация на флагманската серия чат модели на екипа DeepSeek.\n\nТой наследява модела [DeepSeek V3](/deepseek/deepseek-chat-v3) и показва отлични резултати в различни задачи."
|
||||
},
|
||||
"deepseek/deepseek-chat-v3.1": {
|
||||
"description": "DeepSeek-V3.1 е голям хибриден модел за разсъждение, който поддържа 128K дълъг контекст и ефективно превключване на режими, постигащ изключителна производителност и скорост при използване на инструменти, генериране на код и сложни задачи за разсъждение."
|
||||
"description": "DeepSeek-V3.1 е хибриден модел за разсъждение с дълъг контекст от DeepSeek, поддържащ комбинирани мисловни и немисловни режими и интеграция с инструменти."
|
||||
},
|
||||
"deepseek/deepseek-r1": {
|
||||
"description": "DeepSeek R1 моделът е получил малка версия ъпгрейд, текущата версия е DeepSeek-R1-0528. В последната актуализация DeepSeek R1 значително подобри дълбочината и способността за разсъждение чрез използване на увеличени изчислителни ресурси и въвеждане на алгоритмични оптимизации след обучението. Моделът постига отлични резултати в множество бенчмаркове като математика, програмиране и обща логика, като общата му производителност вече се доближава до водещи модели като O3 и Gemini 2.5 Pro."
|
||||
},
|
||||
"deepseek/deepseek-r1-0528": {
|
||||
"description": "DeepSeek-R1 значително подобрява способността за разсъждение на модела дори с много малко анотирани данни. Преди да изведе окончателния отговор, моделът първо генерира мисловна верига, за да повиши точността на крайния отговор."
|
||||
"description": "DeepSeek R1 0528 е обновен вариант на DeepSeek, фокусиран върху отвореност и дълбочина на разсъждение."
|
||||
},
|
||||
"deepseek/deepseek-r1-0528:free": {
|
||||
"description": "DeepSeek-R1 значително подобрява способността за разсъждение на модела дори с много малко анотирани данни. Преди да изведе окончателния отговор, моделът първо генерира мисловна верига, за да повиши точността на крайния отговор."
|
||||
},
|
||||
"deepseek/deepseek-r1-distill-llama-70b": {
|
||||
"description": "DeepSeek-R1-Distill-Llama-70B е дистилиран и по-ефективен вариант на 70B Llama модела. Той запазва силна производителност при генериране на текст, намалявайки изчислителните разходи за по-лесно внедряване и изследване. Обслужва се от Groq с помощта на техния персонализиран хардуер за езикова обработка (LPU), осигурявайки бързо и ефективно разсъждение."
|
||||
"description": "DeepSeek R1 Distill Llama 70B е голям езиков модел, базиран на Llama3.3 70B, който използва фино настройване, извлечено от DeepSeek R1, за да постигне конкурентна производителност, съпоставима с водещите мащабни модели."
|
||||
},
|
||||
"deepseek/deepseek-r1-distill-llama-8b": {
|
||||
"description": "DeepSeek R1 Distill Llama 8B е дестилиран голям езиков модел, базиран на Llama-3.1-8B-Instruct, обучен с изхода на DeepSeek R1."
|
||||
@@ -1172,6 +1193,9 @@
|
||||
"deepseek/deepseek-r1:free": {
|
||||
"description": "DeepSeek-R1 значително подобри способността на модела за разсъждение при наличието на много малко маркирани данни. Преди да предостави окончателния отговор, моделът първо ще изведе част от съдържанието на веригата на мислене, за да повиши точността на окончателния отговор."
|
||||
},
|
||||
"deepseek/deepseek-reasoner": {
|
||||
"description": "DeepSeek-V3 Thinking (reasoner) е експериментален модел за разсъждение от DeepSeek, подходящ за задачи с висока сложност."
|
||||
},
|
||||
"deepseek/deepseek-v3": {
|
||||
"description": "Бърз универсален голям езиков модел с подобрени способности за разсъждение."
|
||||
},
|
||||
@@ -1478,9 +1502,6 @@
|
||||
"gemini-2.0-flash-lite-001": {
|
||||
"description": "Gemini 2.0 Flash е вариант на модела, оптимизиран за икономичност и ниска латентност."
|
||||
},
|
||||
"gemini-2.0-flash-preview-image-generation": {
|
||||
"description": "Gemini 2.0 Flash предварителен модел, поддържащ генериране на изображения"
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"description": "Gemini 2.5 Flash е най-ефективният модел на Google, предлагащ пълна функционалност."
|
||||
},
|
||||
@@ -1508,9 +1529,6 @@
|
||||
"gemini-2.5-flash-preview-04-17": {
|
||||
"description": "Gemini 2.5 Flash Preview е моделът с най-добро съотношение цена-качество на Google, предлагащ пълна функционалност."
|
||||
},
|
||||
"gemini-2.5-flash-preview-05-20": {
|
||||
"description": "Gemini 2.5 Flash Preview е най-ефективният модел на Google, предлагащ пълна функционалност."
|
||||
},
|
||||
"gemini-2.5-flash-preview-09-2025": {
|
||||
"description": "Прегледна версия (25 септември 2025 г.) на Gemini 2.5 Flash"
|
||||
},
|
||||
@@ -1526,6 +1544,15 @@
|
||||
"gemini-2.5-pro-preview-06-05": {
|
||||
"description": "Gemini 2.5 Pro Preview е най-напредналият мисловен модел на Google, способен да разсъждава върху сложни проблеми в областта на кодирането, математиката и STEM, както и да анализира големи набори от данни, кодови бази и документи с дълъг контекст."
|
||||
},
|
||||
"gemini-3-pro-image-preview": {
|
||||
"description": "Gemini 3 Pro Image (Nano Banana Pro) е модел за генериране на изображения от Google, който поддържа и мултимодален диалог."
|
||||
},
|
||||
"gemini-3-pro-image-preview:image": {
|
||||
"description": "Gemini 3 Pro Image (Nano Banana Pro) е модел за генериране на изображения от Google, който поддържа и мултимодален диалог."
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
"description": "Gemini 3 Pro е най-добрият в света модел за мултимодално разбиране, както и най-мощният интелигентен агент и модел за програмиране на атмосфера от Google досега. Предлага богати визуални ефекти и дълбока интерактивност, базирани на най-съвременни логически способности."
|
||||
},
|
||||
"gemini-flash-latest": {
|
||||
"description": "Последно издание на Gemini Flash"
|
||||
},
|
||||
@@ -1649,8 +1676,11 @@
|
||||
"glm-zero-preview": {
|
||||
"description": "GLM-Zero-Preview притежава мощни способности за сложни разсъждения, показвайки отлични резултати в логическото разсъждение, математиката и програмирането."
|
||||
},
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
"description": "Claude Opus 4.5 е водещият модел на Anthropic, който съчетава изключителен интелект и мащабируема производителност, подходящ за сложни задачи, изискващи най-високо качество на отговорите и способност за разсъждение."
|
||||
},
|
||||
"google/gemini-2.0-flash": {
|
||||
"description": "Gemini 2.0 Flash предлага следващо поколение функции и подобрения, включително изключителна скорост, вградена употреба на инструменти, мултимодално генериране и контекстен прозорец от 1 милион токена."
|
||||
"description": "Gemini 2.0 Flash е високопроизводителен модел за разсъждение от Google, подходящ за разширени мултимодални задачи."
|
||||
},
|
||||
"google/gemini-2.0-flash-001": {
|
||||
"description": "Gemini 2.0 Flash предлага следващо поколение функции и подобрения, включително изключителна скорост, нативна употреба на инструменти, многомодално генериране и контекстен прозорец от 1M токена."
|
||||
@@ -1661,14 +1691,23 @@
|
||||
"google/gemini-2.0-flash-lite": {
|
||||
"description": "Gemini 2.0 Flash Lite предлага следващо поколение функции и подобрения, включително изключителна скорост, вградена употреба на инструменти, мултимодално генериране и контекстен прозорец от 1 милион токена."
|
||||
},
|
||||
"google/gemini-2.0-flash-lite-001": {
|
||||
"description": "Gemini 2.0 Flash Lite е олекотена версия от семейството Gemini, по подразбиране без активирано разсъждение за по-ниска латентност и разходи, но може да бъде активирано чрез параметри."
|
||||
},
|
||||
"google/gemini-2.5-flash": {
|
||||
"description": "Gemini 2.5 Flash е мислещ модел, който предлага отлични всеобхватни възможности. Той е проектиран да балансира цена и производителност, поддържайки мултимодалност и контекстен прозорец от 1 милион токена."
|
||||
"description": "Серията Gemini 2.5 Flash (Lite/Pro/Flash) са модели на Google с ниска до висока латентност и производителност за разсъждение."
|
||||
},
|
||||
"google/gemini-2.5-flash-image": {
|
||||
"description": "Gemini 2.5 Flash Image (Nano Banana) е модел за генериране на изображения от Google, който поддържа и мултимодален диалог."
|
||||
},
|
||||
"google/gemini-2.5-flash-image-free": {
|
||||
"description": "Безплатна версия на Gemini 2.5 Flash Image, поддържа ограничено количество мултимодално генериране."
|
||||
},
|
||||
"google/gemini-2.5-flash-image-preview": {
|
||||
"description": "Gemini 2.5 Flash експериментален модел, поддържащ генериране на изображения."
|
||||
},
|
||||
"google/gemini-2.5-flash-lite": {
|
||||
"description": "Gemini 2.5 Flash-Lite е балансиран, с ниска латентност модел с конфигурируем бюджет за мислене и свързаност с инструменти (например Google Search grounding и изпълнение на код). Поддържа мултимодален вход и предлага контекстен прозорец от 1 милион токена."
|
||||
"description": "Gemini 2.5 Flash Lite е олекотена версия на Gemini 2.5, оптимизирана за ниска латентност и разходи, подходяща за сценарии с висок трафик."
|
||||
},
|
||||
"google/gemini-2.5-flash-preview": {
|
||||
"description": "Gemini 2.5 Flash е най-напредналият основен модел на Google, проектиран за напреднали разсъждения, кодиране, математика и научни задачи. Той включва вградена способност за \"мислене\", което му позволява да предоставя отговори с по-висока точност и детайлна обработка на контекста.\n\nЗабележка: Този модел има два варианта: с мислене и без мислене. Цените на изхода значително варират в зависимост от активирането на способността за мислене. Ако изберете стандартния вариант (без суфикс \":thinking\"), моделът ще избягва генерирането на токени за мислене.\n\nЗа да се възползвате от способността за мислене и да получите токени за мислене, трябва да изберете варианта \":thinking\", което ще доведе до по-високи цени на изхода за мислене.\n\nОсвен това, Gemini 2.5 Flash може да бъде конфигуриран чрез параметъра \"максимален брой токени за разсъждение\", както е описано в документацията (https://openrouter.ai/docs/use-cases/reasoning-tokens#max-tokens-for-reasoning)."
|
||||
@@ -1677,11 +1716,26 @@
|
||||
"description": "Gemini 2.5 Flash е най-напредналият основен модел на Google, проектиран за напреднали разсъждения, кодиране, математика и научни задачи. Той включва вградена способност за \"мислене\", което му позволява да предоставя отговори с по-висока точност и детайлна обработка на контекста.\n\nЗабележка: Този модел има два варианта: с мислене и без мислене. Цените на изхода значително варират в зависимост от активирането на способността за мислене. Ако изберете стандартния вариант (без суфикс \":thinking\"), моделът ще избягва генерирането на токени за мислене.\n\nЗа да се възползвате от способността за мислене и да получите токени за мислене, трябва да изберете варианта \":thinking\", което ще доведе до по-високи цени на изхода за мислене.\n\nОсвен това, Gemini 2.5 Flash може да бъде конфигуриран чрез параметъра \"максимален брой токени за разсъждение\", както е описано в документацията (https://openrouter.ai/docs/use-cases/reasoning-tokens#max-tokens-for-reasoning)."
|
||||
},
|
||||
"google/gemini-2.5-pro": {
|
||||
"description": "Gemini 2.5 Pro е нашият най-напреднал разсъдъчен Gemini модел, способен да решава сложни проблеми. Той разполага с контекстен прозорец от 2 милиона токена и поддържа мултимодален вход, включително текст, изображения, аудио, видео и PDF документи."
|
||||
"description": "Gemini 2.5 Pro е флагманският модел за разсъждение на Google, поддържащ дълъг контекст и сложни задачи."
|
||||
},
|
||||
"google/gemini-2.5-pro-free": {
|
||||
"description": "Безплатна версия на Gemini 2.5 Pro, поддържа ограничен мултимодален дълъг контекст, подходяща за тестване и леки работни потоци."
|
||||
},
|
||||
"google/gemini-2.5-pro-preview": {
|
||||
"description": "Gemini 2.5 Pro Preview е най-усъвършенстваният мисловен модел на Google, способен да извършва разсъждения върху сложни проблеми в областта на кодирането, математиката и STEM, както и да анализира големи набори от данни, кодови бази и документи с дълъг контекст."
|
||||
},
|
||||
"google/gemini-3-pro-image-preview": {
|
||||
"description": "Gemini 3 Pro Image (Nano Banana Pro) е модел на Google за генериране на изображения, който поддържа мултимодален диалог."
|
||||
},
|
||||
"google/gemini-3-pro-image-preview-free": {
|
||||
"description": "Безплатна версия на Gemini 3 Pro Image, поддържа ограничено количество мултимодално генериране."
|
||||
},
|
||||
"google/gemini-3-pro-preview": {
|
||||
"description": "Gemini 3 Pro е следващото поколение мултимодален модел за разсъждение от серията Gemini, способен да разбира текст, аудио, изображения, видео и други входове, и да обработва сложни задачи и големи кодови бази."
|
||||
},
|
||||
"google/gemini-3-pro-preview-free": {
|
||||
"description": "Безплатна предварителна версия на Gemini 3 Pro, предлага същите мултимодални възможности за разбиране и разсъждение като стандартната версия, но с ограничения в безплатния лимит и скорост, по-подходяща за тестване и нискочестотна употреба."
|
||||
},
|
||||
"google/gemini-embedding-001": {
|
||||
"description": "Водещ модел за вграждане с отлична производителност при задачи на английски, многоезични и кодови задачи."
|
||||
},
|
||||
@@ -1907,6 +1961,12 @@
|
||||
"grok-4-0709": {
|
||||
"description": "Grok 4 от xAI, с мощни способности за разсъждение."
|
||||
},
|
||||
"grok-4-1-fast-non-reasoning": {
|
||||
"description": "Модерен мултимодален модел, специално оптимизиран за високоефективно използване на агентски инструменти."
|
||||
},
|
||||
"grok-4-1-fast-reasoning": {
|
||||
"description": "Модерен мултимодален модел, специално оптимизиран за високоефективно използване на агентски инструменти."
|
||||
},
|
||||
"grok-4-fast-non-reasoning": {
|
||||
"description": "С удоволствие представяме Grok 4 Fast, нашият най-нов напредък в модели за разсъждение с висока ефективност на разходите."
|
||||
},
|
||||
@@ -2051,21 +2111,36 @@
|
||||
"inception/mercury-coder-small": {
|
||||
"description": "Mercury Coder Small е идеален за задачи по генериране, отстраняване на грешки и рефакториране на код с минимална латентност."
|
||||
},
|
||||
"inclusionAI/Ling-1T": {
|
||||
"description": "Ling-1T е първият флагмански non-thinking модел от серията „Ling 2.0“, с общо 1 трилион параметри и около 50 милиарда активни параметри на токен. Изграден върху архитектурата Ling 2.0, Ling-1T цели да преодолее границите на ефективното разсъждение и мащабируемото познание. Ling-1T-base е обучен върху над 20 трилиона висококачествени, интензивно логически токени."
|
||||
},
|
||||
"inclusionAI/Ling-flash-2.0": {
|
||||
"description": "Ling-flash-2.0 е третият модел от серията Ling 2.0 архитектури, публикуван от екипа на Ant Group Bailing. Това е модел с хибридни експерти (MoE) с общо 100 милиарда параметри, но при всеки токен активира само 6.1 милиарда параметри (без вграждания – 4.8 милиарда). Като леко конфигуриран модел, Ling-flash-2.0 показва в множество авторитетни оценки производителност, сравнима или дори превъзхождаща плътни (Dense) модели с 40 милиарда параметри и по-големи MoE модели. Моделът е предназначен да изследва пътища за висока ефективност чрез изключителен дизайн на архитектурата и стратегии за обучение, в контекста на общоприетото схващане, че „големият модел е равен на големи параметри“."
|
||||
},
|
||||
"inclusionAI/Ling-mini-2.0": {
|
||||
"description": "Ling-mini-2.0 е малък, но високопроизводителен голям езиков модел, базиран на MoE архитектура. Той има общо 16 милиарда параметри, но при всеки токен активира само 1.4 милиарда (без вграждания – 789 милиона), което осигурява изключително бърза генерация. Благодарение на ефективния MoE дизайн и големия обем висококачествени тренировъчни данни, въпреки че активираните параметри са само 1.4 милиарда, Ling-mini-2.0 демонстрира върхова производителност в downstream задачи, сравнима с плътни LLM под 10 милиарда параметри и по-големи MoE модели."
|
||||
},
|
||||
"inclusionAI/Ring-1T": {
|
||||
"description": "Ring-1T е отворен модел за мислене с трилион параметри, разработен от екипа на Bailing. Базиран е на архитектурата Ling 2.0 и основния модел Ling-1T-base, с общо 1 трилион параметри и 50 милиарда активни параметри, поддържащ контекстуален прозорец до 128K. Моделът е оптимизиран чрез мащабно обучение с проверими награди и подсилено обучение."
|
||||
},
|
||||
"inclusionAI/Ring-flash-2.0": {
|
||||
"description": "Ring-flash-2.0 е високопроизводителен мисловен модел, дълбоко оптимизиран на базата на Ling-flash-2.0-base. Той използва MoE архитектура с общо 100 милиарда параметри, но при всяко извод активира само 6.1 милиарда параметри. Моделът решава нестабилността на големите MoE модели при обучение с подсилено учене (RL) чрез уникалния алгоритъм icepop, което позволява непрекъснато подобряване на сложните разсъждения при дългосрочно обучение. Ring-flash-2.0 постига значителни пробиви в множество трудни бенчмаркове като математически състезания, генериране на код и логически разсъждения. Неговата производителност не само превъзхожда топ плътни модели с по-малко от 40 милиарда параметри, но и се сравнява с по-големи отворени MoE модели и затворени високопроизводителни мисловни модели. Въпреки че е фокусиран върху сложни разсъждения, моделът се представя отлично и в творческо писане. Благодарение на ефективния си архитектурен дизайн, Ring-flash-2.0 осигурява висока производителност и бърз извод, значително намалявайки разходите за внедряване на мисловни модели при висока паралелност."
|
||||
},
|
||||
"inclusionai/ling-1t": {
|
||||
"description": "Ling-1T е MoE модел с 1 трилион параметъра от inclusionAI, оптимизиран за интензивни логически задачи и мащабен контекст."
|
||||
},
|
||||
"inclusionai/ling-flash-2.0": {
|
||||
"description": "Ling-flash-2.0 е MoE модел от inclusionAI, оптимизиран за ефективност и логическа производителност, подходящ за средни и големи задачи."
|
||||
},
|
||||
"inclusionai/ling-mini-2.0": {
|
||||
"description": "Ling-mini-2.0 е олекотен MoE модел от inclusionAI, който значително намалява разходите, като запазва логическите си способности."
|
||||
},
|
||||
"inclusionai/ming-flash-omini-preview": {
|
||||
"description": "Ming-flash-omni Preview е мултимодален модел от inclusionAI, поддържащ вход от глас, изображения и видео, с подобрени възможности за визуализация и разпознаване на реч."
|
||||
},
|
||||
"inclusionai/ring-1t": {
|
||||
"description": "Ring-1T е MoE модел за разсъждение с трилион параметри от inclusionAI, подходящ за мащабни логически и изследователски задачи."
|
||||
},
|
||||
"inclusionai/ring-flash-2.0": {
|
||||
"description": "Ring-flash-2.0 е вариант на модела Ring от inclusionAI, насочен към сценарии с висок трафик, с акцент върху скорост и разходна ефективност."
|
||||
},
|
||||
"inclusionai/ring-mini-2.0": {
|
||||
"description": "Ring-mini-2.0 е олекотена версия на MoE модела от inclusionAI с висока пропускателна способност, предназначена за паралелни сценарии."
|
||||
},
|
||||
"internlm/internlm2_5-7b-chat": {
|
||||
"description": "InternLM2.5 предлага интелигентни решения за диалог в множество сценарии."
|
||||
},
|
||||
@@ -2117,6 +2192,12 @@
|
||||
"kimi-k2-instruct": {
|
||||
"description": "Kimi K2 Instruct, официален модел за извеждане от Kimi, поддържащ дълъг контекст, програмиране, въпроси и отговори и други сценарии."
|
||||
},
|
||||
"kimi-k2-thinking": {
|
||||
"description": "Моделът kimi-k2-thinking, предоставен от Moonshot AI, е мисловен модел с универсални агентни и логически способности. Той е отличен в дълбокото разсъждение и може да използва инструменти на няколко стъпки, за да помага при решаването на различни сложни проблеми."
|
||||
},
|
||||
"kimi-k2-thinking-turbo": {
|
||||
"description": "Ускорена версия на модела K2 за дълбоко разсъждение, поддържа 256k контекст и предлага скорост на изход от 60–100 токена в секунда при запазване на дълбоките логически способности."
|
||||
},
|
||||
"kimi-k2-turbo-preview": {
|
||||
"description": "Kimi-k2 е базов модел с MoE архитектура, който притежава изключителни възможности за работа с код и агентни функции. Общият брой параметри е 1T, а активните параметри са 32B. В бенчмарковете за основни категории като общо знание и разсъждение, програмиране, математика и агентни задачи, моделът K2 превъзхожда другите водещи отворени модели."
|
||||
},
|
||||
@@ -2129,6 +2210,9 @@
|
||||
"kimi-thinking-preview": {
|
||||
"description": "Моделът kimi-thinking-preview, предоставен от „Тъмната страна на Луната“, е мултимодален мисловен модел с възможности за мултимодално и общо разсъждение, който е експерт в дълбокото разсъждение и помага за решаването на по-сложни задачи."
|
||||
},
|
||||
"kuaishou/kat-coder-pro-v1": {
|
||||
"description": "KAT-Coder-Pro-V1 (временно безплатен) е фокусиран върху разбиране на код и автоматизирано програмиране, предназначен за ефективни задачи с програмен агент."
|
||||
},
|
||||
"learnlm-1.5-pro-experimental": {
|
||||
"description": "LearnLM е експериментален езиков модел, специфичен за задачи, обучен да отговаря на принципите на научното обучение, способен да следва системни инструкции в учебни и обучителни сценарии, да действа като експертен ментор и др."
|
||||
},
|
||||
@@ -2225,6 +2309,9 @@
|
||||
"megrez-3b-instruct": {
|
||||
"description": "Megrez 3B Instruct е ефективен модел с малък брой параметри, разработен от Wuwen Xinqiong."
|
||||
},
|
||||
"meituan/longcat-flash-chat": {
|
||||
"description": "Longcat Flash Chat е с отворен код от Meituan и представлява базов модел без мисловни процеси, оптимизиран за диалогови взаимодействия и задачи на интелигентни агенти, с изключителна ефективност при използване на инструменти и в сложни многократни взаимодействия."
|
||||
},
|
||||
"meta-llama-3-70b-instruct": {
|
||||
"description": "Мощен модел с 70 милиарда параметри, отличаващ се в разсъждения, кодиране и широки езикови приложения."
|
||||
},
|
||||
@@ -2456,6 +2543,12 @@
|
||||
"minimax-m2": {
|
||||
"description": "MiniMax M2 е ефективен голям езиков модел, създаден специално за кодиране и работни процеси с агенти."
|
||||
},
|
||||
"minimax/minimax-m2": {
|
||||
"description": "MiniMax-M2 е високоефективен модел с отлично представяне при кодиране и агентски задачи, подходящ за различни инженерни приложения."
|
||||
},
|
||||
"minimaxai/minimax-m2": {
|
||||
"description": "MiniMax-M2 е компактен, бърз и икономичен хибриден експертен (MoE) модел с общо 230 милиарда параметъра и 10 милиарда активни параметъра, създаден за постигане на върхова производителност при кодиране и задачи, свързани с интелигентни агенти, като същевременно поддържа силен общ интелект. Моделът се отличава с отлична работа при редактиране на множество файлове, затворен цикъл кодиране-изпълнение-поправка, тестване и валидиране на поправки, както и при сложни дълговерижни инструментални процеси, което го прави идеален избор за работния процес на разработчиците."
|
||||
},
|
||||
"ministral-3b-latest": {
|
||||
"description": "Ministral 3B е световен лидер сред моделите на Mistral."
|
||||
},
|
||||
@@ -2600,12 +2693,21 @@
|
||||
"moonshotai/kimi-k2": {
|
||||
"description": "Kimi K2 е голям смесен експертен (MoE) езиков модел с 1 трилион общи параметри и 32 милиарда активни параметри на преден проход, разработен от Moonshot AI. Оптимизиран е за агентски способности, включително усъвършенствано използване на инструменти, разсъждения и синтез на код."
|
||||
},
|
||||
"moonshotai/kimi-k2-0711": {
|
||||
"description": "Kimi K2 0711 е Instruct версия от серията Kimi, подходяща за висококачествено програмиране и използване на инструменти."
|
||||
},
|
||||
"moonshotai/kimi-k2-0905": {
|
||||
"description": "Моделът kimi-k2-0905-preview има контекстна дължина от 256k, с по-силни способности за агентно кодиране, по-изразителна естетика и практичност на фронтенд кода, както и по-добро разбиране на контекста."
|
||||
"description": "Kimi K2 0905 е актуализация от серията Kimi, подобряваща контекстуалната обработка и логическите възможности, оптимизирана за кодиране."
|
||||
},
|
||||
"moonshotai/kimi-k2-instruct-0905": {
|
||||
"description": "Моделът kimi-k2-0905-preview има контекстна дължина от 256k, с по-силни способности за агентно кодиране, по-изразителна естетика и практичност на фронтенд кода, както и по-добро разбиране на контекста."
|
||||
},
|
||||
"moonshotai/kimi-k2-thinking": {
|
||||
"description": "Kimi K2 Thinking е модел за дълбоко разсъждение, оптимизиран от Moonshot, с универсални способности на агент."
|
||||
},
|
||||
"moonshotai/kimi-k2-thinking-turbo": {
|
||||
"description": "Kimi K2 Thinking Turbo е ускорена версия на Kimi K2 Thinking, която запазва дълбоките логически възможности при значително по-ниска латентност."
|
||||
},
|
||||
"morph/morph-v3-fast": {
|
||||
"description": "Morph предоставя специализиран AI модел, който прилага препоръчаните от водещи модели (като Claude или GPT-4o) промени в кода към съществуващите ви файлове БЪРЗО - над 4500+ токена в секунда. Той служи като последна стъпка в AI кодовия работен поток. Поддържа 16k входни и 16k изходни токена."
|
||||
},
|
||||
@@ -2688,28 +2790,49 @@
|
||||
"description": "gpt-4-turbo от OpenAI притежава обширни общи знания и експертиза в различни области, което му позволява да следва сложни инструкции на естествен език и да решава точно трудни проблеми. Знанията му са актуални до април 2023 г., а контекстният прозорец е 128 000 токена."
|
||||
},
|
||||
"openai/gpt-4.1": {
|
||||
"description": "GPT 4.1 е водещият модел на OpenAI, подходящ за сложни задачи. Изключително подходящ за решаване на проблеми в различни области."
|
||||
"description": "Серията GPT-4.1 предлага по-голям контекст и по-силни инженерни и логически способности."
|
||||
},
|
||||
"openai/gpt-4.1-mini": {
|
||||
"description": "GPT 4.1 mini постига баланс между интелигентност, скорост и цена, което го прави привлекателен модел за много случаи на употреба."
|
||||
"description": "GPT-4.1 Mini предлага по-ниска латентност и по-добро съотношение цена/качество, подходящ за средни по дължина контексти."
|
||||
},
|
||||
"openai/gpt-4.1-nano": {
|
||||
"description": "GPT-4.1 nano е най-бързият и икономичен модел от серията GPT 4.1."
|
||||
"description": "GPT-4.1 Nano е изключително икономичен и с ниска латентност, подходящ за чести кратки диалози или класификационни задачи."
|
||||
},
|
||||
"openai/gpt-4o": {
|
||||
"description": "GPT-4o от OpenAI притежава обширни общи знания и експертиза в различни области, способен да следва сложни инструкции на естествен език и да решава точно трудни проблеми. Предлага производителност, съпоставима с GPT-4 Turbo, но с по-бърз и по-евтин API."
|
||||
"description": "Серията GPT-4o е Omni модел на OpenAI, поддържащ вход от текст + изображение и изход в текстов формат."
|
||||
},
|
||||
"openai/gpt-4o-mini": {
|
||||
"description": "GPT-4o mini от OpenAI е техният най-напреднал и икономичен малък модел. Той е мултимодален (приема текст или изображения като вход и генерира текст) и е по-интелигентен от gpt-3.5-turbo, като същевременно е също толкова бърз."
|
||||
"description": "GPT-4o-mini е бърза и компактна версия на GPT-4o, подходяща за нисколатентни мултимодални сценарии."
|
||||
},
|
||||
"openai/gpt-5": {
|
||||
"description": "GPT-5 е водещият езиков модел на OpenAI, отличаващ се в сложни разсъждения, обширни знания за реалния свят, задачи с интензивен код и многостъпкови агентски задачи."
|
||||
"description": "GPT-5 е високопроизводителен модел на OpenAI, подходящ за широк спектър от производствени и изследователски задачи."
|
||||
},
|
||||
"openai/gpt-5-chat": {
|
||||
"description": "GPT-5 Chat е подмодел на GPT-5, оптимизиран за диалогови сценарии с по-ниска латентност и по-добро взаимодействие."
|
||||
},
|
||||
"openai/gpt-5-codex": {
|
||||
"description": "GPT-5-Codex е вариант на GPT-5, допълнително оптимизиран за програмиране, подходящ за мащабни работни потоци с код."
|
||||
},
|
||||
"openai/gpt-5-mini": {
|
||||
"description": "GPT-5 mini е оптимизиран по отношение на разходите модел, който се представя отлично при задачи за разсъждение и чат. Предлага най-добрия баланс между скорост, цена и способности."
|
||||
"description": "GPT-5 Mini е олекотена версия от семейството GPT-5, предназначена за нисколатентни и нискобюджетни приложения."
|
||||
},
|
||||
"openai/gpt-5-nano": {
|
||||
"description": "GPT-5 nano е модел с висок пропускателен капацитет, който се справя отлично с прости инструкции или задачи за класификация."
|
||||
"description": "GPT-5 Nano е ултракомпактна версия от семейството, подходяща за сценарии с изключително високи изисквания към разходи и латентност."
|
||||
},
|
||||
"openai/gpt-5-pro": {
|
||||
"description": "GPT-5 Pro е флагманският модел на OpenAI, предлагащ усъвършенствано разсъждение, генериране на код и корпоративни функции, с поддръжка на маршрутизиране при тестване и стриктни политики за сигурност."
|
||||
},
|
||||
"openai/gpt-5.1": {
|
||||
"description": "GPT-5.1 е най-новият флагмански модел от серията GPT-5, с значителни подобрения в общото разсъждение, следване на инструкции и естественост на диалога, подходящ за широк спектър от задачи."
|
||||
},
|
||||
"openai/gpt-5.1-chat": {
|
||||
"description": "GPT-5.1 Chat е лек член на семейството GPT-5.1, оптимизиран за диалози с ниска латентност, като същевременно запазва силни логически и изпълнителни способности."
|
||||
},
|
||||
"openai/gpt-5.1-codex": {
|
||||
"description": "GPT-5.1-Codex е вариант на GPT-5.1, оптимизиран за софтуерно инженерство и работни потоци с код, подходящ за мащабни рефакторинги, сложна отстраняване на грешки и дългосрочно автономно кодиране."
|
||||
},
|
||||
"openai/gpt-5.1-codex-mini": {
|
||||
"description": "GPT-5.1-Codex-Mini е компактна и ускорена версия на GPT-5.1-Codex, по-подходяща за кодиране в среди, чувствителни към латентност и разходи."
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
"description": "Изключително способен универсален голям езиков модел с мощни и контролируеми способности за разсъждение."
|
||||
@@ -2736,7 +2859,7 @@
|
||||
"description": "o3-mini high е версия с високо ниво на разсъждение, която предлага висока интелигентност при същите разходи и цели за закъснение като o1-mini."
|
||||
},
|
||||
"openai/o4-mini": {
|
||||
"description": "o4-mini на OpenAI предлага бързо и икономично разсъждение с отлична производителност за своя размер, особено в математика (най-добър в AIME бенчмарка), кодиране и визуални задачи."
|
||||
"description": "OpenAI o4-mini е компактен и ефективен логически модел, подходящ за нисколатентни приложения."
|
||||
},
|
||||
"openai/o4-mini-high": {
|
||||
"description": "o4-mini версия с високо ниво на извеждане, оптимизирана за бързо и ефективно извеждане, показваща изключителна ефективност и производителност в задачи по кодиране и визуализация."
|
||||
@@ -2940,7 +3063,7 @@
|
||||
"description": "Мощен среден модел за код, поддържащ 32K дължина на контекста, специализиран в многоезично програмиране."
|
||||
},
|
||||
"qwen/qwen3-14b": {
|
||||
"description": "Qwen3-14B е плътен езиков модел с 14.8 милиарда параметри от серията Qwen3, проектиран за сложни разсъждения и ефективен диалог. Той поддържа безпроблемно преминаване между режим на \"разсъждение\" за математика, програмиране и логическо разсъждение и режим на \"неразсъждение\" за общи разговори. Моделът е фино настроен за следване на инструкции, използване на инструменти за агенти, креативно писане и многоезични задачи на над 100 езика и диалекта. Той нативно обработва контекст от 32K токена и може да бъде разширен до 131K токена с помощта на разширение, базирано на YaRN."
|
||||
"description": "Qwen3-14B е 14B версия от серията Qwen, подходяща за стандартни логически и диалогови задачи."
|
||||
},
|
||||
"qwen/qwen3-14b:free": {
|
||||
"description": "Qwen3-14B е плътен езиков модел с 14.8 милиарда параметри от серията Qwen3, проектиран за сложни разсъждения и ефективен диалог. Той поддържа безпроблемно преминаване между режим на \"разсъждение\" за математика, програмиране и логическо разсъждение и режим на \"неразсъждение\" за общи разговори. Моделът е фино настроен за следване на инструкции, използване на инструменти за агенти, креативно писане и многоезични задачи на над 100 езика и диалекта. Той нативно обработва контекст от 32K токена и може да бъде разширен до 131K токена с помощта на разширение, базирано на YaRN."
|
||||
@@ -2948,6 +3071,12 @@
|
||||
"qwen/qwen3-235b-a22b": {
|
||||
"description": "Qwen3-235B-A22B е модел с 235B параметри, разработен от Qwen, с експертна смесена (MoE) архитектура, активираща 22B параметри при всяко напредване. Той поддържа безпроблемно преминаване между режим на \"разсъждение\" за сложни разсъждения, математика и кодиране и режим на \"неразсъждение\" за ефективен общ диалог. Моделът демонстрира силни способности за разсъждение, многоезична поддръжка (над 100 езика и диалекта), напреднало следване на инструкции и способности за извикване на инструменти за агенти. Той нативно обработва контекстен прозорец от 32K токена и може да бъде разширен до 131K токена с помощта на разширение, базирано на YaRN."
|
||||
},
|
||||
"qwen/qwen3-235b-a22b-2507": {
|
||||
"description": "Qwen3-235B-A22B-Instruct-2507 е Instruct версия от серията Qwen3, съчетаваща многоезични инструкции и дълъг контекст."
|
||||
},
|
||||
"qwen/qwen3-235b-a22b-thinking-2507": {
|
||||
"description": "Qwen3-235B-A22B-Thinking-2507 е Thinking вариант от Qwen3, подсилен за сложни математически и логически задачи."
|
||||
},
|
||||
"qwen/qwen3-235b-a22b:free": {
|
||||
"description": "Qwen3-235B-A22B е модел с 235B параметри, разработен от Qwen, с експертна смесена (MoE) архитектура, активираща 22B параметри при всяко напредване. Той поддържа безпроблемно преминаване между режим на \"разсъждение\" за сложни разсъждения, математика и кодиране и режим на \"неразсъждение\" за ефективен общ диалог. Моделът демонстрира силни способности за разсъждение, многоезична поддръжка (над 100 езика и диалекта), напреднало следване на инструкции и способности за извикване на инструменти за агенти. Той нативно обработва контекстен прозорец от 32K токена и може да бъде разширен до 131K токена с помощта на разширение, базирано на YaRN."
|
||||
},
|
||||
@@ -2966,6 +3095,21 @@
|
||||
"qwen/qwen3-8b:free": {
|
||||
"description": "Qwen3-8B е плътен езиков модел с 8.2 милиарда параметри от серията Qwen3, проектиран за задачи с интензивно разсъждение и ефективен диалог. Той поддържа безпроблемно преминаване между режим на \"разсъждение\" за математика, програмиране и логическо разсъждение и режим на \"неразсъждение\" за общи разговори. Моделът е фино настроен за следване на инструкции, интеграция на агенти, креативно писане и многоезично използване на над 100 езика и диалекта. Той нативно поддържа контекстен прозорец от 32K токена и може да бъде разширен до 131K токена чрез YaRN."
|
||||
},
|
||||
"qwen/qwen3-coder": {
|
||||
"description": "Qwen3-Coder е генератор на код от семейството Qwen3, специализиран в разбиране и генериране на код в дълги документи."
|
||||
},
|
||||
"qwen/qwen3-coder-plus": {
|
||||
"description": "Qwen3-Coder-Plus е специално оптимизиран агент за кодиране от серията Qwen, поддържащ по-сложни инструментални операции и дългосрочни сесии."
|
||||
},
|
||||
"qwen/qwen3-max": {
|
||||
"description": "Qwen3 Max е висок клас логически модел от серията Qwen3, подходящ за многоезично разсъждение и интеграция с инструменти."
|
||||
},
|
||||
"qwen/qwen3-max-preview": {
|
||||
"description": "Qwen3 Max (предварителен преглед) е Max версията от серията Qwen, насочена към усъвършенствано разсъждение и интеграция с инструменти."
|
||||
},
|
||||
"qwen/qwen3-vl-plus": {
|
||||
"description": "Qwen3 VL-Plus е визуално подсилена версия от Qwen3, подобряваща мултимодалното разсъждение и обработката на видео."
|
||||
},
|
||||
"qwen2": {
|
||||
"description": "Qwen2 е новото поколение голям езиков модел на Alibaba, предлагащ отлична производителност за разнообразни приложения."
|
||||
},
|
||||
@@ -3260,9 +3404,6 @@
|
||||
"step-r1-v-mini": {
|
||||
"description": "Този модел е мощен модел за разсъждение с отлични способности за разбиране на изображения, способен да обработва информация от изображения и текст, и след дълбочинно разсъждение да генерира текстово съдържание. Моделът показва изключителни резултати в областта на визуалните разсъждения, като същевременно притежава първокласни способности в математиката, кода и текстовите разсъждения. Дължината на контекста е 100k."
|
||||
},
|
||||
"step3": {
|
||||
"description": "Step3 е мултимодален модел, разработен от StepStar, с мощни способности за визуално разбиране."
|
||||
},
|
||||
"stepfun-ai/step3": {
|
||||
"description": "Step3 е авангарден мултимодален модел за разсъждение, публикуван от StepFun (阶跃星辰). Той е изграден върху архитектура на смес от експерти (MoE) с общо 321 милиарда параметъра и 38 милиарда активни параметъра. Моделът е с енд-ту-енд дизайн, целящ минимизиране на разходите за декодиране, като същевременно предоставя водещи резултати във визуално-лингвистичното разсъждение. Чрез кооперативния дизайн на многоматрично факторизирано внимание (MFA) и декуплиране на внимание и FFN (AFD), Step3 поддържа отлична ефективност както на флагмански, така и на по-бюджетни ускорители. По време на предварителното обучение Step3 е обработил над 20 трилиона текстови токена и 4 трилиона смесени текстово-изображенчески токена, обхващайки повече от десет езика. Моделът постига водещи резултати сред отворените модели в множество бенчмаркове, включително математика, код и мултимодални задачи."
|
||||
},
|
||||
@@ -3344,6 +3485,9 @@
|
||||
"vercel/v0-1.5-md": {
|
||||
"description": "Достъп до модела зад v0 за генериране, поправка и оптимизация на модерни уеб приложения с разсъждения, специфични за рамки, и актуални знания."
|
||||
},
|
||||
"volcengine/doubao-seed-code": {
|
||||
"description": "Doubao-Seed-Code е голям модел на ByteDance Volcano Engine, оптимизиран за Agentic Programming, с отлично представяне в множество програмни и агентски бенчмаркове и поддръжка на 256K контекст."
|
||||
},
|
||||
"wan2.2-t2i-flash": {
|
||||
"description": "Wanxiang 2.2 експресна версия, най-новият модел към момента. Комплексно подобрение в креативност, стабилност и реализъм, с бърза скорост на генериране и висока цена-ефективност."
|
||||
},
|
||||
@@ -3371,6 +3515,24 @@
|
||||
"wizardlm2:8x22b": {
|
||||
"description": "WizardLM 2 е езиков модел, предоставен от Microsoft AI, който се отличава в сложни диалози, многоезичност, разсъждение и интелигентни асистенти."
|
||||
},
|
||||
"x-ai/grok-4": {
|
||||
"description": "Grok 4 е флагманският логически модел на xAI, предлагащ мощни логически и мултимодални възможности."
|
||||
},
|
||||
"x-ai/grok-4-fast": {
|
||||
"description": "Grok 4 Fast е високопроизводителен и икономичен модел на xAI (с поддръжка на 2M контекстен прозорец), подходящ за приложения с висока едновременност и дълъг контекст."
|
||||
},
|
||||
"x-ai/grok-4-fast-non-reasoning": {
|
||||
"description": "Grok 4 Fast (Non-Reasoning) е високопроизводителен и икономичен мултимодален модел на xAI (с поддръжка на 2M контекстен прозорец), предназначен за сценарии, чувствителни към латентност и разходи, но без нужда от вътрешно разсъждение. Съществува паралелно с reasoning версията на Grok 4 Fast и позволява активиране на разсъждение чрез параметъра reasoning enable в API. Подадените заявки и отговори може да бъдат използвани от xAI или OpenRouter за подобряване на бъдещи модели."
|
||||
},
|
||||
"x-ai/grok-4.1-fast": {
|
||||
"description": "Grok 4 Fast е високопроизводителен и икономичен модел на xAI (с поддръжка на 2M контекстен прозорец), подходящ за приложения с висока едновременност и дълъг контекст."
|
||||
},
|
||||
"x-ai/grok-4.1-fast-non-reasoning": {
|
||||
"description": "Grok 4 Fast (Non-Reasoning) е високопроизводителен и икономичен мултимодален модел на xAI (с поддръжка на 2M контекстен прозорец), предназначен за сценарии, чувствителни към латентност и разходи, но без нужда от вътрешно разсъждение. Съществува паралелно с reasoning версията на Grok 4 Fast и позволява активиране на разсъждение чрез параметъра reasoning enable в API. Подадените заявки и отговори може да бъдат използвани от xAI или OpenRouter за подобряване на бъдещи модели."
|
||||
},
|
||||
"x-ai/grok-code-fast-1": {
|
||||
"description": "Grok Code Fast 1 е бърз кодов модел на xAI, предлагащ четим и инженерно пригоден изход."
|
||||
},
|
||||
"x1": {
|
||||
"description": "Моделът Spark X1 ще бъде допълнително обновен, като на базата на водещите в страната резултати в математически задачи, ще постигне ефекти в общи задачи като разсъждение, генериране на текст и разбиране на език, сравними с OpenAI o1 и DeepSeek R1."
|
||||
},
|
||||
@@ -3431,6 +3593,15 @@
|
||||
"yi-vision-v2": {
|
||||
"description": "Модел за сложни визуални задачи, предлагащ висока производителност в разбирането и анализа на базата на множество изображения."
|
||||
},
|
||||
"z-ai/glm-4.5": {
|
||||
"description": "GLM 4.5 е флагманският модел на Z.AI, поддържащ хибриден режим на разсъждение и оптимизиран за инженерни и дългоконтекстови задачи."
|
||||
},
|
||||
"z-ai/glm-4.5-air": {
|
||||
"description": "GLM 4.5 Air е олекотена версия на GLM 4.5, подходяща за сценарии с ограничен бюджет, като същевременно запазва силни логически възможности."
|
||||
},
|
||||
"z-ai/glm-4.6": {
|
||||
"description": "GLM 4.6 е флагманският модел на Z.AI, с разширен контекст и подобрени кодиращи способности."
|
||||
},
|
||||
"zai-org/GLM-4.5": {
|
||||
"description": "GLM-4.5 е базов модел, специално създаден за интелигентни агенти, използващ архитектура с микс от експерти (Mixture-of-Experts). Той е дълбоко оптимизиран за използване на инструменти, уеб браузване, софтуерно инженерство и фронтенд програмиране, и поддържа безпроблемна интеграция с кодови агенти като Claude Code и Roo Code. GLM-4.5 използва смесен режим на разсъждение, подходящ за сложни и ежедневни приложения."
|
||||
},
|
||||
@@ -3451,5 +3622,8 @@
|
||||
},
|
||||
"zai/glm-4.5v": {
|
||||
"description": "GLM-4.5V е изграден върху основния модел GLM-4.5-Air, наследявайки проверените технологии на GLM-4.1V-Thinking и постига ефективно мащабиране чрез мощната MoE архитектура с 106 милиарда параметри."
|
||||
},
|
||||
"zenmux/auto": {
|
||||
"description": "Функцията за автоматично маршрутизиране на ZenMux автоматично избира най-добрия модел с най-добро съотношение цена/качество и представяне според съдържанието на вашата заявка от поддържаните модели."
|
||||
}
|
||||
}
|
||||
|
||||
+34
-22
@@ -1,4 +1,38 @@
|
||||
{
|
||||
"builtins": {
|
||||
"lobe-knowledge-base": {
|
||||
"apiName": {
|
||||
"readKnowledge": "Прочети съдържанието на базата знания",
|
||||
"searchKnowledgeBase": "Търси в базата знания"
|
||||
},
|
||||
"title": "База знания"
|
||||
},
|
||||
"lobe-local-system": {
|
||||
"apiName": {
|
||||
"editLocalFile": "Редактирай файл",
|
||||
"getCommandOutput": "Вземи изхода от кода",
|
||||
"globLocalFiles": "Търси съвпадащи файлове",
|
||||
"grepContent": "Търси съдържание",
|
||||
"killCommand": "Прекрати изпълнението на кода",
|
||||
"listLocalFiles": "Преглед на списъка с файлове",
|
||||
"moveLocalFiles": "Премести файлове",
|
||||
"readLocalFile": "Прочети съдържанието на файла",
|
||||
"renameLocalFile": "Преименувай",
|
||||
"runCommand": "Изпълни код",
|
||||
"searchLocalFiles": "Търси файлове",
|
||||
"writeLocalFile": "Запиши файл"
|
||||
},
|
||||
"title": "Локална система"
|
||||
},
|
||||
"lobe-web-browsing": {
|
||||
"apiName": {
|
||||
"crawlMultiPages": "Прочети съдържание от няколко страници",
|
||||
"crawlSinglePage": "Прочети съдържание от страница",
|
||||
"search": "Търси страница"
|
||||
},
|
||||
"title": "Търсене в интернет"
|
||||
}
|
||||
},
|
||||
"confirm": "Потвърждавам",
|
||||
"debug": {
|
||||
"arguments": "Параметри на извикване",
|
||||
@@ -251,23 +285,6 @@
|
||||
"content": "Извикване на плъгина...",
|
||||
"plugin": "Плъгинът работи..."
|
||||
},
|
||||
"localSystem": {
|
||||
"apiName": {
|
||||
"editLocalFile": "Редактиране на файл",
|
||||
"getCommandOutput": "Получаване на изход от командата",
|
||||
"globLocalFiles": "Търсене на съвпадащи файлове",
|
||||
"grepContent": "Търсене на съдържание",
|
||||
"killCommand": "Прекратяване на изпълнението на командата",
|
||||
"listLocalFiles": "Преглед на списък с файлове",
|
||||
"moveLocalFiles": "Преместване на файлове",
|
||||
"readLocalFile": "Четене на съдържание на файл",
|
||||
"renameLocalFile": "Преименуване",
|
||||
"runCommand": "Изпълни код",
|
||||
"searchLocalFiles": "Търсене на файлове",
|
||||
"writeLocalFile": "Запис в файл"
|
||||
},
|
||||
"title": "Локална система"
|
||||
},
|
||||
"mcpInstall": {
|
||||
"CHECKING_INSTALLATION": "Проверка на инсталационната среда...",
|
||||
"COMPLETED": "Инсталацията е завършена",
|
||||
@@ -375,11 +392,6 @@
|
||||
"warning": "⚠️ Моля, уверете се, че имате доверие на източника на този плъгин, злонамерени плъгини могат да застрашат сигурността на вашата система."
|
||||
},
|
||||
"search": {
|
||||
"apiName": {
|
||||
"crawlMultiPages": "Четене на съдържание от множество страници",
|
||||
"crawlSinglePage": "Четене на съдържание от страница",
|
||||
"search": "Търсене на страници"
|
||||
},
|
||||
"config": {
|
||||
"addKey": "Добавяне на ключ",
|
||||
"close": "Изтриване",
|
||||
|
||||
@@ -191,6 +191,9 @@
|
||||
"xinference": {
|
||||
"description": "Xorbits Inference (Xinference) е платформа с отворен код, предназначена да опрости изпълнението и интегрирането на различни AI модели. С Xinference можете да използвате всякакви LLM с отворен код, модели за вграждане и мултимодални модели за извършване на изводи в облак или локална среда, както и да създавате мощни AI приложения."
|
||||
},
|
||||
"zenmux": {
|
||||
"description": "ZenMux е унифицирана платформа за агрегиране на AI услуги, поддържаща множество водещи AI интерфейси като OpenAI, Anthropic, Google VertexAI и други. Тя предлага гъвкави възможности за маршрутизиране, които ви позволяват лесно да превключвате и управлявате различни AI модели."
|
||||
},
|
||||
"zeroone": {
|
||||
"description": "01.AI се фокусира върху технологии за изкуствен интелект от ерата на AI 2.0, активно насърчавайки иновации и приложения на \"човек + изкуствен интелект\", използвайки мощни модели и напреднали AI технологии за повишаване на производителността на човека и реализиране на технологично овластяване."
|
||||
},
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "Елегантно",
|
||||
"title": "Анимация на отговор"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "По подразбиране",
|
||||
"desc": "Изберете начина на показване на контекстното меню за чат съобщения",
|
||||
"disabled": "Не използвай",
|
||||
"title": "Схема на контекстното меню"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "Персонализиране на сивата скала с различни цветови нюанси",
|
||||
"title": "Неутрални цветове"
|
||||
|
||||
+20
-1
@@ -14,7 +14,21 @@
|
||||
"images": "Изображения:",
|
||||
"prompt": "подсказка"
|
||||
},
|
||||
"lobe-knowledge-base": {
|
||||
"readKnowledge": {
|
||||
"meta": {
|
||||
"chars": "Брой знаци",
|
||||
"lines": "Брой редове"
|
||||
}
|
||||
}
|
||||
},
|
||||
"localFiles": {
|
||||
"editFile": {
|
||||
"newString": "Замени с",
|
||||
"oldString": "Търсене на съдържание",
|
||||
"replaceAll": "Замени всички съвпадения",
|
||||
"replaceFirst": "Замени само първото съвпадение"
|
||||
},
|
||||
"file": "Файл",
|
||||
"folder": "Папка",
|
||||
"moveFiles": {
|
||||
@@ -34,7 +48,12 @@
|
||||
"readFile": "Прочети файл",
|
||||
"readFileError": "Неуспешно четене на файла, моля, проверете дали пътят към файла е правилен",
|
||||
"readFiles": "Прочети файлове",
|
||||
"readFilesError": "Неуспешно четене на файловете, моля, проверете дали пътят към файловете е правилен"
|
||||
"readFilesError": "Неуспешно четене на файловете, моля, проверете дали пътят към файловете е правилен",
|
||||
"writeFile": {
|
||||
"characters": "Знаци",
|
||||
"preview": "Преглед на съдържанието",
|
||||
"truncated": "Съкратено"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"createNewSearch": "Създаване на нова търсене",
|
||||
|
||||
+114
-1
@@ -52,6 +52,104 @@
|
||||
"required": "Inhalt darf nicht leer sein"
|
||||
}
|
||||
},
|
||||
"betterAuth": {
|
||||
"errors": {
|
||||
"emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"emailNotRegistered": "Diese E-Mail-Adresse ist noch nicht registriert",
|
||||
"emailNotVerified": "E-Mail-Adresse nicht verifiziert, bitte zuerst verifizieren",
|
||||
"emailRequired": "Bitte geben Sie eine E-Mail-Adresse ein",
|
||||
"firstNameRequired": "Bitte geben Sie Ihren Vornamen ein",
|
||||
"lastNameRequired": "Bitte geben Sie Ihren Nachnamen ein",
|
||||
"loginFailed": "Anmeldung fehlgeschlagen, bitte überprüfen Sie E-Mail und Passwort",
|
||||
"passwordFormat": "Das Passwort muss Buchstaben und Zahlen enthalten",
|
||||
"passwordMaxLength": "Das Passwort darf maximal 64 Zeichen lang sein",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"passwordRequired": "Bitte geben Sie ein Passwort ein",
|
||||
"usernameRequired": "Bitte geben Sie einen Benutzernamen ein"
|
||||
},
|
||||
"resetPassword": {
|
||||
"backToSignIn": "Zurück zur Anmeldung",
|
||||
"confirmPasswordPlaceholder": "Neues Passwort bestätigen",
|
||||
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr neues Passwort",
|
||||
"description": "Bitte geben Sie Ihr neues Passwort ein",
|
||||
"error": "Passwort zurücksetzen fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"invalidToken": "Ungültiger oder abgelaufener Zurücksetzungslink",
|
||||
"newPasswordPlaceholder": "Neues Passwort eingeben",
|
||||
"passwordMismatch": "Die Passwörter stimmen nicht überein",
|
||||
"submit": "Passwort zurücksetzen",
|
||||
"success": "Passwort erfolgreich zurückgesetzt, bitte melden Sie sich mit dem neuen Passwort an",
|
||||
"title": "Passwort zurücksetzen"
|
||||
},
|
||||
"signin": {
|
||||
"backToEmail": "Zurück zur E-Mail-Adresse",
|
||||
"continueWithAuth0": "Mit Auth0 anmelden",
|
||||
"continueWithAuthelia": "Mit Authelia anmelden",
|
||||
"continueWithAuthentik": "Mit Authentik anmelden",
|
||||
"continueWithCasdoor": "Mit Casdoor anmelden",
|
||||
"continueWithCloudflareZeroTrust": "Mit Cloudflare Zero Trust anmelden",
|
||||
"continueWithCognito": "Mit AWS Cognito anmelden",
|
||||
"continueWithFeishu": "Mit Feishu anmelden",
|
||||
"continueWithGithub": "Mit GitHub anmelden",
|
||||
"continueWithGoogle": "Mit Google anmelden",
|
||||
"continueWithKeycloak": "Mit Keycloak anmelden",
|
||||
"continueWithLogto": "Mit Logto anmelden",
|
||||
"continueWithMicrosoft": "Mit Microsoft anmelden",
|
||||
"continueWithOIDC": "Mit OIDC anmelden",
|
||||
"continueWithOkta": "Mit Okta anmelden",
|
||||
"continueWithWechat": "Mit WeChat anmelden",
|
||||
"continueWithZitadel": "Mit Zitadel anmelden",
|
||||
"emailPlaceholder": "Bitte geben Sie Ihre E-Mail-Adresse ein",
|
||||
"emailStep": {
|
||||
"subtitle": "Bitte geben Sie Ihre E-Mail-Adresse ein, um fortzufahren",
|
||||
"title": "Anmelden"
|
||||
},
|
||||
"error": "Anmeldung fehlgeschlagen, bitte überprüfen Sie E-Mail und Passwort",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"forgotPasswordError": "Link zum Zurücksetzen des Passworts konnte nicht gesendet werden",
|
||||
"forgotPasswordSent": "Link zum Zurücksetzen des Passworts wurde gesendet, bitte überprüfen Sie Ihre E-Mail",
|
||||
"magicLinkButton": "Anmeldelink senden",
|
||||
"magicLinkError": "Anmeldelink konnte nicht gesendet werden, bitte versuchen Sie es später erneut",
|
||||
"magicLinkSent": "Anmeldelink wurde gesendet, bitte überprüfen Sie Ihre E-Mail",
|
||||
"nextStep": "Nächster Schritt",
|
||||
"noAccount": "Noch kein Konto?",
|
||||
"orContinueWith": "oder",
|
||||
"passwordPlaceholder": "Bitte geben Sie Ihr Passwort ein",
|
||||
"passwordStep": {
|
||||
"subtitle": "Bitte geben Sie Ihr Passwort ein, um fortzufahren"
|
||||
},
|
||||
"signupLink": "Jetzt registrieren",
|
||||
"socialError": "Soziale Anmeldung fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"socialOnlyHint": "Diese E-Mail-Adresse wurde mit einem sozialen Konto registriert. Bitte melden Sie sich mit dem sozialen Konto an",
|
||||
"submit": "Anmelden"
|
||||
},
|
||||
"signup": {
|
||||
"emailPlaceholder": "Bitte geben Sie Ihre E-Mail-Adresse ein",
|
||||
"error": "Registrierung fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"firstNamePlaceholder": "Vorname",
|
||||
"hasAccount": "Bereits ein Konto?",
|
||||
"lastNamePlaceholder": "Nachname",
|
||||
"passwordPlaceholder": "Bitte geben Sie ein Passwort ein",
|
||||
"signinLink": "Jetzt anmelden",
|
||||
"submit": "Registrieren",
|
||||
"subtitle": "Treten Sie der LobeChat-Community bei",
|
||||
"success": "Registrierung erfolgreich! Bitte überprüfen Sie Ihre E-Mail zur Verifizierung",
|
||||
"title": "Konto erstellen",
|
||||
"usernamePlaceholder": "Bitte geben Sie einen Benutzernamen ein"
|
||||
},
|
||||
"verifyEmail": {
|
||||
"backToSignIn": "Zurück zur Anmeldung",
|
||||
"checkSpam": "Wenn Sie keine E-Mail erhalten haben, überprüfen Sie bitte Ihren Spam-Ordner",
|
||||
"descriptionPrefix": "Wir haben eine Bestätigungs-E-Mail an",
|
||||
"descriptionSuffix": "gesendet",
|
||||
"resend": {
|
||||
"button": "Bestätigungs-E-Mail erneut senden",
|
||||
"error": "Senden fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"noEmail": "E-Mail-Adresse fehlt",
|
||||
"success": "Bestätigungs-E-Mail wurde erneut gesendet, bitte überprüfen Sie Ihre E-Mail"
|
||||
},
|
||||
"title": "Bestätigen Sie Ihre E-Mail-Adresse"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"prevMonth": "Letzter Monat",
|
||||
"recent30Days": "Letzte 30 Tage"
|
||||
@@ -86,16 +184,31 @@
|
||||
"loginOrSignup": "Einloggen / Registrieren",
|
||||
"profile": {
|
||||
"avatar": "Avatar",
|
||||
"cancel": "Abbrechen",
|
||||
"changePassword": "Passwort zurücksetzen",
|
||||
"email": "E-Mail-Adresse",
|
||||
"fullName": "Vollständiger Name",
|
||||
"fullNameInputHint": "Bitte geben Sie einen neuen vollständigen Namen ein",
|
||||
"password": "Passwort",
|
||||
"resetPasswordError": "Link zum Zurücksetzen des Passworts konnte nicht gesendet werden",
|
||||
"resetPasswordSent": "Link zum Zurücksetzen des Passworts wurde gesendet, bitte überprüfen Sie Ihre E-Mail",
|
||||
"save": "Speichern",
|
||||
"sso": {
|
||||
"link": {
|
||||
"button": "Konto verbinden",
|
||||
"success": "Konto erfolgreich verbunden"
|
||||
},
|
||||
"loading": "Laden der verknüpften Drittanbieter-Konten",
|
||||
"providers": "Verbundene Konten",
|
||||
"unlink": {
|
||||
"description": "Wenn Sie die Verknüpfung aufheben, können Sie sich nicht mehr mit dem {{provider}}-Konto „{{providerAccountId}}“ anmelden. Wenn Sie das {{provider}}-Konto erneut mit dem aktuellen Konto verknüpfen möchten, stellen Sie bitte sicher, dass die E-Mail-Adresse des {{provider}}-Kontos {{email}} ist, und wir werden es Ihnen automatisch bei der Anmeldung mit dem aktuellen Konto zuordnen.",
|
||||
"description": "Nach dem Trennen können Sie sich nicht mehr mit dem {{provider}}-Konto \"{{providerAccountId}}\" anmelden. Wenn Sie das {{provider}}-Konto erneut mit diesem Konto verbinden möchten, stellen Sie bitte sicher, dass die E-Mail-Adresse des {{provider}}-Kontos {{email}} ist. Wir werden es beim nächsten Login automatisch mit Ihrem aktuellen Konto verknüpfen.",
|
||||
"forbidden": "Sie müssen mindestens ein Drittanbieter-Konto verbunden behalten.",
|
||||
"title": "Möchten Sie das Drittanbieter-Konto {{provider}} wirklich trennen?"
|
||||
}
|
||||
},
|
||||
"title": "Profilinformationen",
|
||||
"updateAvatar": "Avatar aktualisieren",
|
||||
"updateFullName": "Vollständigen Namen aktualisieren",
|
||||
"username": "Benutzername"
|
||||
},
|
||||
"signout": "Ausloggen",
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
"desc": "Basierend auf den Einschränkungen des Claude Thinking-Mechanismus (<1>Mehr erfahren</1>), wird bei Aktivierung die Begrenzung der Anzahl historischer Nachrichten automatisch deaktiviert.",
|
||||
"title": "Tiefes Denken aktivieren"
|
||||
},
|
||||
"imageAspectRatio": {
|
||||
"title": "Seitenverhältnis des Bildes"
|
||||
},
|
||||
"imageResolution": {
|
||||
"title": "Bildauflösung"
|
||||
},
|
||||
"reasoningBudgetToken": {
|
||||
"title": "Token für Denkaufwand"
|
||||
},
|
||||
@@ -65,6 +71,9 @@
|
||||
"thinking": {
|
||||
"title": "Tiefdenk-Schalter"
|
||||
},
|
||||
"thinkingLevel": {
|
||||
"title": "Denkebene"
|
||||
},
|
||||
"title": "Modell Erweiterungsfunktionen",
|
||||
"urlContext": {
|
||||
"desc": "Wenn aktiviert, werden Webseiten-Links automatisch analysiert, um den tatsächlichen Webseiteninhalt zu erfassen",
|
||||
@@ -330,6 +339,11 @@
|
||||
"screenshot": "Screenshot",
|
||||
"settings": "Exporteinstellungen",
|
||||
"text": "Text",
|
||||
"widthMode": {
|
||||
"label": "Breitenmodus",
|
||||
"narrow": "Schmalbildmodus",
|
||||
"wide": "Breitbildmodus"
|
||||
},
|
||||
"withBackground": "Mit Hintergrundbild",
|
||||
"withFooter": "Mit Fußzeile",
|
||||
"withPluginInfo": "Mit Plugin-Informationen",
|
||||
@@ -391,6 +405,7 @@
|
||||
"rejectReasonPlaceholder": "Die Angabe eines Ablehnungsgrundes hilft dem Agenten, zukünftige Aktionen zu verbessern",
|
||||
"rejectTitle": "Tool-Ausführung ablehnen",
|
||||
"rejectedWithReason": "Die Tool-Ausführung wurde abgelehnt: {{reason}}",
|
||||
"toolAbort": "Dieser Werkzeugaufruf wurde vom Benutzer abgebrochen",
|
||||
"toolRejected": "Die Tool-Ausführung wurde abgelehnt"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -135,6 +135,27 @@
|
||||
}
|
||||
},
|
||||
"close": "Schließen",
|
||||
"cmdk": {
|
||||
"about": "Über",
|
||||
"communitySupport": "Community-Support",
|
||||
"discover": "Entdecken",
|
||||
"knowledgeBase": "Wissensdatenbank",
|
||||
"navigate": "Navigieren",
|
||||
"newAgent": "Neuen Assistenten erstellen",
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"openSettings": "Einstellungen öffnen",
|
||||
"painting": "KI-Malerei",
|
||||
"searchPlaceholder": "Befehl eingeben oder suchen...",
|
||||
"settings": "Einstellungen",
|
||||
"starOnGitHub": "Gib uns einen Stern auf GitHub",
|
||||
"submitIssue": "Problem melden",
|
||||
"theme": "Design",
|
||||
"themeAuto": "Systemeinstellung folgen",
|
||||
"themeDark": "Dunkles Design",
|
||||
"themeLight": "Helles Design",
|
||||
"toOpen": "Öffnen",
|
||||
"toSelect": "Auswählen"
|
||||
},
|
||||
"confirm": "Bestätigen",
|
||||
"contact": "Kontakt",
|
||||
"copy": "Kopieren",
|
||||
@@ -283,6 +304,7 @@
|
||||
"business": "Geschäftliche Zusammenarbeit",
|
||||
"support": "E-Mail-Support"
|
||||
},
|
||||
"new": "Neu",
|
||||
"oauth": "SSO-Anmeldung",
|
||||
"officialSite": "Offizielle Website",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"SPII": "Ihr Inhalt könnte sensible personenbezogene Daten enthalten. Zum Schutz der Privatsphäre entfernen Sie bitte diese Informationen und versuchen Sie es erneut.",
|
||||
"default": "Inhalt blockiert: {{blockReason}}. Bitte passen Sie Ihre Anfrage an und versuchen Sie es erneut."
|
||||
},
|
||||
"InsufficientQuota": "Es tut uns leid, das Kontingent (Quota) für diesen Schlüssel ist erreicht. Bitte überprüfen Sie Ihr Kontoguthaben oder erhöhen Sie das Kontingent des Schlüssels und versuchen Sie es erneut.",
|
||||
"InsufficientQuota": "Es tut uns leid, das Kontingent dieses Schlüssels wurde erreicht. Bitte überprüfen Sie, ob Ihr Kontostand ausreichend ist, oder erhöhen Sie das Kontingent des Schlüssels und versuchen Sie es erneut.",
|
||||
"InvalidAccessCode": "Das Passwort ist ungültig oder leer. Bitte geben Sie das richtige Zugangspasswort ein oder fügen Sie einen benutzerdefinierten API-Schlüssel hinzu.",
|
||||
"InvalidBedrockCredentials": "Die Bedrock-Authentifizierung ist fehlgeschlagen. Bitte überprüfen Sie AccessKeyId/SecretAccessKey und versuchen Sie es erneut.",
|
||||
"InvalidClerkUser": "Entschuldigung, du bist derzeit nicht angemeldet. Bitte melde dich an oder registriere ein Konto, um fortzufahren.",
|
||||
@@ -131,7 +131,7 @@
|
||||
"PluginServerError": "Fehler bei der Serveranfrage des Plugins. Bitte überprüfen Sie die Fehlerinformationen unten in Ihrer Plugin-Beschreibungsdatei, Plugin-Konfiguration oder Serverimplementierung",
|
||||
"PluginSettingsInvalid": "Das Plugin muss korrekt konfiguriert werden, um verwendet werden zu können. Bitte überprüfen Sie Ihre Konfiguration auf Richtigkeit",
|
||||
"ProviderBizError": "Fehler bei der Anforderung des {{provider}}-Dienstes. Bitte überprüfen Sie die folgenden Informationen oder versuchen Sie es erneut.",
|
||||
"QuotaLimitReached": "Es tut uns leid, die aktuelle Token-Nutzung oder die Anzahl der Anfragen hat das Kontingent (Quota) für diesen Schlüssel erreicht. Bitte erhöhen Sie das Kontingent für diesen Schlüssel oder versuchen Sie es später erneut.",
|
||||
"QuotaLimitReached": "Es tut uns leid, die Anzahl der Token oder Anfragen hat das Kontingent dieses Schlüssels erreicht. Bitte erhöhen Sie das Kontingent des Schlüssels oder versuchen Sie es später erneut.",
|
||||
"StreamChunkError": "Fehler beim Parsen des Nachrichtenchunks der Streaming-Anfrage. Bitte überprüfen Sie, ob die aktuelle API-Schnittstelle den Standards entspricht, oder wenden Sie sich an Ihren API-Anbieter.",
|
||||
"SubscriptionKeyMismatch": "Es tut uns leid, aufgrund eines vorübergehenden Systemfehlers ist das aktuelle Abonnement vorübergehend ungültig. Bitte klicken Sie auf die Schaltfläche unten, um das Abonnement wiederherzustellen, oder kontaktieren Sie uns per E-Mail für Unterstützung.",
|
||||
"SubscriptionPlanLimit": "Ihr Abonnementspunktestand ist erschöpft, Sie können diese Funktion nicht nutzen. Bitte upgraden Sie auf einen höheren Plan oder konfigurieren Sie die benutzerdefinierte Modell-API, um weiterhin zu verwenden.",
|
||||
|
||||
+11
-13
@@ -55,11 +55,11 @@
|
||||
},
|
||||
"documentList": {
|
||||
"copyContent": "Gesamten Inhalt kopieren",
|
||||
"documentCount": "Insgesamt {{count}} Dokumente",
|
||||
"duplicate": "Kopie erstellen",
|
||||
"empty": "Noch keine Dokumente vorhanden. Klicken Sie oben, um Ihr erstes Dokument zu erstellen.",
|
||||
"empty": "Noch keine Dokumente vorhanden. Klicke auf die Schaltfläche oben, um dein erstes Dokument zu erstellen.",
|
||||
"noResults": "Keine passenden Dokumente gefunden",
|
||||
"selectNote": "Wählen Sie ein Dokument zum Bearbeiten",
|
||||
"pageCount": "Insgesamt {{count}} Dokumente",
|
||||
"selectNote": "Wähle ein Dokument aus, um mit der Bearbeitung zu beginnen",
|
||||
"untitled": "Ohne Titel"
|
||||
},
|
||||
"empty": "Keine hochgeladenen Dateien/Ordner vorhanden",
|
||||
@@ -70,7 +70,6 @@
|
||||
"uploadFile": "Datei hochladen",
|
||||
"uploadFolder": "Ordner hochladen"
|
||||
},
|
||||
"newDocumentButton": "Neues Dokument",
|
||||
"newNoteDialog": {
|
||||
"cancel": "Abbrechen",
|
||||
"editTitle": "Dokument bearbeiten",
|
||||
@@ -83,14 +82,15 @@
|
||||
"title": "Neues Dokument",
|
||||
"updateSuccess": "Dokument erfolgreich aktualisiert"
|
||||
},
|
||||
"newPageButton": "Neues Dokument erstellen",
|
||||
"uploadButton": "Hochladen"
|
||||
},
|
||||
"home": {
|
||||
"getStarted": "Loslegen",
|
||||
"greeting": "Loslegen",
|
||||
"quickActions": "Schnellaktionen",
|
||||
"recentDocuments": "Kürzlich verwendete Dokumente",
|
||||
"recentFiles": "Kürzlich verwendete Dateien",
|
||||
"recentPages": "Kürzlich geöffnete Dokumente",
|
||||
"subtitle": "Willkommen im Wissensspeicher. Beginnen Sie hier mit der Verwaltung Ihrer Dokumente.",
|
||||
"uploadEntries": {
|
||||
"files": {
|
||||
@@ -102,8 +102,8 @@
|
||||
"knowledgeBase": {
|
||||
"title": "Neue Wissensdatenbank"
|
||||
},
|
||||
"newDocument": {
|
||||
"title": "Neues Dokument"
|
||||
"newPage": {
|
||||
"title": "Neues Dokument erstellen"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -116,8 +116,8 @@
|
||||
"title": "Wissensdatenbank"
|
||||
},
|
||||
"menu": {
|
||||
"allDocuments": "Alle Dokumente",
|
||||
"allFiles": "Alle Dateien"
|
||||
"allFiles": "Alle Dateien",
|
||||
"allPages": "Alle Dokumente"
|
||||
},
|
||||
"networkError": "Fehler beim Abrufen der Wissensdatenbank. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
|
||||
"notSupportGuide": {
|
||||
@@ -142,8 +142,8 @@
|
||||
"downloadFile": "Datei herunterladen",
|
||||
"unsupportedFileAndContact": "Dieses Dateiformat wird derzeit nicht für die Online-Vorschau unterstützt. Wenn Sie eine Vorschau wünschen, können Sie uns gerne <1>Feedback geben</1>."
|
||||
},
|
||||
"searchDocumentPlaceholder": "Dokumente durchsuchen",
|
||||
"searchFilePlaceholder": "Datei suchen",
|
||||
"searchPagePlaceholder": "Dokumente durchsuchen",
|
||||
"tab": {
|
||||
"all": "Alle",
|
||||
"audios": "Audio",
|
||||
@@ -156,9 +156,7 @@
|
||||
"websites": "Webseiten"
|
||||
},
|
||||
"title": "Wissensdatenbank",
|
||||
"toggleLeftPanel": {
|
||||
"title": "Linkes Panel einblenden/ausblenden"
|
||||
},
|
||||
"toggleLeftPanel": "Seitenleiste ein-/ausblenden",
|
||||
"uploadDock": {
|
||||
"body": {
|
||||
"collapse": "Einklappen",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user