From ed0e068a47f90c1e5ce8118f1a43307eafa7c253 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 21 Jan 2026 15:47:49 +0800 Subject: [PATCH 01/43] fix(i18n): update model provider tip to only mention OpenAI in English, Japanese, and Simplified Chinese translations (#31339) Co-authored-by: CodingOnStar --- web/i18n/en-US/common.json | 2 +- web/i18n/ja-JP/common.json | 2 +- web/i18n/zh-Hans/common.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index d2e5281282..20e5400e56 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -350,7 +350,7 @@ "modelProvider.card.quota": "QUOTA", "modelProvider.card.quotaExhausted": "Quota exhausted", "modelProvider.card.removeKey": "Remove API Key", - "modelProvider.card.tip": "Message Credits supports models from OpenAI, Anthropic, Gemini, xAI, DeepSeek and Tongyi. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.", + "modelProvider.card.tip": "Message Credits supports models from OpenAI. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.", "modelProvider.card.tokens": "Tokens", "modelProvider.collapse": "Collapse", "modelProvider.config": "Config", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index ffc2d0bd31..8a76021759 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -350,7 +350,7 @@ "modelProvider.card.quota": "クォータ", "modelProvider.card.quotaExhausted": "クォータが使い果たされました", "modelProvider.card.removeKey": "API キーを削除", - "modelProvider.card.tip": "メッセージ枠はOpenAI、Anthropic、Gemini、xAI、DeepSeek、Tongyiのモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。", + "modelProvider.card.tip": "メッセージ枠はOpenAIのモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。", "modelProvider.card.tokens": "トークン", "modelProvider.collapse": "折り畳み", "modelProvider.config": "設定", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index b5eabfeecc..6f62b53e2d 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -350,7 +350,7 @@ "modelProvider.card.quota": "额度", "modelProvider.card.quotaExhausted": "配额已用完", "modelProvider.card.removeKey": "删除 API 密钥", - "modelProvider.card.tip": "消息额度支持使用 OpenAI、Anthropic、Gemini、xAI、深度求索、通义 的模型;免费额度会在付费额度用尽后才会消耗。", + "modelProvider.card.tip": "消息额度支持使用 OpenAI 的模型;免费额度会在付费额度用尽后才会消耗。", "modelProvider.card.tokens": "Tokens", "modelProvider.collapse": "收起", "modelProvider.config": "配置", From 146ee4d3e9a9d000e993d05bb0dba1eaa00c9b36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:15:58 +0800 Subject: [PATCH 02/43] chore(i18n): sync translations with en-US (#31332) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/billing.json | 1 + web/i18n/de-DE/billing.json | 1 + web/i18n/es-ES/billing.json | 1 + web/i18n/fa-IR/billing.json | 1 + web/i18n/fr-FR/billing.json | 1 + web/i18n/hi-IN/billing.json | 1 + web/i18n/id-ID/billing.json | 1 + web/i18n/it-IT/billing.json | 1 + web/i18n/ko-KR/billing.json | 1 + web/i18n/pl-PL/billing.json | 1 + web/i18n/pt-BR/billing.json | 1 + web/i18n/ro-RO/billing.json | 1 + web/i18n/ru-RU/billing.json | 1 + web/i18n/sl-SI/billing.json | 1 + web/i18n/th-TH/billing.json | 1 + web/i18n/tr-TR/billing.json | 1 + web/i18n/uk-UA/billing.json | 1 + web/i18n/vi-VN/billing.json | 1 + web/i18n/zh-Hant/billing.json | 1 + 19 files changed, 19 insertions(+) diff --git a/web/i18n/ar-TN/billing.json b/web/i18n/ar-TN/billing.json index a67f8216a3..24bc5d2d58 100644 --- a/web/i18n/ar-TN/billing.json +++ b/web/i18n/ar-TN/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "حصة رفع المستندات", "usagePage.perMonth": "شهريًا", "usagePage.resetsIn": "يتم إعادة التعيين في {{count,number}} أيام", + "usagePage.storageThresholdTooltip": "يتم عرض الاستخدام التفصيلي بمجرد أن تتجاوز مساحة التخزين 50 ميجابايت.", "usagePage.teamMembers": "أعضاء الفريق", "usagePage.triggerEvents": "أحداث المشغل", "usagePage.vectorSpace": "تخزين بيانات المعرفة", diff --git a/web/i18n/de-DE/billing.json b/web/i18n/de-DE/billing.json index 31d9150135..f44605984d 100644 --- a/web/i18n/de-DE/billing.json +++ b/web/i18n/de-DE/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Dokumenten-Upload-Quota", "usagePage.perMonth": "pro Monat", "usagePage.resetsIn": "Setzt in {{count,number}} Tagen zurück", + "usagePage.storageThresholdTooltip": "Die detaillierte Nutzung wird angezeigt, sobald der Speicher 50 MB überschreitet.", "usagePage.teamMembers": "Teammitglieder", "usagePage.triggerEvents": "Auslöser-Ereignisse", "usagePage.vectorSpace": "Wissensdatenbank", diff --git a/web/i18n/es-ES/billing.json b/web/i18n/es-ES/billing.json index 7e5c4ed1de..04150901fc 100644 --- a/web/i18n/es-ES/billing.json +++ b/web/i18n/es-ES/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Cuota de carga de documentos", "usagePage.perMonth": "por mes", "usagePage.resetsIn": "Se reinicia en {{count,number}} días", + "usagePage.storageThresholdTooltip": "El uso detallado se muestra una vez que el almacenamiento supera los 50 MB.", "usagePage.teamMembers": "Miembros del equipo", "usagePage.triggerEvents": "Eventos desencadenantes", "usagePage.vectorSpace": "Almacenamiento de Datos de Conocimiento", diff --git a/web/i18n/fa-IR/billing.json b/web/i18n/fa-IR/billing.json index 0cd2e28106..c7790411af 100644 --- a/web/i18n/fa-IR/billing.json +++ b/web/i18n/fa-IR/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "حجم بارگذاری اسناد", "usagePage.perMonth": "در ماه", "usagePage.resetsIn": "در {{count,number}} روز بازنشانی می‌شود", + "usagePage.storageThresholdTooltip": "جزئیات استفاده زمانی نمایش داده می‌شود که فضای ذخیره‌سازی از 50 مگابایت بیشتر شود.", "usagePage.teamMembers": "اعضای تیم", "usagePage.triggerEvents": "رویدادهای محرک", "usagePage.vectorSpace": "ذخیره‌سازی داده‌های دانش", diff --git a/web/i18n/fr-FR/billing.json b/web/i18n/fr-FR/billing.json index 0c67b010d8..34db09bb32 100644 --- a/web/i18n/fr-FR/billing.json +++ b/web/i18n/fr-FR/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Quota de téléchargement de documents", "usagePage.perMonth": "par mois", "usagePage.resetsIn": "Réinitialisations dans {{count,number}} jours", + "usagePage.storageThresholdTooltip": "L'utilisation détaillée est affichée lorsque le stockage dépasse 50 Mo.", "usagePage.teamMembers": "Membres de l'équipe", "usagePage.triggerEvents": "Événements déclencheurs", "usagePage.vectorSpace": "Stockage de données de connaissance", diff --git a/web/i18n/hi-IN/billing.json b/web/i18n/hi-IN/billing.json index 37c6555640..5ab7130ad0 100644 --- a/web/i18n/hi-IN/billing.json +++ b/web/i18n/hi-IN/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "दस्तावेज़ अपलोड कोटा", "usagePage.perMonth": "प्रति माह", "usagePage.resetsIn": "{{count,number}} दिनों में रीसेट होता है", + "usagePage.storageThresholdTooltip": "स्टोरेज 50 MB से अधिक होने पर विस्तृत उपयोग दिखाया जाता है।", "usagePage.teamMembers": "टीम के सदस्य", "usagePage.triggerEvents": "उत्तेजक घटनाएँ", "usagePage.vectorSpace": "ज्ञान डेटा भंडारण", diff --git a/web/i18n/id-ID/billing.json b/web/i18n/id-ID/billing.json index f912cf1960..920836d467 100644 --- a/web/i18n/id-ID/billing.json +++ b/web/i18n/id-ID/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Kuota Unggah Dokumen", "usagePage.perMonth": "per bulan", "usagePage.resetsIn": "Diatur ulang dalam {{count,number}} hari", + "usagePage.storageThresholdTooltip": "Penggunaan terperinci ditampilkan setelah penyimpanan melebihi 50 MB.", "usagePage.teamMembers": "Anggota Tim", "usagePage.triggerEvents": "Pemicu Acara", "usagePage.vectorSpace": "Penyimpanan Data Pengetahuan", diff --git a/web/i18n/it-IT/billing.json b/web/i18n/it-IT/billing.json index fdf2547374..7626232646 100644 --- a/web/i18n/it-IT/billing.json +++ b/web/i18n/it-IT/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Quota di Caricamento Documenti", "usagePage.perMonth": "al mese", "usagePage.resetsIn": "Si resetta tra {{count,number}} giorni", + "usagePage.storageThresholdTooltip": "L'utilizzo dettagliato viene visualizzato quando lo spazio di archiviazione supera i 50 MB.", "usagePage.teamMembers": "Membri del team", "usagePage.triggerEvents": "Eventi di attivazione", "usagePage.vectorSpace": "Archiviazione dei dati conoscitivi", diff --git a/web/i18n/ko-KR/billing.json b/web/i18n/ko-KR/billing.json index 318435d63d..602f5c7407 100644 --- a/web/i18n/ko-KR/billing.json +++ b/web/i18n/ko-KR/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "문서 업로드 한도", "usagePage.perMonth": "월별", "usagePage.resetsIn": "{{count,number}}일 후 초기화", + "usagePage.storageThresholdTooltip": "저장 공간이 50 MB를 초과하면 세부 사용량이 표시됩니다.", "usagePage.teamMembers": "팀원들", "usagePage.triggerEvents": "트리거 이벤트", "usagePage.vectorSpace": "지식 데이터 저장소", diff --git a/web/i18n/pl-PL/billing.json b/web/i18n/pl-PL/billing.json index 913778e91d..62381ab9ba 100644 --- a/web/i18n/pl-PL/billing.json +++ b/web/i18n/pl-PL/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Limit przesyłania dokumentów", "usagePage.perMonth": "miesięcznie", "usagePage.resetsIn": "Resetuje się za {{count,number}} dni", + "usagePage.storageThresholdTooltip": "Szczegółowe użycie jest wyświetlane, gdy przestrzeń dyskowa przekracza 50 MB.", "usagePage.teamMembers": "Członkowie zespołu", "usagePage.triggerEvents": "Wydarzenia wyzwalające", "usagePage.vectorSpace": "Magazynowanie danych wiedzy", diff --git a/web/i18n/pt-BR/billing.json b/web/i18n/pt-BR/billing.json index 8e447d0c17..6a4a0b0eb1 100644 --- a/web/i18n/pt-BR/billing.json +++ b/web/i18n/pt-BR/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Cota de Upload de Documentos", "usagePage.perMonth": "por mês", "usagePage.resetsIn": "Reinicia em {{count,number}} dias", + "usagePage.storageThresholdTooltip": "O uso detalhado é exibido quando o armazenamento excede 50 MB.", "usagePage.teamMembers": "Membros da equipe", "usagePage.triggerEvents": "Eventos de Gatilho", "usagePage.vectorSpace": "Armazenamento de Dados do Conhecimento", diff --git a/web/i18n/ro-RO/billing.json b/web/i18n/ro-RO/billing.json index 99fcb93a4e..41bb429905 100644 --- a/web/i18n/ro-RO/billing.json +++ b/web/i18n/ro-RO/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Cota de încărcare a documentelor", "usagePage.perMonth": "pe lună", "usagePage.resetsIn": "Se resetează în {{count,number}} zile", + "usagePage.storageThresholdTooltip": "Utilizarea detaliată este afișată odată ce spațiul de stocare depășește 50 MB.", "usagePage.teamMembers": "Membrii echipei", "usagePage.triggerEvents": "Evenimente declanșatoare", "usagePage.vectorSpace": "Stocarea datelor de cunoștințe", diff --git a/web/i18n/ru-RU/billing.json b/web/i18n/ru-RU/billing.json index 722953747e..c5a526418a 100644 --- a/web/i18n/ru-RU/billing.json +++ b/web/i18n/ru-RU/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Квота на загрузку документов", "usagePage.perMonth": "в месяц", "usagePage.resetsIn": "Сброс через {{count,number}} дней", + "usagePage.storageThresholdTooltip": "Подробные данные об использовании отображаются после превышения 50 МБ хранилища.", "usagePage.teamMembers": "Члены команды", "usagePage.triggerEvents": "Триггерные события", "usagePage.vectorSpace": "Хранилище данных знаний", diff --git a/web/i18n/sl-SI/billing.json b/web/i18n/sl-SI/billing.json index c9bbbf8043..6409a7aedb 100644 --- a/web/i18n/sl-SI/billing.json +++ b/web/i18n/sl-SI/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Kvota za nalaganje dokumentov", "usagePage.perMonth": "na mesec", "usagePage.resetsIn": "Ponastavitve čez {{count,number}} dni", + "usagePage.storageThresholdTooltip": "Podrobna uporaba se prikaže, ko shramba preseže 50 MB.", "usagePage.teamMembers": "Člani ekipe", "usagePage.triggerEvents": "Sprožilni dogodki", "usagePage.vectorSpace": "Shranjevanje podatkov znanja", diff --git a/web/i18n/th-TH/billing.json b/web/i18n/th-TH/billing.json index ec1cbf501f..b0d6eadafd 100644 --- a/web/i18n/th-TH/billing.json +++ b/web/i18n/th-TH/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "โควต้าการอัปโหลดเอกสาร", "usagePage.perMonth": "ต่อเดือน", "usagePage.resetsIn": "รีเซ็ตในอีก {{count,number}} วัน", + "usagePage.storageThresholdTooltip": "รายละเอียดการใช้งานจะแสดงเมื่อพื้นที่จัดเก็บเกิน 50 MB", "usagePage.teamMembers": "สมาชิกในทีม", "usagePage.triggerEvents": "เหตุการณ์กระตุ้น", "usagePage.vectorSpace": "การจัดเก็บข้อมูลความรู้", diff --git a/web/i18n/tr-TR/billing.json b/web/i18n/tr-TR/billing.json index 036f3e98c3..b780045768 100644 --- a/web/i18n/tr-TR/billing.json +++ b/web/i18n/tr-TR/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Belgeler Yükleme Kotası", "usagePage.perMonth": "ayda", "usagePage.resetsIn": "{{count,number}} gün içinde sıfırlanır", + "usagePage.storageThresholdTooltip": "Depolama alanı 50 MB'yi aştığında ayrıntılı kullanım gösterilir.", "usagePage.teamMembers": "Ekip Üyeleri", "usagePage.triggerEvents": "Tetikleyici Olaylar", "usagePage.vectorSpace": "Bilgi Veri Depolama", diff --git a/web/i18n/uk-UA/billing.json b/web/i18n/uk-UA/billing.json index 7fe974c96e..223eccb699 100644 --- a/web/i18n/uk-UA/billing.json +++ b/web/i18n/uk-UA/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Квота на завантаження документів", "usagePage.perMonth": "на місяць", "usagePage.resetsIn": "Скидання через {{count,number}} днів", + "usagePage.storageThresholdTooltip": "Детальне використання відображається після перевищення 50 МБ сховища.", "usagePage.teamMembers": "Члени команди", "usagePage.triggerEvents": "Тригерні події", "usagePage.vectorSpace": "Сховище даних знань", diff --git a/web/i18n/vi-VN/billing.json b/web/i18n/vi-VN/billing.json index ca792318fa..e111c94082 100644 --- a/web/i18n/vi-VN/billing.json +++ b/web/i18n/vi-VN/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Hạn ngạch tải lên tài liệu", "usagePage.perMonth": "mỗi tháng", "usagePage.resetsIn": "Đặt lại sau {{count,number}} ngày", + "usagePage.storageThresholdTooltip": "Thông tin sử dụng chi tiết sẽ được hiển thị khi dung lượng lưu trữ vượt quá 50 MB.", "usagePage.teamMembers": "Các thành viên trong nhóm", "usagePage.triggerEvents": "Các sự kiện kích hoạt", "usagePage.vectorSpace": "Lưu trữ dữ liệu kiến thức", diff --git a/web/i18n/zh-Hant/billing.json b/web/i18n/zh-Hant/billing.json index 1b343d814a..20277ca50c 100644 --- a/web/i18n/zh-Hant/billing.json +++ b/web/i18n/zh-Hant/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "文件上傳配額", "usagePage.perMonth": "每月", "usagePage.resetsIn": "{{count,number}} 天後重置", + "usagePage.storageThresholdTooltip": "儲存空間超過 50 MB 後,將顯示詳細使用情況。", "usagePage.teamMembers": "團隊成員", "usagePage.triggerEvents": "觸發事件", "usagePage.vectorSpace": "知識數據儲存", From 061feebd87099f0bc0129d7e61d720cf88a6f96e Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:31:48 +0800 Subject: [PATCH 03/43] fix: check and update doc links (#30849) Co-authored-by: Riskey <36894937+RiskeyL@users.noreply.github.com> --- web/README.md | 2 +- .../[appId]/overview/card-view.tsx | 17 +- .../conversation-history/history-panel.tsx | 11 - .../settings-modal/retrieval-section.spec.tsx | 9 +- .../settings-modal/retrieval-section.tsx | 5 +- .../tools/external-data-tool-modal.tsx | 2 +- .../components/app/create-app-modal/index.tsx | 18 - web/app/components/app/overview/app-card.tsx | 2 +- .../app/overview/customize/index.tsx | 2 +- .../app/overview/settings/index.tsx | 12 - .../components/app/overview/trigger-card.tsx | 2 +- .../base/features/new-feature-panel/index.tsx | 11 - .../moderation/moderation-setting-modal.tsx | 2 +- .../datasets/create/step-three/index.spec.tsx | 2 +- .../datasets/create/step-three/index.tsx | 2 +- .../components/indexing-mode-section.tsx | 2 +- .../documents/components/documents-header.tsx | 2 +- .../data-source/online-documents/index.tsx | 2 +- .../data-source/online-drive/index.spec.tsx | 2 +- .../data-source/online-drive/index.tsx | 2 +- .../data-source/website-crawl/index.tsx | 2 +- .../processing/index.spec.tsx | 2 +- .../create-from-pipeline/processing/index.tsx | 2 +- .../external-api/external-api-modal/Form.tsx | 2 +- .../external-api-panel/index.spec.tsx | 2 +- .../external-api/external-api-panel/index.tsx | 2 +- .../create/InfoPanel.tsx | 4 +- .../create/index.spec.tsx | 2 +- .../external-knowledge-base/create/index.tsx | 2 +- .../hit-testing/modify-retrieval-modal.tsx | 5 +- .../datasets/no-linked-apps-panel.tsx | 2 +- .../datasets/settings/form/index.tsx | 7 +- .../header/account-dropdown/index.tsx | 2 +- .../api-based-extension-page/empty.tsx | 2 +- .../api-based-extension-page/modal.tsx | 2 +- .../plugin-detail-panel/endpoint-list.tsx | 2 +- .../plugins/plugin-page/debug-info.tsx | 7 +- .../components/plugins/plugin-page/index.tsx | 7 +- web/app/components/plugins/utils.ts | 13 - .../rag-pipeline-header/publisher/popup.tsx | 4 +- .../hooks/use-available-nodes-meta-data.ts | 15 +- web/app/components/tools/mcp/create-card.tsx | 14 +- .../components/tools/mcp/mcp-service-card.tsx | 2 +- .../tools/provider/custom-create-card.tsx | 22 +- .../workflow-onboarding-modal/index.tsx | 11 - .../hooks/use-available-nodes-meta-data.ts | 3 +- .../nodes/_base/components/agent-strategy.tsx | 5 +- .../components/error-handle/default-value.tsx | 11 - .../error-handle/fail-branch-card.tsx | 2 +- .../variable/var-reference-popup.tsx | 14 +- .../chunk-structure/instruction/index.tsx | 2 +- .../components/retrieval-setting/index.tsx | 4 +- .../json-schema-config.tsx | 14 +- .../panel/chat-variable-panel/index.tsx | 13 - web/app/components/workflow/run/node.tsx | 2 +- web/app/components/workflow/run/status.tsx | 2 +- .../workflow/variable-inspect/empty.tsx | 4 +- .../education-apply/education-apply-page.tsx | 2 +- .../education-apply/expire-notice-modal.tsx | 2 +- .../education-apply/verify-state-modal.tsx | 2 +- web/app/install/installForm.tsx | 5 +- web/app/signin/invite-settings/page.tsx | 5 +- web/app/signin/one-more-step.tsx | 6 +- web/constants/link.ts | 1 + web/context/i18n.ts | 19 +- web/eslint.config.mjs | 2 +- web/hooks/use-api-access-url.ts | 15 +- web/i18n-config/language.ts | 9 +- web/package.json | 1 + web/scripts/gen-doc-paths.ts | 433 ++++++++++++++++++ web/types/doc-paths.ts | 316 +++++++++++++ 71 files changed, 858 insertions(+), 282 deletions(-) create mode 100644 web/constants/link.ts create mode 100644 web/scripts/gen-doc-paths.ts create mode 100644 web/types/doc-paths.ts diff --git a/web/README.md b/web/README.md index 13780eec6c..9c731a081a 100644 --- a/web/README.md +++ b/web/README.md @@ -138,7 +138,7 @@ This will help you determine the testing strategy. See [web/testing/testing.md]( ## Documentation -Visit to view the full documentation. +Visit to view the full documentation. ## Community diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index 81b4f2474e..f07b2932c9 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -5,7 +5,6 @@ import type { BlockEnum } from '@/app/components/workflow/types' import type { UpdateAppSiteCodeResponse } from '@/models/app' import type { App } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' -import * as React from 'react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -17,7 +16,6 @@ import { ToastContext } from '@/app/components/base/toast' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import { isTriggerNode } from '@/app/components/workflow/types' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' -import { useDocLink } from '@/context/i18n' import { fetchAppDetail, updateAppSiteAccessToken, @@ -36,7 +34,6 @@ export type ICardViewProps = { const CardView: FC = ({ appId, isInPanel, className }) => { const { t } = useTranslation() - const docLink = useDocLink() const { notify } = useContext(ToastContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) @@ -59,25 +56,13 @@ const CardView: FC = ({ appId, isInPanel, className }) => { const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false const disableAppCards = !shouldRenderAppCards - const triggerDocUrl = docLink('/guides/workflow/node/start') const buildTriggerModeMessage = useCallback((featureName: string) => (
{t('overview.disableTooltip.triggerMode', { ns: 'appOverview', feature: featureName })}
- { - event.stopPropagation() - }} - > - {t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })} -
- ), [t, triggerDocUrl]) + ), [t]) const disableWebAppTooltip = disableAppCards ? buildTriggerModeMessage(t('overview.appInfo.title', { ns: 'appOverview' })) diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx index b0b042b2a5..44c4fc8f46 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import Panel from '@/app/components/app/configuration/base/feature-panel' import OperationBtn from '@/app/components/app/configuration/base/operation-btn' import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general' -import { useDocLink } from '@/context/i18n' type Props = { showWarning: boolean @@ -17,8 +16,6 @@ const HistoryPanel: FC = ({ onShowEditModal, }) => { const { t } = useTranslation() - const docLink = useDocLink() - return ( = ({
{t('feature.conversationHistory.tip', { ns: 'appDebug' })} - - {t('feature.conversationHistory.learnMore', { ns: 'appDebug' })} -
)} diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx index 0d7b705d9e..2140afe1dd 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx @@ -1,5 +1,6 @@ import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' +import type { DocPathWithoutLang } from '@/types/doc-paths' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { IndexingType } from '@/app/components/datasets/create/step-two' @@ -237,15 +238,15 @@ describe('RetrievalSection', () => { retrievalConfig={retrievalConfig} showMultiModalTip onRetrievalConfigChange={vi.fn()} - docLink={docLink} + docLink={docLink as unknown as (path?: DocPathWithoutLang) => string} />, ) // Assert expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' }) - expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting') - expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting') + expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/use-dify/knowledge/create-knowledge/setting-indexing-methods') + expect(docLink).toHaveBeenCalledWith('/use-dify/knowledge/create-knowledge/setting-indexing-methods') }) it('propagates retrieval config changes for economical indexing', async () => { @@ -263,7 +264,7 @@ describe('RetrievalSection', () => { retrievalConfig={createRetrievalConfig()} showMultiModalTip={false} onRetrievalConfigChange={handleRetrievalChange} - docLink={path => path} + docLink={path => path || ''} />, ) const [topKIncrement] = screen.getAllByLabelText('increment') diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx index 6c9bd14d1e..6d478de908 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' +import type { DocPathWithoutLang } from '@/types/doc-paths' import { RiCloseLine } from '@remixicon/react' import Divider from '@/app/components/base/divider' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' @@ -84,7 +85,7 @@ type InternalRetrievalSectionProps = CommonSectionProps & { retrievalConfig: RetrievalConfig showMultiModalTip: boolean onRetrievalConfigChange: (value: RetrievalConfig) => void - docLink: (path: string) => string + docLink: (path?: DocPathWithoutLang) => string } const InternalRetrievalSection: FC = ({ @@ -102,7 +103,7 @@ const InternalRetrievalSection: FC = ({
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 71827c4e0d..fece5598e1 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -240,7 +240,7 @@ const ExternalDataToolModal: FC = ({ ) diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index c1a662df5d..9975c81b3e 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -245,7 +245,7 @@ function AppCard({
window.open(docLink('/guides/workflow/node/user-input'), '_blank')} + onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')} > {t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 77dae81a01..c7391abe3d 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -118,7 +118,7 @@ const CustomizeModal: FC = ({ className="mt-2" onClick={() => window.open( - docLink('/guides/application-publishing/developing-with-apis'), + docLink('/use-dify/publish/developing-with-apis'), '_blank', )} > diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 428a475da9..0d087e27c2 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -23,7 +23,6 @@ import Textarea from '@/app/components/base/textarea' import { useToastContext } from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { useDocLink } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { languages } from '@/i18n-config/language' @@ -100,7 +99,6 @@ const SettingsModal: FC = ({ const [language, setLanguage] = useState(default_language) const [saveLoading, setSaveLoading] = useState(false) const { t } = useTranslation() - const docLink = useDocLink() const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [appIcon, setAppIcon] = useState( @@ -240,16 +238,6 @@ const SettingsModal: FC = ({
{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })} - - {t('operation.learnMore', { ns: 'common' })} -
{/* form body */} diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx index a2d28606a1..12a294b4ec 100644 --- a/web/app/components/app/overview/trigger-card.tsx +++ b/web/app/components/app/overview/trigger-card.tsx @@ -208,7 +208,7 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { {t('overview.triggerInfo.triggerStatusDescription', { ns: 'appOverview' })} {' '} { const { t } = useTranslation() - const docLink = useDocLink() const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text) const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts) @@ -76,14 +73,6 @@ const NewFeaturePanel = ({
diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 59b62d0bfd..c9455c98eb 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -319,7 +319,7 @@ const ModerationSettingModal: FC = ({
{t('apiBasedExtension.selector.title', { ns: 'common' })}
{ // Assert const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') - expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application') expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noreferrer noopener') }) diff --git a/web/app/components/datasets/create/step-three/index.tsx b/web/app/components/datasets/create/step-three/index.tsx index ad26711311..5ab21f6302 100644 --- a/web/app/components/datasets/create/step-three/index.tsx +++ b/web/app/components/datasets/create/step-three/index.tsx @@ -87,7 +87,7 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie
{t('stepThree.sideTipTitle', { ns: 'datasetCreation' })}
{t('stepThree.sideTipContent', { ns: 'datasetCreation' })}
= ({ {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} diff --git a/web/app/components/datasets/documents/components/documents-header.tsx b/web/app/components/datasets/documents/components/documents-header.tsx index ed97742fdd..490893d43f 100644 --- a/web/app/components/datasets/documents/components/documents-header.tsx +++ b/web/app/components/datasets/documents/components/documents-header.tsx @@ -121,7 +121,7 @@ const DocumentsHeader: FC = ({ className="flex items-center text-text-accent" target="_blank" rel="noopener noreferrer" - href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')} + href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')} > {t('list.learnMore', { ns: 'datasetDocuments' })} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index 9b0df231bd..4bdaac895b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -138,7 +138,7 @@ const OnlineDocuments = ({
{ render() // Assert - expect(mockDocLink).toHaveBeenCalledWith('/guides/knowledge-base/knowledge-pipeline/authorize-data-source') + expect(mockDocLink).toHaveBeenCalledWith('/use-dify/knowledge/knowledge-pipeline/authorize-data-source') }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index 508745aaeb..4346a2d0af 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -196,7 +196,7 @@ const OnlineDrive = ({
{ // Assert const link = screen.getByRole('link', { name: 'datasetPipeline.addDocuments.stepThree.learnMore' }) - expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/knowledge-pipeline/authorize-data-source') expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noreferrer noopener') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx index 97c8937442..283600fa69 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx @@ -44,7 +44,7 @@ const Processing = ({
{t('stepThree.sideTipTitle', { ns: 'datasetCreation' })}
{t('stepThree.sideTipContent', { ns: 'datasetCreation' })}
= React.memo(({ {variable === 'endpoint' && ( { render() const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') expect(docLink).toBeInTheDocument() - expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base') + expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/connect-external-knowledge-base') }) it('should render create button', () => { diff --git a/web/app/components/datasets/external-api/external-api-panel/index.tsx b/web/app/components/datasets/external-api/external-api-panel/index.tsx index a137348626..c37ff20ba7 100644 --- a/web/app/components/datasets/external-api/external-api-panel/index.tsx +++ b/web/app/components/datasets/external-api/external-api-panel/index.tsx @@ -54,7 +54,7 @@ const ExternalAPIPanel: React.FC = ({ onClose }) => {
{t('externalAPIPanelDescription', { ns: 'dataset' })}
diff --git a/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx b/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx index beb6a3cf71..61b37a0a1d 100644 --- a/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx @@ -18,14 +18,14 @@ const InfoPanel = () => { {t('connectDatasetIntro.content.front', { ns: 'dataset' })} - + {t('connectDatasetIntro.content.link', { ns: 'dataset' })} {t('connectDatasetIntro.content.end', { ns: 'dataset' })} diff --git a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx index 2fce096cd5..d56833fd36 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx @@ -146,7 +146,7 @@ describe('ExternalKnowledgeBaseCreate', () => { renderComponent() const docLink = screen.getByText('dataset.connectHelper.helper4') - expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/guides/knowledge-base/connect-external-knowledge-base') + expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base') expect(docLink).toHaveAttribute('target', '_blank') expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') }) diff --git a/web/app/components/datasets/external-knowledge-base/create/index.tsx b/web/app/components/datasets/external-knowledge-base/create/index.tsx index 1d17b23b43..07b6e71fa6 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/index.tsx @@ -61,7 +61,7 @@ const ExternalKnowledgeBaseCreate: React.FC = {t('connectHelper.helper1', { ns: 'dataset' })} {t('connectHelper.helper2', { ns: 'dataset' })} {t('connectHelper.helper3', { ns: 'dataset' })} - + {t('connectHelper.helper4', { ns: 'dataset' })} diff --git a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx index d21297fc93..a942c402ed 100644 --- a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx +++ b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx @@ -96,10 +96,7 @@ const ModifyRetrievalModal: FC = ({ {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} diff --git a/web/app/components/datasets/no-linked-apps-panel.tsx b/web/app/components/datasets/no-linked-apps-panel.tsx index 1b0357bc6a..12e87a7379 100644 --- a/web/app/components/datasets/no-linked-apps-panel.tsx +++ b/web/app/components/datasets/no-linked-apps-panel.tsx @@ -15,7 +15,7 @@ const NoLinkedAppsPanel = () => {
{t('datasetMenus.emptyTip', { ns: 'common' })}
diff --git a/web/app/components/datasets/settings/form/index.tsx b/web/app/components/datasets/settings/form/index.tsx index 5fbaefade7..a25d770518 100644 --- a/web/app/components/datasets/settings/form/index.tsx +++ b/web/app/components/datasets/settings/form/index.tsx @@ -281,7 +281,7 @@ const Form = () => { {t('form.chunkStructure.learnMore', { ns: 'datasetSettings' })} @@ -421,10 +421,7 @@ const Form = () => { {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index e16c00acd0..07dd0fca3d 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -137,7 +137,7 @@ export default function AppSelector() { diff --git a/web/app/components/header/account-setting/api-based-extension-page/empty.tsx b/web/app/components/header/account-setting/api-based-extension-page/empty.tsx index 38525993fa..d75e66f8d0 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/empty.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/empty.tsx @@ -17,7 +17,7 @@ const Empty = () => {
{t('apiBasedExtension.title', { ns: 'common' })}
diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx index d3146d7baa..f35986dbb0 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx @@ -102,7 +102,7 @@ const ApiBasedExtensionModal: FC = ({
{t('detailPanel.endpointsTip', { ns: 'plugin' })}
diff --git a/web/app/components/plugins/plugin-page/debug-info.tsx b/web/app/components/plugins/plugin-page/debug-info.tsx index f62f8a4134..f3eed424f4 100644 --- a/web/app/components/plugins/plugin-page/debug-info.tsx +++ b/web/app/components/plugins/plugin-page/debug-info.tsx @@ -8,8 +8,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Tooltip from '@/app/components/base/tooltip' -import { getDocsUrl } from '@/app/components/plugins/utils' -import { useLocale } from '@/context/i18n' +import { useDocLink } from '@/context/i18n' import { useDebugKey } from '@/service/use-plugins' import KeyValueItem from '../base/key-value-item' @@ -17,7 +16,7 @@ const i18nPrefix = 'debugInfo' const DebugInfo: FC = () => { const { t } = useTranslation() - const locale = useLocale() + const docLink = useDocLink() const { data: info, isLoading } = useDebugKey() // info.key likes 4580bdb7-b878-471c-a8a4-bfd760263a53 mask the middle part using *. @@ -34,7 +33,7 @@ const DebugInfo: FC = () => { <>
{t(`${i18nPrefix}.title`, { ns: 'plugin' })} - + {t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })} diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index d852e4d0b8..efb665197a 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -15,10 +15,9 @@ import Button from '@/app/components/base/button' import TabSlider from '@/app/components/base/tab-slider' import Tooltip from '@/app/components/base/tooltip' import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal' -import { getDocsUrl } from '@/app/components/plugins/utils' import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' -import { useLocale } from '@/context/i18n' +import { useDocLink } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { usePluginInstallation } from '@/hooks/use-query-params' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' @@ -47,7 +46,7 @@ const PluginPage = ({ marketplace, }: PluginPageProps) => { const { t } = useTranslation() - const locale = useLocale() + const docLink = useDocLink() useDocumentTitle(t('metadata.title', { ns: 'plugin' })) // Use nuqs hook for installation state @@ -175,7 +174,7 @@ const PluginPage = ({
window.open(docLink('/guides/workflow/node/user-input'), '_blank')} + onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')} > {t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
diff --git a/web/app/components/tools/provider/custom-create-card.tsx b/web/app/components/tools/provider/custom-create-card.tsx index 637d17c3c3..bf86a1f833 100644 --- a/web/app/components/tools/provider/custom-create-card.tsx +++ b/web/app/components/tools/provider/custom-create-card.tsx @@ -2,16 +2,12 @@ import type { CustomCollectionBackend } from '../types' import { RiAddCircleFill, - RiArrowRightUpLine, - RiBookOpenLine, } from '@remixicon/react' -import { useMemo, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import { useAppContext } from '@/context/app-context' -import { useDocLink, useLocale } from '@/context/i18n' -import { getLanguage } from '@/i18n-config/language' import { createCustomCollection } from '@/service/tools' type Props = { @@ -20,17 +16,8 @@ type Props = { const Contribute = ({ onRefreshData }: Props) => { const { t } = useTranslation() - const locale = useLocale() - const language = getLanguage(locale) const { isCurrentWorkspaceManager } = useAppContext() - const docLink = useDocLink() - const linkUrl = useMemo(() => { - return docLink('/guides/tools#how-to-create-custom-tools', { - 'zh-Hans': '/guides/tools#ru-he-chuang-jian-zi-ding-yi-gong-ju', - }) - }, [language]) - const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false) const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => { await createCustomCollection(data) @@ -54,13 +41,6 @@ const Contribute = ({ onRefreshData }: Props) => {
{t('createCustomTool', { ns: 'tools' })}
-
)} {isShowEditCollectionToolModal && ( diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx index 0f92982cf2..0faf43bfd1 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx @@ -8,7 +8,6 @@ import { import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' import { BlockEnum } from '@/app/components/workflow/types' -import { useDocLink } from '@/context/i18n' import StartNodeSelectionPanel from './start-node-selection-panel' type WorkflowOnboardingModalProps = { @@ -23,7 +22,6 @@ const WorkflowOnboardingModal: FC = ({ onSelectStartNode, }) => { const { t } = useTranslation() - const docLink = useDocLink() const handleSelectUserInput = useCallback(() => { onSelectStartNode(BlockEnum.Start) @@ -63,15 +61,6 @@ const WorkflowOnboardingModal: FC = ({
{t('onboarding.description', { ns: 'workflow' })} {' '} - - {t('onboarding.learnMore', { ns: 'workflow' })} - - {' '} {t('onboarding.aboutStartNode', { ns: 'workflow' })}
diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index 60f0bf3b28..0c5c1e4a40 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -1,4 +1,5 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store' +import type { DocPathWithoutLang } from '@/types/doc-paths' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' @@ -44,7 +45,7 @@ export const useAvailableNodesMetaData = () => { const { metaData } = node const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' }) - const helpLinkPath = `guides/workflow/node/${metaData.helpLinkUri}` + const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang return { ...node, metaData: { diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index 8303681d90..42be3d46e4 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -251,10 +251,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { {' '}
diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx index 538dce09d0..080fa0f107 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx @@ -5,7 +5,6 @@ import Input from '@/app/components/base/input' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { VarType } from '@/app/components/workflow/types' -import { useDocLink } from '@/context/i18n' type DefaultValueProps = { forms: DefaultValueForm[] @@ -16,7 +15,6 @@ const DefaultValue = ({ onFormChange, }: DefaultValueProps) => { const { t } = useTranslation() - const docLink = useDocLink() const getFormChangeHandler = useCallback(({ key, type }: DefaultValueForm) => { return (payload: any) => { let value @@ -35,15 +33,6 @@ const DefaultValue = ({
{t('nodes.common.errorHandle.defaultValue.desc', { ns: 'workflow' })}   - - {t('common.learnMore', { ns: 'workflow' })} -
{ diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx index fe267f52c4..49cd44160c 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx @@ -19,7 +19,7 @@ const FailBranchCard = () => { {t('nodes.common.errorHandle.failBranch.customizeTip', { ns: 'workflow' })}   diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx index 6184bcad9f..26f10b7a1d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx @@ -6,7 +6,6 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ListEmpty from '@/app/components/base/list-empty' import { useStore } from '@/app/components/workflow/store' -import { useDocLink } from '@/context/i18n' import VarReferenceVars from './var-reference-vars' type Props = { @@ -31,7 +30,7 @@ const VarReferencePopup: FC = ({ const pipelineId = useStore(s => s.pipelineId) const showManageRagInputFields = useMemo(() => !!pipelineId, [pipelineId]) const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) - const docLink = useDocLink() + // max-h-[300px] overflow-y-auto todo: use portal to handle long list return (
= ({ description={( )} /> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx index 77981639cd..73e87ec12b 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx @@ -31,7 +31,7 @@ const Instruction = ({

{t('nodes.knowledgeBase.chunkStructureTip.message', { ns: 'workflow' })}

{ const { t } = useTranslation() + const docLink = useDocLink() const { options, hybridSearchModeOptions, @@ -61,7 +63,7 @@ const RetrievalSetting = ({ title: t('form.retrievalSetting.title', { ns: 'datasetSettings' }), subTitle: ( diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx index e7ac493bd2..b4dac4b58e 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx @@ -1,14 +1,12 @@ import type { FC } from 'react' import type { SchemaRoot } from '../../types' -import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react' -import * as React from 'react' +import { RiBracesLine, RiCloseLine, RiTimelineView } from '@remixicon/react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import Toast from '@/app/components/base/toast' import { JSON_SCHEMA_MAX_DEPTH } from '@/config' -import { useDocLink } from '@/context/i18n' import { SegmentedControl } from '../../../../../base/segmented-control' import { Type } from '../../types' import { @@ -55,7 +53,6 @@ const JsonSchemaConfig: FC = ({ onClose, }) => { const { t } = useTranslation() - const docLink = useDocLink() const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor) const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2)) @@ -253,15 +250,6 @@ const JsonSchemaConfig: FC = ({
{/* Footer */}
- - {t('nodes.llm.jsonSchema.doc', { ns: 'workflow' })} - -
, + MarkdownForm: ({ children }: PropsWithChildren) =>
{children}
, + Paragraph: ({ children }: PropsWithChildren) =>

{children}

, + PluginImg: ({ alt }: { alt?: string }) => {alt}, + PluginParagraph: ({ children }: PropsWithChildren) =>

{children}

, + ScriptBlock: () => null, + ThinkBlock: ({ children }: PropsWithChildren) =>
{children}
, + VideoBlock: ({ children }: PropsWithChildren) =>
{children}
, +})) + +vi.mock('@/app/components/base/markdown-blocks/code-block', () => ({ + default: ({ children }: PropsWithChildren) => {children}, +})) + +describe('ReactMarkdownWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Strikethrough rendering', () => { + it('should NOT render single tilde as strikethrough', () => { + // Arrange - single tilde should be rendered as literal text + const content = 'Range: 0.3~8mm' + + // Act + render() + + // Assert - check that ~ is rendered as text, not as strikethrough (del element) + // The content should contain the tilde as literal text + expect(screen.getByText(/0\.3~8mm/)).toBeInTheDocument() + expect(document.querySelector('del')).toBeNull() + }) + + it('should render double tildes as strikethrough', () => { + // Arrange - double tildes should create strikethrough + const content = 'This is ~~strikethrough~~ text' + + // Act + render() + + // Assert - del element should be present for double tildes + const delElement = document.querySelector('del') + expect(delElement).not.toBeNull() + expect(delElement?.textContent).toBe('strikethrough') + }) + + it('should handle mixed content with single and double tildes correctly', () => { + // Arrange - real-world example from issue #31391 + const content = 'PCB thickness: 0.3~8mm and ~~removed feature~~ text' + + // Act + render() + + // Assert + // Only double tildes should create strikethrough + const delElements = document.querySelectorAll('del') + expect(delElements).toHaveLength(1) + expect(delElements[0].textContent).toBe('removed feature') + + // Single tilde should remain as literal text + expect(screen.getByText(/0\.3~8mm/)).toBeInTheDocument() + }) + }) + + describe('Basic rendering', () => { + it('should render plain text content', () => { + // Arrange + const content = 'Hello World' + + // Act + render() + + // Assert + expect(screen.getByText('Hello World')).toBeInTheDocument() + }) + + it('should render bold text', () => { + // Arrange + const content = '**bold text**' + + // Act + render() + + // Assert + expect(screen.getByText('bold text')).toBeInTheDocument() + expect(document.querySelector('strong')).not.toBeNull() + }) + + it('should render italic text', () => { + // Arrange + const content = '*italic text*' + + // Act + render() + + // Assert + expect(screen.getByText('italic text')).toBeInTheDocument() + expect(document.querySelector('em')).not.toBeNull() + }) + }) +}) diff --git a/web/app/components/base/markdown/react-markdown-wrapper.tsx b/web/app/components/base/markdown/react-markdown-wrapper.tsx index ef735b5e76..ed9e93e8b3 100644 --- a/web/app/components/base/markdown/react-markdown-wrapper.tsx +++ b/web/app/components/base/markdown/react-markdown-wrapper.tsx @@ -30,7 +30,7 @@ export const ReactMarkdownWrapper: FC = (props) => { return ( Date: Thu, 22 Jan 2026 18:02:54 +0800 Subject: [PATCH 17/43] fix: use thread local isolation the context (#31410) --- api/context/flask_app_context.py | 34 ++++++------- .../workflow/context/execution_context.py | 12 +++-- api/core/workflow/graph_engine/worker.py | 3 +- .../context/test_execution_context.py | 50 +++++++++++++++++++ 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/api/context/flask_app_context.py b/api/context/flask_app_context.py index 360be16beb..2d465c8cf4 100644 --- a/api/context/flask_app_context.py +++ b/api/context/flask_app_context.py @@ -3,6 +3,7 @@ Flask App Context - Flask implementation of AppContext interface. """ import contextvars +import threading from collections.abc import Generator from contextlib import contextmanager from typing import Any, final @@ -118,6 +119,7 @@ class FlaskExecutionContext: self._context_vars = context_vars self._user = user self._flask_app = flask_app + self._local = threading.local() @property def app_context(self) -> FlaskAppContext: @@ -136,47 +138,39 @@ class FlaskExecutionContext: def __enter__(self) -> "FlaskExecutionContext": """Enter the Flask execution context.""" - # Restore context variables + # Restore non-Flask context variables to avoid leaking Flask tokens across threads for var, val in self._context_vars.items(): var.set(val) - # Save current user from g if available - saved_user = None - if hasattr(g, "_login_user"): - saved_user = g._login_user - # Enter Flask app context - self._cm = self._app_context.enter() - self._cm.__enter__() + cm = self._app_context.enter() + self._local.cm = cm + cm.__enter__() # Restore user in new app context - if saved_user is not None: - g._login_user = saved_user + if self._user is not None: + g._login_user = self._user return self def __exit__(self, *args: Any) -> None: """Exit the Flask execution context.""" - if hasattr(self, "_cm"): - self._cm.__exit__(*args) + cm = getattr(self._local, "cm", None) + if cm is not None: + cm.__exit__(*args) @contextmanager def enter(self) -> Generator[None, None, None]: """Enter Flask execution context as context manager.""" - # Restore context variables + # Restore non-Flask context variables to avoid leaking Flask tokens across threads for var, val in self._context_vars.items(): var.set(val) - # Save current user from g if available - saved_user = None - if hasattr(g, "_login_user"): - saved_user = g._login_user - # Enter Flask app context with self._flask_app.app_context(): # Restore user in new app context - if saved_user is not None: - g._login_user = saved_user + if self._user is not None: + g._login_user = self._user yield diff --git a/api/core/workflow/context/execution_context.py b/api/core/workflow/context/execution_context.py index d951c95d68..e3007530f0 100644 --- a/api/core/workflow/context/execution_context.py +++ b/api/core/workflow/context/execution_context.py @@ -3,6 +3,7 @@ Execution Context - Abstracted context management for workflow execution. """ import contextvars +import threading from abc import ABC, abstractmethod from collections.abc import Callable, Generator from contextlib import AbstractContextManager, contextmanager @@ -88,6 +89,7 @@ class ExecutionContext: self._app_context = app_context self._context_vars = context_vars self._user = user + self._local = threading.local() @property def app_context(self) -> AppContext | None: @@ -125,14 +127,16 @@ class ExecutionContext: def __enter__(self) -> "ExecutionContext": """Enter the execution context.""" - self._cm = self.enter() - self._cm.__enter__() + cm = self.enter() + self._local.cm = cm + cm.__enter__() return self def __exit__(self, *args: Any) -> None: """Exit the execution context.""" - if hasattr(self, "_cm"): - self._cm.__exit__(*args) + cm = getattr(self._local, "cm", None) + if cm is not None: + cm.__exit__(*args) class NullAppContext(AppContext): diff --git a/api/core/workflow/graph_engine/worker.py b/api/core/workflow/graph_engine/worker.py index 95db5c5c92..6c69ea5df0 100644 --- a/api/core/workflow/graph_engine/worker.py +++ b/api/core/workflow/graph_engine/worker.py @@ -11,7 +11,6 @@ import time from collections.abc import Sequence from datetime import datetime from typing import TYPE_CHECKING, final -from uuid import uuid4 from typing_extensions import override @@ -113,7 +112,7 @@ class Worker(threading.Thread): self._ready_queue.task_done() except Exception as e: error_event = NodeRunFailedEvent( - id=str(uuid4()), + id=node.execution_id, node_id=node.id, node_type=node.node_type, in_iteration_id=None, diff --git a/api/tests/unit_tests/core/workflow/context/test_execution_context.py b/api/tests/unit_tests/core/workflow/context/test_execution_context.py index 63466cfb5e..8dd669e17f 100644 --- a/api/tests/unit_tests/core/workflow/context/test_execution_context.py +++ b/api/tests/unit_tests/core/workflow/context/test_execution_context.py @@ -1,6 +1,8 @@ """Tests for execution context module.""" import contextvars +import threading +from contextlib import contextmanager from typing import Any from unittest.mock import MagicMock @@ -149,6 +151,54 @@ class TestExecutionContext: assert ctx.user == user + def test_thread_safe_context_manager(self): + """Test shared ExecutionContext works across threads without token mismatch.""" + test_var = contextvars.ContextVar("thread_safe_test_var") + + class TrackingAppContext(AppContext): + def get_config(self, key: str, default: Any = None) -> Any: + return default + + def get_extension(self, name: str) -> Any: + return None + + @contextmanager + def enter(self): + token = test_var.set(threading.get_ident()) + try: + yield + finally: + test_var.reset(token) + + ctx = ExecutionContext(app_context=TrackingAppContext()) + errors: list[Exception] = [] + barrier = threading.Barrier(2) + + def worker(): + try: + for _ in range(20): + with ctx: + try: + barrier.wait() + barrier.wait() + except threading.BrokenBarrierError: + return + except Exception as exc: + errors.append(exc) + try: + barrier.abort() + except Exception: + pass + + t1 = threading.Thread(target=worker) + t2 = threading.Thread(target=worker) + t1.start() + t2.start() + t1.join(timeout=5) + t2.join(timeout=5) + + assert not errors + class TestIExecutionContextProtocol: """Test IExecutionContext protocol.""" From c575c34ca62a988de9c2e9628c26a28380966274 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 22 Jan 2026 18:08:21 +0800 Subject: [PATCH 18/43] refactor: Move workflow node factory to app workflow (#31385) Signed-off-by: -LAN- --- api/.importlinter | 250 +++++++++++++++++- api/core/app/apps/pipeline/pipeline_runner.py | 2 +- api/core/app/apps/workflow_app_runner.py | 2 +- api/core/app/workflow/__init__.py | 3 + .../nodes => app/workflow}/node_factory.py | 3 +- api/core/workflow/nodes/base/node.py | 8 +- .../nodes/iteration/iteration_node.py | 2 +- api/core/workflow/nodes/loop/loop_node.py | 2 +- api/core/workflow/workflow_entry.py | 2 +- .../workflow/nodes/test_code.py | 2 +- .../workflow/nodes/test_http.py | 2 +- .../workflow/nodes/test_llm.py | 2 +- .../nodes/test_parameter_extractor.py | 2 +- .../workflow/nodes/test_template_transform.py | 2 +- .../workflow/nodes/test_tool.py | 2 +- .../graph_engine/test_mock_factory.py | 2 +- .../test_parallel_streaming_workflow.py | 2 +- .../graph_engine/test_table_runner.py | 2 +- .../core/workflow/nodes/answer/test_answer.py | 2 +- .../core/workflow/nodes/test_if_else.py | 2 +- .../v1/test_variable_assigner_v1.py | 2 +- .../v2/test_variable_assigner_v2.py | 2 +- 22 files changed, 273 insertions(+), 27 deletions(-) create mode 100644 api/core/app/workflow/__init__.py rename api/core/{workflow/nodes => app/workflow}/node_factory.py (98%) diff --git a/api/.importlinter b/api/.importlinter index 2dec958788..b676e97591 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -27,7 +27,9 @@ ignore_imports = core.workflow.nodes.iteration.iteration_node -> core.workflow.graph_events core.workflow.nodes.loop.loop_node -> core.workflow.graph_events - core.workflow.nodes.node_factory -> core.workflow.graph + core.workflow.nodes.iteration.iteration_node -> core.app.workflow.node_factory + core.workflow.nodes.loop.loop_node -> core.app.workflow.node_factory + core.workflow.nodes.iteration.iteration_node -> core.workflow.graph_engine core.workflow.nodes.iteration.iteration_node -> core.workflow.graph core.workflow.nodes.iteration.iteration_node -> core.workflow.graph_engine.command_channels @@ -57,6 +59,252 @@ ignore_imports = core.workflow.graph_engine.manager -> extensions.ext_redis core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_redis +[importlinter:contract:workflow-external-imports] +name = Workflow External Imports +type = forbidden +source_modules = + core.workflow +forbidden_modules = + configs + controllers + extensions + models + services + tasks + core.agent + core.app + core.base + core.callback_handler + core.datasource + core.db + core.entities + core.errors + core.extension + core.external_data_tool + core.file + core.helper + core.hosting_configuration + core.indexing_runner + core.llm_generator + core.logging + core.mcp + core.memory + core.model_manager + core.moderation + core.ops + core.plugin + core.prompt + core.provider_manager + core.rag + core.repositories + core.schemas + core.tools + core.trigger + core.variables +ignore_imports = + core.workflow.nodes.loop.loop_node -> core.app.workflow.node_factory + core.workflow.graph_engine.command_channels.redis_channel -> extensions.ext_redis + core.workflow.graph_engine.layers.observability -> configs + core.workflow.graph_engine.layers.observability -> extensions.otel.runtime + core.workflow.graph_engine.layers.persistence -> core.ops.ops_trace_manager + core.workflow.graph_engine.worker_management.worker_pool -> configs + core.workflow.nodes.agent.agent_node -> core.model_manager + core.workflow.nodes.agent.agent_node -> core.provider_manager + core.workflow.nodes.agent.agent_node -> core.tools.tool_manager + core.workflow.nodes.code.code_node -> core.helper.code_executor.code_executor + core.workflow.nodes.datasource.datasource_node -> models.model + core.workflow.nodes.datasource.datasource_node -> models.tools + core.workflow.nodes.datasource.datasource_node -> services.datasource_provider_service + core.workflow.nodes.document_extractor.node -> configs + core.workflow.nodes.document_extractor.node -> core.file.file_manager + core.workflow.nodes.document_extractor.node -> core.helper.ssrf_proxy + core.workflow.nodes.http_request.entities -> configs + core.workflow.nodes.http_request.executor -> configs + core.workflow.nodes.http_request.executor -> core.file.file_manager + core.workflow.nodes.http_request.node -> configs + core.workflow.nodes.http_request.node -> core.tools.tool_file_manager + core.workflow.nodes.iteration.iteration_node -> core.app.workflow.node_factory + core.workflow.nodes.knowledge_index.knowledge_index_node -> core.rag.index_processor.index_processor_factory + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.rag.datasource.retrieval_service + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.rag.retrieval.dataset_retrieval + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> models.dataset + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> services.feature_service + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.model_runtime.model_providers.__base.large_language_model + core.workflow.nodes.llm.llm_utils -> configs + core.workflow.nodes.llm.llm_utils -> core.app.entities.app_invoke_entities + core.workflow.nodes.llm.llm_utils -> core.file.models + core.workflow.nodes.llm.llm_utils -> core.model_manager + core.workflow.nodes.llm.llm_utils -> core.model_runtime.model_providers.__base.large_language_model + core.workflow.nodes.llm.llm_utils -> models.model + core.workflow.nodes.llm.llm_utils -> models.provider + core.workflow.nodes.llm.llm_utils -> services.credit_pool_service + core.workflow.nodes.llm.node -> core.tools.signature + core.workflow.nodes.template_transform.template_transform_node -> configs + core.workflow.nodes.tool.tool_node -> core.callback_handler.workflow_tool_callback_handler + core.workflow.nodes.tool.tool_node -> core.tools.tool_engine + core.workflow.nodes.tool.tool_node -> core.tools.tool_manager + core.workflow.workflow_entry -> configs + core.workflow.workflow_entry -> models.workflow + core.workflow.nodes.agent.agent_node -> core.agent.entities + core.workflow.nodes.agent.agent_node -> core.agent.plugin_entities + core.workflow.graph_engine.layers.persistence -> core.app.entities.app_invoke_entities + core.workflow.nodes.base.node -> core.app.entities.app_invoke_entities + core.workflow.nodes.knowledge_index.knowledge_index_node -> core.app.entities.app_invoke_entities + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.app_config.entities + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.entities.app_invoke_entities + core.workflow.nodes.llm.node -> core.app.entities.app_invoke_entities + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.app.entities.app_invoke_entities + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.advanced_prompt_transform + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.simple_prompt_transform + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.model_runtime.model_providers.__base.large_language_model + core.workflow.nodes.question_classifier.question_classifier_node -> core.app.entities.app_invoke_entities + core.workflow.nodes.question_classifier.question_classifier_node -> core.prompt.advanced_prompt_transform + core.workflow.nodes.question_classifier.question_classifier_node -> core.prompt.simple_prompt_transform + core.workflow.nodes.start.entities -> core.app.app_config.entities + core.workflow.nodes.start.start_node -> core.app.app_config.entities + core.workflow.workflow_entry -> core.app.apps.exc + core.workflow.workflow_entry -> core.app.entities.app_invoke_entities + core.workflow.workflow_entry -> core.app.workflow.node_factory + core.workflow.nodes.datasource.datasource_node -> core.datasource.datasource_manager + core.workflow.nodes.datasource.datasource_node -> core.datasource.utils.message_transformer + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.entities.agent_entities + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.entities.model_entities + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.model_manager + core.workflow.nodes.llm.llm_utils -> core.entities.provider_entities + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.model_manager + core.workflow.nodes.question_classifier.question_classifier_node -> core.model_manager + core.workflow.node_events.node -> core.file + core.workflow.nodes.agent.agent_node -> core.file + core.workflow.nodes.datasource.datasource_node -> core.file + core.workflow.nodes.datasource.datasource_node -> core.file.enums + core.workflow.nodes.document_extractor.node -> core.file + core.workflow.nodes.http_request.executor -> core.file.enums + core.workflow.nodes.http_request.node -> core.file + core.workflow.nodes.http_request.node -> core.file.file_manager + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.file.models + core.workflow.nodes.list_operator.node -> core.file + core.workflow.nodes.llm.file_saver -> core.file + core.workflow.nodes.llm.llm_utils -> core.variables.segments + core.workflow.nodes.llm.node -> core.file + core.workflow.nodes.llm.node -> core.file.file_manager + core.workflow.nodes.llm.node -> core.file.models + core.workflow.nodes.loop.entities -> core.variables.types + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.file + core.workflow.nodes.protocols -> core.file + core.workflow.nodes.question_classifier.question_classifier_node -> core.file.models + core.workflow.nodes.tool.tool_node -> core.file + core.workflow.nodes.tool.tool_node -> core.tools.utils.message_transformer + core.workflow.nodes.tool.tool_node -> models + core.workflow.nodes.trigger_webhook.node -> core.file + core.workflow.runtime.variable_pool -> core.file + core.workflow.runtime.variable_pool -> core.file.file_manager + core.workflow.system_variable -> core.file.models + core.workflow.utils.condition.processor -> core.file + core.workflow.utils.condition.processor -> core.file.file_manager + core.workflow.workflow_entry -> core.file.models + core.workflow.workflow_type_encoder -> core.file.models + core.workflow.nodes.agent.agent_node -> models.model + core.workflow.nodes.code.code_node -> core.helper.code_executor.code_node_provider + core.workflow.nodes.code.code_node -> core.helper.code_executor.javascript.javascript_code_provider + core.workflow.nodes.code.code_node -> core.helper.code_executor.python3.python3_code_provider + core.workflow.nodes.code.entities -> core.helper.code_executor.code_executor + core.workflow.nodes.datasource.datasource_node -> core.variables.variables + core.workflow.nodes.http_request.executor -> core.helper.ssrf_proxy + core.workflow.nodes.http_request.node -> core.helper.ssrf_proxy + core.workflow.nodes.llm.file_saver -> core.helper.ssrf_proxy + core.workflow.nodes.llm.node -> core.helper.code_executor + core.workflow.nodes.template_transform.template_renderer -> core.helper.code_executor.code_executor + core.workflow.nodes.llm.node -> core.llm_generator.output_parser.errors + core.workflow.nodes.llm.node -> core.llm_generator.output_parser.structured_output + core.workflow.nodes.llm.node -> core.model_manager + core.workflow.graph_engine.layers.persistence -> core.ops.entities.trace_entity + core.workflow.nodes.agent.entities -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.prompt.simple_prompt_transform + core.workflow.nodes.llm.entities -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.llm.llm_utils -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.llm.node -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.llm.node -> core.prompt.utils.prompt_message_util + core.workflow.nodes.parameter_extractor.entities -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.utils.prompt_message_util + core.workflow.nodes.question_classifier.entities -> core.prompt.entities.advanced_prompt_entities + core.workflow.nodes.question_classifier.question_classifier_node -> core.prompt.utils.prompt_message_util + core.workflow.nodes.knowledge_index.entities -> core.rag.retrieval.retrieval_methods + core.workflow.nodes.knowledge_index.knowledge_index_node -> core.rag.retrieval.retrieval_methods + core.workflow.nodes.knowledge_index.knowledge_index_node -> models.dataset + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.rag.retrieval.retrieval_methods + core.workflow.nodes.llm.node -> models.dataset + core.workflow.nodes.agent.agent_node -> core.tools.utils.message_transformer + core.workflow.nodes.llm.file_saver -> core.tools.signature + core.workflow.nodes.llm.file_saver -> core.tools.tool_file_manager + core.workflow.nodes.tool.tool_node -> core.tools.errors + core.workflow.conversation_variable_updater -> core.variables + core.workflow.graph_engine.entities.commands -> core.variables.variables + core.workflow.nodes.agent.agent_node -> core.variables.segments + core.workflow.nodes.answer.answer_node -> core.variables + core.workflow.nodes.code.code_node -> core.variables.segments + core.workflow.nodes.code.code_node -> core.variables.types + core.workflow.nodes.code.entities -> core.variables.types + core.workflow.nodes.datasource.datasource_node -> core.variables.segments + core.workflow.nodes.document_extractor.node -> core.variables + core.workflow.nodes.document_extractor.node -> core.variables.segments + core.workflow.nodes.http_request.executor -> core.variables.segments + core.workflow.nodes.http_request.node -> core.variables.segments + core.workflow.nodes.iteration.iteration_node -> core.variables + core.workflow.nodes.iteration.iteration_node -> core.variables.segments + core.workflow.nodes.iteration.iteration_node -> core.variables.variables + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.variables + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.variables.segments + core.workflow.nodes.list_operator.node -> core.variables + core.workflow.nodes.list_operator.node -> core.variables.segments + core.workflow.nodes.llm.node -> core.variables + core.workflow.nodes.loop.loop_node -> core.variables + core.workflow.nodes.parameter_extractor.entities -> core.variables.types + core.workflow.nodes.parameter_extractor.exc -> core.variables.types + core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.variables.types + core.workflow.nodes.tool.tool_node -> core.variables.segments + core.workflow.nodes.tool.tool_node -> core.variables.variables + core.workflow.nodes.trigger_webhook.node -> core.variables.types + core.workflow.nodes.trigger_webhook.node -> core.variables.variables + core.workflow.nodes.variable_aggregator.entities -> core.variables.types + core.workflow.nodes.variable_aggregator.variable_aggregator_node -> core.variables.segments + core.workflow.nodes.variable_assigner.common.helpers -> core.variables + core.workflow.nodes.variable_assigner.common.helpers -> core.variables.consts + core.workflow.nodes.variable_assigner.common.helpers -> core.variables.types + core.workflow.nodes.variable_assigner.v1.node -> core.variables + core.workflow.nodes.variable_assigner.v2.helpers -> core.variables + core.workflow.nodes.variable_assigner.v2.node -> core.variables + core.workflow.nodes.variable_assigner.v2.node -> core.variables.consts + core.workflow.runtime.graph_runtime_state_protocol -> core.variables.segments + core.workflow.runtime.read_only_wrappers -> core.variables.segments + core.workflow.runtime.variable_pool -> core.variables + core.workflow.runtime.variable_pool -> core.variables.consts + core.workflow.runtime.variable_pool -> core.variables.segments + core.workflow.runtime.variable_pool -> core.variables.variables + core.workflow.utils.condition.processor -> core.variables + core.workflow.utils.condition.processor -> core.variables.segments + core.workflow.variable_loader -> core.variables + core.workflow.variable_loader -> core.variables.consts + core.workflow.workflow_type_encoder -> core.variables + core.workflow.graph_engine.manager -> extensions.ext_redis + core.workflow.nodes.agent.agent_node -> extensions.ext_database + core.workflow.nodes.datasource.datasource_node -> extensions.ext_database + core.workflow.nodes.knowledge_index.knowledge_index_node -> extensions.ext_database + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_database + core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_redis + core.workflow.nodes.llm.file_saver -> extensions.ext_database + core.workflow.nodes.llm.llm_utils -> extensions.ext_database + core.workflow.nodes.llm.node -> extensions.ext_database + core.workflow.nodes.tool.tool_node -> extensions.ext_database + core.workflow.workflow_entry -> extensions.otel.runtime + core.workflow.nodes.agent.agent_node -> models + core.workflow.nodes.base.node -> models.enums + core.workflow.nodes.llm.llm_utils -> models.provider_ids + core.workflow.nodes.llm.node -> models.model + core.workflow.workflow_entry -> models.enums + core.workflow.nodes.agent.agent_node -> services + core.workflow.nodes.tool.tool_node -> services + [importlinter:contract:rsc] name = RSC type = layers diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 0157521ae9..34d02a1e51 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -9,13 +9,13 @@ from core.app.entities.app_invoke_entities import ( InvokeFrom, RagPipelineGenerateEntity, ) +from core.app.workflow.node_factory import DifyNodeFactory from core.variables.variables import RAGPipelineVariable, RAGPipelineVariableInput from core.workflow.entities.graph_init_params import GraphInitParams from core.workflow.enums import WorkflowType from core.workflow.graph import Graph from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.graph_events import GraphEngineEvent, GraphRunFailedEvent -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository from core.workflow.runtime import GraphRuntimeState, VariablePool diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 7adf3504ac..2ca153f835 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -25,6 +25,7 @@ from core.app.entities.queue_entities import ( QueueWorkflowStartedEvent, QueueWorkflowSucceededEvent, ) +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.graph import Graph from core.workflow.graph_engine.layers.base import GraphEngineLayer @@ -53,7 +54,6 @@ from core.workflow.graph_events import ( ) from core.workflow.graph_events.graph import GraphRunAbortedEvent from core.workflow.nodes import NodeType -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable diff --git a/api/core/app/workflow/__init__.py b/api/core/app/workflow/__init__.py new file mode 100644 index 0000000000..172ee5d703 --- /dev/null +++ b/api/core/app/workflow/__init__.py @@ -0,0 +1,3 @@ +from .node_factory import DifyNodeFactory + +__all__ = ["DifyNodeFactory"] diff --git a/api/core/workflow/nodes/node_factory.py b/api/core/app/workflow/node_factory.py similarity index 98% rename from api/core/workflow/nodes/node_factory.py rename to api/core/app/workflow/node_factory.py index 5c04e5110f..e0a0059a38 100644 --- a/api/core/workflow/nodes/node_factory.py +++ b/api/core/app/workflow/node_factory.py @@ -15,6 +15,7 @@ from core.workflow.nodes.base.node import Node from core.workflow.nodes.code.code_node import CodeNode from core.workflow.nodes.code.limits import CodeNodeLimits from core.workflow.nodes.http_request.node import HttpRequestNode +from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING from core.workflow.nodes.protocols import FileManagerProtocol, HttpClientProtocol from core.workflow.nodes.template_transform.template_renderer import ( CodeExecutorJinja2TemplateRenderer, @@ -23,8 +24,6 @@ from core.workflow.nodes.template_transform.template_renderer import ( from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode from libs.typing import is_str, is_str_dict -from .node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING - if TYPE_CHECKING: from core.workflow.entities import GraphInitParams from core.workflow.runtime import GraphRuntimeState diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index 55c8db40ea..63e0260341 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -469,12 +469,8 @@ class Node(Generic[NodeDataT]): import core.workflow.nodes as _nodes_pkg for _, _modname, _ in pkgutil.walk_packages(_nodes_pkg.__path__, _nodes_pkg.__name__ + "."): - # Avoid importing modules that depend on the registry to prevent circular imports - # e.g. node_factory imports node_mapping which builds the mapping here. - if _modname in { - "core.workflow.nodes.node_factory", - "core.workflow.nodes.node_mapping", - }: + # Avoid importing modules that depend on the registry to prevent circular imports. + if _modname == "core.workflow.nodes.node_mapping": continue importlib.import_module(_modname) diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py index 569a4196fb..ced996e7e0 100644 --- a/api/core/workflow/nodes/iteration/iteration_node.py +++ b/api/core/workflow/nodes/iteration/iteration_node.py @@ -588,11 +588,11 @@ class IterationNode(LLMUsageTrackingMixin, Node[IterationNodeData]): def _create_graph_engine(self, index: int, item: object): # Import dependencies + from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.graph import Graph from core.workflow.graph_engine import GraphEngine from core.workflow.graph_engine.command_channels import InMemoryChannel - from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState # Create GraphInitParams from node attributes diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index 1f9fc8a115..07d05966cc 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/api/core/workflow/nodes/loop/loop_node.py @@ -413,11 +413,11 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]): def _create_graph_engine(self, start_at: datetime, root_node_id: str): # Import dependencies + from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.graph import Graph from core.workflow.graph_engine import GraphEngine from core.workflow.graph_engine.command_channels import InMemoryChannel - from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState # Create GraphInitParams from node attributes diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index ee37314721..c7bcc66c8b 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -7,6 +7,7 @@ from typing import Any from configs import dify_config from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.file.models import File from core.workflow.constants import ENVIRONMENT_VARIABLE_NODE_ID from core.workflow.entities import GraphInitParams @@ -19,7 +20,6 @@ from core.workflow.graph_engine.protocols.command_channel import CommandChannel from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent from core.workflow.nodes import NodeType from core.workflow.nodes.base.node import Node -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable diff --git a/api/tests/integration_tests/workflow/nodes/test_code.py b/api/tests/integration_tests/workflow/nodes/test_code.py index 9b0bd6275b..1a9d69b2d2 100644 --- a/api/tests/integration_tests/workflow/nodes/test_code.py +++ b/api/tests/integration_tests/workflow/nodes/test_code.py @@ -5,13 +5,13 @@ import pytest from configs import dify_config from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph from core.workflow.node_events import NodeRunResult from core.workflow.nodes.code.code_node import CodeNode from core.workflow.nodes.code.limits import CodeNodeLimits -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from models.enums import UserFrom diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index d814da8ec7..1bcac3b5fe 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -5,11 +5,11 @@ from urllib.parse import urlencode import pytest from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph from core.workflow.nodes.http_request.node import HttpRequestNode -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from models.enums import UserFrom diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index d268c5da22..c361bfcc6f 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -5,13 +5,13 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.llm_generator.output_parser.structured_output import _parse_structured_output from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph from core.workflow.node_events import StreamCompletedEvent from core.workflow.nodes.llm.node import LLMNode -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from extensions.ext_database import db diff --git a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py index 654db59bec..7445699a86 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -4,11 +4,11 @@ import uuid from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.model_runtime.entities import AssistantPromptMessage from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable diff --git a/api/tests/integration_tests/workflow/nodes/test_template_transform.py b/api/tests/integration_tests/workflow/nodes/test_template_transform.py index 3bcb9a3a34..bc03ce1b96 100644 --- a/api/tests/integration_tests/workflow/nodes/test_template_transform.py +++ b/api/tests/integration_tests/workflow/nodes/test_template_transform.py @@ -4,10 +4,10 @@ import uuid import pytest from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable diff --git a/api/tests/integration_tests/workflow/nodes/test_tool.py b/api/tests/integration_tests/workflow/nodes/test_tool.py index d666f0ebe2..cfbef52c93 100644 --- a/api/tests/integration_tests/workflow/nodes/test_tool.py +++ b/api/tests/integration_tests/workflow/nodes/test_tool.py @@ -3,12 +3,12 @@ import uuid from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.tools.utils.configuration import ToolParameterConfigurationManager from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph from core.workflow.node_events import StreamCompletedEvent -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.tool.tool_node import ToolNode from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py index 6e9a432745..170445225b 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py @@ -7,9 +7,9 @@ requiring external services (LLM, Agent, Tool, Knowledge Retrieval, HTTP Request from typing import TYPE_CHECKING, Any +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.enums import NodeType from core.workflow.nodes.base.node import Node -from core.workflow.nodes.node_factory import DifyNodeFactory from .test_mock_nodes import ( MockAgentNode, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_streaming_workflow.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_streaming_workflow.py index b76fe42fce..e8cd665107 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_streaming_workflow.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_streaming_workflow.py @@ -13,6 +13,7 @@ from unittest.mock import patch from uuid import uuid4 from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus from core.workflow.graph import Graph @@ -26,7 +27,6 @@ from core.workflow.graph_events import ( ) from core.workflow.node_events import NodeRunResult, StreamCompletedEvent from core.workflow.nodes.llm.node import LLMNode -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from models.enums import UserFrom diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py index 08f7b00a33..10ac1206fb 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py @@ -19,6 +19,7 @@ from functools import lru_cache from pathlib import Path from typing import Any +from core.app.workflow.node_factory import DifyNodeFactory from core.tools.utils.yaml_utils import _load_yaml_file from core.variables import ( ArrayNumberVariable, @@ -38,7 +39,6 @@ from core.workflow.graph_events import ( GraphRunStartedEvent, GraphRunSucceededEvent, ) -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable diff --git a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py index 98d9560e64..1e95ec1970 100644 --- a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py +++ b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py @@ -3,11 +3,11 @@ import uuid from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.workflow.entities import GraphInitParams from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph from core.workflow.nodes.answer.answer_node import AnswerNode -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from extensions.ext_database import db diff --git a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index dc7175f964..d700888c2f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, Mock import pytest from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.file import File, FileTransferMethod, FileType from core.variables import ArrayFileSegment from core.workflow.entities import GraphInitParams @@ -12,7 +13,6 @@ from core.workflow.enums import WorkflowNodeExecutionStatus from core.workflow.graph import Graph from core.workflow.nodes.if_else.entities import IfElseNodeData from core.workflow.nodes.if_else.if_else_node import IfElseNode -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from core.workflow.utils.condition.entities import Condition, SubCondition, SubVariableCondition diff --git a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py index 1df75380af..d4b7a017f9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py +++ b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py @@ -3,11 +3,11 @@ import uuid from uuid import uuid4 from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.variables import ArrayStringVariable, StringVariable from core.workflow.entities import GraphInitParams from core.workflow.graph import Graph from core.workflow.graph_events.node import NodeRunSucceededEvent -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.variable_assigner.common import helpers as common_helpers from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode from core.workflow.nodes.variable_assigner.v1.node_data import WriteMode diff --git a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_variable_assigner_v2.py b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_variable_assigner_v2.py index 353d56fe25..b08f9c37b4 100644 --- a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_variable_assigner_v2.py +++ b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_variable_assigner_v2.py @@ -3,10 +3,10 @@ import uuid from uuid import uuid4 from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.workflow.node_factory import DifyNodeFactory from core.variables import ArrayStringVariable from core.workflow.entities import GraphInitParams from core.workflow.graph import Graph -from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode from core.workflow.nodes.variable_assigner.v2.enums import InputType, Operation from core.workflow.runtime import GraphRuntimeState, VariablePool From b9f718005c03a89a5be11054ff1e7e5c8b42d78c Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 22 Jan 2026 18:16:37 +0800 Subject: [PATCH 19/43] feat: frontend part of support try apps (#31287) Co-authored-by: CodingOnStar Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../app/configuration/config-var/index.tsx | 4 +- .../app/configuration/config-var/var-item.tsx | 2 +- .../app/configuration/config-vision/index.tsx | 88 +++-- .../config/agent/agent-tools/index.tsx | 15 +- .../app/configuration/config/config-audio.tsx | 22 +- .../configuration/config/config-document.tsx | 22 +- .../app/configuration/config/index.tsx | 26 +- .../dataset-config/card-item/index.spec.tsx | 2 +- .../dataset-config/card-item/index.tsx | 36 +- .../configuration/dataset-config/index.tsx | 62 +-- .../configuration/debug/chat-user-input.tsx | 7 +- .../text-generation-item.tsx | 3 +- .../debug/debug-with-single-model/index.tsx | 2 + .../app/configuration/debug/index.tsx | 86 ++-- .../prompt-value-panel/index.tsx | 20 +- .../create-app-dialog/app-card/index.spec.tsx | 1 + .../app/create-app-dialog/app-card/index.tsx | 23 +- web/app/components/app/log/list.tsx | 3 +- .../app/text-generate/item/index.tsx | 23 +- web/app/components/apps/index.spec.tsx | 59 ++- web/app/components/apps/index.tsx | 130 ++++++- web/app/components/apps/list.tsx | 15 +- web/app/components/apps/new-app-card.tsx | 11 +- .../components/base/action-button/index.tsx | 9 +- web/app/components/base/alert.tsx | 59 +++ web/app/components/base/audio-btn/audio.ts | 4 +- web/app/components/base/carousel/index.tsx | 227 +++++++++++ .../chat/chat-with-history/chat-wrapper.tsx | 14 +- .../chat/chat-with-history/hooks.spec.tsx | 41 +- .../base/chat/chat-with-history/hooks.tsx | 27 +- .../base/chat/chat/answer/index.tsx | 2 +- .../chat/chat/answer/suggested-questions.tsx | 10 +- .../base/chat/chat/chat-input-area/index.tsx | 16 +- .../chat/chat/chat-input-area/operation.tsx | 8 +- web/app/components/base/chat/chat/context.tsx | 10 +- web/app/components/base/chat/chat/index.tsx | 19 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 22 +- .../base/chat/embedded-chatbot/context.tsx | 4 + .../base/chat/embedded-chatbot/hooks.spec.tsx | 41 +- .../base/chat/embedded-chatbot/hooks.tsx | 83 +++- .../base/chat/embedded-chatbot/index.tsx | 7 +- .../embedded-chatbot/inputs-form/index.tsx | 6 +- .../inputs-form/view-form-dropdown.tsx | 2 +- .../new-feature-panel/feature-bar.tsx | 14 +- .../file-uploader-in-chat-input/index.tsx | 6 + .../text-generation-image-uploader.tsx | 6 +- web/app/components/base/tab-header/index.tsx | 7 +- web/app/components/base/voice-input/index.tsx | 4 +- .../explore/app-card/index.spec.tsx | 1 + web/app/components/explore/app-card/index.tsx | 28 +- .../explore/app-list/index.spec.tsx | 13 +- web/app/components/explore/app-list/index.tsx | 71 +++- .../components/explore/banner/banner-item.tsx | 187 +++++++++ web/app/components/explore/banner/banner.tsx | 94 +++++ .../explore/banner/indicator-button.tsx | 112 ++++++ web/app/components/explore/category.tsx | 2 +- web/app/components/explore/index.tsx | 14 + .../explore/installed-app/index.tsx | 5 +- .../explore/sidebar/app-nav-item/index.tsx | 2 +- .../components/explore/sidebar/index.spec.tsx | 7 +- web/app/components/explore/sidebar/index.tsx | 56 ++- .../explore/sidebar/no-apps/index.tsx | 24 ++ .../sidebar/no-apps/no-web-apps-dark.png | Bin 0 -> 22064 bytes .../sidebar/no-apps/no-web-apps-light.png | Bin 0 -> 21852 bytes .../explore/sidebar/no-apps/style.module.css | 7 + .../explore/try-app/app-info/index.tsx | 95 +++++ .../try-app/app-info/use-get-requirements.ts | 78 ++++ .../components/explore/try-app/app/chat.tsx | 104 +++++ .../components/explore/try-app/app/index.tsx | 44 +++ .../explore/try-app/app/text-generation.tsx | 262 +++++++++++++ web/app/components/explore/try-app/index.tsx | 74 ++++ .../try-app/preview/basic-app-preview.tsx | 367 ++++++++++++++++++ .../try-app/preview/flow-app-preview.tsx | 39 ++ .../explore/try-app/preview/index.tsx | 25 ++ web/app/components/explore/try-app/tab.tsx | 37 ++ .../share/text-generation/index.tsx | 17 +- .../share/text-generation/result/index.tsx | 28 +- .../share/text-generation/run-once/index.tsx | 22 +- .../components/share/text-generation/types.ts | 19 + .../components/before-run-form/bool-input.tsx | 3 + web/app/components/workflow/types.ts | 1 + .../workflow/workflow-preview/index.tsx | 5 +- web/context/app-list-context.ts | 19 + web/context/debug-configuration.ts | 2 + web/context/explore-context.ts | 15 +- web/contract/console/try-app.ts | 56 +++ web/contract/router.ts | 7 + web/eslint-suppressions.json | 23 +- web/i18n/ar-TN/explore.json | 4 - web/i18n/de-DE/explore.json | 7 - web/i18n/en-US/common.json | 1 + web/i18n/en-US/explore.json | 27 +- web/i18n/es-ES/explore.json | 7 - web/i18n/fa-IR/explore.json | 7 - web/i18n/fr-FR/explore.json | 7 - web/i18n/hi-IN/explore.json | 7 - web/i18n/id-ID/explore.json | 7 - web/i18n/it-IT/explore.json | 7 - web/i18n/ja-JP/common.json | 1 + web/i18n/ja-JP/explore.json | 27 +- web/i18n/ko-KR/explore.json | 7 - web/i18n/pl-PL/explore.json | 7 - web/i18n/pt-BR/explore.json | 7 - web/i18n/ro-RO/explore.json | 7 - web/i18n/ru-RU/explore.json | 7 - web/i18n/sl-SI/explore.json | 7 - web/i18n/th-TH/explore.json | 7 - web/i18n/tr-TR/explore.json | 7 - web/i18n/uk-UA/explore.json | 7 - web/i18n/vi-VN/explore.json | 7 - web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hans/explore.json | 27 +- web/i18n/zh-Hant/explore.json | 7 - web/models/app.ts | 14 + web/models/debug.ts | 1 + web/models/explore.ts | 1 + web/models/share.ts | 3 +- web/models/try-app.ts | 21 + web/package.json | 2 + web/pnpm-lock.yaml | 40 ++ web/service/debug.ts | 20 +- web/service/explore.ts | 7 + web/service/share.ts | 165 ++++---- web/service/try-app.ts | 26 ++ web/service/use-explore.ts | 17 +- web/service/use-share.spec.tsx | 36 +- web/service/use-share.ts | 23 +- web/service/use-try-app.ts | 44 +++ web/tsconfig.json | 5 +- web/types/feature.ts | 4 + 130 files changed, 3233 insertions(+), 685 deletions(-) create mode 100644 web/app/components/base/alert.tsx create mode 100644 web/app/components/base/carousel/index.tsx create mode 100644 web/app/components/explore/banner/banner-item.tsx create mode 100644 web/app/components/explore/banner/banner.tsx create mode 100644 web/app/components/explore/banner/indicator-button.tsx create mode 100644 web/app/components/explore/sidebar/no-apps/index.tsx create mode 100644 web/app/components/explore/sidebar/no-apps/no-web-apps-dark.png create mode 100644 web/app/components/explore/sidebar/no-apps/no-web-apps-light.png create mode 100644 web/app/components/explore/sidebar/no-apps/style.module.css create mode 100644 web/app/components/explore/try-app/app-info/index.tsx create mode 100644 web/app/components/explore/try-app/app-info/use-get-requirements.ts create mode 100644 web/app/components/explore/try-app/app/chat.tsx create mode 100644 web/app/components/explore/try-app/app/index.tsx create mode 100644 web/app/components/explore/try-app/app/text-generation.tsx create mode 100644 web/app/components/explore/try-app/index.tsx create mode 100644 web/app/components/explore/try-app/preview/basic-app-preview.tsx create mode 100644 web/app/components/explore/try-app/preview/flow-app-preview.tsx create mode 100644 web/app/components/explore/try-app/preview/index.tsx create mode 100644 web/app/components/explore/try-app/tab.tsx create mode 100644 web/app/components/share/text-generation/types.ts create mode 100644 web/context/app-list-context.ts create mode 100644 web/contract/console/try-app.ts create mode 100644 web/models/try-app.ts create mode 100644 web/service/try-app.ts create mode 100644 web/service/use-try-app.ts diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 1a8810f7cd..4d9a4e480f 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -271,9 +271,9 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar
)} {hasVar && ( -
+
{ onPromptVariablesChange?.(list.map(item => item.variable)) }} handle=".handle" diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx index 1fc21e3d33..b26249dac8 100644 --- a/web/app/components/app/configuration/config-var/var-item.tsx +++ b/web/app/components/app/configuration/config-var/var-item.tsx @@ -39,7 +39,7 @@ const VarItem: FC = ({ const [isDeleting, setIsDeleting] = useState(false) return ( -
+
{canDrag && ( diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index bc313b9ac1..481e6b5ab6 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' @@ -10,14 +11,17 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho import { Vision } from '@/app/components/base/icons/src/vender/features' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' +import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { SupportUploadFileTypes } from '@/app/components/workflow/types' // import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import ConfigContext from '@/context/debug-configuration' +import { Resolution } from '@/types/app' +import { cn } from '@/utils/classnames' import ParamConfig from './param-config' const ConfigVision: FC = () => { const { t } = useTranslation() - const { isShowVisionConfig, isAllowVideoUpload } = useContext(ConfigContext) + const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext) const file = useFeatures(s => s.features.file) const featuresStore = useFeaturesStore() @@ -54,7 +58,7 @@ const ConfigVision: FC = () => { setFeatures(newFeatures) }, [featuresStore, isAllowVideoUpload]) - if (!isShowVisionConfig) + if (!isShowVisionConfig || (readonly && !isImageEnabled)) return null return ( @@ -75,37 +79,55 @@ const ConfigVision: FC = () => { />
- {/*
-
{t('appDebug.vision.visionSettings.resolution')}
- - {t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => ( -
{item}
- ))} -
- } - /> -
*/} - {/*
- handleChange(Resolution.high)} - /> - handleChange(Resolution.low)} - /> -
*/} - -
- + {readonly + ? ( + <> +
+
{t('vision.visionSettings.resolution', { ns: 'appDebug' })}
+ + {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
{item}
+ ))} +
+ )} + /> +
+
+ + +
+ + ) + : ( + <> + +
+ + + )} +
) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 7139ba66e0..486c0a8ac9 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -40,7 +40,7 @@ type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection } const AgentTools: FC = () => { const { t } = useTranslation() const [isShowChooseTool, setIsShowChooseTool] = useState(false) - const { modelConfig, setModelConfig } = useContext(ConfigContext) + const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext) const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() @@ -168,10 +168,10 @@ const AgentTools: FC = () => { {tools.filter(item => !!item.enabled).length} / {tools.length} -  +   {t('agent.tools.enabled', { ns: 'appDebug' })}
- {tools.length < MAX_TOOLS_NUM && ( + {tools.length < MAX_TOOLS_NUM && !readonly && ( <>
{
)} > -
+
{tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => (
{ > {getProviderShowName(item)} {item.tool_label} - {!item.isDeleted && ( + {!item.isDeleted && !readonly && ( @@ -259,7 +259,7 @@ const AgentTools: FC = () => {
)} - {!item.isDeleted && ( + {!item.isDeleted && !readonly && (
{!item.notAuthor && ( { {!item.notAuthor && ( { const newModelConfig = produce(modelConfig, (draft) => { @@ -312,6 +312,7 @@ const AgentTools: FC = () => { {item.notAuthor && (
-
-
- -
+ {!readonly && ( +
+
+ +
+ )}
) } diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 3f192fd401..7d48c1582a 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -17,7 +17,7 @@ const ConfigDocument: FC = () => { const { t } = useTranslation() const file = useFeatures(s => s.features.file) const featuresStore = useFeaturesStore() - const { isShowDocumentConfig } = useContext(ConfigContext) + const { isShowDocumentConfig, readonly } = useContext(ConfigContext) const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false @@ -45,7 +45,7 @@ const ConfigDocument: FC = () => { setFeatures(newFeatures) }, [featuresStore]) - if (!isShowDocumentConfig) + if (!isShowDocumentConfig || (readonly && !isDocumentEnabled)) return null return ( @@ -65,14 +65,16 @@ const ConfigDocument: FC = () => { )} />
-
-
- -
+ {!readonly && ( +
+
+ +
+ )}
) } diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index f208b99e59..3e2b201172 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -18,6 +18,7 @@ import ConfigDocument from './config-document' const Config: FC = () => { const { + readonly, mode, isAdvancedMode, modelModeType, @@ -27,6 +28,7 @@ const Config: FC = () => { modelConfig, setModelConfig, setPrevPromptConfig, + dataSets, } = useContext(ConfigContext) const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode) const formattingChangedDispatcher = useFormattingChangedDispatcher() @@ -65,19 +67,27 @@ const Config: FC = () => { promptTemplate={promptTemplate} promptVariables={promptVariables} onChange={handlePromptChange} + readonly={readonly} /> {/* Variables */} - + {!(readonly && promptVariables.length === 0) && ( + + )} {/* Dataset */} - - + {!(readonly && dataSets.length === 0) && ( + + )} {/* Tools */} - {isAgent && ( + {isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && ( )} @@ -88,7 +98,7 @@ const Config: FC = () => { {/* Chat History */} - {isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && ( + {!readonly && isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && ( { expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' })) }) await waitFor(() => { - expect(screen.getByText('Mock settings modal')).not.toBeVisible() + expect(screen.queryByText('Mock settings modal')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.tsx index 00d3f6d6ad..a5ad3312ec 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/index.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/index.tsx @@ -30,6 +30,7 @@ const Item: FC = ({ config, onSave, onRemove, + readonly = false, editable = true, }) => { const media = useBreakpoints() @@ -56,6 +57,7 @@ const Item: FC = ({
@@ -70,7 +72,7 @@ const Item: FC = ({
{ - editable && ( + editable && !readonly && ( { e.stopPropagation() @@ -81,14 +83,18 @@ const Item: FC = ({ ) } - onRemove(config.id)} - state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} - onMouseEnter={() => setIsDeleting(true)} - onMouseLeave={() => setIsDeleting(false)} - > - - + { + !readonly && ( + onRemove(config.id)} + state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} + onMouseEnter={() => setIsDeleting(true)} + onMouseLeave={() => setIsDeleting(false)} + > + + + ) + }
{ !!config.indexing_technique && ( @@ -107,11 +113,13 @@ const Item: FC = ({ ) } setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl"> - setShowSettingsModal(false)} - onSave={handleSave} - /> + {showSettingsModal && ( + setShowSettingsModal(false)} + onSave={handleSave} + /> + )}
) diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 309c6e7ddb..6de77cad9e 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -30,6 +30,7 @@ import { import { useSelector as useAppContextSelector } from '@/context/app-context' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' +import { cn } from '@/utils/classnames' import { hasEditPermissionForDataset } from '@/utils/permission' import FeaturePanel from '../base/feature-panel' import OperationBtn from '../base/operation-btn' @@ -38,7 +39,11 @@ import CardItem from './card-item' import ContextVar from './context-var' import ParamsConfig from './params-config' -const DatasetConfig: FC = () => { +type Props = { + readonly?: boolean + hideMetadataFilter?: boolean +} +const DatasetConfig: FC = ({ readonly, hideMetadataFilter }) => { const { t } = useTranslation() const userProfile = useAppContextSelector(s => s.userProfile) const { @@ -259,17 +264,19 @@ const DatasetConfig: FC = () => { className="mt-2" title={t('feature.dataSet.title', { ns: 'appDebug' })} headerRight={( -
- {!isAgent && } - -
+ !readonly && ( +
+ {!isAgent && } + +
+ ) )} hasHeaderBottomBorder={!hasData} noBodySpacing > {hasData ? ( -
+
{formattedDataset.map(item => ( { onRemove={onRemove} onSave={handleSave} editable={item.editable} + readonly={readonly} /> ))}
@@ -287,27 +295,29 @@ const DatasetConfig: FC = () => {
)} -
- item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)} - availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)} - /> -
+ {!hideMetadataFilter && ( +
+ item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)} + availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)} + /> +
+ )} - {mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( + {!readonly && mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( { const { t } = useTranslation() - const { modelConfig, setInputs } = useContext(ConfigContext) + const { modelConfig, setInputs, readonly } = useContext(ConfigContext) const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { return key && key?.trim() && name && name?.trim() @@ -88,6 +88,7 @@ const ChatUserInput = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'paragraph' && ( @@ -96,6 +97,7 @@ const ChatUserInput = ({ placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} onChange={(e) => { handleInputValueChange(key, e.target.value) }} + readOnly={readonly} /> )} {type === 'select' && ( @@ -105,6 +107,7 @@ const ChatUserInput = ({ onSelect={(i) => { handleInputValueChange(key, i.value as string) }} items={(options || []).map(i => ({ name: i, value: i }))} allowSearch={false} + disabled={readonly} /> )} {type === 'number' && ( @@ -115,6 +118,7 @@ const ChatUserInput = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'checkbox' && ( @@ -123,6 +127,7 @@ const ChatUserInput = ({ value={!!inputs[key]} required={required} onChange={(value) => { handleInputValueChange(key, value) }} + readonly={readonly} /> )} diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx index d7918e7ad6..eb18ca45b1 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx @@ -15,6 +15,7 @@ import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/ import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useProviderContext } from '@/context/provider-context' +import { AppSourceType } from '@/service/share' import { promptVariablesToUserInputsForm } from '@/utils/model-config' import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types' @@ -130,11 +131,11 @@ const TextGenerationItem: FC = ({ return ( { const { userProfile } = useAppContext() const { + readonly, modelConfig, appId, inputs, @@ -150,6 +151,7 @@ const DebugWithSingleModel = ( return ( = ({ }) => { const { t } = useTranslation() const { + readonly, appId, mode, modelModeType, @@ -416,25 +418,33 @@ const Debug: FC = ({ } {mode !== AppModeEnum.COMPLETION && ( <> - - - - - - {varList.length > 0 && ( -
+ { + !readonly && ( - setExpanded(!expanded)}> - + + + - {expanded &&
} -
- )} + ) + } + + { + varList.length > 0 && ( +
+ + !readonly && setExpanded(!expanded)}> + + + + {expanded &&
} +
+ ) + } )}
@@ -444,19 +454,21 @@ const Debug: FC = ({
)} - {mode === AppModeEnum.COMPLETION && ( - - )} + { + mode === AppModeEnum.COMPLETION && ( + + ) + } { debugWithMultipleModel && ( @@ -510,12 +522,12 @@ const Debug: FC = ({
= ({
) } - {isShowFormattingChangeConfirm && ( - - )} - {!isAPIKeySet && ()} + { + isShowFormattingChangeConfirm && ( + + ) + } + {!isAPIKeySet && !readonly && ()} ) } diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 613efb8710..e695616810 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -40,7 +40,7 @@ const PromptValuePanel: FC = ({ onVisionFilesChange, }) => { const { t } = useTranslation() - const { modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext) + const { readonly, modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext) const [userInputFieldCollapse, setUserInputFieldCollapse] = useState(false) const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { return key && key?.trim() && name && name?.trim() @@ -78,12 +78,12 @@ const PromptValuePanel: FC = ({ if (isAdvancedMode) { if (modelModeType === ModelModeType.chat) - return chatPromptConfig.prompt.every(({ text }) => !text) + return chatPromptConfig?.prompt.every(({ text }) => !text) return !completionPromptConfig.prompt?.text } else { return !modelConfig.configs.prompt_template } - }, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) + }, [chatPromptConfig?.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) const handleInputValueChange = (key: string, value: string | boolean) => { if (!(key in promptVariableObj)) @@ -142,6 +142,7 @@ const PromptValuePanel: FC = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'paragraph' && ( @@ -150,6 +151,7 @@ const PromptValuePanel: FC = ({ placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} onChange={(e) => { handleInputValueChange(key, e.target.value) }} + readOnly={readonly} /> )} {type === 'select' && ( @@ -160,6 +162,7 @@ const PromptValuePanel: FC = ({ items={(options || []).map(i => ({ name: i, value: i }))} allowSearch={false} bgClassName="bg-gray-50" + disabled={readonly} /> )} {type === 'number' && ( @@ -170,6 +173,7 @@ const PromptValuePanel: FC = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'checkbox' && ( @@ -178,6 +182,7 @@ const PromptValuePanel: FC = ({ value={!!inputs[key]} required={required} onChange={(value) => { handleInputValueChange(key, value) }} + readonly={readonly} /> )} @@ -196,6 +201,7 @@ const PromptValuePanel: FC = ({ url: fileItem.url, upload_file_id: fileItem.fileId, })))} + disabled={readonly} /> @@ -204,12 +210,12 @@ const PromptValuePanel: FC = ({ )} {!userInputFieldCollapse && (
- + {canNotRun && (
diff --git a/web/app/components/app/create-app-dialog/app-card/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx index e1f9773ac3..82e4fb8f94 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx @@ -10,6 +10,7 @@ vi.mock('@heroicons/react/20/solid', () => ({ })) const mockApp: App = { + can_trial: true, app: { id: 'test-app-id', mode: AppModeEnum.CHAT, diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 695faed5e0..15cfbd5411 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -1,9 +1,14 @@ 'use client' import type { App } from '@/models/explore' import { PlusIcon } from '@heroicons/react/20/solid' +import { RiInformation2Line } from '@remixicon/react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useContextSelector } from 'use-context-selector' import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' +import AppListContext from '@/context/app-list-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { cn } from '@/utils/classnames' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' @@ -20,6 +25,14 @@ const AppCard = ({ }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app + const { systemFeatures } = useGlobalPublicStore() + const isTrialApp = app.can_trial && systemFeatures.enable_trial_app + const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) + const showTryAPPPanel = useCallback((appId: string) => { + return () => { + setShowTryAppPanel?.(true, { appId, app }) + } + }, [setShowTryAppPanel, app.category]) return (
@@ -51,11 +64,17 @@ const AppCard = ({
{canCreate && ( )} diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 410953ccf7..5197a02bb3 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -39,6 +39,7 @@ import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' +import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' @@ -638,12 +639,12 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
item.from_source === 'admin')} onFeedback={feedback => onFeedback(detail.message.id, feedback)} diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 78f4f426f5..c39282a022 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -29,7 +29,7 @@ import { Markdown } from '@/app/components/base/markdown' import NewAudioButton from '@/app/components/base/new-audio-button' import Toast from '@/app/components/base/toast' import { fetchTextGenerationMessage } from '@/service/debug' -import { fetchMoreLikeThis, updateFeedback } from '@/service/share' +import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share' import { cn } from '@/utils/classnames' import ResultTab from './result-tab' @@ -53,7 +53,7 @@ export type IGenerationItemProps = { onFeedback?: (feedback: FeedbackType) => void onSave?: (messageId: string) => void isMobile?: boolean - isInstalledApp: boolean + appSourceType: AppSourceType installedAppId?: string taskId?: string controlClearMoreLikeThis?: number @@ -87,7 +87,7 @@ const GenerationItem: FC = ({ onSave, depth = 1, isMobile, - isInstalledApp, + appSourceType, installedAppId, taskId, controlClearMoreLikeThis, @@ -100,6 +100,7 @@ const GenerationItem: FC = ({ const { t } = useTranslation() const params = useParams() const isTop = depth === 1 + const isTryApp = appSourceType === AppSourceType.tryApp const [completionRes, setCompletionRes] = useState('') const [childMessageId, setChildMessageId] = useState(null) const [childFeedback, setChildFeedback] = useState({ @@ -113,7 +114,7 @@ const GenerationItem: FC = ({ const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) const handleFeedback = async (childFeedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId) + await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId) setChildFeedback(childFeedback) } @@ -131,7 +132,7 @@ const GenerationItem: FC = ({ onSave, isShowTextToSpeech, isMobile, - isInstalledApp, + appSourceType, installedAppId, controlClearMoreLikeThis, isWorkflow, @@ -145,7 +146,7 @@ const GenerationItem: FC = ({ return } startQuerying() - const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId) + const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId) setCompletionRes(res.answer) setChildFeedback({ rating: null, @@ -310,7 +311,7 @@ const GenerationItem: FC = ({ )} {/* action buttons */}
- {!isInWebApp && !isInstalledApp && !isResponding && ( + {!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && (
@@ -319,12 +320,12 @@ const GenerationItem: FC = ({
)}
- {moreLikeThis && ( + {moreLikeThis && !isTryApp && ( )} - {isShowTextToSpeech && ( + {isShowTextToSpeech && !isTryApp && ( = ({ )} - {isInWebApp && !isWorkflow && ( + {isInWebApp && !isWorkflow && !isTryApp && ( { onSave?.(messageId as string) }}> )}
- {(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && ( + {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
{!feedback?.rating && ( <> diff --git a/web/app/components/apps/index.spec.tsx b/web/app/components/apps/index.spec.tsx index c3dc39955d..c77c1bdb01 100644 --- a/web/app/components/apps/index.spec.tsx +++ b/web/app/components/apps/index.spec.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import * as React from 'react' @@ -22,6 +24,15 @@ vi.mock('@/app/education-apply/hooks', () => ({ }, })) +vi.mock('@/hooks/use-import-dsl', () => ({ + useImportDSL: () => ({ + handleImportDSL: vi.fn(), + handleImportDSLConfirm: vi.fn(), + versions: [], + isFetching: false, + }), +})) + // Mock List component vi.mock('./list', () => ({ default: () => { @@ -30,6 +41,25 @@ vi.mock('./list', () => ({ })) describe('Apps', () => { + const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const renderWithClient = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return { + queryClient, + ...render(ui, { wrapper }), + } + } + beforeEach(() => { vi.clearAllMocks() documentTitleCalls = [] @@ -38,17 +68,17 @@ describe('Apps', () => { describe('Rendering', () => { it('should render without crashing', () => { - render() + renderWithClient() expect(screen.getByTestId('apps-list')).toBeInTheDocument() }) it('should render List component', () => { - render() + renderWithClient() expect(screen.getByText('Apps List')).toBeInTheDocument() }) it('should have correct container structure', () => { - const { container } = render() + const { container } = renderWithClient() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col') }) @@ -56,19 +86,19 @@ describe('Apps', () => { describe('Hooks', () => { it('should call useDocumentTitle with correct title', () => { - render() + renderWithClient() expect(documentTitleCalls).toContain('common.menus.apps') }) it('should call useEducationInit', () => { - render() + renderWithClient() expect(educationInitCalls).toBeGreaterThan(0) }) }) describe('Integration', () => { it('should render full component tree', () => { - render() + renderWithClient() // Verify container exists expect(screen.getByTestId('apps-list')).toBeInTheDocument() @@ -79,23 +109,32 @@ describe('Apps', () => { }) it('should handle multiple renders', () => { - const { rerender } = render() + const queryClient = createQueryClient() + const { rerender } = render( + + + , + ) expect(screen.getByTestId('apps-list')).toBeInTheDocument() - rerender() + rerender( + + + , + ) expect(screen.getByTestId('apps-list')).toBeInTheDocument() }) }) describe('Styling', () => { it('should have overflow-y-auto class', () => { - const { container } = render() + const { container } = renderWithClient() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('overflow-y-auto') }) it('should have background styling', () => { - const { container } = render() + const { container } = renderWithClient() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('bg-background-body') }) diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index b151df1e1f..255bfbf9c5 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -1,7 +1,17 @@ 'use client' +import type { CreateAppModalProps } from '../explore/create-app-modal' +import type { CurrentTryAppParams } from '@/context/explore-context' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEducationInit } from '@/app/education-apply/hooks' +import AppListContext from '@/context/app-list-context' import useDocumentTitle from '@/hooks/use-document-title' +import { useImportDSL } from '@/hooks/use-import-dsl' +import { DSLImportMode } from '@/models/app' +import { fetchAppDetail } from '@/service/explore' +import DSLConfirmModal from '../app/create-from-dsl-modal/dsl-confirm-modal' +import CreateAppModal from '../explore/create-app-modal' +import TryApp from '../explore/try-app' import List from './list' const Apps = () => { @@ -10,10 +20,124 @@ const Apps = () => { useDocumentTitle(t('menus.apps', { ns: 'common' })) useEducationInit() + const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const currApp = currentTryAppParams?.app + const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) + const hideTryAppPanel = useCallback(() => { + setIsShowTryAppPanel(false) + }, []) + const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => { + if (showTryAppPanel) + setCurrentTryAppParams(params) + else + setCurrentTryAppParams(undefined) + setIsShowTryAppPanel(showTryAppPanel) + } + const [isShowCreateModal, setIsShowCreateModal] = useState(false) + + const handleShowFromTryApp = useCallback(() => { + setIsShowCreateModal(true) + }, []) + + const [controlRefreshList, setControlRefreshList] = useState(0) + const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0) + const onSuccess = useCallback(() => { + setControlRefreshList(prev => prev + 1) + setControlHideCreateFromTemplatePanel(prev => prev + 1) + }, []) + + const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) + + const { + handleImportDSL, + handleImportDSLConfirm, + versions, + isFetching, + } = useImportDSL() + + const onConfirmDSL = useCallback(async () => { + await handleImportDSLConfirm({ + onSuccess, + }) + }, [handleImportDSLConfirm, onSuccess]) + + const onCreate: CreateAppModalProps['onConfirm'] = async ({ + name, + icon_type, + icon, + icon_background, + description, + }) => { + hideTryAppPanel() + + const { export_data } = await fetchAppDetail( + currApp?.app.id as string, + ) + const payload = { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: export_data, + name, + icon_type, + icon, + icon_background, + description, + } + await handleImportDSL(payload, { + onSuccess: () => { + setIsShowCreateModal(false) + }, + onPending: () => { + setShowDSLConfirmModal(true) + }, + }) + } + return ( -
- -
+ +
+ + {isShowTryAppPanel && ( + + )} + + { + showDSLConfirmModal && ( + setShowDSLConfirmModal(false)} + onConfirm={onConfirmDSL} + confirmDisabled={isFetching} + /> + ) + } + + {isShowCreateModal && ( + setIsShowCreateModal(false)} + /> + )} +
+
) } diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 8a236fe260..6bf79b7338 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -1,5 +1,6 @@ 'use client' +import type { FC } from 'react' import { RiApps2Line, RiDragDropLine, @@ -53,7 +54,12 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro ssr: false, }) -const List = () => { +type Props = { + controlRefreshList?: number +} +const List: FC = ({ + controlRefreshList = 0, +}) => { const { t } = useTranslation() const { systemFeatures } = useGlobalPublicStore() const router = useRouter() @@ -110,6 +116,13 @@ const List = () => { refetch, } = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator }) + useEffect(() => { + if (controlRefreshList > 0) { + refetch() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlRefreshList]) + const anchorRef = useRef(null) const options = [ { value: 'all', text: t('types.all', { ns: 'app' }), icon: }, diff --git a/web/app/components/apps/new-app-card.tsx b/web/app/components/apps/new-app-card.tsx index bfa7af3892..868da0dcb5 100644 --- a/web/app/components/apps/new-app-card.tsx +++ b/web/app/components/apps/new-app-card.tsx @@ -6,10 +6,12 @@ import { useSearchParams, } from 'next/navigation' import * as React from 'react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useContextSelector } from 'use-context-selector' import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' +import AppListContext from '@/context/app-list-context' import { useProviderContext } from '@/context/provider-context' import { cn } from '@/utils/classnames' @@ -55,6 +57,13 @@ const CreateAppCard = ({ return undefined }, [dslUrl]) + const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel) + useEffect(() => { + if (controlHideCreateFromTemplatePanel > 0) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setShowNewAppTemplateDialog(false) + }, [controlHideCreateFromTemplatePanel]) + return (
{ +const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, disabled, ...props }: ActionButtonProps) => { return ( + ) + }, +) +CarouselPrevious.displayName = 'CarouselPrevious' + +const CarouselNext = React.forwardRef( + ({ children, ...props }, ref) => { + const { scrollNext, canScrollNext } = useCarousel() + + return ( + + ) + }, +) +CarouselNext.displayName = 'CarouselNext' + +const CarouselDot = React.forwardRef( + ({ children, ...props }, ref) => { + const { api, selectedIndex } = useCarousel() + + return api?.slideNodes().map((_, index) => { + return ( + + ) + }) + }, +) +CarouselDot.displayName = 'CarouselDot' + +const CarouselPlugins = { + Autoplay, +} + +Carousel.Content = CarouselContent +Carousel.Item = CarouselItem +Carousel.Previous = CarouselPrevious +Carousel.Next = CarouselNext +Carousel.Dot = CarouselDot +Carousel.Plugin = CarouselPlugins + +export { Carousel, useCarousel } diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 25ff39370f..38a3f6c6b2 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -12,6 +12,7 @@ import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested import { Markdown } from '@/app/components/base/markdown' import { InputVarType } from '@/app/components/workflow/types' import { + AppSourceType, fetchSuggestedQuestions, getUrl, stopChatMessageResponding, @@ -52,6 +53,11 @@ const ChatWrapper = () => { initUserVariables, } = useChatWithHistoryContext() + const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp + + // Semantic variable for better code readability + const isHistoryConversation = !!currentConversationId + const appConfig = useMemo(() => { const config = appParams || {} @@ -79,7 +85,7 @@ const ChatWrapper = () => { inputsForm: inputsForms, }, appPrevChatTree, - taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId), + taskId => stopChatMessageResponding('', taskId, appSourceType, appId), clearChatList, setClearChatList, ) @@ -138,11 +144,11 @@ const ChatWrapper = () => { } handleSend( - getUrl('chat-messages', isInstalledApp, appId || ''), + getUrl('chat-messages', appSourceType, appId || ''), data, { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId), - onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, isPublicAPI: !isInstalledApp, }, ) diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx index f6a8f25cbb..399f16716d 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import { ToastProvider } from '@/app/components/base/toast' import { + AppSourceType, fetchChatList, fetchConversations, generationConversationName, @@ -49,20 +50,24 @@ vi.mock('../utils', async () => { } }) -vi.mock('@/service/share', () => ({ - fetchChatList: vi.fn(), - fetchConversations: vi.fn(), - generationConversationName: vi.fn(), - fetchAppInfo: vi.fn(), - fetchAppMeta: vi.fn(), - fetchAppParams: vi.fn(), - getAppAccessModeByAppCode: vi.fn(), - delConversation: vi.fn(), - pinConversation: vi.fn(), - renameConversation: vi.fn(), - unpinConversation: vi.fn(), - updateFeedback: vi.fn(), -})) +vi.mock('@/service/share', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), + delConversation: vi.fn(), + pinConversation: vi.fn(), + renameConversation: vi.fn(), + unpinConversation: vi.fn(), + updateFeedback: vi.fn(), + } +}) const mockFetchConversations = vi.mocked(fetchConversations) const mockFetchChatList = vi.mocked(fetchChatList) @@ -162,13 +167,13 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) + expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, true, 100) }) await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) + expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, false, 100) }) await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') }) await waitFor(() => { expect(result.current.pinnedConversationList).toEqual(pinnedData.data) @@ -204,7 +209,7 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') + expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-new') }) await waitFor(() => { expect(result.current.conversationList[0]).toEqual(generatedConversation) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index ed1981b530..ad1de38d07 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -27,6 +27,7 @@ import { useWebAppStore } from '@/context/web-app-context' import { useAppFavicon } from '@/hooks/use-app-favicon' import { changeLanguage } from '@/i18n-config/client' import { + AppSourceType, delConversation, pinConversation, renameConversation, @@ -72,6 +73,7 @@ function getFormattedChatList(messages: any[]) { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) + const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp const appInfo = useWebAppStore(s => s.appInfo) const appParams = useWebAppStore(s => s.appParams) const appMeta = useWebAppStore(s => s.appMeta) @@ -177,7 +179,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [currentConversationId, newConversationId]) const { data: appPinnedConversationData } = useShareConversations({ - isInstalledApp, + appSourceType, appId, pinned: true, limit: 100, @@ -190,7 +192,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { data: appConversationData, isLoading: appConversationDataLoading, } = useShareConversations({ - isInstalledApp, + appSourceType, appId, pinned: false, limit: 100, @@ -204,7 +206,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { isLoading: appChatListDataLoading, } = useShareChatList({ conversationId: chatShouldReloadKey, - isInstalledApp, + appSourceType, appId, }, { enabled: !!chatShouldReloadKey, @@ -334,10 +336,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const { data: newConversation } = useShareConversationName({ conversationId: newConversationId, - isInstalledApp, + appSourceType, appId, }, { refetchOnWindowFocus: false, + enabled: !!newConversationId, }) const [originConversationList, setOriginConversationList] = useState([]) useEffect(() => { @@ -462,16 +465,16 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [invalidateShareConversations]) const handlePinConversation = useCallback(async (conversationId: string) => { - await pinConversation(isInstalledApp, appId, conversationId) + await pinConversation(appSourceType, appId, conversationId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) handleUpdateConversationList() - }, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) + }, [appSourceType, appId, notify, t, handleUpdateConversationList]) const handleUnpinConversation = useCallback(async (conversationId: string) => { - await unpinConversation(isInstalledApp, appId, conversationId) + await unpinConversation(appSourceType, appId, conversationId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) handleUpdateConversationList() - }, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) + }, [appSourceType, appId, notify, t, handleUpdateConversationList]) const [conversationDeleting, setConversationDeleting] = useState(false) const handleDeleteConversation = useCallback(async ( @@ -485,7 +488,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { try { setConversationDeleting(true) - await delConversation(isInstalledApp, appId, conversationId) + await delConversation(appSourceType, appId, conversationId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) onSuccess() } @@ -520,7 +523,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setConversationRenaming(true) try { - await renameConversation(isInstalledApp, appId, conversationId, newName) + await renameConversation(appSourceType, appId, conversationId, newName) notify({ type: 'success', @@ -550,9 +553,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [handleConversationIdInfoChange, invalidateShareConversations]) const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId) + await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) - }, [isInstalledApp, appId, t, notify]) + }, [appSourceType, appId, t, notify]) return { isInstalledApp, diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 9f1efa3ae0..da46f47c61 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -150,7 +150,7 @@ const Answer: FC = ({ data={workflowProcess} item={item} hideProcessDetail={hideProcessDetail} - readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined} + readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined} /> ) } diff --git a/web/app/components/base/chat/chat/answer/suggested-questions.tsx b/web/app/components/base/chat/chat/answer/suggested-questions.tsx index 019ed78348..ce997a49b6 100644 --- a/web/app/components/base/chat/chat/answer/suggested-questions.tsx +++ b/web/app/components/base/chat/chat/answer/suggested-questions.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { ChatItem } from '../../types' import { memo } from 'react' +import { cn } from '@/utils/classnames' import { useChatContext } from '../context' type SuggestedQuestionsProps = { @@ -9,7 +10,7 @@ type SuggestedQuestionsProps = { const SuggestedQuestions: FC = ({ item, }) => { - const { onSend } = useChatContext() + const { onSend, readonly } = useChatContext() const { isOpeningStatement, @@ -24,8 +25,11 @@ const SuggestedQuestions: FC = ({ {suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
onSend?.(question)} + className={cn( + 'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', + readonly && 'pointer-events-none opacity-50', + )} + onClick={() => !readonly && onSend?.(question)} > {question}
diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 192f46fb23..9de52cb18c 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -5,6 +5,7 @@ import type { } from '../../types' import type { InputForm } from '../type' import type { FileUpload } from '@/app/components/base/features/types' +import { noop } from 'es-toolkit/function' import { decode } from 'html-entities' import Recorder from 'js-audio-recorder' import { @@ -30,6 +31,7 @@ import { useTextAreaHeight } from './hooks' import Operation from './operation' type ChatInputAreaProps = { + readonly?: boolean botName?: string showFeatureBar?: boolean showFileUpload?: boolean @@ -45,6 +47,7 @@ type ChatInputAreaProps = { disabled?: boolean } const ChatInputArea = ({ + readonly, botName, showFeatureBar, showFileUpload, @@ -170,6 +173,7 @@ const ChatInputArea = ({ const operation = (
{ @@ -239,7 +244,14 @@ const ChatInputArea = ({ ) }
- {showFeatureBar && } + {showFeatureBar && ( + + )} ) } diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx index 27e5bf6cad..5bce827754 100644 --- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx @@ -8,6 +8,7 @@ import { RiMicLine, RiSendPlane2Fill, } from '@remixicon/react' +import { noop } from 'es-toolkit/function' import { memo } from 'react' import ActionButton from '@/app/components/base/action-button' import Button from '@/app/components/base/button' @@ -15,6 +16,7 @@ import { FileUploaderInChatInput } from '@/app/components/base/file-uploader' import { cn } from '@/utils/classnames' type OperationProps = { + readonly?: boolean fileConfig?: FileUpload speechToTextConfig?: EnableType onShowVoiceInput?: () => void @@ -23,6 +25,7 @@ type OperationProps = { ref?: Ref } const Operation: FC = ({ + readonly, ref, fileConfig, speechToTextConfig, @@ -41,11 +44,12 @@ const Operation: FC = ({ ref={ref} >
- {fileConfig?.enabled && } + {fileConfig?.enabled && } { speechToTextConfig?.enabled && ( @@ -56,7 +60,7 @@ const Operation: FC = ({ + { + !hideEditEntrance && ( + + ) + }
)}
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx index 1ae328d67a..08bb8b45d1 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx @@ -13,21 +13,27 @@ import FileFromLinkOrLocal from '../file-from-link-or-local' type FileUploaderInChatInputProps = { fileConfig: FileUpload + readonly?: boolean } const FileUploaderInChatInput = ({ fileConfig, + readonly, }: FileUploaderInChatInputProps) => { const renderTrigger = useCallback((open: boolean) => { return ( ) }, []) + if (readonly) + return renderTrigger(false) + return ( = ({ type TextGenerationImageUploaderProps = { settings: VisionSettings onFilesChange: (files: ImageFile[]) => void + disabled?: boolean } const TextGenerationImageUploader: FC = ({ settings, onFilesChange, + disabled, }) => { const { t } = useTranslation() @@ -93,7 +95,7 @@ const TextGenerationImageUploader: FC = ({ const localUpload = ( = settings.number_limits} + disabled={files.length >= settings.number_limits || disabled} limit={+settings.image_file_size_limit!} > { @@ -115,7 +117,7 @@ const TextGenerationImageUploader: FC = ({ const urlUpload = ( = settings.number_limits} + disabled={files.length >= settings.number_limits || disabled} /> ) diff --git a/web/app/components/base/tab-header/index.tsx b/web/app/components/base/tab-header/index.tsx index e762e23232..6ba6a354a3 100644 --- a/web/app/components/base/tab-header/index.tsx +++ b/web/app/components/base/tab-header/index.tsx @@ -16,6 +16,8 @@ export type ITabHeaderProps = { items: Item[] value: string itemClassName?: string + itemWrapClassName?: string + activeItemClassName?: string onChange: (value: string) => void } @@ -23,6 +25,8 @@ const TabHeader: FC = ({ items, value, itemClassName, + itemWrapClassName, + activeItemClassName, onChange, }) => { const renderItem = ({ id, name, icon, extra, disabled }: Item) => ( @@ -30,8 +34,9 @@ const TabHeader: FC = ({ key={id} className={cn( 'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5', - id === value ? 'border-components-tab-active text-text-primary' : 'text-text-tertiary', + id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary', disabled && 'cursor-not-allowed opacity-30', + itemWrapClassName, )} onClick={() => !disabled && onChange(id)} > diff --git a/web/app/components/base/voice-input/index.tsx b/web/app/components/base/voice-input/index.tsx index 4fa2c774f4..52e3c754f8 100644 --- a/web/app/components/base/voice-input/index.tsx +++ b/web/app/components/base/voice-input/index.tsx @@ -8,7 +8,7 @@ import { useParams, usePathname } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' -import { audioToText } from '@/service/share' +import { AppSourceType, audioToText } from '@/service/share' import { cn } from '@/utils/classnames' import s from './index.module.css' import { convertToMp3 } from './utils' @@ -108,7 +108,7 @@ const VoiceInput = ({ } try { - const audioResponse = await audioToText(url, isPublic, formData) + const audioResponse = await audioToText(url, isPublic ? AppSourceType.webApp : AppSourceType.installedApp, formData) onConverted(audioResponse.text) onCancel() } diff --git a/web/app/components/explore/app-card/index.spec.tsx b/web/app/components/explore/app-card/index.spec.tsx index 769b317929..152eab92a9 100644 --- a/web/app/components/explore/app-card/index.spec.tsx +++ b/web/app/components/explore/app-card/index.spec.tsx @@ -10,6 +10,7 @@ vi.mock('../../app/type-selector', () => ({ })) const createApp = (overrides?: Partial): App => ({ + can_trial: true, app_id: 'app-id', description: 'App description', copyright: '2024', diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 0b6cd9920d..5d82ab65cc 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -1,8 +1,13 @@ 'use client' import type { App } from '@/models/explore' import { PlusIcon } from '@heroicons/react/20/solid' +import { RiInformation2Line } from '@remixicon/react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useContextSelector } from 'use-context-selector' import AppIcon from '@/app/components/base/app-icon' +import ExploreContext from '@/context/explore-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' import { AppTypeIcon } from '../../app/type-selector' @@ -23,8 +28,17 @@ const AppCard = ({ }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app + const { systemFeatures } = useGlobalPublicStore() + const isTrialApp = app.can_trial && systemFeatures.enable_trial_app + const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel) + const showTryAPPPanel = useCallback((appId: string) => { + return () => { + setShowTryAppPanel?.(true, { appId, app }) + } + }, [setShowTryAppPanel, app]) + return ( -
+
- {isExplore && canCreate && ( + {isExplore && (canCreate || isTrialApp) && ( )} diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx index a9e4feeba8..a87d5a2363 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -16,9 +16,13 @@ let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() -vi.mock('nuqs', () => ({ - useQueryState: () => [mockTabValue, mockSetTab], -})) +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryState: () => [mockTabValue, mockSetTab], + } +}) vi.mock('ahooks', async () => { const actual = await vi.importActual('ahooks') @@ -102,6 +106,7 @@ const createApp = (overrides: Partial = {}): App => ({ description: overrides.app?.description ?? 'Alpha description', use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, }, + can_trial: true, app_id: overrides.app_id ?? 'app-1', description: overrides.description ?? 'Alpha description', copyright: overrides.copyright ?? '', @@ -127,6 +132,8 @@ const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => setInstalledApps: vi.fn(), isFetchingInstalledApps: false, setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), }} > diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 5b318b780b..1749bde76a 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -7,14 +7,17 @@ import { useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' +import { useContext, useContextSelector } from 'use-context-selector' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' +import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' import AppCard from '@/app/components/explore/app-card' +import Banner from '@/app/components/explore/banner/banner' import Category from '@/app/components/explore/category' import CreateAppModal from '@/app/components/explore/create-app-modal' import ExploreContext from '@/context/explore-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode, @@ -22,6 +25,7 @@ import { import { fetchAppDetail } from '@/service/explore' import { useExploreAppList } from '@/service/use-explore' import { cn } from '@/utils/classnames' +import TryApp from '../try-app' import s from './style.module.css' type AppsProps = { @@ -32,12 +36,19 @@ const Apps = ({ onSuccess, }: AppsProps) => { const { t } = useTranslation() + const { systemFeatures } = useGlobalPublicStore() const { hasEditPermission } = useContext(ExploreContext) const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' }) const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') + const hasFilterCondition = !!keywords + const handleResetFilter = useCallback(() => { + setKeywords('') + setSearchKeywords('') + }, []) + const { run: handleSearch } = useDebounceFn(() => { setSearchKeywords(keywords) }, { wait: 500 }) @@ -84,6 +95,18 @@ const Apps = ({ isFetching, } = useImportDSL() const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) + + const isShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.isShowTryAppPanel) + const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel) + const hideTryAppPanel = useCallback(() => { + setShowTryAppPanel(false) + }, [setShowTryAppPanel]) + const appParams = useContextSelector(ExploreContext, ctx => ctx.currentApp) + const handleShowFromTryApp = useCallback(() => { + setCurrApp(appParams?.app || null) + setIsShowCreateModal(true) + }, [appParams?.app]) + const onCreate: CreateAppModalProps['onConfirm'] = async ({ name, icon_type, @@ -91,6 +114,8 @@ const Apps = ({ icon_background, description, }) => { + hideTryAppPanel() + const { export_data } = await fetchAppDetail( currApp?.app.id as string, ) @@ -137,22 +162,24 @@ const Apps = ({ 'flex h-full flex-col border-l-[0.5px] border-divider-regular', )} > - -
-
{t('apps.title', { ns: 'explore' })}
-
{t('apps.description', { ns: 'explore' })}
-
- + {systemFeatures.enable_explore_banner && ( +
+ +
+ )}
- +
+
{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}
+ {hasFilterCondition && ( + <> +
+ + + )} +
+
+ +
+
) } + + {isShowTryAppPanel && ( + + )}
) } diff --git a/web/app/components/explore/banner/banner-item.tsx b/web/app/components/explore/banner/banner-item.tsx new file mode 100644 index 0000000000..5ce810bafb --- /dev/null +++ b/web/app/components/explore/banner/banner-item.tsx @@ -0,0 +1,187 @@ +/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */ +import type { FC } from 'react' +import type { Banner } from '@/models/app' +import { RiArrowRightLine } from '@remixicon/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useCarousel } from '@/app/components/base/carousel' +import { cn } from '@/utils/classnames' +import { IndicatorButton } from './indicator-button' + +type BannerItemProps = { + banner: Banner + autoplayDelay: number + isPaused?: boolean +} + +const RESPONSIVE_BREAKPOINT = 1200 +const MAX_RESPONSIVE_WIDTH = 600 +const INDICATOR_WIDTH = 20 +const INDICATOR_GAP = 8 +const MIN_VIEW_MORE_WIDTH = 480 + +export const BannerItem: FC = ({ banner, autoplayDelay, isPaused = false }) => { + const { t } = useTranslation() + const { api, selectedIndex } = useCarousel() + const { category, title, description, 'img-src': imgSrc } = banner.content + + const [resetKey, setResetKey] = useState(0) + const textAreaRef = useRef(null) + const [maxWidth, setMaxWidth] = useState(undefined) + + const slideInfo = useMemo(() => { + const slides = api?.slideNodes() ?? [] + const totalSlides = slides.length + const nextIndex = totalSlides > 0 ? (selectedIndex + 1) % totalSlides : 0 + return { slides, totalSlides, nextIndex } + }, [api, selectedIndex]) + + const indicatorsWidth = useMemo(() => { + const count = slideInfo.totalSlides + if (count === 0) + return 0 + // Calculate: indicator buttons + gaps + extra spacing (3 * 20px for divider and padding) + return (count + 2) * INDICATOR_WIDTH + (count - 1) * INDICATOR_GAP + }, [slideInfo.totalSlides]) + + const viewMoreStyle = useMemo(() => { + if (!maxWidth) + return undefined + return { + maxWidth: `${maxWidth}px`, + minWidth: indicatorsWidth ? `${Math.min(maxWidth - indicatorsWidth, MIN_VIEW_MORE_WIDTH)}px` : undefined, + } + }, [maxWidth, indicatorsWidth]) + + const responsiveStyle = useMemo( + () => (maxWidth !== undefined ? { maxWidth: `${maxWidth}px` } : undefined), + [maxWidth], + ) + + const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), []) + + useEffect(() => { + const updateMaxWidth = () => { + if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) { + const textAreaWidth = textAreaRef.current.offsetWidth + setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH)) + } + else { + setMaxWidth(undefined) + } + } + + updateMaxWidth() + + const resizeObserver = new ResizeObserver(updateMaxWidth) + if (textAreaRef.current) + resizeObserver.observe(textAreaRef.current) + + window.addEventListener('resize', updateMaxWidth) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', updateMaxWidth) + } + }, []) + + useEffect(() => { + incrementResetKey() + }, [selectedIndex, incrementResetKey]) + + const handleBannerClick = useCallback(() => { + incrementResetKey() + if (banner.link) + window.open(banner.link, '_blank', 'noopener,noreferrer') + }, [banner.link, incrementResetKey]) + + const handleIndicatorClick = useCallback((index: number) => { + incrementResetKey() + api?.scrollTo(index) + }, [api, incrementResetKey]) + + return ( +
+ {/* Left content area */} +
+
+ {/* Text section */} +
+ {/* Title area */} +
+

+ {category} +

+

+ {title} +

+
+ {/* Description area */} +
+

+ {description} +

+
+
+ + {/* Actions section */} +
+ {/* View more button */} +
+
+ +
+ + {t('banner.viewMore', { ns: 'explore' })} + +
+ +
+ {/* Slide navigation indicators */} +
+ {slideInfo.slides.map((_: unknown, index: number) => ( + handleIndicatorClick(index)} + /> + ))} +
+
+
+
+
+
+ + {/* Right image area */} +
+ {title} +
+
+ ) +} diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx new file mode 100644 index 0000000000..4ec0efdb2b --- /dev/null +++ b/web/app/components/explore/banner/banner.tsx @@ -0,0 +1,94 @@ +import type { FC } from 'react' +import * as React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Carousel } from '@/app/components/base/carousel' +import { useLocale } from '@/context/i18n' +import { useGetBanners } from '@/service/use-explore' +import Loading from '../../base/loading' +import { BannerItem } from './banner-item' + +const AUTOPLAY_DELAY = 5000 +const MIN_LOADING_HEIGHT = 168 +const RESIZE_DEBOUNCE_DELAY = 50 + +const LoadingState: FC = () => ( +
+ +
+) + +const Banner: FC = () => { + const locale = useLocale() + const { data: banners, isLoading, isError } = useGetBanners(locale) + const [isHovered, setIsHovered] = useState(false) + const [isResizing, setIsResizing] = useState(false) + const resizeTimerRef = useRef(null) + + const enabledBanners = useMemo( + () => banners?.filter(banner => banner.status === 'enabled') ?? [], + [banners], + ) + + const isPaused = isHovered || isResizing + + // Handle window resize to pause animation + useEffect(() => { + const handleResize = () => { + setIsResizing(true) + + if (resizeTimerRef.current) + clearTimeout(resizeTimerRef.current) + + resizeTimerRef.current = setTimeout(() => { + setIsResizing(false) + }, RESIZE_DEBOUNCE_DELAY) + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + if (resizeTimerRef.current) + clearTimeout(resizeTimerRef.current) + } + }, []) + + if (isLoading) + return + + if (isError || enabledBanners.length === 0) + return null + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {enabledBanners.map(banner => ( + + + + ))} + + + ) +} + +export default React.memo(Banner) diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx new file mode 100644 index 0000000000..332dae53ba --- /dev/null +++ b/web/app/components/explore/banner/indicator-button.tsx @@ -0,0 +1,112 @@ +/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */ +import type { FC } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { cn } from '@/utils/classnames' + +type IndicatorButtonProps = { + index: number + selectedIndex: number + isNextSlide: boolean + autoplayDelay: number + resetKey: number + isPaused?: boolean + onClick: () => void +} + +const PROGRESS_MAX = 100 +const DEGREES_PER_PERCENT = 3.6 + +export const IndicatorButton: FC = ({ + index, + selectedIndex, + isNextSlide, + autoplayDelay, + resetKey, + isPaused = false, + onClick, +}) => { + const [progress, setProgress] = useState(0) + const frameIdRef = useRef(undefined) + const startTimeRef = useRef(0) + + const isActive = index === selectedIndex + const shouldAnimate = !document.hidden && !isPaused + + useEffect(() => { + if (!isNextSlide) { + setProgress(0) + if (frameIdRef.current) + cancelAnimationFrame(frameIdRef.current) + return + } + + setProgress(0) + startTimeRef.current = Date.now() + + const animate = () => { + if (!document.hidden && !isPaused) { + const elapsed = Date.now() - startTimeRef.current + const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX) + setProgress(newProgress) + + if (newProgress < PROGRESS_MAX) + frameIdRef.current = requestAnimationFrame(animate) + } + else { + frameIdRef.current = requestAnimationFrame(animate) + } + } + + if (shouldAnimate) + frameIdRef.current = requestAnimationFrame(animate) + + return () => { + if (frameIdRef.current) + cancelAnimationFrame(frameIdRef.current) + } + }, [isNextSlide, autoplayDelay, resetKey, isPaused]) + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + onClick() + }, [onClick]) + + const progressDegrees = progress * DEGREES_PER_PERCENT + + return ( + + ) +} diff --git a/web/app/components/explore/category.tsx b/web/app/components/explore/category.tsx index 97a9ca92b3..47c2a4e3a7 100644 --- a/web/app/components/explore/category.tsx +++ b/web/app/components/explore/category.tsx @@ -29,7 +29,7 @@ const Category: FC = ({ const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn const itemClassName = (isSelected: boolean) => cn( - 'flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] font-medium leading-[18px] text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active', + 'system-sm-medium flex h-7 cursor-pointer items-center rounded-lg border border-transparent px-3 text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active', isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs', ) diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index 30132eea66..0b5e18a1de 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { CurrentTryAppParams } from '@/context/explore-context' import type { InstalledApp } from '@/models/explore' import { useRouter } from 'next/navigation' import * as React from 'react' @@ -41,6 +42,16 @@ const Explore: FC = ({ return router.replace('/datasets') }, [isCurrentWorkspaceDatasetOperator]) + const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) + const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => { + if (showTryAppPanel) + setCurrentTryAppParams(params) + else + setCurrentTryAppParams(undefined) + setIsShowTryAppPanel(showTryAppPanel) + } + return (
= ({ setInstalledApps, isFetchingInstalledApps, setIsFetchingInstalledApps, + currentApp: currentTryAppParams, + isShowTryAppPanel, + setShowTryAppPanel, } } > diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index def66c0260..7366057445 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { AccessMode } from '@/models/access-control' import type { AppData } from '@/models/share' import * as React from 'react' import { useEffect } from 'react' @@ -62,8 +63,8 @@ const InstalledApp: FC = ({ if (appMeta) updateWebAppMeta(appMeta) if (webAppAccessMode) - updateWebAppAccessMode(webAppAccessMode.accessMode) - updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) + updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode) + updateUserCanAccessApp(Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result)) }, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode]) if (appParamsError) { diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx index 3347efeb3f..08558578f6 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx @@ -56,7 +56,7 @@ export default function AppNavItem({ <>
-
{name}
+
{name}
e.stopPropagation()}> { setInstalledApps: vi.fn(), isFetchingInstalledApps: false, setIsFetchingInstalledApps: vi.fn(), - }} + } as unknown as IExplore} > , @@ -97,8 +98,8 @@ describe('SideBar', () => { renderWithContext(mockInstalledApps) // Assert - expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument() - expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument() expect(screen.getByText('My App')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 1257886165..3e9b664580 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -1,5 +1,7 @@ 'use client' import type { FC } from 'react' +import { RiAppsFill, RiExpandRightLine, RiLayoutLeft2Line } from '@remixicon/react' +import { useBoolean } from 'ahooks' import Link from 'next/link' import { useSelectedLayoutSegments } from 'next/navigation' import * as React from 'react' @@ -14,18 +16,7 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s import { cn } from '@/utils/classnames' import Toast from '../../base/toast' import Item from './app-nav-item' - -const SelectedDiscoveryIcon = () => ( - - - -) - -const DiscoveryIcon = () => ( - - - -) +import NoApps from './no-apps' export type IExploreSideBarProps = { controlUpdateInstalledApps: number @@ -45,6 +36,9 @@ const SideBar: FC = ({ const media = useBreakpoints() const isMobile = media === MediaType.mobile + const [isFold, { + toggle: toggleIsFold, + }] = useBoolean(false) const [showConfirm, setShowConfirm] = useState(false) const [currId, setCurrId] = useState('') @@ -84,22 +78,31 @@ const SideBar: FC = ({ const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length return ( -
+
- {isDiscoverySelected ? : } - {!isMobile &&
{t('sidebar.discovery', { ns: 'explore' })}
} +
+ +
+ {!isMobile && !isFold &&
{t('sidebar.title', { ns: 'explore' })}
}
+ + {installedApps.length === 0 && !isMobile && !isFold + && ( +
+ +
+ )} + {installedApps.length > 0 && ( -
-

{t('sidebar.workspace', { ns: 'explore' })}

+
+ {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

}
= ({ {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( = ({
)} + + {!isMobile && ( +
+ {isFold + ? + : ( + + )} +
+ )} + {showConfirm && ( { + const { t } = useTranslation() + const { theme } = useTheme() + return ( +
+
+
{t(`${i18nPrefix}.title`, { ns: 'explore' })}
+
{t(`${i18nPrefix}.description`, { ns: 'explore' })}
+ {t(`${i18nPrefix}.learnMore`, { ns: 'explore' })} +
+ ) +} +export default React.memo(NoApps) diff --git a/web/app/components/explore/sidebar/no-apps/no-web-apps-dark.png b/web/app/components/explore/sidebar/no-apps/no-web-apps-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e153686fcd09b6c7b38f5acf81d183279ad07e9e GIT binary patch literal 22064 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 f_V#?Dqp$bR3O<;%-AvA^WS$=HeP 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 void +} + +const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3' + +const AppInfo: FC = ({ + appId, + className, + category, + appDetail, + onCreate, +}) => { + const { t } = useTranslation() + const mode = appDetail?.mode + const { requirements } = useGetRequirements({ appDetail, appId }) + return ( +
+ {/* name and icon */} +
+
+ + +
+
+
+
{appDetail.name}
+
+
+ {mode === 'advanced-chat' &&
{t('types.advanced', { ns: 'app' }).toUpperCase()}
} + {mode === 'chat' &&
{t('types.chatbot', { ns: 'app' }).toUpperCase()}
} + {mode === 'agent-chat' &&
{t('types.agent', { ns: 'app' }).toUpperCase()}
} + {mode === 'workflow' &&
{t('types.workflow', { ns: 'app' }).toUpperCase()}
} + {mode === 'completion' &&
{t('types.completion', { ns: 'app' }).toUpperCase()}
} +
+
+
+ {appDetail.description && ( +
{appDetail.description}
+ )} + + + {category && ( +
+
{t('tryApp.category', { ns: 'explore' })}
+
{category}
+
+ )} + {requirements.length > 0 && ( +
+
{t('tryApp.requirements', { ns: 'explore' })}
+
+ {requirements.map(item => ( +
+
+
{item.name}
+
+ ))} +
+
+ )} + +
+ ) +} +export default React.memo(AppInfo) diff --git a/web/app/components/explore/try-app/app-info/use-get-requirements.ts b/web/app/components/explore/try-app/app-info/use-get-requirements.ts new file mode 100644 index 0000000000..976989be73 --- /dev/null +++ b/web/app/components/explore/try-app/app-info/use-get-requirements.ts @@ -0,0 +1,78 @@ +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' +import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' +import type { TryAppInfo } from '@/service/try-app' +import type { AgentTool } from '@/types/app' +import { uniqBy } from 'es-toolkit/compat' +import { BlockEnum } from '@/app/components/workflow/types' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { useGetTryAppFlowPreview } from '@/service/use-try-app' + +type Params = { + appDetail: TryAppInfo + appId: string +} + +type RequirementItem = { + name: string + iconUrl: string +} +const getIconUrl = (provider: string, tool: string) => { + return `${MARKETPLACE_API_PREFIX}/plugins/${provider}/${tool}/icon` +} + +const useGetRequirements = ({ appDetail, appId }: Params) => { + const isBasic = ['chat', 'completion', 'agent-chat'].includes(appDetail.mode) + const isAgent = appDetail.mode === 'agent-chat' + const isAdvanced = !isBasic + const { data: flowData } = useGetTryAppFlowPreview(appId, isBasic) + + const requirements: RequirementItem[] = [] + if (isBasic) { + const modelProviderAndName = appDetail.model_config.model.provider.split('/') + const name = appDetail.model_config.model.provider.split('/').pop() || '' + requirements.push({ + name, + iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]), + }) + } + if (isAgent) { + requirements.push(...appDetail.model_config.agent_mode.tools.filter(data => (data as AgentTool).enabled).map((data) => { + const tool = data as AgentTool + const modelProviderAndName = tool.provider_id.split('/') + return { + name: tool.tool_label, + iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]), + } + })) + } + if (isAdvanced && flowData && flowData?.graph?.nodes?.length > 0) { + const nodes = flowData.graph.nodes + const llmNodes = nodes.filter(node => node.data.type === BlockEnum.LLM) + requirements.push(...llmNodes.map((node) => { + const data = node.data as LLMNodeType + const modelProviderAndName = data.model.provider.split('/') + return { + name: data.model.name, + iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]), + } + })) + + const toolNodes = nodes.filter(node => node.data.type === BlockEnum.Tool) + requirements.push(...toolNodes.map((node) => { + const data = node.data as ToolNodeType + const toolProviderAndName = data.provider_id.split('/') + return { + name: data.tool_label, + iconUrl: getIconUrl(toolProviderAndName[0], toolProviderAndName[1]), + } + })) + } + + const uniqueRequirements = uniqBy(requirements, 'name') + + return { + requirements: uniqueRequirements, + } +} + +export default useGetRequirements diff --git a/web/app/components/explore/try-app/app/chat.tsx b/web/app/components/explore/try-app/app/chat.tsx new file mode 100644 index 0000000000..b6b4a76ad5 --- /dev/null +++ b/web/app/components/explore/try-app/app/chat.tsx @@ -0,0 +1,104 @@ +'use client' +import type { FC } from 'react' +import type { + EmbeddedChatbotContextValue, +} from '@/app/components/base/chat/embedded-chatbot/context' +import type { TryAppInfo } from '@/service/try-app' +import { RiResetLeftLine } from '@remixicon/react' +import { useBoolean } from 'ahooks' +import * as React from 'react' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' +import Alert from '@/app/components/base/alert' +import AppIcon from '@/app/components/base/app-icon' +import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' +import { + EmbeddedChatbotContext, +} from '@/app/components/base/chat/embedded-chatbot/context' +import { + useEmbeddedChatbot, +} from '@/app/components/base/chat/embedded-chatbot/hooks' +import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown' +import Tooltip from '@/app/components/base/tooltip' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { AppSourceType } from '@/service/share' +import { cn } from '@/utils/classnames' +import { useThemeContext } from '../../../base/chat/embedded-chatbot/theme/theme-context' + +type Props = { + appId: string + appDetail: TryAppInfo + className: string +} + +const TryApp: FC = ({ + appId, + appDetail, + className, +}) => { + const { t } = useTranslation() + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + const themeBuilder = useThemeContext() + const { removeConversationIdInfo, ...chatData } = useEmbeddedChatbot(AppSourceType.tryApp, appId) + const currentConversationId = chatData.currentConversationId + const inputsForms = chatData.inputsForms + useEffect(() => { + if (appId) + removeConversationIdInfo(appId) + }, [appId]) + const [isHideTryNotice, { + setTrue: hideTryNotice, + }] = useBoolean(false) + + const handleNewConversation = () => { + removeConversationIdInfo(appId) + chatData.handleNewConversation() + } + return ( + +
+
+
+ +
{appDetail.name}
+
+
+ {currentConversationId && ( + + + + + + )} + {currentConversationId && inputsForms.length > 0 && ( + + )} +
+
+
+ {!isHideTryNotice && ( + + )} + +
+
+
+ ) +} +export default React.memo(TryApp) diff --git a/web/app/components/explore/try-app/app/index.tsx b/web/app/components/explore/try-app/app/index.tsx new file mode 100644 index 0000000000..f5dc14510d --- /dev/null +++ b/web/app/components/explore/try-app/app/index.tsx @@ -0,0 +1,44 @@ +'use client' +import type { FC } from 'react' +import type { AppData } from '@/models/share' +import type { TryAppInfo } from '@/service/try-app' +import * as React from 'react' +import useDocumentTitle from '@/hooks/use-document-title' +import Chat from './chat' +import TextGeneration from './text-generation' + +type Props = { + appId: string + appDetail: TryAppInfo +} + +const TryApp: FC = ({ + appId, + appDetail, +}) => { + const mode = appDetail?.mode + const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) + const isCompletion = !isChat + + useDocumentTitle(appDetail?.site?.title || '') + return ( +
+ {isChat && ( + + )} + {isCompletion && ( + + )} +
+ ) +} +export default React.memo(TryApp) diff --git a/web/app/components/explore/try-app/app/text-generation.tsx b/web/app/components/explore/try-app/app/text-generation.tsx new file mode 100644 index 0000000000..3189e621e9 --- /dev/null +++ b/web/app/components/explore/try-app/app/text-generation.tsx @@ -0,0 +1,262 @@ +'use client' +import type { FC } from 'react' +import type { InputValueTypes, Task } from '../../../share/text-generation/types' +import type { MoreLikeThisConfig, PromptConfig, TextToSpeechConfig } from '@/models/debug' +import type { AppData, CustomConfigValueType, SiteInfo } from '@/models/share' +import type { VisionFile, VisionSettings } from '@/types/app' +import { useBoolean } from 'ahooks' +import { noop } from 'es-toolkit/function' +import * as React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Alert from '@/app/components/base/alert' +import AppIcon from '@/app/components/base/app-icon' +import Loading from '@/app/components/base/loading' +import Res from '@/app/components/share/text-generation/result' +import { TaskStatus } from '@/app/components/share/text-generation/types' +import { appDefaultIconBackground } from '@/config' +import { useWebAppStore } from '@/context/web-app-context' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { AppSourceType } from '@/service/share' +import { useGetTryAppParams } from '@/service/use-try-app' +import { Resolution, TransferMethod } from '@/types/app' +import { cn } from '@/utils/classnames' +import { userInputsFormToPromptVariables } from '@/utils/model-config' +import RunOnce from '../../../share/text-generation/run-once' + +type Props = { + appId: string + className?: string + isWorkflow?: boolean + appData: AppData | null +} + +const TextGeneration: FC = ({ + appId, + className, + isWorkflow, + appData, +}) => { + const { t } = useTranslation() + const media = useBreakpoints() + const isPC = media === MediaType.pc + + const [inputs, doSetInputs] = useState>({}) + const inputsRef = useRef>(inputs) + const setInputs = useCallback((newInputs: Record) => { + doSetInputs(newInputs) + inputsRef.current = newInputs + }, []) + + const updateAppInfo = useWebAppStore(s => s.updateAppInfo) + const { data: tryAppParams } = useGetTryAppParams(appId) + + const updateAppParams = useWebAppStore(s => s.updateAppParams) + const appParams = useWebAppStore(s => s.appParams) + const [siteInfo, setSiteInfo] = useState(null) + const [promptConfig, setPromptConfig] = useState(null) + const [customConfig, setCustomConfig] = useState | null>(null) + const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) + const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) + const [controlSend, setControlSend] = useState(0) + const [visionConfig, setVisionConfig] = useState({ + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + }) + const [completionFiles, setCompletionFiles] = useState([]) + const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false) + const showResultPanel = () => { + // fix: useClickAway hideResSidebar will close sidebar + setTimeout(() => { + doShowResultPanel() + }, 0) + } + + const handleSend = () => { + setControlSend(Date.now()) + showResultPanel() + } + + const [resultExisted, setResultExisted] = useState(false) + + useEffect(() => { + if (!appData) + return + updateAppInfo(appData) + }, [appData, updateAppInfo]) + + useEffect(() => { + if (!tryAppParams) + return + updateAppParams(tryAppParams) + }, [tryAppParams, updateAppParams]) + + useEffect(() => { + (async () => { + if (!appData || !appParams) + return + const { site: siteInfo, custom_config } = appData + setSiteInfo(siteInfo as SiteInfo) + setCustomConfig(custom_config) + + const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams + setVisionConfig({ + // legacy of image upload compatible + ...file_upload, + transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, + // legacy of image upload compatible + image_file_size_limit: appParams?.system_parameters.image_file_size_limit, + fileUploadConfig: appParams?.system_parameters, + // eslint-disable-next-line ts/no-explicit-any + } as any) + const prompt_variables = userInputsFormToPromptVariables(user_input_form) + setPromptConfig({ + prompt_template: '', // placeholder for future + prompt_variables, + } as PromptConfig) + setMoreLikeThisConfig(more_like_this) + setTextToSpeechConfig(text_to_speech) + })() + }, [appData, appParams]) + + const [isCompleted, setIsCompleted] = useState(false) + const handleCompleted = useCallback(() => { + setIsCompleted(true) + }, []) + const [isHideTryNotice, { + setTrue: hideTryNotice, + }] = useBoolean(false) + + const renderRes = (task?: Task) => ( + setResultExisted(true)} + /> + ) + + const renderResWrap = ( +
+
+ {isCompleted && !isHideTryNotice && ( + + )} + {renderRes()} +
+
+ ) + + if (!siteInfo || !promptConfig) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Left */} +
+ {/* Header */} +
+
+ +
{siteInfo.title}
+
+ {siteInfo.description && ( +
{siteInfo.description}
+ )} +
+ {/* form */} +
+ +
+
+ + {/* Result */} +
+ {!isPC && ( +
{ + if (isShowResultPanel) + hideResultPanel() + else + showResultPanel() + }} + > +
+
+ )} + {renderResWrap} +
+
+ ) +} + +export default React.memo(TextGeneration) diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx new file mode 100644 index 0000000000..b2e2b72140 --- /dev/null +++ b/web/app/components/explore/try-app/index.tsx @@ -0,0 +1,74 @@ +/* eslint-disable style/multiline-ternary */ +'use client' +import type { FC } from 'react' +import { RiCloseLine } from '@remixicon/react' +import * as React from 'react' +import { useState } from 'react' +import Loading from '@/app/components/base/loading' +import Modal from '@/app/components/base/modal/index' +import { useGetTryAppInfo } from '@/service/use-try-app' +import Button from '../../base/button' +import App from './app' +import AppInfo from './app-info' +import Preview from './preview' +import Tab, { TypeEnum } from './tab' + +type Props = { + appId: string + category?: string + onClose: () => void + onCreate: () => void +} + +const TryApp: FC = ({ + appId, + category, + onClose, + onCreate, +}) => { + const [type, setType] = useState(TypeEnum.TRY) + const { data: appDetail, isLoading } = useGetTryAppInfo(appId) + + return ( + + {isLoading ? ( +
+ +
+ ) : ( +
+
+ + +
+ {/* Main content */} +
+ {type === TypeEnum.TRY ? : } + +
+
+ )} +
+ ) +} +export default React.memo(TryApp) diff --git a/web/app/components/explore/try-app/preview/basic-app-preview.tsx b/web/app/components/explore/try-app/preview/basic-app-preview.tsx new file mode 100644 index 0000000000..6954546b2e --- /dev/null +++ b/web/app/components/explore/try-app/preview/basic-app-preview.tsx @@ -0,0 +1,367 @@ +/* eslint-disable ts/no-explicit-any */ +'use client' +import type { FC } from 'react' +import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelConfig } from '@/models/debug' +import type { ModelConfig as BackendModelConfig, PromptVariable } from '@/types/app' +import { noop } from 'es-toolkit/function' +import { clone } from 'es-toolkit/object' +import * as React from 'react' +import { useMemo, useState } from 'react' +import Config from '@/app/components/app/configuration/config' +import Debug from '@/app/components/app/configuration/debug' +import { FeaturesProvider } from '@/app/components/base/features' +import Loading from '@/app/components/base/loading' +import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import ConfigContext from '@/context/debug-configuration' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { PromptMode } from '@/models/debug' +import { useAllToolProviders } from '@/service/use-tools' +import { useGetTryAppDataSets, useGetTryAppInfo } from '@/service/use-try-app' +import { ModelModeType, Resolution, TransferMethod } from '@/types/app' +import { correctModelProvider, correctToolProvider } from '@/utils' +import { userInputsFormToPromptVariables } from '@/utils/model-config' +import { basePath } from '@/utils/var' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '../../../header/account-setting/model-provider-page/hooks' + +type Props = { + appId: string +} + +const defaultModelConfig = { + provider: 'langgenius/openai/openai', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.unset, + configs: { + prompt_template: '', + prompt_variables: [] as PromptVariable[], + }, + more_like_this: null, + opening_statement: '', + suggested_questions: [], + sensitive_word_avoidance: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + suggested_questions_after_answer: null, + retriever_resource: null, + annotation_reply: null, + dataSets: [], + agentConfig: DEFAULT_AGENT_SETTING, +} +const BasicAppPreview: FC = ({ + appId, +}) => { + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + + const { data: appDetail, isLoading: isLoadingAppDetail } = useGetTryAppInfo(appId) + const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = useAllToolProviders() + const collectionList = collectionListFromServer?.map((item) => { + return { + ...item, + icon: basePath && typeof item.icon == 'string' && !item.icon.includes(basePath) ? `${basePath}${item.icon}` : item.icon, + } + }) + const datasetIds = (() => { + if (isLoadingAppDetail) + return [] + const modelConfig = appDetail?.model_config + if (!modelConfig) + return [] + let datasets: any = null + + if (modelConfig.agent_mode?.tools?.find(({ dataset }: any) => dataset?.enabled)) + datasets = modelConfig.agent_mode?.tools.filter(({ dataset }: any) => dataset?.enabled) + // new dataset struct + else if (modelConfig.dataset_configs.datasets?.datasets?.length > 0) + datasets = modelConfig.dataset_configs?.datasets?.datasets + + if (datasets?.length && datasets?.length > 0) + return datasets.map(({ dataset }: any) => dataset.id) + + return [] + })() + const { data: dataSetData, isLoading: isLoadingDatasets } = useGetTryAppDataSets(appId, datasetIds) + const dataSets = dataSetData?.data || [] + const isLoading = isLoadingAppDetail || isLoadingDatasets || isLoadingToolProviders + + const modelConfig: ModelConfig = ((modelConfig?: BackendModelConfig) => { + if (isLoading || !modelConfig) + return defaultModelConfig + + const model = modelConfig.model + + const newModelConfig = { + provider: correctModelProvider(model.provider), + model_id: model.name, + mode: model.mode, + configs: { + prompt_template: modelConfig.pre_prompt || '', + prompt_variables: userInputsFormToPromptVariables( + [ + ...(modelConfig.user_input_form as any), + ...( + modelConfig.external_data_tools?.length + ? modelConfig.external_data_tools.map((item) => { + return { + external_data_tool: { + variable: item.variable as string, + label: item.label as string, + enabled: item.enabled, + type: item.type as string, + config: item.config, + required: true, + icon: item.icon, + icon_background: item.icon_background, + }, + } + }) + : [] + ), + ], + modelConfig.dataset_query_variable, + ), + }, + more_like_this: modelConfig.more_like_this, + opening_statement: modelConfig.opening_statement, + suggested_questions: modelConfig.suggested_questions, + sensitive_word_avoidance: modelConfig.sensitive_word_avoidance, + speech_to_text: modelConfig.speech_to_text, + text_to_speech: modelConfig.text_to_speech, + file_upload: modelConfig.file_upload, + suggested_questions_after_answer: modelConfig.suggested_questions_after_answer, + retriever_resource: modelConfig.retriever_resource, + annotation_reply: modelConfig.annotation_reply, + external_data_tools: modelConfig.external_data_tools, + dataSets, + agentConfig: appDetail?.mode === 'agent-chat' + // eslint-disable-next-line style/multiline-ternary + ? ({ + max_iteration: DEFAULT_AGENT_SETTING.max_iteration, + ...modelConfig.agent_mode, + // remove dataset + enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true + tools: modelConfig.agent_mode?.tools.filter((tool: any) => { + return !tool.dataset + }).map((tool: any) => { + const toolInCollectionList = collectionList?.find(c => tool.provider_id === c.id) + return { + ...tool, + isDeleted: appDetail?.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name), + notAuthor: toolInCollectionList?.is_team_authorization === false, + ...(tool.provider_type === 'builtin' + ? { + provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList), + provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList), + } + : {}), + } + }), + }) : DEFAULT_AGENT_SETTING, + } + return (newModelConfig as any) + })(appDetail?.model_config) + const mode = appDetail?.mode + // const isChatApp = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) + + // chat configuration + const promptMode = modelConfig?.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple + const isAdvancedMode = promptMode === PromptMode.advanced + const isAgent = mode === 'agent-chat' + const chatPromptConfig = isAdvancedMode ? (modelConfig?.chat_prompt_config || clone(DEFAULT_CHAT_PROMPT_CONFIG)) : undefined + const suggestedQuestions = modelConfig?.suggested_questions || [] + const moreLikeThisConfig = modelConfig?.more_like_this || { enabled: false } + const suggestedQuestionsAfterAnswerConfig = modelConfig?.suggested_questions_after_answer || { enabled: false } + const speechToTextConfig = modelConfig?.speech_to_text || { enabled: false } + const textToSpeechConfig = modelConfig?.text_to_speech || { enabled: false, voice: '', language: '' } + const citationConfig = modelConfig?.retriever_resource || { enabled: false } + const annotationConfig = modelConfig?.annotation_reply || { + id: '', + enabled: false, + score_threshold: ANNOTATION_DEFAULT.score_threshold, + embedding_model: { + embedding_provider_name: '', + embedding_model_name: '', + }, + } + const moderationConfig = modelConfig?.sensitive_word_avoidance || { enabled: false } + // completion configuration + const completionPromptConfig = modelConfig?.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any + + // prompt & model config + const inputs = {} + const query = '' + const completionParams = useState({}) + + const { + currentModel: currModel, + } = useTextGenerationCurrentProviderAndModelAndModelList( + { + provider: modelConfig.provider, + model: modelConfig.model_id, + }, + ) + + const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision) + const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document) + const isShowAudioConfig = !!currModel?.features?.includes(ModelFeatureEnum.audio) + const isAllowVideoUpload = !!currModel?.features?.includes(ModelFeatureEnum.video) + const visionConfig = { + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + } + + const featuresData: FeaturesData = useMemo(() => { + return { + moreLikeThis: modelConfig.more_like_this || { enabled: false }, + opening: { + enabled: !!modelConfig.opening_statement, + opening_statement: modelConfig.opening_statement || '', + suggested_questions: modelConfig.suggested_questions || [], + }, + moderation: modelConfig.sensitive_word_avoidance || { enabled: false }, + speech2text: modelConfig.speech_to_text || { enabled: false }, + text2speech: modelConfig.text_to_speech || { enabled: false }, + file: { + image: { + detail: modelConfig.file_upload?.image?.detail || Resolution.high, + enabled: !!modelConfig.file_upload?.image?.enabled, + number_limits: modelConfig.file_upload?.image?.number_limits || 3, + transfer_methods: modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + }, + enabled: !!(modelConfig.file_upload?.enabled || modelConfig.file_upload?.image?.enabled), + allowed_file_types: modelConfig.file_upload?.allowed_file_types || [], + allowed_file_extensions: modelConfig.file_upload?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image], ...FILE_EXTS[SupportUploadFileTypes.video]].map(ext => `.${ext}`), + allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: modelConfig.file_upload?.number_limits || modelConfig.file_upload?.image?.number_limits || 3, + fileUploadConfig: {}, + } as FileUpload, + suggested: modelConfig.suggested_questions_after_answer || { enabled: false }, + citation: modelConfig.retriever_resource || { enabled: false }, + annotationReply: modelConfig.annotation_reply || { enabled: false }, + } + }, [modelConfig]) + + if (isLoading) { + return ( +
+ +
+ ) + } + const value = { + readonly: true, + appId, + isAPIKeySet: true, + isTrailFinished: false, + mode, + modelModeType: '', + promptMode, + isAdvancedMode, + isAgent, + isOpenAI: false, + isFunctionCall: false, + collectionList: [], + setPromptMode: noop, + canReturnToSimpleMode: false, + setCanReturnToSimpleMode: noop, + chatPromptConfig, + completionPromptConfig, + currentAdvancedPrompt: '', + setCurrentAdvancedPrompt: noop, + conversationHistoriesRole: completionPromptConfig.conversation_histories_role, + showHistoryModal: false, + setConversationHistoriesRole: noop, + hasSetBlockStatus: true, + conversationId: '', + introduction: '', + setIntroduction: noop, + suggestedQuestions, + setSuggestedQuestions: noop, + setConversationId: noop, + controlClearChatMessage: false, + setControlClearChatMessage: noop, + prevPromptConfig: {}, + setPrevPromptConfig: noop, + moreLikeThisConfig, + setMoreLikeThisConfig: noop, + suggestedQuestionsAfterAnswerConfig, + setSuggestedQuestionsAfterAnswerConfig: noop, + speechToTextConfig, + setSpeechToTextConfig: noop, + textToSpeechConfig, + setTextToSpeechConfig: noop, + citationConfig, + setCitationConfig: noop, + annotationConfig, + setAnnotationConfig: noop, + moderationConfig, + setModerationConfig: noop, + externalDataToolsConfig: {}, + setExternalDataToolsConfig: noop, + formattingChanged: false, + setFormattingChanged: noop, + inputs, + setInputs: noop, + query, + setQuery: noop, + completionParams, + setCompletionParams: noop, + modelConfig, + setModelConfig: noop, + showSelectDataSet: noop, + dataSets, + setDataSets: noop, + datasetConfigs: [], + datasetConfigsRef: {}, + setDatasetConfigs: noop, + hasSetContextVar: true, + isShowVisionConfig, + visionConfig, + setVisionConfig: noop, + isAllowVideoUpload, + isShowDocumentConfig, + isShowAudioConfig, + rerankSettingModalOpen: false, + setRerankSettingModalOpen: noop, + } + return ( + + +
+
+
+ +
+ {!isMobile && ( +
+
+ +
+
+ )} +
+
+
+
+ ) +} +export default React.memo(BasicAppPreview) diff --git a/web/app/components/explore/try-app/preview/flow-app-preview.tsx b/web/app/components/explore/try-app/preview/flow-app-preview.tsx new file mode 100644 index 0000000000..ba64aecfba --- /dev/null +++ b/web/app/components/explore/try-app/preview/flow-app-preview.tsx @@ -0,0 +1,39 @@ +'use client' +import type { FC } from 'react' +import * as React from 'react' +import Loading from '@/app/components/base/loading' +import WorkflowPreview from '@/app/components/workflow/workflow-preview' +import { useGetTryAppFlowPreview } from '@/service/use-try-app' +import { cn } from '@/utils/classnames' + +type Props = { + appId: string + className?: string +} + +const FlowAppPreview: FC = ({ + appId, + className, +}) => { + const { data, isLoading } = useGetTryAppFlowPreview(appId) + + if (isLoading) { + return ( +
+ +
+ ) + } + if (!data) + return null + return ( +
+ +
+ ) +} +export default React.memo(FlowAppPreview) diff --git a/web/app/components/explore/try-app/preview/index.tsx b/web/app/components/explore/try-app/preview/index.tsx new file mode 100644 index 0000000000..a0c5fdc594 --- /dev/null +++ b/web/app/components/explore/try-app/preview/index.tsx @@ -0,0 +1,25 @@ +'use client' +import type { FC } from 'react' +import type { TryAppInfo } from '@/service/try-app' +import * as React from 'react' +import BasicAppPreview from './basic-app-preview' +import FlowAppPreview from './flow-app-preview' + +type Props = { + appId: string + appDetail: TryAppInfo +} + +const Preview: FC = ({ + appId, + appDetail, +}) => { + const isBasicApp = ['agent-chat', 'chat', 'completion'].includes(appDetail.mode) + + return ( +
+ {isBasicApp ? : } +
+ ) +} +export default React.memo(Preview) diff --git a/web/app/components/explore/try-app/tab.tsx b/web/app/components/explore/try-app/tab.tsx new file mode 100644 index 0000000000..75ba402204 --- /dev/null +++ b/web/app/components/explore/try-app/tab.tsx @@ -0,0 +1,37 @@ +'use client' +import type { FC } from 'react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import TabHeader from '../../base/tab-header' + +export enum TypeEnum { + TRY = 'try', + DETAIL = 'detail', +} + +type Props = { + value: TypeEnum + onChange: (value: TypeEnum) => void +} + +const Tab: FC = ({ + value, + onChange, +}) => { + const { t } = useTranslation() + const tabs = [ + { id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }) }, + { id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) }, + ] + return ( + void} + itemClassName="ml-0 system-md-semibold-uppercase" + itemWrapClassName="pt-2" + activeItemClassName="border-util-colors-blue-brand-blue-brand-500" + /> + ) +} +export default React.memo(Tab) diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 509687e245..90a2fb9277 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -34,7 +34,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { changeLanguage } from '@/i18n-config/client' import { AccessMode } from '@/models/access-control' -import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' +import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' import { Resolution, TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' import { userInputsFormToPromptVariables } from '@/utils/model-config' @@ -69,10 +69,10 @@ export type IMainProps = { const TextGeneration: FC = ({ isInstalledApp = false, - installedAppInfo, isWorkflow = false, }) => { const { notify } = Toast + const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp const { t } = useTranslation() const media = useBreakpoints() @@ -102,16 +102,18 @@ const TextGeneration: FC = ({ // save message const [savedMessages, setSavedMessages] = useState([]) const fetchSavedMessage = useCallback(async () => { - const res: any = await doFetchSavedMessage(isInstalledApp, appId) + if (!appId) + return + const res: any = await doFetchSavedMessage(appSourceType, appId) setSavedMessages(res.data) - }, [isInstalledApp, appId]) + }, [appSourceType, appId]) const handleSaveMessage = async (messageId: string) => { - await saveMessage(messageId, isInstalledApp, appId) + await saveMessage(messageId, appSourceType, appId) notify({ type: 'success', message: t('api.saved', { ns: 'common' }) }) fetchSavedMessage() } const handleRemoveSavedMessage = async (messageId: string) => { - await removeMessage(messageId, isInstalledApp, appId) + await removeMessage(messageId, appSourceType, appId) notify({ type: 'success', message: t('api.remove', { ns: 'common' }) }) fetchSavedMessage() } @@ -423,9 +425,8 @@ const TextGeneration: FC = ({ isCallBatchAPI={isCallBatchAPI} isPC={isPC} isMobile={!isPC} - isInstalledApp={isInstalledApp} + appSourceType={isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp} appId={appId} - installedAppInfo={installedAppInfo} isError={task?.status === TaskStatus.failed} promptConfig={promptConfig} moreLikeThisEnabled={!!moreLikeThisConfig?.enabled} diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index a0ffb31b06..fe518c6d25 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -4,8 +4,8 @@ import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { PromptConfig } from '@/models/debug' -import type { InstalledApp } from '@/models/explore' import type { SiteInfo } from '@/models/share' +import type { AppSourceType } from '@/service/share' import type { VisionFile, VisionSettings } from '@/types/app' import { RiLoader2Line } from '@remixicon/react' import { useBoolean } from 'ahooks' @@ -35,9 +35,8 @@ export type IResultProps = { isCallBatchAPI: boolean isPC: boolean isMobile: boolean - isInstalledApp: boolean - appId: string - installedAppInfo?: InstalledApp + appSourceType: AppSourceType + appId?: string isError: boolean isShowTextToSpeech: boolean promptConfig: PromptConfig | null @@ -63,9 +62,8 @@ const Result: FC = ({ isCallBatchAPI, isPC, isMobile, - isInstalledApp, + appSourceType, appId, - installedAppInfo, isError, isShowTextToSpeech, promptConfig, @@ -133,7 +131,7 @@ const Result: FC = ({ }) const handleFeedback = async (feedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, installedAppInfo?.id) + await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) setFeedback(feedback) } @@ -147,9 +145,9 @@ const Result: FC = ({ setIsStopping(true) try { if (isWorkflow) - await stopWorkflowMessage(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') + await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') else - await stopChatMessageResponding(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') + await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') abortControllerRef.current?.abort() } catch (error) { @@ -159,7 +157,7 @@ const Result: FC = ({ finally { setIsStopping(false) } - }, [appId, currentTaskId, installedAppInfo?.id, isInstalledApp, isStopping, isWorkflow, notify]) + }, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify]) useEffect(() => { if (!onRunControlChange) @@ -468,8 +466,8 @@ const Result: FC = ({ })) }, }, - isInstalledApp, - installedAppInfo?.id, + appSourceType, + appId, ).catch((error) => { setRespondingFalse() resetRunState() @@ -514,7 +512,7 @@ const Result: FC = ({ getAbortController: (abortController) => { abortControllerRef.current = abortController }, - }, isInstalledApp, installedAppInfo?.id) + }, appSourceType, appId) } } @@ -562,8 +560,8 @@ const Result: FC = ({ feedback={feedback} onSave={handleSaveMessage} isMobile={isMobile} - isInstalledApp={isInstalledApp} - installedAppId={installedAppInfo?.id} + appSourceType={appSourceType} + installedAppId={appId} isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false} taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined} controlClearMoreLikeThis={controlClearMoreLikeThis} diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index ca29ce1a98..4531ff8beb 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent, FC, FormEvent } from 'react' +import type { InputValueTypes } from '../types' import type { PromptConfig } from '@/models/debug' import type { SiteInfo } from '@/models/share' import type { VisionFile, VisionSettings } from '@/types/app' @@ -25,9 +26,9 @@ import { cn } from '@/utils/classnames' export type IRunOnceProps = { siteInfo: SiteInfo promptConfig: PromptConfig - inputs: Record - inputsRef: React.RefObject> - onInputsChange: (inputs: Record) => void + inputs: Record + inputsRef: React.RefObject> + onInputsChange: (inputs: Record) => void onSend: () => void visionConfig: VisionSettings onVisionFilesChange: (files: VisionFile[]) => void @@ -52,7 +53,7 @@ const RunOnce: FC = ({ const [isInitialized, setIsInitialized] = useState(false) const onClear = () => { - const newInputs: Record = {} + const newInputs: Record = {} promptConfig.prompt_variables.forEach((item) => { if (item.type === 'string' || item.type === 'paragraph') newInputs[item.key] = '' @@ -127,7 +128,7 @@ const RunOnce: FC = ({ {item.type === 'select' && ( ) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} maxLength={item.max_length} /> @@ -146,7 +147,7 @@ const RunOnce: FC = ({