Compare commits

...

5 Commits

Author SHA1 Message Date
arvinxx 4fa6d64df2 add plugin settings 2025-07-29 14:48:46 +08:00
Zhijie He 403aebd52e 💄 style: add more OpenAI SDK Text2Image providers (#8573) 2025-07-29 14:45:19 +08:00
LobeHub Bot 356cf0c392 💄 style: update i18n (#8593)
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-07-29 14:37:29 +08:00
Arvin Xu be98d56ef4 ♻️ refactor: refactor jose-JWT to xor obfuscation (#8595)
* refactor jose-jwt to xor obfuscation

* rename JWTPayload type to ClientSecretPayload

* fix tests

* revert next version
2025-07-29 14:37:01 +08:00
lobehubbot 3fae1b2638 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 04:07:54 +00:00
61 changed files with 2373 additions and 249 deletions
+7
View File
@@ -1,4 +1,11 @@
[
{
"children": {
"improvements": ["Support more Text2Image from Qwen."]
},
"date": "2025-07-29",
"version": "1.105.1"
},
{
"children": {
"features": ["Implement API Key management functionality."]
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "تم الإنشاء تلقائيًا",
"copy": "نسخ",
"copyError": "فشل النسخ",
"copySuccess": "تم نسخ مفتاح API إلى الحافظة",
"enterPlaceholder": "الرجاء الإدخال",
"hide": "إخفاء",
"neverExpires": "لا تنتهي صلاحيتها أبدًا",
"neverUsed": "لم يُستخدم أبدًا",
"show": "عرض"
},
"form": {
"fields": {
"expiresAt": {
"label": "تاريخ الانتهاء",
"placeholder": "لا تنتهي صلاحيتها أبدًا"
},
"name": {
"label": "الاسم",
"placeholder": "الرجاء إدخال اسم مفتاح API"
}
},
"submit": "إنشاء",
"title": "إنشاء مفتاح API"
},
"list": {
"actions": {
"create": "إنشاء مفتاح API",
"delete": "حذف",
"deleteConfirm": {
"actions": {
"cancel": "إلغاء",
"ok": "تأكيد"
},
"content": "هل أنت متأكد من حذف هذا المفتاح؟",
"title": "تأكيد العملية"
}
},
"columns": {
"actions": "الإجراءات",
"expiresAt": "تاريخ الانتهاء",
"key": "المفتاح",
"lastUsedAt": "آخر استخدام",
"name": "الاسم",
"status": "حالة التفعيل"
},
"title": "قائمة مفاتيح API"
},
"validation": {
"required": "لا يمكن أن يكون المحتوى فارغًا"
}
},
"date": {
"prevMonth": "الشهر الماضي",
"recent30Days": "آخر 30 يومًا"
@@ -89,6 +142,7 @@
"words": "كلمات"
},
"tab": {
"apikey": "إدارة مفاتيح API",
"profile": "الملف الشخصي",
"security": "الأمان",
"stats": "الإحصائيات"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Автоматично генериран",
"copy": "Копирай",
"copyError": "Грешка при копиране",
"copySuccess": "API ключът е копиран в клипборда",
"enterPlaceholder": "Моля, въведете",
"hide": "Скрий",
"neverExpires": "Никога не изтича",
"neverUsed": "Никога не е използван",
"show": "Покажи"
},
"form": {
"fields": {
"expiresAt": {
"label": "Дата на изтичане",
"placeholder": "Никога не изтича"
},
"name": {
"label": "Име",
"placeholder": "Моля, въведете име на API ключ"
}
},
"submit": "Създай",
"title": "Създаване на API ключ"
},
"list": {
"actions": {
"create": "Създай API ключ",
"delete": "Изтрий",
"deleteConfirm": {
"actions": {
"cancel": "Отказ",
"ok": "Потвърди"
},
"content": "Сигурни ли сте, че искате да изтриете този API ключ?",
"title": "Потвърждение на действие"
}
},
"columns": {
"actions": "Действия",
"expiresAt": "Дата на изтичане",
"key": "Ключ",
"lastUsedAt": "Последна употреба",
"name": "Име",
"status": "Статус на активиране"
},
"title": "Списък с API ключове"
},
"validation": {
"required": "Полето не може да бъде празно"
}
},
"date": {
"prevMonth": "Миналия месец",
"recent30Days": "Последните 30 дни"
@@ -89,6 +142,7 @@
"words": "Думи"
},
"tab": {
"apikey": "Управление на API ключове",
"profile": "Профил",
"security": "Сигурност",
"stats": "Статистика"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Automatisch generiert",
"copy": "Kopieren",
"copyError": "Kopieren fehlgeschlagen",
"copySuccess": "API-Schlüssel wurde in die Zwischenablage kopiert",
"enterPlaceholder": "Bitte eingeben",
"hide": "Verbergen",
"neverExpires": "Läuft nie ab",
"neverUsed": "Nie verwendet",
"show": "Anzeigen"
},
"form": {
"fields": {
"expiresAt": {
"label": "Ablaufdatum",
"placeholder": "Läuft nie ab"
},
"name": {
"label": "Name",
"placeholder": "Bitte API-Schlüsselname eingeben"
}
},
"submit": "Erstellen",
"title": "API-Schlüssel erstellen"
},
"list": {
"actions": {
"create": "API-Schlüssel erstellen",
"delete": "Löschen",
"deleteConfirm": {
"actions": {
"cancel": "Abbrechen",
"ok": "Bestätigen"
},
"content": "Möchten Sie diesen API-Schlüssel wirklich löschen?",
"title": "Bestätigung"
}
},
"columns": {
"actions": "Aktionen",
"expiresAt": "Ablaufdatum",
"key": "Schlüssel",
"lastUsedAt": "Letzte Verwendung",
"name": "Name",
"status": "Aktivierungsstatus"
},
"title": "API-Schlüssel Liste"
},
"validation": {
"required": "Inhalt darf nicht leer sein"
}
},
"date": {
"prevMonth": "Letzter Monat",
"recent30Days": "Letzte 30 Tage"
@@ -89,6 +142,7 @@
"words": "Wörter"
},
"tab": {
"apikey": "API-Schlüssel Verwaltung",
"profile": "Profil",
"security": "Sicherheit",
"stats": "Statistiken"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Auto-generated",
"copy": "Copy",
"copyError": "Copy failed",
"copySuccess": "API Key copied to clipboard",
"enterPlaceholder": "Please enter",
"hide": "Hide",
"neverExpires": "Never expires",
"neverUsed": "Never used",
"show": "Show"
},
"form": {
"fields": {
"expiresAt": {
"label": "Expiration Date",
"placeholder": "Never expires"
},
"name": {
"label": "Name",
"placeholder": "Please enter API Key name"
}
},
"submit": "Create",
"title": "Create API Key"
},
"list": {
"actions": {
"create": "Create API Key",
"delete": "Delete",
"deleteConfirm": {
"actions": {
"cancel": "Cancel",
"ok": "Confirm"
},
"content": "Are you sure you want to delete this API Key?",
"title": "Confirm Action"
}
},
"columns": {
"actions": "Actions",
"expiresAt": "Expiration Date",
"key": "Key",
"lastUsedAt": "Last Used",
"name": "Name",
"status": "Enabled Status"
},
"title": "API Key List"
},
"validation": {
"required": "This field cannot be empty"
}
},
"date": {
"prevMonth": "Last Month",
"recent30Days": "Last 30 Days"
@@ -89,6 +142,7 @@
"words": "Total Words"
},
"tab": {
"apikey": "API Key Management",
"profile": "Profile",
"security": "Security",
"stats": "Statistics"
+1
View File
@@ -535,6 +535,7 @@
"experiment": "Experiment",
"hotkey": "Hotkeys",
"llm": "Language Model",
"plugin": "Plugin Management",
"provider": "AI Service Provider",
"proxy": "Network Proxy",
"storage": "Data Storage",
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Generado automáticamente",
"copy": "Copiar",
"copyError": "Error al copiar",
"copySuccess": "Clave API copiada al portapapeles",
"enterPlaceholder": "Por favor ingrese",
"hide": "Ocultar",
"neverExpires": "Nunca expira",
"neverUsed": "Nunca usado",
"show": "Mostrar"
},
"form": {
"fields": {
"expiresAt": {
"label": "Fecha de expiración",
"placeholder": "Nunca expira"
},
"name": {
"label": "Nombre",
"placeholder": "Por favor ingrese el nombre de la clave API"
}
},
"submit": "Crear",
"title": "Crear Clave API"
},
"list": {
"actions": {
"create": "Crear Clave API",
"delete": "Eliminar",
"deleteConfirm": {
"actions": {
"cancel": "Cancelar",
"ok": "Confirmar"
},
"content": "¿Está seguro de eliminar esta clave API?",
"title": "Confirmar acción"
}
},
"columns": {
"actions": "Acciones",
"expiresAt": "Fecha de expiración",
"key": "Clave",
"lastUsedAt": "Último uso",
"name": "Nombre",
"status": "Estado"
},
"title": "Lista de Claves API"
},
"validation": {
"required": "El contenido no puede estar vacío"
}
},
"date": {
"prevMonth": "Último mes",
"recent30Days": "Últimos 30 días"
@@ -89,6 +142,7 @@
"words": "Palabras"
},
"tab": {
"apikey": "Gestión de Claves API",
"profile": "Perfil",
"security": "Seguridad",
"stats": "Estadísticas"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "تولید خودکار",
"copy": "کپی",
"copyError": "کپی ناموفق بود",
"copySuccess": "کلید API به کلیپ‌بورد کپی شد",
"enterPlaceholder": "لطفاً وارد کنید",
"hide": "مخفی کردن",
"neverExpires": "هرگز منقضی نمی‌شود",
"neverUsed": "هرگز استفاده نشده",
"show": "نمایش"
},
"form": {
"fields": {
"expiresAt": {
"label": "تاریخ انقضا",
"placeholder": "هرگز منقضی نمی‌شود"
},
"name": {
"label": "نام",
"placeholder": "لطفاً نام کلید API را وارد کنید"
}
},
"submit": "ایجاد",
"title": "ایجاد کلید API"
},
"list": {
"actions": {
"create": "ایجاد کلید API",
"delete": "حذف",
"deleteConfirm": {
"actions": {
"cancel": "لغو",
"ok": "تأیید"
},
"content": "آیا از حذف این کلید API مطمئن هستید؟",
"title": "تأیید عملیات"
}
},
"columns": {
"actions": "عملیات",
"expiresAt": "تاریخ انقضا",
"key": "کلید",
"lastUsedAt": "آخرین زمان استفاده",
"name": "نام",
"status": "وضعیت فعال"
},
"title": "فهرست کلیدهای API"
},
"validation": {
"required": "محتوا نباید خالی باشد"
}
},
"date": {
"prevMonth": "ماه گذشته",
"recent30Days": "۳۰ روز گذشته"
@@ -89,6 +142,7 @@
"words": "کلمات"
},
"tab": {
"apikey": "مدیریت کلید API",
"profile": "پروفایل",
"security": "امنیت",
"stats": "آمار"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Généré automatiquement",
"copy": "Copier",
"copyError": "Échec de la copie",
"copySuccess": "Clé API copiée dans le presse-papiers",
"enterPlaceholder": "Veuillez saisir",
"hide": "Cacher",
"neverExpires": "N'expire jamais",
"neverUsed": "Jamais utilisé",
"show": "Afficher"
},
"form": {
"fields": {
"expiresAt": {
"label": "Date d'expiration",
"placeholder": "N'expire jamais"
},
"name": {
"label": "Nom",
"placeholder": "Veuillez saisir le nom de la clé API"
}
},
"submit": "Créer",
"title": "Créer une clé API"
},
"list": {
"actions": {
"create": "Créer une clé API",
"delete": "Supprimer",
"deleteConfirm": {
"actions": {
"cancel": "Annuler",
"ok": "Confirmer"
},
"content": "Confirmez-vous la suppression de cette clé API ?",
"title": "Confirmation"
}
},
"columns": {
"actions": "Actions",
"expiresAt": "Date d'expiration",
"key": "Clé",
"lastUsedAt": "Dernière utilisation",
"name": "Nom",
"status": "Statut d'activation"
},
"title": "Liste des clés API"
},
"validation": {
"required": "Ce champ est obligatoire"
}
},
"date": {
"prevMonth": "Le mois dernier",
"recent30Days": "Les 30 derniers jours"
@@ -89,6 +142,7 @@
"words": "Mots"
},
"tab": {
"apikey": "Gestion des clés API",
"profile": "Profil",
"security": "Sécurité",
"stats": "Statistiques"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Generato automaticamente",
"copy": "Copia",
"copyError": "Copia non riuscita",
"copySuccess": "Chiave API copiata negli appunti",
"enterPlaceholder": "Inserisci",
"hide": "Nascondi",
"neverExpires": "Non scade mai",
"neverUsed": "Mai usato",
"show": "Mostra"
},
"form": {
"fields": {
"expiresAt": {
"label": "Data di scadenza",
"placeholder": "Non scade mai"
},
"name": {
"label": "Nome",
"placeholder": "Inserisci il nome della Chiave API"
}
},
"submit": "Crea",
"title": "Crea Chiave API"
},
"list": {
"actions": {
"create": "Crea Chiave API",
"delete": "Elimina",
"deleteConfirm": {
"actions": {
"cancel": "Annulla",
"ok": "Conferma"
},
"content": "Sei sicuro di voler eliminare questa Chiave API?",
"title": "Conferma azione"
}
},
"columns": {
"actions": "Azioni",
"expiresAt": "Data di scadenza",
"key": "Chiave",
"lastUsedAt": "Ultimo utilizzo",
"name": "Nome",
"status": "Stato attivo"
},
"title": "Elenco Chiavi API"
},
"validation": {
"required": "Il contenuto non può essere vuoto"
}
},
"date": {
"prevMonth": "Mese Scorso",
"recent30Days": "Ultimi 30 Giorni"
@@ -89,6 +142,7 @@
"words": "Parole"
},
"tab": {
"apikey": "Gestione Chiavi API",
"profile": "Profilo",
"security": "Sicurezza",
"stats": "Statistiche"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "自動生成",
"copy": "コピー",
"copyError": "コピーに失敗しました",
"copySuccess": "APIキーがクリップボードにコピーされました",
"enterPlaceholder": "入力してください",
"hide": "非表示",
"neverExpires": "期限なし",
"neverUsed": "未使用",
"show": "表示"
},
"form": {
"fields": {
"expiresAt": {
"label": "有効期限",
"placeholder": "期限なし"
},
"name": {
"label": "名前",
"placeholder": "APIキーの名前を入力してください"
}
},
"submit": "作成",
"title": "APIキーを作成"
},
"list": {
"actions": {
"create": "APIキーを作成",
"delete": "削除",
"deleteConfirm": {
"actions": {
"cancel": "キャンセル",
"ok": "確認"
},
"content": "このAPIキーを削除してもよろしいですか?",
"title": "操作の確認"
}
},
"columns": {
"actions": "操作",
"expiresAt": "有効期限",
"key": "キー",
"lastUsedAt": "最終使用日時",
"name": "名前",
"status": "有効状態"
},
"title": "APIキー一覧"
},
"validation": {
"required": "内容を入力してください"
}
},
"date": {
"prevMonth": "先月",
"recent30Days": "過去30日間"
@@ -89,6 +142,7 @@
"words": "単語"
},
"tab": {
"apikey": "APIキー管理",
"profile": "プロフィール",
"security": "セキュリティ",
"stats": "統計"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "자동 생성",
"copy": "복사",
"copyError": "복사 실패",
"copySuccess": "API 키가 클립보드에 복사되었습니다",
"enterPlaceholder": "입력하세요",
"hide": "숨기기",
"neverExpires": "만료되지 않음",
"neverUsed": "한 번도 사용되지 않음",
"show": "표시"
},
"form": {
"fields": {
"expiresAt": {
"label": "만료 시간",
"placeholder": "만료되지 않음"
},
"name": {
"label": "이름",
"placeholder": "API 키 이름을 입력하세요"
}
},
"submit": "생성",
"title": "API 키 생성"
},
"list": {
"actions": {
"create": "API 키 생성",
"delete": "삭제",
"deleteConfirm": {
"actions": {
"cancel": "취소",
"ok": "확인"
},
"content": "이 API 키를 삭제하시겠습니까?",
"title": "작업 확인"
}
},
"columns": {
"actions": "작업",
"expiresAt": "만료 시간",
"key": "키",
"lastUsedAt": "마지막 사용 시간",
"name": "이름",
"status": "활성 상태"
},
"title": "API 키 목록"
},
"validation": {
"required": "내용을 비워둘 수 없습니다"
}
},
"date": {
"prevMonth": "지난 달",
"recent30Days": "최근 30일"
@@ -89,6 +142,7 @@
"words": "단어"
},
"tab": {
"apikey": "API 키 관리",
"profile": "프로필",
"security": "보안",
"stats": "통계"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Automatisch gegenereerd",
"copy": "Kopiëren",
"copyError": "Kopiëren mislukt",
"copySuccess": "API-sleutel is gekopieerd naar het klembord",
"enterPlaceholder": "Voer in",
"hide": "Verbergen",
"neverExpires": "Verloopt nooit",
"neverUsed": "Nooit gebruikt",
"show": "Weergeven"
},
"form": {
"fields": {
"expiresAt": {
"label": "Vervaldatum",
"placeholder": "Verloopt nooit"
},
"name": {
"label": "Naam",
"placeholder": "Voer de naam van de API-sleutel in"
}
},
"submit": "Aanmaken",
"title": "API-sleutel aanmaken"
},
"list": {
"actions": {
"create": "API-sleutel aanmaken",
"delete": "Verwijderen",
"deleteConfirm": {
"actions": {
"cancel": "Annuleren",
"ok": "Bevestigen"
},
"content": "Weet u zeker dat u deze API-sleutel wilt verwijderen?",
"title": "Bevestig actie"
}
},
"columns": {
"actions": "Acties",
"expiresAt": "Vervaldatum",
"key": "Sleutel",
"lastUsedAt": "Laatst gebruikt",
"name": "Naam",
"status": "Status"
},
"title": "API-sleutellijst"
},
"validation": {
"required": "Inhoud mag niet leeg zijn"
}
},
"date": {
"prevMonth": "Vorige maand",
"recent30Days": "Laatste 30 dagen"
@@ -89,6 +142,7 @@
"words": "Woorden"
},
"tab": {
"apikey": "API-sleutelbeheer",
"profile": "Profiel",
"security": "Beveiliging",
"stats": "Statistieken"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Automatycznie wygenerowany",
"copy": "Kopiuj",
"copyError": "Kopiowanie nie powiodło się",
"copySuccess": "Klucz API został skopiowany do schowka",
"enterPlaceholder": "Wpisz",
"hide": "Ukryj",
"neverExpires": "Nigdy nie wygasa",
"neverUsed": "Nigdy nie używany",
"show": "Pokaż"
},
"form": {
"fields": {
"expiresAt": {
"label": "Data wygaśnięcia",
"placeholder": "Nigdy nie wygasa"
},
"name": {
"label": "Nazwa",
"placeholder": "Wpisz nazwę klucza API"
}
},
"submit": "Utwórz",
"title": "Utwórz klucz API"
},
"list": {
"actions": {
"create": "Utwórz klucz API",
"delete": "Usuń",
"deleteConfirm": {
"actions": {
"cancel": "Anuluj",
"ok": "Potwierdź"
},
"content": "Czy na pewno chcesz usunąć ten klucz API?",
"title": "Potwierdź operację"
}
},
"columns": {
"actions": "Akcje",
"expiresAt": "Data wygaśnięcia",
"key": "Klucz",
"lastUsedAt": "Ostatnie użycie",
"name": "Nazwa",
"status": "Status aktywacji"
},
"title": "Lista kluczy API"
},
"validation": {
"required": "Pole nie może być puste"
}
},
"date": {
"prevMonth": "Poprzedni miesiąc",
"recent30Days": "Ostatnie 30 dni"
@@ -89,6 +142,7 @@
"words": "Słowa"
},
"tab": {
"apikey": "Zarządzanie kluczami API",
"profile": "Profil",
"security": "Bezpieczeństwo",
"stats": "Statystyki"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Gerado automaticamente",
"copy": "Copiar",
"copyError": "Falha ao copiar",
"copySuccess": "Chave API copiada para a área de transferência",
"enterPlaceholder": "Por favor, insira",
"hide": "Ocultar",
"neverExpires": "Nunca expira",
"neverUsed": "Nunca usado",
"show": "Mostrar"
},
"form": {
"fields": {
"expiresAt": {
"label": "Data de expiração",
"placeholder": "Nunca expira"
},
"name": {
"label": "Nome",
"placeholder": "Por favor, insira o nome da Chave API"
}
},
"submit": "Criar",
"title": "Criar Chave API"
},
"list": {
"actions": {
"create": "Criar Chave API",
"delete": "Excluir",
"deleteConfirm": {
"actions": {
"cancel": "Cancelar",
"ok": "Confirmar"
},
"content": "Tem certeza de que deseja excluir esta Chave API?",
"title": "Confirmar ação"
}
},
"columns": {
"actions": "Ações",
"expiresAt": "Data de expiração",
"key": "Chave",
"lastUsedAt": "Último uso",
"name": "Nome",
"status": "Status de ativação"
},
"title": "Lista de Chaves API"
},
"validation": {
"required": "O conteúdo não pode estar vazio"
}
},
"date": {
"prevMonth": "Último Mês",
"recent30Days": "Últimos 30 Dias"
@@ -89,6 +142,7 @@
"words": "Palavras"
},
"tab": {
"apikey": "Gerenciamento de Chave API",
"profile": "Perfil",
"security": "Segurança",
"stats": "Estatísticas"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Автоматически сгенерировано",
"copy": "Копировать",
"copyError": "Ошибка копирования",
"copySuccess": "API ключ скопирован в буфер обмена",
"enterPlaceholder": "Пожалуйста, введите",
"hide": "Скрыть",
"neverExpires": "Никогда не истекает",
"neverUsed": "Никогда не использовался",
"show": "Показать"
},
"form": {
"fields": {
"expiresAt": {
"label": "Срок действия",
"placeholder": "Никогда не истекает"
},
"name": {
"label": "Название",
"placeholder": "Пожалуйста, введите название API ключа"
}
},
"submit": "Создать",
"title": "Создать API ключ"
},
"list": {
"actions": {
"create": "Создать API ключ",
"delete": "Удалить",
"deleteConfirm": {
"actions": {
"cancel": "Отмена",
"ok": "Подтвердить"
},
"content": "Вы уверены, что хотите удалить этот API ключ?",
"title": "Подтверждение действия"
}
},
"columns": {
"actions": "Действия",
"expiresAt": "Срок действия",
"key": "Ключ",
"lastUsedAt": "Последнее использование",
"name": "Название",
"status": "Статус активации"
},
"title": "Список API ключей"
},
"validation": {
"required": "Поле не может быть пустым"
}
},
"date": {
"prevMonth": "Прошлый месяц",
"recent30Days": "Последние 30 дней"
@@ -89,6 +142,7 @@
"words": "Слова"
},
"tab": {
"apikey": "Управление API ключами",
"profile": "Профиль",
"security": "Безопасность",
"stats": "Статистика"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Otomatik Oluşturuldu",
"copy": "Kopyala",
"copyError": "Kopyalama Başarısız",
"copySuccess": "API Anahtarı panoya kopyalandı",
"enterPlaceholder": "Lütfen giriniz",
"hide": "Gizle",
"neverExpires": "Asla Süresi Dolmaz",
"neverUsed": "Hiç Kullanılmadı",
"show": "Göster"
},
"form": {
"fields": {
"expiresAt": {
"label": "Son Kullanma Tarihi",
"placeholder": "Asla Süresi Dolmaz"
},
"name": {
"label": "Ad",
"placeholder": "Lütfen API Anahtarı adını giriniz"
}
},
"submit": "Oluştur",
"title": "API Anahtarı Oluştur"
},
"list": {
"actions": {
"create": "API Anahtarı Oluştur",
"delete": "Sil",
"deleteConfirm": {
"actions": {
"cancel": "İptal",
"ok": "Onayla"
},
"content": "Bu API Anahtarını silmek istediğinize emin misiniz?",
"title": "İşlemi Onayla"
}
},
"columns": {
"actions": "İşlemler",
"expiresAt": "Son Kullanma Tarihi",
"key": "Anahtar",
"lastUsedAt": "Son Kullanım Tarihi",
"name": "Ad",
"status": "Etkinlik Durumu"
},
"title": "API Anahtarı Listesi"
},
"validation": {
"required": "Bu alan boş bırakılamaz"
}
},
"date": {
"prevMonth": "Geçen Ay",
"recent30Days": "Son 30 Gün"
@@ -89,6 +142,7 @@
"words": "Toplam kelime sayısı"
},
"tab": {
"apikey": "API Anahtarı Yönetimi",
"profile": "Profil",
"security": "Güvenlik",
"stats": "İstatistikler"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "Tự động tạo",
"copy": "Sao chép",
"copyError": "Sao chép thất bại",
"copySuccess": "API Key đã được sao chép vào bộ nhớ tạm",
"enterPlaceholder": "Vui lòng nhập",
"hide": "Ẩn",
"neverExpires": "Không bao giờ hết hạn",
"neverUsed": "Chưa từng sử dụng",
"show": "Hiển thị"
},
"form": {
"fields": {
"expiresAt": {
"label": "Thời gian hết hạn",
"placeholder": "Không bao giờ hết hạn"
},
"name": {
"label": "Tên",
"placeholder": "Vui lòng nhập tên API Key"
}
},
"submit": "Tạo",
"title": "Tạo API Key"
},
"list": {
"actions": {
"create": "Tạo API Key",
"delete": "Xóa",
"deleteConfirm": {
"actions": {
"cancel": "Hủy",
"ok": "Xác nhận"
},
"content": "Bạn có chắc chắn muốn xóa API Key này không?",
"title": "Xác nhận thao tác"
}
},
"columns": {
"actions": "Thao tác",
"expiresAt": "Thời gian hết hạn",
"key": "Khóa",
"lastUsedAt": "Lần sử dụng cuối",
"name": "Tên",
"status": "Trạng thái kích hoạt"
},
"title": "Danh sách API Key"
},
"validation": {
"required": "Nội dung không được để trống"
}
},
"date": {
"prevMonth": "Tháng trước",
"recent30Days": "30 ngày qua"
@@ -89,6 +142,7 @@
"words": "Từ"
},
"tab": {
"apikey": "Quản lý API Key",
"profile": "Hồ sơ",
"security": "Bảo mật",
"stats": "Thống kê"
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "自动生成",
"copy": "复制",
"copyError": "复制失败",
"copySuccess": "API Key 已复制到剪贴板",
"enterPlaceholder": "请输入",
"hide": "隐藏",
"neverExpires": "永不过期",
"neverUsed": "从未使用",
"show": "显示"
},
"form": {
"fields": {
"expiresAt": {
"label": "过期时间",
"placeholder": "永不过期"
},
"name": {
"label": "名称",
"placeholder": "请输入 API Key 名称"
}
},
"submit": "创建",
"title": "创建 API Key"
},
"list": {
"actions": {
"create": "创建 API Key",
"delete": "删除",
"deleteConfirm": {
"actions": {
"cancel": "取消",
"ok": "确认"
},
"content": "确认删除该 API Key 吗?",
"title": "确认操作"
}
},
"columns": {
"actions": "操作",
"expiresAt": "过期时间",
"key": "密钥",
"lastUsedAt": "最后使用时间",
"name": "名称",
"status": "启用状态"
},
"title": "API Key 列表"
},
"validation": {
"required": "内容不得为空"
}
},
"date": {
"prevMonth": "上个月",
"recent30Days": "最近30天"
@@ -89,6 +142,7 @@
"words": "累计字数"
},
"tab": {
"apikey": "API Key 管理",
"profile": "个人资料",
"security": "安全",
"stats": "数据统计"
+1
View File
@@ -535,6 +535,7 @@
"experiment": "实验",
"hotkey": "快捷键",
"llm": "语言模型",
"plugin": "插件管理",
"provider": "AI 服务商",
"proxy": "网络代理",
"storage": "数据存储",
+54
View File
@@ -1,4 +1,57 @@
{
"apikey": {
"display": {
"autoGenerated": "自動生成",
"copy": "複製",
"copyError": "複製失敗",
"copySuccess": "API Key 已複製到剪貼簿",
"enterPlaceholder": "請輸入",
"hide": "隱藏",
"neverExpires": "永不過期",
"neverUsed": "從未使用",
"show": "顯示"
},
"form": {
"fields": {
"expiresAt": {
"label": "過期時間",
"placeholder": "永不過期"
},
"name": {
"label": "名稱",
"placeholder": "請輸入 API Key 名稱"
}
},
"submit": "建立",
"title": "建立 API Key"
},
"list": {
"actions": {
"create": "建立 API Key",
"delete": "刪除",
"deleteConfirm": {
"actions": {
"cancel": "取消",
"ok": "確認"
},
"content": "確認刪除該 API Key 嗎?",
"title": "確認操作"
}
},
"columns": {
"actions": "操作",
"expiresAt": "過期時間",
"key": "密鑰",
"lastUsedAt": "最後使用時間",
"name": "名稱",
"status": "啟用狀態"
},
"title": "API Key 列表"
},
"validation": {
"required": "內容不得為空"
}
},
"date": {
"prevMonth": "上個月",
"recent30Days": "最近30天"
@@ -89,6 +142,7 @@
"words": "總字數"
},
"tab": {
"apikey": "API Key 管理",
"profile": "個人資料",
"security": "安全",
"stats": "數據統計"
+1 -1
View File
@@ -196,7 +196,7 @@
"i18next-resources-to-backend": "^1.2.1",
"idb-keyval": "^6.2.2",
"immer": "^10.1.1",
"jose": "^5.10.0",
"jose": "^6.0.12",
"js-sha256": "^0.11.1",
"jsonl-parse-stringify": "^1.0.3",
"keyv": "^4.5.4",
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentRuntimeError } from '@/libs/model-runtime';
import { ChatErrorType } from '@/types/fetch';
import { createErrorResponse } from '@/utils/errorResponse';
import { getJWTPayload } from '@/utils/server/jwt';
import { getXorPayload } from '@/utils/server/xor';
import { RequestHandler, checkAuth } from './index';
import { checkAuthMethod } from './utils';
@@ -20,8 +20,8 @@ vi.mock('./utils', () => ({
checkAuthMethod: vi.fn(),
}));
vi.mock('@/utils/server/jwt', () => ({
getJWTPayload: vi.fn(),
vi.mock('@/utils/server/xor', () => ({
getXorPayload: vi.fn(),
}));
describe('checkAuth', () => {
@@ -50,7 +50,7 @@ describe('checkAuth', () => {
it('should return error response on getJWTPayload error', async () => {
const mockError = AgentRuntimeError.createError(ChatErrorType.Unauthorized);
mockRequest.headers.set('Authorization', 'invalid');
vi.mocked(getJWTPayload).mockRejectedValueOnce(mockError);
vi.mocked(getXorPayload).mockRejectedValueOnce(mockError);
await checkAuth(mockHandler)(mockRequest, mockOptions);
@@ -64,7 +64,7 @@ describe('checkAuth', () => {
it('should return error response on checkAuthMethod error', async () => {
const mockError = AgentRuntimeError.createError(ChatErrorType.Unauthorized);
mockRequest.headers.set('Authorization', 'valid');
vi.mocked(getJWTPayload).mockResolvedValueOnce({});
vi.mocked(getXorPayload).mockResolvedValueOnce({});
vi.mocked(checkAuthMethod).mockImplementationOnce(() => {
throw mockError;
});
+6 -6
View File
@@ -2,7 +2,7 @@ import { AuthObject } from '@clerk/backend';
import { NextRequest } from 'next/server';
import {
JWTPayload,
ClientSecretPayload,
LOBE_CHAT_AUTH_HEADER,
LOBE_CHAT_OIDC_AUTH_HEADER,
OAUTH_AUTHORIZED,
@@ -13,18 +13,18 @@ import { AgentRuntime, AgentRuntimeError, ChatCompletionErrorPayload } from '@/l
import { validateOIDCJWT } from '@/libs/oidc-provider/jwt';
import { ChatErrorType } from '@/types/fetch';
import { createErrorResponse } from '@/utils/errorResponse';
import { getJWTPayload } from '@/utils/server/jwt';
import { getXorPayload } from '@/utils/server/xor';
import { checkAuthMethod } from './utils';
type CreateRuntime = (jwtPayload: JWTPayload) => AgentRuntime;
type CreateRuntime = (jwtPayload: ClientSecretPayload) => AgentRuntime;
type RequestOptions = { createRuntime?: CreateRuntime; params: Promise<{ provider: string }> };
export type RequestHandler = (
req: Request,
options: RequestOptions & {
createRuntime?: CreateRuntime;
jwtPayload: JWTPayload;
jwtPayload: ClientSecretPayload;
},
) => Promise<Response>;
@@ -36,7 +36,7 @@ export const checkAuth =
return handler(req, { ...options, jwtPayload: { userId: 'DEV_USER' } });
}
let jwtPayload: JWTPayload;
let jwtPayload: ClientSecretPayload;
try {
// get Authorization from header
@@ -55,7 +55,7 @@ export const checkAuth =
clerkAuth = data.clerkAuth;
}
jwtPayload = await getJWTPayload(authorization);
jwtPayload = getXorPayload(authorization);
const oidcAuthorization = req.headers.get(LOBE_CHAT_OIDC_AUTH_HEADER);
let isUseOidcAuth = false;
@@ -6,7 +6,7 @@ import { checkAuthMethod } from '@/app/(backend)/middleware/auth/utils';
import { LOBE_CHAT_AUTH_HEADER, OAUTH_AUTHORIZED } from '@/const/auth';
import { AgentRuntime, LobeRuntimeAI } from '@/libs/model-runtime';
import { ChatErrorType } from '@/types/fetch';
import { getJWTPayload } from '@/utils/server/jwt';
import { getXorPayload } from '@/utils/server/xor';
import { POST } from './route';
@@ -18,8 +18,8 @@ vi.mock('@/app/(backend)/middleware/auth/utils', () => ({
checkAuthMethod: vi.fn(),
}));
vi.mock('@/utils/server/jwt', () => ({
getJWTPayload: vi.fn(),
vi.mock('@/utils/server/xor', () => ({
getXorPayload: vi.fn(),
}));
// 定义一个变量来存储 enableAuth 的值
@@ -61,7 +61,7 @@ describe('POST handler', () => {
const mockParams = Promise.resolve({ provider: 'test-provider' });
// 设置 getJWTPayload 和 initAgentRuntimeWithUserPayload 的模拟返回值
vi.mocked(getJWTPayload).mockResolvedValueOnce({
vi.mocked(getXorPayload).mockReturnValueOnce({
accessCode: 'test-access-code',
apiKey: 'test-api-key',
azureApiVersion: 'v1',
@@ -78,7 +78,7 @@ describe('POST handler', () => {
await POST(request as unknown as Request, { params: mockParams });
// 验证是否正确调用了模拟函数
expect(getJWTPayload).toHaveBeenCalledWith('Bearer some-valid-token');
expect(getXorPayload).toHaveBeenCalledWith('Bearer some-valid-token');
expect(spy).toHaveBeenCalledWith('test-provider', expect.anything());
});
@@ -104,7 +104,7 @@ describe('POST handler', () => {
it('should have pass clerk Auth when enable clerk', async () => {
enableClerk = true;
vi.mocked(getJWTPayload).mockResolvedValueOnce({
vi.mocked(getXorPayload).mockReturnValueOnce({
accessCode: 'test-access-code',
apiKey: 'test-api-key',
azureApiVersion: 'v1',
@@ -142,7 +142,9 @@ describe('POST handler', () => {
it('should return InternalServerError error when throw a unknown error', async () => {
const mockParams = Promise.resolve({ provider: 'test-provider' });
vi.mocked(getJWTPayload).mockRejectedValueOnce(new Error('unknown error'));
vi.mocked(getXorPayload).mockImplementationOnce(() => {
throw new Error('unknown error');
});
const response = await POST(request, { params: mockParams });
@@ -159,7 +161,7 @@ describe('POST handler', () => {
describe('chat', () => {
it('should correctly handle chat completion with valid payload', async () => {
vi.mocked(getJWTPayload).mockResolvedValueOnce({
vi.mocked(getXorPayload).mockReturnValueOnce({
accessCode: 'test-access-code',
apiKey: 'test-api-key',
azureApiVersion: 'v1',
@@ -189,7 +191,7 @@ describe('POST handler', () => {
it('should return an error response when chat completion fails', async () => {
// 设置 getJWTPayload 和 initAgentRuntimeWithUserPayload 的模拟返回值
vi.mocked(getJWTPayload).mockResolvedValueOnce({
vi.mocked(getXorPayload).mockReturnValueOnce({
accessCode: 'test-access-code',
apiKey: 'test-api-key',
azureApiVersion: 'v1',
@@ -8,7 +8,7 @@ import { AgentRuntimeError } from '@/libs/model-runtime';
import { TraceClient } from '@/libs/traces';
import { ChatErrorType, ErrorType } from '@/types/fetch';
import { createErrorResponse } from '@/utils/errorResponse';
import { getJWTPayload } from '@/utils/server/jwt';
import { getXorPayload } from '@/utils/server/xor';
import { getTracePayload } from '@/utils/trace';
import { parserPluginSettings } from './settings';
@@ -44,7 +44,7 @@ export const POST = async (req: Request) => {
if (!authorization) throw AgentRuntimeError.createError(ChatErrorType.Unauthorized);
const oauthAuthorized = !!req.headers.get(OAUTH_AUTHORIZED);
const payload = await getJWTPayload(authorization);
const payload = getXorPayload(authorization);
const result = checkAuth(payload.accessCode!, oauthAuthorized);
@@ -18,7 +18,7 @@ import { LayoutProps } from '../type';
import Header from './Header';
import SideBar from './SideBar';
const SKIP_PATHS = ['/settings/provider', '/settings/agent'];
const SKIP_PATHS = ['/settings/provider', '/settings/agent', '/settings/plugin'];
const Layout = memo<LayoutProps>(({ children, category }) => {
const ref = useRef<any>(null);
@@ -8,6 +8,7 @@ import {
Info,
KeyboardIcon,
Mic2,
Puzzle,
Settings2,
Sparkles,
} from 'lucide-react';
@@ -115,6 +116,15 @@ export const useCategory = () => {
</Link>
),
},
{
icon: <Icon icon={Puzzle} />,
key: SettingsTabs.Plugin,
label: (
<Link href={'/settings/plugin'} onClick={(e) => e.preventDefault()}>
{t('tab.plugin')}
</Link>
),
},
{
type: 'divider',
},
@@ -0,0 +1,23 @@
import { memo } from 'react';
import McpDetail from '@/features/PluginStore/McpList/Detail';
import PluginDetail from '@/features/PluginStore/PluginList/Detail';
import CustomPluginEmptyState from '@/features/PluginStore/InstalledList/Detail/CustomPluginEmptyState';
interface DetailProps {
identifier: string;
runtimeType?: 'mcp' | 'default';
type?: 'plugin' | 'customPlugin' | 'builtin';
}
const Detail = memo<DetailProps>(({ identifier, type, runtimeType }) => {
if (type === 'customPlugin') return <CustomPluginEmptyState identifier={identifier} />;
if (runtimeType === 'mcp') return <McpDetail identifier={identifier} />;
if (type === 'plugin') return <PluginDetail identifier={identifier} />;
return null;
});
export default Detail;
@@ -0,0 +1,77 @@
import { Empty } from 'antd';
import isEqual from 'fast-deep-equal';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { Virtuoso } from 'react-virtuoso';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { LobeToolType } from '@/types/tool/tool';
import PluginItem from '@/features/PluginStore/InstalledList/List/Item';
interface ListProps {
identifier?: string;
keywords?: string;
setIdentifier?: (props: {
identifier?: string;
runtimeType: 'mcp' | 'default';
type?: LobeToolType;
}) => void;
}
export const List = memo<ListProps>(({ keywords, identifier, setIdentifier }) => {
const { t } = useTranslation('plugin');
const installedPlugins = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
const filteredPluginList = useMemo(
() =>
installedPlugins.filter((item) =>
[item?.title, item?.description, item.author, ...(item?.tags || [])]
.filter(Boolean)
.join('')
.toLowerCase()
.includes((keywords || '')?.toLowerCase()),
),
[installedPlugins, keywords],
);
const isEmpty = installedPlugins.length === 0;
if (isEmpty)
return (
<Center paddingBlock={40}>
<Empty description={t('store.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
);
return (
<Virtuoso
data={filteredPluginList}
itemContent={(_, item) => {
return (
<Flexbox
key={item.identifier}
onClick={() => {
setIdentifier?.({
identifier: item.identifier,
runtimeType: item.runtimeType as any,
type: item.type,
});
}}
paddingBlock={2}
paddingInline={4}
>
<PluginItem active={identifier === item.identifier} {...(item as any)} />
</Flexbox>
);
}}
overscan={400}
style={{ height: '100%', width: '100%' }}
totalCount={filteredPluginList.length}
/>
);
});
export default List;
@@ -0,0 +1,101 @@
'use client';
import { DraggablePanel } from '@lobehub/ui';
import { Empty, Input } from 'antd';
import { useTheme } from 'antd-style';
import { Search } from 'lucide-react';
import { memo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { LobeToolType } from '@/types/tool/tool';
import Detail from './components/Detail';
import List from './components/List';
const PluginSettings = memo(() => {
const { t } = useTranslation('plugin');
const ref = useRef<HTMLDivElement>(null);
const theme = useTheme();
const [keywords, setKeywords] = useState<string>('');
const [type, setType] = useState<LobeToolType>();
const [runtimeType, setRuntimeType] = useState<'mcp' | 'default'>();
const [identifier] = useToolStore((s) => [s.activePluginIdentifier]);
const isEmpty = useToolStore((s) => pluginSelectors.installedPluginMetaList(s).length === 0);
useFetchInstalledPlugins();
if (isEmpty)
return (
<Center height={'60vh'} paddingBlock={40}>
<Empty description={t('store.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
);
return (
<Flexbox
height={'100vh'}
horizontal
style={{
overflow: 'hidden',
position: 'relative',
}}
width={'100%'}
>
<DraggablePanel maxWidth={1024} minWidth={420} placement={'left'}>
<Flexbox padding={8}>
<Input
allowClear
onChange={(e) => setKeywords(e.target.value)}
placeholder={t('store.search')}
prefix={<Search size={16} />}
style={{ width: '100%' }}
value={keywords}
/>
</Flexbox>
<List
identifier={identifier}
keywords={keywords}
setIdentifier={({ identifier, type, runtimeType }) => {
useToolStore.setState({ activePluginIdentifier: identifier });
setType(type);
setRuntimeType(runtimeType);
ref?.current?.scrollTo({ top: 0 });
}}
/>
</DraggablePanel>
{identifier ? (
<Flexbox
height={'100%'}
padding={16}
ref={ref}
style={{
background: theme.colorBgContainerSecondary,
overflowX: 'hidden',
overflowY: 'auto',
}}
width={'100%'}
>
<Detail identifier={identifier} runtimeType={runtimeType} type={type} />
</Flexbox>
) : (
<Center
height={'100%'}
style={{
background: theme.colorBgContainerSecondary,
}}
width={'100%'}
>
<Empty description={t('store.emptySelectHint')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</Flexbox>
);
});
export default PluginSettings;
@@ -0,0 +1,17 @@
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { DynamicLayoutProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const locale = await RouteVariants.getLocale(props);
const { t } = await translation('setting', locale);
return metadataModule.generate({
description: t('header.desc'),
title: t('tab.plugin'),
url: '/settings/plugin',
});
};
export { default } from './index';
+269 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
const giteeaiChatModels: AIChatModelCard[] = [
{
@@ -222,6 +222,273 @@ const giteeaiChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...giteeaiChatModels];
const giteeaiImageModels: AIImageModelCard[] = [
{
description:
'FLUX.1-dev 是由 Black Forest Labs 开发的一款开源 多模态语言模型(Multimodal Language Model, MLLM),专为图文任务优化,融合了图像和文本的理解与生成能力。它建立在先进的大语言模型(如 Mistral-7B)基础上,通过精心设计的视觉编码器与多阶段指令微调,实现了图文协同处理与复杂任务推理的能力。',
displayName: 'FLUX.1-dev',
enabled: true,
id: 'FLUX.1-dev',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024', '1536x1536'],
},
},
type: 'image',
},
{
description:
'由 Black Forest Labs 开发的 120 亿参数文生图模型,采用潜在对抗扩散蒸馏技术,能够在 1 到 4 步内生成高质量图像。该模型性能媲美闭源替代品,并在 Apache-2.0 许可证下发布,适用于个人、科研和商业用途。',
displayName: 'flux-1-schnell',
enabled: true,
id: 'flux-1-schnell',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024', '1536x1536', '2048x2048'],
},
},
type: 'image',
},
{
description:
'FLUX.1-Kontext-dev 是由 Black Forest Labs 开发的一款基于 Rectified Flow Transformer 架构 的多模态图像生成与编辑模型,拥有 12B(120 亿)参数规模,专注于在给定上下文条件下生成、重构、增强或编辑图像。该模型结合了扩散模型的可控生成优势与 Transformer 的上下文建模能力,支持高质量图像输出,广泛适用于图像修复、图像补全、视觉场景重构等任务。',
displayName: 'FLUX.1-Kontext-dev',
enabled: true,
id: 'FLUX.1-Kontext-dev',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024', '1536x1536', '2048x2048'],
},
},
type: 'image',
},
{
description:
'Stable Diffusion 3.5 Large Turbo 专注于高质量图像生成,具备强大的细节表现力和场景还原能力。',
displayName: 'stable-diffusion-3.5-large-turbo',
enabled: true,
id: 'stable-diffusion-3.5-large-turbo',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'由 Stability AI 推出的最新文生图大模型。这一版本在继承了前代的优点上,对图像质量、文本理解和风格多样性等方面进行了显著改进,能够更准确地解读复杂的自然语言提示,并生成更为精确和多样化的图像。',
displayName: 'stable-diffusion-3-medium',
enabled: true,
id: 'stable-diffusion-3-medium',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'由 Stability AI 开发并开源的文生图大模型,其创意图像生成能力位居行业前列。具备出色的指令理解能力,能够支持反向 Prompt 定义来精确生成内容。',
displayName: 'stable-diffusion-xl-base-1.0',
enabled: true,
id: 'stable-diffusion-xl-base-1.0',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'Kolors 是由快手 Kolors 团队开发的文生图模型。由数十亿的参数训练,在视觉质量、中文语义理解和文本渲染方面有显著优势。',
displayName: 'Kolors',
enabled: true,
id: 'Kolors',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'hunyuandit-v1.2-distilled 是一款轻量级的文生图模型,经过蒸馏优化,能够快速生成高质量的图像,特别适用于低资源环境和实时生成任务。',
displayName: 'HunyuanDiT-v1.2-Diffusers-Distilled',
enabled: true,
id: 'HunyuanDiT-v1.2-Diffusers-Distilled',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'HiDream-I1 是一个全新的开源图像生成基础模型,是由国内企业智象未来开源的。拥有 170 亿参数(Flux是12B参数),能够在几秒内实现行业领先的图像生成质量。',
displayName: 'HiDream-I1-Full',
enabled: true,
id: 'HiDream-I1-Full',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'HiDream-E1-Full 是由智象未来(HiDream.ai)推出的一款 开源多模态图像编辑大模型,基于先进的 Diffusion Transformer 架构,并结合强大的语言理解能力(内嵌 LLaMA 3.1-8B-Instruct),支持通过自然语言指令进行图像生成、风格迁移、局部编辑和内容重绘,具备出色的图文理解与执行能力。',
displayName: 'HiDream-E1-Full',
enabled: true,
id: 'HiDream-I1-Full',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'HelloMeme 是一个可以根据你提供的图片或动作,自动生成表情包、动图或短视频的 AI 工具。它不需要你有任何绘画或编程基础,只需要准备好参考图片,它就能帮你做出好看、有趣、风格一致的内容。',
displayName: 'HelloMeme',
enabled: true,
id: 'HelloMeme',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'OmniConsistency 通过引入大规模 Diffusion TransformersDiTs)和配对风格化数据,提升图像到图像(Image-to-Image)任务中的风格一致性和泛化能力,避免风格退化。',
displayName: 'OmniConsistency',
enabled: true,
id: 'OmniConsistency',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'InstantCharacter 是由腾讯 AI 团队在 2025 年发布的一款 无需微调(tuning-free) 的个性化角色生成模型,旨在实现高保真、跨场景的一致角色生成。该模型支持仅基于 一张参考图像 对角色进行建模,并能够将该角色灵活迁移到各种风格、动作和背景中。',
displayName: 'InstantCharacter',
enabled: true,
id: 'InstantCharacter',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'DreamO 是由字节跳动与北京大学联合研发的开源图像定制生成模型,旨在通过统一架构支持多任务图像生成。它采用高效的组合建模方法,可根据用户指定的身份、主体、风格、背景等多个条件生成高度一致且定制化的图像。',
displayName: 'DreamO',
enabled: true,
id: 'DreamO',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
{
description:
'AnimeSharp(又名 “4xAnimeSharp”) 是 Kim2091 基于 ESRGAN 架构开发的开源超分辨率模型,专注于动漫风格图像的放大与锐化。它于 2022 年 2 月重命名自 “4x-TextSharpV1”,原本也适用于文字图像但性能针对动漫内容进行了大幅优化',
displayName: 'AnimeSharp',
enabled: true,
id: 'AnimeSharp',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024'],
},
},
type: 'image',
},
];
export const allModels = [...giteeaiChatModels, ...giteeaiImageModels];
export default allModels;
+24 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
// https://siliconflow.cn/zh-cn/models
const siliconcloudChatModels: AIChatModelCard[] = [
@@ -830,6 +830,28 @@ const siliconcloudChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...siliconcloudChatModels];
const siliconcloudImageModels: AIImageModelCard[] = [
{
description:
'Kolors 是由快手 Kolors 团队开发的基于潜在扩散的大规模文本到图像生成模型。该模型通过数十亿文本-图像对的训练,在视觉质量、复杂语义准确性以及中英文字符渲染方面展现出显著优势。它不仅支持中英文输入,在理解和生成中文特定内容方面也表现出色',
displayName: 'Kolors',
enabled: true,
id: 'Kwai-Kolors/Kolors',
parameters: {
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['1024x1024', '960x1280', '768x1024', '720x1440', '720x1280'],
},
},
releasedAt: '2024-07-06',
type: 'image',
},
];
export const allModels = [...siliconcloudChatModels, ...siliconcloudImageModels];
export default allModels;
+67 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
// https://platform.stepfun.com/docs/pricing/details
@@ -275,6 +275,71 @@ const stepfunChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...stepfunChatModels];
const stepfunImageModels: AIImageModelCard[] = [
// https://platform.stepfun.com/docs/llm/image
{
description:
'阶跃星辰新一代生图模型,该模型专注于图像生成任务,能够根据用户提供的文本描述,生成高质量的图像。新模型生成图片质感更真实,中英文文字生成能力更强。',
displayName: 'Step 2X Large',
enabled: true,
id: 'step-2x-large',
parameters: {
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['256x256', '512x512', '768x768', '1024x1024', '1280x800', '800x1280'],
},
steps: { default: 50, max: 100, min: 1 },
},
releasedAt: '2024-08-07',
type: 'image',
},
{
description:
'该模型拥有强大的图像生成能力,支持文本描述作为输入方式。具备原生的中文支持,能够更好的理解和处理中文文本描述,并且能够更准确地捕捉文本描述中的语义信息,并将其转化为图像特征,从而实现更精准的图像生成。模型能够根据输入生成高分辨率、高质量的图像,并具备一定的风格迁移能力。',
displayName: 'Step 1X Medium',
enabled: true,
id: 'step-1x-medium',
parameters: {
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['256x256', '512x512', '768x768', '1024x1024', '1280x800', '800x1280'],
},
steps: { default: 50, max: 100, min: 1 },
},
releasedAt: '2025-07-15',
type: 'image',
},
{
description:
'该模型专注于图像编辑任务,能够根据用户提供的图片和文本描述,对图片进行修改和增强。支持多种输入格式,包括文本描述和示例图像。模型能够理解用户的意图,并生成符合要求的图像编辑结果。',
displayName: 'Step 1X Edit',
enabled: true,
id: 'step-1x-edit',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['512x512', '768x768', '1024x1024'],
},
steps: { default: 28, max: 100, min: 1 },
},
releasedAt: '2025-03-04',
type: 'image',
},
];
export const allModels = [...stepfunChatModels, ...stepfunImageModels];
export default allModels;
+56 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
// modelInfo https://www.volcengine.com/docs/82379/1330310
// pricing https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement
@@ -509,6 +509,60 @@ const doubaoChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...doubaoChatModels];
const volcengineImageModels: AIImageModelCard[] = [
{
/*
// TODO: AIImageModelCard 不支持 config.deploymentName
config: {
deploymentName: 'doubao-seedream-3-0-t2i-250415',
},
*/
description:
'Doubao图片生成模型由字节跳动 Seed 团队研发,支持文字与图片输入,提供高可控、高质量的图片生成体验。基于文本提示词生成图片。',
displayName: 'Doubao Seedream 3.0 t2i',
enabled: true,
id: 'doubao-seedream-3-0-t2i-250415',
parameters: {
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['1024x1024', '864x1152', '1152x864', '1280x720', '720x1280', '832x1248', '1248x832', '1512x648'],
},
},
releasedAt: '2025-04-15',
type: 'image',
},
/*
// Note: Doubao 图生图模型与文生图模型公用一个 Endpoint,当前如果存在 imageUrl 会切换至 edit endpoint 下
{
config: {
deploymentName: 'doubao-seededit-3-0-i2i-250628',
},
description:
'Doubao图片生成模型由字节跳动 Seed 团队研发,支持文字与图片输入,提供高可控、高质量的图片生成体验。支持通过文本指令编辑图像,生成图像的边长在512~1536之间。',
displayName: 'Doubao SeedEdit 3.0 i2i',
enabled: true,
id: 'doubao-seededit-3-0-i2i-250628',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['1024x1024', '864x1152', '1152x864', '1280x720', '720x1280', '832x1248', '1248x832', '1512x648'],
},
},
releasedAt: '2025-06-28',
type: 'image',
},
*/
];
export const allModels = [...doubaoChatModels, ...volcengineImageModels];
export default allModels;
+62 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
const wenxinChatModels: AIChatModelCard[] = [
{
@@ -564,6 +564,66 @@ const wenxinChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...wenxinChatModels];
const wenxinImageModels: AIImageModelCard[] = [
{
description:
'百度自研的iRAGimage based RAG),检索增强的文生图技术,将百度搜索的亿级图片资源跟强大的基础模型能力相结合,就可以生成各种超真实的图片,整体效果远远超过文生图原生系统,去掉了AI味儿,而且成本很低。iRAG具备无幻觉、超真实、立等可取等特点。',
displayName: 'ERNIE iRAG',
enabled: true,
id: 'irag-1.0',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['768x768', '1024x1024', '1536x1536', '2048x2048', '1024x768', '2048x1536', '768x1024', '1536x2048', '1024x576', '2048x1152', '576x1024', '1152x2048'],
},
},
releasedAt: '2025-02-05',
type: 'image',
},
{
description:
'百度自研的ERNIE iRAG Edit图像编辑模型支持基于图片进行erase(消除对象)、repaint(重绘对象)、variation(生成变体)等操作。',
displayName: 'ERNIE iRAG Edit',
enabled: true,
id: 'ernie-irag-edit',
parameters: {
imageUrl: { default: null },
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['768x768', '1024x1024', '1536x1536', '2048x2048', '1024x768', '2048x1536', '768x1024', '1536x2048', '1024x576', '2048x1152', '576x1024', '1152x2048'],
},
},
releasedAt: '2025-04-17',
type: 'image',
},
{
description:
'具有120亿参数的修正流变换器,能够根据文本描述生成图像。',
displayName: 'FLUX.1-schnell',
enabled: true,
id: 'flux.1-schnell',
parameters: {
prompt: {
default: '',
},
seed: { default: null },
size: {
default: '1024x1024',
enum: ['768x768', '1024x1024', '1536x1536', '2048x2048', '1024x768', '2048x1536', '768x1024', '1536x2048', '1024x576', '2048x1152', '576x1024', '1152x2048'],
},
steps: { default: 25, max: 50, min: 1 },
},
releasedAt: '2025-03-27',
type: 'image',
},
];
export const allModels = [...wenxinChatModels, ...wenxinImageModels];
export default allModels;
+19 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
// https://docs.x.ai/docs/models
const xaiChatModels: AIChatModelCard[] = [
@@ -158,6 +158,23 @@ const xaiChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...xaiChatModels];
const xaiImageModels: AIImageModelCard[] = [
{
description:
'我们最新的图像生成模型可以根据文本提示生成生动逼真的图像。它在营销、社交媒体和娱乐等领域的图像生成方面表现出色。',
displayName: 'Grok 2 Image 1212',
enabled: true,
id: 'grok-2-image-1212',
parameters: {
prompt: {
default: '',
},
},
releasedAt: '2024-12-12',
type: 'image',
},
];
export const allModels = [...xaiChatModels, ...xaiImageModels];
export default allModels;
+2 -3
View File
@@ -9,11 +9,10 @@ export const LOBE_CHAT_OIDC_AUTH_HEADER = 'Oidc-Auth';
export const OAUTH_AUTHORIZED = 'X-oauth-authorized';
export const JWT_SECRET_KEY = 'LobeHub · LobeChat';
export const NON_HTTP_PREFIX = 'http_nosafe';
export const SECRET_XOR_KEY = 'LobeHub · LobeHub';
/* eslint-disable typescript-sort-keys/interface */
export interface JWTPayload {
export interface ClientSecretPayload {
/**
* password
*/
+3 -3
View File
@@ -4,7 +4,7 @@ import { LangfuseGenerationClient, LangfuseTraceClient } from 'langfuse-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as langfuseCfg from '@/config/langfuse';
import { JWTPayload } from '@/const/auth';
import { ClientSecretPayload } from '@/const/auth';
import { TraceNameMap } from '@/const/trace';
import { AgentRuntime, ChatStreamPayload, LobeOpenAI, ModelProvider } from '@/libs/model-runtime';
import { providerRuntimeMap } from '@/libs/model-runtime/runtimeMap';
@@ -51,7 +51,7 @@ const specialProviders = [
const testRuntime = (providerId: string, payload?: any) => {
describe(`${providerId} provider runtime`, () => {
it('should initialize correctly', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-key', ...payload };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-key', ...payload };
const runtime = await AgentRuntime.initializeWithProvider(providerId, jwtPayload);
// @ts-ignore
@@ -66,7 +66,7 @@ const testRuntime = (providerId: string, payload?: any) => {
let mockModelRuntime: AgentRuntime;
beforeEach(async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-openai-key', baseURL: 'user-endpoint' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-openai-key', baseURL: 'user-endpoint' };
mockModelRuntime = await AgentRuntime.initializeWithProvider(ModelProvider.OpenAI, jwtPayload);
});
+3 -3
View File
@@ -1,14 +1,14 @@
import debug from 'debug';
import { NextRequest } from 'next/server';
import { JWTPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { ClientSecretPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { LobeChatDatabase } from '@/database/type';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
const log = debug('lobe-async:context');
export interface AsyncAuthContext {
jwtPayload: JWTPayload;
jwtPayload: ClientSecretPayload;
secret: string;
serverDB?: LobeChatDatabase;
userId?: string | null;
@@ -19,7 +19,7 @@ export interface AsyncAuthContext {
* This is useful for testing when we don't want to mock Next.js' request/response
*/
export const createAsyncContextInner = async (params?: {
jwtPayload?: JWTPayload;
jwtPayload?: ClientSecretPayload;
secret?: string;
userId?: string | null;
}): Promise<AsyncAuthContext> => ({
+7 -2
View File
@@ -1,13 +1,18 @@
import { User } from 'next-auth';
import { NextRequest } from 'next/server';
import { JWTPayload, LOBE_CHAT_AUTH_HEADER, enableClerk, enableNextAuth } from '@/const/auth';
import {
ClientSecretPayload,
LOBE_CHAT_AUTH_HEADER,
enableClerk,
enableNextAuth,
} from '@/const/auth';
import { ClerkAuth, IClerkAuth } from '@/libs/clerk-auth';
export interface AuthContext {
authorizationHeader?: string | null;
clerkAuth?: IClerkAuth;
jwtPayload?: JWTPayload | null;
jwtPayload?: ClientSecretPayload | null;
nextAuth?: User;
userId?: string | null;
}
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createCallerFactory } from '@/libs/trpc/edge';
import { AuthContext, createContextInner } from '@/libs/trpc/edge/context';
import { edgeTrpc as trpc } from '@/libs/trpc/edge/init';
import * as utils from '@/utils/server/jwt';
import * as utils from '@/utils/server/xor';
import { jwtPayloadChecker } from './jwtPayload';
@@ -40,7 +40,7 @@ describe('passwordChecker middleware', () => {
it('should call next with jwtPayload in context if access code is correct', async () => {
const jwtPayload = { accessCode: '123' };
vi.spyOn(utils, 'getJWTPayload').mockResolvedValue(jwtPayload);
vi.spyOn(utils, 'getXorPayload').mockResolvedValue(jwtPayload);
ctx = await createContextInner({ authorizationHeader: 'Bearer token' });
router = createCaller(ctx);
@@ -52,7 +52,7 @@ describe('passwordChecker middleware', () => {
it('should call next with jwtPayload in context if no access codes are set', async () => {
const jwtPayload = {};
vi.spyOn(utils, 'getJWTPayload').mockResolvedValue(jwtPayload);
vi.spyOn(utils, 'getXorPayload').mockResolvedValue(jwtPayload);
ctx = await createContextInner({ authorizationHeader: 'Bearer token' });
router = createCaller(ctx);
@@ -63,7 +63,7 @@ describe('passwordChecker middleware', () => {
});
it('should call next with jwtPayload in context if access codes is undefined', async () => {
const jwtPayload = {};
vi.spyOn(utils, 'getJWTPayload').mockResolvedValue(jwtPayload);
vi.spyOn(utils, 'getXorPayload').mockResolvedValue(jwtPayload);
ctx = await createContextInner({ authorizationHeader: 'Bearer token' });
router = createCaller(ctx);
+2 -2
View File
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { getJWTPayload } from '@/utils/server/jwt';
import { getXorPayload } from '@/utils/server/xor';
import { edgeTrpc } from '../init';
@@ -9,7 +9,7 @@ export const jwtPayloadChecker = edgeTrpc.middleware(async (opts) => {
if (!ctx.authorizationHeader) throw new TRPCError({ code: 'UNAUTHORIZED' });
const jwtPayload = await getJWTPayload(ctx.authorizationHeader);
const jwtPayload = getXorPayload(ctx.authorizationHeader);
return opts.next({ ctx: { jwtPayload } });
});
+2 -2
View File
@@ -4,7 +4,7 @@ import { User } from 'next-auth';
import { NextRequest } from 'next/server';
import {
JWTPayload,
ClientSecretPayload,
LOBE_CHAT_AUTH_HEADER,
LOBE_CHAT_OIDC_AUTH_HEADER,
enableClerk,
@@ -29,7 +29,7 @@ export interface OIDCAuth {
export interface AuthContext {
authorizationHeader?: string | null;
clerkAuth?: IClerkAuth;
jwtPayload?: JWTPayload | null;
jwtPayload?: ClientSecretPayload | null;
marketAccessToken?: string;
nextAuth?: User;
// Add OIDC authentication information
+2 -2
View File
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { getJWTPayload } from '@/utils/server/jwt';
import { getXorPayload } from '@/utils/server/xor';
import { trpc } from '../init';
@@ -10,7 +10,7 @@ export const keyVaults = trpc.middleware(async (opts) => {
if (!ctx.authorizationHeader) throw new TRPCError({ code: 'UNAUTHORIZED' });
try {
const jwtPayload = await getJWTPayload(ctx.authorizationHeader);
const jwtPayload = getXorPayload(ctx.authorizationHeader);
return opts.next({ ctx: { jwtPayload } });
} catch (e) {
+28 -25
View File
@@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { JWTPayload } from '@/const/auth';
import { ClientSecretPayload } from '@/const/auth';
import {
LobeAnthropicAI,
LobeAzureOpenAI,
@@ -67,7 +67,10 @@ vi.mock('@/config/llm', () => ({
describe('initAgentRuntimeWithUserPayload method', () => {
describe('should initialize with options correctly', () => {
it('OpenAI provider: with apikey and endpoint', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-openai-key', baseURL: 'user-endpoint' };
const jwtPayload: ClientSecretPayload = {
apiKey: 'user-openai-key',
baseURL: 'user-endpoint',
};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.OpenAI, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenAI);
@@ -75,7 +78,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Azure AI provider: with apikey, endpoint and apiversion', async () => {
const jwtPayload: JWTPayload = {
const jwtPayload: ClientSecretPayload = {
apiKey: 'user-azure-key',
baseURL: 'user-azure-endpoint',
azureApiVersion: '2024-06-01',
@@ -87,35 +90,35 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('ZhiPu AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'zhipu.user-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'zhipu.user-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.ZhiPu, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeZhipuAI);
});
it('Google provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-google-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-google-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Google, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeGoogleAI);
});
it('Moonshot AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-moonshot-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-moonshot-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Moonshot, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeMoonshotAI);
});
it('Qwen AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-qwen-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-qwen-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Qwen, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeQwenAI);
});
it('Bedrock AI provider: with apikey, awsAccessKeyId, awsSecretAccessKey, awsRegion', async () => {
const jwtPayload: JWTPayload = {
const jwtPayload: ClientSecretPayload = {
apiKey: 'user-bedrock-key',
awsAccessKeyId: 'user-aws-id',
awsSecretAccessKey: 'user-aws-secret',
@@ -127,7 +130,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Ollama provider: with endpoint', async () => {
const jwtPayload: JWTPayload = { baseURL: 'http://user-ollama-url' };
const jwtPayload: ClientSecretPayload = { baseURL: 'http://user-ollama-url' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Ollama, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOllamaAI);
@@ -135,49 +138,49 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Perplexity AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-perplexity-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-perplexity-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Perplexity, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobePerplexityAI);
});
it('Anthropic AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-anthropic-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-anthropic-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Anthropic, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeAnthropicAI);
});
it('Minimax AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-minimax-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-minimax-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Minimax, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeMinimaxAI);
});
it('Mistral AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-mistral-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-mistral-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Mistral, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeMistralAI);
});
it('OpenRouter AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-openrouter-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-openrouter-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.OpenRouter, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenRouterAI);
});
it('DeepSeek AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-deepseek-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-deepseek-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.DeepSeek, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeDeepSeekAI);
});
it('Together AI provider: with apikey', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-togetherai-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-togetherai-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.TogetherAI, jwtPayload);
expect(runtime).toBeInstanceOf(AgentRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeTogetherAI);
@@ -205,7 +208,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Unknown Provider: with apikey and endpoint, should initialize to OpenAi', async () => {
const jwtPayload: JWTPayload = {
const jwtPayload: ClientSecretPayload = {
apiKey: 'user-unknown-key',
baseURL: 'user-unknown-endpoint',
};
@@ -218,13 +221,13 @@ describe('initAgentRuntimeWithUserPayload method', () => {
describe('should initialize without some options', () => {
it('OpenAI provider: without apikey', async () => {
const jwtPayload: JWTPayload = {};
const jwtPayload: ClientSecretPayload = {};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.OpenAI, jwtPayload);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenAI);
});
it('Azure AI Provider: without apikey', async () => {
const jwtPayload: JWTPayload = {
const jwtPayload: ClientSecretPayload = {
azureApiVersion: 'test-azure-api-version',
};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Azure, jwtPayload);
@@ -233,7 +236,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('ZhiPu AI provider: without apikey', async () => {
const jwtPayload: JWTPayload = {};
const jwtPayload: ClientSecretPayload = {};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.ZhiPu, jwtPayload);
// 假设 LobeZhipuAI 是 ZhiPu 提供者的实现类
@@ -248,7 +251,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Moonshot AI provider: without apikey', async () => {
const jwtPayload: JWTPayload = {};
const jwtPayload: ClientSecretPayload = {};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Moonshot, jwtPayload);
// 假设 LobeMoonshotAI 是 Moonshot 提供者的实现类
@@ -256,7 +259,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Qwen AI provider: without apikey', async () => {
const jwtPayload: JWTPayload = {};
const jwtPayload: ClientSecretPayload = {};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Qwen, jwtPayload);
// 假设 LobeQwenAI 是 Qwen 提供者的实现类
@@ -264,7 +267,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
});
it('Qwen AI provider: without endpoint', async () => {
const jwtPayload: JWTPayload = { apiKey: 'user-qwen-key' };
const jwtPayload: ClientSecretPayload = { apiKey: 'user-qwen-key' };
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Qwen, jwtPayload);
// 假设 LobeQwenAI 是 Qwen 提供者的实现类
@@ -356,7 +359,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
it('OpenAI provider: without apikey with OPENAI_PROXY_URL', async () => {
process.env.OPENAI_PROXY_URL = 'https://proxy.example.com/v1';
const jwtPayload: JWTPayload = {};
const jwtPayload: ClientSecretPayload = {};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.OpenAI, jwtPayload);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenAI);
// 应返回 OPENAI_PROXY_URL
@@ -366,7 +369,7 @@ describe('initAgentRuntimeWithUserPayload method', () => {
it('Qwen AI provider: without apiKey and endpoint with OPENAI_PROXY_URL', async () => {
process.env.OPENAI_PROXY_URL = 'https://proxy.example.com/v1';
const jwtPayload: JWTPayload = {};
const jwtPayload: ClientSecretPayload = {};
const runtime = await initAgentRuntimeWithUserPayload(ModelProvider.Qwen, jwtPayload);
// 假设 LobeQwenAI 是 Qwen 提供者的实现类
+3 -3
View File
@@ -1,5 +1,5 @@
import { getLLMConfig } from '@/config/llm';
import { JWTPayload } from '@/const/auth';
import { ClientSecretPayload } from '@/const/auth';
import { AgentRuntime, ModelProvider } from '@/libs/model-runtime';
import apiKeyManager from './apiKeyManager';
@@ -14,7 +14,7 @@ export * from './trace';
* @param payload - The JWT payload.
* @returns The options object.
*/
const getParamsFromPayload = (provider: string, payload: JWTPayload) => {
const getParamsFromPayload = (provider: string, payload: ClientSecretPayload) => {
const llmConfig = getLLMConfig() as Record<string, any>;
switch (provider) {
@@ -115,7 +115,7 @@ const getParamsFromPayload = (provider: string, payload: JWTPayload) => {
*/
export const initAgentRuntimeWithUserPayload = (
provider: string,
payload: JWTPayload,
payload: ClientSecretPayload,
params: any = {},
) => {
return AgentRuntime.initializeWithProvider(provider, {
+2 -2
View File
@@ -3,7 +3,7 @@ import superjson from 'superjson';
import urlJoin from 'url-join';
import { serverDBEnv } from '@/config/db';
import { JWTPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { ClientSecretPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { isDesktop } from '@/const/version';
import { appEnv } from '@/envs/app';
import { createAsyncCallerFactory } from '@/libs/trpc/async';
@@ -13,7 +13,7 @@ import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { asyncRouter } from './index';
import type { AsyncRouter } from './index';
export const createAsyncServerClient = async (userId: string, payload: JWTPayload) => {
export const createAsyncServerClient = async (userId: string, payload: ClientSecretPayload) => {
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const headers: Record<string, string> = {
Authorization: `Bearer ${serverDBEnv.KEY_VAULTS_SECRET}`,
+2 -2
View File
@@ -9,8 +9,8 @@ import { SEARCH_SEARXNG_NOT_CONFIG } from '@/types/tool/search';
import { searchRouter } from './search';
// Mock JWT verification
vi.mock('@/utils/server/jwt', () => ({
getJWTPayload: vi.fn().mockResolvedValue({ userId: '1' }),
vi.mock('@/utils/server/xor', () => ({
getXorPayload: vi.fn().mockReturnValue({ userId: '1' }),
}));
vi.mock('@lobechat/web-crawler', () => ({
+3 -3
View File
@@ -1,4 +1,4 @@
import { JWTPayload } from '@/const/auth';
import { ClientSecretPayload } from '@/const/auth';
import { AsyncTaskModel } from '@/database/models/asyncTask';
import { FileModel } from '@/database/models/file';
import { serverDB } from '@/database/server';
@@ -30,7 +30,7 @@ export class ChunkService {
return this.chunkClient.chunkContent(params);
}
async asyncEmbeddingFileChunks(fileId: string, payload: JWTPayload) {
async asyncEmbeddingFileChunks(fileId: string, payload: ClientSecretPayload) {
const result = await this.fileModel.findById(fileId);
if (!result) return;
@@ -66,7 +66,7 @@ export class ChunkService {
/**
* parse file to chunks with async task
*/
async asyncParseFileToChunks(fileId: string, payload: JWTPayload, skipExist?: boolean) {
async asyncParseFileToChunks(fileId: string, payload: ClientSecretPayload, skipExist?: boolean) {
const result = await this.fileModel.findById(fileId);
if (!result) return;
+3 -3
View File
@@ -1,4 +1,4 @@
import { JWTPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { ClientSecretPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { isDeprecatedEdition } from '@/const/version';
import { ModelProvider } from '@/libs/model-runtime';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
@@ -10,7 +10,7 @@ import {
CloudflareKeyVault,
OpenAICompatibleKeyVault,
} from '@/types/user/settings';
import { createJWT } from '@/utils/jwt';
import { obfuscatePayloadWithXOR } from '@/utils/client/xor-obfuscation';
export const getProviderAuthPayload = (
provider: string,
@@ -80,7 +80,7 @@ const createAuthTokenWithPayload = async (payload = {}) => {
const accessCode = keyVaultsConfigSelectors.password(useUserStore.getState());
const userId = userProfileSelectors.userId(useUserStore.getState());
return createJWT<JWTPayload>({ accessCode, userId, ...payload });
return obfuscatePayloadWithXOR<ClientSecretPayload>({ accessCode, userId, ...payload });
};
interface AuthParams {
+1
View File
@@ -31,6 +31,7 @@ export enum SettingsTabs {
Common = 'common',
Hotkey = 'hotkey',
LLM = 'llm',
Plugin = 'plugin',
Provider = 'provider',
Proxy = 'proxy',
Storage = 'storage',
+370
View File
@@ -0,0 +1,370 @@
import { describe, expect, it } from 'vitest';
import { SECRET_XOR_KEY } from '@/const/auth';
import { obfuscatePayloadWithXOR } from './xor-obfuscation';
describe('xor-obfuscation', () => {
describe('obfuscatePayloadWithXOR', () => {
it('应该对简单字符串进行混淆并返回Base64字符串', () => {
const payload = 'hello world';
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
// 验证结果长度大于0
expect(result.length).toBeGreaterThan(0);
});
it('应该对JSON对象进行混淆', () => {
const payload = { name: 'test', value: 123, active: true };
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该对数组进行混淆', () => {
const payload = [1, 2, 3, 'test', { nested: true }];
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该对复杂嵌套对象进行混淆', () => {
const payload = {
user: {
id: 123,
profile: {
name: 'John Doe',
settings: {
theme: 'dark',
notifications: true,
preferences: ['email', 'sms'],
},
},
},
tokens: ['abc123', 'def456'],
metadata: null,
};
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('相同的输入应该产生相同的输出', () => {
const payload = { test: 'consistent' };
const result1 = obfuscatePayloadWithXOR(payload);
const result2 = obfuscatePayloadWithXOR(payload);
expect(result1).toBe(result2);
});
it('不同的输入应该产生不同的输出', () => {
const payload1 = { test: 'value1' };
const payload2 = { test: 'value2' };
const result1 = obfuscatePayloadWithXOR(payload1);
const result2 = obfuscatePayloadWithXOR(payload2);
expect(result1).not.toBe(result2);
});
it('应该处理包含特殊字符的字符串', () => {
const payload = 'Hello! @#$%^&*()_+-=[]{}|;:,.<>?/~`"\'\\';
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理包含Unicode字符的字符串', () => {
const payload = '你好世界 🌍 émojis 日本語 한국어';
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理空字符串', () => {
const payload = '';
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理空对象', () => {
const payload = {};
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理空数组', () => {
const result = obfuscatePayloadWithXOR([]);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理null值', () => {
const payload = null;
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理数字', () => {
const payload = 42;
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理布尔值', () => {
const payloadTrue = true;
const payloadFalse = false;
const resultTrue = obfuscatePayloadWithXOR(payloadTrue);
const resultFalse = obfuscatePayloadWithXOR(payloadFalse);
// 验证返回值是字符串
expect(typeof resultTrue).toBe('string');
expect(typeof resultFalse).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(resultTrue)).not.toThrow();
expect(() => atob(resultFalse)).not.toThrow();
// 验证不同布尔值产生不同结果
expect(resultTrue).not.toBe(resultFalse);
});
it('应该处理包含特殊JSON字符的对象', () => {
const payload = {
quotes: '"double quotes"',
singleQuotes: "'single quotes'",
backslash: 'back\\slash',
newline: 'line1\nline2',
tab: 'col1\tcol2',
unicode: '\u0041\u0042\u0043',
};
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该处理很长的字符串', () => {
const payload = 'a'.repeat(10000);
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
// 验证结果长度合理(Base64编码后长度应该大约是原始长度的4/3)
expect(result.length).toBeGreaterThan(0);
});
it('应该产生不同长度输入的不同输出长度', () => {
const shortPayload = 'short';
const longPayload = 'this is a much longer string that should produce different output';
const shortResult = obfuscatePayloadWithXOR(shortPayload);
const longResult = obfuscatePayloadWithXOR(longPayload);
// 较长的输入应该产生较长的输出
expect(longResult.length).toBeGreaterThan(shortResult.length);
});
it('应该验证输出是有效的Base64格式', () => {
const payload = { test: 'base64 validation' };
const result = obfuscatePayloadWithXOR(payload);
// 验证Base64格式的正则表达式
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
expect(base64Regex.test(result)).toBe(true);
});
it('应该处理包含循环引用的对象(通过JSON.stringify处理)', () => {
// JSON.stringify 会抛出错误处理循环引用,但我们测试正常情况
const payload = {
id: 1,
name: 'test',
nested: {
back: 'reference',
},
};
const result = obfuscatePayloadWithXOR(payload);
expect(typeof result).toBe('string');
expect(() => atob(result)).not.toThrow();
});
it('应该对undefined值进行处理', () => {
const payload = undefined;
const result = obfuscatePayloadWithXOR(payload);
// 验证返回值是字符串
expect(typeof result).toBe('string');
// 验证返回值是有效的Base64字符串
expect(() => atob(result)).not.toThrow();
});
it('应该对包含函数的对象进行处理(函数会被JSON.stringify忽略)', () => {
const payload = {
name: 'test',
fn: function () {
return 'test';
},
arrow: () => 'arrow',
value: 123,
};
const result = obfuscatePayloadWithXOR(payload);
expect(typeof result).toBe('string');
expect(() => atob(result)).not.toThrow();
});
it('应该确保XOR操作的确定性', () => {
const payload = 'deterministic test';
const results: any[] = [];
// 多次运行相同输入
for (let i = 0; i < 10; i++) {
results.push(obfuscatePayloadWithXOR(payload));
}
// 所有结果应该相同
expect(results.every((result) => result === results[0])).toBe(true);
});
it('应该处理包含日期对象的数据', () => {
const payload = {
timestamp: new Date('2024-01-01T00:00:00Z'),
created: new Date(),
name: 'date test',
};
const result = obfuscatePayloadWithXOR(payload);
expect(typeof result).toBe('string');
expect(() => atob(result)).not.toThrow();
});
it('应该处理包含Symbol的对象(Symbol会被JSON.stringify忽略)', () => {
const sym = Symbol('test');
const payload = {
name: 'symbol test',
[sym]: 'symbol value',
normalKey: 'normal value',
};
const result = obfuscatePayloadWithXOR(payload);
expect(typeof result).toBe('string');
expect(() => atob(result)).not.toThrow();
});
it('应该验证混淆后的数据长度合理性', () => {
const originalPayload = { test: 'length check' };
const originalJSON = JSON.stringify(originalPayload);
const result = obfuscatePayloadWithXOR(originalPayload);
// Base64 编码后的长度通常是原始长度的 4/3 倍(向上取整到4的倍数)
const expectedMinLength = Math.ceil((originalJSON.length * 4) / 3 / 4) * 4;
expect(result.length).toBeGreaterThanOrEqual(expectedMinLength - 4); // 允许一些误差
});
it('应该验证XOR操作的正确性(通过逆向操作)', () => {
const originalPayload = { message: 'XOR test', value: 42 };
const obfuscatedResult = obfuscatePayloadWithXOR(originalPayload);
// 手动实现逆向操作来验证 XOR 操作的正确性
const base64Decoded = atob(obfuscatedResult);
const xoredBytes = new Uint8Array(base64Decoded.length);
for (let i = 0; i < base64Decoded.length; i++) {
xoredBytes[i] = base64Decoded.charCodeAt(i);
}
// 使用相同的密钥进行逆向 XOR 操作
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
const decodedBytes = new Uint8Array(xoredBytes.length);
for (let i = 0; i < xoredBytes.length; i++) {
decodedBytes[i] = xoredBytes[i] ^ keyBytes[i % keyBytes.length];
}
// 将结果转换回字符串
const decodedString = new TextDecoder().decode(decodedBytes);
const decodedPayload = JSON.parse(decodedString);
// 验证解码后的数据与原始数据相同
expect(decodedPayload).toEqual(originalPayload);
});
it('应该验证不同输入产生不同的Base64输出', () => {
const payloads = [
'test1',
'test2',
{ key: 'value1' },
{ key: 'value2' },
[1, 2, 3],
[4, 5, 6],
];
const results = payloads.map((payload) => obfuscatePayloadWithXOR(payload));
// 验证所有结果都不相同
for (let i = 0; i < results.length; i++) {
for (let j = i + 1; j < results.length; j++) {
expect(results[i]).not.toBe(results[j]);
}
}
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { SECRET_XOR_KEY } from '@/const/auth';
/**
* 将字符串转换为 Uint8Array (UTF-8 编码)
*/
const stringToUint8Array = (str: string): Uint8Array => {
return new TextEncoder().encode(str);
};
/**
* 对 Uint8Array 进行 XOR 运算
* @param data 要处理的 Uint8Array
* @param key 用于 XOR 的密钥 (Uint8Array)
* @returns 经过 XOR 运算的 Uint8Array
*/
const xorProcess = (data: Uint8Array, key: Uint8Array): Uint8Array => {
const result = new Uint8Array(data.length);
for (const [i, datum] of data.entries()) {
result[i] = datum ^ key[i % key.length]; // 密钥循环使用
}
return result;
};
/**
* 对 payload 进行 XOR 混淆并 Base64 编码
* @param payload 要混淆的 JSON 对象
* @returns Base64 编码后的混淆字符串
*/
export const obfuscatePayloadWithXOR = <T>(payload: T): string => {
const jsonString = JSON.stringify(payload);
const dataBytes = stringToUint8Array(jsonString);
const keyBytes = stringToUint8Array(SECRET_XOR_KEY);
const xoredBytes = xorProcess(dataBytes, keyBytes);
// 将 Uint8Array 转换为 Base64 字符串
// 浏览器环境 btoa 只能处理 Latin-1 字符,所以需要先转换为适合 btoa 的字符串
return btoa(String.fromCharCode(...xoredBytes));
};
-27
View File
@@ -1,27 +0,0 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { NON_HTTP_PREFIX } from '@/const/auth';
import { createJWT } from './jwt';
describe('createJWT', () => {
it('should create a JWT token', async () => {
const payload = { test: 'test' };
const token = await createJWT(payload);
expect(token).toBeTruthy();
});
it('should return a token with NON_HTTP_PREFIX when crypto.subtle does not exist', async () => {
const originalCryptoSubtle = crypto.subtle;
// @ts-ignore
crypto['subtle'] = undefined;
const payload = { test: 'test' };
const token = await createJWT(payload);
expect(token.startsWith(NON_HTTP_PREFIX)).toBeTruthy();
// @ts-ignore
crypto['subtle'] = originalCryptoSubtle;
});
});
-37
View File
@@ -1,37 +0,0 @@
import { SignJWT, importJWK } from 'jose';
import { JWT_SECRET_KEY, NON_HTTP_PREFIX } from '@/const/auth';
export const createJWT = async <T>(payload: T) => {
const now = Math.floor(Date.now() / 1000);
const duration = 100; // 100s
const encoder = new TextEncoder();
// fix the issue that crypto.subtle is not available in non-HTTPS environment
// refs: https://github.com/lobehub/lobe-chat/pull/1238
if (!crypto.subtle) {
const buffer = encoder.encode(JSON.stringify(payload));
return `${NON_HTTP_PREFIX}.${Buffer.from(buffer).toString('base64')}`;
}
// create a secret key
const secretKey = await crypto.subtle.digest('SHA-256', encoder.encode(JWT_SECRET_KEY));
// get the JWK from the secret key
const jwkSecretKey = await importJWK(
{
k: Buffer.from(secretKey).toString('base64'),
kty: 'oct',
},
'HS256',
);
// 创建JWT
return new SignJWT(payload as Record<string, any>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now) // 设置JWT的iat(签发时间)声明
.setExpirationTime(now + duration) // 设置 JWT 的 exp(过期时间)为 100 s
.sign(jwkSecretKey);
};
-62
View File
@@ -1,62 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { NON_HTTP_PREFIX } from '@/const/auth';
import { getJWTPayload } from './jwt';
let enableClerkMock = false;
let enableNextAuthMock = false;
vi.mock('@/const/auth', async (importOriginal) => {
const data = await importOriginal();
return {
...(data as any),
get enableClerk() {
return enableClerkMock;
},
get enableNextAuth() {
return enableNextAuthMock;
},
};
});
vi.mock('@/envs/app', () => ({
getAppConfig: vi.fn(),
}));
describe('getJWTPayload', () => {
it('should parse JWT payload for non-HTTPS token', async () => {
const token = `${NON_HTTP_PREFIX}.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ`;
const payload = await getJWTPayload(token);
expect(payload).toEqual({
sub: '1234567890',
name: 'John Doe',
iat: 1516239022,
});
});
it('should verify and parse JWT payload for HTTPS token', async () => {
const token =
'eyJhbGciOiJIUzI1NiJ9.eyJhY2Nlc3NDb2RlIjoiIiwidXNlcklkIjoiMDAxMzYyYzMtNDhjNS00NjM1LWJkM2ItODM3YmZmZjU4ZmMwIiwiYXBpS2V5IjoiYWJjIiwiZW5kcG9pbnQiOiJhYmMiLCJpYXQiOjE3MTY4MDIyMjUsImV4cCI6MTAwMDAwMDAwMDE3MTY4MDIwMDB9.FF0FxsE8Cajs-_hv5GD0TNUDwvekAkI9l_LL_IOPdGQ';
const payload = await getJWTPayload(token);
expect(payload).toEqual({
accessCode: '',
apiKey: 'abc',
endpoint: 'abc',
exp: 10000000001716802000,
iat: 1716802225,
userId: '001362c3-48c5-4635-bd3b-837bfff58fc0',
});
});
it('should not verify success and parse JWT payload for dated token', async () => {
const token =
'eyJhbGciOiJIUzI1NiJ9.eyJhY2Nlc3NDb2RlIjoiIiwidXNlcklkIjoiYWY3M2JhODktZjFhMy00YjliLWEwM2QtZGViZmZlMzE4NmQxIiwiYXBpS2V5IjoiYWJjIiwiZW5kcG9pbnQiOiJhYmMiLCJpYXQiOjE3MTY3OTk5ODAsImV4cCI6MTcxNjgwMDA4MH0.8AGFsLcwyrQG82kVUYOGFXHIwihm2n16ctyArKW9100';
try {
await getJWTPayload(token);
} catch (e) {
expect((e as Error).message).toEqual('"exp" claim timestamp check failed');
}
});
});
-28
View File
@@ -1,28 +0,0 @@
import { importJWK, jwtVerify } from 'jose';
import { JWTPayload, JWT_SECRET_KEY, NON_HTTP_PREFIX } from '@/const/auth';
export const getJWTPayload = async (token: string): Promise<JWTPayload> => {
//如果是 HTTP 协议发起的请求,直接解析 token
// 这是一个非常 hack 的解决方案,未来要找更好的解决方案来处理这个问题
// refs: https://github.com/lobehub/lobe-chat/pull/1238
if (token.startsWith(NON_HTTP_PREFIX)) {
const jwtParts = token.split('.');
const payload = jwtParts[1];
return JSON.parse(atob(payload));
}
const encoder = new TextEncoder();
const secretKey = await crypto.subtle.digest('SHA-256', encoder.encode(JWT_SECRET_KEY));
const jwkSecretKey = await importJWK(
{ k: Buffer.from(secretKey).toString('base64'), kty: 'oct' },
'HS256',
);
const { payload } = await jwtVerify(token, jwkSecretKey);
return payload as JWTPayload;
};
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import { obfuscatePayloadWithXOR } from '@/utils/client/xor-obfuscation';
import { getXorPayload } from './xor';
describe('getXorPayload', () => {
it('should correctly decode XOR obfuscated payload with user data', () => {
const originalPayload = {
userId: '001362c3-48c5-4635-bd3b-837bfff58fc0',
accessCode: 'test-access-code',
apiKey: 'test-api-key',
baseURL: 'https://api.example.com',
};
// 使用客户端的混淆函数生成token
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
// 使用服务端的解码函数解码
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should correctly decode XOR obfuscated payload with minimal data', () => {
const originalPayload = {
userId: '12345',
};
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should correctly decode XOR obfuscated payload with AWS credentials', () => {
const originalPayload = {
userId: 'aws-user-123',
awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
awsSecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
awsRegion: 'us-east-1',
awsSessionToken: 'session-token-example',
};
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should correctly decode XOR obfuscated payload with Azure data', () => {
const originalPayload = {
userId: 'azure-user-456',
apiKey: 'azure-api-key',
baseURL: 'https://your-resource.openai.azure.com',
azureApiVersion: '2024-02-15-preview',
};
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should correctly decode XOR obfuscated payload with Cloudflare data', () => {
const originalPayload = {
userId: 'cf-user-789',
apiKey: 'cloudflare-api-key',
cloudflareBaseURLOrAccountID: 'account-id-example',
};
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should handle empty payload correctly', () => {
const originalPayload = {};
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should handle payload with undefined values', () => {
const originalPayload = {
userId: 'test-user',
accessCode: undefined,
apiKey: 'test-key',
};
const obfuscatedToken = obfuscatePayloadWithXOR(originalPayload);
const decodedPayload = getXorPayload(obfuscatedToken);
expect(decodedPayload).toEqual(originalPayload);
});
it('should throw error for invalid base64 token', () => {
const invalidToken = 'invalid-base64-token!@#';
expect(() => getXorPayload(invalidToken)).toThrow(SyntaxError);
});
it('should throw error for token that cannot be parsed as JSON', () => {
// 创建一个能正确base64解码但不是有效JSON的token
const invalidJsonString = 'this is not json';
const invalidJsonBytes = new TextEncoder().encode(invalidJsonString);
const keyBytes = new TextEncoder().encode('LobeHub · LobeHub');
// 进行XOR处理
const result = new Uint8Array(invalidJsonBytes.length);
for (const [i, datum] of invalidJsonBytes.entries()) {
result[i] = datum ^ keyBytes[i % keyBytes.length];
}
// 转换为base64
const invalidToken = Buffer.from(result).toString('base64');
expect(() => getXorPayload(invalidToken)).toThrow(SyntaxError);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { ClientSecretPayload, SECRET_XOR_KEY } from '@/const/auth';
/**
* 将 Base64 字符串转换为 Uint8Array
*/
const base64ToUint8Array = (base64: string): Uint8Array => {
// Node.js 环境下直接使用 Buffer
return Buffer.from(base64, 'base64');
};
/**
* 对 Uint8Array 进行 XOR 运算 (与客户端的 xorProcess 函数相同)
*/
const xorProcess = (data: Uint8Array, key: Uint8Array): Uint8Array => {
const result = new Uint8Array(data.length);
for (const [i, datum] of data.entries()) {
result[i] = datum ^ key[i % key.length];
}
return result;
};
/**
* 将 Uint8Array 转换为字符串 (UTF-8 解码)
*/
const uint8ArrayToString = (arr: Uint8Array): string => {
return new TextDecoder().decode(arr);
};
export const getXorPayload = (token: string): ClientSecretPayload => {
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
// 1. Base64 解码
const base64DecodedBytes = base64ToUint8Array(token);
// 2. XOR 解混淆
const xorDecryptedBytes = xorProcess(base64DecodedBytes, keyBytes);
// 3. 转换为字符串并解析 JSON
const decodedJsonString = uint8ArrayToString(xorDecryptedBytes);
return JSON.parse(decodedJsonString) as ClientSecretPayload;
};