mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 06:15:58 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de6337f95e | |||
| ae3156e361 | |||
| e7d641b044 |
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي.\n\nهل ترغب في أن أتناسب مع سير عملك بشكل أفضل؟ انتقل إلى [إعدادات الوكيل]({{url}}) واملأ ملف تعريف الوكيل (يمكنك تعديله في أي وقت).",
|
||||
"agentDefaultMessageWithSystemRole": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentDefaultMessageWithoutEdit": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "الوكلاء",
|
||||
"artifact.generating": "يتم التوليد",
|
||||
"artifact.inThread": "لا يمكن العرض في الموضوع الفرعي، يرجى التبديل إلى منطقة المحادثة الرئيسية للفتح",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно.\n\nИскате да се адаптирам по-добре към вашия работен процес? Отидете в [Настройки на Агента]({{url}}) и попълнете Профила на Агента (можете да го редактирате по всяко време).",
|
||||
"agentDefaultMessageWithSystemRole": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentDefaultMessageWithoutEdit": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Агенти",
|
||||
"artifact.generating": "Генериране",
|
||||
"artifact.inThread": "Не може да се прегледа в подтема, моля преминете към основната зона за разговори, за да отворите",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Hallo, ich bin **{{name}}**. Ein Satz genügt.\n\nMöchten Sie, dass ich besser zu Ihrem Arbeitsablauf passe? Gehen Sie zu [Agenteneinstellungen]({{url}}) und füllen Sie das Agentenprofil aus (Sie können es jederzeit bearbeiten).",
|
||||
"agentDefaultMessageWithSystemRole": "Hallo, ich bin **{{name}}**. Ein Satz genügt – Sie haben die Kontrolle.",
|
||||
"agentDefaultMessageWithoutEdit": "Hallo, ich bin **{{name}}**. Ein Satz genügt – Sie haben die Kontrolle.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agenten",
|
||||
"artifact.generating": "Wird generiert",
|
||||
"artifact.inThread": "In Unterthemen nicht einsehbar, bitte wechseln Sie zum Hauptgesprächsbereich",
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"agentDefaultMessage": "Hi, I’m **{{name}}**. One sentence is enough.\n\nWant me to match your workflow better? Go to [Agent Settings]({{url}}) and fill in the Agent Profile (you can edit it anytime).",
|
||||
"agentDefaultMessageWithSystemRole": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
||||
"agentDefaultMessageWithoutEdit": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.edit": "Edit",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.preview": "Preview",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agentWorkspace.title": "Agent Workspace",
|
||||
"agents": "Agents",
|
||||
"artifact.generating": "Generating",
|
||||
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Hola, soy **{{name}}**. Una frase es suficiente.\n\n¿Quieres que me adapte mejor a tu flujo de trabajo? Ve a [Configuración del Agente]({{url}}) y completa el Perfil del Agente (puedes editarlo en cualquier momento).",
|
||||
"agentDefaultMessageWithSystemRole": "Hola, soy **{{name}}**. Una frase es suficiente—tú tienes el control.",
|
||||
"agentDefaultMessageWithoutEdit": "Hola, soy **{{name}}**. Una frase es suficiente—tú tienes el control.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agentes",
|
||||
"artifact.generating": "Generando",
|
||||
"artifact.inThread": "No se puede ver en el subtema, cambia al área principal de conversación para abrirlo",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "سلام، من **{{name}}** هستم. یک جمله کافی است.\n\nمیخواهی بهتر با جریان کاریات هماهنگ شوم؟ به [تنظیمات عامل]({{url}}) برو و نمایه عامل را پر کن (هر زمان میتوانی ویرایشش کنی).",
|
||||
"agentDefaultMessageWithSystemRole": "سلام، من **{{name}}** هستم. یک جمله کافی است—کنترل با توست.",
|
||||
"agentDefaultMessageWithoutEdit": "سلام، من **{{name}}** هستم. یک جمله کافی است—کنترل با توست.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "عوامل",
|
||||
"artifact.generating": "در حال تولید",
|
||||
"artifact.inThread": "در زیرموضوع قابل مشاهده نیست، لطفاً به بخش اصلی گفتگو بروید",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Bonjour, je suis **{{name}}**. Une phrase suffit.\n\nVous souhaitez que je m’adapte mieux à votre flux de travail ? Allez dans [Paramètres de l’agent]({{url}}) et complétez le profil de l’agent (modifiable à tout moment).",
|
||||
"agentDefaultMessageWithSystemRole": "Bonjour, je suis **{{name}}**. Une phrase suffit — vous avez le contrôle.",
|
||||
"agentDefaultMessageWithoutEdit": "Bonjour, je suis **{{name}}**. Une phrase suffit — vous avez le contrôle.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agents",
|
||||
"artifact.generating": "Génération en cours",
|
||||
"artifact.inThread": "Impossible d’afficher dans un sous-sujet, veuillez revenir à la conversation principale pour l’ouvrir",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Ciao, sono **{{name}}**. Una frase è sufficiente.\n\nVuoi che mi adatti meglio al tuo flusso di lavoro? Vai su [Impostazioni Agente]({{url}}) e compila il Profilo Agente (puoi modificarlo in qualsiasi momento).",
|
||||
"agentDefaultMessageWithSystemRole": "Ciao, sono **{{name}}**. Una frase è sufficiente—sei tu al comando.",
|
||||
"agentDefaultMessageWithoutEdit": "Ciao, sono **{{name}}**. Una frase è sufficiente—sei tu al comando.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agenti",
|
||||
"artifact.generating": "Generazione in corso",
|
||||
"artifact.inThread": "Impossibile visualizzare nel sottotema, passa all'area principale della conversazione per aprire",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "こんにちは、私は **{{name}}** です。一文から始めましょう。\n\nよりあなたの働き方に合わせるには:[アシスタント設定]({{url}}) でアシスタントプロフィールを補完してください(いつでも変更可能)",
|
||||
"agentDefaultMessageWithSystemRole": "こんにちは、私は **{{name}}** です。一文から始めましょう—判断はあなたにあります",
|
||||
"agentDefaultMessageWithoutEdit": "こんにちは、私は **{{name}}** です。一文から始めましょう—判断はあなたにあります",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "アシスタント",
|
||||
"artifact.generating": "生成中",
|
||||
"artifact.inThread": "サブトピックでは表示できません。メインの対話エリアに戻って開いてください",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "안녕하세요, 저는 **{{name}}**입니다. 한 문장으로 시작하세요.\n\n더 당신의 업무 방식에 맞추려면: [도우미 설정]({{url}})에서 도우미 프로필을 보완하세요(언제든 변경 가능)",
|
||||
"agentDefaultMessageWithSystemRole": "안녕하세요, 저는 **{{name}}**입니다. 한 문장으로 시작하세요—결정은 당신에게 있습니다",
|
||||
"agentDefaultMessageWithoutEdit": "안녕하세요, 저는 **{{name}}**입니다. 한 문장으로 시작하세요—결정은 당신에게 있습니다",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "도우미",
|
||||
"artifact.generating": "생성 중",
|
||||
"artifact.inThread": "하위 주제에서는 볼 수 없습니다. 메인 대화 영역으로 돌아가서 열어주세요",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Hoi, ik ben **{{name}}**. Eén zin is genoeg.\n\nWil je dat ik beter aansluit op jouw workflow? Ga naar [Agentinstellingen]({{url}}) en vul het Agentprofiel in (je kunt dit altijd aanpassen).",
|
||||
"agentDefaultMessageWithSystemRole": "Hoi, ik ben **{{name}}**. Eén zin is genoeg—jij hebt de controle.",
|
||||
"agentDefaultMessageWithoutEdit": "Hoi, ik ben **{{name}}**. Eén zin is genoeg—jij hebt de controle.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agents",
|
||||
"artifact.generating": "Bezig met genereren",
|
||||
"artifact.inThread": "Kan niet worden bekeken in subonderwerp, schakel over naar het hoofdgesprek om te openen",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Cześć, jestem **{{name}}**. Jedno zdanie wystarczy.\n\nChcesz, żebym lepiej dopasował się do Twojego stylu pracy? Przejdź do [Ustawień Agenta]({{url}}) i uzupełnij Profil Agenta (możesz go edytować w każdej chwili).",
|
||||
"agentDefaultMessageWithSystemRole": "Cześć, jestem **{{name}}**. Jedno zdanie wystarczy — to Ty masz kontrolę.",
|
||||
"agentDefaultMessageWithoutEdit": "Cześć, jestem **{{name}}**. Jedno zdanie wystarczy — to Ty masz kontrolę.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agenci",
|
||||
"artifact.generating": "Generowanie",
|
||||
"artifact.inThread": "Nie można wyświetlić w podtemacie, przejdź do głównej rozmowy, aby otworzyć",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Olá, sou **{{name}}**. Uma frase basta.\n\nQuer que eu me adapte melhor ao seu fluxo de trabalho? Vá para [Configurações do Agente]({{url}}) e preencha o Perfil do Agente (você pode editá-lo a qualquer momento).",
|
||||
"agentDefaultMessageWithSystemRole": "Olá, sou **{{name}}**. Uma frase basta — você está no controle.",
|
||||
"agentDefaultMessageWithoutEdit": "Olá, sou **{{name}}**. Uma frase basta — você está no controle.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Agentes",
|
||||
"artifact.generating": "Gerando",
|
||||
"artifact.inThread": "Não é possível visualizar no subtópico, mude para a área principal da conversa para abrir",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Привет, я **{{name}}**. Одного предложения достаточно.\n\nХотите, чтобы я лучше соответствовал вашему рабочему процессу? Перейдите в [Настройки агента]({{url}}) и заполните профиль агента (вы можете изменить его в любое время).",
|
||||
"agentDefaultMessageWithSystemRole": "Привет, я **{{name}}**. Одного предложения достаточно — вы управляете процессом.",
|
||||
"agentDefaultMessageWithoutEdit": "Привет, я **{{name}}**. Одного предложения достаточно — вы управляете процессом.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Агенты",
|
||||
"artifact.generating": "Генерация",
|
||||
"artifact.inThread": "Невозможно просмотреть в подтеме, пожалуйста, переключитесь в основную область беседы",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Merhaba, ben **{{name}}**. Bir cümle yeterli.\n\nİş akışına daha iyi uyum sağlamamı ister misin? [Ajan Ayarları]({{url}}) bölümüne gidip Ajan Profilini doldur (istediğin zaman düzenleyebilirsin).",
|
||||
"agentDefaultMessageWithSystemRole": "Merhaba, ben **{{name}}**. Bir cümle yeterli—kontrol sende.",
|
||||
"agentDefaultMessageWithoutEdit": "Merhaba, ben **{{name}}**. Bir cümle yeterli—kontrol sende.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Ajanlar",
|
||||
"artifact.generating": "Oluşturuluyor",
|
||||
"artifact.inThread": "Alt konuda görüntülenemez, açmak için ana konuşma alanına geçin",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"agentDefaultMessage": "Chào bạn, tôi là **{{name}}**. Một câu là đủ.\n\nMuốn tôi phù hợp hơn với quy trình làm việc của bạn? Truy cập [Cài đặt Tác nhân]({{url}}) và điền Hồ sơ Tác nhân (bạn có thể chỉnh sửa bất cứ lúc nào).",
|
||||
"agentDefaultMessageWithSystemRole": "Chào bạn, tôi là **{{name}}**. Một câu là đủ—bạn là người kiểm soát.",
|
||||
"agentDefaultMessageWithoutEdit": "Chào bạn, tôi là **{{name}}**. Một câu là đủ—bạn là người kiểm soát.",
|
||||
"agentWorkspace.agentDocuments": "Agent Documents",
|
||||
"agentWorkspace.documents.close": "Close",
|
||||
"agentWorkspace.documents.error": "Failed to load document",
|
||||
"agentWorkspace.documents.loading": "Loading document...",
|
||||
"agentWorkspace.documents.save": "Save",
|
||||
"agentWorkspace.documents.saved": "All changes saved",
|
||||
"agentWorkspace.documents.title": "Document",
|
||||
"agentWorkspace.documents.unsaved": "Unsaved changes",
|
||||
"agentWorkspace.progress": "Progress",
|
||||
"agentWorkspace.progress.allCompleted": "All tasks completed",
|
||||
"agentWorkspace.resources": "Resources",
|
||||
"agentWorkspace.resources.empty": "No agent documents yet",
|
||||
"agentWorkspace.resources.error": "Failed to load resources",
|
||||
"agentWorkspace.resources.loading": "Loading resources...",
|
||||
"agentWorkspace.resources.previewError": "Failed to load preview",
|
||||
"agentWorkspace.resources.previewLoading": "Loading preview...",
|
||||
"agents": "Tác nhân",
|
||||
"artifact.generating": "Đang tạo",
|
||||
"artifact.inThread": "Không thể xem trong chủ đề phụ, vui lòng chuyển sang khu vực hội thoại chính để mở",
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式:去 [助理设置]({{url}}) 补充助理档案(随时可改)",
|
||||
"agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
|
||||
"agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
|
||||
"agentWorkspace.agentDocuments": "助理文档",
|
||||
"agentWorkspace.documents.close": "关闭",
|
||||
"agentWorkspace.documents.edit": "编辑",
|
||||
"agentWorkspace.documents.error": "文档加载失败",
|
||||
"agentWorkspace.documents.loading": "文档加载中...",
|
||||
"agentWorkspace.documents.preview": "预览",
|
||||
"agentWorkspace.documents.save": "保存",
|
||||
"agentWorkspace.documents.saved": "所有更改已保存",
|
||||
"agentWorkspace.documents.title": "文档",
|
||||
"agentWorkspace.documents.unsaved": "有未保存更改",
|
||||
"agentWorkspace.progress": "进度",
|
||||
"agentWorkspace.progress.allCompleted": "全部任务已完成",
|
||||
"agentWorkspace.resources": "资源",
|
||||
"agentWorkspace.resources.empty": "暂无助理文档",
|
||||
"agentWorkspace.resources.error": "资源加载失败",
|
||||
"agentWorkspace.resources.loading": "资源加载中...",
|
||||
"agentWorkspace.resources.previewError": "预览加载失败",
|
||||
"agentWorkspace.resources.previewLoading": "预览加载中...",
|
||||
"agentWorkspace.title": "助理工作区",
|
||||
"agents": "助理",
|
||||
"artifact.generating": "生成中",
|
||||
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
|
||||
|
||||
@@ -18,6 +18,23 @@
|
||||
"agentDefaultMessage": "你好,我是 **{{name}}**,你可以立即與我開始對話,也可以前往 [助手設定]({{url}}) 完善我的資訊。",
|
||||
"agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**,有什麼我可以幫忙的嗎?",
|
||||
"agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**,有什麼我可以幫忙的嗎?",
|
||||
"agentWorkspace.agentDocuments": "助理文件",
|
||||
"agentWorkspace.documents.close": "關閉",
|
||||
"agentWorkspace.documents.error": "文件載入失敗",
|
||||
"agentWorkspace.documents.loading": "文件載入中...",
|
||||
"agentWorkspace.documents.save": "儲存",
|
||||
"agentWorkspace.documents.saved": "所有變更已儲存",
|
||||
"agentWorkspace.documents.title": "文件",
|
||||
"agentWorkspace.documents.unsaved": "有未儲存變更",
|
||||
"agentWorkspace.progress": "進度",
|
||||
"agentWorkspace.progress.allCompleted": "所有任務已完成",
|
||||
"agentWorkspace.resources": "資源",
|
||||
"agentWorkspace.resources.empty": "尚無助理文件",
|
||||
"agentWorkspace.resources.error": "資源載入失敗",
|
||||
"agentWorkspace.resources.loading": "資源載入中...",
|
||||
"agentWorkspace.resources.previewError": "預覽載入失敗",
|
||||
"agentWorkspace.resources.previewLoading": "預覽載入中...",
|
||||
"agentWorkspace.title": "助理工作區",
|
||||
"agents": "助手",
|
||||
"artifact.generating": "生成中",
|
||||
"artifact.inThread": "子話題中無法查看,請切換到主對話區打開",
|
||||
|
||||
@@ -172,20 +172,28 @@ describe('AgentDocumentModel', () => {
|
||||
});
|
||||
|
||||
describe('rename and copy', () => {
|
||||
it('should rename and preserve extension for filename/source', async () => {
|
||||
it('should rename and preserve human-readable filename/source', async () => {
|
||||
const created = await agentDocumentModel.create(agentId, 'old-name.md', 'hello');
|
||||
|
||||
const renamed = await agentDocumentModel.rename(created.id, 'New Name');
|
||||
|
||||
expect(renamed?.title).toBe('New Name');
|
||||
expect(renamed?.filename).toBe('new-name.md');
|
||||
expect(renamed?.filename).toBe('New Name.md');
|
||||
|
||||
const [doc] = await serverDB
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, created.documentId));
|
||||
|
||||
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('new-name.md')}`);
|
||||
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('New Name.md')}`);
|
||||
});
|
||||
|
||||
it('should preserve typed extension when renaming', async () => {
|
||||
const created = await agentDocumentModel.create(agentId, 'identity.md', 'hello');
|
||||
|
||||
const renamed = await agentDocumentModel.rename(created.id, 'IDENTITY 2.md');
|
||||
|
||||
expect(renamed?.filename).toBe('IDENTITY 2.md');
|
||||
});
|
||||
|
||||
it('should copy into a new record and keep policy/template metadata', async () => {
|
||||
@@ -201,7 +209,7 @@ describe('AgentDocumentModel', () => {
|
||||
expect(copied).toBeDefined();
|
||||
expect(copied?.id).not.toBe(created.id);
|
||||
expect(copied?.documentId).not.toBe(created.documentId);
|
||||
expect(copied?.filename).toBe('copied-title.md');
|
||||
expect(copied?.filename).toBe('Copied Title.md');
|
||||
expect(copied?.templateId).toBe('claw');
|
||||
expect(copied?.policy?.context?.maxTokens).toBe(200);
|
||||
expect(copied?.metadata).toMatchObject({ description: 'source desc', domain: 'A' });
|
||||
|
||||
@@ -9,10 +9,23 @@ export const slugifyDocumentTitle = (title: string): string =>
|
||||
.replaceAll(/-+/g, '-')
|
||||
.replaceAll(/^-|-$/g, '');
|
||||
|
||||
const sanitizeDocumentFilename = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
// Prevent path traversal / nested paths in filenames.
|
||||
.replaceAll(/[\\/]/g, '-')
|
||||
// Remove null bytes and trim trailing dots/spaces for broad FS compatibility.
|
||||
.replaceAll('\0', '')
|
||||
.replaceAll(/[.\s]+$/g, '');
|
||||
|
||||
export const buildDocumentFilename = (title: string, fallbackFilename = 'document.txt'): string => {
|
||||
const extensionMatch = fallbackFilename.match(/(\.[^./\\]+)$/);
|
||||
const extension = extensionMatch?.[1] || '.txt';
|
||||
const slug = slugifyDocumentTitle(title);
|
||||
const sanitizedTitle = sanitizeDocumentFilename(title);
|
||||
if (!sanitizedTitle) return `${SLUG_FALLBACK}${extension}`;
|
||||
|
||||
return `${slug || SLUG_FALLBACK}${extension}`;
|
||||
const typedExtensionMatch = sanitizedTitle.match(/(\.[^./\\]+)$/);
|
||||
if (typedExtensionMatch) return sanitizedTitle;
|
||||
|
||||
return `${sanitizedTitle}${extension}`;
|
||||
};
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { type SkillResourceTreeNode } from '@lobechat/types';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { ChevronDown, ChevronRight, File, FolderIcon, FolderOpenIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
item: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline-end: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
itemSelected: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
label: css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface FileTreeProps {
|
||||
onSelectFile: (path: string) => void;
|
||||
resourceTree: SkillResourceTreeNode[];
|
||||
selectedFile: string;
|
||||
}
|
||||
|
||||
const TreeNode = memo<{
|
||||
depth: number;
|
||||
expandedFolders: Set<string>;
|
||||
node: SkillResourceTreeNode;
|
||||
onSelectFile: (_path: string) => void;
|
||||
onToggleFolder: (_path: string) => void;
|
||||
selectedFile: string;
|
||||
}>(({ node, depth, selectedFile, onSelectFile, expandedFolders, onToggleFolder }) => {
|
||||
const isDir = node.type === 'directory';
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = !isDir && selectedFile === node.path;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDir) {
|
||||
onToggleFolder(node.path);
|
||||
} else {
|
||||
onSelectFile(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.item} ${isSelected ? styles.itemSelected : ''}`}
|
||||
style={{ paddingInlineStart: 8 + depth * 16 }}
|
||||
title={node.path}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isDir && <Icon icon={isExpanded ? ChevronDown : ChevronRight} size={14} />}
|
||||
{!isDir && <span style={{ flexShrink: 0, width: 14 }} />}
|
||||
<Icon icon={isDir ? (isExpanded ? FolderOpenIcon : FolderIcon) : File} size={16} />
|
||||
<span className={styles.label}>{node.name}</span>
|
||||
</div>
|
||||
{isDir &&
|
||||
isExpanded &&
|
||||
node.children?.map((child) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
expandedFolders={expandedFolders}
|
||||
key={child.path}
|
||||
node={child}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
TreeNode.displayName = 'TreeNode';
|
||||
|
||||
const FileTree = memo<FileTreeProps>(({ resourceTree, selectedFile, onSelectFile }) => {
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Expand all directories by default when tree is loaded
|
||||
const allDirs = new Set<string>();
|
||||
const collectDirs = (nodes: SkillResourceTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'directory') {
|
||||
allDirs.add(node.path);
|
||||
if (node.children) collectDirs(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectDirs(resourceTree);
|
||||
setExpandedFolders(allDirs);
|
||||
}, [resourceTree]);
|
||||
|
||||
const handleToggleFolder = useCallback((path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSkillMdSelected = selectedFile === 'SKILL.md';
|
||||
|
||||
const hasResources = useMemo(() => resourceTree.length > 0, [resourceTree]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div
|
||||
className={`${styles.item} ${isSkillMdSelected ? styles.itemSelected : ''}`}
|
||||
style={{ paddingInlineStart: 8 }}
|
||||
onClick={() => onSelectFile('SKILL.md')}
|
||||
>
|
||||
<span style={{ flexShrink: 0, width: 14 }} />
|
||||
<Icon icon={File} size={16} />
|
||||
<span className={styles.label}>SKILL.md</span>
|
||||
</div>
|
||||
{hasResources &&
|
||||
resourceTree.map((node) => (
|
||||
<TreeNode
|
||||
depth={0}
|
||||
expandedFolders={expandedFolders}
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileTree.displayName = 'FileTree';
|
||||
|
||||
export default FileTree;
|
||||
@@ -11,10 +11,10 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PublishedTime from '@/components/PublishedTime';
|
||||
import SkillAvatar from '@/components/SkillAvatar';
|
||||
import FileTree, { FileTreeSkeleton } from '@/features/FileTree';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
|
||||
import ContentViewer from './ContentViewer';
|
||||
import FileTree from './FileTree';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
description: css`
|
||||
@@ -61,7 +61,7 @@ interface AgentSkillDetailProps {
|
||||
skillId: string;
|
||||
}
|
||||
|
||||
const buildContentMap = (nodes: SkillResourceTreeNode[]): Record<string, string> => {
|
||||
const buildContentMap = (nodes: SkillResourceTreeNode[] = []): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
const walk = (items: SkillResourceTreeNode[]) => {
|
||||
for (const node of items) {
|
||||
@@ -82,10 +82,27 @@ const AgentSkillDetail = memo<AgentSkillDetailProps>(({ skillId }) => {
|
||||
const { data, isLoading } = useToolStore((s) => s.useFetchAgentSkillDetail)(skillId);
|
||||
|
||||
const skillDetail = data?.skillDetail;
|
||||
const resourceTree = data?.resourceTree ?? [];
|
||||
const resourceTree = data?.resourceTree;
|
||||
const contentMap = useMemo(() => buildContentMap(resourceTree), [resourceTree]);
|
||||
|
||||
if (isLoading) return <Skeleton active paragraph={{ rows: 8 }} style={{ padding: 16 }} />;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flexbox style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<div className={styles.meta}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} style={{ margin: 0 }} title={{ width: 220 }} />
|
||||
</div>
|
||||
<Flexbox horizontal style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div className={styles.left}>
|
||||
<FileTreeSkeleton rows={9} />
|
||||
</div>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.right}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} style={{ padding: 16 }} />
|
||||
</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
const version = skillDetail?.manifest?.version;
|
||||
const description = skillDetail?.description || skillDetail?.manifest?.description;
|
||||
@@ -145,7 +162,7 @@ const AgentSkillDetail = memo<AgentSkillDetailProps>(({ skillId }) => {
|
||||
<Flexbox horizontal style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div className={styles.left}>
|
||||
<FileTree
|
||||
resourceTree={resourceTree}
|
||||
resourceTree={resourceTree || []}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={setSelectedFile}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ContentViewer from '@/features/AgentSkillDetail/ContentViewer';
|
||||
import FileTree from '@/features/AgentSkillDetail/FileTree';
|
||||
import FileTree from '@/features/FileTree';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
|
||||
import SkillEditForm, { type SkillEditFormValues } from './SkillEditForm';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TextArea } from '@lobehub/ui';
|
||||
import { TextArea, type TextAreaProps } from '@lobehub/ui';
|
||||
import { type FC } from 'react';
|
||||
|
||||
interface EditorCanvasProps {
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
style?: TextAreaProps['style'];
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const EditorCanvas: FC<EditorCanvasProps> = ({ defaultValue, value, onChange }) => {
|
||||
const EditorCanvas: FC<EditorCanvasProps> = ({ defaultValue, value, onChange, style }) => {
|
||||
return (
|
||||
<TextArea
|
||||
defaultValue={defaultValue}
|
||||
@@ -19,6 +20,7 @@ const EditorCanvas: FC<EditorCanvasProps> = ({ defaultValue, value, onChange })
|
||||
minHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
padding: 16,
|
||||
...style,
|
||||
}}
|
||||
onChange={(e) => {
|
||||
onChange?.(e.target.value);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, Skeleton } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface FileTreeSkeletonProps {
|
||||
rows?: number;
|
||||
showRootFile?: boolean;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 28;
|
||||
|
||||
const FileTreeSkeleton = memo<FileTreeSkeletonProps>(({ rows = 8, showRootFile = true }) => {
|
||||
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<Flexbox gap={2}>
|
||||
{showRootFile && (
|
||||
<Flexbox horizontal align={'center'} gap={6} height={ROW_HEIGHT} paddingInline={8}>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size={'small'}
|
||||
style={{
|
||||
borderRadius: cssVar.borderRadius,
|
||||
height: 14,
|
||||
minWidth: 14,
|
||||
width: 14,
|
||||
}}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size={'small'}
|
||||
style={{
|
||||
borderRadius: cssVar.borderRadius,
|
||||
height: 16,
|
||||
minWidth: 80,
|
||||
opacity: 0.6,
|
||||
width: '40%',
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
{skeletonRows.map((rowIndex) => {
|
||||
const depth = rowIndex % 3;
|
||||
const width = `${40 + ((rowIndex * 13) % 45)}%`;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={6}
|
||||
height={ROW_HEIGHT}
|
||||
key={rowIndex}
|
||||
paddingInline={8}
|
||||
style={{ paddingInlineStart: 8 + depth * 16 }}
|
||||
>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size={'small'}
|
||||
style={{
|
||||
borderRadius: cssVar.borderRadius,
|
||||
height: 14,
|
||||
minWidth: 14,
|
||||
width: 14,
|
||||
}}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size={'small'}
|
||||
style={{
|
||||
borderRadius: cssVar.borderRadius,
|
||||
height: 16,
|
||||
minWidth: 70,
|
||||
opacity: 0.55,
|
||||
width,
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
FileTreeSkeleton.displayName = 'FileTreeSkeleton';
|
||||
|
||||
export default FileTreeSkeleton;
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import type { SkillResourceTreeNode } from '@lobechat/types';
|
||||
import type { MenuProps } from '@lobehub/ui';
|
||||
import { ContextMenuTrigger, Icon } from '@lobehub/ui';
|
||||
import { Input } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { ChevronDown, ChevronRight, File, FolderIcon, FolderOpenIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
item: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline-end: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
itemSelected: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
label: css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
// Inline rename should look like plain filename text.
|
||||
// Ant Input adds default spacing/border/font styles (and `size="small"` adds extra scaling),
|
||||
// so we fully neutralize visual chrome to avoid layout jump when entering edit mode.
|
||||
editingInput: css`
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
|
||||
font-size: 13px !important;
|
||||
line-height: 1.4 !important;
|
||||
|
||||
background: transparent !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
`,
|
||||
// Reset wrapper-level styles too; Ant applies some padding/radius on the semantic root.
|
||||
// If only `input` is reset, the row can still shift by a few pixels.
|
||||
editingInputRoot: css`
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface FileTreeProps {
|
||||
editableFilePath?: string | null;
|
||||
getFileContextMenuItems?: (file: { name: string; path: string }) => MenuProps['items'];
|
||||
onCancelRenameFile?: () => void;
|
||||
onCommitRenameFile?: (
|
||||
file: { name: string; path: string },
|
||||
newName: string,
|
||||
) => Promise<void> | void;
|
||||
onSelectFile: (path: string) => void;
|
||||
resourceTree: SkillResourceTreeNode[];
|
||||
rootFile?: {
|
||||
label: string;
|
||||
path: string;
|
||||
} | null;
|
||||
selectedFile: string;
|
||||
}
|
||||
|
||||
const TreeNode = memo<{
|
||||
depth: number;
|
||||
editableFilePath?: string | null;
|
||||
expandedFolders: Set<string>;
|
||||
getFileContextMenuItems?: (file: { name: string; path: string }) => MenuProps['items'];
|
||||
node: SkillResourceTreeNode;
|
||||
onCancelRenameFile?: () => void;
|
||||
onCommitRenameFile?: (
|
||||
file: { name: string; path: string },
|
||||
newName: string,
|
||||
) => Promise<void> | void;
|
||||
onSelectFile: (_path: string) => void;
|
||||
onToggleFolder: (_path: string) => void;
|
||||
selectedFile: string;
|
||||
}>(
|
||||
({
|
||||
node,
|
||||
depth,
|
||||
selectedFile,
|
||||
onSelectFile,
|
||||
expandedFolders,
|
||||
onToggleFolder,
|
||||
getFileContextMenuItems,
|
||||
editableFilePath,
|
||||
onCancelRenameFile,
|
||||
onCommitRenameFile,
|
||||
}) => {
|
||||
const isDir = node.type === 'directory';
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = !isDir && selectedFile === node.path;
|
||||
const isEditing = !isDir && editableFilePath === node.path && !!onCommitRenameFile;
|
||||
const [editingName, setEditingName] = useState(node.name);
|
||||
const inputRef = useRef<any>(null);
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
|
||||
setEditingName(node.name);
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus?.();
|
||||
inputRef.current?.select?.();
|
||||
});
|
||||
}, [isEditing, node.name]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isEditing) return;
|
||||
|
||||
if (isDir) {
|
||||
onToggleFolder(node.path);
|
||||
} else {
|
||||
onSelectFile(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setEditingName(node.name);
|
||||
onCancelRenameFile?.();
|
||||
}, [node.name, onCancelRenameFile]);
|
||||
|
||||
const handleCommitRename = useCallback(async () => {
|
||||
if (!isEditing || !onCommitRenameFile || isSubmittingRef.current) return;
|
||||
|
||||
const nextName = editingName.trim();
|
||||
if (nextName === node.name) {
|
||||
handleCancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onCommitRenameFile({ name: node.name, path: node.path }, nextName);
|
||||
onCancelRenameFile?.();
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
}, [
|
||||
editingName,
|
||||
handleCancelRename,
|
||||
isEditing,
|
||||
node.name,
|
||||
node.path,
|
||||
onCancelRenameFile,
|
||||
onCommitRenameFile,
|
||||
]);
|
||||
|
||||
const contextMenuItems =
|
||||
!isDir && !isEditing
|
||||
? getFileContextMenuItems?.({ name: node.name, path: node.path })
|
||||
: undefined;
|
||||
|
||||
const nodeContent = (
|
||||
<div
|
||||
className={`${styles.item} ${isSelected ? styles.itemSelected : ''}`}
|
||||
style={{ paddingInlineStart: 8 + depth * 16 }}
|
||||
title={node.path}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isDir && <Icon icon={isExpanded ? ChevronDown : ChevronRight} size={14} />}
|
||||
{!isDir && <span style={{ flexShrink: 0, width: 14 }} />}
|
||||
<Icon icon={isDir ? (isExpanded ? FolderOpenIcon : FolderIcon) : File} size={16} />
|
||||
{isEditing ? (
|
||||
<Input
|
||||
classNames={{ input: styles.editingInput, root: styles.editingInputRoot }}
|
||||
ref={inputRef}
|
||||
value={editingName}
|
||||
variant={'borderless'}
|
||||
onBlur={() => void handleCommitRename()}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
void handleCommitRename();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.label}>{node.name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isDir && contextMenuItems && contextMenuItems.length > 0 ? (
|
||||
<ContextMenuTrigger items={contextMenuItems}>{nodeContent}</ContextMenuTrigger>
|
||||
) : (
|
||||
nodeContent
|
||||
)}
|
||||
{isDir &&
|
||||
isExpanded &&
|
||||
node.children?.map((child) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
editableFilePath={editableFilePath}
|
||||
expandedFolders={expandedFolders}
|
||||
getFileContextMenuItems={getFileContextMenuItems}
|
||||
key={child.path}
|
||||
node={child}
|
||||
selectedFile={selectedFile}
|
||||
onCancelRenameFile={onCancelRenameFile}
|
||||
onCommitRenameFile={onCommitRenameFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TreeNode.displayName = 'TreeNode';
|
||||
|
||||
const FileTree = memo<FileTreeProps>(
|
||||
({
|
||||
resourceTree,
|
||||
rootFile,
|
||||
selectedFile,
|
||||
onSelectFile,
|
||||
getFileContextMenuItems,
|
||||
editableFilePath,
|
||||
onCancelRenameFile,
|
||||
onCommitRenameFile,
|
||||
}) => {
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Expand all directories by default when tree is loaded
|
||||
const allDirs = new Set<string>();
|
||||
const collectDirs = (nodes: SkillResourceTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'directory') {
|
||||
allDirs.add(node.path);
|
||||
if (node.children) collectDirs(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectDirs(resourceTree);
|
||||
setExpandedFolders(allDirs);
|
||||
}, [resourceTree]);
|
||||
|
||||
const handleToggleFolder = useCallback((path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rootFilePath = rootFile === undefined ? 'SKILL.md' : rootFile?.path;
|
||||
const rootFileLabel = rootFile === undefined ? 'SKILL.md' : rootFile?.label;
|
||||
const isRootFileSelected = !!rootFilePath && selectedFile === rootFilePath;
|
||||
|
||||
const hasResources = useMemo(() => resourceTree.length > 0, [resourceTree]);
|
||||
const rootFileContextMenuItems = useMemo(
|
||||
() =>
|
||||
rootFilePath && rootFileLabel
|
||||
? getFileContextMenuItems?.({ name: rootFileLabel, path: rootFilePath })
|
||||
: undefined,
|
||||
[getFileContextMenuItems, rootFileLabel, rootFilePath],
|
||||
);
|
||||
|
||||
const rootFileContent = rootFilePath && rootFileLabel && (
|
||||
<div
|
||||
className={`${styles.item} ${isRootFileSelected ? styles.itemSelected : ''}`}
|
||||
style={{ paddingInlineStart: 8 }}
|
||||
onClick={() => onSelectFile(rootFilePath)}
|
||||
>
|
||||
<span style={{ flexShrink: 0, width: 14 }} />
|
||||
<Icon icon={File} size={16} />
|
||||
<span className={styles.label}>{rootFileLabel}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{rootFileContent && rootFileContextMenuItems && rootFileContextMenuItems.length > 0 ? (
|
||||
<ContextMenuTrigger items={rootFileContextMenuItems}>
|
||||
{rootFileContent}
|
||||
</ContextMenuTrigger>
|
||||
) : (
|
||||
rootFileContent
|
||||
)}
|
||||
{hasResources &&
|
||||
resourceTree.map((node) => (
|
||||
<TreeNode
|
||||
depth={0}
|
||||
editableFilePath={editableFilePath}
|
||||
expandedFolders={expandedFolders}
|
||||
getFileContextMenuItems={getFileContextMenuItems}
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedFile={selectedFile}
|
||||
onCancelRenameFile={onCancelRenameFile}
|
||||
onCommitRenameFile={onCommitRenameFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FileTree.displayName = 'FileTree';
|
||||
|
||||
export { default as FileTreeSkeleton } from './Skeleton';
|
||||
|
||||
export default FileTree;
|
||||
@@ -7,7 +7,7 @@ import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ContentViewer from '@/features/AgentSkillDetail/ContentViewer';
|
||||
import FileTree from '@/features/AgentSkillDetail/FileTree';
|
||||
import FileTree from '@/features/FileTree';
|
||||
import { DetailProvider } from '@/features/MCPPluginDetail/DetailProvider';
|
||||
import Tools from '@/features/MCPPluginDetail/Schema/Tools';
|
||||
import { ModeType } from '@/features/MCPPluginDetail/Schema/types';
|
||||
|
||||
@@ -11,15 +11,14 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PublishedTime from '@/components/PublishedTime';
|
||||
import ContentViewer from '@/features/AgentSkillDetail/ContentViewer';
|
||||
import FileTree from '@/features/FileTree';
|
||||
import { marketApiService } from '@/services/marketApi';
|
||||
import { useDiscoverStore } from '@/store/discover';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { agentSkillsSelectors } from '@/store/tool/selectors';
|
||||
import { type DiscoverSkillDetail as DiscoverSkillDetailType } from '@/types/discover';
|
||||
|
||||
import ContentViewer from '../../../AgentSkillDetail/ContentViewer';
|
||||
import FileTree from '../../../AgentSkillDetail/FileTree';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
description: css`
|
||||
overflow: hidden;
|
||||
|
||||
@@ -581,6 +581,33 @@ export default {
|
||||
'workflow.toolDisplayName.upsertDocumentByFilename': 'Updated a document',
|
||||
'workflow.toolDisplayName.writeLocalFile': 'Wrote a file',
|
||||
'workflow.working': 'Working...',
|
||||
'agentWorkspace.agentDocuments': 'Agent Documents',
|
||||
'agentWorkspace.progress': 'Progress',
|
||||
'agentWorkspace.progress.allCompleted': 'All tasks completed',
|
||||
'agentWorkspace.resources': 'Resources',
|
||||
'agentWorkspace.resources.deleteConfirm': 'This action cannot be undone.',
|
||||
'agentWorkspace.resources.deleteError': 'Failed to delete document',
|
||||
'agentWorkspace.resources.deleteSuccess': 'Document deleted',
|
||||
'agentWorkspace.resources.deleteTitle': 'Delete document?',
|
||||
'agentWorkspace.resources.empty': 'No agent documents yet',
|
||||
'agentWorkspace.resources.error': 'Failed to load resources',
|
||||
'agentWorkspace.resources.loading': 'Loading resources...',
|
||||
'agentWorkspace.resources.previewError': 'Failed to load preview',
|
||||
'agentWorkspace.resources.previewLoading': 'Loading preview...',
|
||||
'agentWorkspace.resources.renameEmpty': 'Title cannot be empty',
|
||||
'agentWorkspace.resources.renameError': 'Failed to rename document',
|
||||
'agentWorkspace.resources.renameSuccess': 'Document renamed',
|
||||
'agentWorkspace.documents.close': 'Close',
|
||||
'agentWorkspace.documents.error': 'Failed to load document',
|
||||
'agentWorkspace.documents.loading': 'Loading document...',
|
||||
'agentWorkspace.documents.discard': 'Discard',
|
||||
'agentWorkspace.documents.preview': 'Preview',
|
||||
'agentWorkspace.documents.edit': 'Edit',
|
||||
'agentWorkspace.documents.save': 'Save',
|
||||
'agentWorkspace.documents.saved': 'All changes saved',
|
||||
'agentWorkspace.documents.title': 'Document',
|
||||
'agentWorkspace.documents.unsaved': 'Unsaved changes',
|
||||
'agentWorkspace.title': 'Agent Workspace',
|
||||
'you': 'You',
|
||||
'zenMode': 'Zen Mode',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import HeaderActions from './index';
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
ActionIcon: () => <button data-testid={'overflow-menu-button'} />,
|
||||
DropdownMenu: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./useMenu', () => ({
|
||||
useMenu: () => ({
|
||||
menuItems: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Conversation header actions', () => {
|
||||
it('renders the overflow actions button', () => {
|
||||
render(<HeaderActions />);
|
||||
|
||||
expect(screen.getByTestId('overflow-menu-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { type DropdownItem } from '@lobehub/ui';
|
||||
import { FilePenIcon, Maximize2 } from 'lucide-react';
|
||||
import { FilePenIcon, Maximize2, PanelRightOpen } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,12 +14,13 @@ export const useMenu = (): { menuItems: DropdownItem[] } => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t: tPortal } = useTranslation('portal');
|
||||
|
||||
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
|
||||
const [wideScreen, toggleRightPanel, toggleWideScreen] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.wideScreen(s),
|
||||
s.toggleRightPanel,
|
||||
s.toggleWideScreen,
|
||||
]);
|
||||
|
||||
const [showNotebook, toggleNotebook] = useChatStore((s) => [s.showNotebook, s.toggleNotebook]);
|
||||
const toggleNotebook = useChatStore((s) => s.toggleNotebook);
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
@@ -29,6 +30,12 @@ export const useMenu = (): { menuItems: DropdownItem[] } => {
|
||||
label: tPortal('notebook.title'),
|
||||
onClick: () => toggleNotebook(),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={PanelRightOpen} />,
|
||||
key: 'agent-workspace',
|
||||
label: t('agentWorkspace.title'),
|
||||
onClick: () => toggleRightPanel(),
|
||||
},
|
||||
{
|
||||
checked: wideScreen,
|
||||
icon: <Icon icon={Maximize2} />,
|
||||
@@ -38,7 +45,7 @@ export const useMenu = (): { menuItems: DropdownItem[] } => {
|
||||
type: 'switch',
|
||||
},
|
||||
],
|
||||
[t, tPortal, wideScreen, toggleWideScreen, showNotebook, toggleNotebook],
|
||||
[t, tPortal, wideScreen, toggleRightPanel, toggleWideScreen, toggleNotebook],
|
||||
);
|
||||
|
||||
return { menuItems };
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Avatar, Button, Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getPlatformIcon } from '@/routes/(main)/agent/channel/const';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
header: css`
|
||||
min-height: 128px;
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
channelCount: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
channelIcon: css`
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
const AgentSummary = memo(() => {
|
||||
const { t } = useTranslation(['chat', 'discover']);
|
||||
const navigate = useNavigate();
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
const meta = useAgentStore(agentSelectors.currentAgentMeta);
|
||||
const { data: providers = [] } = useAgentStore((s) => s.useFetchBotProviders(activeAgentId));
|
||||
const { data: platforms = [] } = useAgentStore((s) => s.useFetchPlatformDefinitions());
|
||||
const title = meta.title || t('untitledAgent');
|
||||
|
||||
const enabledChannels = useMemo(() => providers.filter((item) => item.enabled), [providers]);
|
||||
|
||||
const channelIcons = useMemo(() => {
|
||||
const platformNameById = new Map(platforms.map((item) => [item.id, item.name]));
|
||||
const iconKeys = Array.from(
|
||||
new Set(
|
||||
enabledChannels.map((item) => {
|
||||
const name = platformNameById.get(item.platform) || item.platform;
|
||||
return name;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return iconKeys
|
||||
.map((key) => ({ Icon: getPlatformIcon(key), key }))
|
||||
.filter((item): item is { Icon: NonNullable<typeof item.Icon>; key: string } => !!item.Icon)
|
||||
.slice(0, 3);
|
||||
}, [enabledChannels, platforms]);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
className={styles.header}
|
||||
data-testid="workspace-summary"
|
||||
gap={8}
|
||||
padding={16}
|
||||
width={'100%'}
|
||||
>
|
||||
<Flexbox gap={12}>
|
||||
<Avatar avatar={meta.avatar} background={meta.backgroundColor} shape={'square'} size={44} />
|
||||
<Flexbox gap={4} style={{ minWidth: 0 }}>
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
<strong className={styles.title}>{title}</strong>
|
||||
{channelIcons.map(({ Icon, key }) => (
|
||||
<Icon className={styles.channelIcon} key={key} size={14} />
|
||||
))}
|
||||
{enabledChannels.length > channelIcons.length && (
|
||||
<span className={styles.channelCount}>
|
||||
+{enabledChannels.length - channelIcons.length}
|
||||
</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<Button
|
||||
block
|
||||
shape={'round'}
|
||||
size={'small'}
|
||||
style={{ color: cssVar.colorTextTertiary, width: 'fit-content' }}
|
||||
variant={'filled'}
|
||||
onClick={() => activeAgentId && navigate(`/agent/${activeAgentId}/profile`)}
|
||||
>
|
||||
{t('user.editProfile', { ns: 'discover' })}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
AgentSummary.displayName = 'AgentSummary';
|
||||
|
||||
export default AgentSummary;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Accordion, AccordionItem, Checkbox, Flexbox, Tag, Text } from '@lobehub/ui';
|
||||
import { Progress } from 'antd';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { selectTodosFromMessages } from '@/store/chat/slices/message/selectors/dbMessage';
|
||||
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
||||
|
||||
import { useAgentContext } from '../../useAgentContext';
|
||||
import { normalizeTaskProgress } from './taskProgressAdapter';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
barWrap: css`
|
||||
margin-block-end: 2px;
|
||||
margin-inline: -16px;
|
||||
`,
|
||||
chevron: css`
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
`,
|
||||
progressBadge: css`
|
||||
color: ${cssVar.colorTextLightSolid};
|
||||
`,
|
||||
sectionTitle: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
itemRow: css`
|
||||
padding-block: 6px;
|
||||
padding-inline: 0;
|
||||
`,
|
||||
textCompleted: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
textProcessing: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
textTodo: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const ProgressSection = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const context = useAgentContext();
|
||||
const chatKey = messageMapKey(context);
|
||||
const dbMessages = useChatStore((s) => s.dbMessagesMap[chatKey]);
|
||||
|
||||
const progress = useMemo(
|
||||
() => normalizeTaskProgress(selectTodosFromMessages(dbMessages || [])),
|
||||
[dbMessages],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Progress
|
||||
percent={progress.completionPercent}
|
||||
railColor={cssVar.colorFillTertiary}
|
||||
showInfo={false}
|
||||
strokeColor={cssVar.colorSuccess}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
<Flexbox data-testid="workspace-progress" padding={16}>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Accordion defaultExpandedKeys={['progress']} gap={0}>
|
||||
<AccordionItem
|
||||
itemKey={'progress'}
|
||||
paddingBlock={0}
|
||||
paddingInline={0}
|
||||
title={<Text strong>{t('agentWorkspace.progress')}</Text>}
|
||||
styles={{
|
||||
header: {
|
||||
width: 'fit-content',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ paddingTop: 2 }}>
|
||||
{progress.items.map((item) => {
|
||||
const isCompleted = item.status === 'completed';
|
||||
const isProcessing = item.status === 'processing';
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
backgroundColor={cssVar.colorSuccess}
|
||||
checked={isCompleted}
|
||||
key={item.id}
|
||||
shape={'circle'}
|
||||
style={{ borderWidth: 1.5, cursor: 'default', pointerEvents: 'none' }}
|
||||
classNames={{
|
||||
text: cx(
|
||||
styles.textTodo,
|
||||
isCompleted && styles.textCompleted,
|
||||
isProcessing && styles.textProcessing,
|
||||
),
|
||||
wrapper: styles.itemRow,
|
||||
}}
|
||||
textProps={{
|
||||
type: isCompleted || isProcessing ? 'secondary' : undefined,
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Tag
|
||||
size={'small'}
|
||||
variant={'filled'}
|
||||
style={{
|
||||
background: cssVar.colorSuccess,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
minWidth: 42,
|
||||
paddingInline: 8,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span className={styles.progressBadge}>{progress.completionPercent}%</span>
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ProgressSection.displayName = 'ProgressSection';
|
||||
|
||||
export default ProgressSection;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { StepContextTodos } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeTaskProgress } from './taskProgressAdapter';
|
||||
|
||||
describe('normalizeTaskProgress', () => {
|
||||
it('maps message-derived todos into workspace progress state', () => {
|
||||
const result = normalizeTaskProgress({
|
||||
items: [
|
||||
{ status: 'completed', text: 'Gather context' },
|
||||
{ status: 'processing', text: 'Draft answer' },
|
||||
{ status: 'todo', text: 'Send response' },
|
||||
],
|
||||
updatedAt: '2026-04-02T00:00:00.000Z',
|
||||
} satisfies StepContextTodos);
|
||||
|
||||
expect(result.completionPercent).toBe(33);
|
||||
expect(result.currentTask).toBe('Draft answer');
|
||||
expect(result.items).toEqual([
|
||||
{ id: 'todo-0', status: 'completed', text: 'Gather context' },
|
||||
{ id: 'todo-1', status: 'processing', text: 'Draft answer' },
|
||||
{ id: 'todo-2', status: 'todo', text: 'Send response' },
|
||||
]);
|
||||
expect(result.updatedAt).toBe('2026-04-02T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { StepContextTodos } from '@lobechat/types';
|
||||
|
||||
import type { WorkspaceProgressState } from '../types';
|
||||
|
||||
export const normalizeTaskProgress = (todos?: StepContextTodos): WorkspaceProgressState => {
|
||||
const items = todos?.items ?? [];
|
||||
const completedCount = items.filter((item) => item.status === 'completed').length;
|
||||
const currentTask =
|
||||
items.find((item) => item.status === 'processing') ||
|
||||
items.find((item) => item.status === 'todo');
|
||||
|
||||
return {
|
||||
completionPercent: items.length === 0 ? 0 : Math.round((completedCount / items.length) * 100),
|
||||
currentTask: currentTask?.text,
|
||||
items: items.map((item, index) => ({
|
||||
id: `todo-${index}`,
|
||||
status: item.status,
|
||||
text: item.text,
|
||||
})),
|
||||
updatedAt: todos?.updatedAt,
|
||||
};
|
||||
};
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentDocumentsGroup from './AgentDocumentsGroup';
|
||||
|
||||
const useClientDataSWR = vi.fn();
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
Text: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/swr', () => ({
|
||||
useClientDataSWR: (...args: unknown[]) => useClientDataSWR(...args),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
(
|
||||
({
|
||||
'agentWorkspace.agentDocuments': 'Agent Documents',
|
||||
'agentWorkspace.resources.empty': 'No agent documents yet',
|
||||
'agentWorkspace.resources.error': 'Failed to load resources',
|
||||
'agentWorkspace.resources.loading': 'Loading resources...',
|
||||
'agentWorkspace.resources.previewError': 'Failed to load preview',
|
||||
'agentWorkspace.resources.previewLoading': 'Loading preview...',
|
||||
}) as Record<string, string>
|
||||
)[key] || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/agentDocument', () => ({
|
||||
agentDocumentService: {
|
||||
getDocuments: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: { activeAgentId: string }) => unknown) =>
|
||||
selector({ activeAgentId: 'agent-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/FileTree', () => ({
|
||||
default: ({
|
||||
onSelectFile,
|
||||
resourceTree,
|
||||
}: {
|
||||
onSelectFile: (path: string) => void;
|
||||
resourceTree: Array<{ children?: Array<{ name: string; path: string }> }>;
|
||||
}) => (
|
||||
<div>
|
||||
{resourceTree.flatMap((node) =>
|
||||
(node.children || []).map((child) => (
|
||||
<button key={child.path} onClick={() => onSelectFile(child.path)}>
|
||||
{child.name}
|
||||
</button>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AgentDocumentsGroup', () => {
|
||||
beforeEach(() => {
|
||||
useClientDataSWR.mockReset();
|
||||
});
|
||||
|
||||
it('renders documents and delegates selection to parent', async () => {
|
||||
const onSelectDocument = vi.fn();
|
||||
|
||||
useClientDataSWR.mockImplementation((key: unknown) => {
|
||||
if (Array.isArray(key) && key[0] === 'workspace-agent-documents') {
|
||||
return {
|
||||
data: [{ filename: 'brief.md', id: 'doc-1', templateId: 'claw', title: 'Brief' }],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: undefined, error: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
render(<AgentDocumentsGroup selectedDocumentId={null} onSelectDocument={onSelectDocument} />);
|
||||
|
||||
expect(await screen.findByText('brief.md')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('brief.md'));
|
||||
expect(onSelectDocument).toHaveBeenCalledWith('doc-1');
|
||||
});
|
||||
});
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import type { SkillResourceTreeNode } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FileTree, { FileTreeSkeleton } from '@/features/FileTree';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
|
||||
interface AgentDocumentsGroupProps {
|
||||
onSelectDocument: (id: string | null) => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
type AgentDocumentListItem = Awaited<ReturnType<typeof agentDocumentService.getDocuments>>[number];
|
||||
|
||||
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(
|
||||
({ onSelectDocument, selectedDocumentId }) => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { message, modal } = App.useApp();
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documents(agentId) : null, () =>
|
||||
agentDocumentService.getDocuments({ agentId: agentId! }),
|
||||
);
|
||||
|
||||
const resourceTree = useMemo<SkillResourceTreeNode[]>(
|
||||
() => [
|
||||
{
|
||||
children: data.map((item) => ({
|
||||
name: item.filename || item.title,
|
||||
path: item.id,
|
||||
type: 'file' as const,
|
||||
})),
|
||||
name: t('agentWorkspace.agentDocuments'),
|
||||
path: 'agent-documents',
|
||||
type: 'directory' as const,
|
||||
},
|
||||
],
|
||||
[data, t],
|
||||
);
|
||||
|
||||
const handleCommitRenameDocument = useCallback(
|
||||
async (file: { name: string; path: string }, nextName: string) => {
|
||||
if (!agentId) return;
|
||||
|
||||
const normalizedTitle = nextName.trim();
|
||||
setEditingDocumentId(null);
|
||||
|
||||
if (!normalizedTitle) {
|
||||
message.error(t('agentWorkspace.resources.renameEmpty', { ns: 'chat' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedTitle === file.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate(
|
||||
async (current: AgentDocumentListItem[] = []) => {
|
||||
const renamed = await agentDocumentService.renameDocument({
|
||||
agentId,
|
||||
id: file.path,
|
||||
newTitle: normalizedTitle,
|
||||
});
|
||||
|
||||
return current.map((item) =>
|
||||
item.id === file.path
|
||||
? {
|
||||
...item,
|
||||
filename: renamed?.filename ?? item.filename,
|
||||
title: renamed?.title ?? normalizedTitle,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: (current: AgentDocumentListItem[] = []) =>
|
||||
current.map((item) =>
|
||||
item.id === file.path
|
||||
? {
|
||||
...item,
|
||||
filename: normalizedTitle,
|
||||
title: normalizedTitle,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
revalidate: false,
|
||||
rollbackOnError: true,
|
||||
},
|
||||
);
|
||||
|
||||
message.success(t('agentWorkspace.resources.renameSuccess', { ns: 'chat' }));
|
||||
} catch (error) {
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('agentWorkspace.resources.renameError', { ns: 'chat' }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[agentId, message, mutate, t],
|
||||
);
|
||||
|
||||
const handleDeleteDocument = useCallback(
|
||||
(id: string) => {
|
||||
if (!agentId) return;
|
||||
|
||||
modal.confirm({
|
||||
content: t('agentWorkspace.resources.deleteConfirm', { ns: 'chat' }),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('delete', { ns: 'common' }),
|
||||
onOk: async () => {
|
||||
const wasSelected = selectedDocumentId === id;
|
||||
if (wasSelected) onSelectDocument(null);
|
||||
|
||||
try {
|
||||
await mutate(
|
||||
async (current = []) => {
|
||||
await agentDocumentService.removeDocument({ agentId, id });
|
||||
return current.filter((item) => item.id !== id);
|
||||
},
|
||||
{
|
||||
optimisticData: (current = []) => current.filter((item) => item.id !== id),
|
||||
revalidate: false,
|
||||
rollbackOnError: true,
|
||||
},
|
||||
);
|
||||
|
||||
message.success(t('agentWorkspace.resources.deleteSuccess', { ns: 'chat' }));
|
||||
} catch (error) {
|
||||
if (wasSelected) onSelectDocument(id);
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('agentWorkspace.resources.deleteError', { ns: 'chat' }),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
title: t('agentWorkspace.resources.deleteTitle', { ns: 'chat' }),
|
||||
});
|
||||
},
|
||||
[agentId, message, modal, mutate, onSelectDocument, selectedDocumentId, t],
|
||||
);
|
||||
|
||||
const getFileContextMenuItems = useCallback(
|
||||
(file: { path: string }) => [
|
||||
{
|
||||
icon: <Icon icon={Pencil} />,
|
||||
key: 'rename',
|
||||
label: t('rename', { ns: 'common' }),
|
||||
onClick: () => setEditingDocumentId(file.path),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash2} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: () => handleDeleteDocument(file.path),
|
||||
},
|
||||
],
|
||||
[handleDeleteDocument, t],
|
||||
);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
{isLoading && <FileTreeSkeleton rows={6} showRootFile={false} />}
|
||||
{error && <Text type={'danger'}>{t('agentWorkspace.resources.error')}</Text>}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<Text type={'secondary'}>{t('agentWorkspace.resources.empty')}</Text>
|
||||
)}
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<FileTree
|
||||
editableFilePath={editingDocumentId}
|
||||
getFileContextMenuItems={getFileContextMenuItems}
|
||||
resourceTree={resourceTree}
|
||||
rootFile={null}
|
||||
selectedFile={selectedDocumentId || ''}
|
||||
onCancelRenameFile={() => setEditingDocumentId(null)}
|
||||
onCommitRenameFile={handleCommitRenameDocument}
|
||||
onSelectFile={onSelectDocument}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AgentDocumentsGroup.displayName = 'AgentDocumentsGroup';
|
||||
|
||||
export default AgentDocumentsGroup;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Accordion, AccordionItem, Flexbox, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AgentDocumentsGroup from './AgentDocumentsGroup';
|
||||
|
||||
interface ResourcesSectionProps {
|
||||
onSelectDocument: (id: string | null) => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
const ResourcesSection = memo<ResourcesSectionProps>(({ onSelectDocument, selectedDocumentId }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<Flexbox data-testid="workspace-resources" padding={16}>
|
||||
<Accordion defaultExpandedKeys={['resources']} gap={0}>
|
||||
<AccordionItem
|
||||
itemKey={'resources'}
|
||||
paddingBlock={0}
|
||||
paddingInline={0}
|
||||
title={<Text strong>{t('agentWorkspace.resources')}</Text>}
|
||||
>
|
||||
<Flexbox paddingBlock={8}>
|
||||
<AgentDocumentsGroup
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onSelectDocument={onSelectDocument}
|
||||
/>
|
||||
</Flexbox>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
ResourcesSection.displayName = 'ResourcesSection';
|
||||
|
||||
export default ResourcesSection;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { initialState } from '@/store/global/initialState';
|
||||
|
||||
import Conversation from '../index';
|
||||
import AgentWorkspaceRightPanel from './index';
|
||||
|
||||
const useClientDataSWR = vi.fn();
|
||||
|
||||
let mockAgentMeta: { avatar?: string; title?: string } = {
|
||||
avatar: 'agent-avatar',
|
||||
title: 'Test Agent',
|
||||
};
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Accordion: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
AccordionItem: ({
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div {...props}>
|
||||
{title}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Checkbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
DraggablePanel: ({ children, expand }: { children?: ReactNode; expand?: boolean }) => (
|
||||
<div data-expand={String(expand)} data-testid="right-panel">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Avatar: ({ avatar }: { avatar?: ReactNode | string }) => <div>{avatar}</div>,
|
||||
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
Icon: () => <div />,
|
||||
Markdown: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Progress: () => <div data-testid="workspace-progress-bar" />,
|
||||
Tag: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Text: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
TextArea: () => <textarea />,
|
||||
TooltipGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
(
|
||||
({
|
||||
'untitledAgent': 'Localized Untitled Agent',
|
||||
'user.editProfile': 'Edit Profile',
|
||||
'agentWorkspace.agentDocuments': 'Agent Documents',
|
||||
'agentWorkspace.progress': 'Progress',
|
||||
'agentWorkspace.progress.allCompleted': 'All tasks completed',
|
||||
'agentWorkspace.resources': 'Resources',
|
||||
'agentWorkspace.resources.empty': 'No agent documents yet',
|
||||
}) as Record<string, string>
|
||||
)[key] || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/swr', () => ({
|
||||
useClientDataSWR: (...args: unknown[]) => useClientDataSWR(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/DragUploadZone', () => ({
|
||||
default: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
useUploadFiles: () => ({ handleUploadFiles: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector?.({
|
||||
activeAgentId: 'agent-1',
|
||||
useFetchBotProviders: () => ({ data: [], isLoading: false }),
|
||||
useFetchPlatformDefinitions: () => ({ data: [], isLoading: false }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent/selectors', () => ({
|
||||
agentSelectors: {
|
||||
currentAgentMeta: () => mockAgentMeta,
|
||||
currentAgentModel: () => 'mock-model',
|
||||
currentAgentModelProvider: () => 'mock-provider',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation/store', () => ({
|
||||
dataSelectors: {
|
||||
dbMessages: (state: { dbMessages?: unknown[] }) => state.dbMessages,
|
||||
},
|
||||
useConversationStore: (selector: (state: { dbMessages: unknown[] }) => unknown) =>
|
||||
selector({ dbMessages: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationArea', () => ({
|
||||
default: () => <div>conversation-area</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../Header', () => ({
|
||||
default: () => <div>chat-header</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../ViewerPanel', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
useClientDataSWR.mockImplementation(() => ({
|
||||
data: [],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
}));
|
||||
mockAgentMeta = {
|
||||
avatar: 'agent-avatar',
|
||||
title: 'Test Agent',
|
||||
};
|
||||
useGlobalStore.setState(initialState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Conversation right panel mount', () => {
|
||||
it('mounts the conversation-side right panel path and respects the existing global right-panel state', async () => {
|
||||
const { unmount } = render(<Conversation />);
|
||||
|
||||
expect(screen.getByText('chat-header')).toBeInTheDocument();
|
||||
expect(screen.getByText('conversation-area')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workspace-summary')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workspace-progress')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workspace-resources')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-expand', 'true');
|
||||
expect(useGlobalStore.getState().status.showRightPanel).toBe(true);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useGlobalStore.getState().status.showRightPanel).toBe(true);
|
||||
});
|
||||
|
||||
it('renders summary, progress, and resources sections in order', () => {
|
||||
render(<AgentWorkspaceRightPanel selectedDocumentId={null} onSelectDocument={vi.fn()} />);
|
||||
|
||||
const summary = screen.getByTestId('workspace-summary');
|
||||
const progress = screen.getByTestId('workspace-progress');
|
||||
const resources = screen.getByTestId('workspace-resources');
|
||||
|
||||
expect(summary).toHaveTextContent('Test Agent');
|
||||
expect(summary).toHaveTextContent('Edit Profile');
|
||||
expect(progress).toHaveTextContent('Progress');
|
||||
expect(progress).toHaveTextContent('0%');
|
||||
expect(summary.compareDocumentPosition(progress)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(progress.compareDocumentPosition(resources)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it('uses the localized untitled-agent fallback when the active agent has no title', () => {
|
||||
mockAgentMeta = { avatar: 'agent-avatar' };
|
||||
|
||||
render(<AgentWorkspaceRightPanel selectedDocumentId={null} onSelectDocument={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('workspace-summary')).toHaveTextContent('Localized Untitled Agent');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { PanelRightCloseIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import RightPanel from '@/features/RightPanel';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import ResourcesSection from './ResourcesSection';
|
||||
|
||||
interface AgentWorkspaceRightPanelProps {
|
||||
onSelectDocument: (id: string | null) => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
const AgentWorkspaceRightPanel = memo<AgentWorkspaceRightPanelProps>(
|
||||
({ onSelectDocument, selectedDocumentId }) => {
|
||||
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
|
||||
|
||||
return (
|
||||
<RightPanel defaultWidth={360} maxWidth={520} minWidth={300}>
|
||||
<Flexbox height={'100%'} width={'100%'}>
|
||||
<NavHeader
|
||||
showTogglePanelButton={false}
|
||||
style={{ paddingBlock: 8, paddingInline: 8 }}
|
||||
right={
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
onClick={() => toggleRightPanel(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Flexbox gap={8} height={'100%'} style={{ overflowY: 'auto' }} width={'100%'}>
|
||||
{/* <AgentSummary /> */}
|
||||
{/* <ProgressSection /> */}
|
||||
<ResourcesSection
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onSelectDocument={onSelectDocument}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</RightPanel>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default AgentWorkspaceRightPanel;
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface WorkspacePanelSection {
|
||||
id: 'progress' | 'resources';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceProgressItem {
|
||||
id: string;
|
||||
status: 'todo' | 'processing' | 'completed';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceProgressState {
|
||||
completionPercent: number;
|
||||
currentTask?: string;
|
||||
items: WorkspaceProgressItem[];
|
||||
title?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ResourceGroupItem {
|
||||
id: string;
|
||||
subtitle?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ResourceGroupState {
|
||||
groupKey: string;
|
||||
items: ResourceGroupItem[];
|
||||
label: string;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
|
||||
import { type DraggablePanelProps } from '@lobehub/ui';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
DraggablePanel,
|
||||
Flexbox,
|
||||
Icon,
|
||||
Markdown,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from '@lobehub/ui';
|
||||
import { Segmented } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { Eye, PanelRightCloseIcon, SquarePen } from 'lucide-react';
|
||||
import { extname } from 'pathe';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EditorTextArea from '@/features/EditorModal/TextArea';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
border-inline-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-inline-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
editor: css`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
`,
|
||||
editorWrapper: css`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`,
|
||||
footer: css`
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.2s ${cssVar.motionEaseInOut},
|
||||
opacity 0.2s ${cssVar.motionEaseInOut},
|
||||
transform 0.2s ${cssVar.motionEaseInOut},
|
||||
border-color 0.2s ${cssVar.motionEaseInOut};
|
||||
`,
|
||||
footerOpen: css`
|
||||
transform: translateY(0);
|
||||
max-height: 72px;
|
||||
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
opacity: 1;
|
||||
`,
|
||||
footerClosed: css`
|
||||
pointer-events: none;
|
||||
|
||||
transform: translateY(8px);
|
||||
|
||||
max-height: 0;
|
||||
border-block-start-color: transparent;
|
||||
|
||||
opacity: 0;
|
||||
`,
|
||||
footerInner: css`
|
||||
padding: 12px;
|
||||
`,
|
||||
header: css`
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
preview: css`
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface AgentDocumentSidePanelProps {
|
||||
onClose: () => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
type DocumentViewMode = 'edit' | 'preview';
|
||||
|
||||
const isMarkdownFile = (filename?: string) => {
|
||||
if (!filename) return false;
|
||||
|
||||
const extension = extname(filename).toLowerCase();
|
||||
return extension === '.md' || extension === '.markdown';
|
||||
};
|
||||
|
||||
const AgentDocumentSidePanel = memo<AgentDocumentSidePanelProps>(
|
||||
({ selectedDocumentId, onClose }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [savedContent, setSavedContent] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentViewMode>('edit');
|
||||
const initializedDocumentIdRef = useRef<string | null>(null);
|
||||
|
||||
const { data, error, isLoading, mutate } = useClientDataSWR(
|
||||
agentId && selectedDocumentId
|
||||
? agentDocumentSWRKeys.readDocument(agentId, selectedDocumentId)
|
||||
: null,
|
||||
() => agentDocumentService.readDocument({ agentId: agentId!, id: selectedDocumentId! }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (data.id !== selectedDocumentId) return;
|
||||
if (initializedDocumentIdRef.current === data.id) return;
|
||||
|
||||
setDraft(data.content);
|
||||
setSavedContent(data.content);
|
||||
setViewMode(isMarkdownFile(data.filename || data.title) ? 'preview' : 'edit');
|
||||
initializedDocumentIdRef.current = data.id;
|
||||
}, [data, selectedDocumentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDocumentId) return;
|
||||
initializedDocumentIdRef.current = null;
|
||||
}, [selectedDocumentId]);
|
||||
|
||||
const isDirty = useMemo(() => draft !== savedContent, [draft, savedContent]);
|
||||
const isDocumentReady = data?.id === selectedDocumentId;
|
||||
const shouldShowLoading = Boolean(selectedDocumentId) && (isLoading || !isDocumentReady);
|
||||
const isOpen = Boolean(selectedDocumentId);
|
||||
const isMarkdownDocument = isMarkdownFile(data?.filename || data?.title);
|
||||
const [panelWidth, setPanelWidth] = useState<number>(520);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
const handlePanelSizeChange: DraggablePanelProps['onSizeChange'] = (_, size) => {
|
||||
if (!size?.width) return;
|
||||
const width = typeof size.width === 'number' ? size.width : Number.parseInt(size.width, 10);
|
||||
if (!Number.isFinite(width)) return;
|
||||
setPanelWidth(width);
|
||||
};
|
||||
|
||||
const saveDocument = async () => {
|
||||
if (!isDirty || isSaving || !selectedDocumentId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await agentDocumentService.editDocument({
|
||||
agentId,
|
||||
content: draft,
|
||||
id: selectedDocumentId,
|
||||
});
|
||||
await mutate();
|
||||
setSavedContent(draft);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggablePanel
|
||||
className={styles.container}
|
||||
data-testid="workspace-document-panel"
|
||||
defaultSize={{ width: panelWidth }}
|
||||
expand={isOpen}
|
||||
expandable={false}
|
||||
maxWidth={720}
|
||||
minWidth={320}
|
||||
placement="right"
|
||||
showHandleWhenCollapsed={false}
|
||||
size={{ height: '100%', width: panelWidth }}
|
||||
onSizeChange={handlePanelSizeChange}
|
||||
>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
justify={'space-between'}
|
||||
padding={12}
|
||||
>
|
||||
<Text strong>
|
||||
{data?.filename || data?.title || t('agentWorkspace.documents.title')}
|
||||
</Text>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{isMarkdownDocument && (
|
||||
<Segmented<DocumentViewMode>
|
||||
value={viewMode}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon icon={Eye} size={14} />
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{t('agentWorkspace.documents.preview')}
|
||||
</span>
|
||||
</Flexbox>
|
||||
),
|
||||
value: 'preview',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon icon={SquarePen} size={14} />
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{t('agentWorkspace.documents.edit')}
|
||||
</span>
|
||||
</Flexbox>
|
||||
),
|
||||
value: 'edit',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setViewMode(value)}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{shouldShowLoading && (
|
||||
<Flexbox className={styles.editor} gap={8}>
|
||||
<Skeleton active paragraph={{ rows: 10 }} title={false} />
|
||||
</Flexbox>
|
||||
)}
|
||||
{error && (
|
||||
<Text style={{ padding: 12 }} type={'danger'}>
|
||||
{t('agentWorkspace.documents.error')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!shouldShowLoading && !error && data && (
|
||||
<>
|
||||
<Flexbox className={styles.editorWrapper}>
|
||||
{viewMode === 'preview' ? (
|
||||
<div className={styles.preview}>
|
||||
<Markdown variant={'chat'}>{draft}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<EditorTextArea
|
||||
style={{ height: '100%', resize: 'none' }}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
className={`${styles.footer} ${isDirty ? styles.footerOpen : styles.footerClosed}`}
|
||||
>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.footerInner}
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Text type={'secondary'}>{t('agentWorkspace.documents.unsaved')}</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Button
|
||||
disabled={isSaving || shouldShowLoading}
|
||||
size={'small'}
|
||||
onClick={() => setDraft(savedContent)}
|
||||
>
|
||||
{t('agentWorkspace.documents.discard')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={shouldShowLoading || Boolean(error)}
|
||||
loading={isSaving}
|
||||
size={'small'}
|
||||
type={'primary'}
|
||||
onClick={saveDocument}
|
||||
>
|
||||
{t('agentWorkspace.documents.save')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</DraggablePanel>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AgentDocumentSidePanel.displayName = 'AgentDocumentSidePanel';
|
||||
|
||||
export default AgentDocumentSidePanel;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import AgentDocumentSidePanel from './AgentDocumentSidePanel';
|
||||
|
||||
interface ViewerPanelProps {
|
||||
onClose: () => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
const ViewerPanel = memo<ViewerPanelProps>(({ selectedDocumentId, onClose }) => {
|
||||
return <AgentDocumentSidePanel selectedDocumentId={selectedDocumentId} onClose={onClose} />;
|
||||
});
|
||||
|
||||
ViewerPanel.displayName = 'ViewerPanel';
|
||||
|
||||
export default ViewerPanel;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flexbox, TooltipGroup } from '@lobehub/ui';
|
||||
import React, { memo,Suspense } from 'react';
|
||||
import React, { memo, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
@@ -10,6 +10,8 @@ import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
|
||||
import ConversationArea from './ConversationArea';
|
||||
import ChatHeader from './Header';
|
||||
import AgentWorkspaceRightPanel from './RightPanel';
|
||||
import ViewerPanel from './ViewerPanel';
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
@@ -19,24 +21,41 @@ const wrapperStyle: React.CSSProperties = {
|
||||
|
||||
const ChatConversation = memo(() => {
|
||||
const showHeader = useGlobalStore(systemStatusSelectors.showChatHeader);
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
|
||||
// Get current agent's model info for vision support check
|
||||
const model = useAgentStore(agentSelectors.currentAgentModel);
|
||||
const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
|
||||
const { handleUploadFiles } = useUploadFiles({ model, provider });
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDocumentId(null);
|
||||
}, [activeAgentId]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Loading debugId="Agent > ChatConversation" />}>
|
||||
<DragUploadZone style={wrapperStyle} onUploadFiles={handleUploadFiles}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
height={'100%'}
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
{showHeader && <ChatHeader />}
|
||||
<TooltipGroup>
|
||||
<ConversationArea />
|
||||
</TooltipGroup>
|
||||
<Flexbox flex={1} height={'100%'} style={{ minWidth: 0 }}>
|
||||
{showHeader && <ChatHeader />}
|
||||
<TooltipGroup>
|
||||
<ConversationArea />
|
||||
</TooltipGroup>
|
||||
</Flexbox>
|
||||
<ViewerPanel
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onClose={() => setSelectedDocumentId(null)}
|
||||
/>
|
||||
<AgentWorkspaceRightPanel
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onSelectDocument={setSelectedDocumentId}
|
||||
/>
|
||||
</Flexbox>
|
||||
</DragUploadZone>
|
||||
</Suspense>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { lambdaClient } from '@/libs/trpc/client';
|
||||
|
||||
export const agentDocumentSWRKeys = {
|
||||
documents: (agentId: string) => ['agent-documents', agentId] as const,
|
||||
readDocument: (agentId: string, id: string) =>
|
||||
['workspace-agent-document-editor', agentId, id] as const,
|
||||
};
|
||||
|
||||
const VALID_DOCUMENT_POSITIONS = new Set<AgentContextDocument['loadPosition']>(
|
||||
@@ -29,6 +31,10 @@ const revalidateAgentDocuments = async (agentId: string) => {
|
||||
await mutate(agentDocumentSWRKeys.documents(agentId));
|
||||
};
|
||||
|
||||
const revalidateReadDocument = async (agentId: string, id: string) => {
|
||||
await mutate(agentDocumentSWRKeys.readDocument(agentId, id));
|
||||
};
|
||||
|
||||
class AgentDocumentService {
|
||||
getTemplates = async () => {
|
||||
return lambdaClient.agentDocument.getTemplates.query();
|
||||
@@ -99,6 +105,7 @@ class AgentDocumentService {
|
||||
renameDocument = async (params: { agentId: string; id: string; newTitle: string }) => {
|
||||
const result = await lambdaClient.agentDocument.renameDocument.mutate(params);
|
||||
await revalidateAgentDocuments(params.agentId);
|
||||
await revalidateReadDocument(params.agentId, params.id);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user