feat: files and knowledge base (#3487)

*  feat: add files and knowledge base

Update edge.ts

Update test.yml

🎨 chore: fix locale

Update index.tsx

测试 pgvector workflow

* 💄 style: improve upload detail

*  feat: support delete s3 file when delete files

* 💄 style: improve chunks in message

* ♻️ refactor: refactor the auth method

*  feat: support use user client api key

* 💄 style: fix image list in mobile

*  feat: support file upload on mobile

*  test: fix test

* fix vercel build

* docs: update docs

* 👷 build: improve docker

* update i18n
This commit is contained in:
Arvin Xu 2024-08-21 21:28:29 +08:00 committed by GitHub
parent d8950b27ea
commit 6574c01843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
352 changed files with 20331 additions and 1724 deletions

View file

@ -8,7 +8,7 @@ jobs:
services:
postgres:
image: postgres:16
image: pgvector/pgvector:pg16
env:
POSTGRES_PASSWORD: postgres
options: >-
@ -39,6 +39,7 @@ jobs:
DATABASE_DRIVER: node
NEXT_PUBLIC_SERVICE_MODE: server
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
NEXT_PUBLIC_S3_DOMAIN: https://example.com
- name: Upload Server coverage to Codecov
uses: codecov/codecov-action@v4

View file

@ -92,6 +92,7 @@ COPY --from=builder /deps/node_modules/drizzle-orm /app/node_modules/drizzle-orm
# Copy database migrations
COPY --from=builder /app/src/database/server/migrations /app/migrations
COPY --from=builder /app/scripts/migrateServerDB/docker.cjs /app/docker.cjs
COPY --from=builder /app/scripts/migrateServerDB/errorHint.js /app/errorHint.js
## Production image, copy all the files and run next
FROM base
@ -107,6 +108,7 @@ ENV HOSTNAME="0.0.0.0" \
# General Variables
ENV ACCESS_CODE="" \
APP_URL="" \
API_KEY_SELECT_MODE="" \
DEFAULT_AGENT_CONFIG="" \
SYSTEM_AGENT="" \

View file

@ -0,0 +1,65 @@
# 知识库/文件上传
LobeChat 支持文件上传/知识库管理。该功能依赖于以下核心技术组件,了解这些组件将有助于你成功部署和维护知识库系统。
## 核心组件
### 1. PostgreSQL 与 PGVector
PostgreSQL 是一个强大的开源关系型数据库系统,而 PGVector 是其扩展,为向量操作提供支持。
- **用途**:存储结构化数据和向量索引
- **部署建议**:使用官方 Docker 镜像可以快速部署 PostgreSQL 和 PGVector
示例部署脚本:
```
docker run -p 5432:5432 -d --name pg -e POSTGRES_PASSWORD=mysecretpassword pgvector/pgvector:pg16
```
- **注意事项**:确保分配足够的资源以处理向量操作
### 2. S3 兼容的对象存储
S3或兼容 S3 协议的存储服务)用于存储上传的文件。
- **用途**:存储原始文件
- **选项**:可以使用 AWS S3、MinIO 或其他兼容 S3 协议的存储服务
- **注意事项**:配置适当的访问权限和安全策略
### 3. OpenAI Embedding
OpenAI 的嵌入Embedding服务用于将文本转化为向量表示。
- **用途**:生成文本的向量表示,用于语义搜索
- **注意事项**
- 需要有效的 OpenAI API 密钥
- 实施适当的 API 调用限制和错误处理机制
### 4. Unstructured.io可选
Unstructured.io 是一个强大的文档处理工具。
- **用途**:处理复杂的文档格式,提取结构化信息
- **应用场景**:处理 PDF、Word 等非纯文本格式的文档
- **注意事项**:评估处理需求,根据文档复杂度决定是否部署
## 部署注意事项
1. **数据安全**:确保所有组件都有适当的安全措施,特别是涉及敏感数据时。
2. **性能优化**
- 为 PostgreSQL 和 PGVector 配置足够的计算资源
- 优化 S3 存储的访问策略和缓存机制
3. **可扩展性**:设计架构时考虑未来可能的数据增长和用户增加。
4. **监控与维护**
- 实施日志记录和监控系统
- 定期备份数据库和对象存储
5. **合规性**:确保部署符合相关的数据保护法规和隐私政策。
通过正确配置和集成这些核心组件,您可以为 LobeChat 构建一个强大、高效的知识库系统。每个组件都在整体架构中扮演着关键角色,共同支持高级的文档管理和智能检索功能。

View file

