Compare commits

...

3 Commits

Author SHA1 Message Date
Neko Ayaka de6337f95e feat: filename change, file remove 2026-04-12 15:45:32 +08:00
Neko Ayaka ae3156e361 chore: disable agent summary and progress for now 2026-04-12 15:45:31 +08:00
Neko Ayaka e7d641b044 feat(agentWorkspace): right side panel, and many agent document feat 2026-04-12 15:45:28 +08:00
45 changed files with 2063 additions and 187 deletions
+16
View File
@@ -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": "لا يمكن العرض في الموضوع الفرعي، يرجى التبديل إلى منطقة المحادثة الرئيسية للفتح",
+16
View File
@@ -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": "Не може да се прегледа в подтема, моля преминете към основната зона за разговори, за да отворите",
+16
View File
@@ -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",
+19
View File
@@ -18,6 +18,25 @@
"agentDefaultMessage": "Hi, Im **{{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, Im **{{name}}**. One sentence is enough—you're in control.",
"agentDefaultMessageWithoutEdit": "Hi, Im **{{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",
+16
View File
@@ -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",
+16
View File
@@ -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": "در زیرموضوع قابل مشاهده نیست، لطفاً به بخش اصلی گفتگو بروید",
+16
View File
@@ -18,6 +18,22 @@
"agentDefaultMessage": "Bonjour, je suis **{{name}}**. Une phrase suffit.\n\nVous souhaitez que je madapte mieux à votre flux de travail ? Allez dans [Paramètres de lagent]({{url}}) et complétez le profil de lagent (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 dafficher dans un sous-sujet, veuillez revenir à la conversation principale pour louvrir",
+16
View File
@@ -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",
+16
View File
@@ -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": "サブトピックでは表示できません。メインの対話エリアに戻って開いてください",
+16
View File
@@ -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": "하위 주제에서는 볼 수 없습니다. 메인 대화 영역으로 돌아가서 열어주세요",
+16
View File
@@ -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",
+16
View File
@@ -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ć",
+16
View File
@@ -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",
+16
View File
@@ -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": "Невозможно просмотреть в подтеме, пожалуйста, переключитесь в основную область беседы",
+16
View File
@@ -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",
+16
View File
@@ -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ở",
+19
View File
@@ -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": "子话题中暂不支持查看。请回到主对话区打开",
+17
View File
@@ -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}`;
};
-160
View File
@@ -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;
+22 -5
View File
@@ -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}
/>
+1 -1
View File
@@ -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';
+4 -2
View File
@@ -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);
+88
View File
@@ -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;
+343
View File
@@ -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;
+27
View File
@@ -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;
@@ -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');
});
});
@@ -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,
};
};
@@ -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');
});
});
@@ -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>
+7
View File
@@ -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;
};