diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c15a236cd..82b9eedd7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,131 @@ # Changelog +### [Version 1.122.4](https://github.com/lobehub/lobe-chat/compare/v1.122.3...v1.122.4) + +Released on **2025-09-04** + +#### 💄 Styles + +- **misc**: Update i18n. + +
+ +
+Improvements and Fixes + +#### Styles + +- **misc**: Update i18n, closes [#9062](https://github.com/lobehub/lobe-chat/issues/9062) ([970ece0](https://github.com/lobehub/lobe-chat/commit/970ece0)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +### [Version 1.122.3](https://github.com/lobehub/lobe-chat/compare/v1.122.2...v1.122.3) + +Released on **2025-09-04** + +#### 🐛 Bug Fixes + +- **misc**: Support base64 image from markdown image syntax. + +#### 💄 Styles + +- **misc**: Update the price of the o3 model in OpenRouter. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- **misc**: Support base64 image from markdown image syntax, closes [#9054](https://github.com/lobehub/lobe-chat/issues/9054) ([d013a16](https://github.com/lobehub/lobe-chat/commit/d013a16)) + +#### Styles + +- **misc**: Update the price of the o3 model in OpenRouter, closes [#9075](https://github.com/lobehub/lobe-chat/issues/9075) ([43ef47c](https://github.com/lobehub/lobe-chat/commit/43ef47c)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +### [Version 1.122.2](https://github.com/lobehub/lobe-chat/compare/v1.122.1...v1.122.2) + +Released on **2025-09-04** + +#### 🐛 Bug Fixes + +- **modelProvider**: Add lmstudio to provider whitelist to enable fetchOnClient toggle. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- **modelProvider**: Add lmstudio to provider whitelist to enable fetchOnClient toggle, closes [#9067](https://github.com/lobehub/lobe-chat/issues/9067) ([e58864f](https://github.com/lobehub/lobe-chat/commit/e58864f)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +### [Version 1.122.1](https://github.com/lobehub/lobe-chat/compare/v1.122.0...v1.122.1) + +Released on **2025-09-04** + +
+ +
+Improvements and Fixes + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +## [Version 1.122.0](https://github.com/lobehub/lobe-chat/compare/v1.121.1...v1.122.0) + +Released on **2025-09-04** + +#### ✨ Features + +- **misc**: Refactor to speed up send message in server mode. + +
+ +
+Improvements and Fixes + +#### What's improved + +- **misc**: Refactor to speed up send message in server mode, closes [#9046](https://github.com/lobehub/lobe-chat/issues/9046) ([4813b6d](https://github.com/lobehub/lobe-chat/commit/4813b6d)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ ### [Version 1.121.1](https://github.com/lobehub/lobe-chat/compare/v1.121.0...v1.121.1) Released on **2025-09-03** diff --git a/Dockerfile b/Dockerfile index 17842e3c82b..6c25f9de4c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,7 +66,7 @@ ENV NEXT_PUBLIC_ANALYTICS_UMAMI="${NEXT_PUBLIC_ANALYTICS_UMAMI}" \ NEXT_PUBLIC_UMAMI_WEBSITE_ID="${NEXT_PUBLIC_UMAMI_WEBSITE_ID}" # Node -ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV NODE_OPTIONS="--max-old-space-size=6144" WORKDIR /app diff --git a/Dockerfile.database b/Dockerfile.database index e69e4ffc9a3..25f658d60a2 100644 --- a/Dockerfile.database +++ b/Dockerfile.database @@ -74,7 +74,7 @@ ENV NEXT_PUBLIC_ANALYTICS_UMAMI="${NEXT_PUBLIC_ANALYTICS_UMAMI}" \ NEXT_PUBLIC_UMAMI_WEBSITE_ID="${NEXT_PUBLIC_UMAMI_WEBSITE_ID}" # Node -ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV NODE_OPTIONS="--max-old-space-size=6144" WORKDIR /app diff --git a/Dockerfile.pglite b/Dockerfile.pglite index b3195609eea..35a454ae7f8 100644 --- a/Dockerfile.pglite +++ b/Dockerfile.pglite @@ -68,7 +68,7 @@ ENV NEXT_PUBLIC_ANALYTICS_UMAMI="${NEXT_PUBLIC_ANALYTICS_UMAMI}" \ NEXT_PUBLIC_UMAMI_WEBSITE_ID="${NEXT_PUBLIC_UMAMI_WEBSITE_ID}" # Node -ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV NODE_OPTIONS="--max-old-space-size=6144" WORKDIR /app diff --git a/README.md b/README.md index cae5ff95a79..f15071c770d 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ From productivity tools to development environments, discover new ways to extend **Peak Performance, Zero Distractions** -Get the full LobeChat experience without browser limitations—lightweight, focused, and always ready to go. Our desktop application provides a dedicated environment for your AI interactions, ensuring optimal performance and minimal distractions. +Get the full LobeChat experience without browser limitations—comprehensive, focused, and always ready to go. Our desktop application provides a dedicated environment for your AI interactions, ensuring optimal performance and minimal distractions. Experience faster response times, better resource management, and a more stable connection to your AI assistant. The desktop app is designed for users who demand the best performance from their AI tools. @@ -481,7 +481,7 @@ We deeply understand the importance of providing a seamless experience for users Therefore, we have adopted Progressive Web Application ([PWA](https://support.google.com/chrome/answer/9658361)) technology, a modern web technology that elevates web applications to an experience close to that of native apps. -Through PWA, LobeChat can offer a highly optimized user experience on both desktop and mobile devices while maintaining its lightweight and high-performance characteristics. +Through PWA, LobeChat can offer a highly optimized user experience on both desktop and mobile devices while maintaining high-performance characteristics. Visually and in terms of feel, we have also meticulously designed the interface to ensure it is indistinguishable from native apps, providing smooth animations, responsive layouts, and adapting to different device screen resolutions. diff --git a/changelog/v1.json b/changelog/v1.json index be8b3c741ee..cc550c83a20 100644 --- a/changelog/v1.json +++ b/changelog/v1.json @@ -1,4 +1,36 @@ [ + { + "children": { + "improvements": ["Update i18n."] + }, + "date": "2025-09-04", + "version": "1.122.4" + }, + { + "children": { + "fixes": ["Support base64 image from markdown image syntax."], + "improvements": ["Update the price of the o3 model in OpenRouter."] + }, + "date": "2025-09-04", + "version": "1.122.3" + }, + { + "children": {}, + "date": "2025-09-04", + "version": "1.122.2" + }, + { + "children": {}, + "date": "2025-09-04", + "version": "1.122.1" + }, + { + "children": { + "features": ["Refactor to speed up send message in server mode."] + }, + "date": "2025-09-04", + "version": "1.122.0" + }, { "children": { "fixes": ["Fix socks5 proxy not work problem, fix virtuaso minheight was null."] diff --git a/locales/ar/common.json b/locales/ar/common.json index 614de81f328..80b331232d8 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -182,6 +182,13 @@ "title": "هل تحب منتجنا؟" }, "fullscreen": "وضع كامل الشاشة", + "geminiImageChineseWarning": { + "content": "قد يفشل Nano Banana أحيانًا في إنشاء الصور عند استخدام اللغة الصينية. يُنصح باستخدام اللغة الإنجليزية للحصول على نتائج أفضل.", + "continueGenerate": "متابعة الإنشاء", + "continueSend": "متابعة الإرسال", + "doNotShowAgain": "عدم الإظهار مرة أخرى", + "title": "تنبيه إدخال اللغة الصينية" + }, "historyRange": "نطاق التاريخ", "import": "استيراد", "importData": "استيراد البيانات", diff --git a/locales/bg-BG/common.json b/locales/bg-BG/common.json index 8cbaf18e9d3..9eabd48195a 100644 --- a/locales/bg-BG/common.json +++ b/locales/bg-BG/common.json @@ -182,6 +182,13 @@ "title": "Харесвате нашия продукт?" }, "fullscreen": "Цял екран", + "geminiImageChineseWarning": { + "content": "Nano Banana може да не успее да генерира изображение при използване на китайски език. Препоръчваме използването на английски за по-добри резултати.", + "continueGenerate": "Продължи генерирането", + "continueSend": "Продължи изпращането", + "doNotShowAgain": "Не показвай отново", + "title": "Подсказка за въвеждане на китайски" + }, "historyRange": "Диапазон на историята", "import": "Импортиране", "importData": "Импорт на данни", diff --git a/locales/de-DE/common.json b/locales/de-DE/common.json index 7a00060c964..3649beec72c 100644 --- a/locales/de-DE/common.json +++ b/locales/de-DE/common.json @@ -182,6 +182,13 @@ "title": "Mögen Sie unser Produkt?" }, "fullscreen": "Vollbildmodus", + "geminiImageChineseWarning": { + "content": "Nano Banana kann bei der Verwendung von Chinesisch möglicherweise keine Bilder generieren. Es wird empfohlen, Englisch zu verwenden, um bessere Ergebnisse zu erzielen.", + "continueGenerate": "Weiter generieren", + "continueSend": "Weiter senden", + "doNotShowAgain": "Nicht mehr anzeigen", + "title": "Hinweis zur chinesischen Eingabe" + }, "historyRange": "Verlaufsbereich", "import": "Importieren", "importData": "Daten importieren", diff --git a/locales/en-US/common.json b/locales/en-US/common.json index abef0cd06a6..d5ab4702a83 100644 --- a/locales/en-US/common.json +++ b/locales/en-US/common.json @@ -182,6 +182,13 @@ "title": "Like Our Product?" }, "fullscreen": "Full Screen Mode", + "geminiImageChineseWarning": { + "content": "Nano Banana may occasionally fail to generate images when using Chinese. It is recommended to use English for better results.", + "continueGenerate": "Continue Generating", + "continueSend": "Continue Sending", + "doNotShowAgain": "Do Not Show Again", + "title": "Chinese Input Notice" + }, "historyRange": "History Range", "import": "Import", "importData": "Import Data", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 48e02d8f824..548977d3940 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -182,6 +182,13 @@ "title": "¿Te gusta nuestro producto?" }, "fullscreen": "Pantalla completa", + "geminiImageChineseWarning": { + "content": "Nano Banana tiene una probabilidad de fallo al generar imágenes usando chino. Se recomienda usar inglés para obtener mejores resultados.", + "continueGenerate": "Continuar generando", + "continueSend": "Continuar enviando", + "doNotShowAgain": "No mostrar de nuevo", + "title": "Aviso de entrada en chino" + }, "historyRange": "Rango de historial", "import": "Importar", "importData": "Importar datos", diff --git a/locales/fa-IR/common.json b/locales/fa-IR/common.json index 019c4e24ba6..d589d959b5f 100644 --- a/locales/fa-IR/common.json +++ b/locales/fa-IR/common.json @@ -182,6 +182,13 @@ "title": "آیا از محصول ما خوشتان آمده؟" }, "fullscreen": "حالت تمام صفحه", + "geminiImageChineseWarning": { + "content": "Nano Banana احتمال دارد در استفاده از زبان چینی در تولید تصویر با خطا مواجه شود. توصیه می‌شود برای دریافت نتایج بهتر از زبان انگلیسی استفاده کنید.", + "continueGenerate": "ادامه تولید", + "continueSend": "ادامه ارسال", + "doNotShowAgain": "دیگر نمایش نده", + "title": "هشدار ورودی به زبان چینی" + }, "historyRange": "محدوده تاریخی", "import": "وارد کردن", "importData": "وارد کردن داده‌ها", diff --git a/locales/fr-FR/common.json b/locales/fr-FR/common.json index 15c9270b2c3..647543c8314 100644 --- a/locales/fr-FR/common.json +++ b/locales/fr-FR/common.json @@ -182,6 +182,13 @@ "title": "Vous aimez notre produit ?" }, "fullscreen": "Mode plein écran", + "geminiImageChineseWarning": { + "content": "Nano Banana peut rencontrer des échecs aléatoires lors de la génération d'images en chinois. Il est recommandé d'utiliser l'anglais pour de meilleurs résultats.", + "continueGenerate": "Continuer la génération", + "continueSend": "Continuer l'envoi", + "doNotShowAgain": "Ne plus afficher", + "title": "Avertissement pour saisie en chinois" + }, "historyRange": "Plage d'historique", "import": "Importer", "importData": "Importer des données", diff --git a/locales/it-IT/common.json b/locales/it-IT/common.json index 7145e299514..36f31aa606e 100644 --- a/locales/it-IT/common.json +++ b/locales/it-IT/common.json @@ -182,6 +182,13 @@ "title": "Ti piace il nostro prodotto?" }, "fullscreen": "Modalità a schermo intero", + "geminiImageChineseWarning": { + "content": "Nano Banana potrebbe non riuscire a generare immagini correttamente se si utilizza il cinese. Si consiglia di utilizzare l'inglese per ottenere risultati migliori.", + "continueGenerate": "Continua a generare", + "continueSend": "Continua a inviare", + "doNotShowAgain": "Non mostrare più", + "title": "Avviso per input in cinese" + }, "historyRange": "Intervallo cronologico", "import": "Importa", "importData": "Importa dati", diff --git a/locales/ja-JP/common.json b/locales/ja-JP/common.json index 23584ea684d..d84833a914b 100644 --- a/locales/ja-JP/common.json +++ b/locales/ja-JP/common.json @@ -182,6 +182,13 @@ "title": "当社の製品がお気に入りですか?" }, "fullscreen": "フルスクリーンモード", + "geminiImageChineseWarning": { + "content": "Nano Bananaは中国語を使用すると画像生成に失敗する可能性があります。より良い結果を得るために英語の使用をお勧めします。", + "continueGenerate": "生成を続ける", + "continueSend": "送信を続ける", + "doNotShowAgain": "今後表示しない", + "title": "中国語入力の注意" + }, "historyRange": "履歴範囲", "import": "インポート", "importData": "データをインポートする", diff --git a/locales/ko-KR/common.json b/locales/ko-KR/common.json index 77a5562671e..f41f554e347 100644 --- a/locales/ko-KR/common.json +++ b/locales/ko-KR/common.json @@ -182,6 +182,13 @@ "title": "우리 제품을 좋아하십니까?" }, "fullscreen": "전체 화면", + "geminiImageChineseWarning": { + "content": "Nano Banana는 중국어 사용 시 이미지 생성에 실패할 가능성이 있습니다. 더 나은 결과를 위해 영어 사용을 권장합니다.", + "continueGenerate": "계속 생성", + "continueSend": "계속 전송", + "doNotShowAgain": "다시 표시하지 않음", + "title": "중국어 입력 안내" + }, "historyRange": "기록 범위", "import": "가져오기", "importData": "데이터 가져오기", diff --git a/locales/nl-NL/common.json b/locales/nl-NL/common.json index 462f8b31686..2ac2c75bc4f 100644 --- a/locales/nl-NL/common.json +++ b/locales/nl-NL/common.json @@ -182,6 +182,13 @@ "title": "Houdt u van ons product?" }, "fullscreen": "Volledig scherm", + "geminiImageChineseWarning": { + "content": "Nano Banana kan bij gebruik van Chinees mogelijk geen afbeeldingen genereren. Het wordt aanbevolen om Engels te gebruiken voor betere resultaten.", + "continueGenerate": "Doorgaan met genereren", + "continueSend": "Doorgaan met verzenden", + "doNotShowAgain": "Niet meer tonen", + "title": "Chinese invoer waarschuwing" + }, "historyRange": "Geschiedenisbereik", "import": "Importeren", "importData": "Gegevens importeren", diff --git a/locales/pl-PL/common.json b/locales/pl-PL/common.json index 51150cc8820..1ba28514e30 100644 --- a/locales/pl-PL/common.json +++ b/locales/pl-PL/common.json @@ -182,6 +182,13 @@ "title": "Podoba ci się nasz produkt?" }, "fullscreen": "Tryb pełnoekranowy", + "geminiImageChineseWarning": { + "content": "Nano Banana może mieć problemy z generowaniem obrazów przy użyciu języka chińskiego. Zaleca się korzystanie z języka angielskiego, aby uzyskać lepsze rezultaty.", + "continueGenerate": "Kontynuuj generowanie", + "continueSend": "Kontynuuj wysyłanie", + "doNotShowAgain": "Nie pokazuj ponownie", + "title": "Wskazówka dotycząca wprowadzania w języku chińskim" + }, "historyRange": "Zakres historii", "import": "Importuj", "importData": "Importuj dane", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index bf3c9673e85..b56d59eb677 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -182,6 +182,13 @@ "title": "Está gostando do nosso produto?" }, "fullscreen": "Modo de Tela Cheia", + "geminiImageChineseWarning": { + "content": "O Nano Banana pode falhar ao gerar imagens ao usar o chinês. Recomendamos usar inglês para obter melhores resultados.", + "continueGenerate": "Continuar gerando", + "continueSend": "Continuar enviando", + "doNotShowAgain": "Não mostrar novamente", + "title": "Aviso sobre entrada em chinês" + }, "historyRange": "Intervalo de histórico", "import": "Importar", "importData": "Importar dados", diff --git a/locales/ru-RU/common.json b/locales/ru-RU/common.json index 9afc2a759f9..2c39346cb79 100644 --- a/locales/ru-RU/common.json +++ b/locales/ru-RU/common.json @@ -182,6 +182,13 @@ "title": "Нравится наш продукт?" }, "fullscreen": "Полноэкранный режим", + "geminiImageChineseWarning": { + "content": "Nano Banana при использовании китайского языка может с вероятностью не сгенерировать изображение. Рекомендуется использовать английский для лучшего результата.", + "continueGenerate": "Продолжить генерацию", + "continueSend": "Продолжить отправку", + "doNotShowAgain": "Больше не показывать", + "title": "Подсказка для ввода на китайском" + }, "historyRange": "История", "import": "Импорт", "importData": "Импорт данных", diff --git a/locales/tr-TR/common.json b/locales/tr-TR/common.json index 5862518d854..76f7c8491cc 100644 --- a/locales/tr-TR/common.json +++ b/locales/tr-TR/common.json @@ -182,6 +182,13 @@ "title": "Ürünümüzü Beğendiniz mi?" }, "fullscreen": "Tam Ekran Modu", + "geminiImageChineseWarning": { + "content": "Nano Banana, Çince kullanıldığında resim oluşturma işlemi bazen başarısız olabilir. Daha iyi sonuçlar için İngilizce kullanmanız önerilir.", + "continueGenerate": "Oluşturmaya devam et", + "continueSend": "Göndermeye devam et", + "doNotShowAgain": "Bir daha gösterme", + "title": "Çince Giriş Uyarısı" + }, "historyRange": "Geçmiş Aralığı", "import": "İçe aktar", "importData": "Veri İçe Aktar", diff --git a/locales/vi-VN/common.json b/locales/vi-VN/common.json index e913b31785a..058119d2592 100644 --- a/locales/vi-VN/common.json +++ b/locales/vi-VN/common.json @@ -182,6 +182,13 @@ "title": "Yêu thích sản phẩm của chúng tôi?" }, "fullscreen": "Chế độ toàn màn hình", + "geminiImageChineseWarning": { + "content": "Nano Banana khi sử dụng tiếng Trung có khả năng không tạo được hình ảnh. Khuyến nghị sử dụng tiếng Anh để có kết quả tốt hơn.", + "continueGenerate": "Tiếp tục tạo", + "continueSend": "Tiếp tục gửi", + "doNotShowAgain": "Không hiển thị lại", + "title": "Thông báo nhập tiếng Trung" + }, "historyRange": "Phạm vi lịch sử", "import": "Nhập khẩu", "importData": "Nhập dữ liệu", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index dd4e6045e6c..c34aaecd20a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -182,6 +182,13 @@ "title": "喜歡我們的產品?" }, "fullscreen": "全螢幕模式", + "geminiImageChineseWarning": { + "content": "Nano Banana 使用中文有機率生成圖片失敗。建議使用英文以獲得更好的效果。", + "continueGenerate": "繼續生成", + "continueSend": "繼續發送", + "doNotShowAgain": "不再提示", + "title": "中文輸入提示" + }, "historyRange": "歷史範圍", "import": "匯入", "importData": "匯入資料", diff --git a/next.config.ts b/next.config.ts index 42a67303a91..1939e0ea108 100644 --- a/next.config.ts +++ b/next.config.ts @@ -38,6 +38,7 @@ const nextConfig: NextConfig = { // refs: https://github.com/lobehub/lobe-chat/pull/7430 serverMinification: false, webVitalsAttribution: ['CLS', 'LCP'], + webpackMemoryOptimizations: true, }, async headers() { const securityHeaders = [ diff --git a/package.json b/package.json index ba8f2ffc005..abe5daa7ef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lobehub/chat", - "version": "1.121.1", + "version": "1.122.4", "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.", "keywords": [ "framework", @@ -35,7 +35,7 @@ "build-migrate-db": "bun run db:migrate", "build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts", "build:analyze": "NODE_OPTIONS=--max-old-space-size=6144 ANALYZE=true next build", - "build:docker": "npm run prebuild && DOCKER=true next build && npm run build-sitemap", + "build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=6144 DOCKER=true next build && npm run build-sitemap", "prebuild:electron": "cross-env NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/prebuild.mts", "build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=6144 NEXT_PUBLIC_IS_DESKTOP_APP=1 NEXT_PUBLIC_SERVICE_MODE=server next build", "db:generate": "drizzle-kit generate && npm run db:generate-client && npm run workflow:dbml", diff --git a/packages/const/src/index.ts b/packages/const/src/index.ts index cca30448ccd..2b68dd81252 100644 --- a/packages/const/src/index.ts +++ b/packages/const/src/index.ts @@ -3,6 +3,7 @@ export * from './branding'; export * from './currency'; export * from './layoutTokens'; export * from './message'; +export * from './session'; export * from './settings'; export * from './user'; export * from './version'; diff --git a/packages/const/src/session.ts b/packages/const/src/session.ts index c5d17c9830e..1abbf88cedc 100644 --- a/packages/const/src/session.ts +++ b/packages/const/src/session.ts @@ -1,7 +1,7 @@ -import { DEFAULT_AGENT_META, DEFAULT_INBOX_AVATAR } from '@/const/meta'; -import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; -import { LobeAgentSession, LobeSessionType } from '@/types/session'; -import { merge } from '@/utils/merge'; +import { LobeAgentSession, LobeSessionType } from '@lobechat/types'; + +import { DEFAULT_AGENT_META } from './meta'; +import { DEFAULT_AGENT_CONFIG } from './settings'; export const INBOX_SESSION_ID = 'inbox'; @@ -16,10 +16,3 @@ export const DEFAULT_AGENT_LOBE_SESSION: LobeAgentSession = { type: LobeSessionType.Agent, updatedAt: new Date(), }; - -export const DEFAULT_INBOX_SESSION: LobeAgentSession = merge(DEFAULT_AGENT_LOBE_SESSION, { - id: 'inbox', - meta: { - avatar: DEFAULT_INBOX_AVATAR, - }, -}); diff --git a/packages/database/src/models/__tests__/drizzleMigration.test.ts b/packages/database/src/models/__tests__/drizzleMigration.test.ts new file mode 100644 index 00000000000..f22e2899b36 --- /dev/null +++ b/packages/database/src/models/__tests__/drizzleMigration.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it } from 'vitest'; + +import { LobeChatDatabase } from '../../type'; +import { DrizzleMigrationModel } from '../drizzleMigration'; +import { getTestDB } from './_util'; + +const serverDB: LobeChatDatabase = await getTestDB(); + +const drizzleMigrationModel = new DrizzleMigrationModel(serverDB); + +describe('DrizzleMigrationModel', () => { + beforeEach(async () => { + // Clean up database before each test if needed + }); + + describe('getTableCounts', () => { + it('should return table count from information_schema', async () => { + const count = await drizzleMigrationModel.getTableCounts(); + + expect(count).toBeTypeOf('number'); + expect(count).toBeGreaterThanOrEqual(0); + }); + + it('should return integer value', async () => { + const count = await drizzleMigrationModel.getTableCounts(); + + expect(Number.isInteger(count)).toBe(true); + }); + }); + + describe('getMigrationList', () => { + it('should return migration list', async () => { + const migrations = await drizzleMigrationModel.getMigrationList(); + + expect(Array.isArray(migrations)).toBe(true); + }); + + it('should return migration items with required fields', async () => { + const migrations = await drizzleMigrationModel.getMigrationList(); + + migrations.forEach((migration) => { + expect(migration).toHaveProperty('hash'); + expect(migration).toHaveProperty('created_at'); + expect(typeof migration.hash).toBe('string'); + }); + }); + }); + + describe('getLatestMigrationHash', () => { + it('should return the hash of the latest migration', async () => { + const hash = await drizzleMigrationModel.getLatestMigrationHash(); + const migrations = await drizzleMigrationModel.getMigrationList(); + + if (migrations.length > 0) { + expect(hash).toBe(migrations[0].hash); + expect(typeof hash).toBe('string'); + } + }); + + it('should return the first item hash from migration list', async () => { + const migrations = await drizzleMigrationModel.getMigrationList(); + + if (migrations.length > 0) { + const latestHash = await drizzleMigrationModel.getLatestMigrationHash(); + expect(latestHash).toBe(migrations[0].hash); + } + }); + }); +}); diff --git a/packages/database/src/models/__tests__/file.test.ts b/packages/database/src/models/__tests__/file.test.ts index 1e9a5c5eba5..4ef604b2388 100644 --- a/packages/database/src/models/__tests__/file.test.ts +++ b/packages/database/src/models/__tests__/file.test.ts @@ -1020,4 +1020,61 @@ describe('FileModel', () => { }); }); }); + + describe('private getFileTypePrefix method', () => { + it('should handle unknown file category', async () => { + // This tests the default case in switch statement (line 312-313) + const unknownCategory = 'unknown' as FilesTabs; + + // We need to access the private method indirectly by testing the query method + // that uses getFileTypePrefix internally + const params = { + category: unknownCategory, + current: 1, + pageSize: 10, + }; + + // This should not throw an error and should handle the unknown category gracefully + const result = await fileModel.query(params); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('large batch operations', () => { + it('should handle large number of chunks deletion in batches', async () => { + // This tests the batch processing code (lines 351-381) + // First create a file with many chunks to test the batch deletion logic + const testFile = { + name: 'large-file.txt', + url: 'https://example.com/large-file.txt', + size: 100000, + fileType: 'text/plain', + fileHash: 'large-file-hash', + }; + + const { id: fileId } = await fileModel.create(testFile, true); + + // Create many chunks for this file to trigger batch processing + // Note: This is a simplified test since we can't easily create 3000+ chunks + // But it will still exercise the batch deletion code path + const chunkData = Array.from({ length: 10 }, (_, i) => ({ + id: `chunk-${i}`, + text: `chunk content ${i}`, + index: i, + type: 'text' as const, + userId, + })); + + // Insert chunks (this might need to be done through proper API) + // For testing purposes, we'll delete the file which should trigger the batch deletion + await fileModel.delete(fileId, true); + + // Verify the file is deleted + const deletedFile = await serverDB.query.files.findFirst({ + where: eq(files.id, fileId), + }); + expect(deletedFile).toBeUndefined(); + }); + }); }); diff --git a/packages/database/src/models/__tests__/message.test.ts b/packages/database/src/models/__tests__/message.test.ts index 9dcd55f47e8..dbdff4f0e1e 100644 --- a/packages/database/src/models/__tests__/message.test.ts +++ b/packages/database/src/models/__tests__/message.test.ts @@ -2112,6 +2112,47 @@ describe('MessageModel', () => { }); }); + describe('updateMessageRAG', () => { + it('should insert message query chunks for RAG', async () => { + // prepare message and query + const messageId = 'rag-msg-1'; + const queryId = uuid(); + const chunk1 = uuid(); + const chunk2 = uuid(); + + await serverDB.transaction(async (trx) => { + await trx.insert(messages).values({ id: messageId, role: 'user', userId, content: 'c' }); + await trx.insert(chunks).values([ + { id: chunk1, text: 'a' }, + { id: chunk2, text: 'b' }, + ]); + await trx + .insert(messageQueries) + .values({ id: queryId, messageId, userId, userQuery: 'q', rewriteQuery: 'rq' }); + }); + + await messageModel.updateMessageRAG(messageId, { + ragQueryId: queryId, + fileChunks: [ + { id: chunk1, similarity: 0.9 }, + { id: chunk2, similarity: 0.8 }, + ], + }); + + const rows = await serverDB + .select() + .from(messageQueryChunks) + .where(eq(messageQueryChunks.messageId, messageId)); + + expect(rows).toHaveLength(2); + const s1 = rows.find((r) => r.chunkId === chunk1)!; + const s2 = rows.find((r) => r.chunkId === chunk2)!; + expect(s1.queryId).toBe(queryId); + expect(s1.similarity).toBe('0.90000'); + expect(s2.similarity).toBe('0.80000'); + }); + }); + describe('deleteMessageQuery', () => { it('should delete a message query by ID', async () => { // 创建测试数据 diff --git a/packages/database/src/models/__tests__/session.test.ts b/packages/database/src/models/__tests__/session.test.ts index 036a6b879f5..fbba56de834 100644 --- a/packages/database/src/models/__tests__/session.test.ts +++ b/packages/database/src/models/__tests__/session.test.ts @@ -386,7 +386,7 @@ describe('SessionModel', () => { }); describe('duplicate', () => { - it.skip('should duplicate a session', async () => { + it('should duplicate a session', async () => { // 创建一个用户和一个 session await serverDB.transaction(async (trx) => { await trx @@ -1146,4 +1146,26 @@ describe('SessionModel', () => { expect(result).toBe(false); }); }); + + describe('findSessionsByKeywords', () => { + it('should handle errors gracefully and return empty array', async () => { + // 这个测试旨在覆盖 findSessionsByKeywords 中的错误处理逻辑 (lines 484-486) + // 通过模拟一个可能导致错误的场景来触发 catch 块 + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // 创建一个会导致错误的场景 + // 我们可以通过传递一个会导致数据库查询问题的关键词来测试错误处理 + const result = await sessionModel.findSessionsByKeywords({ keyword: 'test' }); + + // 即使发生错误,方法也应该返回一个空数组 + expect(Array.isArray(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should return empty array for empty keyword', async () => { + const result = await sessionModel.queryByKeyword(''); + expect(result).toEqual([]); + }); + }); }); diff --git a/packages/database/src/models/message.ts b/packages/database/src/models/message.ts index 1dad3ae8893..6d7d0478e7b 100644 --- a/packages/database/src/models/message.ts +++ b/packages/database/src/models/message.ts @@ -1,15 +1,3 @@ -import type { HeatmapsProps } from '@lobehub/charts'; -import dayjs from 'dayjs'; -import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, like, sql } from 'drizzle-orm'; - -import { LobeChatDatabase } from '../type'; -import { - genEndDateWhere, - genRangeWhere, - genStartDateWhere, - genWhere, -} from '../utils/genWhere'; -import { idGenerator } from '../utils/idGenerator'; import { ChatFileItem, ChatImageItem, @@ -22,7 +10,12 @@ import { ModelRankItem, NewMessageQueryParams, UpdateMessageParams, -} from '@/types/message'; + UpdateMessageRAGParams, +} from '@lobechat/types'; +import type { HeatmapsProps } from '@lobehub/charts'; +import dayjs from 'dayjs'; +import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, like, sql } from 'drizzle-orm'; + import { merge } from '@/utils/merge'; import { today } from '@/utils/time'; @@ -41,6 +34,9 @@ import { messages, messagesFiles, } from '../schemas'; +import { LobeChatDatabase } from '../type'; +import { genEndDateWhere, genRangeWhere, genStartDateWhere, genWhere } from '../utils/genWhere'; +import { idGenerator } from '../utils/idGenerator'; export interface QueryMessageParams { current?: number; @@ -614,6 +610,18 @@ export class MessageModel { .where(eq(messageTTS.id, id)); }; + async updateMessageRAG(id: string, { ragQueryId, fileChunks }: UpdateMessageRAGParams) { + return this.db.insert(messageQueryChunks).values( + fileChunks.map((chunk) => ({ + chunkId: chunk.id, + messageId: id, + queryId: ragQueryId, + similarity: chunk.similarity?.toString(), + userId: this.userId, + })), + ); + } + // **************** Delete *************** // deleteMessage = async (id: string) => { diff --git a/packages/database/src/models/topic.ts b/packages/database/src/models/topic.ts index 3f59eba6a9d..544fba864c1 100644 --- a/packages/database/src/models/topic.ts +++ b/packages/database/src/models/topic.ts @@ -1,23 +1,18 @@ import { and, count, desc, eq, gt, ilike, inArray, isNull, sql } from 'drizzle-orm'; -import { LobeChatDatabase } from '../type'; -import { - genEndDateWhere, - genRangeWhere, - genStartDateWhere, - genWhere, -} from '../utils/genWhere'; -import { idGenerator } from '../utils/idGenerator'; import { MessageItem } from '@/types/message'; import { TopicRankItem } from '@/types/topic'; import { TopicItem, messages, topics } from '../schemas'; +import { LobeChatDatabase } from '../type'; +import { genEndDateWhere, genRangeWhere, genStartDateWhere, genWhere } from '../utils/genWhere'; +import { idGenerator } from '../utils/idGenerator'; export interface CreateTopicParams { favorite?: boolean; messages?: string[]; sessionId?: string | null; - title: string; + title?: string; } interface QueryTopicParams { diff --git a/packages/database/src/server/models/__tests__/user.test.ts b/packages/database/src/server/models/__tests__/user.test.ts index 74483a6b48e..a89cefdf6e9 100644 --- a/packages/database/src/server/models/__tests__/user.test.ts +++ b/packages/database/src/server/models/__tests__/user.test.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import dayjs from 'dayjs'; -import { eq } from 'drizzle-orm'; +import { count, eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { INBOX_SESSION_ID } from '@/const/session'; @@ -10,7 +10,7 @@ import { UserGuide, UserPreference } from '@/types/user'; import { getTestDBInstance } from '../../../core/dbForTest'; import { SessionModel } from '../../../models/session'; import { UserModel, UserNotFoundError } from '../../../models/user'; -import { UserSettingsItem, userSettings, users } from '../../../schemas'; +import { UserSettingsItem, nextauthAccounts, userSettings, users } from '../../../schemas'; let serverDB = await getTestDBInstance(); @@ -408,6 +408,80 @@ describe('UserModel', () => { }); }); }); + + describe('getUserSSOProviders', () => { + it('should get user SSO providers from nextauth accounts', async () => { + // Insert a user and associated OAuth account + await serverDB.insert(users).values({ id: userId }); + await serverDB.insert(nextauthAccounts).values({ + userId, + type: 'oauth', + provider: 'github', + providerAccountId: '123456', + expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + scope: 'user:email', + } as any); + + const result = await userModel.getUserSSOProviders(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + provider: 'github', + providerAccountId: '123456', + type: 'oauth', + userId, + scope: 'user:email', + }); + expect(result[0].expiresAt).toBeDefined(); + }); + + it('should return empty array when no SSO providers exist', async () => { + await serverDB.insert(users).values({ id: userId }); + + const result = await userModel.getUserSSOProviders(); + expect(result).toEqual([]); + }); + }); + + describe('static methods', () => { + describe('makeSureUserExist', () => { + it('should create user if not exists', async () => { + const newUserId = 'new-user-123'; + + // Ensure user doesn't exist + const existingUser = await serverDB.query.users.findFirst({ + where: eq(users.id, newUserId), + }); + expect(existingUser).toBeUndefined(); + + // Call makeSureUserExist + await UserModel.makeSureUserExist(serverDB, newUserId); + + // Verify user was created + const createdUser = await serverDB.query.users.findFirst({ + where: eq(users.id, newUserId), + }); + expect(createdUser).toBeDefined(); + expect(createdUser?.id).toBe(newUserId); + }); + + it('should not create duplicate user if already exists', async () => { + // Create user first + await serverDB.insert(users).values({ id: userId }); + + // Call makeSureUserExist again + await UserModel.makeSureUserExist(serverDB, userId); + + // Verify there's still only one user with this ID + const userCount = await serverDB + .select({ count: count() }) + .from(users) + .where(eq(users.id, userId)); + + expect(userCount[0].count).toBe(1); + }); + }); + }); }); describe('UserNotFoundError', () => { diff --git a/packages/model-bank/src/aiModels/openrouter.ts b/packages/model-bank/src/aiModels/openrouter.ts index 2b74d6c07c2..dd6190839de 100644 --- a/packages/model-bank/src/aiModels/openrouter.ts +++ b/packages/model-bank/src/aiModels/openrouter.ts @@ -396,9 +396,9 @@ const openrouterChatModels: AIChatModelCard[] = [ maxOutput: 100_000, pricing: { units: [ - { name: 'textInput_cacheRead', rate: 2.5, strategy: 'fixed', unit: 'millionTokens' }, - { name: 'textInput', rate: 10, strategy: 'fixed', unit: 'millionTokens' }, - { name: 'textOutput', rate: 40, strategy: 'fixed', unit: 'millionTokens' }, + { name: 'textInput_cacheRead', rate: 0.5, strategy: 'fixed', unit: 'millionTokens' }, + { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' }, + { name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' }, ], }, releasedAt: '2025-04-17', diff --git a/packages/model-runtime/src/utils/streams/openai/openai.test.ts b/packages/model-runtime/src/utils/streams/openai/openai.test.ts index 5bb9926f80c..42131ade110 100644 --- a/packages/model-runtime/src/utils/streams/openai/openai.test.ts +++ b/packages/model-runtime/src/utils/streams/openai/openai.test.ts @@ -163,6 +163,61 @@ describe('OpenAIStream', () => { ); }); + it('should emit base64_image and strip markdown data:image from text', async () => { + const data = [ + { + id: 'img-1', + choices: [ + { index: 0, delta: { role: 'assistant', content: '这是一张图片: ' } }, + ], + }, + { + id: 'img-1', + choices: [ + { + index: 0, + delta: { + content: + '![image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAAB3D1E1AA==)', + }, + }, + ], + }, + { id: 'img-1', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }, + ]; + + const mockOpenAIStream = new ReadableStream({ + start(controller) { + data.forEach((c) => controller.enqueue(c)); + controller.close(); + }, + }); + + const protocolStream = OpenAIStream(mockOpenAIStream); + + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual( + [ + 'id: img-1', + 'event: text', + `data: "这是一张图片: "\n`, + 'id: img-1', + 'event: base64_image', + `data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAAB3D1E1AA=="\n`, + 'id: img-1', + 'event: stop', + `data: "stop"\n`, + ].map((i) => `${i}\n`), + ); + }); + it('should handle content with tool_calls but is an empty object', async () => { // data: {"id":"chatcmpl-A7pokGUqSov0JuMkhiHhWU9GRtAgJ", "object":"chat.completion.chunk", "created":1726430846, "model":"gpt-4o-2024-05-13", "choices":[{"index":0, "delta":{"content":" today", "role":"", "tool_calls":[]}, "finish_reason":"", "logprobs":""}], "prompt_annotations":[{"prompt_index":0, "content_filter_results":null}]} const mockOpenAIStream = new ReadableStream({ @@ -2311,4 +2366,86 @@ describe('OpenAIStream', () => { expect(chunks).toEqual(['id: 6\n', 'event: base64_image\n', `data: "${base64}"\n\n`]); }); + + it('should handle finish_reason with markdown image in content', async () => { + const base64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAAgAElEQVR4nFy9a5okSY4jCFBU3SOr53HdvcZeYW/YVZnhZqpCYn+AVIuZ7PqqKyPczfQhQgIgSOH/+//9PxRVu7QzX5nvqveVP5mv+3rf+XPt985b2NIVgVgK1jr0da7zrAiegWPhPBABLi1GILhCEMkFnCuOFRFxHN/r/CbOym/om/h1X+d1H/v667rP9328r9g3VNblpoXsAwsnTtnWp0kQ40siih6NixuHlN9Rt7ehv1mbW2dkg1ef03J9zQQpQg5yc/XllveG4wa4arKtSr0NwSCdGEJVNeKlkDZMov695YaQ5NVK3fmjn4OrE9N/U04C0EqT/2HCBxrf9pJe1L2nPBjqhKEq1TEi1Q/OXiIq+IrqX2fUb+qF+2kF10k/4ScwIXidU6/T6vGkA/bSR/fZ7Ok8yOd0s+27CnP8PH3cijINdbAcAAAAASUVORK5CYII='; + const mockOpenAIStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + id: 'chatcmpl-test', + choices: [ + { + index: 0, + delta: { content: `这有一张图片: ![image](${base64})` }, + finish_reason: 'stop', + }, + ], + }); + + controller.close(); + }, + }); + + const protocolStream = OpenAIStream(mockOpenAIStream); + + const decoder = new TextDecoder(); + const chunks = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual([ + 'id: chatcmpl-test\n', + 'event: text\n', + `data: "这有一张图片:"\n\n`, + 'id: chatcmpl-test\n', + 'event: base64_image\n', + `data: "${base64}"\n\n`, + ]); + }); + + it('should handle finish_reason with multiple markdown images in content', async () => { + const base64_1 = 'data:image/png;base64,first'; + const base64_2 = 'data:image/jpeg;base64,second'; + const mockOpenAIStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + id: 'chatcmpl-multi', + choices: [ + { + index: 0, + delta: { content: `![img1](${base64_1}) and ![img2](${base64_2})` }, + finish_reason: 'stop', + }, + ], + }); + + controller.close(); + }, + }); + + const protocolStream = OpenAIStream(mockOpenAIStream); + + const decoder = new TextDecoder(); + const chunks = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual([ + 'id: chatcmpl-multi\n', + 'event: text\n', + `data: "and"\n\n`, // Remove all markdown base64 image segments + 'id: chatcmpl-multi\n', + 'event: base64_image\n', + `data: "${base64_1}"\n\n`, + 'id: chatcmpl-multi\n', + 'event: base64_image\n', + `data: "${base64_2}"\n\n`, + ]); + }); }); diff --git a/packages/model-runtime/src/utils/streams/openai/openai.ts b/packages/model-runtime/src/utils/streams/openai/openai.ts index 1a28de7e837..42180768f11 100644 --- a/packages/model-runtime/src/utils/streams/openai/openai.ts +++ b/packages/model-runtime/src/utils/streams/openai/openai.ts @@ -20,6 +20,28 @@ import { generateToolCallId, } from '../protocol'; +// Process markdown base64 images: extract URLs and clean text in one pass +const processMarkdownBase64Images = (text: string): { cleanedText: string, urls: string[]; } => { + if (!text) return { cleanedText: text, urls: [] }; + + const urls: string[] = []; + const mdRegex = /!\[[^\]]*]\(\s*(data:image\/[\d+.A-Za-z-]+;base64,[^\s)]+)\s*\)/g; + let cleanedText = text; + let m: RegExpExecArray | null; + + // Reset regex lastIndex to ensure we start from the beginning + mdRegex.lastIndex = 0; + + while ((m = mdRegex.exec(text)) !== null) { + if (m[1]) urls.push(m[1]); + } + + // Remove all markdown base64 image segments + cleanedText = text.replaceAll(mdRegex, '').trim(); + + return { cleanedText, urls }; +}; + const transformOpenAIStream = ( chunk: OpenAI.ChatCompletionChunk, streamContext: StreamContext, @@ -137,7 +159,19 @@ const transformOpenAIStream = ( return { data: null, id: chunk.id, type: 'text' }; } - return { data: item.delta.content, id: chunk.id, type: 'text' }; + + const text = item.delta.content as string; + const { urls: images, cleanedText: cleaned } = processMarkdownBase64Images(text); + if (images.length > 0) { + const arr: StreamProtocolChunk[] = []; + if (cleaned) arr.push({ data: cleaned, id: chunk.id, type: 'text' }); + arr.push( + ...images.map((url: string) => ({ data: url, id: chunk.id, type: 'base64_image' as const })), + ); + return arr; + } + + return { data: text, id: chunk.id, type: 'text' }; } // OpenAI Search Preview 模型返回引用源 @@ -284,7 +318,7 @@ const transformOpenAIStream = ( if (citations) { streamContext.returnedCitation = true; - return [ + const baseChunks: StreamProtocolChunk[] = [ { data: { citations: (citations as any[]) @@ -303,6 +337,20 @@ const transformOpenAIStream = ( type: streamContext?.thinkingInContent ? 'reasoning' : 'text', }, ]; + return baseChunks; + } + } + + // 非思考模式下,额外解析 markdown 中的 base64 图片,按顺序输出 text -> base64_image + if (!streamContext?.thinkingInContent) { + const { urls, cleanedText: cleaned } = processMarkdownBase64Images(thinkingContent); + if (urls.length > 0) { + const arr: StreamProtocolChunk[] = []; + if (cleaned) arr.push({ data: cleaned, id: chunk.id, type: 'text' }); + arr.push( + ...urls.map((url: string) => ({ data: url, id: chunk.id, type: 'base64_image' as const })), + ); + return arr; } } diff --git a/packages/types/src/aiChat.ts b/packages/types/src/aiChat.ts new file mode 100644 index 00000000000..b65c682f9b1 --- /dev/null +++ b/packages/types/src/aiChat.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { ChatMessage } from './message'; +import { ChatTopic } from './topic'; + +export interface SendNewMessage { + content: string; + // if message has attached with files, then add files to message and the agent + files?: string[]; +} + +export interface SendMessageServerParams { + newAssistantMessage: { + model: string; + provider: string; + }; + newTopic?: { + title?: string; + topicMessageIds?: string[]; + }; + newUserMessage: SendNewMessage; + sessionId?: string; + threadId?: string; + // if there is activeTopicId,then add topicId to message + topicId?: string; +} + +export const AiSendMessageServerSchema = z.object({ + newAssistantMessage: z.object({ + model: z.string().optional(), + provider: z.string().optional(), + }), + newTopic: z + .object({ + title: z.string().optional(), + topicMessageIds: z.array(z.string()).optional(), + }) + .optional(), + newUserMessage: z.object({ + content: z.string(), + files: z.array(z.string()).optional(), + }), + sessionId: z.string().optional(), + threadId: z.string().optional(), + topicId: z.string().optional(), +}); + +export interface SendMessageServerResponse { + assistantMessageId: string; + isCreatNewTopic: boolean; + messages: ChatMessage[]; + topicId: string; + topics?: ChatTopic[]; + userMessageId: string; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 12ee4baa58f..b865e302559 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from './agent'; +export * from './aiChat'; export * from './aiProvider'; export * from './artifact'; export * from './asyncTask'; @@ -11,7 +12,10 @@ export * from './knowledgeBase'; export * from './llm'; export * from './message'; export * from './meta'; +export * from './rag'; export * from './serverConfig'; +export * from './session'; +export * from './topic'; export * from './user'; export * from './user/settings'; // FIXME: I think we need a refactor for the "openai" types diff --git a/packages/types/src/message/base.ts b/packages/types/src/message/base.ts index 4b0f979b634..4444a856bf5 100644 --- a/packages/types/src/message/base.ts +++ b/packages/types/src/message/base.ts @@ -1,7 +1,20 @@ -import { ChatMessageError } from '@/types/message/chat'; -import { ChatImageItem } from '@/types/message/image'; -import { ChatToolPayload, MessageToolCall } from '@/types/message/tools'; -import { GroundingSearch } from '@/types/search'; +import { IPluginErrorType } from '@lobehub/chat-plugin-sdk'; + +import { ILobeAgentRuntimeErrorType } from '@/libs/model-runtime'; + +import { ErrorType } from '../fetch'; +import { GroundingSearch } from '../search'; +import { ChatImageItem } from './image'; +import { ChatToolPayload, MessageToolCall } from './tools'; + +/** + * 聊天消息错误对象 + */ +export interface ChatMessageError { + body?: any; + message: string; + type: ErrorType | IPluginErrorType | ILobeAgentRuntimeErrorType; +} export interface CitationItem { id?: string; diff --git a/packages/types/src/message/chat.ts b/packages/types/src/message/chat.ts index ba6038213ee..08be47f0d75 100644 --- a/packages/types/src/message/chat.ts +++ b/packages/types/src/message/chat.ts @@ -1,25 +1,11 @@ -import { IPluginErrorType } from '@lobehub/chat-plugin-sdk'; - -import { ILobeAgentRuntimeErrorType } from '@/libs/model-runtime'; - -import { ErrorType } from '../fetch'; import { MetaData } from '../meta'; import { MessageSemanticSearchChunk } from '../rag'; import { GroundingSearch } from '../search'; -import { MessageMetadata, MessageRoleType, ModelReasoning } from './base'; +import type { ChatMessageError, MessageMetadata, MessageRoleType, ModelReasoning } from './base'; import { ChatImageItem } from './image'; import { ChatPluginPayload, ChatToolPayload } from './tools'; import { Translate } from './translate'; -/** - * 聊天消息错误对象 - */ -export interface ChatMessageError { - body?: any; - message: string; - type: ErrorType | IPluginErrorType | ILobeAgentRuntimeErrorType; -} - export interface ChatTranslate extends Translate { content?: string; } diff --git a/packages/types/src/message/index.ts b/packages/types/src/message/index.ts index 76b05ef0ab1..cd19cdd6ef5 100644 --- a/packages/types/src/message/index.ts +++ b/packages/types/src/message/index.ts @@ -3,6 +3,7 @@ import { UploadFileItem } from '../files'; export * from './base'; export * from './chat'; export * from './image'; +export * from './rag'; export * from './tools'; export interface SendMessageParams { diff --git a/packages/types/src/message/rag.ts b/packages/types/src/message/rag.ts new file mode 100644 index 00000000000..f3ffd377637 --- /dev/null +++ b/packages/types/src/message/rag.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { MessageSemanticSearchChunk } from '../rag'; + +export const SemanticSearchChunkSchema = z.object({ + id: z.string(), + similarity: z.number(), +}); + +export interface UpdateMessageRAGParams { + fileChunks: MessageSemanticSearchChunk[]; + ragQueryId?: string; +} + +export const UpdateMessageRAGParamsSchema = z.object({ + id: z.string(), + value: z.object({ + fileChunks: z.array(SemanticSearchChunkSchema), + ragQueryId: z.string().optional(), + }), +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 9069f86cad1..fac243a40e4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,6 +2,7 @@ export * from './client/cookie'; export * from './detectChinese'; export * from './format'; export * from './imageToBase64'; +export * from './object'; export * from './parseModels'; export * from './pricing'; export * from './safeParseJSON'; diff --git a/packages/utils/src/object.test.ts b/packages/utils/src/object.test.ts new file mode 100644 index 00000000000..9fea897b537 --- /dev/null +++ b/packages/utils/src/object.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { cleanObject } from './object'; + +describe('cleanObject', () => { + it('should remove null, undefined and empty string fields', () => { + const input = { a: 1, b: null, c: undefined, d: '', e: 0, f: false } as const; + const res = cleanObject({ ...input }); + expect(res).toEqual({ a: 1, e: 0, f: false }); + }); +}); diff --git a/src/server/routers/lambda/__tests__/message.test.ts b/src/server/routers/lambda/__tests__/message.test.ts index 25075557477..1abc8e4922b 100644 --- a/src/server/routers/lambda/__tests__/message.test.ts +++ b/src/server/routers/lambda/__tests__/message.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MessageModel } from '@/database/models/message'; import { FileService } from '@/server/services/file'; import { ChatMessage, CreateMessageParams } from '@/types/message'; +import { UpdateMessageRAGParams } from '@/types/message/rag'; vi.mock('@/database/models/message', () => ({ MessageModel: vi.fn(), @@ -210,4 +211,33 @@ describe('messageRouter', () => { expect(mockUpdate).toHaveBeenCalledWith(input.id, input.value); expect(result).toEqual({ success: true }); }); + + it('should handle updateMessageRAG', async () => { + const mockUpdateRAG = vi.fn().mockResolvedValue(undefined); + vi.mocked(MessageModel).mockImplementation( + () => + ({ + updateMessageRAG: mockUpdateRAG, + }) as any, + ); + + const input = { + id: 'msg1', + value: { ragQueryId: 'q1', fileChunks: [{ id: 'c1', similarity: 0.9 }] }, + } as { + id: string; + value: UpdateMessageRAGParams; + }; + + const ctx = { + messageModel: new MessageModel({} as any, 'user1'), + }; + + await ctx.messageModel.updateMessageRAG(input.id, input.value); + + expect(mockUpdateRAG).toHaveBeenCalledWith('msg1', { + ragQueryId: 'q1', + fileChunks: [{ id: 'c1', similarity: 0.9 }], + }); + }); }); diff --git a/src/server/routers/lambda/aiChat.test.ts b/src/server/routers/lambda/aiChat.test.ts new file mode 100644 index 00000000000..f2494fab828 --- /dev/null +++ b/src/server/routers/lambda/aiChat.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MessageModel } from '@/database/models/message'; +import { TopicModel } from '@/database/models/topic'; +import { AiChatService } from '@/server/services/aiChat'; + +import { aiChatRouter } from './aiChat'; + +vi.mock('@/database/models/message'); +vi.mock('@/database/models/topic'); +vi.mock('@/server/services/aiChat'); +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn(), +})); + +describe('aiChatRouter', () => { + const mockCtx = { userId: 'u1' }; + + it('should create topic optionally, create user/assistant messages, and return payload', async () => { + const mockCreateTopic = vi.fn().mockResolvedValue({ id: 't1' }); + const mockCreateMessage = vi + .fn() + .mockResolvedValueOnce({ id: 'm-user' }) + .mockResolvedValueOnce({ id: 'm-assistant' }); + const mockGet = vi + .fn() + .mockResolvedValue({ messages: [{ id: 'm-user' }, { id: 'm-assistant' }], topics: [{}] }); + + vi.mocked(TopicModel).mockImplementation(() => ({ create: mockCreateTopic }) as any); + vi.mocked(MessageModel).mockImplementation(() => ({ create: mockCreateMessage }) as any); + vi.mocked(AiChatService).mockImplementation(() => ({ getMessagesAndTopics: mockGet }) as any); + + const caller = aiChatRouter.createCaller(mockCtx as any); + + const input = { + newAssistantMessage: { model: 'gpt-4o', provider: 'openai' }, + newTopic: { title: 'T', topicMessageIds: ['a', 'b'] }, + newUserMessage: { content: 'hi', files: ['f1'] }, + sessionId: 's1', + } as any; + + const res = await caller.sendMessageInServer(input); + + expect(mockCreateTopic).toHaveBeenCalledWith({ + messages: ['a', 'b'], + sessionId: 's1', + title: 'T', + }); + + expect(mockCreateMessage).toHaveBeenNthCalledWith(1, { + content: 'hi', + files: ['f1'], + role: 'user', + sessionId: 's1', + topicId: 't1', + }); + + expect(mockCreateMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + content: expect.any(String), + fromModel: 'gpt-4o', + parentId: 'm-user', + role: 'assistant', + sessionId: 's1', + topicId: 't1', + }), + ); + + expect(mockGet).toHaveBeenCalledWith({ includeTopic: true, sessionId: 's1', topicId: 't1' }); + expect(res.assistantMessageId).toBe('m-assistant'); + expect(res.userMessageId).toBe('m-user'); + expect(res.isCreatNewTopic).toBe(true); + expect(res.topicId).toBe('t1'); + expect(res.messages?.length).toBe(2); + expect(res.topics?.length).toBe(1); + }); + + it('should reuse existing topic when topicId provided', async () => { + const mockCreateMessage = vi + .fn() + .mockResolvedValueOnce({ id: 'm-user' }) + .mockResolvedValueOnce({ id: 'm-assistant' }); + const mockGet = vi.fn().mockResolvedValue({ messages: [], topics: undefined }); + + vi.mocked(MessageModel).mockImplementation(() => ({ create: mockCreateMessage }) as any); + vi.mocked(AiChatService).mockImplementation(() => ({ getMessagesAndTopics: mockGet }) as any); + + const caller = aiChatRouter.createCaller(mockCtx as any); + + const res = await caller.sendMessageInServer({ + newAssistantMessage: { model: 'gpt-4o', provider: 'openai' }, + newUserMessage: { content: 'hi' }, + sessionId: 's1', + topicId: 't-exist', + } as any); + + expect(mockCreateMessage).toHaveBeenCalled(); + expect(mockGet).toHaveBeenCalledWith({ + includeTopic: false, + sessionId: 's1', + topicId: 't-exist', + }); + expect(res.isCreatNewTopic).toBe(false); + expect(res.topicId).toBe('t-exist'); + }); +}); diff --git a/src/server/routers/lambda/aiChat.ts b/src/server/routers/lambda/aiChat.ts new file mode 100644 index 00000000000..cd034964f3b --- /dev/null +++ b/src/server/routers/lambda/aiChat.ts @@ -0,0 +1,80 @@ +import { AiSendMessageServerSchema, SendMessageServerResponse } from '@lobechat/types'; + +import { LOADING_FLAT } from '@/const/message'; +import { MessageModel } from '@/database/models/message'; +import { TopicModel } from '@/database/models/topic'; +import { authedProcedure, router } from '@/libs/trpc/lambda'; +import { serverDatabase } from '@/libs/trpc/lambda/middleware'; +import { AiChatService } from '@/server/services/aiChat'; +import { FileService } from '@/server/services/file'; + +const aiChatProcedure = authedProcedure.use(serverDatabase).use(async (opts) => { + const { ctx } = opts; + + return opts.next({ + ctx: { + aiChatService: new AiChatService(ctx.serverDB, ctx.userId), + fileService: new FileService(ctx.serverDB, ctx.userId), + messageModel: new MessageModel(ctx.serverDB, ctx.userId), + topicModel: new TopicModel(ctx.serverDB, ctx.userId), + }, + }); +}); + +export const aiChatRouter = router({ + sendMessageInServer: aiChatProcedure + .input(AiSendMessageServerSchema) + .mutation(async ({ input, ctx }) => { + let messageId: string; + let topicId = input.topicId!; + + let isCreatNewTopic = false; + + // create topic if there should be a new topic + if (input.newTopic) { + const topicItem = await ctx.topicModel.create({ + messages: input.newTopic.topicMessageIds, + sessionId: input.sessionId, + title: input.newTopic.title, + }); + topicId = topicItem.id; + isCreatNewTopic = true; + } + + // create user message + const userMessageItem = await ctx.messageModel.create({ + content: input.newUserMessage.content, + files: input.newUserMessage.files, + role: 'user', + sessionId: input.sessionId!, + topicId, + }); + + messageId = userMessageItem.id; + // create assistant message + const assistantMessageItem = await ctx.messageModel.create({ + content: LOADING_FLAT, + fromModel: input.newAssistantMessage.model, + parentId: messageId, + role: 'assistant', + sessionId: input.sessionId!, + topicId, + }); + + // retrieve latest messages and topic with + const { messages, topics } = await ctx.aiChatService.getMessagesAndTopics({ + includeTopic: isCreatNewTopic, + sessionId: input.sessionId, + topicId, + }); + + return { + assistantMessageId: assistantMessageItem.id, + isCreatNewTopic, + messages, + topicId, + topics, + userMessageId: messageId, + } as SendMessageServerResponse; + }), +}); diff --git a/src/server/routers/lambda/index.ts b/src/server/routers/lambda/index.ts index 3ff8289da34..197a209737d 100644 --- a/src/server/routers/lambda/index.ts +++ b/src/server/routers/lambda/index.ts @@ -4,6 +4,7 @@ import { publicProcedure, router } from '@/libs/trpc/lambda'; import { agentRouter } from './agent'; +import { aiChatRouter } from './aiChat'; import { aiModelRouter } from './aiModel'; import { aiProviderRouter } from './aiProvider'; import { apiKeyRouter } from './apiKey'; @@ -30,6 +31,7 @@ import { userRouter } from './user'; export const lambdaRouter = router({ agent: agentRouter, + aiChat: aiChatRouter, aiModel: aiModelRouter, aiProvider: aiProviderRouter, apiKey: apiKeyRouter, diff --git a/src/server/routers/lambda/message.ts b/src/server/routers/lambda/message.ts index 69d88b8866e..bfcce60c0b6 100644 --- a/src/server/routers/lambda/message.ts +++ b/src/server/routers/lambda/message.ts @@ -7,6 +7,7 @@ import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; import { FileService } from '@/server/services/file'; import { ChatMessage } from '@/types/message'; +import { UpdateMessageRAGParamsSchema } from '@/types/message/rag'; import { BatchTaskResult } from '@/types/service'; type ChatMessageList = ChatMessage[]; @@ -174,6 +175,12 @@ export const messageRouter = router({ return ctx.messageModel.updateMessagePlugin(input.id, input.value); }), + updateMessageRAG: messageProcedure + .input(UpdateMessageRAGParamsSchema) + .mutation(async ({ input, ctx }) => { + await ctx.messageModel.updateMessageRAG(input.id, input.value); + }), + updatePluginError: messageProcedure .input( z.object({ diff --git a/src/server/services/aiChat/index.test.ts b/src/server/services/aiChat/index.test.ts new file mode 100644 index 00000000000..b0cc11ea3ae --- /dev/null +++ b/src/server/services/aiChat/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MessageModel } from '@/database/models/message'; +import { TopicModel } from '@/database/models/topic'; +import { LobeChatDatabase } from '@/database/type'; +import { FileService } from '@/server/services/file'; + +import { AiChatService } from '.'; + +vi.mock('@/database/models/message'); +vi.mock('@/database/models/topic'); +vi.mock('@/server/services/file'); + +describe('AiChatService', () => { + it('getMessagesAndTopics should fetch messages and topics concurrently', async () => { + const serverDB = {} as unknown as LobeChatDatabase; + + const mockQueryMessages = vi.fn().mockResolvedValue([{ id: 'm1' }]); + const mockQueryTopics = vi.fn().mockResolvedValue([{ id: 't1' }]); + + vi.mocked(MessageModel).mockImplementation(() => ({ query: mockQueryMessages }) as any); + vi.mocked(TopicModel).mockImplementation(() => ({ query: mockQueryTopics }) as any); + vi.mocked(FileService).mockImplementation( + () => ({ getFullFileUrl: vi.fn().mockResolvedValue('url') }) as any, + ); + + const service = new AiChatService(serverDB, 'u1'); + + const res = await service.getMessagesAndTopics({ includeTopic: true, sessionId: 's1' }); + + expect(mockQueryMessages).toHaveBeenCalledWith( + { includeTopic: true, sessionId: 's1' }, + expect.objectContaining({ postProcessUrl: expect.any(Function) }), + ); + expect(mockQueryTopics).toHaveBeenCalledWith({ sessionId: 's1' }); + expect(res.messages).toEqual([{ id: 'm1' }]); + expect(res.topics).toEqual([{ id: 't1' }]); + }); + + it('getMessagesAndTopics should not query topics when includeTopic is false', async () => { + const serverDB = {} as unknown as LobeChatDatabase; + + const mockQueryMessages = vi.fn().mockResolvedValue([]); + vi.mocked(MessageModel).mockImplementation(() => ({ query: mockQueryMessages }) as any); + vi.mocked(TopicModel).mockImplementation(() => ({ query: vi.fn() }) as any); + vi.mocked(FileService).mockImplementation( + () => ({ getFullFileUrl: vi.fn().mockResolvedValue('url') }) as any, + ); + + const service = new AiChatService(serverDB, 'u1'); + + const res = await service.getMessagesAndTopics({ includeTopic: false, topicId: 't1' }); + + expect(mockQueryMessages).toHaveBeenCalled(); + expect(res.topics).toBeUndefined(); + }); +}); diff --git a/src/server/services/aiChat/index.ts b/src/server/services/aiChat/index.ts new file mode 100644 index 00000000000..0c9f0b462db --- /dev/null +++ b/src/server/services/aiChat/index.ts @@ -0,0 +1,36 @@ +import { MessageModel } from '@/database/models/message'; +import { TopicModel } from '@/database/models/topic'; +import { LobeChatDatabase } from '@/database/type'; +import { FileService } from '@/server/services/file'; + +export class AiChatService { + private userId: string; + private messageModel: MessageModel; + private fileService: FileService; + private topicModel: TopicModel; + + constructor(serverDB: LobeChatDatabase, userId: string) { + this.userId = userId; + + this.messageModel = new MessageModel(serverDB, userId); + this.topicModel = new TopicModel(serverDB, userId); + this.fileService = new FileService(serverDB, userId); + } + + async getMessagesAndTopics(params: { + current?: number; + includeTopic?: boolean; + pageSize?: number; + sessionId?: string; + topicId?: string; + }) { + const [messages, topics] = await Promise.all([ + this.messageModel.query(params, { + postProcessUrl: (path) => this.fileService.getFullFileUrl(path), + }), + params.includeTopic ? this.topicModel.query({ sessionId: params.sessionId }) : undefined, + ]); + + return { messages, topics }; + } +} diff --git a/src/services/aiChat.ts b/src/services/aiChat.ts new file mode 100644 index 00000000000..c813b0021b2 --- /dev/null +++ b/src/services/aiChat.ts @@ -0,0 +1,12 @@ +import { SendMessageServerParams } from '@lobechat/types'; +import { cleanObject } from '@lobechat/utils'; + +import { lambdaClient } from '@/libs/trpc/client'; + +class AiChatService { + sendMessageInServer = async (params: SendMessageServerParams) => { + return lambdaClient.aiChat.sendMessageInServer.mutate(cleanObject(params)); + }; +} + +export const aiChatService = new AiChatService(); diff --git a/src/services/message/_deprecated.ts b/src/services/message/_deprecated.ts index 0ea3a99ed5f..7dcd2081101 100644 --- a/src/services/message/_deprecated.ts +++ b/src/services/message/_deprecated.ts @@ -143,4 +143,8 @@ export class ClientService implements IMessageService { async updateMessagePluginError() { throw new Error('Method not implemented.'); } + + async updateMessageRAG(): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/services/message/client.ts b/src/services/message/client.ts index fc355265bb5..90bc9d22531 100644 --- a/src/services/message/client.ts +++ b/src/services/message/client.ts @@ -96,6 +96,11 @@ export class ClientService extends BaseClientService implements IMessageService return this.messageModel.updateMessagePlugin(id, { error: value }); }; + updateMessageRAG: IMessageService['updateMessageRAG'] = async (id, value) => { + console.log(id, value); + throw new Error('not implemented'); + }; + updateMessagePluginArguments: IMessageService['updateMessagePluginArguments'] = async ( id, value, diff --git a/src/services/message/server.ts b/src/services/message/server.ts index dd4b70fcc37..92a4d84e29a 100644 --- a/src/services/message/server.ts +++ b/src/services/message/server.ts @@ -84,6 +84,10 @@ export class ServerService implements IMessageService { return lambdaClient.message.updatePluginError.mutate({ id, value: error as any }); }; + updateMessageRAG: IMessageService['updateMessageRAG'] = async (id, data) => { + return lambdaClient.message.updateMessageRAG.mutate({ id, value: data }); + }; + removeMessage: IMessageService['removeMessage'] = async (id) => { return lambdaClient.message.removeMessage.mutate({ id }); }; diff --git a/src/services/message/type.ts b/src/services/message/type.ts index 992988a3d6e..79f6817beef 100644 --- a/src/services/message/type.ts +++ b/src/services/message/type.ts @@ -11,6 +11,7 @@ import { ModelRankItem, UpdateMessageParams, } from '@/types/message'; +import { UpdateMessageRAGParams } from '@/types/message/rag'; /* eslint-disable typescript-sort-keys/interface */ @@ -39,6 +40,7 @@ export interface IMessageService { updateMessageTranslate(id: string, translate: Partial | false): Promise; updateMessagePluginState(id: string, value: Record): Promise; updateMessagePluginError(id: string, value: ChatMessagePluginError | null): Promise; + updateMessageRAG(id: string, value: UpdateMessageRAGParams): Promise; updateMessagePluginArguments(id: string, value: string | Record): Promise; removeMessage(id: string): Promise; removeMessages(ids: string[]): Promise; diff --git a/src/store/aiInfra/slices/aiProvider/selectors.ts b/src/store/aiInfra/slices/aiProvider/selectors.ts index 53e18c5d743..9ff518fd9b3 100644 --- a/src/store/aiInfra/slices/aiProvider/selectors.ts +++ b/src/store/aiInfra/slices/aiProvider/selectors.ts @@ -25,7 +25,7 @@ const activeProviderConfig = (s: AIProviderStoreState) => s.aiProviderDetail; const isAiProviderConfigLoading = (id: string) => (s: AIProviderStoreState) => s.activeAiProvider !== id; -const providerWhitelist = new Set(['ollama']); +const providerWhitelist = new Set(['ollama', 'lmstudio']); const activeProviderKeyVaults = (s: AIProviderStoreState) => activeProviderConfig(s)?.keyVaults; diff --git a/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts new file mode 100644 index 00000000000..e9e4e852634 --- /dev/null +++ b/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts @@ -0,0 +1,437 @@ +import { act, renderHook } from '@testing-library/react'; +import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LOADING_FLAT } from '@/const/message'; +import { + DEFAULT_AGENT_CHAT_CONFIG, + DEFAULT_AGENT_CONFIG, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from '@/const/settings'; +import { aiChatService } from '@/services/aiChat'; +import { chatService } from '@/services/chat'; +// +import { messageService } from '@/services/message'; +import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors'; +import { sessionMetaSelectors } from '@/store/session/selectors'; +import { UploadFileItem } from '@/types/files/upload'; +import { ChatMessage } from '@/types/message'; + +import { useChatStore } from '../../../../store'; + +vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('mock'))), +); + +vi.mock('zustand/traditional'); +vi.mock('@/const/version', async (importOriginal) => { + const module = await importOriginal(); + return { + ...(module as any), + isServerMode: true, + isDesktop: false, + }; +}); +vi.mock('@/services/aiChat', () => ({ + aiChatService: { + sendMessageInServer: vi.fn(async (params: any) => { + const userId = 'user-message-id'; + const assistantId = 'assistant-message-id'; + const topicId = params.topicId ?? 'topic-id'; + return { + messages: [ + { + id: userId, + role: 'user', + content: params.newUserMessage?.content ?? '', + sessionId: params.sessionId ?? 'session-id', + topicId, + } as any, + { + id: assistantId, + role: 'assistant', + content: LOADING_FLAT, + sessionId: params.sessionId ?? 'session-id', + topicId, + } as any, + ], + topics: [], + topicId, + userMessageId: userId, + assistantMessageId: assistantId, + isCreatNewTopic: !params.topicId, + } as any; + }), + }, +})); +// Mock service +vi.mock('@/services/message', () => ({ + messageService: { + getMessages: vi.fn(), + updateMessageError: vi.fn(), + removeMessage: vi.fn(), + removeMessagesByAssistant: vi.fn(), + removeMessages: vi.fn(() => Promise.resolve()), + createMessage: vi.fn(() => Promise.resolve('new-message-id')), + updateMessage: vi.fn(), + removeAllMessages: vi.fn(() => Promise.resolve()), + }, +})); +vi.mock('@/services/topic', () => ({ + topicService: { + createTopic: vi.fn(() => Promise.resolve()), + removeTopic: vi.fn(() => Promise.resolve()), + }, +})); +vi.mock('@/services/chat', async (importOriginal) => { + const module = await importOriginal(); + + return { + chatService: { + createAssistantMessage: vi.fn(() => Promise.resolve('assistant-message')), + createAssistantMessageStream: (module as any).chatService.createAssistantMessageStream, + }, + }; +}); +vi.mock('@/services/session', async (importOriginal) => { + const module = await importOriginal(); + + return { + sessionService: { + updateSession: vi.fn(), + }, + }; +}); + +const realCoreProcessMessage = useChatStore.getState().internal_execAgentRuntime; + +// Mock state +const mockState = { + activeId: 'session-id', + activeTopicId: 'topic-id', + messages: [], + refreshMessages: vi.fn(), + refreshTopic: vi.fn(), + internal_execAgentRuntime: vi.fn(), + saveToTopic: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + useChatStore.setState(mockState, false); + vi.spyOn(agentSelectors, 'currentAgentConfig').mockImplementation(() => DEFAULT_AGENT_CONFIG); + vi.spyOn(agentChatConfigSelectors, 'currentChatConfig').mockImplementation( + () => DEFAULT_AGENT_CHAT_CONFIG, + ); + vi.spyOn(sessionMetaSelectors, 'currentAgentMeta').mockImplementation(() => ({ tags: [] })); +}); + +afterEach(() => { + process.env.NEXT_PUBLIC_BASE_PATH = undefined; + + vi.restoreAllMocks(); +}); + +describe('generateAIChatV2 actions', () => { + describe('sendMessageInServer', () => { + it('should not send message if there is no active session', async () => { + useChatStore.setState({ activeId: undefined }); + const { result } = renderHook(() => useChatStore()); + const message = 'Test message'; + + await act(async () => { + await result.current.sendMessage({ message }); + }); + + expect(messageService.createMessage).not.toHaveBeenCalled(); + expect(result.current.refreshMessages).not.toHaveBeenCalled(); + expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled(); + }); + + it('should not send message if message is empty and there are no files', async () => { + const { result } = renderHook(() => useChatStore()); + const message = ''; + + await act(async () => { + await result.current.sendMessage({ message }); + }); + + expect(messageService.createMessage).not.toHaveBeenCalled(); + expect(result.current.refreshMessages).not.toHaveBeenCalled(); + expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled(); + }); + + it('should not send message if message is empty and there are empty files', async () => { + const { result } = renderHook(() => useChatStore()); + const message = ''; + + await act(async () => { + await result.current.sendMessage({ message, files: [] }); + }); + + expect(messageService.createMessage).not.toHaveBeenCalled(); + expect(result.current.refreshMessages).not.toHaveBeenCalled(); + expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled(); + }); + + it('should create message and call internal_execAgentRuntime if message or files are provided', async () => { + const { result } = renderHook(() => useChatStore()); + const message = 'Test message'; + const files = [{ id: 'file-id' } as UploadFileItem]; + + // Mock messageService.create to resolve with a message id + (messageService.createMessage as Mock).mockResolvedValue('new-message-id'); + + await act(async () => { + await result.current.sendMessage({ message, files }); + }); + + expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith({ + newAssistantMessage: { + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + }, + newUserMessage: { + content: message, + files: files.map((f) => f.id), + }, + sessionId: mockState.activeId, + topicId: mockState.activeTopicId, + }); + expect(result.current.internal_execAgentRuntime).toHaveBeenCalled(); + }); + + it('should handle RAG query when internal_shouldUseRAG returns true', async () => { + const { result } = renderHook(() => useChatStore()); + const message = 'Test RAG query'; + + vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(true); + + await act(async () => { + await result.current.sendMessage({ message }); + }); + + expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + ragQuery: message, + }), + ); + }); + + it('should not use RAG when internal_shouldUseRAG returns false', async () => { + const { result } = renderHook(() => useChatStore()); + const message = 'Test without RAG'; + + vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(false); + vi.spyOn(result.current, 'internal_retrieveChunks'); + + await act(async () => { + await result.current.sendMessage({ message }); + }); + + expect(result.current.internal_retrieveChunks).not.toHaveBeenCalled(); + expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + ragQuery: undefined, + }), + ); + }); + + it('should add user message and not call internal_execAgentRuntime if onlyAddUserMessage = true', async () => { + const { result } = renderHook(() => useChatStore()); + + await act(async () => { + await result.current.sendMessage({ message: 'test', onlyAddUserMessage: true }); + }); + + expect(messageService.createMessage).toHaveBeenCalled(); + expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled(); + }); + + it('当 isWelcomeQuestion 为 true 时,正确地传递给 internal_execAgentRuntime', async () => { + const { result } = renderHook(() => useChatStore()); + + await act(async () => { + await result.current.sendMessage({ message: 'test', isWelcomeQuestion: true }); + }); + + expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + isWelcomeQuestion: true, + }), + ); + }); + + it('当只有文件而没有消息内容时,正确发送消息', async () => { + const { result } = renderHook(() => useChatStore()); + + await act(async () => { + await result.current.sendMessage({ message: '', files: [{ id: 'file-1' }] as any }); + }); + + expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith({ + newAssistantMessage: { + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + }, + newUserMessage: { + content: '', + files: ['file-1'], + }, + sessionId: 'session-id', + topicId: 'topic-id', + }); + }); + + it('当同时有文件和消息内容时,正确发送消息并关联文件', async () => { + const { result } = renderHook(() => useChatStore()); + + await act(async () => { + await result.current.sendMessage({ message: 'test', files: [{ id: 'file-1' }] as any }); + }); + + expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith({ + newAssistantMessage: { + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + }, + newUserMessage: { + content: 'test', + files: ['file-1'], + }, + sessionId: 'session-id', + topicId: 'topic-id', + }); + }); + + it('当 createMessage 抛出错误时,正确处理错误而不影响整个应用', async () => { + const { result } = renderHook(() => useChatStore()); + vi.spyOn(aiChatService, 'sendMessageInServer').mockRejectedValue( + new Error('create message error'), + ); + + try { + await result.current.sendMessage({ message: 'test' }); + } catch (e) {} + + expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled(); + }); + + // it('自动创建主题成功后,正确地将消息复制到新主题,并删除之前的临时消息', async () => { + // const { result } = renderHook(() => useChatStore()); + // act(() => { + // useAgentStore.setState({ + // agentConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: 1 }, + // }); + // + // useChatStore.setState({ + // // Mock the currentChats selector to return a list that does not reach the threshold + // messagesMap: { + // [messageMapKey('inbox')]: [{ id: '1' }, { id: '2' }] as ChatMessage[], + // }, + // activeId: 'inbox', + // }); + // }); + // vi.spyOn(topicService, 'createTopic').mockResolvedValue('new-topic'); + // + // await act(async () => { + // await result.current.sendMessage({ message: 'test' }); + // }); + // + // expect(result.current.messagesMap[messageMapKey('inbox')]).toEqual([ + // // { id: '1' }, + // // { id: '2' }, + // // { id: 'temp-id', content: 'test', role: 'user' }, + // ]); + // // expect(result.current.getMessages('session-id')).toEqual([]); + // }); + + // it('自动创建主题失败时,正确地处理错误,不会影响后续的消息发送', async () => { + // const { result } = renderHook(() => useChatStore()); + // result.current.setAgentConfig({ enableAutoCreateTopic: true, autoCreateTopicThreshold: 1 }); + // result.current.setMessages([{ id: '1' }, { id: '2' }] as any); + // vi.spyOn(topicService, 'createTopic').mockRejectedValue(new Error('create topic error')); + // + // await act(async () => { + // await result.current.sendMessage({ message: 'test' }); + // }); + // + // expect(result.current.getMessages('session-id')).toEqual([ + // { id: '1' }, + // { id: '2' }, + // { id: 'new-message-id', content: 'test', role: 'user' }, + // ]); + // }); + + // it('当 activeTopicId 不存在且 autoCreateTopic 为 true,但消息数量未达到阈值时,正确地总结主题标题', async () => { + // const { result } = renderHook(() => useChatStore()); + // result.current.setAgentConfig({ enableAutoCreateTopic: true, autoCreateTopicThreshold: 10 }); + // result.current.setMessages([{ id: '1' }, { id: '2' }] as any); + // result.current.setActiveTopic({ id: 'topic-1', title: '' }); + // + // await act(async () => { + // await result.current.sendMessage({ message: 'test' }); + // }); + // + // expect(result.current.summaryTopicTitle).toHaveBeenCalledWith('topic-1', [ + // { id: '1' }, + // { id: '2' }, + // { id: 'new-message-id', content: 'test', role: 'user' }, + // { id: 'assistant-message', role: 'assistant' }, + // ]); + // }); + // + // it('当 activeTopicId 存在且主题标题为空时,正确地总结主题标题', async () => { + // const { result } = renderHook(() => useChatStore()); + // result.current.setActiveTopic({ id: 'topic-1', title: '' }); + // result.current.setMessages([{ id: '1' }, { id: '2' }] as any, 'session-id', 'topic-1'); + // + // await act(async () => { + // await result.current.sendMessage({ message: 'test' }); + // }); + // + // expect(result.current.summaryTopicTitle).toHaveBeenCalledWith('topic-1', [ + // { id: '1' }, + // { id: '2' }, + // { id: 'new-message-id', content: 'test', role: 'user' }, + // { id: 'assistant-message', role: 'assistant' }, + // ]); + // }); + }); + + describe('internal_execAgentRuntime', () => { + it('should handle the core AI message processing', async () => { + useChatStore.setState({ internal_execAgentRuntime: realCoreProcessMessage }); + + const { result } = renderHook(() => useChatStore()); + const userMessage = { + id: 'user-message-id', + role: 'user', + content: 'Hello, world!', + sessionId: mockState.activeId, + topicId: mockState.activeTopicId, + } as ChatMessage; + const messages = [userMessage]; + + // 模拟 AI 响应 + const aiResponse = 'Hello, human!'; + (chatService.createAssistantMessage as Mock).mockResolvedValue(aiResponse); + const spy = vi.spyOn(chatService, 'createAssistantMessageStream'); + + await act(async () => { + await result.current.internal_execAgentRuntime({ + messages, + userMessageId: userMessage.id, + assistantMessageId: 'abc', + }); + }); + + // 验证 AI 服务是否被调用 + expect(spy).toHaveBeenCalled(); + + // 验证消息列表是否刷新 + expect(mockState.refreshMessages).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/store/chat/slices/aiChat/actions/generateAIChat.ts b/src/store/chat/slices/aiChat/actions/generateAIChat.ts index 1bb128d2060..36172b38090 100644 --- a/src/store/chat/slices/aiChat/actions/generateAIChat.ts +++ b/src/store/chat/slices/aiChat/actions/generateAIChat.ts @@ -152,7 +152,13 @@ export const generateAIChat: StateCreator< }, sendMessage: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => { - const { internal_coreProcessMessage, activeTopicId, activeId, activeThreadId } = get(); + const { + internal_coreProcessMessage, + activeTopicId, + activeId, + activeThreadId, + sendMessageInServer, + } = get(); if (!activeId) return; const fileIdList = files?.map((f) => f.id); @@ -162,6 +168,10 @@ export const generateAIChat: StateCreator< // if message is empty or no files, then stop if (!message && !hasFile) return; + // router to server mode send message + if (isServerMode) + return sendMessageInServer({ message, files, onlyAddUserMessage, isWelcomeQuestion }); + set({ isCreatingMessage: true }, false, n('creatingMessage/start')); const newMessage: CreateMessageParams = { diff --git a/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts b/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts new file mode 100644 index 00000000000..4b00f3c2411 --- /dev/null +++ b/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts @@ -0,0 +1,410 @@ +/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */ +// Disable the auto sort key eslint rule to make the code more logic and readable +import { INBOX_SESSION_ID, isDesktop } from '@lobechat/const'; +import { knowledgeBaseQAPrompts } from '@lobechat/prompts'; +import { + ChatMessage, + ChatTopic, + MessageSemanticSearchChunk, + SendMessageParams, + TraceNameMap, +} from '@lobechat/types'; +import { t } from 'i18next'; +import { StateCreator } from 'zustand/vanilla'; + +import { aiChatService } from '@/services/aiChat'; +import { chatService } from '@/services/chat'; +import { messageService } from '@/services/message'; +import { getAgentStoreState } from '@/store/agent'; +import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/slices/chat'; +import { aiModelSelectors, aiProviderSelectors, getAiInfraStoreState } from '@/store/aiInfra'; +import type { ChatStore } from '@/store/chat/store'; +import { getSessionStoreState } from '@/store/session'; +import { WebBrowsingManifest } from '@/tools/web-browsing'; +import { setNamespace } from '@/utils/storeDebug'; + +import { chatSelectors, topicSelectors } from '../../../selectors'; +import { messageMapKey } from '../../../utils/messageMapKey'; + +const n = setNamespace('ai'); + +export interface AIGenerateV2Action { + /** + * Sends a new message to the AI chat system + */ + sendMessageInServer: (params: SendMessageParams) => Promise; + internal_refreshAiChat: (params: { + topics?: ChatTopic[]; + messages: ChatMessage[]; + sessionId: string; + topicId?: string; + }) => void; + /** + * Executes the core processing logic for AI messages + * including preprocessing and postprocessing steps + */ + internal_execAgentRuntime: (params: { + messages: ChatMessage[]; + userMessageId: string; + assistantMessageId: string; + isWelcomeQuestion?: boolean; + inSearchWorkflow?: boolean; + /** + * the RAG query content, should be embedding and used in the semantic search + */ + ragQuery?: string; + threadId?: string; + inPortalThread?: boolean; + traceId?: string; + }) => Promise; +} + +export const generateAIChatV2: StateCreator< + ChatStore, + [['zustand/devtools', never]], + [], + AIGenerateV2Action +> = (set, get) => ({ + sendMessageInServer: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => { + const { activeTopicId, activeId, activeThreadId, internal_execAgentRuntime } = get(); + if (!activeId) return; + + const fileIdList = files?.map((f) => f.id); + + const hasFile = !!fileIdList && fileIdList.length > 0; + + // if message is empty or no files, then stop + if (!message && !hasFile) return; + + if (onlyAddUserMessage) { + await get().addUserMessage({ message, fileList: fileIdList }); + + return; + } + + const messages = chatSelectors.activeBaseChats(get()); + + // use optimistic update to avoid the slow waiting + const tempId = get().internal_createTmpMessage({ + content: message, + // if message has attached with files, then add files to message and the agent + files: fileIdList, + role: 'user', + sessionId: activeId, + // if there is activeTopicId,then add topicId to message + topicId: activeTopicId, + threadId: activeThreadId, + }); + + get().internal_toggleMessageLoading(true, tempId); + set({ isCreatingMessage: true }, false, 'creatingMessage/start'); + + const { model, provider } = agentSelectors.currentAgentConfig(getAgentStoreState()); + + const data = await aiChatService.sendMessageInServer({ + newUserMessage: { + content: message, + files: fileIdList, + }, + // if there is activeTopicId,then add topicId to message + topicId: activeTopicId, + threadId: activeThreadId, + newTopic: !activeTopicId + ? { + topicMessageIds: messages.map((m) => m.id), + title: t('defaultTitle', { ns: 'topic' }), + } + : undefined, + sessionId: activeId === INBOX_SESSION_ID ? undefined : activeId, + newAssistantMessage: { model, provider: provider! }, + }); + + // refresh the total data + get().internal_refreshAiChat({ + messages: data.messages, + topics: data.topics, + sessionId: activeId, + topicId: data.topicId, + }); + get().internal_dispatchMessage({ type: 'deleteMessage', id: tempId }); + + if (!activeTopicId) { + await get().switchTopic(data.topicId!, true); + } + + get().internal_toggleMessageLoading(false, tempId); + + // update assistant update to make it rerank + getSessionStoreState().triggerSessionUpdate(get().activeId); + + // Get the current messages to generate AI response + // remove the latest assistant message id + const baseMessages = chatSelectors + .activeBaseChats(get()) + .filter((item) => item.id !== data.assistantMessageId); + + try { + await internal_execAgentRuntime({ + messages: baseMessages, + userMessageId: data.userMessageId, + assistantMessageId: data.assistantMessageId, + isWelcomeQuestion, + ragQuery: get().internal_shouldUseRAG() ? message : undefined, + threadId: activeThreadId, + }); + set({ isCreatingMessage: false }, false, 'creatingMessage/stop'); + + const summaryTitle = async () => { + // check activeTopic and then auto update topic title + if (data.isCreatNewTopic) { + await get().summaryTopicTitle(data.topicId, data.messages); + return; + } + + if (!activeTopicId) return; + + const topic = topicSelectors.getTopicById(activeTopicId)(get()); + + if (topic && !topic.title) { + const chats = chatSelectors.getBaseChatsByKey(messageMapKey(activeId, topic.id))(get()); + await get().summaryTopicTitle(topic.id, chats); + } + }; + // + // // if there is relative files, then add files to agent + // // only available in server mode + const userFiles = chatSelectors.currentUserFiles(get()).map((f) => f.id); + const addFilesToAgent = async () => { + await getAgentStoreState().addFilesToAgent(userFiles, false); + }; + + await Promise.all([summaryTitle(), addFilesToAgent()]); + } catch (e) { + console.error(e); + set({ isCreatingMessage: false }, false, 'creatingMessage/stop'); + } + }, + + internal_refreshAiChat: ({ topics, messages, sessionId, topicId }) => { + set( + { + topicMaps: topics ? { ...get().topicMaps, [sessionId]: topics } : get().topicMaps, + messagesMap: { ...get().messagesMap, [messageMapKey(sessionId, topicId)]: messages }, + }, + false, + 'refreshAiChat', + ); + }, + + internal_execAgentRuntime: async (params) => { + const { + assistantMessageId: assistantId, + userMessageId, + ragQuery, + messages: originalMessages, + } = params; + const { + internal_fetchAIChatMessage, + triggerToolCalls, + refreshMessages, + internal_updateMessageRAG, + } = get(); + + // create a new array to avoid the original messages array change + const messages = [...originalMessages]; + + const agentStoreState = getAgentStoreState(); + const { model, provider, chatConfig } = agentSelectors.currentAgentConfig(agentStoreState); + + let fileChunks: MessageSemanticSearchChunk[] | undefined; + let ragQueryId; + + // go into RAG flow if there is ragQuery flag + if (ragQuery) { + // 1. get the relative chunks from semantic search + const { chunks, queryId, rewriteQuery } = await get().internal_retrieveChunks( + userMessageId, + ragQuery, + // should skip the last content + messages.map((m) => m.content).slice(0, messages.length - 1), + ); + + ragQueryId = queryId; + + const lastMsg = messages.pop() as ChatMessage; + + // 2. build the retrieve context messages + const knowledgeBaseQAContext = knowledgeBaseQAPrompts({ + chunks, + userQuery: lastMsg.content, + rewriteQuery, + knowledge: agentSelectors.currentEnabledKnowledge(agentStoreState), + }); + + // 3. add the retrieve context messages to the messages history + messages.push({ + ...lastMsg, + content: (lastMsg.content + '\n\n' + knowledgeBaseQAContext).trim(), + }); + + fileChunks = chunks.map((c) => ({ id: c.id, similarity: c.similarity })); + + if (fileChunks.length > 0) { + await internal_updateMessageRAG(assistantId, { ragQueryId, fileChunks }); + } + } + + // 3. place a search with the search working model if this model is not support tool use + const aiInfraStoreState = getAiInfraStoreState(); + const isModelSupportToolUse = aiModelSelectors.isModelSupportToolUse( + model, + provider!, + )(aiInfraStoreState); + const isProviderHasBuiltinSearch = aiProviderSelectors.isProviderHasBuiltinSearch(provider!)( + aiInfraStoreState, + ); + const isModelHasBuiltinSearch = aiModelSelectors.isModelHasBuiltinSearch( + model, + provider!, + )(aiInfraStoreState); + const useModelBuiltinSearch = agentChatConfigSelectors.useModelBuiltinSearch(agentStoreState); + const useModelSearch = + (isProviderHasBuiltinSearch || isModelHasBuiltinSearch) && useModelBuiltinSearch; + const isAgentEnableSearch = agentChatConfigSelectors.isAgentEnableSearch(agentStoreState); + + if (isAgentEnableSearch && !useModelSearch && !isModelSupportToolUse) { + const { model, provider } = agentChatConfigSelectors.searchFCModel(agentStoreState); + + let isToolsCalling = false; + let isError = false; + + const abortController = get().internal_toggleChatLoading( + true, + assistantId, + n('generateMessage(start)', { messageId: assistantId, messages }), + ); + + get().internal_toggleSearchWorkflow(true, assistantId); + await chatService.fetchPresetTaskResult({ + params: { messages, model, provider, plugins: [WebBrowsingManifest.identifier] }, + onFinish: async (_, { toolCalls, usage }) => { + if (toolCalls && toolCalls.length > 0) { + get().internal_toggleToolCallingStreaming(assistantId, undefined); + // update tools calling + await get().internal_updateMessageContent(assistantId, '', { + toolCalls, + metadata: usage, + model, + provider, + }); + } + }, + trace: { + traceId: params.traceId, + sessionId: get().activeId, + topicId: get().activeTopicId, + traceName: TraceNameMap.SearchIntentRecognition, + }, + abortController, + onMessageHandle: async (chunk) => { + if (chunk.type === 'tool_calls') { + get().internal_toggleSearchWorkflow(false, assistantId); + get().internal_toggleToolCallingStreaming(assistantId, chunk.isAnimationActives); + get().internal_dispatchMessage({ + id: assistantId, + type: 'updateMessage', + value: { tools: get().internal_transformToolCalls(chunk.tool_calls) }, + }); + isToolsCalling = true; + } + + if (chunk.type === 'text') { + abortController!.abort('not fc'); + } + }, + onErrorHandle: async (error) => { + isError = true; + await messageService.updateMessageError(assistantId, error); + await refreshMessages(); + }, + }); + + get().internal_toggleChatLoading( + false, + assistantId, + n('generateMessage(start)', { messageId: assistantId, messages }), + ); + get().internal_toggleSearchWorkflow(false, assistantId); + + // if there is error, then stop + if (isError) return; + + // if it's the function call message, trigger the function method + if (isToolsCalling) { + get().internal_toggleMessageInToolsCalling(true, assistantId); + await refreshMessages(); + await triggerToolCalls(assistantId, { + threadId: params?.threadId, + inPortalThread: params?.inPortalThread, + }); + + // then story the workflow + return; + } + } + + // 4. fetch the AI response + const { isFunctionCall, content } = await internal_fetchAIChatMessage({ + messages, + messageId: assistantId, + params, + model, + provider: provider!, + }); + + // 5. if it's the function call message, trigger the function method + if (isFunctionCall) { + get().internal_toggleMessageInToolsCalling(true, assistantId); + await refreshMessages(); + await triggerToolCalls(assistantId, { + threadId: params?.threadId, + inPortalThread: params?.inPortalThread, + }); + } else { + // 显示桌面通知(仅在桌面端且窗口隐藏时) + if (isDesktop) { + try { + // 动态导入桌面通知服务,避免在非桌面端环境中导入 + const { desktopNotificationService } = await import( + '@/services/electron/desktopNotification' + ); + + await desktopNotificationService.showNotification({ + body: content, + title: t('notification.finishChatGeneration', { ns: 'electron' }), + }); + } catch (error) { + // 静默处理错误,不影响正常流程 + console.error('Desktop notification error:', error); + } + } + } + + // 6. summary history if context messages is larger than historyCount + const historyCount = agentChatConfigSelectors.historyCount(agentStoreState); + + if ( + agentChatConfigSelectors.enableHistoryCount(agentStoreState) && + chatConfig.enableCompressHistory && + originalMessages.length > historyCount + ) { + // after generation: [u1,a1,u2,a2,u3,a3] + // but the `originalMessages` is still: [u1,a1,u2,a2,u3] + // So if historyCount=2, we need to summary [u1,a1,u2,a2] + // because user find UI is [u1,a1,u2,a2 | u3,a3] + const historyMessages = originalMessages.slice(0, -historyCount + 1); + + await get().internal_summaryHistory(historyMessages); + } + }, +}); diff --git a/src/store/chat/slices/aiChat/actions/index.ts b/src/store/chat/slices/aiChat/actions/index.ts index 33211da096b..20a355db263 100644 --- a/src/store/chat/slices/aiChat/actions/index.ts +++ b/src/store/chat/slices/aiChat/actions/index.ts @@ -3,10 +3,15 @@ import { StateCreator } from 'zustand/vanilla'; import { ChatStore } from '@/store/chat/store'; import { AIGenerateAction, generateAIChat } from './generateAIChat'; +import { AIGenerateV2Action, generateAIChatV2 } from './generateAIChatV2'; import { ChatMemoryAction, chatMemory } from './memory'; import { ChatRAGAction, chatRag } from './rag'; -export interface ChatAIChatAction extends ChatRAGAction, ChatMemoryAction, AIGenerateAction { +export interface ChatAIChatAction + extends ChatRAGAction, + ChatMemoryAction, + AIGenerateAction, + AIGenerateV2Action { /**/ } @@ -19,4 +24,5 @@ export const chatAiChat: StateCreator< ...chatRag(...params), ...generateAIChat(...params), ...chatMemory(...params), + ...generateAIChatV2(...params), }); diff --git a/src/store/chat/slices/message/action.ts b/src/store/chat/slices/message/action.ts index dbf96a31873..a0a350913ce 100644 --- a/src/store/chat/slices/message/action.ts +++ b/src/store/chat/slices/message/action.ts @@ -22,6 +22,7 @@ import { ModelReasoning, } from '@/types/message'; import { ChatImageItem } from '@/types/message/image'; +import { UpdateMessageRAGParams } from '@/types/message/rag'; import { GroundingSearch } from '@/types/search'; import { TraceEventPayloads } from '@/types/trace'; import { Action, setNamespace } from '@/utils/storeDebug'; @@ -39,6 +40,7 @@ const SWR_USE_FETCH_MESSAGES = 'SWR_USE_FETCH_MESSAGES'; export interface ChatMessageAction { // create addAIMessage: () => Promise; + addUserMessage: (params: { message: string; fileList?: string[] }) => Promise; // delete /** * clear message on the active session @@ -59,10 +61,11 @@ export interface ChatMessageAction { ) => SWRResponse; copyMessage: (id: string, content: string) => Promise; refreshMessages: () => Promise; - + replaceMessages: (messages: ChatMessage[]) => void; // ========= ↓ Internal Method ↓ ========== // // ========================================== // // ========================================== // + internal_updateMessageRAG: (id: string, input: UpdateMessageRAGParams) => Promise; /** * update message at the frontend @@ -213,6 +216,21 @@ export const chatMessage: StateCreator< updateInputMessage(''); }, + addUserMessage: async ({ message, fileList }) => { + const { internal_createMessage, updateInputMessage, activeTopicId, activeId } = get(); + if (!activeId) return; + + await internal_createMessage({ + content: message, + files: fileList, + role: 'user', + sessionId: activeId, + // if there is activeTopicId,then add topicId to message + topicId: activeTopicId, + }); + + updateInputMessage(''); + }, copyMessage: async (id, content) => { await copyToClipboard(content); @@ -266,6 +284,25 @@ export const chatMessage: StateCreator< refreshMessages: async () => { await mutate([SWR_USE_FETCH_MESSAGES, get().activeId, get().activeTopicId]); }, + replaceMessages: (messages) => { + set( + { + messagesMap: { + ...get().messagesMap, + [messageMapKey(get().activeId, get().activeTopicId)]: messages, + }, + }, + false, + 'replaceMessages', + ); + }, + + internal_updateMessageRAG: async (id, data) => { + const { refreshMessages } = get(); + + await messageService.updateMessageRAG(id, data); + await refreshMessages(); + }, // the internal process method of the AI message internal_dispatchMessage: (payload) => { diff --git a/src/store/chat/slices/message/reducer.ts b/src/store/chat/slices/message/reducer.ts index cb76d4d375b..d75d8d6bcf4 100644 --- a/src/store/chat/slices/message/reducer.ts +++ b/src/store/chat/slices/message/reducer.ts @@ -11,6 +11,11 @@ import { import { merge } from '@/utils/merge'; interface UpdateMessages { + type: 'updateMessages'; + value: ChatMessage[]; +} + +interface UpdateMessage { id: string; type: 'updateMessage'; value: Partial; @@ -72,6 +77,7 @@ interface UpdateMessageExtra { export type MessageDispatch = | CreateMessage + | UpdateMessage | UpdateMessages | UpdatePluginState | UpdateMessageExtra @@ -194,6 +200,11 @@ export const messagesReducer = (state: ChatMessage[], payload: MessageDispatch): draftState.push({ ...value, createdAt: Date.now(), id, meta: {}, updatedAt: Date.now() }); }); } + + case 'updateMessages': { + return payload.value; + } + case 'deleteMessage': { return produce(state, (draft) => { const { id } = payload; diff --git a/src/store/chat/slices/topic/reducer.ts b/src/store/chat/slices/topic/reducer.ts index 6d9ae34357d..09d45391c10 100644 --- a/src/store/chat/slices/topic/reducer.ts +++ b/src/store/chat/slices/topic/reducer.ts @@ -14,12 +14,21 @@ interface UpdateChatTopicAction { value: Partial; } +interface UpdateTopicsAction { + type: 'updateTopics'; + value: ChatTopic[]; +} + interface DeleteChatTopicAction { id: string; type: 'deleteTopic'; } -export type ChatTopicDispatch = AddChatTopicAction | UpdateChatTopicAction | DeleteChatTopicAction; +export type ChatTopicDispatch = + | AddChatTopicAction + | UpdateChatTopicAction + | DeleteChatTopicAction + | UpdateTopicsAction; export const topicReducer = (state: ChatTopic[] = [], payload: ChatTopicDispatch): ChatTopic[] => { switch (payload.type) { @@ -51,6 +60,10 @@ export const topicReducer = (state: ChatTopic[] = [], payload: ChatTopicDispatch }); } + case 'updateTopics': { + return payload.value; + } + case 'deleteTopic': { return produce(state, (draftState) => { const topicIndex = draftState.findIndex((topic) => topic.id === payload.id); diff --git a/src/store/user/slices/modelList/selectors/modelConfig.ts b/src/store/user/slices/modelList/selectors/modelConfig.ts index 100251d452e..ff64736ee5a 100644 --- a/src/store/user/slices/modelList/selectors/modelConfig.ts +++ b/src/store/user/slices/modelList/selectors/modelConfig.ts @@ -9,7 +9,7 @@ import { keyVaultsConfigSelectors } from './keyVaults'; const isProviderEnabled = (provider: GlobalLLMProviderKey) => (s: UserStore) => getProviderConfigById(provider)(s)?.enabled || false; -const providerWhitelist = new Set(['ollama']); +const providerWhitelist = new Set(['ollama', 'lmstudio']); /** * @description The conditions to enable client fetch * 1. If no baseUrl and apikey input, force on Server.