@ -62,6 +62,7 @@
"pin": "تثبيت",
"pinOff": "إلغاء التثبيت",
"rag": {
"referenceChunks": "مراجع",
"userQuery": {
"actions": {
"delete": "حذف الاستعلام",
@ -155,9 +156,18 @@
},
"updateAgent": "تحديث معلومات المساعد",
"upload": {
"actionFiletip": "تحميل المستند",
"actionTooltip": "تحميل الصورة",
"disabled": "النموذج الحالي لا يدعم التعرف على الرؤية، يرجى تغيير النموذج المستخدم",
"action": {
"fileUpload": "رفع ملف",
"folderUpload": "رفع مجلد",
"imageDisabled": "النموذج الحالي لا يدعم التعرف على الصور، يرجى تغيير النموذج لاستخدامه",
"imageUpload": "رفع صورة",
"tooltip": "رفع"
},
"clientMode": {
"actionFiletip": "رفع ملف",
"actionTooltip": "رفع",
"disabled": "النموذج الحالي لا يدعم التعرف على الصور وتحليل الملفات، يرجى تغيير النموذج لاستخدامه"
},
"preview": {
"prepareTasks": "تحضير الأجزاء...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "لم يتم تحويل كتل النص بالكامل إلى متجهات، مما سيؤدي إلى عدم توفر وظيفة البحث الدلالي، لتحسين جودة البحث، يرجى تحويل كتل النص إلى متجهات",
"error": "فشل في تحويل البيانات إلى متجهات",
"errorResult": "فشل في تحويل البيانات إلى متجهات، يرجى التحقق والمحاولة مرة أخرى. سبب الفشل:",
"processing": "يتم تحويل كتل النص إلى متجهات، يرجى الانتظار",
"success": "تم تحويل جميع كتل النص الحالية إلى متجهات"
},

View file

@ -62,6 +62,7 @@
"pin": "Закачи",
"pinOff": "Откачи",
"rag": {
"referenceChunks": "Цитирани източници",
"userQuery": {
"actions": {
"delete": "Изтрий Query",
@ -155,9 +156,18 @@
},
"updateAgent": "Актуализирай информацията за агента",
"upload": {
"actionFiletip": "Загрузите файл",
"actionTooltip": "Качи изображение",
"disabled": "Текущият модел не поддържа визуално разпознаване. Моля, превключи моделите, за да използваш тази функция.",
"action": {
"fileUpload": "Качване на файл",
"folderUpload": "Качване на папка",
"imageDisabled": "Текущият модел не поддържа визуално разпознаване, моля, превключете модела и опитайте отново",
"imageUpload": "Качване на изображение",
"tooltip": "Качване"
},
"clientMode": {
"actionFiletip": "Качване на файл",
"actionTooltip": "Качване",
"disabled": "Текущият модел не поддържа визуално разпознаване и анализ на файлове, моля, превключете модела и опитайте отново"
},
"preview": {
"prepareTasks": "Подготовка на парчета...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Текстовите блокове все още не са напълно векторизирани, което ще доведе до недостъпност на семантичното търсене. За подобряване на качеството на търсенето, моля, векторизирайте текстовите блокове.",
"error": "Неуспешна векторизация",
"errorResult": "Неуспешна векторизация, моля проверете и опитайте отново. Причина за неуспеха:",
"processing": "Текстовите блокове се векторизират, моля, бъдете търпеливи.",
"success": "Текущите текстови блокове са напълно векторизирани."
},

View file

@ -62,6 +62,7 @@
"pin": "Anheften",
"pinOff": "Anheften aufheben",
"rag": {
"referenceChunks": "Referenzstücke",
"userQuery": {
"actions": {
"delete": "Abfrage löschen",
@ -155,9 +156,18 @@
},
"updateAgent": "Assistenteninformationen aktualisieren",
"upload": {
"actionFiletip": "Laden Sie die Datei hoch",
"actionTooltip": "Bild hochladen",
"disabled": "Das aktuelle Modell unterstützt keine visuelle Erkennung. Bitte wechseln Sie das Modell, um es zu verwenden.",
"action": {
"fileUpload": "Datei hochladen",
"folderUpload": "Ordner hochladen",
"imageDisabled": "Das aktuelle Modell unterstützt keine visuelle Erkennung. Bitte wechseln Sie das Modell, um diese Funktion zu nutzen.",
"imageUpload": "Bild hochladen",
"tooltip": "Hochladen"
},
"clientMode": {
"actionFiletip": "Datei hochladen",
"actionTooltip": "Hochladen",
"disabled": "Das aktuelle Modell unterstützt keine visuelle Erkennung und Dateianalyse. Bitte wechseln Sie das Modell, um diese Funktionen zu nutzen."
},
"preview": {
"prepareTasks": "Vorbereitung der Teile...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Textblöcke sind noch nicht vollständig vektorisiert, was die Funktion der semantischen Suche beeinträchtigen kann. Um die Suchqualität zu verbessern, vektorisieren Sie die Textblöcke.",
"error": "Vektorisierung fehlgeschlagen",
"errorResult": "Vektorisierung fehlgeschlagen, bitte überprüfen Sie und versuchen Sie es erneut. Grund für das Scheitern:",
"processing": "Textblöcke werden vektorisiert, bitte haben Sie Geduld.",
"success": "Alle aktuellen Textblöcke sind vektorisiert."
},

View file

@ -62,6 +62,7 @@
"pin": "Pin",
"pinOff": "Unpin",
"rag": {
"referenceChunks": "Reference Source",
"userQuery": {
"actions": {
"delete": "Delete Query Rewrite",
@ -155,9 +156,18 @@
},
"updateAgent": "Update Assistant Information",
"upload": {
"actionFiletip": "Update File",
"actionTooltip": "Upload Image",
"disabled": "The current model does not support visual recognition. Please switch models to use this feature.",
"action": {
"fileUpload": "Upload File",
"folderUpload": "Upload Folder",
"imageDisabled": "The current model does not support visual recognition. Please switch models to use this feature.",
"imageUpload": "Upload Image",
"tooltip": "Upload"
},
"clientMode": {
"actionFiletip": "Upload File",
"actionTooltip": "Upload",
"disabled": "The current model does not support visual recognition and file analysis. Please switch models to use this feature."
},
"preview": {
"prepareTasks": "Preparing chunks...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Text chunks have not been fully vectorized, which will render the semantic search feature unavailable. To improve search quality, please vectorize the text chunks.",
"error": "Vectorization failed",
"errorResult": "Vectorization failed, please check and try again. Reason for failure:",
"processing": "Text chunks are being vectorized, please be patient.",
"success": "All current text chunks have been vectorized."
},

View file

@ -62,6 +62,7 @@
"pin": "Fijar",
"pinOff": "Desfijar",
"rag": {
"referenceChunks": "Fragmentos de referencia",
"userQuery": {
"actions": {
"delete": "Eliminar reescritura de consulta",
@ -155,9 +156,18 @@
},
"updateAgent": "Actualizar información del asistente",
"upload": {
"actionFiletip": "Sube el archivo",
"actionTooltip": "Subir imagen",
"disabled": "El modelo actual no admite reconocimiento visual. Por favor, cambia de modelo para usar esta función",
"action": {
"fileUpload": "Subir archivo",
"folderUpload": "Subir carpeta",
"imageDisabled": "El modelo actual no soporta reconocimiento visual, por favor cambie de modelo para usar esta función",
"imageUpload": "Subir imagen",
"tooltip": "Subir"
},
"clientMode": {
"actionFiletip": "Subir archivo",
"actionTooltip": "Subir",
"disabled": "El modelo actual no soporta reconocimiento visual ni análisis de archivos, por favor cambie de modelo para usar esta función"
},
"preview": {
"prepareTasks": "Preparando fragmentos...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Los bloques de texto aún no están completamente vectorizados, lo que hará que la función de búsqueda semántica no esté disponible. Para mejorar la calidad de búsqueda, por favor vectorice los bloques de texto.",
"error": "Error de vectorización",
"errorResult": "Error de vectorización, por favor verifica y vuelve a intentarlo. Motivo del fallo:",
"processing": "Los bloques de texto están siendo vectorizados, por favor, tenga paciencia.",
"success": "Todos los bloques de texto actuales han sido vectorizados."
},

View file

@ -62,6 +62,7 @@
"pin": "Épingler",
"pinOff": "Désépingler",
"rag": {
"referenceChunks": "Références",
"userQuery": {
"actions": {
"delete": "Supprimer la réécriture de la requête",
@ -155,9 +156,18 @@
},
"updateAgent": "Mettre à jour les informations de l'agent",
"upload": {
"actionFiletip": "Télécharger le fichier",
"actionTooltip": "Télécharger une image",
"disabled": "Le modèle actuel ne prend pas en charge la reconnaissance visuelle. Veuillez changer de modèle pour utiliser cette fonctionnalité.",
"action": {
"fileUpload": "Télécharger un fichier",
"folderUpload": "Télécharger un dossier",
"imageDisabled": "Le modèle actuel ne prend pas en charge la reconnaissance visuelle, veuillez changer de modèle pour l'utiliser",
"imageUpload": "Télécharger une image",
"tooltip": "Télécharger"
},
"clientMode": {
"actionFiletip": "Télécharger un fichier",
"actionTooltip": "Télécharger",
"disabled": "Le modèle actuel ne prend pas en charge la reconnaissance visuelle et l'analyse de fichiers, veuillez changer de modèle pour l'utiliser"
},
"preview": {
"prepareTasks": "Préparation des morceaux...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Les blocs de texte n'ont pas encore été entièrement vectorisés, ce qui rendra la fonction de recherche sémantique indisponible. Pour améliorer la qualité de la recherche, veuillez vectoriser les blocs de texte.",
"error": "Échec de la vectorisation",
"errorResult": "Échec de la vectorisation, veuillez vérifier et réessayer. Raison de l'échec :",
"processing": "Les blocs de texte sont en cours de vectorisation, veuillez patienter.",
"success": "Tous les blocs de texte sont maintenant vectorisés."
},

View file

@ -62,6 +62,7 @@
"pin": "Fissa in alto",
"pinOff": "Annulla fissaggio in alto",
"rag": {
"referenceChunks": "Citazioni di riferimento",
"userQuery": {
"actions": {
"delete": "Elimina la Query riscritta",
@ -155,9 +156,18 @@
},
"updateAgent": "Aggiorna informazioni assistente",
"upload": {
"actionFiletip": "Carica il file",
"actionTooltip": "Carica immagine",
"disabled": "Il modello attuale non supporta il riconoscimento visivo, si prega di cambiare modello prima di utilizzarlo",
"action": {
"fileUpload": "Carica file",
"folderUpload": "Carica cartella",
"imageDisabled": "Il modello attuale non supporta il riconoscimento visivo, si prega di cambiare modello per utilizzare questa funzione",
"imageUpload": "Carica immagine",
"tooltip": "Carica"
},
"clientMode": {
"actionFiletip": "Carica file",
"actionTooltip": "Carica",
"disabled": "Il modello attuale non supporta il riconoscimento visivo e l'analisi dei file, si prega di cambiare modello per utilizzare questa funzione"
},
"preview": {
"prepareTasks": "Preparazione dei blocchi...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "I blocchi di testo non sono stati completamente vettorizzati, il che comporterà l'impossibilità di utilizzare la funzione di ricerca semantica. Per migliorare la qualità della ricerca, si prega di vettorizzare i blocchi di testo.",
"error": "Errore di vettorizzazione",
"errorResult": "Vettorizzazione fallita, controlla e riprova. Motivo del fallimento:",
"processing": "I blocchi di testo sono in fase di vettorizzazione, ti preghiamo di attendere",
"success": "Attualmente tutti i blocchi di testo sono stati vettorizzati"
},

View file

@ -62,6 +62,7 @@
"pin": "ピン留め",
"pinOff": "ピン留め解除",
"rag": {
"referenceChunks": "参照チャンク",
"userQuery": {
"actions": {
"delete": "クエリを削除",
@ -155,9 +156,18 @@
},
"updateAgent": "エージェント情報を更新",
"upload": {
"actionFiletip": "ファイルをアップロードする",
"actionTooltip": "画像をアップロード",
"disabled": "現在のモデルはビジュアル認識をサポートしていません。モデルを切り替えて使用してください",
"action": {
"fileUpload": "ファイルをアップロード",
"folderUpload": "フォルダをアップロード",
"imageDisabled": "現在のモデルは視覚認識をサポートしていません。モデルを切り替えてから使用してください。",
"imageUpload": "画像をアップロード",
"tooltip": "アップロード"
},
"clientMode": {
"actionFiletip": "ファイルをアップロード",
"actionTooltip": "アップロード",
"disabled": "現在のモデルは視覚認識とファイル分析をサポートしていません。モデルを切り替えてから使用してください。"
},
"preview": {
"prepareTasks": "ブロックの準備中...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "テキストブロックはまだ完全にベクトル化されていません。これにより意味検索機能が使用できなくなります。検索品質を向上させるために、テキストブロックをベクトル化してください",
"error": "ベクトル化に失敗しました",
"errorResult": "ベクトル化に失敗しました。再試行する前に確認してください。失敗の理由:",
"processing": "テキストブロックをベクトル化中です。しばらくお待ちください",
"success": "現在のテキストブロックはすべてベクトル化されています"
},

View file

@ -62,6 +62,7 @@
"pin": "고정",
"pinOff": "고정 해제",
"rag": {
"referenceChunks": "참조 조각",
"userQuery": {
"actions": {
"delete": "쿼리 삭제",
@ -155,9 +156,18 @@
},
"updateAgent": "도우미 정보 업데이트",
"upload": {
"actionFiletip": "파일 업로드",
"actionTooltip": "이미지 업로드",
"disabled": "현재 모델은 시각 인식을 지원하지 않습니다. 모델을 전환한 후 사용해주세요.",
"action": {
"fileUpload": "파일 업로드",
"folderUpload": "폴더 업로드",
"imageDisabled": "현재 모델은 시각 인식을 지원하지 않습니다. 모델을 변경한 후 사용하세요.",
"imageUpload": "이미지 업로드",
"tooltip": "업로드"
},
"clientMode": {
"actionFiletip": "파일 업로드",
"actionTooltip": "업로드",
"disabled": "현재 모델은 시각 인식 및 파일 분석을 지원하지 않습니다. 모델을 변경한 후 사용하세요."
},
"preview": {
"prepareTasks": "청크 준비 중...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "텍스트 블록이 완전히 벡터화되지 않았습니다. 이는 의미 검색 기능을 사용할 수 없게 만듭니다. 검색 품질을 향상시키기 위해 텍스트 블록을 벡터화해 주세요.",
"error": "벡터화 실패",
"errorResult": "벡터화에 실패했습니다. 다시 확인한 후 재시도해 주세요. 실패 원인:",
"processing": "텍스트 블록이 벡터화되고 있습니다. 잠시 기다려 주세요.",
"success": "현재 텍스트 블록이 모두 벡터화되었습니다."
},

View file

@ -62,6 +62,7 @@
"pin": "Vastzetten",
"pinOff": "Vastzetten uitschakelen",
"rag": {
"referenceChunks": "Referentiestukken",
"userQuery": {
"actions": {
"delete": "Verwijder Query herschrijving",
@ -155,9 +156,18 @@
},
"updateAgent": "Assistentgegevens bijwerken",
"upload": {
"actionFiletip": "Upload het bestand",
"actionTooltip": "Upload afbeelding",
"disabled": "Het huidige model ondersteunt geen visuele herkenning. Schakel over naar een ander model om dit te gebruiken.",
"action": {
"fileUpload": "Bestand uploaden",
"folderUpload": "Map uploaden",
"imageDisabled": "Dit model ondersteunt momenteel geen visuele herkenning, schakel alstublieft naar een ander model.",
"imageUpload": "Afbeelding uploaden",
"tooltip": "Uploaden"
},
"clientMode": {
"actionFiletip": "Bestand uploaden",
"actionTooltip": "Uploaden",
"disabled": "Dit model ondersteunt momenteel geen visuele herkenning en bestandanalyse, schakel alstublieft naar een ander model."
},
"preview": {
"prepareTasks": "Voorbereiden van blokken...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Tekstblokken zijn nog niet volledig gevectoriseerd, wat de semantische zoekfunctie kan uitschakelen. Om de zoekkwaliteit te verbeteren, vectoriseer de tekstblokken.",
"error": "Vectorisatie mislukt",
"errorResult": "Vectorisatie mislukt, controleer en probeer het opnieuw. Reden van falen:",
"processing": "Tekstblokken worden gevectoriseerd, graag even geduld.",
"success": "Huidige tekstblokken zijn allemaal gevectoriseerd"
},

View file

@ -62,6 +62,7 @@
"pin": "Przypnij",
"pinOff": "Odepnij",
"rag": {
"referenceChunks": "Fragmenty odniesienia",
"userQuery": {
"actions": {
"delete": "Usuń przepisanie zapytania",
@ -155,9 +156,18 @@
},
"updateAgent": "Aktualizuj informacje asystenta",
"upload": {
"actionFiletip": "Prześlij plik",
"actionTooltip": "Prześlij obraz",
"disabled": "Obecny model nie obsługuje rozpoznawania wizyjnego. Proszę przełączyć model.",
"action": {
"fileUpload": "Prześlij plik",
"folderUpload": "Prześlij folder",
"imageDisabled": "Aktualny model nie obsługuje rozpoznawania wizualnego, przełącz się na inny model, aby użyć tej funkcji",
"imageUpload": "Prześlij obraz",
"tooltip": "Prześlij"
},
"clientMode": {
"actionFiletip": "Prześlij plik",
"actionTooltip": "Prześlij",
"disabled": "Aktualny model nie obsługuje rozpoznawania wizualnego i analizy plików, przełącz się na inny model, aby użyć tej funkcji"
},
"preview": {
"prepareTasks": "Przygotowywanie fragmentów...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Bloki tekstowe nie zostały w pełni wektoryzowane, co spowoduje, że funkcja wyszukiwania semantycznego będzie niedostępna. Aby poprawić jakość wyszukiwania, proszę wektoryzować bloki tekstowe",
"error": "Błąd wektoryzacji",
"errorResult": "Błąd wektoryzacji, spróbuj ponownie po sprawdzeniu. Powód błędu:",
"processing": "Bloki tekstowe są wektoryzowane, proszę czekać",
"success": "Obecne bloki tekstowe zostały w pełni wektoryzowane"
},

View file

@ -62,6 +62,7 @@
"pin": "Fixar",
"pinOff": "Desafixar",
"rag": {
"referenceChunks": "Referências",
"userQuery": {
"actions": {
"delete": "Excluir reescrita de Query",
@ -155,9 +156,18 @@
},
"updateAgent": "Atualizar Informações do Assistente",
"upload": {
"actionFiletip": "Enviar arquivo",
"actionTooltip": "Enviar Imagem",
"disabled": "O modelo atual não suporta reconhecimento visual. Por favor, altere o modelo antes de usar.",
"action": {
"fileUpload": "Enviar arquivo",
"folderUpload": "Enviar pasta",
"imageDisabled": "O modelo atual não suporta reconhecimento visual, por favor, mude de modelo antes de usar",
"imageUpload": "Enviar imagem",
"tooltip": "Enviar"
},
"clientMode": {
"actionFiletip": "Enviar arquivo",
"actionTooltip": "Enviar",
"disabled": "O modelo atual não suporta reconhecimento visual e análise de arquivos, por favor, mude de modelo antes de usar"
},
"preview": {
"prepareTasks": "Preparando partes...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Os blocos de texto ainda não foram completamente vetorizados, o que resultará na funcionalidade de busca semântica indisponível. Para melhorar a qualidade da busca, por favor, vetorize os blocos de texto.",
"error": "Falha na vetorização",
"errorResult": "Falha na vetorização, por favor verifique e tente novamente. Motivo da falha:",
"processing": "Os blocos de texto estão sendo vetorizados, por favor, aguarde.",
"success": "Atualmente, todos os blocos de texto foram vetorizados."
},

View file

@ -62,6 +62,7 @@
"pin": "Закрепить",
"pinOff": "Открепить",
"rag": {
"referenceChunks": "Цитируемые источники",
"userQuery": {
"actions": {
"delete": "Удалить переписанный запрос",
@ -155,9 +156,18 @@
},
"updateAgent": "Обновить информацию помощника",
"upload": {
"actionFiletip": "Загрузите файл",
"actionTooltip": "Загрузить изображение",
"disabled": "Текущая модель не поддерживает визуальное распознавание. Пожалуйста, выберите другую модель.",
"action": {
"fileUpload": "Загрузить файл",
"folderUpload": "Загрузить папку",
"imageDisabled": "Текущая модель не поддерживает визуальное распознавание, пожалуйста, переключитесь на другую модель",
"imageUpload": "Загрузить изображение",
"tooltip": "Загрузить"
},
"clientMode": {
"actionFiletip": "Загрузить файл",
"actionTooltip": "Загрузить",
"disabled": "Текущая модель не поддерживает визуальное распознавание и анализ файлов, пожалуйста, переключитесь на другую модель"
},
"preview": {
"prepareTasks": "Подготовка блоков...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Текстовые блоки еще не полностью векторизованы, что приведет к недоступности функции семантического поиска. Для повышения качества поиска, пожалуйста, векторизуйте текстовые блоки.",
"error": "Ошибка векторизации",
"errorResult": "Ошибка векторизации, пожалуйста, проверьте и повторите попытку. Причина сбоя:",
"processing": "Текстовые блоки векторизуются, пожалуйста, подождите.",
"success": "Все текущие текстовые блоки успешно векторизованы."
},

View file

@ -62,6 +62,7 @@
"pin": "Pin",
"pinOff": "Unpin",
"rag": {
"referenceChunks": "Referans Parçaları",
"userQuery": {
"actions": {
"delete": "Sorguyu Sil",
@ -155,9 +156,18 @@
},
"updateAgent": "Asistan Bilgilerini Güncelle",
"upload": {
"actionFiletip": "Dosyayı Yükle",
"actionTooltip": "Resim Yükle",
"disabled": "Geçerli model görüntü tanıma desteğini desteklemiyor, lütfen modeli değiştirerek kullanın",
"action": {
"fileUpload": "Dosya Yükle",
"folderUpload": "Klasör Yükle",
"imageDisabled": "Mevcut model görsel tanımayı desteklemiyor, lütfen modeli değiştirin ve tekrar deneyin",
"imageUpload": "Görüntü Yükle",
"tooltip": "Yükle"
},
"clientMode": {
"actionFiletip": "Dosya Yükle",
"actionTooltip": "Yükle",
"disabled": "Mevcut model görsel tanımayı ve dosya analizini desteklemiyor, lütfen modeli değiştirin ve tekrar deneyin"
},
"preview": {
"prepareTasks": "Parçaları Hazırlıyor...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Metin parçaları henüz tamamen vektörleştirilmedi, bu durum anlamsal arama işlevinin kullanılamamasına neden olabilir, arama kalitesini artırmak için lütfen metin parçalarını vektörleştirin",
"error": "Vektörleştirme başarısız oldu",
"errorResult": "Vektörleştirme başarısız oldu, lütfen kontrol edip tekrar deneyin. Başarısız olma nedeni:",
"processing": "Metin parçaları vektörleştiriliyor, lütfen bekleyin",
"success": "Mevcut metin parçaları tamamen vektörleştirildi"
},

View file

@ -62,6 +62,7 @@
"pin": "Ghim",
"pinOff": "Bỏ ghim",
"rag": {
"referenceChunks": "Trích dẫn nguồn",
"userQuery": {
"actions": {
"delete": "Xóa truy vấn",
@ -155,9 +156,18 @@
},
"updateAgent": "Cập nhật thông tin trợ lý",
"upload": {
"actionFiletip": "Tải lên tập tin",
"actionTooltip": "Tải lên hình ảnh",
"disabled": "Mô hình hiện tại không hỗ trợ nhận diện hình ảnh, vui lòng chuyển đổi mô hình trước khi sử dụng",
"action": {
"fileUpload": "Tải lên tệp",
"folderUpload": "Tải lên thư mục",
"imageDisabled": "Mô hình hiện tại không hỗ trợ nhận diện hình ảnh, vui lòng chuyển đổi mô hình để sử dụng",
"imageUpload": "Tải lên hình ảnh",
"tooltip": "Tải lên"
},
"clientMode": {
"actionFiletip": "Tải lên tệp",
"actionTooltip": "Tải lên",
"disabled": "Mô hình hiện tại không hỗ trợ nhận diện hình ảnh và phân tích tệp, vui lòng chuyển đổi mô hình để sử dụng"
},
"preview": {
"prepareTasks": "Chuẩn bị phân đoạn...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "Các khối văn bản chưa được vector hóa hoàn toàn, sẽ dẫn đến chức năng tìm kiếm ngữ nghĩa không khả dụng, để nâng cao chất lượng tìm kiếm, vui lòng vector hóa các khối văn bản",
"error": "Lỗi vector hóa",
"errorResult": "Lỗi vector hóa, vui lòng kiểm tra và thử lại. Nguyên nhân thất bại:",
"processing": "Các khối văn bản đang được vector hóa, vui lòng chờ",
"success": "Hiện tại tất cả các khối văn bản đã được vector hóa"
},

View file

@ -62,6 +62,7 @@
"pin": "置顶",
"pinOff": "取消置顶",
"rag": {
"referenceChunks": "引用源",
"userQuery": {
"actions": {
"delete": "删除 Query 重写",
@ -155,9 +156,18 @@
},
"updateAgent": "更新助理信息",
"upload": {
"actionFiletip": "上传文件",
"actionTooltip": "上传图片",
"disabled": "当前模型不支持视觉识别和文件分析,请切换模型后使用",
"action": {
"fileUpload": "上传文件",
"folderUpload": "上传文件夹",
"imageDisabled": "当前模型不支持视觉识别,请切换模型后使用",
"imageUpload": "上传图片",
"tooltip": "上传"
},
"clientMode": {
"actionFiletip": "上传文件",
"actionTooltip": "上传",
"disabled": "当前模型不支持视觉识别和文件分析,请切换模型后使用"
},
"preview": {
"prepareTasks": "准备分块...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "文本块尚未完全向量化,将导致语义检索功能不可用,为提升检索质量,请对文本块向量化",
"error": "向量化失败",
"errorResult": "向量化失败,请检查后重试。失败原因:",
"processing": "文本块正在向量化,请耐心等待",
"success": "当前文本块均已向量化"
},

View file

@ -62,6 +62,7 @@
"pin": "置頂",
"pinOff": "取消置頂",
"rag": {
"referenceChunks": "引用來源",
"userQuery": {
"actions": {
"delete": "刪除 Query 重寫",
@ -155,9 +156,18 @@
},
"updateAgent": "更新助理信息",
"upload": {
"actionFiletip": "上傳文件",
"actionTooltip": "上傳圖片",
"disabled": "當前模型不支援視覺識別,請切換模型後使用",
"action": {
"fileUpload": "上傳檔案",
"folderUpload": "上傳資料夾",
"imageDisabled": "當前模型不支援視覺識別,請切換模型後使用",
"imageUpload": "上傳圖片",
"tooltip": "上傳"
},
"clientMode": {
"actionFiletip": "上傳檔案",
"actionTooltip": "上傳",
"disabled": "當前模型不支援視覺識別和檔案分析,請切換模型後使用"
},
"preview": {
"prepareTasks": "準備分塊...",
"status": {

View file

@ -50,6 +50,8 @@
"chunks": {
"embeddingStatus": {
"empty": "文本塊尚未完全向量化,將導致語義檢索功能不可用,為提升檢索質量,請對文本塊向量化",
"error": "向量化失敗",
"errorResult": "向量化失敗,請檢查後重試。失敗原因:",
"processing": "文本塊正在向量化,請耐心等待",
"success": "當前文本塊均已向量化"
},

View file

@ -53,7 +53,8 @@
"pull": "git pull",
"release": "semantic-release",
"self-hosting:docker": "docker build -t lobe-chat:local .",
"self-hosting:docker-cn": "docker build -t lobe-chat:local --build-arg USE_CN_MIRROR=true .",
"self-hosting:docker-cn": "docker build -t lobe-chat-local --build-arg USE_CN_MIRROR=true .",
"self-hosting:docker-cn@database": "docker build -t lobe-chat-database-local -f Dockerfile.database --build-arg USE_CN_MIRROR=true .",
"start": "next start -p 3210",
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "npm run test-app && npm run test-server",

View file

@ -2,6 +2,7 @@ const { join } = require('node:path');
const { Pool } = require('pg');
const { drizzle } = require('drizzle-orm/node-postgres');
const migrator = require('drizzle-orm/node-postgres/migrator');
const { PGVECTOR_HINT } = require('./errorHint');
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not set, please set it in your environment variables.');
@ -29,6 +30,11 @@ runMigrations().catch((err) => {
'❌ Database migrate failed. Please check your database is valid and DATABASE_URL is set correctly. The error detail is below:',
);
console.error(err);
if (err.message.includes('extension "vector" is not available')) {
console.info(PGVECTOR_HINT);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View file

@ -0,0 +1,17 @@
const PGVECTOR_HINT = `⚠️ Database migrate failed due to \`pgvector\` extension not found. Please install the \`pgvector\` extension on your postgres instance.
1) if you are using docker postgres image:
you can just use \`pgvector/pgvector:pg16\` image instead of \`postgres\`, e.g:
\`\`\`
docker run -p 5432:5432 -d --name pg -e POSTGRES_PASSWORD=mysecretpassword pgvector/pgvector:pg16
\`\`\`
2) if you are using cloud postgres instance, please contact your cloud provider for help.
if you have any other question, please open issue here: https://github.com/lobehub/lobe-chat/issues
`;
module.exports = {
PGVECTOR_HINT,
};

View file

@ -3,6 +3,7 @@ import * as migrator from 'drizzle-orm/neon-serverless/migrator';
import { join } from 'node:path';
import { serverDB } from '../../src/database/server/core/db';
import { PGVECTOR_HINT } from './errorHint';
// Read the `.env` file if it exists, or a file specified by the
// dotenv_config_path parameter that's passed to Node.js
@ -24,6 +25,11 @@ if (connectionString) {
// eslint-disable-next-line unicorn/prefer-top-level-await
runMigrations().catch((err) => {
console.error('❌ Database migrate failed:', err);
if ((err.message as string).includes('extension "vector" is not available')) {
console.info(PGVECTOR_HINT);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View file

@ -0,0 +1,37 @@
import { Image } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import FileIcon from '@/components/FileIcon';
import { UploadFileItem } from '@/types/files/upload';
const useStyles = createStyles(({ css }) => ({
image: css`
margin-block: 0 !important;
box-shadow: none;
img {
object-fit: contain;
}
`,
video: css`
overflow: hidden;
border-radius: 8px;
`,
}));
const Content = memo<UploadFileItem>(({ file, previewUrl }) => {
const { styles } = useStyles();
if (file.type.startsWith('image')) {
return <Image alt={file.name} src={previewUrl} wrapperClassName={styles.image} />;
}
if (file.type.startsWith('video')) {
return <video className={styles.video} src={previewUrl} width={'100%'} />;
}
return <FileIcon fileName={file.name} fileType={file.type} size={100} />;
});
export default Content;

View file

@ -0,0 +1,87 @@
import { ActionIcon } from '@lobehub/ui';
import { Typography } from 'antd';
import { createStyles } from 'antd-style';
import { Trash2Icon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useFileStore } from '@/store/file';
import { UploadFileItem } from '@/types/files/upload';
import UploadDetail from '../../../components/UploadDetail';
import Content from './Content';
import { FILE_ITEM_SIZE } from './style';
const useStyles = createStyles(({ css, token }) => ({
actions: css`
position: absolute;
z-index: 10;
inset-block-start: -4px;
inset-inline-end: -4px;
background: ${token.colorBgElevated};
border-radius: 5px;
box-shadow:
0 0 0 0.5px ${token.colorFillSecondary} inset,
${token.boxShadowTertiary};
`,
container: css`
position: relative;
width: ${FILE_ITEM_SIZE}px;
min-width: ${FILE_ITEM_SIZE}px;
height: ${FILE_ITEM_SIZE}px;
background: ${token.colorBgContainer};
border-radius: 8px;
`,
image: css`
margin-block: 0 !important;
`,
status: css`
&.ant-tag {
padding-inline: 0;
background: none;
}
`,
}));
type FileItemProps = UploadFileItem;
const spacing = 8;
const FileItem = memo<FileItemProps>((props) => {
const { file, uploadState, status, id, tasks } = props;
const { t } = useTranslation(['chat', 'common']);
const { styles } = useStyles();
const [removeChatUploadFile] = useFileStore((s) => [s.removeChatUploadFile]);
return (
<Flexbox className={styles.container} distribution={'space-between'}>
<Center flex={1} height={FILE_ITEM_SIZE - 46} padding={spacing}>
<Content {...props} />
</Center>
<Flexbox gap={4} style={{ paddingBottom: 4, paddingInline: spacing }}>
<Typography.Text ellipsis={{ tooltip: true }} style={{ fontSize: 12 }}>
{file.name}
</Typography.Text>
<UploadDetail size={file.size} status={status} tasks={tasks} uploadState={uploadState} />
</Flexbox>
<Flexbox className={styles.actions}>
<ActionIcon
color={'red'}
icon={Trash2Icon}
onClick={() => {
removeChatUploadFile(id);
}}
size={'small'}
title={t('delete', { ns: 'common' })}
/>
</Flexbox>
</Flexbox>
);
});
export default FileItem;

View file

@ -0,0 +1,4 @@
export const FILE_ITEM_SIZE = 200;
// 8px on each side
export const IMAGE_FILE_SIZE = 200 - 2 * 8;

View file

@ -0,0 +1,28 @@
export const getHarmoniousSize = (
inputWidth: number,
inputHeight: number,
{
spacing = 24,
containerWidth,
containerHeight,
}: { containerHeight: number; containerWidth: number; spacing: number },
) => {
let width = String(inputWidth);
let height = String(inputHeight);
const maxWidth = containerWidth - spacing;
const maxHeight = containerHeight - spacing;
if (inputHeight >= inputWidth && inputHeight >= maxHeight) {
height = maxHeight + 'px';
width = 'auto';
} else if (inputWidth >= inputHeight && inputWidth >= maxWidth) {
height = 'auto';
width = maxWidth + 'px';
} else {
width = width + 'px';
height = height + 'px';
}
return { height, width };
};

View file

@ -0,0 +1,41 @@
import { createStyles } from 'antd-style';
import { lighten } from 'polished';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { fileChatSelectors, useFileStore } from '@/store/file';
import FileItem from './FileItem';
const useStyles = createStyles(({ css, token }) => ({
container: css`
overflow-x: scroll;
width: 100%;
background: ${lighten(0.01, token.colorBgLayout)};
border-start-start-radius: 8px;
border-start-end-radius: 8px;
`,
}));
const FileList = memo(() => {
const inputFilesList = useFileStore(fileChatSelectors.chatUploadFileList);
const showFileList = useFileStore(fileChatSelectors.chatUploadFileListHasItem);
const { styles } = useStyles();
return (
<Flexbox
className={styles.container}
gap={6}
horizontal
padding={showFileList ? '16px 16px 12px' : 0}
>
{inputFilesList.map((item) => (
<FileItem key={item.id} {...item} />
))}
</Flexbox>
);
});
export default FileList;

View file

@ -0,0 +1,40 @@
import { memo } from 'react';
import DragUpload from '@/components/DragUpload';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { useFileStore } from '@/store/file';
import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
import FileItemList from './FileList';
const FilePreview = memo(() => {
const model = useAgentStore(agentSelectors.currentAgentModel);
const canUploadImage = useUserStore(modelProviderSelectors.isModelEnabledUpload(model));
const [uploadFiles] = useFileStore((s) => [s.uploadChatFiles]);
const upload = async (fileList: FileList | File[] | undefined) => {
if (!fileList || fileList.length === 0) return;
// Filter out files that are not images if the model does not support image uploads
const files = Array.from(fileList).filter((file) => {
if (canUploadImage) return true;
return !file.type.startsWith('image');
});
uploadFiles(files);
};
return (
<>
<DragUpload onUploadFiles={upload} />
<FileItemList />
</>
);
});
export default FilePreview;

View file

@ -40,7 +40,7 @@ const SendMore = memo<SendMoreProps>(({ disabled, isMac }) => {
]);
const addAIMessage = useChatStore((s) => s.addAIMessage);
const sendMessage = useSendMessage();
const { send: sendMessage } = useSendMessage();
const hotKey = [ALT_KEY, 'enter'].join('+');
useHotkeys(

View file

@ -11,13 +11,12 @@ import StopLoadingIcon from '@/components/StopLoading';
import SaveTopic from '@/features/ChatInput/Topic';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { filesSelectors, useFileStore } from '@/store/file';
import { chatSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { isMacOS } from '@/utils/platform';
import LocalFiles from '../LocalFiles';
import LocalFiles from '../FilePreview';
import SendMore from './SendMore';
const useStyles = createStyles(({ css, prefixCls, token }) => {
@ -57,25 +56,14 @@ const Footer = memo<FooterProps>(({ setExpand, expand }) => {
const { theme, styles } = useStyles();
const [
isAIGenerating,
isHasMessageLoading,
isCreatingMessage,
isCreatingTopic,
stopGenerateMessage,
] = useChatStore((s) => [
const [isAIGenerating, stopGenerateMessage] = useChatStore((s) => [
chatSelectors.isAIGenerating(s),
chatSelectors.isHasMessageLoading(s),
chatSelectors.isCreatingMessage(s),
topicSelectors.isCreatingTopic(s),
s.stopGenerateMessage,
]);
const isImageUploading = useFileStore(filesSelectors.isImageUploading);
const [useCmdEnterToSend] = useUserStore((s) => [preferenceSelectors.useCmdEnterToSend(s)]);
const sendMessage = useSendMessage();
const { send: sendMessage, canSend } = useSendMessage();
const [isMac, setIsMac] = useState<boolean>();
useEffect(() => {
@ -105,9 +93,6 @@ const Footer = memo<FooterProps>(({ setExpand, expand }) => {
const wrapperShortcut = useCmdEnterToSend ? enter : cmdEnter;
const buttonDisabled =
isImageUploading || isHasMessageLoading || isCreatingTopic || isCreatingMessage;
return (
<Flexbox
align={'end'}
@ -146,8 +131,8 @@ const Footer = memo<FooterProps>(({ setExpand, expand }) => {
) : (
<Space.Compact>
<Button
disabled={buttonDisabled}
loading={buttonDisabled}
disabled={!canSend}
loading={!canSend}
onClick={() => {
sendMessage();
setExpand?.(false);
@ -156,7 +141,7 @@ const Footer = memo<FooterProps>(({ setExpand, expand }) => {
>
{t('input.send')}
</Button>
<SendMore disabled={buttonDisabled} isMac={isMac} />
<SendMore disabled={!canSend} isMac={isMac} />
</Space.Compact>
)}
</Flexbox>

View file

@ -1,46 +0,0 @@
import { memo } from 'react';
import DragUpload from '@/components/DragUpload';
import { EditableFileList } from '@/features/FileList';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { useFileStore } from '@/store/file';
import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
interface LocalFilesProps {
padding?: number | string;
}
const LocalFiles = memo<LocalFilesProps>(({ padding }) => {
const model = useAgentStore(agentSelectors.currentAgentModel);
const enabledFiles = useUserStore(modelProviderSelectors.isModelEnabledFiles(model));
const canUpload = useUserStore(modelProviderSelectors.isModelEnabledUpload(model));
const inputFilesList = useFileStore((s) => s.inputFilesList);
const uploadFile = useFileStore((s) => s.uploadFile);
const uploadImages = async (fileList: FileList | undefined) => {
if (!fileList || fileList.length === 0) return;
const pools = Array.from(fileList).map(async (file) => {
// skip none-file items
if (!file.type.startsWith('image') && !enabledFiles) return;
await uploadFile(file);
});
await Promise.all(pools);
};
return (
canUpload && (
<>
<DragUpload enabledFiles={enabledFiles} onUploadFiles={uploadImages} />
<EditableFileList items={inputFilesList} padding={padding ?? 0} />
</>
)
);
});
export default LocalFiles;

View file

@ -51,7 +51,7 @@ const InputArea = memo<InputAreaProps>(({ setExpand }) => {
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
const sendMessage = useSendMessage();
const { send: sendMessage } = useSendMessage();
useAutoFocus(ref);

View file

@ -9,13 +9,12 @@ import {
CHAT_TEXTAREA_MAX_HEIGHT,
HEADER_HEIGHT,
} from '@/const/layoutTokens';
import { useFileStore } from '@/store/file';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import LocalFiles from './FilePreview';
import Footer from './Footer';
import Head from './Header';
import LocalFiles from './LocalFiles';
import TextArea from './TextArea';
const DesktopChatInput = memo(() => {
@ -25,11 +24,10 @@ const DesktopChatInput = memo(() => {
systemStatusSelectors.inputHeight(s),
s.updateSystemStatus,
]);
const showFileList = useFileStore((s) => s.inputFilesList.length > 0);
return (
<>
{!expand && <LocalFiles padding={showFileList ? '8px 16px' : 0} />}
{!expand && <LocalFiles />}
<DraggablePanel
fullscreen={expand}
headerHeight={HEADER_HEIGHT}

View file

@ -1,19 +0,0 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { EditableFileList } from '@/features/FileList';
import { useFileStore } from '@/store/file';
const Files = memo(() => {
const inputFilesList = useFileStore((s) => s.inputFilesList);
if (!inputFilesList || inputFilesList?.length === 0) return null;
return (
<Flexbox paddingBlock={4} style={{ position: 'relative' }}>
<EditableFileList alwaysShowClose items={inputFilesList} padding={'4px 8px 8px'} />
</Flexbox>
);
});
export default Files;

View file

@ -0,0 +1,72 @@
import { ActionIcon } from '@lobehub/ui';
import { Typography } from 'antd';
import { createStyles } from 'antd-style';
import { Trash } from 'lucide-react';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileIcon from '@/components/FileIcon';
import { UploadFileItem } from '@/types/files';
import UploadDetail from '../../../components/UploadDetail';
const useStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
position: relative;
overflow: hidden;
width: 250px;
height: 64px;
padding-block: 4px;
padding-inline: 8px 24px;
background: ${token.colorFillTertiary};
border: 1px solid ${token.colorBorder};
border-radius: 8px;
`,
deleteButton: css`
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
color: #fff;
background: ${token.colorBgMask};
&:hover {
background: ${token.colorError};
}
`,
}));
interface FileItemProps extends UploadFileItem {
onRemove?: () => void;
}
const FileItem = memo<FileItemProps>(({ id, onRemove, file, status, uploadState, tasks }) => {
const { styles } = useStyles();
return (
<Flexbox align={'center'} className={styles.container} gap={12} horizontal key={id}>
<FileIcon fileName={file.name} fileType={file.type} />
<Flexbox style={{ overflow: 'hidden' }}>
<Typography.Text ellipsis={{ tooltip: false }}>{file.name}</Typography.Text>
<UploadDetail size={file.size} status={status} tasks={tasks} uploadState={uploadState} />
</Flexbox>
<ActionIcon
className={styles.deleteButton}
glass
icon={Trash}
onClick={(e) => {
e.stopPropagation();
onRemove?.();
}}
size={'small'}
/>
</Flexbox>
);
});
export default FileItem;

View file

@ -0,0 +1,74 @@
import { ActionIcon, Image } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Trash } from 'lucide-react';
import { memo } from 'react';
import { usePlatform } from '@/hooks/usePlatform';
import { MIN_IMAGE_SIZE } from './style';
const useStyles = createStyles(({ css, token }) => ({
deleteButton: css`
color: #fff;
background: ${token.colorBgMask};
&:hover {
background: ${token.colorError};
}
`,
editableImage: css`
background: ${token.colorBgContainer};
box-shadow: 0 0 0 1px ${token.colorFill} inset;
`,
image: css`
margin-block: 0 !important;
.ant-image {
height: 100% !important;
img {
height: 100% !important;
}
}
`,
}));
interface FileItemProps {
alt?: string;
loading?: boolean;
onRemove?: () => void;
src?: string;
}
const FileItem = memo<FileItemProps>(({ alt, onRemove, src, loading }) => {
const IMAGE_SIZE = MIN_IMAGE_SIZE;
const { styles, cx } = useStyles();
const { isSafari } = usePlatform();
return (
<Image
actions={
<ActionIcon
className={styles.deleteButton}
glass
icon={Trash}
onClick={(e) => {
e.stopPropagation();
onRemove?.();
}}
size={'small'}
/>
}
alt={alt || ''}
alwaysShowActions
height={isSafari ? 'auto' : '100%'}
isLoading={loading}
size={IMAGE_SIZE as any}
src={src}
style={{ height: isSafari ? 'auto' : '100%' }}
wrapperClassName={cx(styles.image, styles.editableImage)}
/>
);
});
export default FileItem;

View file

@ -0,0 +1,39 @@
import { CSSProperties, memo } from 'react';
import { useFileStore } from '@/store/file';
import { UploadFileItem } from '@/types/files';
import File from './File';
import Image from './Image';
interface FileItemProps extends UploadFileItem {
alt?: string;
className?: string;
loading?: boolean;
onClick?: () => void;
onRemove?: () => void;
style?: CSSProperties;
url?: string;
}
const FileItem = memo<FileItemProps>((props) => {
const { file, id, previewUrl, status } = props;
const [removeFile] = useFileStore((s) => [s.removeChatUploadFile]);
if (file.type.startsWith('image')) {
return (
<Image
alt={file.name}
loading={status === 'pending'}
onRemove={() => {
removeFile(id);
}}
src={previewUrl}
/>
);
}
return <File onRemove={() => removeFile(id)} {...props} />;
});
export default FileItem;

View file

@ -0,0 +1 @@
export const MIN_IMAGE_SIZE = 64;

View file

@ -0,0 +1,33 @@
import { ImageGallery } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { filesSelectors, useFileStore } from '@/store/file';
import FileItem from './FileItem';
const Files = memo(() => {
const list = useFileStore(filesSelectors.chatUploadFileList, isEqual);
if (!list || list?.length === 0) return null;
return (
<Flexbox paddingBlock={4} style={{ position: 'relative' }}>
<Flexbox
gap={4}
horizontal
padding={'4px 8px 8px'}
style={{ overflow: 'scroll', width: '100%' }}
>
<ImageGallery>
{list.map((i) => (
<FileItem {...i} key={i.id} loading={i.status === 'pending'} />
))}
</ImageGallery>
</Flexbox>
</Flexbox>
);
});
export default Files;

View file

@ -0,0 +1,41 @@
import { css, cx } from 'antd-style';
import { FC, ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const container = css`
height: inherit;
padding-block: 0;
padding-inline: 8px;
`;
interface InnerContainerProps {
bottomAddons?: ReactNode;
children: ReactNode;
expand?: boolean;
textAreaLeftAddons?: ReactNode;
textAreaRightAddons?: ReactNode;
topAddons?: ReactNode;
}
const InnerContainer: FC<InnerContainerProps> = memo(
({ children, expand, textAreaRightAddons, textAreaLeftAddons, bottomAddons, topAddons }) =>
expand ? (
<Flexbox className={cx(container)} gap={8}>
<Flexbox gap={8} horizontal justify={'flex-end'}>
{textAreaLeftAddons}
{textAreaRightAddons}
</Flexbox>
{children}
{topAddons}
{bottomAddons}
</Flexbox>
) : (
<Flexbox align={'flex-end'} className={cx(container)} gap={8} horizontal>
{textAreaLeftAddons}
{children}
{textAreaRightAddons}
</Flexbox>
),
);
export default InnerContainer;

View file

@ -0,0 +1,154 @@
import { ActionIcon, MobileSafeArea, TextArea } from '@lobehub/ui';
import { useSize } from 'ahooks';
import { createStyles } from 'antd-style';
import { TextAreaRef } from 'antd/es/input/TextArea';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { rgba } from 'polished';
import { CSSProperties, ReactNode, forwardRef, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import InnerContainer from './Container';
const useStyles = createStyles(({ css, token }) => {
return {
container: css`
flex: none;
padding-block: 12px 12px;
background: ${token.colorFillQuaternary};
border-block-start: 1px solid ${rgba(token.colorBorder, 0.25)};
`,
expand: css`
position: absolute;
height: 100%;
`,
expandButton: css`
position: absolute;
inset-inline-start: 14px;
`,
expandTextArea: css`
flex: 1;
`,
};
});
export interface MobileChatInputAreaProps {
bottomAddons?: ReactNode;
className?: string;
expand?: boolean;
loading?: boolean;
onInput?: (value: string) => void;
onSend?: () => void;
safeArea?: boolean;
setExpand?: (expand: boolean) => void;
style?: CSSProperties;
textAreaLeftAddons?: ReactNode;
textAreaRightAddons?: ReactNode;
topAddons?: ReactNode;
value: string;
}
const MobileChatInputArea = forwardRef<TextAreaRef, MobileChatInputAreaProps>(
(
{
className,
style,
topAddons,
textAreaLeftAddons,
textAreaRightAddons,
bottomAddons,
expand = false,
setExpand,
onSend,
onInput,
loading,
value,
safeArea,
},
ref,
) => {
const { t } = useTranslation('chat');
const isChineseInput = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const { cx, styles } = useStyles();
const size = useSize(containerRef);
const [showFullscreen, setShowFullscreen] = useState<boolean>(false);
const [isFocused, setIsFocused] = useState<boolean>(false);
useEffect(() => {
if (!size?.height) return;
setShowFullscreen(size.height > 72);
}, [size]);
const showAddons = !expand && !isFocused;
return (
<Flexbox
className={cx(styles.container, expand && styles.expand, className)}
gap={12}
style={style}
>
{topAddons && <Flexbox style={showAddons ? {} : { display: 'none' }}>{topAddons}</Flexbox>}
<Flexbox
className={cx(expand && styles.expand)}
ref={containerRef}
style={{ position: 'relative' }}
>
{showFullscreen && (
<ActionIcon
active
className={styles.expandButton}
icon={expand ? ChevronDown : ChevronUp}
onClick={() => setExpand?.(!expand)}
size={{ blockSize: 24, borderRadius: '50%', fontSize: 14 }}
style={expand ? { top: 6 } : {}}
/>
)}
<InnerContainer
bottomAddons={bottomAddons}
expand={expand}
textAreaLeftAddons={textAreaLeftAddons}
textAreaRightAddons={textAreaRightAddons}
topAddons={topAddons}
>
<TextArea
autoSize={expand ? false : { maxRows: 6, minRows: 0 }}
className={cx(expand && styles.expandTextArea)}
onBlur={(e) => {
onInput?.(e.target.value);
setIsFocused(false);
}}
onChange={(e) => {
onInput?.(e.target.value);
}}
onCompositionEnd={() => {
isChineseInput.current = false;
}}
onCompositionStart={() => {
isChineseInput.current = true;
}}
onFocus={() => setIsFocused(true)}
onPressEnter={(e) => {
if (!loading && !isChineseInput.current && e.shiftKey) {
e.preventDefault();
onSend?.();
}
}}
placeholder={t('sendPlaceholder')}
ref={ref}
style={{ height: 36, paddingBlock: 6 }}
type={expand ? 'pure' : 'block'}
value={value}
/>
</InnerContainer>
</Flexbox>
{bottomAddons && (
<Flexbox style={showAddons ? {} : { display: 'none' }}>{bottomAddons}</Flexbox>
)}
{safeArea && !isFocused && <MobileSafeArea position={'bottom'} />}
</Flexbox>
);
},
);
export default MobileChatInputArea;

View file

@ -0,0 +1,34 @@
import { ActionIcon, type ActionIconSize, Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { Loader2, SendHorizontal } from 'lucide-react';
import { memo } from 'react';
export interface MobileChatSendButtonProps {
disabled?: boolean;
loading?: boolean;
onSend?: () => void;
onStop?: () => void;
}
const MobileChatSendButton = memo<MobileChatSendButtonProps>(
({ loading, onStop, onSend, disabled }) => {
const size: ActionIconSize = {
blockSize: 36,
fontSize: 16,
};
return loading ? (
<ActionIcon active icon={Loader2} onClick={onStop} size={size} spin />
) : (
<Button
disabled={disabled}
icon={(<Icon icon={SendHorizontal} />) as any}
onClick={onSend}
style={{ flex: 'none' }}
type={'primary'}
/>
);
},
);
export default MobileChatSendButton;

View file

@ -1,29 +1,42 @@
'use client';
import { MobileChatInputArea, MobileChatSendButton } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { TextAreaRef } from 'antd/es/input/TextArea';
import { memo, useRef, useState } from 'react';
import ActionBar from '@/features/ChatInput/ActionBar';
import STT from '@/features/ChatInput/STT';
import SaveTopic from '@/features/ChatInput/Topic';
import { useChatInput } from '@/features/ChatInput/useChatInput';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import Files from './Files';
import InputArea from './InputArea';
import SendButton from './Send';
const MobileChatInput = memo(() => {
const { t } = useTranslation('chat');
const theme = useTheme();
const { ref, onSend, loading, value, onInput, onStop, expand, setExpand } = useChatInput();
const ref = useRef<TextAreaRef>(null);
const [expand, setExpand] = useState<boolean>(false);
const { send: sendMessage, canSend } = useSendMessage();
const [loading, value, onInput, onStop] = useChatStore((s) => [
chatSelectors.isAIGenerating(s),
s.inputMessage,
s.updateInputMessage,
s.stopGenerateMessage,
]);
return (
<MobileChatInputArea
<InputArea
expand={expand}
loading={loading}
onInput={onInput}
onSend={onSend}
placeholder={t('sendPlaceholder')}
onSend={() => {
setExpand(false);
sendMessage();
}}
ref={ref}
setExpand={setExpand}
style={{
@ -34,7 +47,7 @@ const MobileChatInput = memo(() => {
}}
textAreaLeftAddons={<STT mobile />}
textAreaRightAddons={
<MobileChatSendButton loading={loading} onSend={onSend} onStop={onStop} />
<SendButton disabled={!canSend} loading={loading} onSend={sendMessage} onStop={onStop} />
}
topAddons={
<>

View file

@ -0,0 +1,71 @@
import { CheckCircleFilled } from '@ant-design/icons';
import { Icon } from '@lobehub/ui';
import { Progress, Typography } from 'antd';
import { useTheme } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FileUploadState, FileUploadStatus } from '@/types/files/upload';
import { formatSize } from '@/utils/format';
interface UploadStateProps {
size: number;
status: FileUploadStatus;
uploadState?: FileUploadState;
}
const UploadStatus = memo<UploadStateProps>(({ status, size, uploadState }) => {
const theme = useTheme();
const { t } = useTranslation('chat');
switch (status) {
default:
case 'pending': {
return (
<Flexbox align={'center'} gap={4} horizontal>
<Icon icon={Loader2Icon} size={{ fontSize: 12 }} spin />
<Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
{t('upload.preview.status.pending')}
</Typography.Text>
</Flexbox>
);
}
case 'uploading': {
return (
<Flexbox align={'center'} gap={4} horizontal>
<Progress percent={uploadState?.progress} size={14} type="circle" />
<Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
{formatSize(size * ((uploadState?.progress || 0) / 100), 2)} / {formatSize(size)}
</Typography.Text>
</Flexbox>
);
}
case 'processing': {
return (
<Flexbox align={'center'} gap={4} horizontal>
<Progress percent={uploadState?.progress} size={14} type="circle" />
<Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
{formatSize(size)} · {t('upload.preview.status.processing')}
</Typography.Text>
</Flexbox>
);
}
case 'success': {
return (
<Flexbox align={'center'} gap={4} horizontal>
<CheckCircleFilled style={{ color: theme.colorSuccess, fontSize: 12 }} />
<Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
{formatSize(size)}
</Typography.Text>
</Flexbox>
);
}
}
});
export default UploadStatus;

View file

@ -0,0 +1,49 @@
import { Typography } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import FileParsingStatus from '@/components/FileParsingStatus';
import { FileParsingTask } from '@/types/asyncTask';
import { FileUploadState, FileUploadStatus } from '@/types/files';
import UploadStatus from './UploadStatus';
const useStyles = createStyles(({ css }) => ({
status: css`
&.ant-tag {
padding-inline: 0;
background: none;
}
`,
}));
interface UploadDetailProps {
size: number;
status: FileUploadStatus;
tasks?: FileParsingTask;
uploadState?: FileUploadState;
}
const UploadDetail = memo<UploadDetailProps>(({ uploadState, status, size, tasks }) => {
const { t } = useTranslation('chat');
const { styles } = useStyles();
return (
<Flexbox align={'center'} gap={8} height={22} horizontal>
<UploadStatus size={size} status={status} uploadState={uploadState} />
{!!tasks && Object.keys(tasks).length === 0 ? (
<Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
{t('upload.preview.prepareTasks')}
</Typography.Text>
) : (
<div>
<FileParsingStatus {...tasks} className={styles.status} hideEmbeddingButton />
</div>
)}
</Flexbox>
);
});
export default UploadDetail;

View file

@ -0,0 +1,26 @@
import { Flexbox } from 'react-layout-kit';
import Loading from '@/components/CircleLoading';
import FileViewer from '@/features/FileViewer';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { useFileStore } from '@/store/file';
const FilePreview = () => {
const previewFileId = useChatStore(chatPortalSelectors.previewFileId);
const useFetchFileItem = useFileStore((s) => s.useFetchFileItem);
const { data, isLoading } = useFetchFileItem(previewFileId);
if (isLoading) return <Loading />;
if (!data) return;
console.log(data);
return (
<Flexbox height={'100%'} paddingBlock={'0 4px'} paddingInline={4} style={{ borderRadius: 4 }}>
<FileViewer {...data} />
</Flexbox>
);
};
export default FilePreview;

View file

@ -0,0 +1,53 @@
import { Typography } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileIcon from '@/components/FileIcon';
import { ChatFileItem } from '@/types/message';
import { formatSize } from '@/utils/format';
const useStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
overflow: hidden;
max-width: 420px;
padding-block: 8px;
padding-inline: 12px;
background: ${token.colorFillTertiary};
border-radius: 8px;
&:hover {
background: ${token.colorFillSecondary};
}
`,
}));
const ArtifactItem = memo<ChatFileItem>(({ name, fileType, size }) => {
const { styles } = useStyles();
return (
<Flexbox
align={'center'}
className={styles.container}
gap={8}
horizontal
// onClick={() => {
// if (!isToolHasUI || !identifier) return;
//
// openToolUI(messageId, identifier);
// }}
>
<FileIcon fileName={name} fileType={fileType} />
<Flexbox>
<Typography.Text ellipsis={{ tooltip: true }}>{name}</Typography.Text>
<Typography.Text type={'secondary'}>{formatSize(size)}</Typography.Text>
</Flexbox>
</Flexbox>
);
});
export default ArtifactItem;

View file

@ -0,0 +1,50 @@
import { Avatar, Icon } from '@lobehub/ui';
import { Typography } from 'antd';
import { useTheme } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { InboxIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import Balancer from 'react-wrap-balancer';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import SkeletonLoading from '../../../components/SkeletonLoading';
import FileItem from './Item';
const FileList = () => {
const { t } = useTranslation('portal');
const files = useChatStore(chatSelectors.currentUserFiles, isEqual);
const theme = useTheme();
const isCurrentChatLoaded = useChatStore(chatSelectors.isCurrentChatLoaded);
return !isCurrentChatLoaded ? (
<Flexbox gap={12} paddingInline={12}>
<SkeletonLoading />
</Flexbox>
) : files.length === 0 ? (
<Center
gap={8}
paddingBlock={24}
style={{ border: `1px dashed ${theme.colorSplit}`, borderRadius: 8, marginInline: 12 }}
>
<Avatar
avatar={<Icon icon={InboxIcon} size={'large'} />}
background={theme.colorFillTertiary}
size={48}
/>
<Balancer>
<Typography.Text type={'secondary'}>{t('emptyArtifactList')}</Typography.Text>
</Balancer>
</Center>
) : (
<Flexbox gap={12} paddingInline={12}>
{files.map((m) => (
<FileItem {...m} key={m.id} />
))}
</Flexbox>
);
};
export default FileList;

View file

@ -0,0 +1,21 @@
import { Typography } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import FileList from './FileList';
export const Files = memo(() => {
const { t } = useTranslation('portal');
return (
<Flexbox gap={8}>
<Typography.Title level={5} style={{ marginInline: 12 }}>
{t('files')}
</Typography.Title>
<FileList />
</Flexbox>
);
});
export default Files;

View file

@ -1,10 +1,12 @@
import { Flexbox } from 'react-layout-kit';
import Artifacts from './Artifacts';
import Files from './Files';
const Home = () => {
return (
<Flexbox gap={12} height={'100%'}>
<Files />
<Artifacts />
</Flexbox>
);

View file

@ -6,13 +6,17 @@ import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import Artifacts from './Artifacts';
import FilePreview from './FilePreview';
import Home from './Home';
const PortalView = memo(() => {
const showArtifactUI = useChatStore(chatPortalSelectors.showArtifactUI);
const showFilePreview = useChatStore(chatPortalSelectors.showFilePreview);
if (showArtifactUI) return <Artifacts />;
if (showFilePreview) return <FilePreview />;
return <Home />;
});

View file

@ -0,0 +1,41 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { LibraryBig } from 'lucide-react';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import KnowledgeIcon from '@/components/KnowledgeIcon';
import { KnowledgeItem } from '@/types/knowledgeBase';
export interface PluginTagProps {
data: KnowledgeItem[];
}
const PluginTag = memo<PluginTagProps>(({ data }) => {
if (data.length === 0) return null;
const items: MenuProps['items'] = data.map((item) => ({
icon: <KnowledgeIcon fileType={item.fileType} name={item.name} type={item.type} />,
key: item.id,
label: <Flexbox style={{ paddingInlineStart: 8 }}>{item.name}</Flexbox>,
}));
const count = data.length;
return (
<Dropdown menu={{ items }}>
<div>
<Tag>
{<Icon icon={LibraryBig} />}
{data[0].name}
{count > 1 && <div>({data.length - 1}+)</div>}
</Tag>
</div>
</Dropdown>
);
});
export default PluginTag;

View file

@ -1,3 +1,4 @@
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
@ -9,12 +10,15 @@ import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
import PluginTag from '../../../features/PluginTag';
import KnowledgeTag from './KnowledgeTag';
const TitleTags = memo(() => {
const [model, plugins] = useAgentStore((s) => [
const [model, hasKnowledge] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentPlugins(s),
agentSelectors.hasKnowledge(s),
]);
const plugins = useAgentStore(agentSelectors.currentAgentPlugins, isEqual);
const enabledKnowledge = useAgentStore(agentSelectors.currentEnabledKnowledge, isEqual);
const showPlugin = useUserStore(modelProviderSelectors.isModelEnabledFunctionCall(model));
@ -24,6 +28,7 @@ const TitleTags = memo(() => {
<ModelTag model={model} />
</ModelSwitchPanel>
{showPlugin && plugins?.length > 0 && <PluginTag plugins={plugins} />}
{hasKnowledge && <KnowledgeTag data={enabledKnowledge} />}
</Flexbox>
);
});

View file

@ -18,7 +18,7 @@ const HotKeys = () => {
]);
const lastMessage = useChatStore(chatSelectors.latestMessage, isEqual);
const [clearImageList] = useFileStore((s) => [s.clearImageList]);
const [clearImageList] = useFileStore((s) => [s.clearChatUploadFileList]);
const clearHotkeys = [META_KEY, ALT_KEY, CLEAN_MESSAGE_KEY].join('+');
const resetConversation = useCallback(() => {

View file

@ -0,0 +1,27 @@
'use client';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import PanelTitle from '@/components/PanelTitle';
import FileMenu from './features/FileMenu';
import KnowledgeBase from './features/KnowledgeBase';
const Menu = () => {
const { t } = useTranslation('file');
return (
<Flexbox gap={16} height={'100%'}>
<Flexbox paddingInline={8}>
<PanelTitle desc={t('desc')} title={t('title')} />
<FileMenu />
</Flexbox>
<KnowledgeBase />
</Flexbox>
);
};
Menu.displayName = 'Menu';
export default Menu;

View file

@ -0,0 +1,97 @@
'use client';
import { Icon } from '@lobehub/ui';
import { FileText, Globe, ImageIcon, LayoutGrid, Mic2, SquarePlay } from 'lucide-react';
import Link from 'next/link';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useFileCategory } from '@/app/(main)/files/hooks/useFileCategory';
import Menu from '@/components/Menu';
import type { MenuProps } from '@/components/Menu';
import { FilesTabs } from '@/types/files';
const FileMenu = memo(() => {
const { t } = useTranslation('file');
const [activeKey, setActiveKey] = useFileCategory();
const items: MenuProps['items'] = useMemo(
() =>
[
{
icon: <Icon icon={LayoutGrid} />,
key: FilesTabs.All,
label: (
<Link href={'/files'} onClick={(e) => e.preventDefault()}>
{t('tab.all')}
</Link>
),
},
{
icon: <Icon icon={FileText} />,
key: FilesTabs.Documents,
label: (
<Link href={'/files?category=documents'} onClick={(e) => e.preventDefault()}>
{t('tab.documents')}
</Link>
),
},
{
icon: <Icon icon={ImageIcon} />,
key: FilesTabs.Images,
label: (
<Link href={'/files?category=images'} onClick={(e) => e.preventDefault()}>
{t('tab.images')}
</Link>
),
},
{
icon: <Icon icon={Mic2} />,
key: FilesTabs.Audios,
label: (
<Link href={'/files?category=audios'} onClick={(e) => e.preventDefault()}>
{t('tab.audios')}
</Link>
),
},
{
icon: <Icon icon={SquarePlay} />,
key: FilesTabs.Videos,
label: (
<Link href={'/files?category=videos'} onClick={(e) => e.preventDefault()}>
{t('tab.videos')}
</Link>
),
},
{
icon: <Icon icon={Globe} />,
key: FilesTabs.Websites,
label: (
<Link href={'/files?category=websites'} onClick={(e) => e.preventDefault()}>
{t('tab.websites')}
</Link>
),
},
]
.filter(Boolean)
.slice(0, 5) as MenuProps['items'],
[t],
);
return (
<Flexbox>
<Menu
items={items}
onClick={({ key }) => {
setActiveKey(key);
}}
selectable
selectedKeys={[activeKey]}
variant={'compact'}
/>
</Flexbox>
);
});
export default FileMenu;

View file

@ -0,0 +1,53 @@
import { createStyles } from 'antd-style';
import React from 'react';
import { Trans } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, token }) => ({
container: css`
font-size: 12px;
color: ${token.colorTextTertiary};
`,
paragraph: css`
justify-content: center;
width: 100%;
kbd {
margin-inline: 2px;
padding-inline: 6px;
background: ${token.colorFillTertiary};
border-radius: 4px;
}
`,
}));
const EmptyStatus = () => {
const { styles } = useStyles();
return (
<Flexbox
align={'flex-end'}
className={styles.container}
gap={12}
paddingInline={20}
width={'100%'}
>
<svg
fill="currentColor"
fillRule="evenodd"
style={{ flex: 'none', height: 'fit-content', lineHeight: 1 }}
viewBox="0 0 126 64"
width={130}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M.5 63a.5.5 0 001 0h-1zM122 1l-2.887 5h5.774L122 1zM1.5 62.042a.5.5 0 10-1 0h1zm-1-1.917a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.916a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.917a.5.5 0 101 0h-1zm1-1.916a.5.5 0 10-1 0h1zm-1-1.917a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.916a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.917a.5.5 0 101 0h-1zm1.063-1.938a.5.5 0 10-.991-.13l.991.13zm-.418-2.274a.5.5 0 00.924.383l-.924-.383zm1.904-1.312a.5.5 0 10-.793-.609l.793.61zm.776-2.178a.5.5 0 00.61.793l-.61-.793zm2.304-.187a.5.5 0 00-.383-.924l.383.924zm1.761-1.497a.5.5 0 00.13.991l-.13-.991zm2.12.928a.5.5 0 100-1v1zm2.019-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 000-1v1zm2.018-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 100-1v1zm2.019-1a.5.5 0 100 1v-1zm2.019 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 000 1v-1zm2.018 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 100-1v1zm2.02-1a.5.5 0 100 1v-1zm2.018 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 100-1v1zm2.02-1a.5.5 0 100 1v-1zm2.018 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 100-1v1zm2.018-1a.5.5 0 100 1v-1zm2.02 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 100-1v1zm2.018-1a.5.5 0 100 1v-1zm2.02 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 000-1v1zm2.018-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 100-1v1zm2.019-1a.5.5 0 100 1v-1zm2.019 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 100 1v-1zm2.019 1a.5.5 0 100-1v1zm2.02-1a.5.5 0 100 1v-1zm2.018 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 000-1v1zm2.02-1a.5.5 0 100 1v-1zm2.018 1a.5.5 0 100-1v1zm2.02-1a.5.5 0 100 1v-1zm2.019 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.02 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.019 1a.5.5 0 000-1v1zm2.019-1a.5.5 0 000 1v-1zm2.12.928a.501.501 0 00-.13-.991l.13.991zm1.761-1.497a.501.501 0 00.383.924l-.383-.924zm2.304-.187a.5.5 0 00-.609-.793l.609.793zm.776-2.178a.5.5 0 10.793.609l-.793-.61zm1.904-1.312a.5.5 0 10-.924-.383l.924.383zm-.418-2.274a.5.5 0 10.991.13l-.991-.13zm1.063-1.938a.5.5 0 00-1 0h1zm-1-1.917a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.916a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.917a.5.5 0 001 0h-1zm1-1.916a.5.5 0 00-1 0h1zm-1-1.917a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.916a.5.5 0 001 0h-1zm1-1.917a.5.5 0 00-1 0h1zm-1-1.917a.5.5 0 001 0h-1zM1.5 63v-.958h-1V63h1zm0-2.875v-1.917h-1v1.917h1zm0-3.833v-1.917h-1v1.917h1zm0-3.834v-1.916h-1v1.916h1zm0-3.833v-1.917h-1v1.917h1zm0-3.833v-1.917h-1v1.917h1zm0-3.834V40h-1v.958h1zm0-.958c0-.333.022-.66.063-.98l-.991-.13A8.574 8.574 0 00.5 40h1zm.569-2.87c.253-.61.584-1.18.98-1.696l-.793-.609a8.49 8.49 0 00-1.11 1.921l.923.383zm2.365-3.08a7.487 7.487 0 011.695-.981l-.383-.924a8.495 8.495 0 00-1.92 1.111l.608.793zm3.586-1.487c.32-.041.648-.063.98-.063v-1c-.376 0-.746.024-1.11.072l.13.991zM9 32.5h1.01v-1H9v1zm3.029 0h2.02v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.02v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.02v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.02v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.02v1zm4.038 0h2.019v-1h-2.019v1zm4.038 0h2.02v-1h-2.02v1zm4.039 0h2.019v-1h-2.019v1zm4.038 0H114v-1h-1.01v1zm1.01 0c.376 0 .746-.024 1.11-.072l-.13-.991c-.32.041-.648.063-.98.063v1zm3.254-.645a8.506 8.506 0 001.921-1.111l-.609-.793a7.519 7.519 0 01-1.695.98l.383.924zm3.49-2.68a8.516 8.516 0 001.111-1.921l-.924-.383a7.527 7.527 0 01-.98 1.695l.793.609zm1.684-4.066c.048-.363.072-.733.072-1.109h-1c0 .332-.022.66-.063.98l.991.13zM122.5 24v-.958h-1V24h1zm0-2.875v-1.917h-1v1.917h1zm0-3.833v-1.917h-1v1.917h1zm0-3.834v-1.916h-1v1.916h1zm0-3.833V7.708h-1v1.917h1zm0-3.833V3.875h-1v1.917h1z"></path>
</svg>
<Flexbox align={'center'} className={styles.paragraph} horizontal>
<Trans i18nKey={'knowledgeBase.list.empty'} ns={'file'}>
<kbd>+</kbd>
</Trans>
</Flexbox>
</Flexbox>
);
};
export default EmptyStatus;

View file

@ -0,0 +1,175 @@
import { ActionIcon, EditableText, Icon } from '@lobehub/ui';
import { App, Dropdown, type MenuProps, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { LucideLoader2, MoreVertical, PencilLine, Trash } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import BubblesLoading from '@/components/BubblesLoading';
import RepoIcon from '@/components/RepoIcon';
import { LOADING_FLAT } from '@/const/message';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
export const knowledgeItemClass = 'knowledge-base-item';
const useStyles = createStyles(({ css }) => ({
content: css`
position: relative;
overflow: hidden;
flex: 1;
`,
icon: css`
min-width: 24px;
border-radius: 4px;
`,
title: css`
flex: 1;
height: 28px;
line-height: 28px;
text-align: start;
`,
}));
const { Paragraph } = Typography;
interface KnowledgeBaseItemProps {
id: string;
name: string;
showMore: boolean;
}
const Content = memo<KnowledgeBaseItemProps>(({ id, name, showMore }) => {
const { t } = useTranslation(['file', 'common']);
const [editing, updateKnowledgeBase, removeKnowledgeBase, isLoading] = useKnowledgeBaseStore(
(s) => [
s.knowledgeBaseRenamingId === id,
s.updateKnowledgeBase,
s.removeKnowledgeBase,
s.knowledgeBaseLoadingIds.includes(id),
],
);
const { styles } = useStyles();
const toggleEditing = (visible?: boolean) => {
useKnowledgeBaseStore.setState(
{ knowledgeBaseRenamingId: visible ? id : null },
false,
'toggleEditing',
);
};
const { modal } = App.useApp();
const items = useMemo<MenuProps['items']>(
() => [
{
icon: <Icon icon={PencilLine} />,
key: 'rename',
label: t('rename', { ns: 'common' }),
onClick: () => {
toggleEditing(true);
},
},
{
type: 'divider',
},
{
danger: true,
icon: <Icon icon={Trash} />,
key: 'delete',
label: t('delete', { ns: 'common' }),
onClick: () => {
if (!id) return;
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await removeKnowledgeBase(id);
},
title: t('knowledgeBase.list.confirmRemoveKnowledgeBase'),
});
},
},
],
[],
);
return (
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
onDoubleClick={(e) => {
if (!id) return;
if (e.altKey) toggleEditing(true);
}}
>
<Center className={isLoading ? '' : styles.icon} height={24} width={24}>
{isLoading ? <Icon icon={LucideLoader2} spin /> : <RepoIcon />}
</Center>
{!editing ? (
name === LOADING_FLAT ? (
<Flexbox flex={1} height={28} justify={'center'}>
<BubblesLoading />
</Flexbox>
) : (
<Paragraph
className={styles.title}
ellipsis={{ rows: 1, tooltip: { placement: 'left', title: name } }}
style={{ margin: 0, opacity: isLoading ? 0.6 : undefined }}
>
{name}
</Paragraph>
)
) : (
<EditableText
editing={editing}
onChangeEnd={(v) => {
if (name !== v) {
updateKnowledgeBase(id, { name: v });
}
toggleEditing(false);
}}
onClick={(e) => {
e.preventDefault();
}}
onEditingChange={toggleEditing}
showEditIcon={false}
size={'small'}
style={{ height: 28 }}
type={'pure'}
value={name}
/>
)}
{showMore && !editing && (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Dropdown
arrow={false}
menu={{
items: items,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<ActionIcon className={knowledgeItemClass} icon={MoreVertical} size={'small'} />
</Dropdown>
</div>
)}
</Flexbox>
);
});
export default Content;

View file

@ -0,0 +1,69 @@
import { createStyles } from 'antd-style';
import Link from 'next/link';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import Content, { knowledgeItemClass } from './Content';
const useStyles = createStyles(({ css, token, isDarkMode }) => ({
active: css`
background: ${isDarkMode ? token.colorFillSecondary : token.colorFillTertiary};
transition: background 200ms ${token.motionEaseOut};
&:hover {
background: ${token.colorFill};
}
`,
container: css`
cursor: pointer;
margin-inline: 8px;
padding-block: 4px;
padding-inline: 8px;
border-radius: ${token.borderRadius}px;
&.${knowledgeItemClass} {
width: calc(100% - 16px);
}
&:hover {
background: ${token.colorFillSecondary};
}
`,
split: css`
border-block-end: 1px solid ${token.colorSplit};
`,
}));
export interface KnowledgeBaseItemProps {
active?: boolean;
id: string;
name: string;
}
const KnowledgeBaseItem = memo<KnowledgeBaseItemProps>(({ name, active, id }) => {
const { styles, cx } = useStyles();
const [isHover, setHovering] = useState(false);
return (
<Link href={`/repos/${id}`}>
<Flexbox
align={'center'}
className={cx(styles.container, knowledgeItemClass, active && styles.active)}
distribution={'space-between'}
horizontal
onMouseEnter={() => {
setHovering(true);
}}
onMouseLeave={() => {
setHovering(false);
}}
>
<Content id={id} name={name} showMore={isHover} />
</Flexbox>
</Link>
);
});
export default KnowledgeBaseItem;

View file

@ -0,0 +1,30 @@
import React from 'react';
import { Flexbox } from 'react-layout-kit';
import { Virtuoso } from 'react-virtuoso';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import EmptyStatus from './EmptyStatus';
import Item from './Item';
import { SkeletonList } from './SkeletonList';
const KnowledgeBaseList = () => {
const useFetchKnowledgeBaseList = useKnowledgeBaseStore((s) => s.useFetchKnowledgeBaseList);
const { data, isLoading } = useFetchKnowledgeBaseList();
if (isLoading) return <SkeletonList />;
if (data?.length === 0) return <EmptyStatus />;
return (
<Flexbox height={'100%'}>
<Virtuoso
data={data}
fixedItemHeight={36}
itemContent={(index, data) => <Item id={data.id} key={data.id} name={data.name} />}
/>
</Flexbox>
);
};
export default KnowledgeBaseList;

View file

@ -0,0 +1,57 @@
'use client';
import { Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, prefixCls }) => ({
container: css`
display: flex;
flex-direction: column;
justify-content: center;
height: 36px;
padding: 8px;
.${prefixCls}-skeleton-content {
display: flex;
flex-direction: column;
}
`,
paragraph: css`
> li {
height: 20px !important;
}
`,
}));
export const Placeholder = memo(() => {
const { styles } = useStyles();
return (
<Skeleton
active
avatar={false}
className={styles.container}
paragraph={{
className: styles.paragraph,
rows: 1,
style: { marginBottom: 0 },
width: '100%',
}}
title={false}
/>
);
});
export const SkeletonList = memo(() => (
<Flexbox paddingInline={24}>
{Array.from({ length: 3 }).map((_, i) => (
<Placeholder key={i} />
))}
</Flexbox>
));
export default SkeletonList;

View file

@ -0,0 +1,54 @@
import { CaretDownFilled, CaretRightOutlined } from '@ant-design/icons';
import { ActionIcon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { PlusIcon } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useCreateNewModal } from '@/features/KnowledgeBaseModal';
import KnowledgeBaseList from './KnowledgeBaseList';
const useStyles = createStyles(({ css, token }) => ({
header: css`
color: ${token.colorTextDescription};
`,
}));
const KnowledgeBase = () => {
const { t } = useTranslation('file');
const { styles } = useStyles();
const [showList, setShowList] = useState(true);
const { open } = useCreateNewModal();
return (
<Flexbox flex={1} gap={8}>
<Flexbox
align={'center'}
className={styles.header}
horizontal
justify={'space-between'}
paddingInline={'16px 12px'}
>
<Flexbox align={'center'} gap={8} horizontal>
<ActionIcon
icon={(showList ? CaretDownFilled : CaretRightOutlined) as any}
onClick={() => {
setShowList(!showList);
}}
size={'small'}
/>
<div style={{ lineHeight: '14px' }}>{t('knowledgeBase.title')}</div>
</Flexbox>
<ActionIcon icon={PlusIcon} onClick={open} size={'small'} title={t('knowledgeBase.new')} />
</Flexbox>
{showList && <KnowledgeBaseList />}
</Flexbox>
);
};
export default KnowledgeBase;

View file

@ -0,0 +1,16 @@
'use client';
import { memo } from 'react';
import { fileManagerSelectors, useFileStore } from '@/store/file';
import Detail from '../../../features/FileDetail';
const FileDetail = memo<{ id: string }>(({ id }) => {
const file = useFileStore(fileManagerSelectors.getFileById(id));
if (!file) return;
return <Detail {...file} />;
});
export default FileDetail;

View file

@ -0,0 +1,15 @@
'use client';
import { memo } from 'react';
import FileViewer from '@/features/FileViewer';
import { fileManagerSelectors, useFileStore } from '@/store/file';
const FilePreview = memo<{ id: string }>(({ id }) => {
const file = useFileStore(fileManagerSelectors.getFileById(id));
if (!file) return;
return <FileViewer {...file} />;
});
export default FilePreview;

View file

@ -0,0 +1,85 @@
'use client';
import { Modal } from '@lobehub/ui';
import { ConfigProvider } from 'antd';
import { createStyles } from 'antd-style';
import { useRouter } from 'next/navigation';
import { ReactNode, useState } from 'react';
import { DETAIL_PANEL_WIDTH } from '@/app/(main)/files/features/FileDetail';
const useStyles = createStyles(({ css, token }, showDetail: boolean) => {
return {
body: css`
height: 100%;
max-height: calc(100dvh - 56px) !important;
`,
content: css`
height: 100%;
background: transparent !important;
border: none !important;
`,
extra: css`
position: fixed;
z-index: ${token.zIndexPopupBase + 10};
inset-block: 0 0;
inset-inline-end: 0;
width: ${DETAIL_PANEL_WIDTH}px;
background: ${token.colorBgLayout};
border-inline-start: 1px solid ${token.colorSplit};
`,
header: css`
background: transparent !important;
`,
modal: css`
position: relative;
inset-block-start: 0;
width: ${showDetail ? `calc(100vw - ${DETAIL_PANEL_WIDTH}px) ` : '100vw'} !important;
max-width: none;
height: 100%;
margin: 0;
padding-block-end: 0;
> div {
height: 100%;
}
`,
};
});
interface FullscreenModalProps {
children: ReactNode;
detail?: ReactNode;
}
const FullscreenModal = ({ children, detail }: FullscreenModalProps) => {
const router = useRouter();
const [open, setOpen] = useState(true);
const { styles } = useStyles(!!detail);
return (
<>
<ConfigProvider theme={{ token: { motion: false } }}>
<Modal
className={styles.modal}
classNames={{ body: styles.body, content: styles.content, header: styles.header }}
footer={false}
onCancel={() => {
router.back();
setOpen(false);
}}
open={open}
width={'auto'}
>
{children}
</Modal>
</ConfigProvider>
{!!detail && <div className={styles.extra}>{detail}</div>}
</>
);
};
export default FullscreenModal;

View file

@ -0,0 +1,19 @@
import FileDetail from './FileDetail';
import FilePreview from './FilePreview';
import FullscreenModal from './FullscreenModal';
interface Params {
id: string;
}
type Props = { params: Params };
const Page = ({ params }: Props) => {
return (
<FullscreenModal detail={<FileDetail id={params.id} />}>
<FilePreview id={params.id} />
</FullscreenModal>
);
};
export default Page;

View file

@ -0,0 +1,3 @@
// This ensures that the modal is not rendered when it's not active.
export default () => null;

View file

@ -0,0 +1,152 @@
'use client';
import { Icon } from '@lobehub/ui';
import { Typography } from 'antd';
import { createStyles, useTheme } from 'antd-style';
import { Database, FileImage, FileText, FileUpIcon, LibraryBig, SearchCheck } from 'lucide-react';
import Link from 'next/link';
import { Trans, useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import FeatureList from '@/components/FeatureList';
import { DATABASE_SELF_HOSTING_URL, OFFICIAL_URL, UTM_SOURCE } from '@/const/url';
const BLOCK_SIZE = 100;
const ICON_SIZE = 72;
const useStyles = createStyles(({ css, token }) => ({
actionTitle: css`
margin-block-start: 12px;
font-size: 16px;
color: ${token.colorTextSecondary};
`,
card: css`
cursor: pointer;
position: relative;
overflow: hidden;
width: 200px;
height: 140px;
font-weight: 500;
text-align: center;
background: ${token.colorFillTertiary};
border-radius: ${token.borderRadiusLG}px;
box-shadow: 0 0 0 1px ${token.colorFillTertiary} inset;
transition: background 0.3s ease-in-out;
&:hover {
background: ${token.colorFillSecondary};
}
`,
glow: css`
position: absolute;
inset-block-end: -12px;
inset-inline-end: 0;
width: 48px;
height: 48px;
opacity: 0.5;
filter: blur(24px);
`,
icon: css`
color: ${token.colorTextLightSolid};
border-radius: ${token.borderRadiusLG}px;
`,
iconGroup: css`
margin-block-start: -44px;
`,
}));
const NotSupportClient = () => {
const { t } = useTranslation('file');
const theme = useTheme();
const { styles } = useStyles();
const features = [
{
avatar: Database,
desc: t('notSupportGuide.features.allKind.desc'),
title: t('notSupportGuide.features.allKind.title'),
},
{
avatar: SearchCheck,
desc: t('notSupportGuide.features.embeddings.desc'),
title: t('notSupportGuide.features.embeddings.title'),
},
{
avatar: LibraryBig,
desc: t('notSupportGuide.features.repos.desc'),
title: t('notSupportGuide.features.repos.title'),
},
];
return (
<Center gap={40} height={'100%'} width={'100%'}>
<Flexbox className={styles.iconGroup} gap={12} horizontal>
<Center
className={styles.icon}
height={BLOCK_SIZE * 1.25}
style={{
background: theme.purple,
transform: 'rotateZ(-20deg) translateX(10px)',
}}
width={BLOCK_SIZE}
>
<Icon icon={FileImage} size={{ fontSize: ICON_SIZE, strokeWidth: 1.5 }} />
</Center>
<Center
className={styles.icon}
height={BLOCK_SIZE * 1.25}
style={{
background: theme.gold,
transform: 'translateY(-22px)',
zIndex: 1,
}}
width={BLOCK_SIZE}
>
<Icon icon={FileUpIcon} size={{ fontSize: ICON_SIZE, strokeWidth: 1.5 }} />
</Center>
<Center
className={styles.icon}
height={BLOCK_SIZE * 1.25}
style={{
background: theme.geekblue,
transform: 'rotateZ(20deg) translateX(-10px)',
}}
width={BLOCK_SIZE}
>
<Icon icon={FileText} size={{ fontSize: ICON_SIZE, strokeWidth: 1.5 }} />
</Center>
</Flexbox>
<Flexbox justify={'center'} style={{ textAlign: 'center' }}>
<Typography.Title>{t('notSupportGuide.title')}</Typography.Title>
<Typography.Text type={'secondary'}>
<Trans i18nKey={'notSupportGuide.desc'} ns={'file'}>
使
<Link href={DATABASE_SELF_HOSTING_URL}></Link>
使
<Link
href={`${OFFICIAL_URL}?utm_source=${UTM_SOURCE}&utm_medium=client_not_support_file`}
>
LobeChat Cloud
</Link>
</Trans>
</Typography.Text>
</Flexbox>
<Flexbox style={{ marginTop: 40 }}>
<FeatureList data={features} />
</Flexbox>
</Center>
);
};
export default NotSupportClient;

View file

@ -0,0 +1,28 @@
import { Flexbox } from 'react-layout-kit';
import FilePanel from '@/features/FileSidePanel';
import { LayoutProps } from '../type';
const Layout = ({ children, menu, modal }: LayoutProps) => {
return (
<>
<Flexbox
height={'100%'}
horizontal
style={{ maxWidth: 'calc(100vw - 64px)', overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<FilePanel>{menu}</FilePanel>
<Flexbox flex={1} style={{ overflow: 'hidden', position: 'relative' }}>
{children}
</Flexbox>
</Flexbox>
{modal}
</>
);
};
Layout.displayName = 'DesktopFileLayout';
export default Layout;

View file

@ -0,0 +1,47 @@
'use client';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useQuery } from '@/hooks/useQuery';
import { LayoutProps } from './type';
const useStyles = createStyles(({ css, token }) => ({
main: css`
position: relative;
overflow: hidden;
background: ${token.colorBgLayout};
`,
}));
const Layout = memo<LayoutProps>(({ children, menu }) => {
const { showMobileWorkspace } = useQuery();
const { styles } = useStyles();
return (
<>
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? { display: 'none' } : undefined}
width="100%"
>
{menu}
</Flexbox>
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? undefined : { display: 'none' }}
width="100%"
>
{children}
</Flexbox>
</>
);
});
Layout.displayName = 'MobileChatLayout';
export default Layout;

View file

@ -0,0 +1,7 @@
import { ReactNode } from 'react';
export interface LayoutProps {
children: ReactNode;
menu: ReactNode;
modal: ReactNode;
}

View file

@ -0,0 +1,18 @@
import ServerLayout from '@/components/server/ServerLayout';
import { isServerMode } from '@/const/version';
import NotSupportClient from './NotSupportClient';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
import { LayoutProps } from './_layout/type';
const Layout = ServerLayout<LayoutProps>({ Desktop, Mobile });
Layout.displayName = 'FileLayout';
export default (props: LayoutProps) => {
// if there is client db mode , tell user to switch to server mode
if (!isServerMode) return <NotSupportClient />;
return <Layout {...props} />;
};

View file

@ -0,0 +1,14 @@
'use client';
import { useTranslation } from 'react-i18next';
import { useFileCategory } from '@/app/(main)/files/hooks/useFileCategory';
import FileManager from '@/features/FileManager';
import { FilesTabs } from '@/types/files';
export default () => {
const { t } = useTranslation('file');
const [category] = useFileCategory();
return <FileManager category={category} title={t(`tab.${category as FilesTabs}`)} />;
};

View file

@ -0,0 +1,63 @@
'use client';
import { ActionIcon, Icon } from '@lobehub/ui';
import { Button, Divider, Typography } from 'antd';
import { useTheme } from 'antd-style';
import { ArrowLeftIcon, DownloadIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { downloadFile } from '@/utils/downloadFile';
interface HeaderProps {
filename: string;
id: string;
url: string;
}
const Header = memo<HeaderProps>(({ filename, url }) => {
const { t } = useTranslation('common');
const router = useRouter();
const theme = useTheme();
return (
<Flexbox
align={'center'}
horizontal
justify={'space-between'}
paddingBlock={12}
paddingInline={12}
style={{ borderBottom: `1px solid ${theme.colorSplit}` }}
>
<Flexbox align={'baseline'} horizontal>
<Button
icon={<Icon icon={ArrowLeftIcon}></Icon>}
onClick={() => {
router.push('/files');
}}
size={'small'}
type={'text'}
>
{t('back')}
</Button>
<Divider type={'vertical'} />
<Typography.Title
level={1}
style={{ fontSize: 16, lineHeight: 1.5, marginBottom: 0, paddingInlineStart: 8 }}
>
{filename}
</Typography.Title>
</Flexbox>
<Flexbox>
<ActionIcon
icon={DownloadIcon}
onClick={() => {
downloadFile(url, filename);
}}
/>
</Flexbox>
</Flexbox>
);
});
export default Header;

View file

@ -0,0 +1,44 @@
import { notFound } from 'next/navigation';
import { Flexbox } from 'react-layout-kit';
import FileDetail from '@/app/(main)/files/features/FileDetail';
import FileViewer from '@/features/FileViewer';
import { createCallerFactory } from '@/libs/trpc';
import { lambdaRouter } from '@/server/routers/lambda';
import { getUserAuth } from '@/utils/server/auth';
import Header from './Header';
interface Params {
id: string;
}
type Props = { params: Params };
const createCaller = createCallerFactory(lambdaRouter);
const FilePage = async ({ params }: Props) => {
const { userId } = await getUserAuth();
const caller = createCaller({ userId });
const file = await caller.file.getFileItemById({ id: params.id });
if (!file) return notFound();
return (
<Flexbox horizontal width={'100%'}>
<Flexbox flex={1}>
<Flexbox height={'100%'}>
<Header filename={file.name} id={params.id} url={file.url} />
<Flexbox height={'100%'} style={{ overflow: 'scroll' }}>
<FileViewer {...file} />
</Flexbox>
</Flexbox>
</Flexbox>
<FileDetail {...file} />
</Flexbox>
);
};
export default FilePage;

View file

@ -0,0 +1,93 @@
'use client';
import { Icon } from '@lobehub/ui';
import { Descriptions, Divider, Tag } from 'antd';
import { useTheme } from 'antd-style';
import dayjs from 'dayjs';
import { BoltIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FileListItem } from '@/types/files';
import { formatSize } from '@/utils/format';
export const DETAIL_PANEL_WIDTH = 300;
const FileDetail = memo<FileListItem>((props) => {
const { name, embeddingStatus, size, createdAt, updatedAt, chunkCount } = props || {};
const { t } = useTranslation('file');
const theme = useTheme();
if (!props) return null;
const items = [
{ children: name, key: 'name', label: t('detail.basic.filename') },
{ children: formatSize(size), key: 'size', label: t('detail.basic.size') },
{
children: name.split('.').pop()?.toUpperCase(),
key: 'type',
label: t('detail.basic.type'),
},
{
children: dayjs(createdAt).format('YYYY-MM-DD HH:mm'),
key: 'createdAt',
label: t('detail.basic.createdAt'),
},
{
children: dayjs(updatedAt).format('YYYY-MM-DD HH:mm'),
key: 'updatedAt',
label: t('detail.basic.updatedAt'),
},
];
const dataItems = [
{
children: (
<Tag bordered={false} icon={<Icon icon={BoltIcon} />}>
{' '}
{chunkCount}
</Tag>
),
key: 'chunkCount',
label: t('detail.data.chunkCount'),
},
{
children: (
<Tag bordered={false} color={embeddingStatus || 'default'}>
{t(`detail.data.embedding.${embeddingStatus || 'default'}`)}
</Tag>
),
key: 'embeddingStatus',
label: t('detail.data.embeddingStatus'),
},
];
return (
<Flexbox
padding={16}
style={{ borderInlineStart: `1px solid ${theme.colorSplit}` }}
width={DETAIL_PANEL_WIDTH}
>
<Descriptions
colon={false}
column={1}
items={items}
labelStyle={{ width: 120 }}
size={'small'}
title={t('detail.basic.title')}
/>
<Divider />
<Descriptions
colon={false}
column={1}
items={dataItems}
labelStyle={{ width: 120 }}
size={'small'}
/>
</Flexbox>
);
});
export default FileDetail;

View file

@ -0,0 +1,9 @@
import { useQueryState } from 'nuqs';
import { FilesTabs } from '@/types/files';
export const useFileCategory = () =>
useQueryState('category', {
clearOnDefault: true,
defaultValue: FilesTabs.All,
});

View file

@ -0,0 +1,12 @@
import { notFound } from 'next/navigation';
import { PropsWithChildren } from 'react';
import { serverFeatureFlags } from '@/config/featureFlags';
export default ({ children }: PropsWithChildren) => {
const enableKnowledgeBase = serverFeatureFlags().enableKnowledgeBase;
if (!enableKnowledgeBase) return notFound();
return children;
};

View file

@ -0,0 +1,21 @@
'use client';
import { Icon } from '@lobehub/ui';
import { Typography } from 'antd';
import { LoaderCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
export default () => {
const { t } = useTranslation('common');
return (
<Center height={'100%'} width={'100%'}>
<Flexbox align={'center'} gap={8}>
<div>
<Icon icon={LoaderCircle} size={'large'} spin />
</div>
<Typography.Text type={'secondary'}>{t('loading')}</Typography.Text>
</Flexbox>
</Center>
);
};

View file

@ -0,0 +1,33 @@
'use client';
import { Skeleton, Typography } from 'antd';
import { memo } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import GoBack from '@/components/GoBack';
import RepoIcon from '@/components/RepoIcon';
import { useKnowledgeBaseItem } from '../../hooks/useKnowledgeItem';
const Head = memo<{ id: string }>(({ id }) => {
const { data, isLoading } = useKnowledgeBaseItem(id);
return (
<Flexbox gap={8}>
<GoBack href={'/files'} />
<Flexbox align={'center'} gap={8} height={36} horizontal>
<Center style={{ minWidth: 24 }} width={24}>
<RepoIcon />
</Center>
{isLoading ? (
<Skeleton active paragraph={{ rows: 1, style: { marginBottom: 0 } }} title={false} />
) : (
<Typography.Text style={{ fontSize: 16, fontWeight: 'bold' }}>
{data?.name}
</Typography.Text>
)}
</Flexbox>
</Flexbox>
);
});
export default Head;

View file

@ -0,0 +1,56 @@
'use client';
import { Icon } from '@lobehub/ui';
import { FileText, Settings2Icon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import Menu from '@/components/Menu';
import type { MenuProps } from '@/components/Menu';
const FileMenu = memo<{ id: string }>(({ id }) => {
const { t } = useTranslation('knowledgeBase');
const pathname = usePathname();
const [activeKey, setActiveKey] = useState(pathname);
const items: MenuProps['items'] = useMemo(
() => [
{
icon: <Icon icon={FileText} />,
key: `/repos/${id}`,
label: <Link href={`/repos/${id}`}>{t('tab.files')}</Link>,
},
// {
// icon: <Icon icon={TestTubeDiagonal} />,
// key: `/repos/${id}/testing`,
// label: <Link href={`/repos/${id}/testing`}>{t('tab.testing')}</Link>,
// },
{
icon: <Icon icon={Settings2Icon} />,
key: `/repos/${id}/settings`,
label: <Link href={`/repos/${id}/settings`}>{t('tab.settings')}</Link>,
},
],
[t],
);
return (
<Flexbox>
<Menu
items={items}
onClick={({ key }) => {
setActiveKey(key);
}}
selectable
selectedKeys={[activeKey]}
variant={'compact'}
/>
</Flexbox>
);
});
export default FileMenu;

Some files were not shown because too many files have changed in this diff Show more