From a66ed7157eaadb0879866ca57bdd0a278dd31d07 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 4 Jul 2025 14:06:47 +0800 Subject: [PATCH 001/241] feat: add document pause and resume functionality (#21894) --- .../components/datasets/documents/list.tsx | 31 ++++++++++++++++--- web/i18n/de-DE/dataset-documents.ts | 2 ++ web/i18n/en-US/dataset-documents.ts | 2 ++ web/i18n/es-ES/dataset-documents.ts | 2 ++ web/i18n/fa-IR/dataset-documents.ts | 2 ++ web/i18n/fr-FR/dataset-documents.ts | 2 ++ web/i18n/hi-IN/dataset-documents.ts | 2 ++ web/i18n/it-IT/dataset-documents.ts | 2 ++ web/i18n/ja-JP/dataset-documents.ts | 2 ++ web/i18n/ko-KR/dataset-documents.ts | 2 ++ web/i18n/pl-PL/dataset-documents.ts | 2 ++ web/i18n/pt-BR/dataset-documents.ts | 2 ++ web/i18n/ro-RO/dataset-documents.ts | 2 ++ web/i18n/ru-RU/dataset-documents.ts | 2 ++ web/i18n/sl-SI/dataset-documents.ts | 2 ++ web/i18n/th-TH/dataset-documents.ts | 2 ++ web/i18n/tr-TR/dataset-documents.ts | 2 ++ web/i18n/uk-UA/dataset-documents.ts | 2 ++ web/i18n/vi-VN/dataset-documents.ts | 2 ++ web/i18n/zh-Hans/dataset-documents.ts | 2 ++ web/i18n/zh-Hant/dataset-documents.ts | 2 ++ web/service/knowledge/use-document.ts | 21 +++++++++++++ 22 files changed, 88 insertions(+), 4 deletions(-) diff --git a/web/app/components/datasets/documents/list.tsx b/web/app/components/datasets/documents/list.tsx index cb349ee01c..4d566d9234 100644 --- a/web/app/components/datasets/documents/list.tsx +++ b/web/app/components/datasets/documents/list.tsx @@ -11,6 +11,8 @@ import { RiEqualizer2Line, RiLoopLeftLine, RiMoreFill, + RiPauseCircleLine, + RiPlayCircleLine, } from '@remixicon/react' import { useContext } from 'use-context-selector' import { useRouter } from 'next/navigation' @@ -42,7 +44,7 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from ' import type { Props as PaginationProps } from '@/app/components/base/pagination' import Pagination from '@/app/components/base/pagination' import Checkbox from '@/app/components/base/checkbox' -import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' +import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentPause, useDocumentResume, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' import useBatchEditDocumentMetadata from '../metadata/hooks/use-batch-edit-document-metadata' import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal' @@ -168,7 +170,7 @@ export const StatusItem: FC<{ } -type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' +type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' | 'pause' | 'resume' // operation action for list and detail export const OperationAction: FC<{ @@ -180,13 +182,14 @@ export const OperationAction: FC<{ id: string data_source_type: string doc_form: string + display_status?: string } datasetId: string onUpdate: (operationName?: string) => void scene?: 'list' | 'detail' className?: string }> = ({ embeddingAvailable, datasetId, detail, onUpdate, scene = 'list', className = '' }) => { - const { id, enabled = false, archived = false, data_source_type } = detail || {} + const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {} const [showModal, setShowModal] = useState(false) const [deleting, setDeleting] = useState(false) const { notify } = useContext(ToastContext) @@ -199,6 +202,8 @@ export const OperationAction: FC<{ const { mutateAsync: deleteDocument } = useDocumentDelete() const { mutateAsync: syncDocument } = useSyncDocument() const { mutateAsync: syncWebsite } = useSyncWebsite() + const { mutateAsync: pauseDocument } = useDocumentPause() + const { mutateAsync: resumeDocument } = useDocumentResume() const isListScene = scene === 'list' const onOperate = async (operationName: OperationName) => { @@ -222,6 +227,12 @@ export const OperationAction: FC<{ else opApi = syncWebsite break + case 'pause': + opApi = pauseDocument + break + case 'resume': + opApi = resumeDocument + break default: opApi = deleteDocument setDeleting(true) @@ -323,6 +334,18 @@ export const OperationAction: FC<{ )} + {!archived && display_status?.toLowerCase() === 'indexing' && ( +
onOperate('pause')}> + + {t('datasetDocuments.list.action.pause')} +
+ )} + {!archived && display_status?.toLowerCase() === 'paused' && ( +
onOperate('resume')}> + + {t('datasetDocuments.list.action.resume')} +
+ )} {!archived &&
onOperate('archive')}> {t('datasetDocuments.list.action.archive')} @@ -626,7 +649,7 @@ const DocumentList: FC = ({ diff --git a/web/i18n/de-DE/dataset-documents.ts b/web/i18n/de-DE/dataset-documents.ts index f52220a669..baf2e69c74 100644 --- a/web/i18n/de-DE/dataset-documents.ts +++ b/web/i18n/de-DE/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Löschen', enableWarning: 'Archivierte Datei kann nicht aktiviert werden', sync: 'Synchronisieren', + resume: 'Fortsetzen', + pause: 'Pause', }, index: { enable: 'Aktivieren', diff --git a/web/i18n/en-US/dataset-documents.ts b/web/i18n/en-US/dataset-documents.ts index 7d0a3541a0..9bcefd6358 100644 --- a/web/i18n/en-US/dataset-documents.ts +++ b/web/i18n/en-US/dataset-documents.ts @@ -30,6 +30,8 @@ const translation = { delete: 'Delete', enableWarning: 'Archived file cannot be enabled', sync: 'Sync', + pause: 'Pause', + resume: 'Resume', }, index: { enable: 'Enable', diff --git a/web/i18n/es-ES/dataset-documents.ts b/web/i18n/es-ES/dataset-documents.ts index 53a6663847..ee6c5bcf74 100644 --- a/web/i18n/es-ES/dataset-documents.ts +++ b/web/i18n/es-ES/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Eliminar', enableWarning: 'El archivo archivado no puede habilitarse', sync: 'Sincronizar', + resume: 'Reanudar', + pause: 'Pausa', }, index: { enable: 'Habilitar', diff --git a/web/i18n/fa-IR/dataset-documents.ts b/web/i18n/fa-IR/dataset-documents.ts index 048cb5163f..a67c49c126 100644 --- a/web/i18n/fa-IR/dataset-documents.ts +++ b/web/i18n/fa-IR/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'حذف', enableWarning: 'فایل بایگانی شده نمی‌تواند فعال شود', sync: 'همگام‌سازی', + resume: 'رزومه', + pause: 'مکث', }, index: { enable: 'فعال کردن', diff --git a/web/i18n/fr-FR/dataset-documents.ts b/web/i18n/fr-FR/dataset-documents.ts index d8c0fe4af7..7960a3ed02 100644 --- a/web/i18n/fr-FR/dataset-documents.ts +++ b/web/i18n/fr-FR/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Supprimer', enableWarning: 'Le fichier archivé ne peut pas être activé', sync: 'Synchroniser', + pause: 'Pause', + resume: 'Reprendre', }, index: { enable: 'Activer', diff --git a/web/i18n/hi-IN/dataset-documents.ts b/web/i18n/hi-IN/dataset-documents.ts index 1fcdd49449..4713ea1a89 100644 --- a/web/i18n/hi-IN/dataset-documents.ts +++ b/web/i18n/hi-IN/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'हटाएँ', enableWarning: 'संग्रहित फाइल को सक्रिय नहीं किया जा सकता', sync: 'सिंक्रोनाइज़ करें', + resume: 'रिज़्यूमे', + pause: 'रोकें', }, index: { enable: 'सक्रिय करें', diff --git a/web/i18n/it-IT/dataset-documents.ts b/web/i18n/it-IT/dataset-documents.ts index 2881e1fcee..1651719a27 100644 --- a/web/i18n/it-IT/dataset-documents.ts +++ b/web/i18n/it-IT/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Elimina', enableWarning: 'Il file archiviato non può essere abilitato', sync: 'Sincronizza', + resume: 'Riassumere', + pause: 'Pausa', }, index: { enable: 'Abilita', diff --git a/web/i18n/ja-JP/dataset-documents.ts b/web/i18n/ja-JP/dataset-documents.ts index e28425dc8f..1690545fe4 100644 --- a/web/i18n/ja-JP/dataset-documents.ts +++ b/web/i18n/ja-JP/dataset-documents.ts @@ -30,6 +30,8 @@ const translation = { delete: '削除', enableWarning: 'アーカイブされたファイルは有効にできません', sync: '同期', + pause: '一時停止', + resume: '履歴書', }, index: { enable: '有効にする', diff --git a/web/i18n/ko-KR/dataset-documents.ts b/web/i18n/ko-KR/dataset-documents.ts index ee94a880a0..77c94c7466 100644 --- a/web/i18n/ko-KR/dataset-documents.ts +++ b/web/i18n/ko-KR/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: '삭제', enableWarning: '아카이브된 파일은 활성화할 수 없습니다.', sync: '동기화', + resume: '이력서', + pause: '일시 중지', }, index: { enable: '활성화', diff --git a/web/i18n/pl-PL/dataset-documents.ts b/web/i18n/pl-PL/dataset-documents.ts index 78e427ba95..e581b2d090 100644 --- a/web/i18n/pl-PL/dataset-documents.ts +++ b/web/i18n/pl-PL/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Usuń', enableWarning: 'Zarchiwizowany plik nie może zostać włączony', sync: 'Synchronizuj', + resume: 'Wznawiać', + pause: 'Pauza', }, index: { enable: 'Włącz', diff --git a/web/i18n/pt-BR/dataset-documents.ts b/web/i18n/pt-BR/dataset-documents.ts index b8c06c1769..462a3c09ef 100644 --- a/web/i18n/pt-BR/dataset-documents.ts +++ b/web/i18n/pt-BR/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Excluir', enableWarning: 'O arquivo arquivado não pode ser habilitado', sync: 'Sincronizar', + resume: 'Retomar', + pause: 'Pausa', }, index: { enable: 'Habilitar', diff --git a/web/i18n/ro-RO/dataset-documents.ts b/web/i18n/ro-RO/dataset-documents.ts index e0f3e8b476..4c6237fcd0 100644 --- a/web/i18n/ro-RO/dataset-documents.ts +++ b/web/i18n/ro-RO/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Șterge', enableWarning: 'Fișierul arhivat nu poate fi activat', sync: 'Sincronizează', + pause: 'Pauză', + resume: 'Relua', }, index: { enable: 'Activează', diff --git a/web/i18n/ru-RU/dataset-documents.ts b/web/i18n/ru-RU/dataset-documents.ts index 6fadee3b26..17d2c8a542 100644 --- a/web/i18n/ru-RU/dataset-documents.ts +++ b/web/i18n/ru-RU/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Удалить', enableWarning: 'Архивный файл не может быть включен', sync: 'Синхронизировать', + resume: 'Резюме', + pause: 'Пауза', }, index: { enable: 'Включить', diff --git a/web/i18n/sl-SI/dataset-documents.ts b/web/i18n/sl-SI/dataset-documents.ts index 1a83335ad5..d4ea9e6376 100644 --- a/web/i18n/sl-SI/dataset-documents.ts +++ b/web/i18n/sl-SI/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Izbriši', enableWarning: 'Arhivirane datoteke ni mogoče omogočiti', sync: 'Sinhroniziraj', + pause: 'Pavza', + resume: 'Življenjepis', }, index: { enable: 'Omogoči', diff --git a/web/i18n/th-TH/dataset-documents.ts b/web/i18n/th-TH/dataset-documents.ts index 87b43f31e2..966218ad93 100644 --- a/web/i18n/th-TH/dataset-documents.ts +++ b/web/i18n/th-TH/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'ลบ', enableWarning: 'ไม่สามารถเปิดใช้งานไฟล์ที่เก็บถาวรได้', sync: 'ซิงค์', + pause: 'หยุด', + resume: 'ดำเนิน', }, index: { enable: 'เปิด', diff --git a/web/i18n/tr-TR/dataset-documents.ts b/web/i18n/tr-TR/dataset-documents.ts index 2e00975178..8536be3e4e 100644 --- a/web/i18n/tr-TR/dataset-documents.ts +++ b/web/i18n/tr-TR/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Sil', enableWarning: 'Arşivlenmiş dosya etkinleştirilemez', sync: 'Senkronize et', + pause: 'Duraklat', + resume: 'Özgeçmiş', }, index: { enable: 'Etkinleştir', diff --git a/web/i18n/uk-UA/dataset-documents.ts b/web/i18n/uk-UA/dataset-documents.ts index e8464e5661..222d8e8502 100644 --- a/web/i18n/uk-UA/dataset-documents.ts +++ b/web/i18n/uk-UA/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Видалити', enableWarning: 'Архівований файл неможливо активувати', sync: 'Синхронізувати', + pause: 'Пауза', + resume: 'Резюме', }, index: { enable: 'Активувати', diff --git a/web/i18n/vi-VN/dataset-documents.ts b/web/i18n/vi-VN/dataset-documents.ts index 1cc050b804..94d4dec556 100644 --- a/web/i18n/vi-VN/dataset-documents.ts +++ b/web/i18n/vi-VN/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Xóa', enableWarning: 'Tệp đã lưu trữ không thể được kích hoạt', sync: 'Đồng bộ', + pause: 'Tạm dừng', + resume: 'Tiếp tục', }, index: { enable: 'Kích hoạt', diff --git a/web/i18n/zh-Hans/dataset-documents.ts b/web/i18n/zh-Hans/dataset-documents.ts index 2eb883690b..659d8c5ee9 100644 --- a/web/i18n/zh-Hans/dataset-documents.ts +++ b/web/i18n/zh-Hans/dataset-documents.ts @@ -30,6 +30,8 @@ const translation = { delete: '删除', enableWarning: '归档的文件无法启用', sync: '同步', + pause: '暂停', + resume: '恢复', }, index: { enable: '启用中', diff --git a/web/i18n/zh-Hant/dataset-documents.ts b/web/i18n/zh-Hant/dataset-documents.ts index a79f3993e0..104777d0f6 100644 --- a/web/i18n/zh-Hant/dataset-documents.ts +++ b/web/i18n/zh-Hant/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: '刪除', enableWarning: '歸檔的檔案無法啟用', sync: '同步', + resume: '恢復', + pause: '暫停', }, index: { enable: '啟用中', diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index 6dabe7d872..e53a5ebde7 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -5,6 +5,7 @@ import { import { del, get, patch } from '../base' import { useInvalid } from '../use-base' import type { MetadataType, SortType } from '../datasets' +import { pauseDocIndexing, resumeDocIndexing } from '../datasets' import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets' import { DocumentActionType } from '@/models/datasets' import type { CommonResponse } from '@/models/common' @@ -130,3 +131,23 @@ export const useDocumentMetadata = (payload: { export const useInvalidDocumentDetailKey = () => { return useInvalid(useDocumentDetailKey) } + +export const useDocumentPause = () => { + return useMutation({ + mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { + if (!datasetId || !documentId) + throw new Error('datasetId and documentId are required') + return pauseDocIndexing({ datasetId, documentId }) as Promise + }, + }) +} + +export const useDocumentResume = () => { + return useMutation({ + mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { + if (!datasetId || !documentId) + throw new Error('datasetId and documentId are required') + return resumeDocIndexing({ datasetId, documentId }) as Promise + }, + }) +} From de39b737b6d3caa4f6a5778d1a43db5da1fec727 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 4 Jul 2025 14:18:31 +0800 Subject: [PATCH 002/241] Feat list query (#21907) --- .../hooks/use-document-list-query-state.ts | 83 +++++++++++++++++++ .../components/datasets/documents/index.tsx | 53 ++++++++++-- .../components/datasets/documents/list.tsx | 1 - 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 web/app/components/datasets/documents/hooks/use-document-list-query-state.ts diff --git a/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts b/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts new file mode 100644 index 0000000000..4531b7e658 --- /dev/null +++ b/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts @@ -0,0 +1,83 @@ +import { type ReadonlyURLSearchParams, usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useMemo } from 'react' + +export type DocumentListQuery = { + page: number + limit: number + keyword: string +} + +const DEFAULT_QUERY: DocumentListQuery = { + page: 1, + limit: 10, + keyword: '', +} + +// Parse the query parameters from the URL search string. +function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery { + const page = Number.parseInt(params.get('page') || '1', 10) + const limit = Number.parseInt(params.get('limit') || '10', 10) + const keyword = params.get('keyword') || '' + + return { + page: page > 0 ? page : 1, + limit: (limit > 0 && limit <= 100) ? limit : 10, + keyword: keyword ? decodeURIComponent(keyword) : '', + } +} + +// Update the URL search string with the given query parameters. +function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) { + const { page, limit, keyword } = query || {} + + const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim()) + + if (hasNonDefaultParams) { + searchParams.set('page', (page || 1).toString()) + searchParams.set('limit', (limit || 10).toString()) + } + else { + searchParams.delete('page') + searchParams.delete('limit') + } + + if (keyword && keyword.trim()) + searchParams.set('keyword', encodeURIComponent(keyword)) + else + searchParams.delete('keyword') +} + +function useDocumentListQueryState() { + const searchParams = useSearchParams() + const query = useMemo(() => parseParams(searchParams), [searchParams]) + + const router = useRouter() + const pathname = usePathname() + + // Helper function to update specific query parameters + const updateQuery = useCallback((updates: Partial) => { + const newQuery = { ...query, ...updates } + const params = new URLSearchParams() + updateSearchParams(newQuery, params) + const search = params.toString() + const queryString = search ? `?${search}` : '' + router.push(`${pathname}${queryString}`, { scroll: false }) + }, [query, router, pathname]) + + // Helper function to reset query to defaults + const resetQuery = useCallback(() => { + const params = new URLSearchParams() + updateSearchParams(DEFAULT_QUERY, params) + const search = params.toString() + const queryString = search ? `?${search}` : '' + router.push(`${pathname}${queryString}`, { scroll: false }) + }, [router, pathname]) + + return useMemo(() => ({ + query, + updateQuery, + resetQuery, + }), [query, updateQuery, resetQuery]) +} + +export default useDocumentListQueryState diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index 2840e5fa4a..676581a50f 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -26,6 +26,7 @@ import cn from '@/utils/classnames' import { useDocumentList, useInvalidDocumentDetailKey, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useInvalid } from '@/service/use-base' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' +import useDocumentListQueryState from './hooks/use-document-list-query-state' import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata' import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer' import StatusWithAction from '../common/document-status-with-action/status-with-action' @@ -82,7 +83,6 @@ type IDocumentsProps = { } export const fetcher = (url: string) => get(url, {}, {}) -const DEFAULT_LIMIT = 10 const Documents: FC = ({ datasetId }) => { const { t } = useTranslation() @@ -91,8 +91,12 @@ const Documents: FC = ({ datasetId }) => { const isFreePlan = plan.type === 'sandbox' const [inputValue, setInputValue] = useState('') // the input value const [searchValue, setSearchValue] = useState('') - const [currPage, setCurrPage] = React.useState(0) - const [limit, setLimit] = useState(DEFAULT_LIMIT) + + // Use the new hook for URL state management + const { query, updateQuery } = useDocumentListQueryState() + const [currPage, setCurrPage] = React.useState(query.page - 1) // Convert to 0-based index + const [limit, setLimit] = useState(query.limit) + const router = useRouter() const { dataset } = useDatasetDetailContext() const [notionPageSelectorModalVisible, setNotionPageSelectorModalVisible] = useState(false) @@ -103,6 +107,45 @@ const Documents: FC = ({ datasetId }) => { const embeddingAvailable = !!dataset?.embedding_available const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) + // Initialize search value from URL on mount + useEffect(() => { + if (query.keyword) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, []) // Only run on mount + + // Sync local state with URL query changes + useEffect(() => { + setCurrPage(query.page - 1) + setLimit(query.limit) + if (query.keyword !== searchValue) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, [query]) + + // Update URL when pagination changes + const handlePageChange = (newPage: number) => { + setCurrPage(newPage) + updateQuery({ page: newPage + 1 }) // Convert to 1-based index + } + + // Update URL when limit changes + const handleLimitChange = (newLimit: number) => { + setLimit(newLimit) + setCurrPage(0) // Reset to first page when limit changes + updateQuery({ limit: newLimit, page: 1 }) + } + + // Update URL when search changes + useEffect(() => { + if (debouncedSearchValue !== query.keyword) { + setCurrPage(0) // Reset to first page when search changes + updateQuery({ keyword: debouncedSearchValue, page: 1 }) + } + }, [debouncedSearchValue, query.keyword, updateQuery]) + const { data: documentsRes, isFetching: isListLoading } = useDocumentList({ datasetId, query: { @@ -327,9 +370,9 @@ const Documents: FC = ({ datasetId }) => { pagination={{ total, limit, - onLimitChange: setLimit, + onLimitChange: handleLimitChange, current: currPage, - onChange: setCurrPage, + onChange: handlePageChange, }} onManageMetadata={showEditMetadataModal} /> diff --git a/web/app/components/datasets/documents/list.tsx b/web/app/components/datasets/documents/list.tsx index 4d566d9234..c54b6a1a37 100644 --- a/web/app/components/datasets/documents/list.tsx +++ b/web/app/components/datasets/documents/list.tsx @@ -598,7 +598,6 @@ const DocumentList: FC = ({ ) }} /> - {/* {doc.position} */} {index + 1}
From 13990f31a1224c2bfae192562fd6ac7f56c04820 Mon Sep 17 00:00:00 2001 From: Nite Knite Date: Fri, 4 Jul 2025 14:52:30 +0800 Subject: [PATCH 003/241] feat: update account menu style (#21916) --- .../header/account-dropdown/index.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 08a61b7391..9b36fc6020 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -36,7 +36,7 @@ import { useDocLink } from '@/context/i18n' export default function AppSelector() { const itemClassName = ` - flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular + flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular rounded-lg hover:bg-state-base-hover cursor-pointer gap-1 ` const router = useRouter() @@ -87,24 +87,24 @@ export default function AppSelector() { backdrop-blur-sm focus:outline-none " > - -
-
-
- {userProfile.name} - {isEducationAccount && ( - - - EDU - - )} -
-
{userProfile.email}
-
- -
-
+ +
+
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
+
{userProfile.email}
+
+ +
+
Date: Fri, 4 Jul 2025 15:33:20 +0800 Subject: [PATCH 004/241] test:add unit test for api version config (#21919) --- api/tests/unit_tests/configs/test_dify_config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index cac0a688cd..b70c8830ed 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -1,6 +1,7 @@ import os from flask import Flask +from packaging.version import Version from yarl import URL from configs.app_config import DifyConfig @@ -40,6 +41,9 @@ def test_dify_config(monkeypatch): assert config.WORKFLOW_PARALLEL_DEPTH_LIMIT == 3 + # values from pyproject.toml + assert Version(config.project.version) >= Version("1.0.0") + # NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected. # This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`. From 2639f950cc48f07eb1fe2bb9d8c6429e7a7fe907 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:46:48 +0800 Subject: [PATCH 005/241] minor fix: removes the duplicated handling logic for TracingProviderEnum.ARIZE and TracingProviderEnum.PHOENIX from the OpsTraceProviderConfigMap (#21927) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/core/ops/ops_trace_manager.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index d6d6b4a1d4..7aa74a34e4 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -41,28 +41,6 @@ from tasks.ops_trace_task import process_trace_tasks class OpsTraceProviderConfigMap(dict[str, dict[str, Any]]): def __getitem__(self, provider: str) -> dict[str, Any]: match provider: - case TracingProviderEnum.ARIZE: - from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace - from core.ops.entities.config_entity import ArizeConfig - - return { - "config_class": ArizeConfig, - "secret_keys": ["api_key", "space_id"], - "other_keys": ["project", "endpoint"], - "trace_instance": ArizePhoenixDataTrace, - } - - case TracingProviderEnum.PHOENIX: - from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace - from core.ops.entities.config_entity import PhoenixConfig - - return { - "config_class": PhoenixConfig, - "secret_keys": ["api_key"], - "other_keys": ["project", "endpoint"], - "trace_instance": ArizePhoenixDataTrace, - } - case TracingProviderEnum.LANGFUSE: from core.ops.entities.config_entity import LangfuseConfig from core.ops.langfuse_trace.langfuse_trace import LangFuseDataTrace From fec6bafcdaa3b8a0f9876eb63ce611db043dd719 Mon Sep 17 00:00:00 2001 From: HyaCinth <88471803+HyaCiovo@users.noreply.github.com> Date: Fri, 4 Jul 2025 21:53:21 +0800 Subject: [PATCH 006/241] refactor(web): Restructure the operation buttons layout in the app information component (#21742) (#21818) --- web/app/components/app-sidebar/app-info.tsx | 144 +++++++---------- .../components/app-sidebar/app-operations.tsx | 145 ++++++++++++++++++ 2 files changed, 200 insertions(+), 89 deletions(-) create mode 100644 web/app/components/app-sidebar/app-operations.tsx diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index c6d0e776dd..c28cc20df5 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -10,7 +10,6 @@ import { RiFileCopy2Line, RiFileDownloadLine, RiFileUploadLine, - RiMoreLine, } from '@remixicon/react' import AppIcon from '../base/app-icon' import SwitchAppModal from '../app/switch-app-modal' @@ -35,7 +34,8 @@ import ContentDialog from '@/app/components/base/content-dialog' import Button from '@/app/components/base/button' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView' import Divider from '../base/divider' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem' +import type { Operation } from './app-operations' +import AppOperations from './app-operations' export type IAppInfoProps = { expand: boolean @@ -186,14 +186,58 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { isCurrentWorkspaceEditor } = useAppContext() - const [showMore, setShowMore] = useState(false) - const handleTriggerMore = useCallback(() => { - setShowMore(true) - }, [setShowMore]) - if (!appDetail) return null + const operations = [ + { + id: 'edit', + title: t('app.editApp'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowEditModal(true) + }, + }, + { + id: 'duplicate', + title: t('app.duplicate'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowDuplicateModal(true) + }, + }, + { + id: 'export', + title: t('app.export'), + icon: , + onClick: exportCheck, + }, + (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow')) ? { + id: 'import', + title: t('workflow.common.importDSL'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowImportDSLModal(true) + }, + } : undefined, + (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'completion' || appDetail.mode === 'chat')) ? { + id: 'switch', + title: t('app.switch'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowSwitchModal(true) + }, + } : undefined, + ].filter((op): op is Operation => Boolean(op)) + return (
{!onlyShowDetail && ( @@ -259,88 +303,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
{appDetail.description}
)} {/* operations */} -
- - - - {appDetail.mode !== 'agent-chat' && - - - - -
- { - (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') - &&
{ - setOpen(false) - onDetailExpand?.(false) - setShowImportDSLModal(true) - }}> - - {t('workflow.common.importDSL')} -
- } - { - (appDetail.mode === 'completion' || appDetail.mode === 'chat') - &&
{ - setOpen(false) - onDetailExpand?.(false) - setShowSwitchModal(true) - }}> - - {t('app.switch')} -
- } -
-
-
} -
+
void +} + +const AppOperations = ({ operations, gap }: { + operations: Operation[] + gap: number +}) => { + const { t } = useTranslation() + const [visibleOpreations, setVisibleOperations] = useState([]) + const [moreOperations, setMoreOperations] = useState([]) + const [showMore, setShowMore] = useState(false) + const navRef = useRef(null) + const handleTriggerMore = useCallback(() => { + setShowMore(true) + }, [setShowMore]) + + useEffect(() => { + const moreElement = document.getElementById('more') + const navElement = document.getElementById('nav') + let width = 0 + const containerWidth = navElement?.clientWidth ?? 0 + const moreWidth = moreElement?.clientWidth ?? 0 + + if (containerWidth === 0 || moreWidth === 0) return + + const updatedEntries: Record = operations.reduce((pre, cur) => { + pre[cur.id] = false + return pre + }, {} as Record) + const childrens = Array.from(navRef.current!.children).slice(0, -1) + for (let i = 0; i < childrens.length; i++) { + const child: any = childrens[i] + const id = child.dataset.targetid + if (!id) break + const childWidth = child.clientWidth + + if (width + gap + childWidth + moreWidth <= containerWidth) { + updatedEntries[id] = true + width += gap + childWidth + } + else { + if (i === childrens.length - 1 && width + childWidth <= containerWidth) + updatedEntries[id] = true + else + updatedEntries[id] = false + break + } + } + setVisibleOperations(operations.filter(item => updatedEntries[item.id])) + setMoreOperations(operations.filter(item => !updatedEntries[item.id])) + }, [operations, gap]) + + return ( + <> + {!visibleOpreations.length && } +
+ {visibleOpreations.map(operation => + , + )} + {visibleOpreations.length < operations.length && + + + + +
+ {moreOperations.map(item =>
+ {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} +
)} +
+
+
} +
+ + ) +} + +export default AppOperations From a201e9faee43f1755ab63400bd585bbe628882e5 Mon Sep 17 00:00:00 2001 From: heyszt <270985384@qq.com> Date: Fri, 4 Jul 2025 21:54:33 +0800 Subject: [PATCH 007/241] feat: Add Aliyun LLM Observability Integration (#21471) --- api/core/ops/aliyun_trace/__init__.py | 0 api/core/ops/aliyun_trace/aliyun_trace.py | 486 ++++++++++++++++++ .../aliyun_trace/data_exporter/__init__.py | 0 .../aliyun_trace/data_exporter/traceclient.py | 200 +++++++ .../ops/aliyun_trace/entities/__init__.py | 0 .../entities/aliyun_trace_entity.py | 21 + api/core/ops/aliyun_trace/entities/semconv.py | 64 +++ api/core/ops/entities/config_entity.py | 11 + api/core/ops/ops_trace_manager.py | 11 + api/services/ops_service.py | 10 + .../[appId]/overview/tracing/config-popup.tsx | 34 +- .../[appId]/overview/tracing/config.ts | 1 + .../[appId]/overview/tracing/panel.tsx | 17 +- .../tracing/provider-config-modal.tsx | 52 +- .../overview/tracing/provider-panel.tsx | 3 +- .../[appId]/overview/tracing/type.ts | 7 + .../[datasetId]/layout-main.tsx | 2 +- .../assets/public/tracing/aliyun-icon-big.svg | 1 + .../assets/public/tracing/aliyun-icon.svg | 1 + .../icons/src/public/tracing/AliyunIcon.json | 118 +++++ .../icons/src/public/tracing/AliyunIcon.tsx | 16 + .../src/public/tracing/AliyunIconBig.json | 71 +++ .../src/public/tracing/AliyunIconBig.tsx | 16 + .../base/icons/src/public/tracing/index.ts | 2 + web/app/components/base/select/index.tsx | 16 +- web/app/components/header/nav/index.tsx | 2 +- .../components/header/plugins-nav/index.tsx | 4 +- web/app/components/header/tools-nav/index.tsx | 2 +- .../json-schema-config-modal/code-editor.tsx | 2 +- web/app/signin/components/social-auth.tsx | 4 +- web/i18n/en-US/app.ts | 4 + web/i18n/zh-Hans/app.ts | 4 + web/models/app.ts | 6 +- 33 files changed, 1156 insertions(+), 32 deletions(-) create mode 100644 api/core/ops/aliyun_trace/__init__.py create mode 100644 api/core/ops/aliyun_trace/aliyun_trace.py create mode 100644 api/core/ops/aliyun_trace/data_exporter/__init__.py create mode 100644 api/core/ops/aliyun_trace/data_exporter/traceclient.py create mode 100644 api/core/ops/aliyun_trace/entities/__init__.py create mode 100644 api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py create mode 100644 api/core/ops/aliyun_trace/entities/semconv.py create mode 100644 web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg create mode 100644 web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg create mode 100644 web/app/components/base/icons/src/public/tracing/AliyunIcon.json create mode 100644 web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx create mode 100644 web/app/components/base/icons/src/public/tracing/AliyunIconBig.json create mode 100644 web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx diff --git a/api/core/ops/aliyun_trace/__init__.py b/api/core/ops/aliyun_trace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/core/ops/aliyun_trace/aliyun_trace.py new file mode 100644 index 0000000000..163b5d0307 --- /dev/null +++ b/api/core/ops/aliyun_trace/aliyun_trace.py @@ -0,0 +1,486 @@ +import json +import logging +from collections.abc import Sequence +from typing import Optional +from urllib.parse import urljoin + +from opentelemetry.trace import Status, StatusCode +from sqlalchemy.orm import Session, sessionmaker + +from core.ops.aliyun_trace.data_exporter.traceclient import ( + TraceClient, + convert_datetime_to_nanoseconds, + convert_to_span_id, + convert_to_trace_id, + generate_span_id, +) +from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData +from core.ops.aliyun_trace.entities.semconv import ( + GEN_AI_COMPLETION, + GEN_AI_FRAMEWORK, + GEN_AI_MODEL_NAME, + GEN_AI_PROMPT, + GEN_AI_PROMPT_TEMPLATE_TEMPLATE, + GEN_AI_PROMPT_TEMPLATE_VARIABLE, + GEN_AI_RESPONSE_FINISH_REASON, + GEN_AI_SESSION_ID, + GEN_AI_SPAN_KIND, + GEN_AI_SYSTEM, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, + GEN_AI_USER_ID, + INPUT_VALUE, + OUTPUT_VALUE, + RETRIEVAL_DOCUMENT, + RETRIEVAL_QUERY, + TOOL_DESCRIPTION, + TOOL_NAME, + TOOL_PARAMETERS, + GenAISpanKind, +) +from core.ops.base_trace_instance import BaseTraceInstance +from core.ops.entities.config_entity import AliyunConfig +from core.ops.entities.trace_entity import ( + BaseTraceInfo, + DatasetRetrievalTraceInfo, + GenerateNameTraceInfo, + MessageTraceInfo, + ModerationTraceInfo, + SuggestedQuestionTraceInfo, + ToolTraceInfo, + WorkflowTraceInfo, +) +from core.rag.models.document import Document +from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository +from core.workflow.entities.workflow_node_execution import ( + WorkflowNodeExecution, + WorkflowNodeExecutionMetadataKey, + WorkflowNodeExecutionStatus, +) +from core.workflow.nodes import NodeType +from models import Account, App, EndUser, TenantAccountJoin, WorkflowNodeExecutionTriggeredFrom, db + +logger = logging.getLogger(__name__) + + +class AliyunDataTrace(BaseTraceInstance): + def __init__( + self, + aliyun_config: AliyunConfig, + ): + super().__init__(aliyun_config) + base_url = aliyun_config.endpoint.rstrip("/") + endpoint = urljoin(base_url, f"adapt_{aliyun_config.license_key}/api/otlp/traces") + self.trace_client = TraceClient(service_name=aliyun_config.app_name, endpoint=endpoint) + + def trace(self, trace_info: BaseTraceInfo): + if isinstance(trace_info, WorkflowTraceInfo): + self.workflow_trace(trace_info) + if isinstance(trace_info, MessageTraceInfo): + self.message_trace(trace_info) + if isinstance(trace_info, ModerationTraceInfo): + pass + if isinstance(trace_info, SuggestedQuestionTraceInfo): + self.suggested_question_trace(trace_info) + if isinstance(trace_info, DatasetRetrievalTraceInfo): + self.dataset_retrieval_trace(trace_info) + if isinstance(trace_info, ToolTraceInfo): + self.tool_trace(trace_info) + if isinstance(trace_info, GenerateNameTraceInfo): + pass + + def api_check(self): + return self.trace_client.api_check() + + def get_project_url(self): + try: + return self.trace_client.get_project_url() + except Exception as e: + logger.info(f"Aliyun get run url failed: {str(e)}", exc_info=True) + raise ValueError(f"Aliyun get run url failed: {str(e)}") + + def workflow_trace(self, trace_info: WorkflowTraceInfo): + trace_id = convert_to_trace_id(trace_info.workflow_run_id) + workflow_span_id = convert_to_span_id(trace_info.workflow_run_id, "workflow") + self.add_workflow_span(trace_id, workflow_span_id, trace_info) + + workflow_node_executions = self.get_workflow_node_executions(trace_info) + for node_execution in workflow_node_executions: + node_span = self.build_workflow_node_span(node_execution, trace_id, trace_info, workflow_span_id) + self.trace_client.add_span(node_span) + + def message_trace(self, trace_info: MessageTraceInfo): + message_data = trace_info.message_data + if message_data is None: + return + message_id = trace_info.message_id + + user_id = message_data.from_account_id + if message_data.from_end_user_id: + end_user_data: Optional[EndUser] = ( + db.session.query(EndUser).filter(EndUser.id == message_data.from_end_user_id).first() + ) + if end_user_data is not None: + user_id = end_user_data.session_id + + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + + trace_id = convert_to_trace_id(message_id) + message_span_id = convert_to_span_id(message_id, "message") + message_span = SpanData( + trace_id=trace_id, + parent_span_id=None, + span_id=message_span_id, + name="message", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.outputs), + }, + status=status, + ) + self.trace_client.add_span(message_span) + + app_model_config = getattr(trace_info.message_data, "app_model_config", {}) + pre_prompt = getattr(app_model_config, "pre_prompt", "") + inputs_data = getattr(trace_info.message_data, "inputs", {}) + llm_span = SpanData( + trace_id=trace_id, + parent_span_id=message_span_id, + span_id=convert_to_span_id(message_id, "llm"), + name="llm", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""), + GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(trace_info.message_tokens), + GEN_AI_USAGE_OUTPUT_TOKENS: str(trace_info.answer_tokens), + GEN_AI_USAGE_TOTAL_TOKENS: str(trace_info.total_tokens), + GEN_AI_PROMPT_TEMPLATE_VARIABLE: json.dumps(inputs_data, ensure_ascii=False), + GEN_AI_PROMPT_TEMPLATE_TEMPLATE: pre_prompt, + GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False), + GEN_AI_COMPLETION: str(trace_info.outputs), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.outputs), + }, + status=status, + ) + self.trace_client.add_span(llm_span) + + def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo): + if trace_info.message_data is None: + return + message_id = trace_info.message_id + + documents_data = extract_retrieval_documents(trace_info.documents) + dataset_retrieval_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=generate_span_id(), + name="dataset_retrieval", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value, + GEN_AI_FRAMEWORK: "dify", + RETRIEVAL_QUERY: str(trace_info.inputs), + RETRIEVAL_DOCUMENT: json.dumps(documents_data, ensure_ascii=False), + INPUT_VALUE: str(trace_info.inputs), + OUTPUT_VALUE: json.dumps(documents_data, ensure_ascii=False), + }, + ) + self.trace_client.add_span(dataset_retrieval_span) + + def tool_trace(self, trace_info: ToolTraceInfo): + if trace_info.message_data is None: + return + message_id = trace_info.message_id + + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + + tool_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=generate_span_id(), + name=trace_info.tool_name, + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value, + GEN_AI_FRAMEWORK: "dify", + TOOL_NAME: trace_info.tool_name, + TOOL_DESCRIPTION: json.dumps(trace_info.tool_config, ensure_ascii=False), + TOOL_PARAMETERS: json.dumps(trace_info.tool_inputs, ensure_ascii=False), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.tool_outputs), + }, + status=status, + ) + self.trace_client.add_span(tool_span) + + def get_workflow_node_executions(self, trace_info: WorkflowTraceInfo) -> Sequence[WorkflowNodeExecution]: + # through workflow_run_id get all_nodes_execution using repository + session_factory = sessionmaker(bind=db.engine) + # Find the app's creator account + with Session(db.engine, expire_on_commit=False) as session: + # Get the app to find its creator + app_id = trace_info.metadata.get("app_id") + if not app_id: + raise ValueError("No app_id found in trace_info metadata") + + app = session.query(App).filter(App.id == app_id).first() + if not app: + raise ValueError(f"App with id {app_id} not found") + + if not app.created_by: + raise ValueError(f"App with id {app_id} has no creator (created_by is None)") + + service_account = session.query(Account).filter(Account.id == app.created_by).first() + if not service_account: + raise ValueError(f"Creator account with id {app.created_by} not found for app {app_id}") + current_tenant = ( + session.query(TenantAccountJoin).filter_by(account_id=service_account.id, current=True).first() + ) + if not current_tenant: + raise ValueError(f"Current tenant not found for account {service_account.id}") + service_account.set_tenant_id(current_tenant.tenant_id) + workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository( + session_factory=session_factory, + user=service_account, + app_id=trace_info.metadata.get("app_id"), + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + ) + # Get all executions for this workflow run + workflow_node_executions = workflow_node_execution_repository.get_by_workflow_run( + workflow_run_id=trace_info.workflow_run_id + ) + return workflow_node_executions + + def build_workflow_node_span( + self, node_execution: WorkflowNodeExecution, trace_id: int, trace_info: WorkflowTraceInfo, workflow_span_id: int + ): + try: + if node_execution.node_type == NodeType.LLM: + node_span = self.build_workflow_llm_span(trace_id, workflow_span_id, trace_info, node_execution) + elif node_execution.node_type == NodeType.KNOWLEDGE_RETRIEVAL: + node_span = self.build_workflow_retrieval_span(trace_id, workflow_span_id, trace_info, node_execution) + elif node_execution.node_type == NodeType.TOOL: + node_span = self.build_workflow_tool_span(trace_id, workflow_span_id, trace_info, node_execution) + else: + node_span = self.build_workflow_task_span(trace_id, workflow_span_id, trace_info, node_execution) + return node_span + except Exception: + return None + + def get_workflow_node_status(self, node_execution: WorkflowNodeExecution) -> Status: + span_status: Status = Status(StatusCode.UNSET) + if node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED: + span_status = Status(StatusCode.OK) + elif node_execution.status in [WorkflowNodeExecutionStatus.FAILED, WorkflowNodeExecutionStatus.EXCEPTION]: + span_status = Status(StatusCode.ERROR, str(node_execution.error)) + return span_status + + def build_workflow_task_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.TASK.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(node_execution.inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_tool_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + tool_des = {} + if node_execution.metadata: + tool_des = node_execution.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {}) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value, + GEN_AI_FRAMEWORK: "dify", + TOOL_NAME: node_execution.title, + TOOL_DESCRIPTION: json.dumps(tool_des, ensure_ascii=False), + TOOL_PARAMETERS: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False), + INPUT_VALUE: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_retrieval_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + input_value = "" + if node_execution.inputs: + input_value = str(node_execution.inputs.get("query", "")) + output_value = "" + if node_execution.outputs: + output_value = json.dumps(node_execution.outputs.get("result", []), ensure_ascii=False) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value, + GEN_AI_FRAMEWORK: "dify", + RETRIEVAL_QUERY: input_value, + RETRIEVAL_DOCUMENT: output_value, + INPUT_VALUE: input_value, + OUTPUT_VALUE: output_value, + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_llm_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + process_data = node_execution.process_data or {} + outputs = node_execution.outputs or {} + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: process_data.get("model_name", ""), + GEN_AI_SYSTEM: process_data.get("model_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(outputs.get("usage", {}).get("prompt_tokens", 0)), + GEN_AI_USAGE_OUTPUT_TOKENS: str(outputs.get("usage", {}).get("completion_tokens", 0)), + GEN_AI_USAGE_TOTAL_TOKENS: str(outputs.get("usage", {}).get("total_tokens", 0)), + GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + GEN_AI_COMPLETION: str(outputs.get("text", "")), + GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""), + INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + OUTPUT_VALUE: str(outputs.get("text", "")), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def add_workflow_span(self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo): + message_span_id = None + if trace_info.message_id: + message_span_id = convert_to_span_id(trace_info.message_id, "message") + user_id = trace_info.metadata.get("user_id") + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + if message_span_id: # chatflow + message_span = SpanData( + trace_id=trace_id, + parent_span_id=None, + span_id=message_span_id, + name="message", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: trace_info.workflow_run_inputs.get("sys.query", ""), + OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(message_span) + + workflow_span = SpanData( + trace_id=trace_id, + parent_span_id=message_span_id, + span_id=workflow_span_id, + name="workflow", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(trace_info.workflow_run_inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(workflow_span) + + def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo): + message_id = trace_info.message_id + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + suggested_question_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=convert_to_span_id(message_id, "suggested_question"), + name="suggested_question", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""), + GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""), + GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False), + GEN_AI_COMPLETION: json.dumps(trace_info.suggested_question, ensure_ascii=False), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(suggested_question_span) + + +def extract_retrieval_documents(documents: list[Document]): + documents_data = [] + for document in documents: + document_data = { + "content": document.page_content, + "metadata": { + "dataset_id": document.metadata.get("dataset_id"), + "doc_id": document.metadata.get("doc_id"), + "document_id": document.metadata.get("document_id"), + }, + "score": document.metadata.get("score"), + } + documents_data.append(document_data) + return documents_data diff --git a/api/core/ops/aliyun_trace/data_exporter/__init__.py b/api/core/ops/aliyun_trace/data_exporter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/data_exporter/traceclient.py b/api/core/ops/aliyun_trace/data_exporter/traceclient.py new file mode 100644 index 0000000000..ba5ac3f420 --- /dev/null +++ b/api/core/ops/aliyun_trace/data_exporter/traceclient.py @@ -0,0 +1,200 @@ +import hashlib +import logging +import random +import socket +import threading +import uuid +from collections import deque +from collections.abc import Sequence +from datetime import datetime +from typing import Optional + +import requests +from opentelemetry import trace as trace_api +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.semconv.resource import ResourceAttributes + +from configs import dify_config +from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData + +INVALID_SPAN_ID = 0x0000000000000000 +INVALID_TRACE_ID = 0x00000000000000000000000000000000 + +logger = logging.getLogger(__name__) + + +class TraceClient: + def __init__( + self, + service_name: str, + endpoint: str, + max_queue_size: int = 1000, + schedule_delay_sec: int = 5, + max_export_batch_size: int = 50, + ): + self.endpoint = endpoint + self.resource = Resource( + attributes={ + ResourceAttributes.SERVICE_NAME: service_name, + ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", + ResourceAttributes.DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", + ResourceAttributes.HOST_NAME: socket.gethostname(), + } + ) + self.span_builder = SpanBuilder(self.resource) + self.exporter = OTLPSpanExporter(endpoint=endpoint) + + self.max_queue_size = max_queue_size + self.schedule_delay_sec = schedule_delay_sec + self.max_export_batch_size = max_export_batch_size + + self.queue: deque = deque(maxlen=max_queue_size) + self.condition = threading.Condition(threading.Lock()) + self.done = False + + self.worker_thread = threading.Thread(target=self._worker, daemon=True) + self.worker_thread.start() + + self._spans_dropped = False + + def export(self, spans: Sequence[ReadableSpan]): + self.exporter.export(spans) + + def api_check(self): + try: + response = requests.head(self.endpoint, timeout=5) + if response.status_code == 405: + return True + else: + logger.debug(f"AliyunTrace API check failed: Unexpected status code: {response.status_code}") + return False + except requests.exceptions.RequestException as e: + logger.debug(f"AliyunTrace API check failed: {str(e)}") + raise ValueError(f"AliyunTrace API check failed: {str(e)}") + + def get_project_url(self): + return "https://arms.console.aliyun.com/#/llm" + + def add_span(self, span_data: SpanData): + if span_data is None: + return + span: ReadableSpan = self.span_builder.build_span(span_data) + with self.condition: + if len(self.queue) == self.max_queue_size: + if not self._spans_dropped: + logger.warning("Queue is full, likely spans will be dropped.") + self._spans_dropped = True + + self.queue.appendleft(span) + if len(self.queue) >= self.max_export_batch_size: + self.condition.notify() + + def _worker(self): + while not self.done: + with self.condition: + if len(self.queue) < self.max_export_batch_size and not self.done: + self.condition.wait(timeout=self.schedule_delay_sec) + self._export_batch() + + def _export_batch(self): + spans_to_export: list[ReadableSpan] = [] + with self.condition: + while len(spans_to_export) < self.max_export_batch_size and self.queue: + spans_to_export.append(self.queue.pop()) + + if spans_to_export: + try: + self.exporter.export(spans_to_export) + except Exception as e: + logger.debug(f"Error exporting spans: {e}") + + def shutdown(self): + with self.condition: + self.done = True + self.condition.notify_all() + self.worker_thread.join() + self._export_batch() + self.exporter.shutdown() + + +class SpanBuilder: + def __init__(self, resource): + self.resource = resource + self.instrumentation_scope = InstrumentationScope( + __name__, + "", + None, + None, + ) + + def build_span(self, span_data: SpanData) -> ReadableSpan: + span_context = trace_api.SpanContext( + trace_id=span_data.trace_id, + span_id=span_data.span_id, + is_remote=False, + trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED), + trace_state=None, + ) + + parent_span_context = None + if span_data.parent_span_id is not None: + parent_span_context = trace_api.SpanContext( + trace_id=span_data.trace_id, + span_id=span_data.parent_span_id, + is_remote=False, + trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED), + trace_state=None, + ) + + span = ReadableSpan( + name=span_data.name, + context=span_context, + parent=parent_span_context, + resource=self.resource, + attributes=span_data.attributes, + events=span_data.events, + links=span_data.links, + kind=trace_api.SpanKind.INTERNAL, + status=span_data.status, + start_time=span_data.start_time, + end_time=span_data.end_time, + instrumentation_scope=self.instrumentation_scope, + ) + return span + + +def generate_span_id() -> int: + span_id = random.getrandbits(64) + while span_id == INVALID_SPAN_ID: + span_id = random.getrandbits(64) + return span_id + + +def convert_to_trace_id(uuid_v4: Optional[str]) -> int: + try: + uuid_obj = uuid.UUID(uuid_v4) + return uuid_obj.int + except Exception as e: + raise ValueError(f"Invalid UUID input: {e}") + + +def convert_to_span_id(uuid_v4: Optional[str], span_type: str) -> int: + try: + uuid_obj = uuid.UUID(uuid_v4) + except Exception as e: + raise ValueError(f"Invalid UUID input: {e}") + combined_key = f"{uuid_obj.hex}-{span_type}" + hash_bytes = hashlib.sha256(combined_key.encode("utf-8")).digest() + span_id = int.from_bytes(hash_bytes[:8], byteorder="big", signed=False) + return span_id + + +def convert_datetime_to_nanoseconds(start_time_a: Optional[datetime]) -> Optional[int]: + if start_time_a is None: + return None + timestamp_in_seconds = start_time_a.timestamp() + timestamp_in_nanoseconds = int(timestamp_in_seconds * 1e9) + return timestamp_in_nanoseconds diff --git a/api/core/ops/aliyun_trace/entities/__init__.py b/api/core/ops/aliyun_trace/entities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py new file mode 100644 index 0000000000..1caa822cd0 --- /dev/null +++ b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence +from typing import Optional + +from opentelemetry import trace as trace_api +from opentelemetry.sdk.trace import Event, Status, StatusCode +from pydantic import BaseModel, Field + + +class SpanData(BaseModel): + model_config = {"arbitrary_types_allowed": True} + + trace_id: int = Field(..., description="The unique identifier for the trace.") + parent_span_id: Optional[int] = Field(None, description="The ID of the parent span, if any.") + span_id: int = Field(..., description="The unique identifier for this span.") + name: str = Field(..., description="The name of the span.") + attributes: dict[str, str] = Field(default_factory=dict, description="Attributes associated with the span.") + events: Sequence[Event] = Field(default_factory=list, description="Events recorded in the span.") + links: Sequence[trace_api.Link] = Field(default_factory=list, description="Links to other spans.") + status: Status = Field(default=Status(StatusCode.UNSET), description="The status of the span.") + start_time: Optional[int] = Field(..., description="The start time of the span in nanoseconds.") + end_time: Optional[int] = Field(..., description="The end time of the span in nanoseconds.") diff --git a/api/core/ops/aliyun_trace/entities/semconv.py b/api/core/ops/aliyun_trace/entities/semconv.py new file mode 100644 index 0000000000..657cf1da0c --- /dev/null +++ b/api/core/ops/aliyun_trace/entities/semconv.py @@ -0,0 +1,64 @@ +from enum import Enum + +# public +GEN_AI_SESSION_ID = "gen_ai.session.id" + +GEN_AI_USER_ID = "gen_ai.user.id" + +GEN_AI_USER_NAME = "gen_ai.user.name" + +GEN_AI_SPAN_KIND = "gen_ai.span.kind" + +GEN_AI_FRAMEWORK = "gen_ai.framework" + + +# Chain +INPUT_VALUE = "input.value" + +OUTPUT_VALUE = "output.value" + + +# Retriever +RETRIEVAL_QUERY = "retrieval.query" + +RETRIEVAL_DOCUMENT = "retrieval.document" + + +# LLM +GEN_AI_MODEL_NAME = "gen_ai.model_name" + +GEN_AI_SYSTEM = "gen_ai.system" + +GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" + +GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + +GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" + +GEN_AI_PROMPT_TEMPLATE_TEMPLATE = "gen_ai.prompt_template.template" + +GEN_AI_PROMPT_TEMPLATE_VARIABLE = "gen_ai.prompt_template.variable" + +GEN_AI_PROMPT = "gen_ai.prompt" + +GEN_AI_COMPLETION = "gem_ai.completion" + +GEN_AI_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason" + +# Tool +TOOL_NAME = "tool.name" + +TOOL_DESCRIPTION = "tool.description" + +TOOL_PARAMETERS = "tool.parameters" + + +class GenAISpanKind(Enum): + CHAIN = "CHAIN" + RETRIEVER = "RETRIEVER" + RERANKER = "RERANKER" + LLM = "LLM" + EMBEDDING = "EMBEDDING" + TOOL = "TOOL" + AGENT = "AGENT" + TASK = "TASK" diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py index 8a2ce58539..cd5d609ef7 100644 --- a/api/core/ops/entities/config_entity.py +++ b/api/core/ops/entities/config_entity.py @@ -10,6 +10,7 @@ class TracingProviderEnum(StrEnum): LANGSMITH = "langsmith" OPIK = "opik" WEAVE = "weave" + ALIYUN = "aliyun" class BaseTracingConfig(BaseModel): @@ -184,5 +185,15 @@ class WeaveConfig(BaseTracingConfig): return v +class AliyunConfig(BaseTracingConfig): + """ + Model class for Aliyun tracing config. + """ + + app_name: str = "dify_app" + license_key: str + endpoint: str + + OPS_FILE_PATH = "ops_trace/" OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE" diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index 7aa74a34e4..5c9b9d27b7 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -104,6 +104,17 @@ class OpsTraceProviderConfigMap(dict[str, dict[str, Any]]): "other_keys": ["project", "endpoint"], "trace_instance": ArizePhoenixDataTrace, } + case TracingProviderEnum.ALIYUN: + from core.ops.aliyun_trace.aliyun_trace import AliyunDataTrace + from core.ops.entities.config_entity import AliyunConfig + + return { + "config_class": AliyunConfig, + "secret_keys": ["license_key"], + "other_keys": ["endpoint", "app_name"], + "trace_instance": AliyunDataTrace, + } + case _: raise KeyError(f"Unsupported tracing provider: {provider}") diff --git a/api/services/ops_service.py b/api/services/ops_service.py index c88accb9a5..dbeb4f1908 100644 --- a/api/services/ops_service.py +++ b/api/services/ops_service.py @@ -94,6 +94,16 @@ class OpsService: new_decrypt_tracing_config.update({"project_url": project_url}) except Exception: new_decrypt_tracing_config.update({"project_url": "https://wandb.ai/"}) + + if tracing_provider == "aliyun" and ( + "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url") + ): + try: + project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider) + new_decrypt_tracing_config.update({"project_url": project_url}) + except Exception: + new_decrypt_tracing_config.update({"project_url": "https://arms.console.aliyun.com/"}) + trace_config_data.tracing_config = new_decrypt_tracing_config return trace_config_data.to_dict() diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 92ba068b2b..644889f023 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import TracingIcon from './tracing-icon' import ProviderPanel from './provider-panel' -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import ProviderConfigModal from './provider-config-modal' import Indicator from '@/app/components/header/indicator' @@ -29,7 +29,8 @@ export type PopupProps = { langFuseConfig: LangFuseConfig | null opikConfig: OpikConfig | null weaveConfig: WeaveConfig | null - onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void + aliyunConfig: AliyunConfig | null + onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void onConfigRemoved: (provider: TracingProvider) => void } @@ -46,6 +47,7 @@ const ConfigPopup: FC = ({ langFuseConfig, opikConfig, weaveConfig, + aliyunConfig, onConfigUpdated, onConfigRemoved, }) => { @@ -69,7 +71,7 @@ const ConfigPopup: FC = ({ } }, [onChooseProvider]) - const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => { + const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => { onConfigUpdated(currentProvider!, payload) hideConfigModal() }, [currentProvider, hideConfigModal, onConfigUpdated]) @@ -79,8 +81,8 @@ const ConfigPopup: FC = ({ hideConfigModal() }, [currentProvider, hideConfigModal, onConfigRemoved]) - const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig - const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig + const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig && aliyunConfig + const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig && !aliyunConfig const switchContent = ( = ({ key="weave-provider-panel" /> ) + + const aliyunPanel = ( + + ) const configuredProviderPanel = () => { const configuredPanels: JSX.Element[] = [] @@ -188,6 +203,9 @@ const ConfigPopup: FC = ({ if (phoenixConfig) configuredPanels.push(phoenixPanel) + if (aliyunConfig) + configuredPanels.push(aliyunPanel) + return configuredPanels } @@ -212,6 +230,9 @@ const ConfigPopup: FC = ({ if (!weaveConfig) notConfiguredPanels.push(weavePanel) + if (!aliyunConfig) + notConfiguredPanels.push(aliyunPanel) + return notConfiguredPanels } @@ -226,6 +247,8 @@ const ConfigPopup: FC = ({ return langFuseConfig if (currentProvider === TracingProvider.opik) return opikConfig + if (currentProvider === TracingProvider.aliyun) + return aliyunConfig return weaveConfig } @@ -273,6 +296,7 @@ const ConfigPopup: FC = ({ {weavePanel} {arizePanel} {phoenixPanel} + {aliyunPanel}
) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts index 28433991e4..4c81b63ea2 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts @@ -7,4 +7,5 @@ export const docURL = { [TracingProvider.langfuse]: 'https://docs.langfuse.com', [TracingProvider.opik]: 'https://www.comet.com/docs/opik/tracing/integrations/dify#setup-instructions', [TracingProvider.weave]: 'https://weave-docs.wandb.ai/', + [TracingProvider.aliyun]: 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680', } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index c8286c9a51..8bf18904be 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -7,12 +7,12 @@ import { import { useTranslation } from 'react-i18next' import { usePathname } from 'next/navigation' import { useBoolean } from 'ahooks' -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import TracingIcon from './tracing-icon' import ConfigButton from './config-button' import cn from '@/utils/classnames' -import { ArizeIcon, LangfuseIcon, LangsmithIcon, OpikIcon, PhoenixIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' +import { AliyunIcon, ArizeIcon, LangfuseIcon, LangsmithIcon, OpikIcon, PhoenixIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' import Indicator from '@/app/components/header/indicator' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' import type { TracingStatus } from '@/models/app' @@ -69,6 +69,7 @@ const Panel: FC = () => { [TracingProvider.langfuse]: LangfuseIcon, [TracingProvider.opik]: OpikIcon, [TracingProvider.weave]: WeaveIcon, + [TracingProvider.aliyun]: AliyunIcon, } const InUseProviderIcon = inUseTracingProvider ? providerIconMap[inUseTracingProvider] : undefined @@ -78,7 +79,8 @@ const Panel: FC = () => { const [langFuseConfig, setLangFuseConfig] = useState(null) const [opikConfig, setOpikConfig] = useState(null) const [weaveConfig, setWeaveConfig] = useState(null) - const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig) + const [aliyunConfig, setAliyunConfig] = useState(null) + const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig || aliyunConfig) const fetchTracingConfig = async () => { const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.arize }) @@ -99,6 +101,9 @@ const Panel: FC = () => { const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.weave }) if (!weaveHasNotConfig) setWeaveConfig(weaveConfig as WeaveConfig) + const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun }) + if (!aliyunHasNotConfig) + setAliyunConfig(aliyunConfig as AliyunConfig) } const handleTracingConfigUpdated = async (provider: TracingProvider) => { @@ -116,6 +121,8 @@ const Panel: FC = () => { setOpikConfig(tracing_config as OpikConfig) else if (provider === TracingProvider.weave) setWeaveConfig(tracing_config as WeaveConfig) + else if (provider === TracingProvider.aliyun) + setAliyunConfig(tracing_config as AliyunConfig) } const handleTracingConfigRemoved = (provider: TracingProvider) => { @@ -131,6 +138,8 @@ const Panel: FC = () => { setOpikConfig(null) else if (provider === TracingProvider.weave) setWeaveConfig(null) + else if (provider === TracingProvider.aliyun) + setAliyunConfig(null) if (provider === inUseTracingProvider) { handleTracingStatusChange({ enabled: false, @@ -191,6 +200,7 @@ const Panel: FC = () => { langFuseConfig={langFuseConfig} opikConfig={opikConfig} weaveConfig={weaveConfig} + aliyunConfig={aliyunConfig} onConfigUpdated={handleTracingConfigUpdated} onConfigRemoved={handleTracingConfigRemoved} controlShowPopup={controlShowPopup} @@ -228,6 +238,7 @@ const Panel: FC = () => { langFuseConfig={langFuseConfig} opikConfig={opikConfig} weaveConfig={weaveConfig} + aliyunConfig={aliyunConfig} onConfigUpdated={handleTracingConfigUpdated} onConfigRemoved={handleTracingConfigRemoved} controlShowPopup={controlShowPopup} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index 67cac95964..318f1f61d6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import Field from './field' -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import { docURL } from './config' import { @@ -22,10 +22,10 @@ import Divider from '@/app/components/base/divider' type Props = { appId: string type: TracingProvider - payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | null + payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | null onRemoved: () => void onCancel: () => void - onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void + onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void onChosen: (provider: TracingProvider) => void } @@ -71,6 +71,12 @@ const weaveConfigTemplate = { host: '', } +const aliyunConfigTemplate = { + app_name: '', + license_key: '', + endpoint: '', +} + const ProviderConfigModal: FC = ({ appId, type, @@ -84,7 +90,7 @@ const ProviderConfigModal: FC = ({ const isEdit = !!payload const isAdd = !isEdit const [isSaving, setIsSaving] = useState(false) - const [config, setConfig] = useState((() => { + const [config, setConfig] = useState((() => { if (isEdit) return payload @@ -103,6 +109,9 @@ const ProviderConfigModal: FC = ({ else if (type === TracingProvider.opik) return opikConfigTemplate + else if (type === TracingProvider.aliyun) + return aliyunConfigTemplate + return weaveConfigTemplate })()) const [isShowRemoveConfirm, { @@ -183,6 +192,16 @@ const ProviderConfigModal: FC = ({ errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) }) } + if (type === TracingProvider.aliyun) { + const postData = config as AliyunConfig + if (!errorMessage && !postData.app_name) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'App Name' }) + if (!errorMessage && !postData.license_key) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'License Key' }) + if (!errorMessage && !postData.endpoint) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' }) + } + return errorMessage }, [config, t, type]) const handleSave = useCallback(async () => { @@ -294,6 +313,31 @@ const ProviderConfigModal: FC = ({ /> )} + {type === TracingProvider.aliyun && ( + <> + + + + + )} {type === TracingProvider.weave && ( <> { [TracingProvider.langfuse]: LangfuseIconBig, [TracingProvider.opik]: OpikIconBig, [TracingProvider.weave]: WeaveIconBig, + [TracingProvider.aliyun]: AliyunIconBig, })[type] } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts index 7e934b74c6..78bca41ad2 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts @@ -5,6 +5,7 @@ export enum TracingProvider { langfuse = 'langfuse', opik = 'opik', weave = 'weave', + aliyun = 'aliyun', } export type ArizeConfig = { @@ -46,3 +47,9 @@ export type WeaveConfig = { endpoint: string host: string } + +export type AliyunConfig = { + app_name: string + license_key: string + endpoint: string +} diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index fb3a9087ca..acaae3f720 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -77,7 +77,7 @@ const ExtraInfo = ({ isMobile, relatedApps, expand }: IExtraInfoProps) => { )} - {isMobile &&
+ {isMobile &&
{relatedAppsTotal || '--'}
} diff --git a/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg b/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg new file mode 100644 index 0000000000..210a1cd00b --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg @@ -0,0 +1 @@ + diff --git a/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg b/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg new file mode 100644 index 0000000000..6f7645301c --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg @@ -0,0 +1 @@ + diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.json b/web/app/components/base/icons/src/public/tracing/AliyunIcon.json new file mode 100644 index 0000000000..5cbb52c237 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.json @@ -0,0 +1,118 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "fill": "none", + "version": "1.1", + "width": "65", + "height": "16", + "viewBox": "0 0 65 16" + }, + "children": [ + { + "type": "element", + "name": "defs", + "children": [ + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "master_svg0_42_34281" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "x": "0", + "y": "0", + "width": "19", + "height": "16", + "rx": "0" + } + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "clip-path": "url(#master_svg0_42_34281)" + }, + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4.06862,14.6667C3.79213,14.6667,3.45463,14.5688,3.05614,14.373C2.97908,14.3351,2.92692,14.3105,2.89968,14.2992C2.33193,14.0628,1.82911,13.7294,1.39123,13.2989C0.463742,12.3871,0,11.2874,0,10C0,8.71258,0.463742,7.61293,1.39123,6.70107C2.16172,5.94358,3.06404,5.50073,4.09819,5.37252C4.23172,3.98276,4.81755,2.77756,5.85569,1.75693C7.04708,0.585642,8.4857,0,10.1716,0C11.5256,0,12.743,0.396982,13.8239,1.19095C14.8847,1.97019,15.61,2.97855,16,4.21604L14.7045,4.61063C14.4016,3.64918,13.8374,2.86532,13.0121,2.25905C12.1719,1.64191,11.2251,1.33333,10.1716,1.33333C8.8602,1.33333,7.74124,1.7888,6.81467,2.69974C5.88811,3.61067,5.42483,4.71076,5.42483,6L5.42483,6.66667L4.74673,6.66667C3.81172,6.66667,3.01288,6.99242,2.35021,7.64393C1.68754,8.2954,1.35621,9.08076,1.35621,10C1.35621,10.9192,1.68754,11.7046,2.35021,12.3561C2.66354,12.6641,3.02298,12.9026,3.42852,13.0714C3.48193,13.0937,3.55988,13.13,3.66237,13.1803C3.87004,13.2823,4.00545,13.3333,4.06862,13.3333L4.06862,14.6667Z", + "fill-rule": "evenodd", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M13.458613505859375,7.779393492279053C12.975613505859375,7.717463492279053,12.484813505859375,7.686503492279053,11.993983505859376,7.686503492279053C11.152583505859376,7.686503492279053,10.303403505859375,7.779393492279053,9.493183505859374,7.941943492279053C8.682953505859375,8.104503492279052,7.903893505859375,8.359943492279053,7.155983505859375,8.654083492279053C6.657383505859375,8.870823492279053,6.158783505859375,9.128843492279053,5.660181505859375,9.428153492279053C5.332974751859375,9.621673492279053,5.239486705859375,10.070633492279054,5.434253505859375,10.395743492279053L7.413073505859375,13.298533492279052C7.639003505859375,13.623603492279052,8.090863505859375,13.716463492279052,8.418073505859375,13.523003492279052C8.547913505859375,13.435263492279052,8.763453505859374,13.326893492279053,9.064693505859374,13.197863492279053C9.516553505859374,13.004333492279052,9.976203505859374,12.872733492279053,10.459223505859375,12.779863492279052C10.942243505859375,12.679263492279052,11.433053505859375,12.617333492279052,11.955023505859375,12.617333492279052L13.380683505859375,7.810353492279052L13.458613505859375,7.779393492279053ZM15.273813505859374,8.135463492279053L15.016753505859375,5.333333492279053L13.458613505859375,7.787133492279053C13.817013505859375,7.818093492279052,14.144213505859375,7.880023492279053,14.494743505859375,7.949683492279053C14.494743505859375,7.944523492279053,14.754433505859375,8.006453492279054,15.273813505859374,8.135463492279053ZM12.064083505859376,12.648273492279053L11.378523505859375,14.970463492279054L12.515943505859376,16.00003349227905L14.074083505859376,15.643933492279054L14.525943505859376,13.027603492279052C14.198743505859374,12.934663492279054,13.879283505859375,12.834063492279054,13.552083505859375,12.772133492279053C13.069083505859375,12.717933492279052,12.578283505859375,12.648273492279053,12.064083505859376,12.648273492279053ZM18.327743505859374,9.428153492279053C17.829143505859374,9.128843492279053,17.330543505859374,8.870823492279053,16.831943505859375,8.654083492279053C16.348943505859374,8.460573492279053,15.826943505859376,8.267053492279054,15.305013505859375,8.135463492279053L15.305013505859375,8.267053492279054L14.463613505859374,13.043063492279053C14.596083505859376,13.105003492279053,14.759683505859375,13.135933492279053,14.884283505859376,13.205603492279053C15.185523505859376,13.334623492279052,15.401043505859375,13.443003492279052,15.530943505859375,13.530733492279053C15.858143505859376,13.724263492279054,16.341143505859375,13.623603492279052,16.535943505859375,13.306263492279053L18.514743505859375,10.403483492279053C18.779643505859376,10.039673492279054,18.686143505859377,9.621673492279053,18.327743505859374,9.428153492279053Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M25.044,2.668L34.676,2.668L34.676,4.04L25.044,4.04L25.044,2.668ZM29.958,7.82Q29.258,9.066,28.355,10.41Q27.451999999999998,11.754,26.92,12.3L32.506,11.782Q31.442,10.158,30.84,9.346L32.058,8.562000000000001Q32.786,9.5,33.843,11.012Q34.9,12.524,35.516,13.546L34.214,14.526Q33.891999999999996,13.966,33.346000000000004,13.098Q32.016,13.182,29.734,13.378Q27.451999999999998,13.574,25.87,13.742L25.31,13.812L24.834,13.882L24.414,12.468Q24.708,12.37,24.862000000000002,12.265Q25.016,12.16,25.121,12.069Q25.226,11.978,25.268,11.936Q25.912,11.32,26.724,10.165Q27.536,9.01,28.208,7.82L23.854,7.82L23.854,6.434L35.866,6.434L35.866,7.82L29.958,7.82ZM42.656,7.414L42.656,8.576L41.354,8.576L41.354,1.814L42.656,1.87L42.656,7.036Q43.314,5.846,43.888000000000005,4.369Q44.462,2.892,44.714,1.6600000000000001L46.086,1.981999Q45.96,2.612,45.722,3.41L49.6,3.41L49.6,4.74L45.274,4.74Q44.616,6.56,43.706,8.128L42.656,7.414ZM38.596000000000004,2.346L39.884,2.402L39.884,8.212L38.596000000000004,8.212L38.596000000000004,2.346ZM46.184,4.964Q46.688,5.356,47.5,6.175Q48.312,6.994,48.788,7.582L47.751999999999995,8.59Q47.346000000000004,8.072,46.576,7.274Q45.806,6.476,45.204,5.902L46.184,4.964ZM48.41,9.01L48.41,12.706L49.894,12.706L49.894,13.966L37.391999999999996,13.966L37.391999999999996,12.706L38.848,12.706L38.848,9.01L48.41,9.01ZM41.676,10.256L40.164,10.256L40.164,12.706L41.676,12.706L41.676,10.256ZM42.908,12.706L44.364000000000004,12.706L44.364000000000004,10.256L42.908,10.256L42.908,12.706ZM45.582,12.706L47.108000000000004,12.706L47.108000000000004,10.256L45.582,10.256L45.582,12.706ZM54.906,7.456L55.116,8.394L54.178,8.814L54.178,12.818Q54.178,13.434,54.031,13.735Q53.884,14.036,53.534,14.162Q53.184,14.288,52.456,14.358L51.867999999999995,14.414L51.476,13.084L52.162,13.028Q52.512,13,52.652,12.958Q52.792,12.916,52.841,12.797Q52.89,12.678,52.89,12.384L52.89,9.36Q51.980000000000004,9.724,51.322,9.948L51.013999999999996,8.576Q51.798,8.324,52.89,7.876L52.89,5.524L51.42,5.524L51.42,4.166L52.89,4.166L52.89,1.7579989999999999L54.178,1.814L54.178,4.166L55.214,4.166L55.214,5.524L54.178,5.524L54.178,7.316L54.808,7.022L54.906,7.456ZM56.894,4.5440000000000005L56.894,6.098L55.564,6.098L55.564,3.256L58.686,3.256Q58.42,2.346,58.266,1.9260000000000002L59.624,1.7579989999999999Q59.848,2.276,60.142,3.256L63.25,3.256L63.25,6.098L61.962,6.098L61.962,4.5440000000000005L56.894,4.5440000000000005ZM59.008,6.322Q58.392,6.938,57.685,7.512Q56.978,8.086,55.956,8.841999999999999L55.242,7.764Q56.824,6.728,58.126,5.37L59.008,6.322ZM60.422,5.37Q61.024,5.776,62.095,6.581Q63.166,7.386,63.656,7.806L62.942,8.982Q62.368,8.45,61.332,7.652Q60.296,6.854,59.666,6.434L60.422,5.37ZM62.592,10.256L60.044,10.256L60.044,12.566L63.572,12.566L63.572,13.826L55.144,13.826L55.144,12.566L58.63,12.566L58.63,10.256L56.054,10.256L56.054,8.982L62.592,8.982L62.592,10.256Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + } + ] + }, + "name": "AliyunIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx new file mode 100644 index 0000000000..5b062b8a86 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AliyunIcon.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AliyunIcon' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json new file mode 100644 index 0000000000..ea60744daf --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json @@ -0,0 +1,71 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "fill": "none", + "version": "1.1", + "width": "96", + "height": "24", + "viewBox": "0 0 96 24" + }, + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.10294,22C5.68819,22,5.18195,21.8532,4.58421,21.5595C4.46861,21.5027,4.39038,21.4658,4.34951,21.4488C3.49789,21.0943,2.74367,20.5941,2.08684,19.9484C0.695613,18.5806,0,16.9311,0,15C0,13.0689,0.695612,11.4194,2.08684,10.0516C3.24259,8.91537,4.59607,8.2511,6.14728,8.05878C6.34758,5.97414,7.22633,4.16634,8.78354,2.63539C10.5706,0.878463,12.7286,0,15.2573,0C17.2884,0,19.1146,0.595472,20.7358,1.78642C22.327,2.95528,23.4151,4.46783,24,6.32406L22.0568,6.91594C21.6024,5.47377,20.7561,4.29798,19.5181,3.38858C18.2579,2.46286,16.8377,2,15.2573,2C13.2903,2,11.6119,2.6832,10.222,4.04961C8.83217,5.41601,8.13725,7.06614,8.13725,9L8.13725,10L7.12009,10C5.71758,10,4.51932,10.4886,3.52532,11.4659C2.53132,12.4431,2.03431,13.6211,2.03431,15C2.03431,16.3789,2.53132,17.5569,3.52532,18.5341C3.99531,18.9962,4.53447,19.3538,5.14278,19.6071C5.2229,19.6405,5.33983,19.695,5.49356,19.7705C5.80505,19.9235,6.00818,20,6.10294,20L6.10294,22Z", + "fill-rule": "evenodd", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M20.18796103515625,11.66909C19.46346103515625,11.5762,18.72726103515625,11.52975,17.991011035156248,11.52975C16.728921035156247,11.52975,15.45515103515625,11.66909,14.23981103515625,11.91292C13.02447103515625,12.156749999999999,11.85588103515625,12.539909999999999,10.73402103515625,12.98113C9.98612103515625,13.306239999999999,9.23822103515625,13.69327,8.49031803515625,14.14223C7.99950790415625,14.43251,7.85927603515625,15.10595,8.15142503515625,15.59361L11.11966103515625,19.9478C11.45855103515625,20.4354,12.13634103515625,20.5747,12.627151035156249,20.2845C12.821921035156251,20.152900000000002,13.14523103515625,19.990299999999998,13.59708103515625,19.796799999999998C14.27487103515625,19.506500000000003,14.964341035156249,19.3091,15.68887103515625,19.169800000000002C16.413401035156248,19.018900000000002,17.14962103515625,18.926000000000002,17.93258103515625,18.926000000000002L20.071061035156248,11.715530000000001L20.18796103515625,11.66909ZM22.91076103515625,12.20319L22.525161035156252,8L20.18796103515625,11.6807C20.72556103515625,11.72714,21.21636103515625,11.82003,21.74216103515625,11.92453C21.74216103515625,11.91679,22.13166103515625,12.00968,22.91076103515625,12.20319ZM18.09616103515625,18.9724L17.06782103515625,22.4557L18.773961035156248,24L21.11116103515625,23.465899999999998L21.788961035156248,19.5414C21.298161035156248,19.402,20.81896103515625,19.2511,20.32816103515625,19.1582C19.60366103515625,19.076900000000002,18.86746103515625,18.9724,18.09616103515625,18.9724ZM27.49166103515625,14.14223C26.74376103515625,13.69327,25.99586103515625,13.306239999999999,25.24796103515625,12.98113C24.52346103515625,12.69086,23.74046103515625,12.40058,22.95756103515625,12.20319L22.95756103515625,12.40058L21.69546103515625,19.5646C21.89416103515625,19.6575,22.139561035156248,19.7039,22.32646103515625,19.8084C22.77836103515625,20.0019,23.101661035156248,20.1645,23.29646103515625,20.2961C23.78726103515625,20.586399999999998,24.51176103515625,20.4354,24.80396103515625,19.959400000000002L27.77216103515625,15.605229999999999C28.16946103515625,15.05951,28.02926103515625,14.43251,27.49166103515625,14.14223Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M35.785,3.8624638671875L50.233000000000004,3.8624638671875L50.233000000000004,5.9204638671875L35.785,5.9204638671875L35.785,3.8624638671875ZM43.156,11.5904638671875Q42.106,13.4594638671875,40.7515,15.4754638671875Q39.397,17.4914638671875,38.599000000000004,18.3104638671875L46.978,17.5334638671875Q45.382,15.0974638671875,44.479,13.8794638671875L46.306,12.7034638671875Q47.397999999999996,14.1104638671875,48.9835,16.3784638671875Q50.569,18.6464638671875,51.492999999999995,20.1794638671875L49.54,21.6494638671875Q49.057,20.8094638671875,48.238,19.5074638671875Q46.243,19.6334638671875,42.82,19.9274638671875Q39.397,20.2214638671875,37.024,20.4734638671875L36.184,20.5784638671875L35.47,20.6834638671875L34.84,18.5624638671875Q35.281,18.4154638671875,35.512,18.2579638671875Q35.743,18.1004638671875,35.9005,17.963963867187502Q36.058,17.8274638671875,36.121,17.7644638671875Q37.087,16.840463867187502,38.305,15.1079638671875Q39.522999999999996,13.3754638671875,40.531,11.5904638671875L34,11.5904638671875L34,9.5114638671875L52.018,9.5114638671875L52.018,11.5904638671875L43.156,11.5904638671875ZM62.203,10.9814638671875L62.203,12.7244638671875L60.25,12.7244638671875L60.25,2.5814638671875L62.203,2.6654638671875L62.203,10.4144638671875Q63.19,8.6294638671875,64.051,6.4139638671875Q64.912,4.1984638671875,65.28999999999999,2.3504638671875L67.348,2.8334628671875Q67.15899999999999,3.7784638671875,66.80199999999999,4.9754638671875L72.619,4.9754638671875L72.619,6.9704638671875L66.13,6.9704638671875Q65.143,9.7004638671875,63.778,12.0524638671875L62.203,10.9814638671875ZM56.113,3.3794638671875L58.045,3.4634638671875L58.045,12.1784638671875L56.113,12.1784638671875L56.113,3.3794638671875ZM67.495,7.3064638671875Q68.251,7.8944638671875,69.469,9.1229638671875Q70.687,10.3514638671875,71.40100000000001,11.2334638671875L69.84700000000001,12.7454638671875Q69.238,11.9684638671875,68.083,10.7714638671875Q66.928,9.5744638671875,66.025,8.7134638671875L67.495,7.3064638671875ZM70.834,13.3754638671875L70.834,18.9194638671875L73.06,18.9194638671875L73.06,20.8094638671875L54.307,20.8094638671875L54.307,18.9194638671875L56.491,18.9194638671875L56.491,13.3754638671875L70.834,13.3754638671875ZM60.733000000000004,15.2444638671875L58.465,15.2444638671875L58.465,18.9194638671875L60.733000000000004,18.9194638671875L60.733000000000004,15.2444638671875ZM62.581,18.9194638671875L64.765,18.9194638671875L64.765,15.2444638671875L62.581,15.2444638671875L62.581,18.9194638671875ZM66.592,18.9194638671875L68.881,18.9194638671875L68.881,15.2444638671875L66.592,15.2444638671875L66.592,18.9194638671875ZM80.578,11.0444638671875L80.893,12.4514638671875L79.48599999999999,13.0814638671875L79.48599999999999,19.0874638671875Q79.48599999999999,20.0114638671875,79.2655,20.4629638671875Q79.045,20.9144638671875,78.52000000000001,21.1034638671875Q77.995,21.2924638671875,76.90299999999999,21.3974638671875L76.021,21.4814638671875L75.43299999999999,19.4864638671875L76.462,19.4024638671875Q76.987,19.3604638671875,77.197,19.2974638671875Q77.407,19.2344638671875,77.4805,19.0559638671875Q77.554,18.8774638671875,77.554,18.4364638671875L77.554,13.9004638671875Q76.189,14.4464638671875,75.202,14.7824638671875L74.74000000000001,12.7244638671875Q75.916,12.3464638671875,77.554,11.6744638671875L77.554,8.1464638671875L75.34899999999999,8.1464638671875L75.34899999999999,6.1094638671875L77.554,6.1094638671875L77.554,2.4974628671875L79.48599999999999,2.5814638671875L79.48599999999999,6.1094638671875L81.03999999999999,6.1094638671875L81.03999999999999,8.1464638671875L79.48599999999999,8.1464638671875L79.48599999999999,10.8344638671875L80.431,10.3934638671875L80.578,11.0444638671875ZM83.56,6.6764638671875L83.56,9.0074638671875L81.565,9.0074638671875L81.565,4.7444638671875L86.24799999999999,4.7444638671875Q85.84899999999999,3.3794638671875,85.618,2.7494638671875L87.655,2.4974628671875Q87.991,3.2744638671875,88.432,4.7444638671875L93.094,4.7444638671875L93.094,9.0074638671875L91.162,9.0074638671875L91.162,6.6764638671875L83.56,6.6764638671875ZM86.731,9.3434638671875Q85.807,10.2674638671875,84.7465,11.1284638671875Q83.686,11.9894638671875,82.15299999999999,13.1234638671875L81.082,11.5064638671875Q83.455,9.9524638671875,85.408,7.9154638671875L86.731,9.3434638671875ZM88.852,7.9154638671875Q89.755,8.5244638671875,91.3615,9.731963867187499Q92.968,10.9394638671875,93.703,11.5694638671875L92.632,13.3334638671875Q91.771,12.5354638671875,90.217,11.3384638671875Q88.663,10.1414638671875,87.718,9.5114638671875L88.852,7.9154638671875ZM92.107,15.2444638671875L88.285,15.2444638671875L88.285,18.7094638671875L93.577,18.7094638671875L93.577,20.5994638671875L80.935,20.5994638671875L80.935,18.7094638671875L86.164,18.7094638671875L86.164,15.2444638671875L82.3,15.2444638671875L82.3,13.3334638671875L92.107,13.3334638671875L92.107,15.2444638671875Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + }, + "name": "AliyunBigIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx new file mode 100644 index 0000000000..0924f70fbd --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AliyunIconBig.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AliyunIconBig' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/index.ts b/web/app/components/base/icons/src/public/tracing/index.ts index 61a117e863..07e3385f46 100644 --- a/web/app/components/base/icons/src/public/tracing/index.ts +++ b/web/app/components/base/icons/src/public/tracing/index.ts @@ -11,3 +11,5 @@ export { default as PhoenixIcon } from './PhoenixIcon' export { default as TracingIcon } from './TracingIcon' export { default as WeaveIconBig } from './WeaveIconBig' export { default as WeaveIcon } from './WeaveIcon' +export { default as AliyunIconBig } from './AliyunIconBig' +export { default as AliyunIcon } from './AliyunIcon' diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx index 44cb0522f6..77d229672f 100644 --- a/web/app/components/base/select/index.tsx +++ b/web/app/components/base/select/index.tsx @@ -116,7 +116,7 @@ const Select: FC = ({ if (!disabled) setOpen(!open) } - } className={classNames(`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-state-base-hover group-hover:bg-state-base-hover`, optionClassName)}> + } className={classNames(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
{selectedItem?.name}
} = ({ value={item} className={({ active }: { active: boolean }) => classNames( - 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-state-base-hover text-text-secondary', + 'relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', active ? 'bg-state-base-hover' : '', optionClassName, ) @@ -225,8 +225,8 @@ const SimpleSelect: FC = ({ if (listboxRef.current) onOpenChange?.(listboxRef.current.getAttribute('data-open') !== null) }) - }} className={classNames(`flex items-center w-full h-full rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-state-base-hover-alt group-hover/simple-select:bg-state-base-hover-alt ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}> - {selectedItem?.name ?? localPlaceholder} + }} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}> + {selectedItem?.name ?? localPlaceholder} {isLoading ? : (selectedItem && !notClearable) @@ -252,13 +252,13 @@ const SimpleSelect: FC = ({ )} {(!disabled) && ( - + {items.map((item: Item) => ( = ({ : (
@@ -358,7 +358,7 @@ const PortalSelect: FC = ({
{items.map((item: Item) => (
setAppDetail()} className={classNames(` - flex items-center h-7 px-2.5 cursor-pointer rounded-[10px] + flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 ${isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text'} ${curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover'} `)} diff --git a/web/app/components/header/plugins-nav/index.tsx b/web/app/components/header/plugins-nav/index.tsx index b1f903bafb..7b28e27639 100644 --- a/web/app/components/header/plugins-nav/index.tsx +++ b/web/app/components/header/plugins-nav/index.tsx @@ -31,8 +31,8 @@ const PluginsNav = ({ )}>
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx index 83c5d60573..920757c6a6 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx @@ -98,7 +98,7 @@ const CodeEditor: FC = ({ }, []) return ( -
+
{!hideTopMenu && (
diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx index 4ae0b7f1a7..283c650f23 100644 --- a/web/app/signin/components/social-auth.tsx +++ b/web/app/signin/components/social-auth.tsx @@ -32,7 +32,7 @@ export default function SocialAuth(props: SocialAuthProps) { {t('login.withGitHub')} @@ -50,7 +50,7 @@ export default function SocialAuth(props: SocialAuthProps) { {t('login.withGoogle')} diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index b6496e9b9c..c6f35d3df2 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -174,6 +174,10 @@ const translation = { title: 'Weave', description: 'Weave is an open-source platform for evaluating, testing, and monitoring LLM applications.', }, + aliyun: { + title: 'LLM observability', + description: 'The SaaS observability platform provided by Alibaba Cloud enables out of box monitoring, tracing, and evaluation of Dify applications.', + }, inUse: 'In use', configProvider: { title: 'Config ', diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index 07ecc018fb..904ec7e5fa 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -185,6 +185,10 @@ const translation = { title: '编织', description: 'Weave 是一个开源平台,用于评估、测试和监控大型语言模型应用程序。', }, + aliyun: { + title: '大模型可观测', + description: '阿里云提供的SaaS化可观测平台,一键开启Dify应用的监控追踪和评估。', + }, }, appSelector: { label: '应用', diff --git a/web/models/app.ts b/web/models/app.ts index 369cf32387..5798670426 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -1,9 +1,9 @@ -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, TracingProvider, WeaveConfig } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' +import type { AliyunConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, TracingProvider, WeaveConfig } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' import type { App, AppTemplate, SiteConfig } from '@/types/app' import type { Dependency } from '@/app/components/plugins/types' /* export type App = { - id: string + id: strin name: string description: string mode: AppMode @@ -166,5 +166,5 @@ export type TracingStatus = { export type TracingConfig = { tracing_provider: TracingProvider - tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig + tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig } From 51f6095be7da87cbf0f49f24dd0c2f0a84944939 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Sat, 5 Jul 2025 12:45:29 +0800 Subject: [PATCH 008/241] minor fix: translation for pause (#21949) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- web/i18n/fa-IR/dataset-documents.ts | 2 +- web/i18n/ja-JP/dataset-documents.ts | 2 +- web/i18n/ro-RO/dataset-documents.ts | 2 +- web/i18n/ru-RU/dataset-documents.ts | 2 +- web/i18n/sl-SI/dataset-documents.ts | 6 +++--- web/i18n/tr-TR/dataset-documents.ts | 2 +- web/i18n/uk-UA/dataset-documents.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/i18n/fa-IR/dataset-documents.ts b/web/i18n/fa-IR/dataset-documents.ts index a67c49c126..8da7ba8959 100644 --- a/web/i18n/fa-IR/dataset-documents.ts +++ b/web/i18n/fa-IR/dataset-documents.ts @@ -29,7 +29,7 @@ const translation = { delete: 'حذف', enableWarning: 'فایل بایگانی شده نمی‌تواند فعال شود', sync: 'همگام‌سازی', - resume: 'رزومه', + resume: 'ادامه', pause: 'مکث', }, index: { diff --git a/web/i18n/ja-JP/dataset-documents.ts b/web/i18n/ja-JP/dataset-documents.ts index 1690545fe4..ac7a94fafc 100644 --- a/web/i18n/ja-JP/dataset-documents.ts +++ b/web/i18n/ja-JP/dataset-documents.ts @@ -31,7 +31,7 @@ const translation = { enableWarning: 'アーカイブされたファイルは有効にできません', sync: '同期', pause: '一時停止', - resume: '履歴書', + resume: '再開', }, index: { enable: '有効にする', diff --git a/web/i18n/ro-RO/dataset-documents.ts b/web/i18n/ro-RO/dataset-documents.ts index 4c6237fcd0..876ddde40b 100644 --- a/web/i18n/ro-RO/dataset-documents.ts +++ b/web/i18n/ro-RO/dataset-documents.ts @@ -29,7 +29,7 @@ const translation = { enableWarning: 'Fișierul arhivat nu poate fi activat', sync: 'Sincronizează', pause: 'Pauză', - resume: 'Relua', + resume: 'Reia', }, index: { enable: 'Activează', diff --git a/web/i18n/ru-RU/dataset-documents.ts b/web/i18n/ru-RU/dataset-documents.ts index 17d2c8a542..bbc221fba7 100644 --- a/web/i18n/ru-RU/dataset-documents.ts +++ b/web/i18n/ru-RU/dataset-documents.ts @@ -29,7 +29,7 @@ const translation = { delete: 'Удалить', enableWarning: 'Архивный файл не может быть включен', sync: 'Синхронизировать', - resume: 'Резюме', + resume: 'Продовжити', pause: 'Пауза', }, index: { diff --git a/web/i18n/sl-SI/dataset-documents.ts b/web/i18n/sl-SI/dataset-documents.ts index d4ea9e6376..e76055ad79 100644 --- a/web/i18n/sl-SI/dataset-documents.ts +++ b/web/i18n/sl-SI/dataset-documents.ts @@ -29,8 +29,8 @@ const translation = { delete: 'Izbriši', enableWarning: 'Arhivirane datoteke ni mogoče omogočiti', sync: 'Sinhroniziraj', - pause: 'Pavza', - resume: 'Življenjepis', + pause: 'Zaustavi', + resume: 'Nadaljuj', }, index: { enable: 'Omogoči', @@ -334,7 +334,7 @@ const translation = { previewTip: 'Predogled odstavkov bo na voljo po zaključku vdelave', hierarchical: 'Starš-otrok', childMaxTokens: 'Otrok', - pause: 'Pavza', + pause: 'Zaustavi', parentMaxTokens: 'Starš', }, segment: { diff --git a/web/i18n/tr-TR/dataset-documents.ts b/web/i18n/tr-TR/dataset-documents.ts index 8536be3e4e..e7118b8c9b 100644 --- a/web/i18n/tr-TR/dataset-documents.ts +++ b/web/i18n/tr-TR/dataset-documents.ts @@ -30,7 +30,7 @@ const translation = { enableWarning: 'Arşivlenmiş dosya etkinleştirilemez', sync: 'Senkronize et', pause: 'Duraklat', - resume: 'Özgeçmiş', + resume: 'Devam Et', }, index: { enable: 'Etkinleştir', diff --git a/web/i18n/uk-UA/dataset-documents.ts b/web/i18n/uk-UA/dataset-documents.ts index 222d8e8502..27642f1994 100644 --- a/web/i18n/uk-UA/dataset-documents.ts +++ b/web/i18n/uk-UA/dataset-documents.ts @@ -29,7 +29,7 @@ const translation = { enableWarning: 'Архівований файл неможливо активувати', sync: 'Синхронізувати', pause: 'Пауза', - resume: 'Резюме', + resume: 'Продовжити', }, index: { enable: 'Активувати', From 8288145ee4df97b81a6dd57ac6785c2acda62b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=88=EC=9E=AC=EC=9B=90?= Date: Mon, 7 Jul 2025 10:33:09 +0900 Subject: [PATCH 009/241] chore(i18n): fix typos and improve Korean translation (#21955) --- web/i18n/ko-KR/app.ts | 89 ++++++++++++------ web/i18n/ko-KR/billing.ts | 59 +++++++----- web/i18n/ko-KR/education.ts | 14 ++- web/i18n/ko-KR/plugin-tags.ts | 2 +- web/i18n/ko-KR/time.ts | 10 +- web/i18n/ko-KR/workflow.ts | 169 ++++++++++++++++++++++------------ 6 files changed, 221 insertions(+), 122 deletions(-) diff --git a/web/i18n/ko-KR/app.ts b/web/i18n/ko-KR/app.ts index fab6d8cba5..e9634bfbde 100644 --- a/web/i18n/ko-KR/app.ts +++ b/web/i18n/ko-KR/app.ts @@ -16,7 +16,8 @@ const translation = { importDSL: 'DSL 파일 가져오기', createFromConfigFile: 'DSL 파일에서 생성하기', deleteAppConfirmTitle: '이 앱을 삭제하시겠습니까?', - deleteAppConfirmContent: '앱을 삭제하면 복구할 수 없습니다. 사용자는 더 이상 앱에 액세스할 수 없으며 모든 프롬프트 설정 및 로그가 영구적으로 삭제됩니다.', + deleteAppConfirmContent: + '앱을 삭제하면 복구할 수 없습니다. 사용자는 더 이상 앱에 액세스할 수 없으며 모든 프롬프트 설정 및 로그가 영구적으로 삭제됩니다.', appDeleted: '앱이 삭제되었습니다', appDeleteFailed: '앱 삭제 실패', join: '커뮤니티에 참여하기', @@ -26,20 +27,25 @@ const translation = { startFromBlank: '빈 상태로 시작', startFromTemplate: '템플릿에서 시작', captionAppType: '어떤 종류의 앱을 만들어 보시겠어요?', - chatbotDescription: '대화형 어플리케이션을 만듭니다. 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', - completionDescription: '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만듭니다. 기사, 요약, 번역 등을 생성할 수 있습니다.', + chatbotDescription: + '대화형 어플리케이션을 만듭니다. 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', + completionDescription: + '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만듭니다. 기사, 요약, 번역 등을 생성할 수 있습니다.', completionWarning: '이 종류의 앱은 더 이상 지원되지 않습니다.', agentDescription: '작업을 자동으로 완료하는 지능형 에이전트를 만듭니다.', - workflowDescription: '고도로 사용자 지정 가능한 워크플로우에 기반한 고품질 텍스트 생성 어플리케이션을 만듭니다. 경험 있는 사용자를 위한 것입니다.', + workflowDescription: + '고도로 사용자 지정 가능한 워크플로우에 기반한 고품질 텍스트 생성 어플리케이션을 만듭니다. 경험 있는 사용자를 위한 것입니다.', workflowWarning: '현재 베타 버전입니다.', chatbotType: '챗봇 오케스트레이션 방식', basic: '기본', basicTip: '초보자용. 나중에 Chatflow 로 전환할 수 있습니다.', basicFor: '초보자용', - basicDescription: '기본 오케스트레이션은 내장된 프롬프트를 수정할 수 없고 간단한 설정을 사용하여 챗봇 앱을 오케스트레이션합니다. 초보자용입니다.', + basicDescription: + '기본 오케스트레이션은 내장된 프롬프트를 수정할 수 없고 간단한 설정을 사용하여 챗봇 앱을 오케스트레이션합니다. 초보자용입니다.', advanced: 'Chatflow', advancedFor: '고급 사용자용', - advancedDescription: '워크플로우 오케스트레이션은 워크플로우 형식으로 챗봇을 오케스트레이션하며 내장된 프롬프트를 편집할 수 있는 고급 사용자 정의 기능을 제공합니다. 경험이 많은 사용자용입니다.', + advancedDescription: + '워크플로우 오케스트레이션은 워크플로우 형식으로 챗봇을 오케스트레이션하며 내장된 프롬프트를 편집할 수 있는 고급 사용자 정의 기능을 제공합니다. 경험이 많은 사용자용입니다.', captionName: '앱 아이콘과 이름', appNamePlaceholder: '앱 이름을 입력하세요', captionDescription: '설명', @@ -47,10 +53,12 @@ const translation = { useTemplate: '이 템플릿 사용', previewDemo: '데모 미리보기', chatApp: '어시스턴트', - chatAppIntro: '대화형 어플리케이션을 만들고 싶어요. 이 어플리케이션은 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', + chatAppIntro: + '대화형 어플리케이션을 만들고 싶어요. 이 어플리케이션은 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', agentAssistant: '새로운 에이전트 어시스턴트', completeApp: '텍스트 생성기', - completeAppIntro: '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만들고 싶어요. 기사, 요약, 번역 등을 생성합니다.', + completeAppIntro: + '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만들고 싶어요. 기사, 요약, 번역 등을 생성합니다.', showTemplates: '템플릿 선택', hideTemplates: '모드 선택으로 돌아가기', Create: '만들기', @@ -66,13 +74,16 @@ const translation = { appCreateDSLErrorTitle: '버전 비호환성', appCreateDSLErrorPart2: '계속하시겠습니까?', appCreateDSLErrorPart3: '현재 응용 프로그램 DSL 버전:', - appCreateDSLWarning: '주의: DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', - appCreateDSLErrorPart1: 'DSL 버전에서 상당한 차이가 감지되었습니다. 강제로 가져오면 응용 프로그램이 오작동할 수 있습니다.', + appCreateDSLWarning: + '주의: DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', + appCreateDSLErrorPart1: + 'DSL 버전에서 상당한 차이가 감지되었습니다. 강제로 가져오면 응용 프로그램이 오작동할 수 있습니다.', chooseAppType: '앱 유형 선택', forBeginners: '초보자용 기본 앱 유형', forAdvanced: '고급 사용자용', chatbotShortDescription: '간단한 설정으로 LLM 기반 챗봇', - workflowUserDescription: '드래그 앤 드롭으로 자율 AI 워크플로우를 시각적으로 구축', + workflowUserDescription: + '드래그 앤 드롭으로 자율 AI 워크플로우를 시각적으로 구축', noTemplateFoundTip: '다른 키워드를 사용하여 검색해 보십시오.', noIdeaTip: '아이디어가 없으신가요? 템플릿을 확인해 보세요', optional: '선택적', @@ -80,16 +91,20 @@ const translation = { completionShortDescription: '텍스트 생성 작업을 위한 AI 도우미', learnMore: '더 알아보세요', foundResults: '{{개수}} 결과', - agentShortDescription: '추론 및 자율적인 도구 사용 기능이 있는 지능형 에이전트', + agentShortDescription: + '추론 및 자율적인 도구 사용 기능이 있는 지능형 에이전트', advancedShortDescription: '다중 대화를 위해 강화된 워크플로우', noAppsFound: '앱을 찾을 수 없습니다.', foundResult: '{{개수}} 결과', - completionUserDescription: '간단한 구성으로 텍스트 생성 작업을 위한 AI 도우미를 빠르게 구축합니다.', - chatbotUserDescription: '간단한 구성으로 LLM 기반 챗봇을 빠르게 구축할 수 있습니다. 나중에 Chatflow 로 전환할 수 있습니다.', + completionUserDescription: + '간단한 구성으로 텍스트 생성 작업을 위한 AI 도우미를 빠르게 구축합니다.', + chatbotUserDescription: + '간단한 구성으로 LLM 기반 챗봇을 빠르게 구축할 수 있습니다. 나중에 Chatflow 로 전환할 수 있습니다.', workflowShortDescription: '지능형 자동화를 위한 에이전트 플로우', - agentUserDescription: '작업 목표를 달성하기 위해 반복적인 추론과 자율적인 도구를 사용할 수 있는 지능형 에이전트입니다.', + agentUserDescription: + '작업 목표를 달성하기 위해 반복적인 추론과 자율적인 도구를 사용할 수 있는 지능형 에이전트입니다.', advancedUserDescription: '메모리 기능과 챗봇 인터페이스를 갖춘 워크플로우', - dropDSLToCreateApp: '여기에 DSL 파일을 드롭하여 앱을 만드세요.', + dropDSLToCreateApp: '여기에 DSL 파일을 드롭하여 앱을 불러오세요.', }, editApp: '정보 편집하기', editAppTitle: '앱 정보 편집하기', @@ -102,7 +117,8 @@ const translation = { image: '이미지', }, switch: '워크플로우 오케스트레이션으로 전환하기', - switchTipStart: '새로운 앱의 복사본이 생성되어 새로운 복사본이 워크플로우 오케스트레이션으로 전환됩니다. 새로운 복사본은 ', + switchTipStart: + '새로운 앱의 복사본이 생성되어 새로운 복사본이 워크플로우 오케스트레이션으로 전환됩니다. 새로운 복사본은 ', switchTip: '전환을 허용하지 않습니다', switchTipEnd: ' 기본적인 오케스트레이션으로 되돌릴 수 없습니다.', switchLabel: '생성될 앱의 복사본', @@ -126,7 +142,8 @@ const translation = { disabled: '비활성화됨', disabledTip: '먼저 제공업체를 구성해 주세요', enabled: '서비스 중', - tracingDescription: 'LLM 호출, 컨텍스트, 프롬프트, HTTP 요청 등 앱 실행의 전체 컨텍스트를 제 3 자 추적 플랫폼에 캡처합니다.', + tracingDescription: + 'LLM 호출, 컨텍스트, 프롬프트, HTTP 요청 등 앱 실행의 전체 컨텍스트를 제 3 자 추적 플랫폼에 캡처합니다.', configProviderTitle: { configured: '구성됨', notConfigured: '추적을 활성화하려면 제공업체를 구성하세요', @@ -134,19 +151,23 @@ const translation = { }, arize: { title: 'Arize', - description: '엔터프라이즈급 LLM 가시성, 온라인 및 오프라인 평가, 모니터링 및 실험—OpenTelemetry를 기반으로 합니다. LLM 및 에이전트 기반 애플리케이션을 위해 특별히 설계되었습니다.', + description: + '엔터프라이즈급 LLM 가시성, 온라인 및 오프라인 평가, 모니터링 및 실험—OpenTelemetry를 기반으로 합니다. LLM 및 에이전트 기반 애플리케이션을 위해 특별히 설계되었습니다.', }, phoenix: { title: 'Phoenix', - description: '오픈소스 및 OpenTelemetry 기반의 가시성, 평가, 프롬프트 엔지니어링 및 실험 플랫폼으로, LLM 워크플로우 및 에이전트를 지원합니다.', + description: + '오픈소스 및 OpenTelemetry 기반의 가시성, 평가, 프롬프트 엔지니어링 및 실험 플랫폼으로, LLM 워크플로우 및 에이전트를 지원합니다.', }, langsmith: { title: 'LangSmith', - description: 'LLM 기반 애플리케이션 수명 주기의 모든 단계를 위한 올인원 개발자 플랫폼.', + description: + 'LLM 기반 애플리케이션 수명 주기의 모든 단계를 위한 올인원 개발자 플랫폼.', }, langfuse: { title: 'Langfuse', - description: 'LLM 애플리케이션을 디버그하고 개선하기 위한 추적, 평가, 프롬프트 관리 및 메트릭.', + description: + 'LLM 애플리케이션을 디버그하고 개선하기 위한 추적, 평가, 프롬프트 관리 및 메트릭.', }, inUse: '사용 중', configProvider: { @@ -157,22 +178,27 @@ const translation = { secretKey: '비밀 키', viewDocsLink: '{{key}} 문서 보기', removeConfirmTitle: '{{key}} 구성을 제거하시겠습니까?', - removeConfirmContent: '현재 구성이 사용 중입니다. 제거하면 추적 기능이 꺼집니다.', + removeConfirmContent: + '현재 구성이 사용 중입니다. 제거하면 추적 기능이 꺼집니다.', }, view: '보기', opik: { title: '오픽', - description: 'Opik 은 LLM 애플리케이션을 평가, 테스트 및 모니터링하기 위한 오픈 소스 플랫폼입니다.', + description: + 'Opik 은 LLM 애플리케이션을 평가, 테스트 및 모니터링하기 위한 오픈 소스 플랫폼입니다.', }, weave: { title: '직조하다', - description: 'Weave 는 LLM 애플리케이션을 평가하고 테스트하며 모니터링하기 위한 오픈 소스 플랫폼입니다.', + description: + 'Weave 는 LLM 애플리케이션을 평가하고 테스트하며 모니터링하기 위한 오픈 소스 플랫폼입니다.', }, }, answerIcon: { - description: 'web app 아이콘을 사용하여 공유 응용 프로그램에서 바꿀🤖지 여부', + description: + 'web app 아이콘을 사용하여 공유 응용 프로그램에서 바꿀🤖지 여부', title: 'web app 아이콘을 사용하여 🤖', - descriptionInExplore: 'Explore 에서 web app 아이콘을 사용하여 바꿀🤖지 여부', + descriptionInExplore: + 'Explore 에서 web app 아이콘을 사용하여 바꿀🤖지 여부', }, importFromDSL: 'DSL 에서 가져오기', importFromDSLFile: 'DSL 파일에서', @@ -211,8 +237,10 @@ const translation = { structured: '구조화된', configure: '설정하다', moreFillTip: '최대 10 단계 중첩을 표시합니다.', - modelNotSupportedTip: '현재 모델은 이 기능을 지원하지 않으며 자동으로 프롬프트 주입으로 다운그레이드됩니다.', - structuredTip: '구조화된 출력은 모델이 제공한 JSON 스키마를 항상 준수하는 응답을 생성하도록 보장하는 기능입니다.', + modelNotSupportedTip: + '현재 모델은 이 기능을 지원하지 않으며 자동으로 프롬프트 주입으로 다운그레이드됩니다.', + structuredTip: + '구조화된 출력은 모델이 제공한 JSON 스키마를 항상 준수하는 응답을 생성하도록 보장하는 기능입니다.', }, accessItemsDescription: { anyone: '누구나 웹 앱에 접근할 수 있습니다.', @@ -240,7 +268,8 @@ const translation = { members_one: '{{count}} 회원', members_other: '{{count}} 회원', noGroupsOrMembers: '선택된 그룹 또는 멤버가 없습니다.', - webAppSSONotEnabledTip: '웹 앱 인증 방법을 구성하려면 엔터프라이즈 관리자인에게 문의하십시오.', + webAppSSONotEnabledTip: + '웹 앱 인증 방법을 구성하려면 엔터프라이즈 관리자인에게 문의하십시오.', updateSuccess: '업데이트가 성공적으로 완료되었습니다.', description: '웹 앱 접근 권한 설정', }, diff --git a/web/i18n/ko-KR/billing.ts b/web/i18n/ko-KR/billing.ts index 87ccf27fe0..fbb2609adc 100644 --- a/web/i18n/ko-KR/billing.ts +++ b/web/i18n/ko-KR/billing.ts @@ -9,7 +9,7 @@ const translation = { buyPermissionDeniedTip: '구독하려면 엔터프라이즈 관리자에게 문의하세요', plansCommon: { title: '당신에게 맞는 요금제를 선택하세요', - yearlyTip: '연간 구독 시 2 개월 무료!', + yearlyTip: '연간 구독 시 2개월 무료!', mostPopular: '가장 인기 있는', planRange: { monthly: '월간', @@ -20,21 +20,25 @@ const translation = { save: '절약 ', free: '무료', currentPlan: '현재 요금제', - contractSales: '영업에 문의하기', + contractSales: '영업팀에 문의하기', contractOwner: '팀 관리자에게 문의하기', startForFree: '무료로 시작하기', getStartedWith: '시작하기 ', - contactSales: '영업에 문의하기', - talkToSales: '영업과 상담하기', + contactSales: '영업팀에 문의하기', + talkToSales: '영업팀과 상담하기', modelProviders: '모델 제공자', teamMembers: '팀 멤버', buildApps: '앱 만들기', vectorSpace: '벡터 공간', - vectorSpaceBillingTooltip: '1MB 당 약 120 만 글자의 벡터화된 데이터를 저장할 수 있습니다 (OpenAI Embeddings 을 기반으로 추정되며 모델에 따라 다릅니다).', - vectorSpaceTooltip: '벡터 공간은 LLM 이 데이터를 이해하는 데 필요한 장기 기억 시스템입니다.', + vectorSpaceBillingTooltip: + '1MB 당 약 120 만 글자의 벡터화된 데이터를 저장할 수 있습니다 (OpenAI Embeddings 을 기반으로 추정되며 모델에 따라 다릅니다).', + vectorSpaceTooltip: + '벡터 공간은 LLM 이 데이터를 이해하는 데 필요한 장기 기억 시스템입니다.', documentProcessingPriority: '문서 처리 우선순위', - documentProcessingPriorityTip: '더 높은 문서 처리 우선순위를 원하시면 요금제를 업그레이드하세요.', - documentProcessingPriorityUpgrade: '더 높은 정확성과 빠른 속도로 데이터를 처리합니다.', + documentProcessingPriorityTip: + '더 높은 문서 처리 우선순위를 원하시면 요금제를 업그레이드하세요.', + documentProcessingPriorityUpgrade: + '더 높은 정확성과 빠른 속도로 데이터를 처리합니다.', priority: { 'standard': '표준', 'priority': '우선', @@ -60,31 +64,35 @@ const translation = { workflow: '워크플로우', llmLoadingBalancing: 'LLM 로드 밸런싱', bulkUpload: '문서 대량 업로드', - llmLoadingBalancingTooltip: '모델에 여러 API 키를 추가하여 API 속도 제한을 효과적으로 우회할 수 있습니다.', + llmLoadingBalancingTooltip: + '모델에 여러 API 키를 추가하여 API 속도 제한을 효과적으로 우회할 수 있습니다.', }, comingSoon: '곧 출시 예정', member: '멤버', memberAfter: '멤버', messageRequest: { title: '메시지 크레딧', - tooltip: 'GPT 제외 다양한 요금제에서의 메시지 호출 쿼터 (gpt4 제외). 제한을 초과하는 메시지는 OpenAI API 키를 사용합니다.', + tooltip: + 'GPT 제외 다양한 요금제에서의 메시지 호출 쿼터 (gpt4 제외). 제한을 초과하는 메시지는 OpenAI API 키를 사용합니다.', titlePerMonth: '{{count,number}} 메시지/월', }, annotatedResponse: { title: '주석 응답 쿼터', - tooltip: '수동으로 편집 및 응답 주석 달기로 앱의 사용자 정의 가능한 고품질 질의응답 기능을 제공합니다 (채팅 앱에만 해당).', + tooltip: + '수동으로 편집 및 응답 주석 달기로 앱의 사용자 정의 가능한 고품질 질의응답 기능을 제공합니다 (채팅 앱에만 해당).', }, - ragAPIRequestTooltip: 'Dify 의 지식베이스 처리 기능을 호출하는 API 호출 수를 나타냅니다.', + ragAPIRequestTooltip: + 'Dify 의 지식베이스 처리 기능을 호출하는 API 호출 수를 나타냅니다.', receiptInfo: '팀 소유자 및 팀 관리자만 구독 및 청구 정보를 볼 수 있습니다', annotationQuota: 'Annotation Quota(주석 할당량)', documentsUploadQuota: '문서 업로드 할당량', - freeTrialTipPrefix: '가입하고 받으세요', + freeTrialTipPrefix: '요금제에 가입하고 ', comparePlanAndFeatures: '계획 및 기능 비교', documents: '{{count,number}} 지식 문서', apiRateLimit: 'API 요금 한도', cloud: '클라우드 서비스', unlimitedApiRate: 'API 호출 속도 제한 없음', - freeTrialTip: '200 회의 OpenAI 호출에 대한 무료 체험.', + freeTrialTip: '200 회의 OpenAI 호출 무료 체험을 받으세요. ', annualBilling: '연간 청구', getStarted: '시작하기', apiRateLimitUnit: '{{count,number}}/일', @@ -94,10 +102,13 @@ const translation = { teamMember_other: '{{count,number}} 팀원', teamMember_one: '{{count,number}} 팀원', priceTip: '작업 공간당/', - apiRateLimitTooltip: 'Dify API 를 통한 모든 요청에는 API 요금 한도가 적용되며, 여기에는 텍스트 생성, 채팅 대화, 워크플로 실행 및 문서 처리가 포함됩니다.', + apiRateLimitTooltip: + 'Dify API 를 통한 모든 요청에는 API 요금 한도가 적용되며, 여기에는 텍스트 생성, 채팅 대화, 워크플로 실행 및 문서 처리가 포함됩니다.', documentsRequestQuota: '{{count,number}}/분 지식 요청 비율 제한', - documentsTooltip: '지식 데이터 소스에서 가져올 수 있는 문서 수에 대한 쿼터.', - documentsRequestQuotaTooltip: '지식 기반 내에서 작업 공간이 분당 수행할 수 있는 총 작업 수를 지정합니다. 여기에는 데이터 세트 생성, 삭제, 업데이트, 문서 업로드, 수정, 보관 및 지식 기반 쿼리가 포함됩니다. 이 지표는 지식 기반 요청의 성능을 평가하는 데 사용됩니다. 예를 들어, 샌드박스 사용자가 1 분 이내에 10 회의 연속 히트 테스트를 수행하면, 해당 작업 공간은 다음 1 분 동안 데이터 세트 생성, 삭제, 업데이트 및 문서 업로드 또는 수정과 같은 작업을 수행하는 것이 일시적으로 제한됩니다.', + documentsTooltip: + '지식 데이터 소스에서 가져올 수 있는 문서 수에 대한 쿼터.', + documentsRequestQuotaTooltip: + '지식 기반 내에서 작업 공간이 분당 수행할 수 있는 총 작업 수를 지정합니다. 여기에는 데이터 세트 생성, 삭제, 업데이트, 문서 업로드, 수정, 보관 및 지식 기반 쿼리가 포함됩니다. 이 지표는 지식 기반 요청의 성능을 평가하는 데 사용됩니다. 예를 들어, 샌드박스 사용자가 1 분 이내에 10 회의 연속 히트 테스트를 수행하면, 해당 작업 공간은 다음 1 분 동안 데이터 세트 생성, 삭제, 업데이트 및 문서 업로드 또는 수정과 같은 작업을 수행하는 것이 일시적으로 제한됩니다.', }, plans: { sandbox: { @@ -108,9 +119,10 @@ const translation = { }, professional: { name: '프로페셔널', - description: '개인 및 소규모 팀을 위해 더 많은 파워를 저렴한 가격에 제공합니다.', + description: + '개인 및 소규모 팀을 위해 더 많은 파워를 저렴한 가격에 제공합니다.', includesTitle: '무료 플랜에 추가로 포함된 항목:', - for: '독립 개발자/소규모 팀을 위한', + for: '1인 개발자/소규모 팀을 위한', }, team: { name: '팀', @@ -120,7 +132,8 @@ const translation = { }, enterprise: { name: '엔터프라이즈', - description: '대규모 미션 크리티컬 시스템을 위한 완전한 기능과 지원을 제공합니다.', + description: + '대규모 미션 크리티컬 시스템을 위한 완전한 기능과 지원을 제공합니다.', includesTitle: '팀 플랜에 추가로 포함된 항목:', features: { 2: '독점 기업 기능', @@ -178,7 +191,8 @@ const translation = { contactUs: '문의하기', fullTip1: '업그레이드하여 더 많은 앱을 만들기', fullTip2: '계획 한도에 도달했습니다.', - fullTip2des: '비활성 애플리케이션을 정리하여 사용량을 줄이거나 저희에게 문의하는 것이 좋습니다.', + fullTip2des: + '비활성 애플리케이션을 정리하여 사용량을 줄이거나 저희에게 문의하는 것이 좋습니다.', fullTip1des: '이 계획에서 앱을 구축할 수 있는 한계에 도달했습니다.', }, annotatedResponse: { @@ -192,7 +206,8 @@ const translation = { teamMembers: '팀원들', buildApps: '앱 만들기', documentsUploadQuota: '문서 업로드 한도', - vectorSpaceTooltip: '고품질 색인 모드를 사용하는 문서는 지식 데이터 저장소 자원을 소모합니다. 지식 데이터 저장소가 한도에 도달하면 새 문서를 업로드할 수 없습니다.', + vectorSpaceTooltip: + '고품질 색인 모드를 사용하는 문서는 지식 데이터 저장소 자원을 소모합니다. 지식 데이터 저장소가 한도에 도달하면 새 문서를 업로드할 수 없습니다.', }, teamMembers: '팀원들', } diff --git a/web/i18n/ko-KR/education.ts b/web/i18n/ko-KR/education.ts index eba00b0f9f..2dfc111d46 100644 --- a/web/i18n/ko-KR/education.ts +++ b/web/i18n/ko-KR/education.ts @@ -2,7 +2,8 @@ const translation = { toVerifiedTip: { end: 'Dify 프로페셔널 플랜을 위해.', coupon: '독점 100% 쿠폰', - front: '당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오.', + front: + '당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오.', }, form: { schoolName: { @@ -26,15 +27,18 @@ const translation = { privacyPolicy: '개인정보 보호정책', }, option: { - inSchool: '나는 제공된 기관에 재학 중이거나 고용되어 있음을 확인합니다. Dify 는 재학증명서나 고용증명서를 요청할 수 있습니다. 만약 내가 자격을 허위로 진술하면, 나는 내 교육 상태에 따라 처음 면제된 수수료를 지불하기로 동의합니다.', - age: '나는 최소한 18 세 이상임을 확인합니다.', + inSchool: + '나는 제공된 기관에 재학 중이거나 고용되어 있음을 확인합니다. Dify 는 재학증명서나 고용증명서를 요청할 수 있습니다. 만약 내가 자격을 허위로 진술하면, 나는 내 교육 상태에 따라 처음 면제된 수수료를 지불하기로 동의합니다.', + age: '만 18세 이상입니다.', }, title: '약관 및 동의사항', }, }, submit: '제출', - rejectContent: '안타깝게도, 귀하는 교육 인증 상태에 적합하지 않으므로 이 이메일 주소를 사용할 경우 Dify Professional Plan 의 독점 100% 쿠폰을 받을 수 없습니다.', - successContent: '귀하의 계정에 Dify Professional 플랜을 위한 100% 할인 쿠폰을 발급했습니다. 이 쿠폰은 1 년간 유효하므로 유효 기간 내에 사용해 주시기 바랍니다.', + rejectContent: + '안타깝게도, 귀하는 교육 인증 상태에 적합하지 않으므로 이 이메일 주소를 사용할 경우 Dify Professional Plan 의 독점 100% 쿠폰을 받을 수 없습니다.', + successContent: + '귀하의 계정에 Dify Professional 플랜을 위한 100% 할인 쿠폰을 발급했습니다. 이 쿠폰은 1 년간 유효하므로 유효 기간 내에 사용해 주시기 바랍니다.', currentSigned: '현재 로그인 중입니다', toVerified: '교육 인증 받기', rejectTitle: '귀하의 Dify 교육 인증이 거부되었습니다.', diff --git a/web/i18n/ko-KR/plugin-tags.ts b/web/i18n/ko-KR/plugin-tags.ts index ddd75ef3a6..bd6e2fed77 100644 --- a/web/i18n/ko-KR/plugin-tags.ts +++ b/web/i18n/ko-KR/plugin-tags.ts @@ -16,7 +16,7 @@ const translation = { image: '이미지', design: '디자인', business: '사업', - agent: '대리인', + agent: '에이전트', }, allTags: '모든 태그', searchTags: '검색 태그', diff --git a/web/i18n/ko-KR/time.ts b/web/i18n/ko-KR/time.ts index 172bb78bd6..1233dbf979 100644 --- a/web/i18n/ko-KR/time.ts +++ b/web/i18n/ko-KR/time.ts @@ -1,12 +1,12 @@ const translation = { daysInWeek: { + Sun: '일요일', + Mon: '월요일', + Tue: '화요일', Wed: '수요일', Thu: '목요일', - Fri: '자유', + Fri: '금요일', Sat: '토요일', - Sun: '태양', - Tue: '화요일', - Mon: '몬', }, months: { May: '5 월', @@ -26,7 +26,7 @@ const translation = { pickDate: '날짜 선택', cancel: '취소', ok: '좋아요', - now: '지금', + now: '오늘', }, title: { pickTime: '시간 선택', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index e3ae99d299..078d683ca2 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -4,9 +4,9 @@ const translation = { redo: '다시 실행', editing: '편집 중', autoSaved: '자동 저장됨', - unpublished: '미발행', - published: '발행됨', - publish: '발행', + unpublished: '게시되지 않음', + published: '게시됨', + publish: '게시하기', update: '업데이트', run: '실행', running: '실행 중', @@ -43,7 +43,8 @@ const translation = { previewPlaceholder: '디버깅을 시작하려면 아래 상자에 내용을 입력하세요', effectVarConfirm: { title: '변수 제거', - content: '변수가 다른 노드에서 사용되고 있습니다. 그래도 제거하시겠습니까?', + content: + '변수가 다른 노드에서 사용되고 있습니다. 그래도 제거하시겠습니까?', }, insertVarTip: '빠르게 삽입하려면 \'/\' 키를 누르세요', processData: '데이터 처리', @@ -58,7 +59,7 @@ const translation = { duplicate: '복제', pasteHere: '여기에 붙여넣기', pointerMode: '포인터 모드', - handMode: '핸드 모드', + handMode: '드래그 모드', model: '모델', workflowAsTool: '도구로서의 워크플로우', configureRequired: '구성 필요', @@ -73,7 +74,8 @@ const translation = { overwriteAndImport: '덮어쓰기 및 가져오기', importSuccess: '가져오기 성공', syncingData: '단 몇 초 만에 데이터를 동기화할 수 있습니다.', - importDSLTip: '현재 초안을 덮어씁니다. 가져오기 전에 워크플로를 백업으로 내보냅니다.', + importDSLTip: + '현재 초안을 덮어씁니다. 가져오기 전에 워크플로우를 백업으로 내보냅니다.', parallelTip: { click: { title: '클릭', @@ -95,9 +97,11 @@ const translation = { featuresDocLink: '더 알아보세요', fileUploadTip: '이미지 업로드 기능이 파일 업로드로 업그레이드되었습니다.', featuresDescription: '웹앱 사용자 경험 향상', - ImageUploadLegacyTip: '이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.', + ImageUploadLegacyTip: + '이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.', importWarning: '주의', - importWarningDetails: 'DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', + importWarningDetails: + 'DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', openInExplore: 'Explore 에서 열기', onFailure: '실패 시', addFailureBranch: '실패 분기 추가', @@ -118,7 +122,8 @@ const translation = { }, env: { envPanelTitle: '환경 변수', - envDescription: '환경 변수는 개인 정보와 자격 증명을 저장하는 데 사용될 수 있습니다. 이들은 읽기 전용이며 내보내기 중에 DSL 파일과 분리할 수 있습니다.', + envDescription: + '환경 변수는 개인 정보와 자격 증명을 저장하는 데 사용될 수 있습니다. 이들은 읽기 전용이며 내보내기 중에 DSL 파일과 분리할 수 있습니다.', envPanelButton: '변수 추가', modal: { title: '환경 변수 추가', @@ -128,7 +133,8 @@ const translation = { namePlaceholder: '환경 이름', value: '값', valuePlaceholder: '환경 값', - secretTip: '민감한 정보나 데이터를 정의하는 데 사용되며, DSL 설정은 유출 방지를 위해 구성됩니다.', + secretTip: + '민감한 정보나 데이터를 정의하는 데 사용되며, DSL 설정은 유출 방지를 위해 구성됩니다.', }, export: { title: '비밀 환경 변수를 내보내시겠습니까?', @@ -139,7 +145,8 @@ const translation = { }, chatVariable: { panelTitle: '대화 변수', - panelDescription: '대화 변수는 LLM 이 기억해야 할 대화 기록, 업로드된 파일, 사용자 선호도 등의 상호작용 정보를 저장하는 데 사용됩니다. 이들은 읽기 및 쓰기가 가능합니다.', + panelDescription: + '대화 변수는 LLM 이 기억해야 할 대화 기록, 업로드된 파일, 사용자 선호도 등의 상호작용 정보를 저장하는 데 사용됩니다. 이들은 읽기 및 쓰기가 가능합니다.', docLink: '자세한 내용은 문서를 참조하세요.', button: '변수 추가', modal: { @@ -169,7 +176,8 @@ const translation = { placeholder: '아직 아무 것도 변경하지 않았습니다', clearHistory: '기록 지우기', hint: '힌트', - hintText: '편집 작업이 변경 기록에 추적되며, 이 세션 동안 기기에 저장됩니다. 편집기를 떠나면 이 기록이 지워집니다.', + hintText: + '편집 작업이 변경 기록에 추적되며, 이 세션 동안 기기에 저장됩니다. 편집기를 떠나면 이 기록이 지워집니다.', stepBackward_one: '{{count}} 단계 뒤로', stepBackward_other: '{{count}} 단계 뒤로', stepForward_one: '{{count}} 단계 앞으로', @@ -203,7 +211,8 @@ const translation = { visionVariable: '비전 변수', }, invalidVariable: '잘못된 변수', - rerankModelRequired: 'Rerank Model 을 켜기 전에 설정에서 모델이 성공적으로 구성되었는지 확인하십시오.', + rerankModelRequired: + 'Rerank Model 을 켜기 전에 설정에서 모델이 성공적으로 구성되었는지 확인하십시오.', noValidTool: '{{field}} 유효한 도구가 선택되지 않았습니다.', toolParameterRequired: '{{field}}: 매개변수 [{{param}}] 이 필요합니다.', }, @@ -262,22 +271,34 @@ const translation = { 'end': '워크플로우의 종료 및 결과 유형을 정의합니다', 'answer': '대화의 답변 내용을 정의합니다', 'llm': '질문에 답하거나 자연어를 처리하기 위해 대형 언어 모델을 호출합니다', - 'knowledge-retrieval': '사용자 질문과 관련된 텍스트 콘텐츠를 지식 베이스에서 쿼리할 수 있습니다', - 'question-classifier': '사용자 질문의 분류 조건을 정의합니다. LLM 은 분류 설명을 기반으로 대화의 진행 방식을 정의할 수 있습니다', - 'if-else': 'if/else 조건을 기반으로 워크플로우를 두 가지 분기로 나눌 수 있습니다', + 'knowledge-retrieval': + '사용자 질문과 관련된 텍스트 콘텐츠를 지식 베이스에서 쿼리할 수 있습니다', + 'question-classifier': + '사용자 질문의 분류 조건을 정의합니다. LLM 은 분류 설명을 기반으로 대화의 진행 방식을 정의할 수 있습니다', + 'if-else': + 'if/else 조건을 기반으로 워크플로우를 두 가지 분기로 나눌 수 있습니다', 'code': '사용자 정의 논리를 구현하기 위해 Python 또는 NodeJS 코드를 실행합니다', - 'template-transform': 'Jinja 템플릿 구문을 사용하여 데이터를 문자열로 변환합니다', + 'template-transform': + 'Jinja 템플릿 구문을 사용하여 데이터를 문자열로 변환합니다', 'http-request': 'HTTP 프로토콜을 통해 서버 요청을 보낼 수 있습니다', - 'variable-assigner': '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', - 'assigner': '변수 할당 노드는 쓰기 가능한 변수 (대화 변수 등) 에 값을 할당하는 데 사용됩니다.', - 'variable-aggregator': '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', - 'iteration': '목록 객체에서 여러 단계를 수행하여 모든 결과가 출력될 때까지 반복합니다.', - 'parameter-extractor': '도구 호출 또는 HTTP 요청을 위해 자연어에서 구조화된 매개변수를 추출하기 위해 LLM 을 사용합니다.', - 'document-extractor': '업로드된 문서를 LLM 에서 쉽게 이해할 수 있는 텍스트 콘텐츠로 구문 분석하는 데 사용됩니다.', + 'variable-assigner': + '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', + 'assigner': + '변수 할당 노드는 쓰기 가능한 변수 (대화 변수 등) 에 값을 할당하는 데 사용됩니다.', + 'variable-aggregator': + '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', + 'iteration': + '목록 객체에서 여러 단계를 수행하여 모든 결과가 출력될 때까지 반복합니다.', + 'parameter-extractor': + '도구 호출 또는 HTTP 요청을 위해 자연어에서 구조화된 매개변수를 추출하기 위해 LLM 을 사용합니다.', + 'document-extractor': + '업로드된 문서를 LLM 에서 쉽게 이해할 수 있는 텍스트 콘텐츠로 구문 분석하는 데 사용됩니다.', 'list-operator': '배열 내용을 필터링하거나 정렬하는 데 사용됩니다.', - 'agent': '질문에 답하거나 자연어를 처리하기 위해 대규모 언어 모델을 호출하는 경우', + 'agent': + '질문에 답하거나 자연어를 처리하기 위해 대규모 언어 모델을 호출하는 경우', 'loop': '종료 조건이 충족되거나 최대 반복 횟수에 도달할 때까지 논리 루프를 실행합니다.', - 'loop-end': '"break"와 동일합니다. 이 노드는 구성 항목이 없습니다. 루프 본문이 이 노드에 도달하면 루프가 종료됩니다.', + 'loop-end': + '"break"와 동일합니다. 이 노드는 구성 항목이 없습니다. 루프 본문이 이 노드에 도달하면 루프가 종료됩니다.', }, operator: { zoomIn: '확대', @@ -304,7 +325,7 @@ const translation = { changeBlock: '노드 변경', addNextStep: '이 워크플로우에 다음 단계를 추가하세요.', minimize: '전체 화면 종료', - maximize: '캔버스를 최대화하다', + maximize: '캔버스 전체 화면', }, nodes: { common: { @@ -338,9 +359,12 @@ const translation = { failBranch: { title: '실패 분기', desc: '오류가 발생하면 예외 분기를 실행합니다', - customize: '캔버스로 이동하여 fail branch logic 를 사용자 지정합니다.', - inLog: '노드 예외는 실패 분기를 자동으로 실행합니다. 노드 출력은 오류 유형 및 오류 메시지를 반환하고 다운스트림으로 전달합니다.', - customizeTip: 'fail 분기가 활성화되면 노드에서 throw 된 예외가 프로세스를 종료하지 않습니다. 대신 미리 정의된 실패 분기를 자동으로 실행하여 오류 메시지, 보고서, 수정 사항을 유연하게 제공하거나 작업을 건너뛸 수 있습니다.', + customize: + '캔버스로 이동하여 fail branch logic 를 사용자 지정합니다.', + inLog: + '노드 예외는 실패 분기를 자동으로 실행합니다. 노드 출력은 오류 유형 및 오류 메시지를 반환하고 다운스트림으로 전달합니다.', + customizeTip: + 'fail 분기가 활성화되면 노드에서 throw 된 예외가 프로세스를 종료하지 않습니다. 대신 미리 정의된 실패 분기를 자동으로 실행하여 오류 메시지, 보고서, 수정 사항을 유연하게 제공하거나 작업을 건너뛸 수 있습니다.', }, partialSucceeded: { tip: '프로세스에 {{num}} 노드가 비정상적으로 실행 중입니다. 추적으로 이동하여 로그를 확인하십시오.', @@ -358,7 +382,7 @@ const translation = { retrySuccessful: '재시도 성공', retryFailed: '재시도 실패', retryFailedTimes: '{{times}} 재시도 실패', - times: '배', + times: '번', ms: '미에스', retries: '{{숫자}} 재시도', }, @@ -399,7 +423,8 @@ const translation = { variables: '변수', context: '컨텍스트', contextTooltip: '컨텍스트로 지식을 가져올 수 있습니다', - notSetContextInPromptTip: '컨텍스트 기능을 활성화하려면 PROMPT 에 컨텍스트 변수를 입력하세요.', + notSetContextInPromptTip: + '컨텍스트 기능을 활성화하려면 PROMPT 에 컨텍스트 변수를 입력하세요.', prompt: '프롬프트', roleDescription: { system: '대화를 위한 고급 지침 제공', @@ -443,8 +468,10 @@ const translation = { stringValidations: '문자열 검증', showAdvancedOptions: '고급 옵션 표시', promptPlaceholder: '당신의 JSON 스키마를 설명하세요...', - generationTip: '자연어를 사용하여 JSON 스키마를 신속하게 생성할 수 있습니다.', - resultTip: '여기 생성된 결과가 있습니다. 만약 만족하지 않으신다면, 돌아가서 프롬프트를 수정할 수 있습니다.', + generationTip: + '자연어를 사용하여 JSON 스키마를 신속하게 생성할 수 있습니다.', + resultTip: + '여기 생성된 결과가 있습니다. 만약 만족하지 않으신다면, 돌아가서 프롬프트를 수정할 수 있습니다.', regenerate: '재생하다', required: '필수', doc: '구조화된 출력에 대해 더 알아보세요.', @@ -470,7 +497,8 @@ const translation = { }, automatic: { desc: '쿼리 변수를 기반으로 메타데이터 필터링 조건을 자동으로 생성합니다.', - subTitle: '사용자 쿼리를 기반으로 메타데이터 필터링 조건을 자동으로 생성합니다.', + subTitle: + '사용자 쿼리를 기반으로 메타데이터 필터링 조건을 자동으로 생성합니다.', title: '자동', }, manual: { @@ -542,7 +570,8 @@ const translation = { inputVars: '입력 변수', outputVars: '출력 변수', advancedDependencies: '고급 종속성', - advancedDependenciesTip: '더 많은 시간이 소요되거나 기본으로 내장되지 않은 일부 미리 로드된 종속성을 여기에 추가하세요', + advancedDependenciesTip: + '더 많은 시간이 소요되거나 기본으로 내장되지 않은 일부 미리 로드된 종속성을 여기에 추가하세요', searchDependencies: '종속성 검색', }, templateTransform: { @@ -556,7 +585,8 @@ const translation = { ifElse: { if: 'If', else: 'Else', - elseDescription: 'If 조건이 충족되지 않을 때 실행할 논리를 정의하는 데 사용됩니다.', + elseDescription: + 'If 조건이 충족되지 않을 때 실행할 논리를 정의하는 데 사용됩니다.', and: '그리고', or: '또는', operator: '연산자', @@ -609,7 +639,8 @@ const translation = { array: '배열', }, aggregationGroup: '집계 그룹', - aggregationGroupTip: '이 기능을 활성화하면 변수 집계자가 여러 변수 집합을 집계할 수 있습니다.', + aggregationGroupTip: + '이 기능을 활성화하면 변수 집계자가 여러 변수 집합을 집계할 수 있습니다.', addGroup: '그룹 추가', outputVars: { varDescribe: '{{groupName}} 출력', @@ -645,7 +676,8 @@ const translation = { 'noAssignedVars': '사용 가능한 할당된 변수가 없습니다.', 'noVarTip': '"+" 버튼을 클릭하여 변수를 추가합니다.', 'setParameter': '매개 변수 설정...', - 'assignedVarsDescription': '할당된 변수는 대화 변수와 같은 쓰기 가능한 변수여야 합니다.', + 'assignedVarsDescription': + '할당된 변수는 대화 변수와 같은 쓰기 가능한 변수여야 합니다.', 'selectAssignedVariable': '할당된 변수 선택...', 'varNotSet': '변수가 설정되지 않음', }, @@ -677,7 +709,8 @@ const translation = { topicPlaceholder: '주제 이름을 작성하세요', addClass: '클래스 추가', instruction: '지시', - instructionTip: '질문 분류기가 질문을 더 잘 분류할 수 있도록 추가 지시를 입력하세요.', + instructionTip: + '질문 분류기가 질문을 더 잘 분류할 수 있도록 추가 지시를 입력하세요.', instructionPlaceholder: '지시를 작성하세요', }, parameterExtractor: { @@ -693,14 +726,17 @@ const translation = { description: '설명', descriptionPlaceholder: '추출 매개변수 설명', required: '필수', - requiredContent: '필수는 모델 추론을 위한 참고 용도로만 사용되며, 매개변수 출력의 필수 유효성 검사는 아닙니다.', + requiredContent: + '필수는 모델 추론을 위한 참고 용도로만 사용되며, 매개변수 출력의 필수 유효성 검사는 아닙니다.', }, extractParametersNotSet: '추출 매개변수가 설정되지 않음', instruction: '지시', - instructionTip: '매개변수 추출기가 매개변수를 추출하는 방법을 이해하는 데 도움이 되는 추가 지시를 입력하세요.', + instructionTip: + '매개변수 추출기가 매개변수를 추출하는 방법을 이해하는 데 도움이 되는 추가 지시를 입력하세요.', advancedSetting: '고급 설정', reasoningMode: '추론 모드', - reasoningModeTip: '모델의 함수 호출 또는 프롬프트에 대한 지시 응답 능력을 기반으로 적절한 추론 모드를 선택할 수 있습니다.', + reasoningModeTip: + '모델의 함수 호출 또는 프롬프트에 대한 지시 응답 능력을 기반으로 적절한 추론 모드를 선택할 수 있습니다.', isSuccess: '성공 여부. 성공 시 값은 1 이고, 실패 시 값은 0 입니다.', errorReason: '오류 원인', }, @@ -726,9 +762,12 @@ const translation = { error_other: '{{개수}} 오류', parallelModeEnableTitle: 'Parallel Mode Enabled(병렬 모드 사용)', parallelPanelDesc: '병렬 모드에서 반복의 작업은 병렬 실행을 지원합니다.', - parallelModeEnableDesc: '병렬 모드에서는 반복 내의 작업이 병렬 실행을 지원합니다. 오른쪽의 속성 패널에서 이를 구성할 수 있습니다.', - MaxParallelismDesc: '최대 병렬 처리는 단일 반복에서 동시에 실행되는 작업 수를 제어하는 데 사용됩니다.', - answerNodeWarningDesc: '병렬 모드 경고: 응답 노드, 대화 변수 할당 및 반복 내의 지속적인 읽기/쓰기 작업으로 인해 예외가 발생할 수 있습니다.', + parallelModeEnableDesc: + '병렬 모드에서는 반복 내의 작업이 병렬 실행을 지원합니다. 오른쪽의 속성 패널에서 이를 구성할 수 있습니다.', + MaxParallelismDesc: + '최대 병렬 처리는 단일 반복에서 동시에 실행되는 작업 수를 제어하는 데 사용됩니다.', + answerNodeWarningDesc: + '병렬 모드 경고: 응답 노드, 대화 변수 할당 및 반복 내의 지속적인 읽기/쓰기 작업으로 인해 예외가 발생할 수 있습니다.', }, note: { editor: { @@ -778,12 +817,14 @@ const translation = { agent: { strategy: { label: '에이전트 전략', - tooltip: '다양한 에이전트 전략은 시스템이 다단계 도구 호출을 계획하고 실행하는 방법을 결정합니다', + tooltip: + '다양한 에이전트 전략은 시스템이 다단계 도구 호출을 계획하고 실행하는 방법을 결정합니다', configureTip: '에이전트 전략을 구성하세요.', searchPlaceholder: '검색 에이전트 전략', shortLabel: '전략', selectTip: '에이전트 전략 선택', - configureTipDesc: '에이전트 전략을 구성한 후 이 노드는 나머지 구성을 자동으로 로드합니다. 이 전략은 다단계 도구 추론의 메커니즘에 영향을 미칩니다.', + configureTipDesc: + '에이전트 전략을 구성한 후 이 노드는 나머지 구성을 자동으로 로드합니다. 이 전략은 다단계 도구 추론의 메커니즘에 영향을 미칩니다.', }, pluginInstaller: { install: '설치하다', @@ -796,7 +837,8 @@ const translation = { }, modelNotSupport: { title: '지원되지 않는 모델', - descForVersionSwitch: '설치된 플러그인 버전은 이 모델을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', + descForVersionSwitch: + '설치된 플러그인 버전은 이 모델을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', desc: '설치된 플러그인 버전은 이 모델을 제공하지 않습니다.', }, modelSelectorTooltips: { @@ -823,13 +865,17 @@ const translation = { cancel: '취소', title: '플러그인 설치', }, - strategyNotFoundDescAndSwitchVersion: '설치된 플러그인 버전은 이 전략을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', + strategyNotFoundDescAndSwitchVersion: + '설치된 플러그인 버전은 이 전략을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', learnMore: '더 알아보세요', toolNotAuthorizedTooltip: '{{도구}} 권한이 부여되지 않음', - strategyNotFoundDesc: '설치된 플러그인 버전은 이 전략을 제공하지 않습니다.', + strategyNotFoundDesc: + '설치된 플러그인 버전은 이 전략을 제공하지 않습니다.', maxIterations: '최대 반복 횟수', - pluginNotFoundDesc: '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', - pluginNotInstalledDesc: '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', + pluginNotFoundDesc: + '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', + pluginNotInstalledDesc: + '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', strategyNotInstallTooltip: '{{strategy}}가 설치되지 않았습니다.', tools: '도구', unsupportedStrategy: '지원되지 않는 전략', @@ -868,9 +914,11 @@ const translation = { loopVariables: '루프 변수', setLoopVariables: '루프 범위 내에서 변수를 설정합니다.', initialLoopVariables: '초기 루프 변수', - breakConditionTip: '종료 조건과 대화 변수가 있는 루프 내에서만 변수를 참조할 수 있습니다.', + breakConditionTip: + '종료 조건과 대화 변수가 있는 루프 내에서만 변수를 참조할 수 있습니다.', currentLoopCount: '현재 루프 카운트: {{count}}', - loopMaxCountError: '유효한 최대 루프 수를 입력하십시오. 범위는 1 에서 {{maxCount}}입니다.', + loopMaxCountError: + '유효한 최대 루프 수를 입력하십시오. 범위는 1 에서 {{maxCount}}입니다.', totalLoopCount: '총 루프 횟수: {{count}}', variableName: '변수 이름', loopNode: '루프 노드', @@ -885,7 +933,8 @@ const translation = { conversationVars: '대화 변수', noVarsForOperation: '선택한 작업에 할당할 수 있는 변수가 없습니다.', noAssignedVars: '사용 가능한 할당된 변수가 없습니다.', - assignedVarsDescription: '할당된 변수는 다음과 같이 쓰기 가능한 변수여야 합니다.', + assignedVarsDescription: + '할당된 변수는 다음과 같이 쓰기 가능한 변수여야 합니다.', }, versionHistory: { filter: { @@ -899,7 +948,8 @@ const translation = { titleLengthLimit: '제목은 {{limit}}자를 초과할 수 없습니다.', title: '제목', releaseNotes: '릴리스 노트', - releaseNotesLengthLimit: '릴리스 노트는 {{limit}}자를 초과할 수 없습니다.', + releaseNotesLengthLimit: + '릴리스 노트는 {{limit}}자를 초과할 수 없습니다.', }, action: { updateFailure: '버전 업데이트에 실패했습니다.', @@ -914,8 +964,8 @@ const translation = { currentDraft: '현재 초안', releaseNotesPlaceholder: '변경된 내용을 설명하세요.', defaultName: '제목 없는 버전', - nameThisVersion: '이 버전의 이름을 지어주세요', - title: '버전들', + nameThisVersion: '이름 바꾸기', + title: '버전 기록', deletionTip: '삭제는 되돌릴 수 없으니, 확인해 주시기 바랍니다.', restorationTip: '버전 복원 후 현재 초안이 덮어쓰여질 것입니다.', }, @@ -943,7 +993,8 @@ const translation = { clearNode: '캐시된 변수를 지우기', resetConversationVar: '대화 변수를 기본 값으로 재설정합니다.', reset: '마지막 실행 값으로 재설정', - emptyTip: '캔버스에서 노드를 한 단계씩 실행한 후, 변수 검사에서 노드 변수의 현재 값을 볼 수 있습니다.', + emptyTip: + '캔버스에서 노드를 한 단계씩 실행한 후, 변수 검사에서 노드 변수의 현재 값을 볼 수 있습니다.', }, settingsTab: '설정', lastRunTab: '마지막 실행', From ac69b8b19158eb32e98921ece1917d67e838f8d5 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Mon, 7 Jul 2025 09:34:13 +0800 Subject: [PATCH 010/241] refactor: extract common url validator for config_entity.py (#21934) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/core/ops/entities/config_entity.py | 112 +++---- api/core/ops/utils.py | 81 +++++ api/tests/unit_tests/core/ops/__init__.py | 1 + .../unit_tests/core/ops/test_config_entity.py | 309 ++++++++++++++++++ api/tests/unit_tests/core/ops/test_utils.py | 138 ++++++++ 5 files changed, 576 insertions(+), 65 deletions(-) create mode 100644 api/tests/unit_tests/core/ops/__init__.py create mode 100644 api/tests/unit_tests/core/ops/test_config_entity.py create mode 100644 api/tests/unit_tests/core/ops/test_utils.py diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py index cd5d609ef7..c53a236fc0 100644 --- a/api/core/ops/entities/config_entity.py +++ b/api/core/ops/entities/config_entity.py @@ -2,6 +2,8 @@ from enum import StrEnum from pydantic import BaseModel, ValidationInfo, field_validator +from core.ops.utils import validate_project_name, validate_url, validate_url_with_path + class TracingProviderEnum(StrEnum): ARIZE = "arize" @@ -15,10 +17,36 @@ class TracingProviderEnum(StrEnum): class BaseTracingConfig(BaseModel): """ - Base model class for tracing + Base model class for tracing configurations """ - ... + @classmethod + def validate_endpoint_url(cls, v: str, default_url: str) -> str: + """ + Common endpoint URL validation logic + + Args: + v: URL value to validate + default_url: Default URL to use if input is None or empty + + Returns: + Validated and normalized URL + """ + return validate_url(v, default_url) + + @classmethod + def validate_project_field(cls, v: str, default_name: str) -> str: + """ + Common project name validation logic + + Args: + v: Project name to validate + default_name: Default name to use if input is None or empty + + Returns: + Validated project name + """ + return validate_project_name(v, default_name) class ArizeConfig(BaseTracingConfig): @@ -34,23 +62,12 @@ class ArizeConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "default" - - return v + return cls.validate_project_field(v, "default") @field_validator("endpoint") @classmethod def endpoint_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://otlp.arize.com" - if not v.startswith(("https://", "http://")): - raise ValueError("endpoint must start with https:// or http://") - if "/" in v[8:]: - parts = v.split("/") - v = parts[0] + "//" + parts[2] - - return v + return cls.validate_endpoint_url(v, "https://otlp.arize.com") class PhoenixConfig(BaseTracingConfig): @@ -65,23 +82,12 @@ class PhoenixConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "default" - - return v + return cls.validate_project_field(v, "default") @field_validator("endpoint") @classmethod def endpoint_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://app.phoenix.arize.com" - if not v.startswith(("https://", "http://")): - raise ValueError("endpoint must start with https:// or http://") - if "/" in v[8:]: - parts = v.split("/") - v = parts[0] + "//" + parts[2] - - return v + return cls.validate_endpoint_url(v, "https://app.phoenix.arize.com") class LangfuseConfig(BaseTracingConfig): @@ -95,13 +101,8 @@ class LangfuseConfig(BaseTracingConfig): @field_validator("host") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://api.langfuse.com" - if not v.startswith("https://") and not v.startswith("http://"): - raise ValueError("host must start with https:// or http://") - - return v + def host_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://api.langfuse.com") class LangSmithConfig(BaseTracingConfig): @@ -115,13 +116,9 @@ class LangSmithConfig(BaseTracingConfig): @field_validator("endpoint") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://api.smith.langchain.com" - if not v.startswith("https://"): - raise ValueError("endpoint must start with https://") - - return v + def endpoint_validator(cls, v, info: ValidationInfo): + # LangSmith only allows HTTPS + return validate_url(v, "https://api.smith.langchain.com", allowed_schemes=("https",)) class OpikConfig(BaseTracingConfig): @@ -137,22 +134,12 @@ class OpikConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "Default Project" - - return v + return cls.validate_project_field(v, "Default Project") @field_validator("url") @classmethod def url_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://www.comet.com/opik/api/" - if not v.startswith(("https://", "http://")): - raise ValueError("url must start with https:// or http://") - if not v.endswith("/api/"): - raise ValueError("url should ends with /api/") - - return v + return validate_url_with_path(v, "https://www.comet.com/opik/api/", required_suffix="/api/") class WeaveConfig(BaseTracingConfig): @@ -168,20 +155,15 @@ class WeaveConfig(BaseTracingConfig): @field_validator("endpoint") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://trace.wandb.ai" - if not v.startswith("https://"): - raise ValueError("endpoint must start with https://") - - return v + def endpoint_validator(cls, v, info: ValidationInfo): + # Weave only allows HTTPS for endpoint + return validate_url(v, "https://trace.wandb.ai", allowed_schemes=("https",)) @field_validator("host") @classmethod - def validate_host(cls, v, info: ValidationInfo): - if v is not None and v != "": - if not v.startswith(("https://", "http://")): - raise ValueError("host must start with https:// or http://") + def host_validator(cls, v, info: ValidationInfo): + if v is not None and v.strip() != "": + return validate_url(v, v, allowed_schemes=("https", "http")) return v diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py index 8b06df1930..36d060afd2 100644 --- a/api/core/ops/utils.py +++ b/api/core/ops/utils.py @@ -1,6 +1,7 @@ from contextlib import contextmanager from datetime import datetime from typing import Optional, Union +from urllib.parse import urlparse from extensions.ext_database import db from models.model import Message @@ -60,3 +61,83 @@ def generate_dotted_order( return current_segment return f"{parent_dotted_order}.{current_segment}" + + +def validate_url(url: str, default_url: str, allowed_schemes: tuple = ("https", "http")) -> str: + """ + Validate and normalize URL with proper error handling + + Args: + url: The URL to validate + default_url: Default URL to use if input is None or empty + allowed_schemes: Tuple of allowed URL schemes (default: https, http) + + Returns: + Normalized URL string + + Raises: + ValueError: If URL format is invalid or scheme not allowed + """ + if not url or url.strip() == "": + return default_url + + # Parse URL to validate format + parsed = urlparse(url) + + # Check if scheme is allowed + if parsed.scheme not in allowed_schemes: + raise ValueError(f"URL scheme must be one of: {', '.join(allowed_schemes)}") + + # Reconstruct URL with only scheme, netloc (removing path, query, fragment) + normalized_url = f"{parsed.scheme}://{parsed.netloc}" + + return normalized_url + + +def validate_url_with_path(url: str, default_url: str, required_suffix: str | None = None) -> str: + """ + Validate URL that may include path components + + Args: + url: The URL to validate + default_url: Default URL to use if input is None or empty + required_suffix: Optional suffix that URL must end with + + Returns: + Validated URL string + + Raises: + ValueError: If URL format is invalid or doesn't match required suffix + """ + if not url or url.strip() == "": + return default_url + + # Parse URL to validate format + parsed = urlparse(url) + + # Check if scheme is allowed + if parsed.scheme not in ("https", "http"): + raise ValueError("URL must start with https:// or http://") + + # Check required suffix if specified + if required_suffix and not url.endswith(required_suffix): + raise ValueError(f"URL should end with {required_suffix}") + + return url + + +def validate_project_name(project: str, default_name: str) -> str: + """ + Validate and normalize project name + + Args: + project: Project name to validate + default_name: Default name to use if input is None or empty + + Returns: + Normalized project name + """ + if not project or project.strip() == "": + return default_name + + return project.strip() diff --git a/api/tests/unit_tests/core/ops/__init__.py b/api/tests/unit_tests/core/ops/__init__.py new file mode 100644 index 0000000000..bb92ccdec7 --- /dev/null +++ b/api/tests/unit_tests/core/ops/__init__.py @@ -0,0 +1 @@ +# Unit tests for core ops module diff --git a/api/tests/unit_tests/core/ops/test_config_entity.py b/api/tests/unit_tests/core/ops/test_config_entity.py new file mode 100644 index 0000000000..21047106d3 --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_config_entity.py @@ -0,0 +1,309 @@ +import pytest +from pydantic import ValidationError + +from core.ops.entities.config_entity import ( + ArizeConfig, + LangfuseConfig, + LangSmithConfig, + OpikConfig, + PhoenixConfig, + TracingProviderEnum, + WeaveConfig, +) + + +class TestTracingProviderEnum: + """Test cases for TracingProviderEnum""" + + def test_enum_values(self): + """Test that all expected enum values are present""" + assert TracingProviderEnum.ARIZE == "arize" + assert TracingProviderEnum.PHOENIX == "phoenix" + assert TracingProviderEnum.LANGFUSE == "langfuse" + assert TracingProviderEnum.LANGSMITH == "langsmith" + assert TracingProviderEnum.OPIK == "opik" + assert TracingProviderEnum.WEAVE == "weave" + + +class TestArizeConfig: + """Test cases for ArizeConfig""" + + def test_valid_config(self): + """Test valid Arize configuration""" + config = ArizeConfig( + api_key="test_key", space_id="test_space", project="test_project", endpoint="https://custom.arize.com" + ) + assert config.api_key == "test_key" + assert config.space_id == "test_space" + assert config.project == "test_project" + assert config.endpoint == "https://custom.arize.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = ArizeConfig() + assert config.api_key is None + assert config.space_id is None + assert config.project is None + assert config.endpoint == "https://otlp.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = ArizeConfig(project="") + assert config.project == "default" + + def test_project_validation_none(self): + """Test project validation with None value""" + config = ArizeConfig(project=None) + assert config.project == "default" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = ArizeConfig(endpoint="") + assert config.endpoint == "https://otlp.arize.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = ArizeConfig(endpoint="https://custom.arize.com/api/v1") + assert config.endpoint == "https://custom.arize.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="ftp://invalid.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="invalid.com") + + +class TestPhoenixConfig: + """Test cases for PhoenixConfig""" + + def test_valid_config(self): + """Test valid Phoenix configuration""" + config = PhoenixConfig(api_key="test_key", project="test_project", endpoint="https://custom.phoenix.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.phoenix.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = PhoenixConfig() + assert config.api_key is None + assert config.project is None + assert config.endpoint == "https://app.phoenix.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = PhoenixConfig(project="") + assert config.project == "default" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = PhoenixConfig(endpoint="https://custom.phoenix.com/api/v1") + assert config.endpoint == "https://custom.phoenix.com" + + +class TestLangfuseConfig: + """Test cases for LangfuseConfig""" + + def test_valid_config(self): + """Test valid Langfuse configuration""" + config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host="https://custom.langfuse.com") + assert config.public_key == "public_key" + assert config.secret_key == "secret_key" + assert config.host == "https://custom.langfuse.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangfuseConfig(public_key="public", secret_key="secret") + assert config.host == "https://api.langfuse.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangfuseConfig() + + with pytest.raises(ValidationError): + LangfuseConfig(public_key="public") + + with pytest.raises(ValidationError): + LangfuseConfig(secret_key="secret") + + def test_host_validation_empty(self): + """Test host validation with empty value""" + config = LangfuseConfig(public_key="public", secret_key="secret", host="") + assert config.host == "https://api.langfuse.com" + + +class TestLangSmithConfig: + """Test cases for LangSmithConfig""" + + def test_valid_config(self): + """Test valid LangSmith configuration""" + config = LangSmithConfig(api_key="test_key", project="test_project", endpoint="https://custom.smith.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.smith.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangSmithConfig(api_key="key", project="project") + assert config.endpoint == "https://api.smith.langchain.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangSmithConfig() + + with pytest.raises(ValidationError): + LangSmithConfig(api_key="key") + + with pytest.raises(ValidationError): + LangSmithConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + LangSmithConfig(api_key="key", project="project", endpoint="http://insecure.com") + + +class TestOpikConfig: + """Test cases for OpikConfig""" + + def test_valid_config(self): + """Test valid Opik configuration""" + config = OpikConfig( + api_key="test_key", + project="test_project", + workspace="test_workspace", + url="https://custom.comet.com/opik/api/", + ) + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.workspace == "test_workspace" + assert config.url == "https://custom.comet.com/opik/api/" + + def test_default_values(self): + """Test default values are set correctly""" + config = OpikConfig() + assert config.api_key is None + assert config.project is None + assert config.workspace is None + assert config.url == "https://www.comet.com/opik/api/" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = OpikConfig(project="") + assert config.project == "Default Project" + + def test_url_validation_empty(self): + """Test URL validation with empty value""" + config = OpikConfig(url="") + assert config.url == "https://www.comet.com/opik/api/" + + def test_url_validation_missing_suffix(self): + """Test URL validation requires /api/ suffix""" + with pytest.raises(ValidationError, match="URL should end with /api/"): + OpikConfig(url="https://custom.comet.com/opik/") + + def test_url_validation_invalid_scheme(self): + """Test URL validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL must start with https:// or http://"): + OpikConfig(url="ftp://custom.comet.com/opik/api/") + + +class TestWeaveConfig: + """Test cases for WeaveConfig""" + + def test_valid_config(self): + """Test valid Weave configuration""" + config = WeaveConfig( + api_key="test_key", + entity="test_entity", + project="test_project", + endpoint="https://custom.wandb.ai", + host="https://custom.host.com", + ) + assert config.api_key == "test_key" + assert config.entity == "test_entity" + assert config.project == "test_project" + assert config.endpoint == "https://custom.wandb.ai" + assert config.host == "https://custom.host.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = WeaveConfig(api_key="key", project="project") + assert config.entity is None + assert config.endpoint == "https://trace.wandb.ai" + assert config.host is None + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + WeaveConfig() + + with pytest.raises(ValidationError): + WeaveConfig(api_key="key") + + with pytest.raises(ValidationError): + WeaveConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", endpoint="http://insecure.wandb.ai") + + def test_host_validation_optional(self): + """Test host validation is optional but validates when provided""" + config = WeaveConfig(api_key="key", project="project", host=None) + assert config.host is None + + config = WeaveConfig(api_key="key", project="project", host="") + assert config.host == "" + + config = WeaveConfig(api_key="key", project="project", host="https://valid.host.com") + assert config.host == "https://valid.host.com" + + def test_host_validation_invalid_scheme(self): + """Test host validation rejects invalid schemes when provided""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com") + + +class TestConfigIntegration: + """Integration tests for configuration classes""" + + def test_all_configs_can_be_instantiated(self): + """Test that all config classes can be instantiated with valid data""" + configs = [ + ArizeConfig(api_key="key"), + PhoenixConfig(api_key="key"), + LangfuseConfig(public_key="public", secret_key="secret"), + LangSmithConfig(api_key="key", project="project"), + OpikConfig(api_key="key"), + WeaveConfig(api_key="key", project="project"), + ] + + for config in configs: + assert config is not None + + def test_url_normalization_consistency(self): + """Test that URL normalization works consistently across configs""" + # Test that paths are removed from endpoints + arize_config = ArizeConfig(endpoint="https://arize.com/api/v1/test") + phoenix_config = PhoenixConfig(endpoint="https://phoenix.com/api/v2/") + + assert arize_config.endpoint == "https://arize.com" + assert phoenix_config.endpoint == "https://phoenix.com" + + def test_project_default_values(self): + """Test that project default values are set correctly""" + arize_config = ArizeConfig(project="") + phoenix_config = PhoenixConfig(project="") + opik_config = OpikConfig(project="") + + assert arize_config.project == "default" + assert phoenix_config.project == "default" + assert opik_config.project == "Default Project" diff --git a/api/tests/unit_tests/core/ops/test_utils.py b/api/tests/unit_tests/core/ops/test_utils.py new file mode 100644 index 0000000000..7cc2772acf --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_utils.py @@ -0,0 +1,138 @@ +import pytest + +from core.ops.utils import validate_project_name, validate_url, validate_url_with_path + + +class TestValidateUrl: + """Test cases for validate_url function""" + + def test_valid_https_url(self): + """Test valid HTTPS URL""" + result = validate_url("https://example.com", "https://default.com") + assert result == "https://example.com" + + def test_valid_http_url(self): + """Test valid HTTP URL""" + result = validate_url("http://example.com", "https://default.com") + assert result == "http://example.com" + + def test_url_with_path_removed(self): + """Test that URL path is removed during normalization""" + result = validate_url("https://example.com/api/v1/test", "https://default.com") + assert result == "https://example.com" + + def test_url_with_query_removed(self): + """Test that URL query parameters are removed""" + result = validate_url("https://example.com?param=value", "https://default.com") + assert result == "https://example.com" + + def test_url_with_fragment_removed(self): + """Test that URL fragments are removed""" + result = validate_url("https://example.com#section", "https://default.com") + assert result == "https://example.com" + + def test_empty_url_returns_default(self): + """Test empty URL returns default""" + result = validate_url("", "https://default.com") + assert result == "https://default.com" + + def test_none_url_returns_default(self): + """Test None URL returns default""" + result = validate_url(None, "https://default.com") + assert result == "https://default.com" + + def test_whitespace_url_returns_default(self): + """Test whitespace URL returns default""" + result = validate_url(" ", "https://default.com") + assert result == "https://default.com" + + def test_invalid_scheme_raises_error(self): + """Test invalid scheme raises ValueError""" + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("ftp://example.com", "https://default.com") + + def test_no_scheme_raises_error(self): + """Test URL without scheme raises ValueError""" + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("example.com", "https://default.com") + + def test_custom_allowed_schemes(self): + """Test custom allowed schemes""" + result = validate_url("https://example.com", "https://default.com", allowed_schemes=("https",)) + assert result == "https://example.com" + + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("http://example.com", "https://default.com", allowed_schemes=("https",)) + + +class TestValidateUrlWithPath: + """Test cases for validate_url_with_path function""" + + def test_valid_url_with_path(self): + """Test valid URL with path""" + result = validate_url_with_path("https://example.com/api/v1", "https://default.com") + assert result == "https://example.com/api/v1" + + def test_valid_url_with_required_suffix(self): + """Test valid URL with required suffix""" + result = validate_url_with_path("https://example.com/api/", "https://default.com", required_suffix="/api/") + assert result == "https://example.com/api/" + + def test_url_without_required_suffix_raises_error(self): + """Test URL without required suffix raises error""" + with pytest.raises(ValueError, match="URL should end with /api/"): + validate_url_with_path("https://example.com/api", "https://default.com", required_suffix="/api/") + + def test_empty_url_returns_default(self): + """Test empty URL returns default""" + result = validate_url_with_path("", "https://default.com") + assert result == "https://default.com" + + def test_none_url_returns_default(self): + """Test None URL returns default""" + result = validate_url_with_path(None, "https://default.com") + assert result == "https://default.com" + + def test_invalid_scheme_raises_error(self): + """Test invalid scheme raises ValueError""" + with pytest.raises(ValueError, match="URL must start with https:// or http://"): + validate_url_with_path("ftp://example.com", "https://default.com") + + def test_no_scheme_raises_error(self): + """Test URL without scheme raises ValueError""" + with pytest.raises(ValueError, match="URL must start with https:// or http://"): + validate_url_with_path("example.com", "https://default.com") + + +class TestValidateProjectName: + """Test cases for validate_project_name function""" + + def test_valid_project_name(self): + """Test valid project name""" + result = validate_project_name("my-project", "default") + assert result == "my-project" + + def test_empty_project_name_returns_default(self): + """Test empty project name returns default""" + result = validate_project_name("", "default") + assert result == "default" + + def test_none_project_name_returns_default(self): + """Test None project name returns default""" + result = validate_project_name(None, "default") + assert result == "default" + + def test_whitespace_project_name_returns_default(self): + """Test whitespace project name returns default""" + result = validate_project_name(" ", "default") + assert result == "default" + + def test_project_name_with_whitespace_trimmed(self): + """Test project name with whitespace is trimmed""" + result = validate_project_name(" my-project ", "default") + assert result == "my-project" + + def test_custom_default_name(self): + """Test custom default name""" + result = validate_project_name("", "Custom Default") + assert result == "Custom Default" From 108cc3486f96705e3e24a930053a80df77138e92 Mon Sep 17 00:00:00 2001 From: baonudesifeizhai <85092850+baonudesifeizhai@users.noreply.github.com> Date: Sun, 6 Jul 2025 21:59:47 -0400 Subject: [PATCH 011/241] fix(agent): show agent run steps, fixes #21718 (#21945) Co-authored-by: crazywoola <427733928@qq.com> --- api/core/workflow/nodes/agent/agent_node.py | 32 +++++++++++++++++++++ api/core/workflow/nodes/tool/tool_node.py | 29 +++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 987f670acb..ddc03b8d78 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -1,4 +1,5 @@ import json +import uuid from collections.abc import Generator, Mapping, Sequence from typing import Any, Optional, cast @@ -102,6 +103,36 @@ class AgentNode(ToolNode): try: # convert tool messages + agent_thoughts: list = [] + + from core.tools.entities.tool_entities import ToolInvokeMessage + + thought_log_message = ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.LOG, + message=ToolInvokeMessage.LogMessage( + id=str(uuid.uuid4()), + label=f"Agent Strategy: {cast(AgentNodeData, self.node_data).agent_strategy_name}", + parent_id=None, + error=None, + status=ToolInvokeMessage.LogMessage.LogStatus.START, + data={ + "strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, + "parameters": parameters_for_log, + "thought_process": "Agent strategy execution started", + }, + metadata={ + "icon": self.agent_strategy_icon, + "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, + }, + ), + ) + + from core.tools.entities.tool_entities import ToolInvokeMessage + + def enhanced_message_stream(): + yield thought_log_message + + yield from message_stream yield from self._transform_message( message_stream, @@ -110,6 +141,7 @@ class AgentNode(ToolNode): "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, }, parameters_for_log, + agent_thoughts, ) except PluginDaemonClientSideError as e: yield RunCompletedEvent( diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index a4be02d863..59b3b1e2ae 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -1,5 +1,5 @@ from collections.abc import Generator, Mapping, Sequence -from typing import Any, cast +from typing import Any, Optional, cast from sqlalchemy import select from sqlalchemy.orm import Session @@ -191,6 +191,7 @@ class ToolNode(BaseNode[ToolNodeData]): messages: Generator[ToolInvokeMessage, None, None], tool_info: Mapping[str, Any], parameters_for_log: dict[str, Any], + agent_thoughts: Optional[list] = None, ) -> Generator: """ Convert ToolInvokeMessages into tuple[plain_text, files] @@ -368,11 +369,35 @@ class ToolNode(BaseNode[ToolNodeData]): agent_logs.append(agent_log) yield agent_log + # Add agent_logs to outputs['json'] to ensure frontend can access thinking process + json_output: dict[str, Any] = {} + if json: + if isinstance(json, list) and len(json) == 1: + # If json is a list with only one element, convert it to a dictionary + json_output = json[0] if isinstance(json[0], dict) else {"data": json[0]} + elif isinstance(json, list): + # If json is a list with multiple elements, create a dictionary containing all data + json_output = {"data": json} + if agent_logs: + # Add agent_logs to json output + json_output["agent_logs"] = [ + { + "id": log.id, + "parent_id": log.parent_id, + "error": log.error, + "status": log.status, + "data": log.data, + "label": log.label, + "metadata": log.metadata, + "node_id": log.node_id, + } + for log in agent_logs + ] yield RunCompletedEvent( run_result=NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json, **variables}, + outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json_output, **variables}, metadata={ **agent_execution_metadata, WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info, From b9f56852dc7232e58d92d0c20df2994771ce70b9 Mon Sep 17 00:00:00 2001 From: baonudesifeizhai <85092850+baonudesifeizhai@users.noreply.github.com> Date: Sun, 6 Jul 2025 22:05:54 -0400 Subject: [PATCH 012/241] =?UTF-8?q?fix:=20resolve=20JSON.parse=20precision?= =?UTF-8?q?=20issue=20causing=20'list=20index=20out=20of=20ra=E2=80=A6=20(?= =?UTF-8?q?#21253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../code_executor/template_transformer.py | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/api/core/helper/code_executor/template_transformer.py b/api/core/helper/code_executor/template_transformer.py index baa792b5bc..84f212a9c1 100644 --- a/api/core/helper/code_executor/template_transformer.py +++ b/api/core/helper/code_executor/template_transformer.py @@ -28,7 +28,7 @@ class TemplateTransformer(ABC): def extract_result_str_from_response(cls, response: str): result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL) if not result: - raise ValueError("Failed to parse result") + raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...") return result.group(1) @classmethod @@ -38,16 +38,53 @@ class TemplateTransformer(ABC): :param response: response :return: """ + try: - result = json.loads(cls.extract_result_str_from_response(response)) - except json.JSONDecodeError: - raise ValueError("failed to parse response") + result_str = cls.extract_result_str_from_response(response) + result = json.loads(result_str) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse JSON response: {str(e)}. Response content: {result_str[:200]}...") + except ValueError as e: + # Re-raise ValueError from extract_result_str_from_response + raise e + except Exception as e: + raise ValueError(f"Unexpected error during response transformation: {str(e)}") + + # Check if the result contains an error + if isinstance(result, dict) and "error" in result: + raise ValueError(f"JavaScript execution error: {result['error']}") + if not isinstance(result, dict): - raise ValueError("result must be a dict") + raise ValueError(f"Result must be a dict, got {type(result).__name__}") if not all(isinstance(k, str) for k in result): - raise ValueError("result keys must be strings") + raise ValueError("Result keys must be strings") + + # Post-process the result to convert scientific notation strings back to numbers + result = cls._post_process_result(result) return result + @classmethod + def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]: + """ + Post-process the result to convert scientific notation strings back to numbers + """ + + def convert_scientific_notation(value): + if isinstance(value, str): + # Check if the string looks like scientific notation + if re.match(r"^-?\d+\.?\d*e[+-]\d+$", value, re.IGNORECASE): + try: + return float(value) + except ValueError: + pass + elif isinstance(value, dict): + return {k: convert_scientific_notation(v) for k, v in value.items()} + elif isinstance(value, list): + return [convert_scientific_notation(v) for v in value] + return value + + return convert_scientific_notation(result) # type: ignore[no-any-return] + @classmethod @abstractmethod def get_runner_script(cls) -> str: From de22648b9ff1532bebedf53f3fd68a87c8b86b27 Mon Sep 17 00:00:00 2001 From: mizoo <55850618+mizoo-snow21@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:35:30 +0900 Subject: [PATCH 013/241] feat: Add support for type="hidden" input elements in Markdown forms (#21922) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/base/markdown-blocks/form.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index ab7e7cef53..b71193d8f9 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -28,6 +28,7 @@ enum SUPPORTED_TYPES { DATETIME = 'datetime', CHECKBOX = 'checkbox', SELECT = 'select', + HIDDEN = 'hidden', } const MarkdownForm = ({ node }: any) => { const { onSend } = useChatContext() @@ -37,8 +38,12 @@ const MarkdownForm = ({ node }: any) => { useEffect(() => { const initialValues: { [key: string]: any } = {} node.children.forEach((child: any) => { - if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) - initialValues[child.properties.name] = child.properties.value + if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) { + initialValues[child.properties.name] + = (child.tagName === SUPPORTED_TAGS.INPUT && child.properties.type === SUPPORTED_TYPES.HIDDEN) + ? (child.properties.value || '') + : child.properties.value + } }) setFormValues(initialValues) }, [node.children]) @@ -180,6 +185,17 @@ const MarkdownForm = ({ node }: any) => { ) } + if (child.properties.type === SUPPORTED_TYPES.HIDDEN) { + return ( + + ) + } + return ( Date: Mon, 7 Jul 2025 13:15:18 +0800 Subject: [PATCH 014/241] refactor(graph_engine): Take GraphRuntimeState out of GraphEngine (#21882) --- api/core/workflow/graph_engine/graph_engine.py | 4 ++-- .../workflow/nodes/iteration/iteration_node.py | 6 +++++- api/core/workflow/nodes/loop/loop_node.py | 6 +++++- api/core/workflow/workflow_entry.py | 3 ++- .../workflow/graph_engine/test_graph_engine.py | 14 ++++++++++---- .../core/workflow/nodes/test_continue_on_error.py | 14 +++++++++----- 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index 61a7a26652..5a2915e2d3 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -103,7 +103,7 @@ class GraphEngine: call_depth: int, graph: Graph, graph_config: Mapping[str, Any], - variable_pool: VariablePool, + graph_runtime_state: GraphRuntimeState, max_execution_steps: int, max_execution_time: int, thread_pool_id: Optional[str] = None, @@ -140,7 +140,7 @@ class GraphEngine: call_depth=call_depth, ) - self.graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + self.graph_runtime_state = graph_runtime_state self.max_execution_steps = max_execution_steps self.max_execution_time = max_execution_time diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py index 151efc28ec..c447f433aa 100644 --- a/api/core/workflow/nodes/iteration/iteration_node.py +++ b/api/core/workflow/nodes/iteration/iteration_node.py @@ -1,5 +1,6 @@ import contextvars import logging +import time import uuid from collections.abc import Generator, Mapping, Sequence from concurrent.futures import Future, wait @@ -133,8 +134,11 @@ class IterationNode(BaseNode[IterationNodeData]): variable_pool.add([self.node_id, "item"], iterator_list_value[0]) # init graph engine + from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine, GraphEngineThreadPool + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + graph_engine = GraphEngine( tenant_id=self.tenant_id, app_id=self.app_id, @@ -146,7 +150,7 @@ class IterationNode(BaseNode[IterationNodeData]): call_depth=self.workflow_call_depth, graph=iteration_graph, graph_config=graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=self.thread_pool_id, diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index 368d662a75..11fd7b6c2d 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/api/core/workflow/nodes/loop/loop_node.py @@ -1,5 +1,6 @@ import json import logging +import time from collections.abc import Generator, Mapping, Sequence from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Literal, cast @@ -101,8 +102,11 @@ class LoopNode(BaseNode[LoopNodeData]): loop_variable_selectors[loop_variable.label] = variable_selector inputs[loop_variable.label] = processed_segment.value + from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + graph_engine = GraphEngine( tenant_id=self.tenant_id, app_id=self.app_id, @@ -114,7 +118,7 @@ class LoopNode(BaseNode[LoopNodeData]): call_depth=self.workflow_call_depth, graph=loop_graph, graph_config=self.graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=self.thread_pool_id, diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c0e98db3db..2868dcb7de 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -69,6 +69,7 @@ class WorkflowEntry: raise ValueError("Max workflow call depth {} reached.".format(workflow_call_max_depth)) # init workflow run state + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) self.graph_engine = GraphEngine( tenant_id=tenant_id, app_id=app_id, @@ -80,7 +81,7 @@ class WorkflowEntry: call_depth=call_depth, graph=graph, graph_config=graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=thread_pool_id, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py index 7535ec4866..c288a5fa13 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py @@ -1,3 +1,4 @@ +import time from unittest.mock import patch import pytest @@ -19,6 +20,7 @@ from core.workflow.graph_engine.entities.event import ( NodeRunSucceededEvent, ) from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.entities.runtime_route_state import RouteNodeState from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes.code.code_node import CodeNode @@ -172,6 +174,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove): system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"} ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -183,7 +186,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -299,6 +302,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove): user_inputs={}, ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -310,7 +314,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -479,6 +483,7 @@ def test_run_branch(mock_close, mock_remove): user_inputs={"uid": "takato"}, ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -490,7 +495,7 @@ def test_run_branch(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -813,6 +818,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app): system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"} ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -824,7 +830,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py index ff60d5974b..a6c553faf0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py @@ -1,7 +1,9 @@ +import time from unittest.mock import patch from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow.entities.node_entities import NodeRunResult, WorkflowNodeExecutionMetadataKey +from core.workflow.entities.variable_pool import VariablePool from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.enums import SystemVariableKey from core.workflow.graph_engine.entities.event import ( @@ -11,6 +13,7 @@ from core.workflow.graph_engine.entities.event import ( NodeRunStreamChunkEvent, ) from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes.event.event import RunCompletedEvent, RunStreamChunkEvent from core.workflow.nodes.llm.node import LLMNode @@ -163,15 +166,16 @@ class ContinueOnErrorTestHelper: def create_test_graph_engine(graph_config: dict, user_inputs: dict | None = None): """Helper method to create a graph engine instance for testing""" graph = Graph.init(graph_config=graph_config) - variable_pool = { - "system_variables": { + variable_pool = VariablePool( + system_variables={ SystemVariableKey.QUERY: "clear", SystemVariableKey.FILES: [], SystemVariableKey.CONVERSATION_ID: "abababa", SystemVariableKey.USER_ID: "aaa", }, - "user_inputs": user_inputs or {"uid": "takato"}, - } + user_inputs=user_inputs or {"uid": "takato"}, + ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) return GraphEngine( tenant_id="111", @@ -184,7 +188,7 @@ class ContinueOnErrorTestHelper: invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) From c48b32c9e3d800c68b5543badf578d4543ba8919 Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Mon, 7 Jul 2025 14:52:36 +0800 Subject: [PATCH 015/241] ENH(ui): enhance check list (#21932) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../workflow/hooks/use-checklist.ts | 35 ++++++++- .../hooks/use-nodes-available-var-list.ts | 75 +++++++++++++++++++ .../nodes/_base/components/variable/utils.ts | 4 +- 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 web/app/components/workflow/hooks/use-nodes-available-var-list.ts diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 200bee1d26..47ebaa6710 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -9,6 +9,7 @@ import type { CommonNodeType, Edge, Node, + ValueSelector, } from '../types' import { BlockEnum } from '../types' import { useStore } from '../store' @@ -33,6 +34,8 @@ import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/ty import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { MAX_TREE_DEPTH } from '@/config' +import useNodesAvailableVarList from './use-nodes-available-var-list' +import { getNodeUsedVars, isConversationVar, isENV, isSystemVar } from '../nodes/_base/components/variable/utils' export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { t } = useTranslation() @@ -45,6 +48,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { data: strategyProviders } = useStrategyProviders() const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) + const map = useNodesAvailableVarList(nodes) + const getCheckData = useCallback((data: CommonNodeType<{}>) => { let checkData = data if (data.type === BlockEnum.KnowledgeRetrieval) { @@ -70,6 +75,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const node = nodes[i] let toolIcon let moreDataForCheckValid + let usedVars: ValueSelector[] = [] if (node.data.type === BlockEnum.Tool) { const { provider_type } = node.data @@ -84,8 +90,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (provider_type === CollectionType.workflow) toolIcon = workflowTools.find(tool => tool.id === node.data.provider_id)?.icon } - - if (node.data.type === BlockEnum.Agent) { + else if (node.data.type === BlockEnum.Agent) { const data = node.data as AgentNodeType const isReadyForCheckValid = !!strategyProviders const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name) @@ -97,10 +102,34 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { isReadyForCheckValid, } } + else { + usedVars = getNodeUsedVars(node).filter(v => v.length > 0) + } if (node.type === CUSTOM_NODE) { const checkData = getCheckData(node.data) - const { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) + let { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) + + if (!errorMessage) { + const availableVars = map[node.id].availableVars + + for (const variable of usedVars) { + const isEnv = isENV(variable) + const isConvVar = isConversationVar(variable) + const isSysVar = isSystemVar(variable) + if (!isEnv && !isConvVar && !isSysVar) { + const usedNode = availableVars.find(v => v.nodeId === variable?.[0]) + if (usedNode) { + const usedVar = usedNode.vars.find(v => v.variable === variable?.[1]) + if (!usedVar) + errorMessage = t('workflow.errorMsg.invalidVariable') + } + else { + errorMessage = t('workflow.errorMsg.invalidVariable') + } + } + } + } if (errorMessage || !validNodes.find(n => n.id === node.id)) { list.push({ diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts new file mode 100644 index 0000000000..5efe2519ef --- /dev/null +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -0,0 +1,75 @@ +import { + useIsChatMode, + useWorkflow, + useWorkflowVariables, +} from '@/app/components/workflow/hooks' +import { BlockEnum, type Node, type NodeOutPutVar, type ValueSelector, type Var } from '@/app/components/workflow/types' +type Params = { + onlyLeafNodeVar?: boolean + hideEnv?: boolean + hideChatVar?: boolean + filterVar: (payload: Var, selector: ValueSelector) => boolean + passedInAvailableNodes?: Node[] +} + +const getNodeInfo = (nodeId: string, nodes: Node[]) => { + const allNodes = nodes + const node = allNodes.find(n => n.id === nodeId) + const isInIteration = !!node?.data.isInIteration + const isInLoop = !!node?.data.isInLoop + const parentNodeId = node?.parentId + const parentNode = allNodes.find(n => n.id === parentNodeId) + return { + node, + isInIteration, + isInLoop, + parentNode, + } +} + +// TODO: loop type? +const useNodesAvailableVarList = (nodes: Node[], { + onlyLeafNodeVar, + filterVar, + hideEnv = false, + hideChatVar = false, + passedInAvailableNodes, +}: Params = { + onlyLeafNodeVar: false, + filterVar: () => true, + }) => { + const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() + const { getNodeAvailableVars } = useWorkflowVariables() + const isChatMode = useIsChatMode() + + const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {} + + nodes.forEach((node) => { + const nodeId = node.id + const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) + if (node.data.type === BlockEnum.Loop) + availableNodes.push(node) + + const { + parentNode: iterationNode, + } = getNodeInfo(nodeId, nodes) + + const availableVars = getNodeAvailableVars({ + parentNode: iterationNode, + beforeNodes: availableNodes, + isChatMode, + filterVar, + hideEnv, + hideChatVar, + }) + const result = { + node, + availableVars, + availableNodes, + } + nodeAvailabilityMap[nodeId] = result + }) + return nodeAvailabilityMap +} + +export default useNodesAvailableVarList diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index a69f9a51a7..7bd78ec6b6 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -948,9 +948,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { break } case BlockEnum.Answer: { - res = (data as AnswerNodeType).variables?.map((v) => { - return v.value_selector - }) + res = matchNotSystemVars([(data as AnswerNodeType).answer]) break } case BlockEnum.LLM: { From b654c852a5685e187c22583888afc184da6f7e12 Mon Sep 17 00:00:00 2001 From: Arcaner <52057416+lrhan321@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:51:49 +0800 Subject: [PATCH 016/241] =?UTF-8?q?chore(docker):=20increase=20NGINX=5FCLI?= =?UTF-8?q?ENT=5FMAX=5FBODY=5FSIZE=20from=2015M=20to=20100M=20i=E2=80=A6?= =?UTF-8?q?=20(#21995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/.env.example | 2 +- docker/docker-compose-template.yaml | 2 +- docker/docker-compose.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index a024566c8f..e7dbecb413 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -958,7 +958,7 @@ NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3 # Nginx performance tuning NGINX_WORKER_PROCESSES=auto -NGINX_CLIENT_MAX_BODY_SIZE=15M +NGINX_CLIENT_MAX_BODY_SIZE=100M NGINX_KEEPALIVE_TIMEOUT=65 # Proxy settings diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index d45f8f8bfa..a34f96e945 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -265,7 +265,7 @@ services: NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 7f91fd8796..e48b5afd8c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -420,7 +420,7 @@ x-shared-env: &shared-api-worker-env NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} @@ -780,7 +780,7 @@ services: NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} From 5b0b64c7e5ca6b17e64b1987727633e58b717cc0 Mon Sep 17 00:00:00 2001 From: quicksand Date: Mon, 7 Jul 2025 21:53:40 +0800 Subject: [PATCH 017/241] fix: document delete image files check file exist (#21991) --- api/core/indexing_runner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index f2fe306179..305a9190d5 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -317,9 +317,10 @@ class IndexingRunner: image_upload_file_ids = get_image_upload_file_ids(document.page_content) for upload_file_id in image_upload_file_ids: image_file = db.session.query(UploadFile).filter(UploadFile.id == upload_file_id).first() + if image_file is None: + continue try: - if image_file: - storage.delete(image_file.key) + storage.delete(image_file.key) except Exception: logging.exception( "Delete image_files failed while indexing_estimate, \ From de54f8d0efe9a438c57da3c761f3869dcc45a367 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Mon, 7 Jul 2025 21:55:34 +0800 Subject: [PATCH 018/241] Chore: remove unreachable code (#21986) --- api/controllers/files/upload.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index f1a15793c7..15f93d2774 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -87,7 +87,5 @@ class PluginUploadFileApi(Resource): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - return tool_file, 201 - api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin") From aded30b66433552528d02f5d8ba31602dfe66c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B8=BD=E5=AD=90?= <31590806+GeZiMm@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:58:05 +0800 Subject: [PATCH 019/241] fix: resolve dropdown menu visibility issue caused by z-index conflict (#22000) --- web/app/components/tools/provider-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 0970daab9c..b0b4f8a8bc 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -72,7 +72,7 @@ const ProviderList = () => { className='relative flex grow flex-col overflow-y-auto bg-background-body' >
Date: Mon, 7 Jul 2025 22:04:38 +0800 Subject: [PATCH 020/241] minro fix: fix a typo for aliyun (#22001) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/core/ops/aliyun_trace/entities/semconv.py | 2 +- .../(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/core/ops/aliyun_trace/entities/semconv.py b/api/core/ops/aliyun_trace/entities/semconv.py index 657cf1da0c..5d70264320 100644 --- a/api/core/ops/aliyun_trace/entities/semconv.py +++ b/api/core/ops/aliyun_trace/entities/semconv.py @@ -41,7 +41,7 @@ GEN_AI_PROMPT_TEMPLATE_VARIABLE = "gen_ai.prompt_template.variable" GEN_AI_PROMPT = "gen_ai.prompt" -GEN_AI_COMPLETION = "gem_ai.completion" +GEN_AI_COMPLETION = "gen_ai.completion" GEN_AI_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason" diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 644889f023..2afe451fe1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -179,7 +179,7 @@ const ConfigPopup: FC = ({ onConfig={handleOnConfig(TracingProvider.aliyun)} isChosen={chosenProvider === TracingProvider.aliyun} onChoose={handleOnChoose(TracingProvider.aliyun)} - key="alyun-provider-panel" + key="aliyun-provider-panel" /> ) const configuredProviderPanel = () => { From 53c4912cbb18aa213f27ab0bb7f748a9e259269f Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:32:30 +0800 Subject: [PATCH 021/241] feat: add unit tests and validation for aliyun tracing (#22012) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/core/ops/entities/config_entity.py | 17 +++++ .../unit_tests/core/ops/test_config_entity.py | 76 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py index c53a236fc0..89ff0cfded 100644 --- a/api/core/ops/entities/config_entity.py +++ b/api/core/ops/entities/config_entity.py @@ -176,6 +176,23 @@ class AliyunConfig(BaseTracingConfig): license_key: str endpoint: str + @field_validator("app_name") + @classmethod + def app_name_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "dify_app") + + @field_validator("license_key") + @classmethod + def license_key_validator(cls, v, info: ValidationInfo): + if not v or v.strip() == "": + raise ValueError("License key cannot be empty") + return v + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://tracing-analysis-dc-hz.aliyuncs.com") + OPS_FILE_PATH = "ops_trace/" OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE" diff --git a/api/tests/unit_tests/core/ops/test_config_entity.py b/api/tests/unit_tests/core/ops/test_config_entity.py index 21047106d3..81cb04548d 100644 --- a/api/tests/unit_tests/core/ops/test_config_entity.py +++ b/api/tests/unit_tests/core/ops/test_config_entity.py @@ -2,6 +2,7 @@ import pytest from pydantic import ValidationError from core.ops.entities.config_entity import ( + AliyunConfig, ArizeConfig, LangfuseConfig, LangSmithConfig, @@ -23,6 +24,7 @@ class TestTracingProviderEnum: assert TracingProviderEnum.LANGSMITH == "langsmith" assert TracingProviderEnum.OPIK == "opik" assert TracingProviderEnum.WEAVE == "weave" + assert TracingProviderEnum.ALIYUN == "aliyun" class TestArizeConfig: @@ -272,6 +274,71 @@ class TestWeaveConfig: WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com") +class TestAliyunConfig: + """Test cases for AliyunConfig""" + + def test_valid_config(self): + """Test valid Aliyun configuration""" + config = AliyunConfig( + app_name="test_app", + license_key="test_license_key", + endpoint="https://custom.tracing-analysis-dc-hz.aliyuncs.com", + ) + assert config.app_name == "test_app" + assert config.license_key == "test_license_key" + assert config.endpoint == "https://custom.tracing-analysis-dc-hz.aliyuncs.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + assert config.app_name == "dify_app" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + AliyunConfig() + + with pytest.raises(ValidationError): + AliyunConfig(license_key="test_license") + + with pytest.raises(ValidationError): + AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + def test_app_name_validation_empty(self): + """Test app_name validation with empty value""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) + assert config.app_name == "dify_app" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = AliyunConfig(license_key="test_license", endpoint="") + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + AliyunConfig(license_key="test_license", endpoint="ftp://invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + AliyunConfig(license_key="test_license", endpoint="invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_license_key_required(self): + """Test that license_key is required and cannot be empty""" + with pytest.raises(ValidationError): + AliyunConfig(license_key="", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + class TestConfigIntegration: """Integration tests for configuration classes""" @@ -284,6 +351,7 @@ class TestConfigIntegration: LangSmithConfig(api_key="key", project="project"), OpikConfig(api_key="key"), WeaveConfig(api_key="key", project="project"), + AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com"), ] for config in configs: @@ -294,16 +362,24 @@ class TestConfigIntegration: # Test that paths are removed from endpoints arize_config = ArizeConfig(endpoint="https://arize.com/api/v1/test") phoenix_config = PhoenixConfig(endpoint="https://phoenix.com/api/v2/") + aliyun_config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) assert arize_config.endpoint == "https://arize.com" assert phoenix_config.endpoint == "https://phoenix.com" + assert aliyun_config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" def test_project_default_values(self): """Test that project default values are set correctly""" arize_config = ArizeConfig(project="") phoenix_config = PhoenixConfig(project="") opik_config = OpikConfig(project="") + aliyun_config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) assert arize_config.project == "default" assert phoenix_config.project == "default" assert opik_config.project == "Default Project" + assert aliyun_config.app_name == "dify_app" From 71385d594deefff806b4e7e05eb45582adf6f304 Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Tue, 8 Jul 2025 09:33:13 +0800 Subject: [PATCH 022/241] fix(variables): Improve `getNodeUsedVars` implementation details (#21987) --- .../workflow/nodes/_base/components/variable/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 7bd78ec6b6..1058f29119 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -975,6 +975,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = (data as IfElseNodeType).conditions?.map((c) => { return c.variable_selector || [] }) || [] + res.push(...((data as IfElseNodeType).cases || []).flatMap(c => (c.conditions || [])).map(c => c.variable_selector || [])) break } case BlockEnum.Code: { @@ -994,6 +995,9 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = [payload.query_variable_selector] const varInInstructions = matchNotSystemVars([payload.instruction || '']) res.push(...varInInstructions) + + const classes = payload.classes.map(c => c.name) + res.push(...matchNotSystemVars(classes)) break } case BlockEnum.HttpRequest: { From 2ffbf5435d6685650d80c1c23df81773bd5620af Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:49:53 +0800 Subject: [PATCH 023/241] minro fix: fix duplicate local import of ToolProviderType (#22013) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/core/workflow/nodes/agent/agent_node.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index ddc03b8d78..766cdb604f 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -14,7 +14,7 @@ from core.model_runtime.entities.model_entities import AIModelEntity, ModelType from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.impl.plugin import PluginInstaller from core.provider_manager import ProviderManager -from core.tools.entities.tool_entities import ToolParameter, ToolProviderType +from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType from core.tools.tool_manager import ToolManager from core.variables.segments import StringSegment from core.workflow.entities.node_entities import NodeRunResult @@ -105,8 +105,6 @@ class AgentNode(ToolNode): # convert tool messages agent_thoughts: list = [] - from core.tools.entities.tool_entities import ToolInvokeMessage - thought_log_message = ToolInvokeMessage( type=ToolInvokeMessage.MessageType.LOG, message=ToolInvokeMessage.LogMessage( @@ -127,8 +125,6 @@ class AgentNode(ToolNode): ), ) - from core.tools.entities.tool_entities import ToolInvokeMessage - def enhanced_message_stream(): yield thought_log_message From 5c0cb7f9129f448783173b82c5723db94ffa48c0 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:00:00 +0800 Subject: [PATCH 024/241] test: add unit tests for password validation and hashing (#22003) --- api/tests/unit_tests/libs/test_password.py | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 api/tests/unit_tests/libs/test_password.py diff --git a/api/tests/unit_tests/libs/test_password.py b/api/tests/unit_tests/libs/test_password.py new file mode 100644 index 0000000000..79fc792cc5 --- /dev/null +++ b/api/tests/unit_tests/libs/test_password.py @@ -0,0 +1,74 @@ +import base64 +import binascii +import os + +import pytest + +from libs.password import compare_password, hash_password, valid_password + + +class TestValidPassword: + """Test password format validation""" + + def test_should_accept_valid_passwords(self): + """Test accepting valid password formats""" + assert valid_password("password123") == "password123" + assert valid_password("test1234") == "test1234" + assert valid_password("Test123456") == "Test123456" + + def test_should_reject_invalid_passwords(self): + """Test rejecting invalid password formats""" + # Too short + with pytest.raises(ValueError) as exc_info: + valid_password("abc123") + assert "Password must contain letters and numbers" in str(exc_info.value) + + # No numbers + with pytest.raises(ValueError): + valid_password("abcdefgh") + + # No letters + with pytest.raises(ValueError): + valid_password("12345678") + + # Empty + with pytest.raises(ValueError): + valid_password("") + + +class TestPasswordHashing: + """Test password hashing and comparison""" + + def setup_method(self): + """Setup test data""" + self.password = "test123password" + self.salt = os.urandom(16) + self.salt_base64 = base64.b64encode(self.salt).decode() + + password_hash = hash_password(self.password, self.salt) + self.password_hash_base64 = base64.b64encode(password_hash).decode() + + def test_should_verify_correct_password(self): + """Test correct password verification""" + result = compare_password(self.password, self.password_hash_base64, self.salt_base64) + assert result is True + + def test_should_reject_wrong_password(self): + """Test rejection of incorrect passwords""" + result = compare_password("wrongpassword", self.password_hash_base64, self.salt_base64) + assert result is False + + def test_should_handle_invalid_base64(self): + """Test handling of invalid base64 data""" + # Invalid base64 hash + with pytest.raises(binascii.Error): + compare_password(self.password, "invalid_base64!", self.salt_base64) + + # Invalid base64 salt + with pytest.raises(binascii.Error): + compare_password(self.password, self.password_hash_base64, "invalid_base64!") + + def test_should_be_case_sensitive(self): + """Test password case sensitivity""" + result = compare_password(self.password.upper(), self.password_hash_base64, self.salt_base64) + assert result is False From a4bdeba60df49bd2ab52b2a45457b10beba835c6 Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Tue, 8 Jul 2025 10:04:05 +0800 Subject: [PATCH 025/241] feat(question-classifier): add instanceId to class-item editor (#22002) --- .../nodes/question-classifier/components/class-item.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx index 6065037322..be4a6cb901 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx @@ -1,11 +1,12 @@ 'use client' import type { FC } from 'react' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { Topic } from '../types' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import type { ValueSelector, Var } from '@/app/components/workflow/types' +import { uniqueId } from 'lodash-es' const i18nPrefix = 'workflow.nodes.questionClassifiers' @@ -29,6 +30,11 @@ const ClassItem: FC = ({ filterVar, }) => { const { t } = useTranslation() + const [instanceId, setInstanceId] = useState(uniqueId()) + + useEffect(() => { + setInstanceId(`${nodeId}-${uniqueId()}`) + }, [nodeId]) const handleNameChange = useCallback((value: string) => { onChange({ ...payload, name: value }) @@ -52,6 +58,7 @@ const ClassItem: FC = ({ nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} readOnly={readonly} // ? + instanceId={instanceId} justVar // ? isSupportFileVar // ? /> From f62b59a80568f81db968ad94b5e183d72ec55508 Mon Sep 17 00:00:00 2001 From: NFish Date: Tue, 8 Jul 2025 15:15:38 +0800 Subject: [PATCH 026/241] don't add search params when opening detail links from marketplace. (#22034) --- .../components/plugins/marketplace/list/card-wrapper.tsx | 4 ++-- web/app/components/plugins/marketplace/utils.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 5e4b621796..8d9f3a5b10 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import { useTheme } from 'next-themes' import { RiArrowRightUpLine } from '@remixicon/react' -import { getPluginLinkInMarketplace } from '../utils' +import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import type { Plugin } from '@/app/components/plugins/types' @@ -83,7 +83,7 @@ const CardWrapper = ({ return ( { + if (plugin.type === 'bundle') + return `/bundles/${plugin.org}/${plugin.name}` + return `/plugins/${plugin.org}/${plugin.name}` +} + export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => { let plugins = [] as Plugin[] From f925869f61a357b8cefd5a38d10cb003dbbaac9e Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Tue, 8 Jul 2025 15:41:27 +0800 Subject: [PATCH 027/241] fix(variable): ensure unique variable names in var-list (#22038) --- .../nodes/_base/components/variable/var-list.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx index 73ee8262a0..9eb34ac7f2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx @@ -75,8 +75,16 @@ const VarList: FC = ({ if (isSupportConstantValue) draft[index].variable_type = VarKindType.variable - if (!draft[index].variable) - draft[index].variable = value[value.length - 1] + if (!draft[index].variable) { + const variables = draft.map(v => v.variable) + let newVarName = value[value.length - 1] + let count = 1 + while (variables.includes(newVarName)) { + newVarName = `${value[value.length - 1]}_${count}` + count++ + } + draft[index].variable = newVarName + } } else { draft[index].variable_type = VarKindType.constant From 816210d74491eccb950d22d877f084751158646b Mon Sep 17 00:00:00 2001 From: Davide Delbianco Date: Tue, 8 Jul 2025 15:18:00 +0200 Subject: [PATCH 028/241] Expose LLM usage in workflows (#21766) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/core/ops/aliyun_trace/aliyun_trace.py | 7 ++--- .../arize_phoenix_trace.py | 26 ++++++++++++------- api/core/ops/langfuse_trace/langfuse_trace.py | 9 +++---- .../ops/langsmith_trace/langsmith_trace.py | 9 +++---- api/core/ops/opik_trace/opik_trace.py | 8 +++--- api/core/workflow/nodes/llm/node.py | 20 +++++++------- .../parameter_extractor_node.py | 7 ++++- .../question_classifier_node.py | 6 ++++- web/app/components/workflow/constants.ts | 12 +++++++++ .../components/workflow/nodes/llm/panel.tsx | 5 ++++ .../nodes/parameter-extractor/panel.tsx | 9 +++++-- .../nodes/question-classifier/panel.tsx | 5 ++++ web/i18n/de-DE/workflow.ts | 8 ++++-- web/i18n/en-US/workflow.ts | 8 ++++-- web/i18n/es-ES/workflow.ts | 8 ++++-- web/i18n/fa-IR/workflow.ts | 8 ++++-- web/i18n/fr-FR/workflow.ts | 8 ++++-- web/i18n/hi-IN/workflow.ts | 8 ++++-- web/i18n/it-IT/workflow.ts | 9 ++++--- web/i18n/ja-JP/workflow.ts | 8 ++++-- web/i18n/ko-KR/workflow.ts | 6 +++++ web/i18n/pl-PL/workflow.ts | 8 ++++-- web/i18n/pt-BR/workflow.ts | 8 ++++-- web/i18n/ro-RO/workflow.ts | 8 ++++-- web/i18n/ru-RU/workflow.ts | 8 ++++-- web/i18n/sl-SI/workflow.ts | 8 ++++-- web/i18n/th-TH/workflow.ts | 8 ++++-- web/i18n/tr-TR/workflow.ts | 8 ++++-- web/i18n/uk-UA/workflow.ts | 8 ++++-- web/i18n/vi-VN/workflow.ts | 8 ++++-- web/i18n/zh-Hans/workflow.ts | 8 ++++-- web/i18n/zh-Hant/workflow.ts | 8 ++++-- 32 files changed, 201 insertions(+), 81 deletions(-) diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/core/ops/aliyun_trace/aliyun_trace.py index 163b5d0307..b18a6905fe 100644 --- a/api/core/ops/aliyun_trace/aliyun_trace.py +++ b/api/core/ops/aliyun_trace/aliyun_trace.py @@ -372,6 +372,7 @@ class AliyunDataTrace(BaseTraceInstance): ) -> SpanData: process_data = node_execution.process_data or {} outputs = node_execution.outputs or {} + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) return SpanData( trace_id=trace_id, parent_span_id=workflow_span_id, @@ -385,9 +386,9 @@ class AliyunDataTrace(BaseTraceInstance): GEN_AI_FRAMEWORK: "dify", GEN_AI_MODEL_NAME: process_data.get("model_name", ""), GEN_AI_SYSTEM: process_data.get("model_provider", ""), - GEN_AI_USAGE_INPUT_TOKENS: str(outputs.get("usage", {}).get("prompt_tokens", 0)), - GEN_AI_USAGE_OUTPUT_TOKENS: str(outputs.get("usage", {}).get("completion_tokens", 0)), - GEN_AI_USAGE_TOTAL_TOKENS: str(outputs.get("usage", {}).get("total_tokens", 0)), + GEN_AI_USAGE_INPUT_TOKENS: str(usage_data.get("prompt_tokens", 0)), + GEN_AI_USAGE_OUTPUT_TOKENS: str(usage_data.get("completion_tokens", 0)), + GEN_AI_USAGE_TOTAL_TOKENS: str(usage_data.get("total_tokens", 0)), GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False), GEN_AI_COMPLETION: str(outputs.get("text", "")), GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""), diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py index 0b6834acf3..ffda0885d4 100644 --- a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py +++ b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py @@ -213,11 +213,12 @@ class ArizePhoenixDataTrace(BaseTraceInstance): if model: node_metadata["ls_model_name"] = model - usage = json.loads(node_execution.outputs).get("usage", {}) if node_execution.outputs else {} - if usage: - node_metadata["total_tokens"] = usage.get("total_tokens", 0) - node_metadata["prompt_tokens"] = usage.get("prompt_tokens", 0) - node_metadata["completion_tokens"] = usage.get("completion_tokens", 0) + outputs = json.loads(node_execution.outputs).get("usage", {}) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + if usage_data: + node_metadata["total_tokens"] = usage_data.get("total_tokens", 0) + node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0) + node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0) elif node_execution.node_type == "dataset_retrieval": span_kind = OpenInferenceSpanKindValues.RETRIEVER.value elif node_execution.node_type == "tool": @@ -246,14 +247,19 @@ class ArizePhoenixDataTrace(BaseTraceInstance): if model: node_span.set_attribute(SpanAttributes.LLM_MODEL_NAME, model) - usage = json.loads(node_execution.outputs).get("usage", {}) if node_execution.outputs else {} - if usage: - node_span.set_attribute(SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage.get("total_tokens", 0)) + outputs = json.loads(node_execution.outputs).get("usage", {}) + usage_data = ( + process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + ) + if usage_data: node_span.set_attribute( - SpanAttributes.LLM_TOKEN_COUNT_PROMPT, usage.get("prompt_tokens", 0) + SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage_data.get("total_tokens", 0) ) node_span.set_attribute( - SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, usage.get("completion_tokens", 0) + SpanAttributes.LLM_TOKEN_COUNT_PROMPT, usage_data.get("prompt_tokens", 0) + ) + node_span.set_attribute( + SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, usage_data.get("completion_tokens", 0) ) finally: node_span.end(end_time=datetime_to_nanos(finished_at)) diff --git a/api/core/ops/langfuse_trace/langfuse_trace.py b/api/core/ops/langfuse_trace/langfuse_trace.py index 1d4ae49fc7..a3dbce0e59 100644 --- a/api/core/ops/langfuse_trace/langfuse_trace.py +++ b/api/core/ops/langfuse_trace/langfuse_trace.py @@ -181,12 +181,9 @@ class LangFuseDataTrace(BaseTraceInstance): prompt_tokens = 0 completion_tokens = 0 try: - if outputs.get("usage"): - prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0) - else: - prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/ops/langsmith_trace/langsmith_trace.py b/api/core/ops/langsmith_trace/langsmith_trace.py index 8a392940db..f94e5e49d7 100644 --- a/api/core/ops/langsmith_trace/langsmith_trace.py +++ b/api/core/ops/langsmith_trace/langsmith_trace.py @@ -206,12 +206,9 @@ class LangSmithDataTrace(BaseTraceInstance): prompt_tokens = 0 completion_tokens = 0 try: - if outputs.get("usage"): - prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0) - else: - prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/ops/opik_trace/opik_trace.py b/api/core/ops/opik_trace/opik_trace.py index f4d2760ba5..8bedea20fb 100644 --- a/api/core/ops/opik_trace/opik_trace.py +++ b/api/core/ops/opik_trace/opik_trace.py @@ -222,10 +222,10 @@ class OpikDataTrace(BaseTraceInstance): ) try: - if outputs.get("usage"): - total_tokens = outputs["usage"].get("total_tokens", 0) - prompt_tokens = outputs["usage"].get("prompt_tokens", 0) - completion_tokens = outputs["usage"].get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + total_tokens = usage_data.get("total_tokens", 0) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index b5225ce548..9bfb402dc8 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -221,15 +221,6 @@ class LLMNode(BaseNode[LLMNodeData]): jinja2_variables=self.node_data.prompt_config.jinja2_variables, ) - process_data = { - "model_mode": model_config.mode, - "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( - model_mode=model_config.mode, prompt_messages=prompt_messages - ), - "model_provider": model_config.provider, - "model_name": model_config.model, - } - # handle invoke result generator = self._invoke_llm( node_data_model=self.node_data.model, @@ -253,6 +244,17 @@ class LLMNode(BaseNode[LLMNodeData]): elif isinstance(event, LLMStructuredOutput): structured_output = event + process_data = { + "model_mode": model_config.mode, + "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( + model_mode=model_config.mode, prompt_messages=prompt_messages + ), + "usage": jsonable_encoder(usage), + "finish_reason": finish_reason, + "model_provider": model_config.provider, + "model_name": model_config.model, + } + outputs = {"text": result_text, "usage": jsonable_encoder(usage), "finish_reason": finish_reason} if structured_output: outputs["structured_output"] = structured_output.structured_output diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py index 8d6c2d0a5c..25a534256b 100644 --- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py +++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py @@ -253,7 +253,12 @@ class ParameterExtractorNode(BaseNode): status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=inputs, process_data=process_data, - outputs={"__is_success": 1 if not error else 0, "__reason": error, **result}, + outputs={ + "__is_success": 1 if not error else 0, + "__reason": error, + "__usage": jsonable_encoder(usage), + **result, + }, metadata={ WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens, WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price, diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index a518167cc6..74024ed90c 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -145,7 +145,11 @@ class QuestionClassifierNode(LLMNode): "model_provider": model_config.provider, "model_name": model_config.model, } - outputs = {"class_name": category_name, "class_id": category_id} + outputs = { + "class_name": category_name, + "class_id": category_id, + "usage": jsonable_encoder(usage), + } return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 304295cfbf..0ef4dc9dea 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -480,6 +480,10 @@ export const LLM_OUTPUT_STRUCT: Var[] = [ variable: 'text', type: VarType.string, }, + { + variable: 'usage', + type: VarType.object, + }, ] export const KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT: Var[] = [ @@ -501,6 +505,10 @@ export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [ variable: 'class_name', type: VarType.string, }, + { + variable: 'usage', + type: VarType.object, + }, ] export const HTTP_REQUEST_OUTPUT_STRUCT: Var[] = [ @@ -546,6 +554,10 @@ export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [ variable: '__reason', type: VarType.string, }, + { + variable: '__usage', + type: VarType.object, + }, ] export const FILE_STRUCT: Var[] = [ diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 2a71dffa11..471d65ef20 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -282,6 +282,11 @@ const Panel: FC> = ({ type='string' description={t(`${i18nPrefix}.outputVars.output`)} /> + {inputs.structured_output_enabled && ( <> diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index e86a2e3764..a169217609 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -190,12 +190,17 @@ const Panel: FC> = ({ + diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index 8f6f5eb76d..8cf9ec5f7c 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -129,6 +129,11 @@ const Panel: FC> = ({ type='string' description={t(`${i18nPrefix}.outputVars.className`)} /> +
diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 55f5bac0e1..e28577eec7 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Eingabevariablen', outputVars: { className: 'Klassennamen', + usage: 'Nutzungsinformationen des Modells', }, class: 'Klasse', classNamePlaceholder: 'Geben Sie Ihren Klassennamen ein', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Eingabevariable', + outputVars: { + isSuccess: 'Ist Erfolg. Bei Erfolg beträgt der Wert 1, bei Misserfolg beträgt der Wert 0.', + errorReason: 'Fehlergrund', + usage: 'Nutzungsinformationen des Modells', + }, extractParameters: 'Parameter extrahieren', importFromTool: 'Aus Tools importieren', addExtractParameter: 'Extraktionsparameter hinzufügen', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Erweiterte Einstellung', reasoningMode: 'Schlussfolgerungsmodus', reasoningModeTip: 'Sie können den entsprechenden Schlussfolgerungsmodus basierend auf der Fähigkeit des Modells wählen, auf Anweisungen zur Funktionsaufruf- oder Eingabeaufforderungen zu reagieren.', - isSuccess: 'Ist Erfolg. Bei Erfolg beträgt der Wert 1, bei Misserfolg beträgt der Wert 0.', - errorReason: 'Fehlergrund', }, iteration: { deleteTitle: 'Iterationsknoten löschen?', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index df0bb904fd..6518afd604 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -673,6 +673,7 @@ const translation = { inputVars: 'Input Variables', outputVars: { className: 'Class Name', + usage: 'Model Usage Information', }, class: 'Class', classNamePlaceholder: 'Write your class name', @@ -686,6 +687,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Input Variable', + outputVars: { + isSuccess: 'Is Success.On success the value is 1, on failure the value is 0.', + errorReason: 'Error Reason', + usage: 'Model Usage Information', + }, extractParameters: 'Extract Parameters', importFromTool: 'Import from tools', addExtractParameter: 'Add Extract Parameter', @@ -705,8 +711,6 @@ const translation = { advancedSetting: 'Advanced Setting', reasoningMode: 'Reasoning Mode', reasoningModeTip: 'You can choose the appropriate reasoning mode based on the model\'s ability to respond to instructions for function calling or prompts.', - isSuccess: 'Is Success.On success the value is 1, on failure the value is 0.', - errorReason: 'Error Reason', }, iteration: { deleteTitle: 'Delete Iteration Node?', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 580072895c..cff9a4ff5e 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -667,6 +667,7 @@ const translation = { inputVars: 'Variables de entrada', outputVars: { className: 'Nombre de la clase', + usage: 'Información de uso del modelo', }, class: 'Clase', classNamePlaceholder: 'Escribe el nombre de tu clase', @@ -680,6 +681,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variable de entrada', + outputVars: { + isSuccess: 'Es éxito. En caso de éxito el valor es 1, en caso de fallo el valor es 0.', + errorReason: 'Motivo del error', + usage: 'Información de uso del modelo', + }, extractParameters: 'Extraer parámetros', importFromTool: 'Importar desde herramientas', addExtractParameter: 'Agregar parámetro de extracción', @@ -699,8 +705,6 @@ const translation = { advancedSetting: 'Configuración avanzada', reasoningMode: 'Modo de razonamiento', reasoningModeTip: 'Puede elegir el modo de razonamiento apropiado basado en la capacidad del modelo para responder a instrucciones para llamadas de funciones o indicaciones.', - isSuccess: 'Es éxito. En caso de éxito el valor es 1, en caso de fallo el valor es 0.', - errorReason: 'Motivo del error', }, iteration: { deleteTitle: '¿Eliminar nodo de iteración?', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 1f2d87a84b..884acb2388 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'متغیرهای ورودی', outputVars: { className: 'نام کلاس', + usage: 'اطلاعات استفاده از مدل', }, class: 'کلاس', classNamePlaceholder: 'نام کلاس خود را بنویسید', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'متغیر ورودی', + outputVars: { + isSuccess: 'موفقیت‌آمیز است. در صورت موفقیت مقدار 1 و در صورت شکست مقدار 0 است.', + errorReason: 'دلیل خطا', + usage: 'اطلاعات استفاده از مدل', + }, extractParameters: 'استخراج پارامترها', importFromTool: 'وارد کردن از ابزارها', addExtractParameter: 'افزودن پارامتر استخراج شده', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'تنظیمات پیشرفته', reasoningMode: 'حالت استدلال', reasoningModeTip: 'می‌توانید حالت استدلال مناسب را بر اساس توانایی مدل برای پاسخ به دستورات برای فراخوانی عملکردها یا پیشنهادات انتخاب کنید.', - isSuccess: 'موفقیت‌آمیز است. در صورت موفقیت مقدار 1 و در صورت شکست مقدار 0 است.', - errorReason: 'دلیل خطا', }, iteration: { deleteTitle: 'حذف نود تکرار؟', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 0878d18374..aae437d813 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Variables de saisie', outputVars: { className: 'Nom de la classe', + usage: 'Informations sur l\'utilisation du modèle', }, class: 'Classe', classNamePlaceholder: 'Écrivez le nom de votre classe', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variable de saisie', + outputVars: { + isSuccess: 'Est réussi. En cas de succès, la valeur est 1, en cas d\'échec, la valeur est 0.', + errorReason: 'Raison de l\'erreur', + usage: 'Informations sur l\'utilisation du modèle', + }, extractParameters: 'Extraire des paramètres', importFromTool: 'Importer des outils', addExtractParameter: 'Ajouter un paramètre d\'extraction', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Paramètre avancé', reasoningMode: 'Mode de raisonnement', reasoningModeTip: 'Vous pouvez choisir le mode de raisonnement approprié en fonction de la capacité du modèle à répondre aux instructions pour les appels de fonction ou les invites.', - isSuccess: 'Est réussi. En cas de succès, la valeur est 1, en cas d\'échec, la valeur est 0.', - errorReason: 'Raison de l\'erreur', }, iteration: { deleteTitle: 'Supprimer le nœud d\'itération?', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 316116dc02..0fc8449c27 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -685,6 +685,7 @@ const translation = { inputVars: 'इनपुट वेरिएबल्स', outputVars: { className: 'क्लास नाम', + usage: 'मॉडल उपयोग जानकारी', }, class: 'क्लास', classNamePlaceholder: 'अपना क्लास नाम लिखें', @@ -699,6 +700,11 @@ const translation = { }, parameterExtractor: { inputVar: 'इनपुट वेरिएबल', + outputVars: { + isSuccess: 'सफलता है। सफलता पर मान 1 है, असफलता पर मान 0 है।', + errorReason: 'त्रुटि का कारण', + usage: 'मॉडल उपयोग जानकारी', + }, extractParameters: 'पैरामीटर्स निकालें', importFromTool: 'उपकरणों से आयात करें', addExtractParameter: 'एक्सट्रेक्ट पैरामीटर जोड़ें', @@ -721,8 +727,6 @@ const translation = { reasoningMode: 'रीज़निंग मोड', reasoningModeTip: 'फ़ंक्शन कॉलिंग या प्रॉम्प्ट्स के लिए निर्देशों का जवाब देने की मॉडल की क्षमता के आधार पर उपयुक्त रीज़निंग मोड चुन सकते हैं।', - isSuccess: 'सफलता है। सफलता पर मान 1 है, असफलता पर मान 0 है।', - errorReason: 'त्रुटि का कारण', }, iteration: { deleteTitle: 'इटरेशन नोड हटाएं?', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 0092e25d38..90f9ab3f79 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -688,6 +688,7 @@ const translation = { inputVars: 'Variabili di Input', outputVars: { className: 'Nome Classe', + usage: 'Informazioni sull\'utilizzo del modello', }, class: 'Classe', classNamePlaceholder: 'Scrivi il nome della tua classe', @@ -702,6 +703,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variabile di Input', + outputVars: { + isSuccess: 'È successo. In caso di successo il valore è 1, in caso di fallimento il valore è 0.', + errorReason: 'Motivo dell\'errore', + usage: 'Informazioni sull\'utilizzo del modello', + }, extractParameters: 'Estrai Parametri', importFromTool: 'Importa dagli strumenti', addExtractParameter: 'Aggiungi Parametro Estratto', @@ -724,9 +730,6 @@ const translation = { reasoningMode: 'Modalità di ragionamento', reasoningModeTip: 'Puoi scegliere la modalità di ragionamento appropriata in base alla capacità del modello di rispondere alle istruzioni per la chiamata delle funzioni o i prompt.', - isSuccess: - 'È successo. In caso di successo il valore è 1, in caso di fallimento il valore è 0.', - errorReason: 'Motivo dell\'errore', }, iteration: { deleteTitle: 'Eliminare Nodo Iterazione?', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 3c959669bf..370a251443 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -676,6 +676,7 @@ const translation = { inputVars: '入力変数', outputVars: { className: 'クラス名', + usage: 'モデル使用量', }, class: 'クラス', classNamePlaceholder: 'クラス名を入力してください', @@ -689,6 +690,11 @@ const translation = { }, parameterExtractor: { inputVar: '入力変数', + outputVars: { + isSuccess: '成功。成功した場合の値は 1、失敗した場合の値は 0 です。', + errorReason: 'エラーの理由', + usage: 'モデル使用量', + }, extractParameters: 'パラメーターを抽出', importFromTool: 'ツールからインポート', addExtractParameter: '抽出パラメーターを追加', @@ -708,8 +714,6 @@ const translation = { advancedSetting: '高度な設定', reasoningMode: '推論モード', reasoningModeTip: '関数呼び出しやプロンプトの指示に応答するモデルの能力に基づいて、適切な推論モードを選択できます。', - isSuccess: '成功。成功した場合の値は 1、失敗した場合の値は 0 です。', - errorReason: 'エラーの理由', }, iteration: { deleteTitle: 'イテレーションノードを削除しますか?', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 078d683ca2..c429527627 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -701,6 +701,7 @@ const translation = { inputVars: '입력 변수', outputVars: { className: '클래스 이름', + usage: '모델 사용 정보', }, class: '클래스', classNamePlaceholder: '클래스 이름을 작성하세요', @@ -715,6 +716,11 @@ const translation = { }, parameterExtractor: { inputVar: '입력 변수', + outputVars: { + isSuccess: '성공 여부. 성공 시 값은 1 이고, 실패 시 값은 0 입니다.', + errorReason: '오류 원인', + usage: '모델 사용 정보', + }, extractParameters: '매개변수 추출', importFromTool: '도구에서 가져오기', addExtractParameter: '추출 매개변수 추가', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index ca2e95bdf6..ecf5380f5e 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Zmienne wejściowe', outputVars: { className: 'Nazwa klasy', + usage: 'Informacje o użyciu modelu', }, class: 'Klasa', classNamePlaceholder: 'Napisz nazwę swojej klasy', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Zmienna wejściowa', + outputVars: { + isSuccess: 'Czy się udało. W przypadku sukcesu wartość wynosi 1, w przypadku niepowodzenia wartość wynosi 0.', + errorReason: 'Powód błędu', + usage: 'Informacje o użyciu modelu', + }, extractParameters: 'Wyodrębnij parametry', importFromTool: 'Importuj z narzędzi', addExtractParameter: 'Dodaj parametr wyodrębniania', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Zaawansowane ustawienia', reasoningMode: 'Tryb wnioskowania', reasoningModeTip: 'Możesz wybrać odpowiedni tryb wnioskowania w zależności od zdolności modelu do reagowania na instrukcje dotyczące wywoływania funkcji lub zapytań.', - isSuccess: 'Czy się udało. W przypadku sukcesu wartość wynosi 1, w przypadku niepowodzenia wartość wynosi 0.', - errorReason: 'Powód błędu', }, iteration: { deleteTitle: 'Usunąć węzeł iteracji?', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 2a86228499..0c5058a27c 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Variáveis de entrada', outputVars: { className: 'Nome da classe', + usage: 'Informações de uso do modelo', }, class: 'Classe', classNamePlaceholder: 'Escreva o nome da sua classe', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variável de entrada', + outputVars: { + isSuccess: 'É sucesso. Em caso de sucesso, o valor é 1, em caso de falha, o valor é 0.', + errorReason: 'Motivo do erro', + usage: 'Informações de uso do modelo', + }, extractParameters: 'Extrair parâmetros', importFromTool: 'Importar das ferramentas', addExtractParameter: 'Adicionar parâmetro de extração', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Configuração avançada', reasoningMode: 'Modo de raciocínio', reasoningModeTip: 'Você pode escolher o modo de raciocínio apropriado com base na capacidade do modelo de responder a instruções para chamadas de função ou prompts.', - isSuccess: 'É sucesso. Em caso de sucesso, o valor é 1, em caso de falha, o valor é 0.', - errorReason: 'Motivo do erro', }, iteration: { deleteTitle: 'Excluir nó de iteração?', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 09282f4d1c..0c64f33dc4 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Variabile de intrare', outputVars: { className: 'Nume clasă', + usage: 'Informații de utilizare a modelului', }, class: 'Clasă', classNamePlaceholder: 'Scrieți numele clasei', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variabilă de intrare', + outputVars: { + isSuccess: 'Este succes. În caz de succes valoarea este 1, în caz de eșec valoarea este 0.', + errorReason: 'Motivul erorii', + usage: 'Informații de utilizare a modelului', + }, extractParameters: 'Extrageți parametrii', importFromTool: 'Importă din instrumente', addExtractParameter: 'Adăugați parametru de extragere', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Setare avansată', reasoningMode: 'Mod de raționament', reasoningModeTip: 'Puteți alege modul de raționament potrivit în funcție de capacitatea modelului de a răspunde la instrucțiuni pentru apelarea funcțiilor sau prompturi.', - isSuccess: 'Este succes. În caz de succes valoarea este 1, în caz de eșec valoarea este 0.', - errorReason: 'Motivul erorii', }, iteration: { deleteTitle: 'Ștergeți nodul de iterație?', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index b7b41562a9..0474dcac3b 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Входные переменные', outputVars: { className: 'Имя класса', + usage: 'Информация об использовании модели', }, class: 'Класс', classNamePlaceholder: 'Введите имя вашего класса', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Входная переменная', + outputVars: { + isSuccess: 'Успешно. В случае успеха значение равно 1, в случае сбоя - 0.', + errorReason: 'Причина ошибки', + usage: 'Информация об использовании модели', + }, extractParameters: 'Извлечь параметры', importFromTool: 'Импортировать из инструментов', addExtractParameter: 'Добавить параметр для извлечения', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Расширенные настройки', reasoningMode: 'Режим рассуждения', reasoningModeTip: 'Вы можете выбрать соответствующий режим рассуждения, основываясь на способности модели реагировать на инструкции для вызова функций или подсказки.', - isSuccess: 'Успешно. В случае успеха значение равно 1, в случае сбоя - 0.', - errorReason: 'Причина ошибки', }, iteration: { deleteTitle: 'Удалить узел итерации?', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index 2dbcdb008b..117fc88718 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -667,6 +667,7 @@ const translation = { questionClassifiers: { outputVars: { className: 'Ime razreda', + usage: 'Informacije o uporabi modela', }, instruction: 'Navodilo', addClass: 'Dodaj razred', @@ -692,16 +693,19 @@ const translation = { requiredContent: 'Zahtevano se uporablja le kot referenca za sklepanje modela in ne kot obvezno validacijo izhodnih parametrov.', }, extractParameters: 'Izvleči parametre', - errorReason: 'Razlog za napako', instruction: 'Navodilo', instructionTip: 'Vnesite dodatna navodila, da pomagate izvleku parametrov razumeti, kako izvleči parametre.', reasoningMode: 'Način razmišljanja', - isSuccess: 'Ali je uspeh. Na uspehu je vrednost 1, na neuspehu je vrednost 0.', importFromTool: 'Uvoz iz orodij', advancedSetting: 'Napredno nastavitev', addExtractParameter: 'Dodaj parameter za ekstrakcijo', extractParametersNotSet: 'Parameterji za ekstrakcijo niso nastavljeni', inputVar: 'Vhodna spremenljivka', + outputVars: { + isSuccess: 'Ali je uspeh. Na uspehu je vrednost 1, na neuspehu je vrednost 0.', + errorReason: 'Razlog za napako', + usage: 'Informacije o uporabi modela', + }, reasoningModeTip: 'Lahko izberete ustrezen način razmišljanja glede na sposobnost modela, da se odzove na navodila za klic funkcij ali pozive.', }, iteration: { diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index f017b5d02e..1855bf655c 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -668,6 +668,7 @@ const translation = { inputVars: 'ตัวแปรอินพุต', outputVars: { className: 'ชื่อคลาส', + usage: 'ข้อมูลการใช้งานรุ่น', }, class: 'ประเภท', classNamePlaceholder: 'เขียนชื่อชั้นเรียนของคุณ', @@ -681,6 +682,11 @@ const translation = { }, parameterExtractor: { inputVar: 'ตัวแปรอินพุต', + outputVars: { + isSuccess: 'คือ Success เมื่อสําเร็จค่าคือ 1 เมื่อล้มเหลวค่าเป็น 0', + errorReason: 'สาเหตุข้อผิดพลาด', + usage: 'ข้อมูลการใช้งานรุ่น', + }, extractParameters: 'แยกพารามิเตอร์', importFromTool: 'นําเข้าจากเครื่องมือ', addExtractParameter: 'เพิ่มพารามิเตอร์การแยกข้อมูล', @@ -700,8 +706,6 @@ const translation = { advancedSetting: 'การตั้งค่าขั้นสูง', reasoningMode: 'โหมดการให้เหตุผล', reasoningModeTip: 'คุณสามารถเลือกโหมดการให้เหตุผลที่เหมาะสมตามความสามารถของโมเดลในการตอบสนองต่อคําแนะนําสําหรับการเรียกใช้ฟังก์ชันหรือข้อความแจ้ง', - isSuccess: 'คือ Success เมื่อสําเร็จค่าคือ 1 เมื่อล้มเหลวค่าเป็น 0', - errorReason: 'สาเหตุข้อผิดพลาด', }, iteration: { deleteTitle: 'ลบโหนดการทําซ้ํา?', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 0c55351c21..e48c46848b 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -670,6 +670,7 @@ const translation = { inputVars: 'Giriş Değişkenleri', outputVars: { className: 'Sınıf Adı', + usage: 'Model Kullanım Bilgileri', }, class: 'Sınıf', classNamePlaceholder: 'Sınıf adınızı yazın', @@ -683,6 +684,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Giriş Değişkeni', + outputVars: { + isSuccess: 'Başarılı mı. Başarılı olduğunda değer 1, başarısız olduğunda değer 0\'dır.', + errorReason: 'Hata Nedeni', + usage: 'Model Kullanım Bilgileri', + }, extractParameters: 'Parametreleri Çıkar', importFromTool: 'Araçlardan içe aktar', addExtractParameter: 'Çıkarma Parametresi Ekle', @@ -702,8 +708,6 @@ const translation = { advancedSetting: 'Gelişmiş Ayarlar', reasoningMode: 'Akıl Yürütme Modu', reasoningModeTip: 'Modelin fonksiyon çağırma veya istemler için talimatlara yanıt verme yeteneğine bağlı olarak uygun akıl yürütme modunu seçebilirsiniz.', - isSuccess: 'Başarılı mı. Başarılı olduğunda değer 1, başarısız olduğunda değer 0\'dır.', - errorReason: 'Hata Nedeni', }, iteration: { deleteTitle: 'Yineleme Düğümünü Sil?', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 110c82dc1a..65fac7f838 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Вхідні змінні', outputVars: { className: 'Назва класу', + usage: 'Інформація про використання моделі', }, class: 'Клас', classNamePlaceholder: 'Напишіть назву вашого класу', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Вхідна змінна', + outputVars: { + isSuccess: 'Є успіх. У разі успіху значення 1, у разі невдачі значення 0.', + errorReason: 'Причина помилки', + usage: 'Інформація про використання моделі', + }, extractParameters: 'Витягти параметри', importFromTool: 'Імпорт з інструментів', addExtractParameter: 'Додати параметр витягування', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Розширене налаштування', reasoningMode: 'Режим інференції', reasoningModeTip: 'Ви можете вибрати відповідний режим інференції залежно від здатності моделі реагувати на інструкції щодо викликів функцій або запитів.', - isSuccess: 'Є успіх. У разі успіху значення 1, у разі невдачі значення 0.', - errorReason: 'Причина помилки', }, iteration: { deleteTitle: 'Видалити вузол ітерації?', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 7c32f39350..f88fb6e2ab 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Biến đầu vào', outputVars: { className: 'Tên lớp', + usage: 'Thông tin sử dụng mô hình', }, class: 'Lớp', classNamePlaceholder: 'Viết tên lớp của bạn', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Biến đầu vào', + outputVars: { + isSuccess: 'Thành công. Khi thành công giá trị là 1, khi thất bại giá trị là 0.', + errorReason: 'Lý do lỗi', + usage: 'Thông tin sử dụng mô hình', + }, extractParameters: 'Trích xuất tham số', importFromTool: 'Nhập từ công cụ', addExtractParameter: 'Thêm tham số trích xuất', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Cài đặt nâng cao', reasoningMode: 'Chế độ suy luận', reasoningModeTip: 'Bạn có thể chọn chế độ suy luận phù hợp dựa trên khả năng của mô hình để phản hồi các hướng dẫn về việc gọi hàm hoặc prompt.', - isSuccess: 'Thành công. Khi thành công giá trị là 1, khi thất bại giá trị là 0.', - errorReason: 'Lý do lỗi', }, iteration: { deleteTitle: 'Xóa nút lặp?', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index aa9dcae261..ab8f621883 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -674,6 +674,7 @@ const translation = { inputVars: '输入变量', outputVars: { className: '分类名称', + usage: '模型用量信息', }, class: '分类', classNamePlaceholder: '输入你的分类名称', @@ -687,6 +688,11 @@ const translation = { }, parameterExtractor: { inputVar: '输入变量', + outputVars: { + isSuccess: '是否成功。成功时值为 1,失败时值为 0。', + errorReason: '错误原因', + usage: '模型用量信息', + }, extractParameters: '提取参数', importFromTool: '从工具导入', addExtractParameter: '添加提取参数', @@ -706,8 +712,6 @@ const translation = { advancedSetting: '高级设置', reasoningMode: '推理模式', reasoningModeTip: '你可以根据模型对于 Function calling 或 Prompt 的指令响应能力选择合适的推理模式', - isSuccess: '是否成功。成功时值为 1,失败时值为 0。', - errorReason: '错误原因', }, iteration: { deleteTitle: '删除迭代节点?', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 8262e60351..2504f02832 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -670,6 +670,7 @@ const translation = { inputVars: '輸入變量', outputVars: { className: '分類名稱', + usage: '模型用量信息', }, class: '分類', classNamePlaceholder: '輸入你的分類名稱', @@ -683,6 +684,11 @@ const translation = { }, parameterExtractor: { inputVar: '輸入變量', + outputVars: { + isSuccess: '是否成功。成功時值為 1,失敗時值為 0。', + errorReason: '錯誤原因', + usage: '模型用量信息', + }, extractParameters: '提取參數', importFromTool: '從工具導入', addExtractParameter: '添加提取參數', @@ -702,8 +708,6 @@ const translation = { advancedSetting: '高級設置', reasoningMode: '推理模式', reasoningModeTip: '你可以根據模型對於 Function calling 或 Prompt 的指令響應能力選擇合適的推理模式', - isSuccess: '是否成功。成功時值為 1,失敗時值為 0。', - errorReason: '錯誤原因', }, iteration: { deleteTitle: '刪除迭代節點?', From d61ea5a2de9e00b54381b9ab90d0104469b55f6d Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:22:37 +0800 Subject: [PATCH 029/241] test: add comprehensive unit tests for UrlSigner (#22030) --- .../unit_tests/core/helper/test_url_signer.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 api/tests/unit_tests/core/helper/test_url_signer.py diff --git a/api/tests/unit_tests/core/helper/test_url_signer.py b/api/tests/unit_tests/core/helper/test_url_signer.py new file mode 100644 index 0000000000..5af24777de --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_url_signer.py @@ -0,0 +1,194 @@ +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import pytest + +from core.helper.url_signer import SignedUrlParams, UrlSigner + + +class TestUrlSigner: + """Test cases for UrlSigner class""" + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_signed_url_params(self): + """Test generation of signed URL parameters with all required fields""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + # Verify the returned object and required fields + assert isinstance(params, SignedUrlParams) + assert params.sign_key == sign_key + assert params.timestamp is not None + assert params.nonce is not None + assert params.sign is not None + + # Verify nonce format (32 character hex string) + assert len(params.nonce) == 32 + assert all(c in "0123456789abcdef" for c in params.nonce) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_complete_signed_url(self): + """Test generation of complete signed URL with query parameters""" + base_url = "https://example.com/api/test" + sign_key = "test-sign-key" + prefix = "test-prefix" + + signed_url = UrlSigner.get_signed_url(base_url, sign_key, prefix) + + # Parse URL and verify structure + parsed = urlparse(signed_url) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == base_url + + # Verify query parameters + query_params = parse_qs(parsed.query) + assert "timestamp" in query_params + assert "nonce" in query_params + assert "sign" in query_params + + # Verify each parameter has exactly one value + assert len(query_params["timestamp"]) == 1 + assert len(query_params["nonce"]) == 1 + assert len(query_params["sign"]) == 1 + + # Verify parameter values are not empty + assert query_params["timestamp"][0] + assert query_params["nonce"][0] + assert query_params["sign"][0] + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_verify_valid_signature(self): + """Test verification of valid signature""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate and verify signature + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + is_valid = UrlSigner.verify( + sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=prefix + ) + + assert is_valid is True + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + @pytest.mark.parametrize( + ("field", "modifier"), + [ + ("sign_key", lambda _: "wrong-sign-key"), + ("timestamp", lambda t: str(int(t) + 1000)), + ("nonce", lambda _: "different-nonce-123456789012345"), + ("prefix", lambda _: "wrong-prefix"), + ("sign", lambda s: s + "tampered"), + ], + ) + def test_should_reject_invalid_signature_params(self, field, modifier): + """Test signature verification rejects invalid parameters""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate valid signed parameters + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + # Prepare verification parameters + verify_params = { + "sign_key": sign_key, + "timestamp": params.timestamp, + "nonce": params.nonce, + "sign": params.sign, + "prefix": prefix, + } + + # Modify the specific field + verify_params[field] = modifier(verify_params[field]) + + # Verify should fail + is_valid = UrlSigner.verify(**verify_params) + assert is_valid is False + + @patch("configs.dify_config.SECRET_KEY", None) + def test_should_raise_error_without_secret_key(self): + """Test that signing fails when SECRET_KEY is not configured""" + with pytest.raises(Exception) as exc_info: + UrlSigner.get_signed_url_params("key", "prefix") + + assert "SECRET_KEY is not set" in str(exc_info.value) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_unique_signatures(self): + """Test that different inputs produce different signatures""" + params1 = UrlSigner.get_signed_url_params("key1", "prefix1") + params2 = UrlSigner.get_signed_url_params("key2", "prefix2") + + # Different inputs should produce different signatures + assert params1.sign != params2.sign + assert params1.nonce != params2.nonce + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_handle_special_characters(self): + """Test handling of special characters in parameters""" + special_cases = [ + "test with spaces", + "test/with/slashes", + "test中文字符", + ] + + for sign_key in special_cases: + params = UrlSigner.get_signed_url_params(sign_key, "prefix") + + # Should generate valid signature and verify correctly + is_valid = UrlSigner.verify( + sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="prefix" + ) + assert is_valid is True + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_ensure_nonce_randomness(self): + """Test that nonce is random for each generation - critical for security""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate multiple nonces + nonces = set() + for _ in range(5): + params = UrlSigner.get_signed_url_params(sign_key, prefix) + nonces.add(params.nonce) + + # All nonces should be unique + assert len(nonces) == 5 + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + @patch("time.time", return_value=1234567890) + @patch("os.urandom", return_value=b"\xab\xcd\xef\x12\x34\x56\x78\x90\xab\xcd\xef\x12\x34\x56\x78\x90") + def test_should_produce_consistent_signatures(self, mock_urandom, mock_time): + """Test that same inputs produce same signature - ensures deterministic behavior""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate signature multiple times with same inputs (time and nonce are mocked) + params1 = UrlSigner.get_signed_url_params(sign_key, prefix) + params2 = UrlSigner.get_signed_url_params(sign_key, prefix) + + # With mocked time and random, should produce identical results + assert params1.timestamp == params2.timestamp + assert params1.nonce == params2.nonce + assert params1.sign == params2.sign + + # Verify the signature is valid + assert UrlSigner.verify( + sign_key=sign_key, timestamp=params1.timestamp, nonce=params1.nonce, sign=params1.sign, prefix=prefix + ) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_handle_empty_strings(self): + """Test handling of empty string parameters - common edge case""" + # Empty sign_key and prefix should still work + params = UrlSigner.get_signed_url_params("", "") + assert params.sign is not None + + # Should verify correctly + is_valid = UrlSigner.verify( + sign_key="", timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="" + ) + assert is_valid is True From 521488f9268838687d14698d99d2f74df615df7d Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Wed, 9 Jul 2025 09:28:26 +0800 Subject: [PATCH 030/241] Remove tow unused files (#22022) --- api/core/file/models.py | 2 +- api/core/file/upload_file_parser.py | 67 ----------------------------- api/core/helper/lru_cache.py | 22 ---------- 3 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 api/core/file/upload_file_parser.py delete mode 100644 api/core/helper/lru_cache.py diff --git a/api/core/file/models.py b/api/core/file/models.py index aa3b5f629c..f61334e7bc 100644 --- a/api/core/file/models.py +++ b/api/core/file/models.py @@ -51,7 +51,7 @@ class File(BaseModel): # It should be set to `ToolFile.id` when `transfer_method` is `tool_file`. related_id: Optional[str] = None filename: Optional[str] = None - extension: Optional[str] = Field(default=None, description="File extension, should contains dot") + extension: Optional[str] = Field(default=None, description="File extension, should contain dot") mime_type: Optional[str] = None size: int = -1 diff --git a/api/core/file/upload_file_parser.py b/api/core/file/upload_file_parser.py deleted file mode 100644 index 96b2884811..0000000000 --- a/api/core/file/upload_file_parser.py +++ /dev/null @@ -1,67 +0,0 @@ -import base64 -import logging -import time -from typing import Optional - -from configs import dify_config -from constants import IMAGE_EXTENSIONS -from core.helper.url_signer import UrlSigner -from extensions.ext_storage import storage - - -class UploadFileParser: - @classmethod - def get_image_data(cls, upload_file, force_url: bool = False) -> Optional[str]: - if not upload_file: - return None - - if upload_file.extension not in IMAGE_EXTENSIONS: - return None - - if dify_config.MULTIMODAL_SEND_FORMAT == "url" or force_url: - return cls.get_signed_temp_image_url(upload_file.id) - else: - # get image file base64 - try: - data = storage.load(upload_file.key) - except FileNotFoundError: - logging.exception(f"File not found: {upload_file.key}") - return None - - encoded_string = base64.b64encode(data).decode("utf-8") - return f"data:{upload_file.mime_type};base64,{encoded_string}" - - @classmethod - def get_signed_temp_image_url(cls, upload_file_id) -> str: - """ - get signed url from upload file - - :param upload_file_id: the id of UploadFile object - :return: - """ - base_url = dify_config.FILES_URL - image_preview_url = f"{base_url}/files/{upload_file_id}/image-preview" - - return UrlSigner.get_signed_url(url=image_preview_url, sign_key=upload_file_id, prefix="image-preview") - - @classmethod - def verify_image_file_signature(cls, upload_file_id: str, timestamp: str, nonce: str, sign: str) -> bool: - """ - verify signature - - :param upload_file_id: file id - :param timestamp: timestamp - :param nonce: nonce - :param sign: signature - :return: - """ - result = UrlSigner.verify( - sign_key=upload_file_id, timestamp=timestamp, nonce=nonce, sign=sign, prefix="image-preview" - ) - - # verify signature - if not result: - return False - - current_time = int(time.time()) - return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT diff --git a/api/core/helper/lru_cache.py b/api/core/helper/lru_cache.py deleted file mode 100644 index 81501d2e4e..0000000000 --- a/api/core/helper/lru_cache.py +++ /dev/null @@ -1,22 +0,0 @@ -from collections import OrderedDict -from typing import Any - - -class LRUCache: - def __init__(self, capacity: int): - self.cache: OrderedDict[Any, Any] = OrderedDict() - self.capacity = capacity - - def get(self, key: Any) -> Any: - if key not in self.cache: - return None - else: - self.cache.move_to_end(key) # move the key to the end of the OrderedDict - return self.cache[key] - - def put(self, key: Any, value: Any) -> None: - if key in self.cache: - self.cache.move_to_end(key) - self.cache[key] = value - if len(self.cache) > self.capacity: - self.cache.popitem(last=False) # pop the first item From e39236186dce9ad212a522faa4c49b624cd73762 Mon Sep 17 00:00:00 2001 From: kurokobo Date: Wed, 9 Jul 2025 11:12:40 +0900 Subject: [PATCH 031/241] feat: introduce new env ALLOW_UNSAFE_DATA_SCHEME to allow rendering data uri scheme (#21321) --- docker/.env.example | 3 +++ docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 2 ++ web/.env.example | 3 +++ web/app/components/base/markdown-blocks/utils.ts | 6 +++++- web/app/components/base/markdown/markdown-utils.ts | 4 ++++ web/app/layout.tsx | 1 + web/config/index.ts | 1 + web/docker/entrypoint.sh | 1 + web/types/feature.ts | 1 + 10 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docker/.env.example b/docker/.env.example index e7dbecb413..a403f25cb2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -826,6 +826,9 @@ MAX_ITERATIONS_NUM=99 # The timeout for the text generation in millisecond TEXT_GENERATION_TIMEOUT_MS=60000 +# Allow rendering unsafe URLs which have "data:" scheme. +ALLOW_UNSAFE_DATA_SCHEME=false + # ------------------------------ # Environment Variables for db Service # ------------------------------ diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index a34f96e945..fd7c78c7e7 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -67,6 +67,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e48b5afd8c..0a95251ff0 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -364,6 +364,7 @@ x-shared-env: &shared-api-worker-env MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}} POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}} @@ -582,6 +583,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} diff --git a/web/.env.example b/web/.env.example index c30064ffed..37bfc939eb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -32,6 +32,9 @@ NEXT_PUBLIC_CSP_WHITELIST= # Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking NEXT_PUBLIC_ALLOW_EMBED= +# Allow rendering unsafe URLs which have "data:" scheme. +NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false + # Github Access Token, used for invoking Github API NEXT_PUBLIC_GITHUB_ACCESS_TOKEN= # The maximum number of top-k value for RAG. diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index 4e9e98dbed..d8df76aefc 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -1,3 +1,7 @@ +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' + export const isValidUrl = (url: string): boolean => { - return ['http:', 'https:', '//', 'mailto:'].some(prefix => url.startsWith(prefix)) + const validPrefixes = ['http:', 'https:', '//', 'mailto:'] + if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') + return validPrefixes.some(prefix => url.startsWith(prefix)) } diff --git a/web/app/components/base/markdown/markdown-utils.ts b/web/app/components/base/markdown/markdown-utils.ts index 209fcd0b32..0089bef0ac 100644 --- a/web/app/components/base/markdown/markdown-utils.ts +++ b/web/app/components/base/markdown/markdown-utils.ts @@ -4,6 +4,7 @@ * Includes preprocessing for LaTeX and custom "think" tags. */ import { flow } from 'lodash-es' +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' export const preprocessLaTeX = (content: string) => { if (typeof content !== 'string') @@ -86,5 +87,8 @@ export const customUrlTransform = (uri: string): string | undefined => { if (PERMITTED_SCHEME_REGEX.test(scheme)) return uri + if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:') + return uri + return undefined } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f3b2ca7f7f..525445db30 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -44,6 +44,7 @@ const LocaleLayout = async ({ [DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT]: process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT, [DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM]: process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, [DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH]: process.env.NEXT_PUBLIC_MAX_TREE_DEPTH, + [DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME]: process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER, [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, diff --git a/web/config/index.ts b/web/config/index.ts index daaed412d0..af9a21e600 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -270,6 +270,7 @@ export const LOOP_NODE_MAX_COUNT = getNumberConfig(process.env.NEXT_PUBLIC_LOOP_ export const MAX_ITERATIONS_NUM = getNumberConfig(process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM, 99) export const MAX_TREE_DEPTH = getNumberConfig(process.env.NEXT_PUBLIC_MAX_TREE_DEPTH, DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH, 50) +export const ALLOW_UNSAFE_DATA_SCHEME = getBooleanConfig(process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, false) export const ENABLE_WEBSITE_JINAREADER = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER, true) export const ENABLE_WEBSITE_FIRECRAWL = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, true) export const ENABLE_WEBSITE_WATERCRAWL = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, false) diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index c9acbdd422..ef13011a71 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -26,6 +26,7 @@ export NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED} export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS} export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST} export NEXT_PUBLIC_ALLOW_EMBED=${ALLOW_EMBED} +export NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=${ALLOW_UNSAFE_DATA_SCHEME:-false} export NEXT_PUBLIC_TOP_K_MAX_VALUE=${TOP_K_MAX_VALUE} export NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH} export NEXT_PUBLIC_MAX_TOOLS_NUM=${MAX_TOOLS_NUM} diff --git a/web/types/feature.ts b/web/types/feature.ts index b2a80dbea2..5787c2661f 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -116,6 +116,7 @@ export enum DatasetAttr { DATA_PUBLIC_LOOP_NODE_MAX_COUNT = 'data-public-loop-node-max-count', DATA_PUBLIC_MAX_ITERATIONS_NUM = 'data-public-max-iterations-num', DATA_PUBLIC_MAX_TREE_DEPTH = 'data-public-max-tree-depth', + DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME = 'data-public-allow-unsafe-data-scheme', DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER = 'data-public-enable-website-jinareader', DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL = 'data-public-enable-website-firecrawl', DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL = 'data-public-enable-website-watercrawl', From 3643ed101437f2e95b8e769c288c29b4e54e39aa Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Wed, 9 Jul 2025 15:18:23 +0800 Subject: [PATCH 032/241] Feat: description field for env variables (#21556) --- api/fields/workflow_fields.py | 2 + .../nodes/_base/components/variable/utils.ts | 3 +- .../components/variable-modal.tsx | 10 ++-- .../workflow/panel/env-panel/env-item.tsx | 48 ++++++++++++------- .../panel/env-panel/variable-modal.tsx | 17 ++++++- web/app/components/workflow/types.ts | 1 + web/i18n/de-DE/workflow.ts | 2 + web/i18n/en-US/workflow.ts | 2 + web/i18n/es-ES/workflow.ts | 2 + web/i18n/fa-IR/workflow.ts | 2 + web/i18n/fr-FR/workflow.ts | 2 + web/i18n/hi-IN/workflow.ts | 2 + web/i18n/it-IT/workflow.ts | 2 + web/i18n/ja-JP/workflow.ts | 2 + web/i18n/ko-KR/workflow.ts | 2 + web/i18n/pl-PL/workflow.ts | 2 + web/i18n/pt-BR/workflow.ts | 2 + web/i18n/ro-RO/workflow.ts | 2 + web/i18n/ru-RU/workflow.ts | 2 + web/i18n/sl-SI/workflow.ts | 4 +- web/i18n/th-TH/workflow.ts | 2 + web/i18n/tr-TR/workflow.ts | 2 + web/i18n/uk-UA/workflow.ts | 2 + web/i18n/vi-VN/workflow.ts | 2 + web/i18n/zh-Hans/workflow.ts | 2 + web/i18n/zh-Hant/workflow.ts | 2 + 26 files changed, 97 insertions(+), 26 deletions(-) diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index 9f1bef3b36..f00ea71c54 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -17,6 +17,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": encrypter.obfuscated_token(value.value), "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, Variable): return { @@ -24,6 +25,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": value.value, "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, dict): value_type = value.get("value_type") diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 1058f29119..ac95f54757 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -462,6 +462,7 @@ const formatItem = ( return { variable: `env.${env.name}`, type: env.value_type, + description: env.description, } }) as Var[] break @@ -472,7 +473,7 @@ const formatItem = ( return { variable: `conversation.${chatVar.name}`, type: chatVar.value_type, - des: chatVar.description, + description: chatVar.description, } }) as Var[] break diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx index 347c83c155..869317ca6a 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx @@ -80,7 +80,7 @@ const ChatVariableModal = ({ const [objectValue, setObjectValue] = React.useState([DEFAULT_OBJECT_VALUE]) const [editorContent, setEditorContent] = React.useState() const [editInJSON, setEditInJSON] = React.useState(false) - const [des, setDes] = React.useState('') + const [description, setDescription] = React.useState('') const editorMinHeight = useMemo(() => { if (type === ChatVarType.ArrayObject) @@ -237,7 +237,7 @@ const ChatVariableModal = ({ name, value_type: type, value: formatValue(value), - description: des, + description, }) onClose() } @@ -247,7 +247,7 @@ const ChatVariableModal = ({ setName(chatVar.name) setType(chatVar.value_type) setValue(chatVar.value) - setDes(chatVar.description) + setDescription(chatVar.description) setObjectValue(getObjectValue()) if (chatVar.value_type === ChatVarType.ArrayObject) { setEditorContent(JSON.stringify(chatVar.value)) @@ -385,9 +385,9 @@ const ChatVariableModal = ({
+
+ {latestParams.length > 0 && ( +
+
+
{t('tools.mcp.server.modal.parameters')}
+ +
+
{t('tools.mcp.server.modal.parametersTip')}
+
+ {latestParams.map(paramItem => ( + handleParamChange(paramItem.variable, value)} + /> + ))} +
+
+ )} +
+
+ + +
+ + ) +} + +export default MCPServerModal diff --git a/web/app/components/tools/mcp/mcp-server-param-item.tsx b/web/app/components/tools/mcp/mcp-server-param-item.tsx new file mode 100644 index 0000000000..a48d1b92b0 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-param-item.tsx @@ -0,0 +1,37 @@ +'use client' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Textarea from '@/app/components/base/textarea' + +type Props = { + data?: any + value: string + onChange: (value: string) => void +} + +const MCPServerParamItem = ({ + data, + value, + onChange, +}: Props) => { + const { t } = useTranslation() + + return ( +
+
+
{data.label}
+
·
+
{data.variable}
+
{data.type}
+
+ +
+ ) +} + +export default MCPServerParamItem diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx new file mode 100644 index 0000000000..443d7a1d1f --- /dev/null +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -0,0 +1,244 @@ +'use client' +import React, { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiLoopLeftLine, +} from '@remixicon/react' +import { + Mcp, +} from '@/app/components/base/icons/src/vender/other' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import Switch from '@/app/components/base/switch' +import Divider from '@/app/components/base/divider' +import CopyFeedback from '@/app/components/base/copy-feedback' +import Confirm from '@/app/components/base/confirm' +import type { AppDetailResponse } from '@/models/app' +import { useAppContext } from '@/context/app-context' +import type { AppSSO } from '@/types/app' +import Indicator from '@/app/components/header/indicator' +import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' +import { useAppWorkflow } from '@/service/use-workflow' +import { + useInvalidateMCPServerDetail, + useMCPServerDetail, + useRefreshMCPServerCode, + useUpdateMCPServer, +} from '@/service/use-tools' +import { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' +import { fetchAppDetail } from '@/service/apps' + +export type IAppCardProps = { + appInfo: AppDetailResponse & Partial +} + +function MCPServiceCard({ + appInfo, +}: IAppCardProps) { + const { t } = useTranslation() + const appId = appInfo.id + const { mutateAsync: updateMCPServer } = useUpdateMCPServer() + const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() + const invalidateMCPServerDetail = useInvalidateMCPServerDetail() + const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [showMCPServerModal, setShowMCPServerModal] = useState(false) + + const isAdvancedApp = appInfo?.mode === 'advanced-chat' || appInfo?.mode === 'workflow' + const isBasicApp = !isAdvancedApp + const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '') + const [basicAppConfig, setBasicAppConfig] = useState({}) + const basicAppInputForm = useMemo(() => { + if(!isBasicApp || !basicAppConfig?.user_input_form) + return [] + return basicAppConfig.user_input_form.map((item: any) => { + const type = Object.keys(item)[0] + return { + ...item[type], + type: type || 'text-input', + } + }) + }, [basicAppConfig.user_input_form, isBasicApp]) + useEffect(() => { + if(isBasicApp && appId) { + (async () => { + const res = await fetchAppDetail({ url: '/apps', id: appId }) + setBasicAppConfig(res?.model_config || {}) + })() + } + }, [appId, isBasicApp]) + const { data: detail } = useMCPServerDetail(appId) + const { id, status, server_code } = detail ?? {} + + const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at + const serverPublished = !!id + const serverActivated = status === 'active' + const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********' + const toggleDisabled = !isCurrentWorkspaceEditor || appUnpublished + + const [activated, setActivated] = useState(serverActivated) + + const latestParams = useMemo(() => { + if(isAdvancedApp) { + if (!currentWorkflow?.graph) + return [] + const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any + return startNode?.data.variables as any[] || [] + } + return basicAppInputForm + }, [currentWorkflow, basicAppInputForm, isAdvancedApp]) + + const onGenCode = async () => { + await refreshMCPServerCode(detail?.id || '') + invalidateMCPServerDetail(appId) + } + + const onChangeStatus = async (state: boolean) => { + setActivated(state) + if (state) { + if (!serverPublished) { + setShowMCPServerModal(true) + return + } + + await updateMCPServer({ + appID: appId, + id: id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: 'active', + }) + invalidateMCPServerDetail(appId) + } + else { + await updateMCPServer({ + appID: appId, + id: id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: 'inactive', + }) + invalidateMCPServerDetail(appId) + } + } + + const handleServerModalHide = () => { + setShowMCPServerModal(false) + if (!serverActivated) + setActivated(false) + } + + useEffect(() => { + setActivated(serverActivated) + }, [serverActivated]) + + if (!currentWorkflow && isAdvancedApp) + return null + + return ( + <> +
+
+
+
+
+
+ +
+
+
+ {t('tools.mcp.server.title')} +
+
+
+
+ +
+ {serverActivated + ? t('appOverview.overview.status.running') + : t('appOverview.overview.status.disable')} +
+
+ +
+ +
+
+
+
+
+ {t('tools.mcp.server.url')} +
+
+
+
+ {serverURL} +
+
+ {serverPublished && ( + <> + + + {isCurrentWorkspaceManager && ( + +
setShowConfirmDelete(true)} + > + +
+
+ )} + + )} +
+
+
+
+ +
+
+
+ {showMCPServerModal && ( + + )} + {/* button copy link/ button regenerate */} + {showConfirmDelete && ( + { + onGenCode() + setShowConfirmDelete(false) + }} + onCancel={() => setShowConfirmDelete(false)} + /> + )} + + ) +} + +export default MCPServiceCard diff --git a/web/app/components/tools/mcp/mock.ts b/web/app/components/tools/mcp/mock.ts new file mode 100644 index 0000000000..f271f67ed3 --- /dev/null +++ b/web/app/components/tools/mcp/mock.ts @@ -0,0 +1,154 @@ +const tools = [ + { + author: 'Novice', + name: 'NOTION_ADD_PAGE_CONTENT', + label: { + en_US: 'NOTION_ADD_PAGE_CONTENT', + zh_Hans: 'NOTION_ADD_PAGE_CONTENT', + pt_BR: 'NOTION_ADD_PAGE_CONTENT', + ja_JP: 'NOTION_ADD_PAGE_CONTENT', + }, + description: { + en_US: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + zh_Hans: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + pt_BR: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + ja_JP: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + }, + parameters: [ + { + name: 'after', + label: { + en_US: 'after', + zh_Hans: 'after', + pt_BR: 'after', + ja_JP: 'after', + }, + placeholder: null, + scope: null, + auto_generate: null, + template: null, + required: false, + default: null, + min: null, + max: null, + precision: null, + options: [], + type: 'string', + human_description: { + en_US: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + zh_Hans: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + pt_BR: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + ja_JP: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + }, + form: 'llm', + llm_description: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + }, + { + name: 'content_block', + label: { + en_US: 'content_block', + zh_Hans: 'content_block', + pt_BR: 'content_block', + ja_JP: 'content_block', + }, + placeholder: null, + scope: null, + auto_generate: null, + template: null, + required: false, + default: null, + min: null, + max: null, + precision: null, + options: [], + type: 'string', + human_description: { + en_US: 'Child content to append to a page.', + zh_Hans: 'Child content to append to a page.', + pt_BR: 'Child content to append to a page.', + ja_JP: 'Child content to append to a page.', + }, + form: 'llm', + llm_description: 'Child content to append to a page.', + }, + { + name: 'parent_block_id', + label: { + en_US: 'parent_block_id', + zh_Hans: 'parent_block_id', + pt_BR: 'parent_block_id', + ja_JP: 'parent_block_id', + }, + placeholder: null, + scope: null, + auto_generate: null, + template: null, + required: false, + default: null, + min: null, + max: null, + precision: null, + options: [], + type: 'string', + human_description: { + en_US: 'The ID of the page which the children will be added.', + zh_Hans: 'The ID of the page which the children will be added.', + pt_BR: 'The ID of the page which the children will be added.', + ja_JP: 'The ID of the page which the children will be added.', + }, + form: 'llm', + llm_description: 'The ID of the page which the children will be added.', + }, + ], + labels: [], + output_schema: null, + }, +] + +export const listData = [ + { + id: 'fdjklajfkljadslf111', + author: 'KVOJJJin', + name: 'GOGOGO', + icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US', + server_url: 'https://mcp.composio.dev/notion/****/abc', + type: 'mcp', + is_team_authorization: true, + tools, + update_elapsed_time: 1744793369, + label: { + en_US: 'GOGOGO', + zh_Hans: 'GOGOGO', + }, + }, + { + id: 'fdjklajfkljadslf222', + author: 'KVOJJJin', + name: 'GOGOGO2', + icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US', + server_url: 'https://mcp.composio.dev/notion/****/abc', + type: 'mcp', + is_team_authorization: false, + tools: [], + update_elapsed_time: 1744793369, + label: { + en_US: 'GOGOGO2', + zh_Hans: 'GOGOGO2', + }, + }, + { + id: 'fdjklajfkljadslf333', + author: 'KVOJJJin', + name: 'GOGOGO3', + icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US', + server_url: 'https://mcp.composio.dev/notion/****/abc', + type: 'mcp', + is_team_authorization: true, + tools, + update_elapsed_time: 1744793369, + label: { + en_US: 'GOGOGO3', + zh_Hans: 'GOGOGO3', + }, + }, +] diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx new file mode 100644 index 0000000000..0e57cb149b --- /dev/null +++ b/web/app/components/tools/mcp/modal.tsx @@ -0,0 +1,221 @@ +'use client' +import React, { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { getDomain } from 'tldts' +import { RiCloseLine, RiEditLine } from '@remixicon/react' +import AppIconPicker from '@/app/components/base/app-icon-picker' +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import AppIcon from '@/app/components/base/app-icon' +import Modal from '@/app/components/base/modal' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import type { AppIconType } from '@/types/app' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { noop } from 'lodash-es' +import Toast from '@/app/components/base/toast' +import { uploadRemoteFileInfo } from '@/service/common' +import cn from '@/utils/classnames' +import { useHover } from 'ahooks' + +export type DuplicateAppModalProps = { + data?: ToolWithProvider + show: boolean + onConfirm: (info: { + name: string + server_url: string + icon_type: AppIconType + icon: string + icon_background?: string | null + server_identifier: string + }) => void + onHide: () => void +} + +const DEFAULT_ICON = { type: 'emoji', icon: '🧿', background: '#EFF1F5' } +const extractFileId = (url: string) => { + const match = url.match(/files\/(.+?)\/file-preview/) + return match ? match[1] : null +} +const getIcon = (data?: ToolWithProvider) => { + if (!data) + return DEFAULT_ICON as AppIconSelection + if (typeof data.icon === 'string') + return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection + return { + ...data.icon, + icon: data.icon.content, + type: 'emoji', + } as unknown as AppIconSelection +} + +const MCPModal = ({ + data, + show, + onConfirm, + onHide, +}: DuplicateAppModalProps) => { + const { t } = useTranslation() + const isCreate = !data + + const originalServerUrl = data?.server_url + const originalServerID = data?.server_identifier + const [url, setUrl] = React.useState(data?.server_url || '') + const [name, setName] = React.useState(data?.name || '') + const [appIcon, setAppIcon] = useState(getIcon(data)) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '') + const [isFetchingIcon, setIsFetchingIcon] = useState(false) + const appIconRef = useRef(null) + const isHovering = useHover(appIconRef) + + const isValidUrl = (string: string) => { + try { + const urlPattern = /^(https?:\/\/)((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?/i + return urlPattern.test(string) + } + catch (e) { + return false + } + } + + const isValidServerID = (str: string) => { + return /^[a-z0-9_-]{1,24}$/.test(str) + } + + const handleBlur = async (url: string) => { + if (data) + return + if (!isValidUrl(url)) + return + const domain = getDomain(url) + const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` + setIsFetchingIcon(true) + try { + const res = await uploadRemoteFileInfo(remoteIcon, undefined, true) + setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' }) + } + catch (e) { + console.error('Failed to fetch remote icon:', e) + Toast.notify({ type: 'warning', message: 'Failed to fetch remote icon' }) + } + finally { + setIsFetchingIcon(false) + } + } + + const submit = async () => { + if (!isValidUrl(url)) { + Toast.notify({ type: 'error', message: 'invalid server url' }) + return + } + if (!isValidServerID(serverIdentifier.trim())) { + Toast.notify({ type: 'error', message: 'invalid server identifier' }) + return + } + await onConfirm({ + server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(), + name, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, + server_identifier: serverIdentifier.trim(), + }) + if(isCreate) + onHide() + } + + return ( + <> + +
+ +
+
{!isCreate ? t('tools.mcp.modal.editTitle') : t('tools.mcp.modal.title')}
+
+
+
+ {t('tools.mcp.modal.serverUrl')} +
+ setUrl(e.target.value)} + onBlur={e => handleBlur(e.target.value.trim())} + placeholder={t('tools.mcp.modal.serverUrlPlaceholder')} + /> + {originalServerUrl && originalServerUrl !== url && ( +
+ {t('tools.mcp.modal.serverUrlWarning')} +
+ )} +
+
+
+
+ {t('tools.mcp.modal.name')} +
+ setName(e.target.value)} + placeholder={t('tools.mcp.modal.namePlaceholder')} + /> +
+
+ + +
) : null + } + onClick={() => { setShowAppIconPicker(true) }} + /> +
+
+
+
+ {t('tools.mcp.modal.serverIdentifier')} +
+
{t('tools.mcp.modal.serverIdentifierTip')}
+ setServerIdentifier(e.target.value)} + placeholder={t('tools.mcp.modal.serverIdentifierPlaceholder')} + /> + {originalServerID && originalServerID !== serverIdentifier && ( +
+ {t('tools.mcp.modal.serverIdentifierWarning')} +
+ )} +
+
+
+ + +
+ + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) + }} + onClose={() => { + setAppIcon(getIcon(data)) + setShowAppIconPicker(false) + }} + />} + + + ) +} + +export default MCPModal diff --git a/web/app/components/tools/mcp/provider-card.tsx b/web/app/components/tools/mcp/provider-card.tsx new file mode 100644 index 0000000000..677e25c533 --- /dev/null +++ b/web/app/components/tools/mcp/provider-card.tsx @@ -0,0 +1,152 @@ +'use client' +import { useCallback, useState } from 'react' +import { useBoolean } from 'ahooks' +import { useTranslation } from 'react-i18next' +import { useAppContext } from '@/context/app-context' +import { RiHammerFill } from '@remixicon/react' +import Indicator from '@/app/components/header/indicator' +import Icon from '@/app/components/plugins/card/base/card-icon' +import { useFormatTimeFromNow } from './hooks' +import type { ToolWithProvider } from '../../workflow/types' +import Confirm from '@/app/components/base/confirm' +import MCPModal from './modal' +import OperationDropdown from './detail/operation-dropdown' +import { useDeleteMCP, useUpdateMCP } from '@/service/use-tools' +import cn from '@/utils/classnames' + +type Props = { + currentProvider?: ToolWithProvider + data: ToolWithProvider + handleSelect: (providerID: string) => void + onUpdate: (providerID: string) => void + onDeleted: () => void +} + +const MCPCard = ({ + currentProvider, + data, + onUpdate, + handleSelect, + onDeleted, +}: Props) => { + const { t } = useTranslation() + const { formatTimeFromNow } = useFormatTimeFromNow() + const { isCurrentWorkspaceManager } = useAppContext() + + const { mutateAsync: updateMCP } = useUpdateMCP({}) + const { mutateAsync: deleteMCP } = useDeleteMCP({}) + + const [isOperationShow, setIsOperationShow] = useState(false) + + const [isShowUpdateModal, { + setTrue: showUpdateModal, + setFalse: hideUpdateModal, + }] = useBoolean(false) + + const [isShowDeleteConfirm, { + setTrue: showDeleteConfirm, + setFalse: hideDeleteConfirm, + }] = useBoolean(false) + + const [deleting, { + setTrue: showDeleting, + setFalse: hideDeleting, + }] = useBoolean(false) + + const handleUpdate = useCallback(async (form: any) => { + const res = await updateMCP({ + ...form, + provider_id: data.id, + }) + if ((res as any)?.result === 'success') { + hideUpdateModal() + onUpdate(data.id) + } + }, [data, updateMCP, hideUpdateModal, onUpdate]) + + const handleDelete = useCallback(async () => { + showDeleting() + const res = await deleteMCP(data.id) + hideDeleting() + if ((res as any)?.result === 'success') { + hideDeleteConfirm() + onDeleted() + } + }, [showDeleting, deleteMCP, data.id, hideDeleting, hideDeleteConfirm, onDeleted]) + + return ( +
handleSelect(data.id)} + className={cn( + 'group relative flex cursor-pointer flex-col rounded-xl border-[1.5px] border-transparent bg-components-card-bg shadow-xs hover:bg-components-card-bg-alt hover:shadow-md', + currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-card-bg-alt', + )} + > +
+
+ +
+
+
{data.name}
+
{data.server_identifier}
+
+
+
+
+
+ + {data.tools.length > 0 && ( +
{t('tools.mcp.toolsCount', { count: data.tools.length })}
+ )} + {!data.tools.length && ( +
{t('tools.mcp.noTools')}
+ )} +
+
/
+
{`${t('tools.mcp.updateTime')} ${formatTimeFromNow(data.updated_at! * 1000)}`}
+
+ {data.is_team_authorization && data.tools.length > 0 && } + {(!data.is_team_authorization || !data.tools.length) && ( +
+ {t('tools.mcp.noConfigured')} + +
+ )} +
+ {isCurrentWorkspaceManager && ( + + )} + {isShowUpdateModal && ( + + )} + {isShowDeleteConfirm && ( + + {t('tools.mcp.deleteConfirmTitle', { mcp: data.name })} +
+ } + onCancel={hideDeleteConfirm} + onConfirm={handleDelete} + isLoading={deleting} + isDisabled={deleting} + /> + )} +
+ ) +} +export default MCPCard diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index b0b4f8a8bc..ecfa5f6ea2 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -15,11 +15,29 @@ import WorkflowToolEmpty from '@/app/components/tools/add-tool-modal/empty' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' +import MCPList from './mcp' import { useAllToolProviders } from '@/service/use-tools' import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useGlobalPublicStore } from '@/context/global-public-context' +import { ToolTypeEnum } from '../workflow/block-selector/types' +const getToolType = (type: string) => { + switch (type) { + case 'builtin': + return ToolTypeEnum.BuiltIn + case 'api': + return ToolTypeEnum.Custom + case 'workflow': + return ToolTypeEnum.Workflow + case 'mcp': + return ToolTypeEnum.MCP + default: + return ToolTypeEnum.BuiltIn + } +} const ProviderList = () => { + // const searchParams = useSearchParams() + // searchParams.get('category') === 'workflow' const { t } = useTranslation() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const containerRef = useRef(null) @@ -31,6 +49,7 @@ const ProviderList = () => { { value: 'builtin', text: t('tools.type.builtIn') }, { value: 'api', text: t('tools.type.custom') }, { value: 'workflow', text: t('tools.type.workflow') }, + { value: 'mcp', text: 'MCP' }, ] const [tagFilterValue, setTagFilterValue] = useState([]) const handleTagsChange = (value: string[]) => { @@ -85,7 +104,9 @@ const ProviderList = () => { options={options} />
- + {activeTab !== 'mcp' && ( + + )} { />
- {(filteredCollectionList.length > 0 || activeTab !== 'builtin') && ( + {activeTab !== 'mcp' && (
{ />
))} - {!filteredCollectionList.length && activeTab === 'workflow' &&
} + {!filteredCollectionList.length && activeTab === 'workflow' &&
}
)} {!filteredCollectionList.length && activeTab === 'builtin' && ( )} - { - enable_marketplace && activeTab === 'builtin' && ( - { - containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' }) - }} - searchPluginText={keywords} - filterPluginTags={tagFilterValue} - /> - ) - } -
-
+ {enable_marketplace && activeTab === 'builtin' && ( + { + containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' }) + }} + searchPluginText={keywords} + filterPluginTags={tagFilterValue} + /> + )} + {activeTab === 'mcp' && ( + + )} +
+
{currentProvider && !currentProvider.plugin_id && ( { return ( <> {isCurrentWorkspaceManager && ( -
-
setIsShowEditCustomCollectionModal(true)}> +
+
setIsShowEditCustomCollectionModal(true)}>
-
- +
+
-
{t('tools.createCustomTool')}
+
{t('tools.createCustomTool')}
-
diff --git a/web/app/components/tools/provider/tool-item.tsx b/web/app/components/tools/provider/tool-item.tsx index 161b62963b..d79d20cb9c 100644 --- a/web/app/components/tools/provider/tool-item.tsx +++ b/web/app/components/tools/provider/tool-item.tsx @@ -29,7 +29,7 @@ const ToolItem = ({ return ( <>
!disabled && setShowDetail(true)} >
{tool.label[language]}
diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 32c468cde8..d444ee1f38 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -29,6 +29,7 @@ export enum CollectionType { custom = 'api', model = 'model', workflow = 'workflow', + mcp = 'mcp', } export type Emoji = { @@ -50,6 +51,10 @@ export type Collection = { labels: string[] plugin_id?: string letter?: string + // MCP Server + server_url?: string + updated_at?: number + server_identifier?: string } export type ToolParameter = { @@ -168,3 +173,11 @@ export type WorkflowToolProviderResponse = { } privacy_policy: string } + +export type MCPServerDetail = { + id: string + server_code: string + description: string + status: string + parameters?: Record +} diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index 179f59021e..ee7f3379ad 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -1,4 +1,7 @@ import type { ToolCredential, ToolParameter } from '../types' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' + export const toType = (type: string) => { switch (type) { case 'string': @@ -54,7 +57,7 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => { return formSchemas } -export const addDefaultValue = (value: Record, formSchemas: { variable: string; default?: any }[]) => { +export const addDefaultValue = (value: Record, formSchemas: { variable: string; type: string; default?: any }[]) => { const newValues = { ...value } formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] @@ -64,14 +67,47 @@ export const addDefaultValue = (value: Record, formSchemas: { varia return newValues } -export const generateFormValue = (value: Record, formSchemas: { variable: string; default?: any }[], isReasoning = false) => { +const correctInitialData = (type: string, target: any, defaultValue: any) => { + if (type === 'text-input' || type === 'secret-input') + target.type = 'mixed' + + if (type === 'boolean') { + if (typeof defaultValue === 'string') + target.value = defaultValue === 'true' || defaultValue === '1' + + if (typeof defaultValue === 'boolean') + target.value = defaultValue + + if (typeof defaultValue === 'number') + target.value = defaultValue === 1 + } + + if (type === 'number-input') { + if (typeof defaultValue === 'string' && defaultValue !== '') + target.value = Number.parseFloat(defaultValue) + } + + if (type === 'app-selector' || type === 'model-selector') + target.value = defaultValue + + return target +} + +export const generateFormValue = (value: Record, formSchemas: { variable: string; default?: any; type: string }[], isReasoning = false) => { const newValues = {} as any formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { + const value = formSchema.default newValues[formSchema.variable] = { - ...(isReasoning ? { value: null, auto: 1 } : { value: formSchema.default }), + value: { + type: 'constant', + value: formSchema.default, + }, + ...(isReasoning ? { auto: 1, value: null } : {}), } + if (!isReasoning) + newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value) } }) return newValues @@ -80,7 +116,9 @@ export const generateFormValue = (value: Record, formSchemas: { var export const getPlainValue = (value: Record) => { const plainValue = { ...value } Object.keys(plainValue).forEach((key) => { - plainValue[key] = value[key].value + plainValue[key] = { + ...value[key].value, + } }) return plainValue } @@ -94,3 +132,65 @@ export const getStructureValue = (value: Record) => { }) return newValue } + +export const getConfiguredValue = (value: Record, formSchemas: { variable: string; type: string; default?: any }[]) => { + const newValues = { ...value } + formSchemas.forEach((formSchema) => { + const itemValue = value[formSchema.variable] + if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { + const value = formSchema.default + newValues[formSchema.variable] = { + type: 'constant', + value: formSchema.default, + } + newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value) + } + }) + return newValues +} + +const getVarKindType = (type: FormTypeEnum) => { + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return VarKindType.variable + if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber) + return VarKindType.constant + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarKindType.mixed + } + +export const generateAgentToolValue = (value: Record, formSchemas: { variable: string; default?: any; type: string }[], isReasoning = false) => { + const newValues = {} as any + if (!isReasoning) { + formSchemas.forEach((formSchema) => { + const itemValue = value[formSchema.variable] + newValues[formSchema.variable] = { + value: { + type: 'constant', + value: itemValue.value, + }, + } + newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value) + }) + } + else { + formSchemas.forEach((formSchema) => { + const itemValue = value[formSchema.variable] + if (itemValue.auto === 1) { + newValues[formSchema.variable] = { + auto: 1, + value: null, + } + } + else { + newValues[formSchema.variable] = { + auto: 0, + value: itemValue.value || { + type: getVarKindType(formSchema.type as FormTypeEnum), + value: null, + }, + } + } + }) + } + return newValues +} diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 83f354d7d8..fd48935147 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -23,7 +23,7 @@ import { InputVarType, } from '@/app/components/workflow/types' import { useToastContext } from '@/app/components/base/toast' -import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' +import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' import type { PublishWorkflowParams } from '@/types/workflow' import { fetchAppDetail } from '@/service/apps' import { useStore as useAppStore } from '@/app/components/app/store' @@ -89,6 +89,7 @@ const FeaturesTrigger = () => { } }, [appID, setAppDetail]) const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!) + const updatePublishedWorkflow = useInvalidateAppWorkflow() const onPublish = useCallback(async (params?: PublishWorkflowParams) => { if (await handleCheckBeforePublish()) { const res = await publishWorkflow({ @@ -98,6 +99,7 @@ const FeaturesTrigger = () => { if (res) { notify({ type: 'success', message: t('common.api.actionSuccess') }) + updatePublishedWorkflow(appID!) updateAppDetail() workflowStore.getState().setPublishedAt(res.created_at) resetWorkflowVersionHistory() @@ -106,7 +108,7 @@ const FeaturesTrigger = () => { else { throw new Error('Checklist failed') } - }, [handleCheckBeforePublish, notify, t, workflowStore, publishWorkflow, resetWorkflowVersionHistory, updateAppDetail]) + }, [handleCheckBeforePublish, publishWorkflow, notify, t, updatePublishedWorkflow, appID, updateAppDetail, workflowStore, resetWorkflowVersionHistory]) const onPublisherToggle = useCallback((state: boolean) => { if (state) diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index e57a6bd3f7..870d791d4f 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -5,10 +5,11 @@ import { useState, } from 'react' import type { + BlockEnum, OnSelectBlock, ToolWithProvider, } from '../types' -import type { ToolValue } from './types' +import type { ToolDefaultValue, ToolValue } from './types' import { ToolTypeEnum } from './types' import Tools from './tools' import { useToolTabs } from './hooks' @@ -17,8 +18,6 @@ import cn from '@/utils/classnames' import { useGetLanguage } from '@/context/i18n' import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list' -import ActionButton from '../../base/action-button' -import { RiAddLine } from '@remixicon/react' import { PluginType } from '../../plugins/types' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { useGlobalPublicStore } from '@/context/global-public-context' @@ -31,11 +30,12 @@ type AllToolsProps = { buildInTools: ToolWithProvider[] customTools: ToolWithProvider[] workflowTools: ToolWithProvider[] + mcpTools: ToolWithProvider[] onSelect: OnSelectBlock - supportAddCustomTool?: boolean - onAddedCustomTool?: () => void - onShowAddCustomCollectionModal?: () => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const DEFAULT_TAGS: AllToolsProps['tags'] = [] @@ -46,12 +46,14 @@ const AllTools = ({ searchText, tags = DEFAULT_TAGS, onSelect, + canNotSelectMultiple, + onSelectMultiple, buildInTools, workflowTools, customTools, - supportAddCustomTool, - onShowAddCustomCollectionModal, + mcpTools = [], selectedTools, + canChooseMCPTool, }: AllToolsProps) => { const language = useGetLanguage() const tabs = useToolTabs() @@ -64,13 +66,15 @@ const AllTools = ({ const tools = useMemo(() => { let mergedTools: ToolWithProvider[] = [] if (activeTab === ToolTypeEnum.All) - mergedTools = [...buildInTools, ...customTools, ...workflowTools] + mergedTools = [...buildInTools, ...customTools, ...workflowTools, ...mcpTools] if (activeTab === ToolTypeEnum.BuiltIn) mergedTools = buildInTools if (activeTab === ToolTypeEnum.Custom) mergedTools = customTools if (activeTab === ToolTypeEnum.Workflow) mergedTools = workflowTools + if (activeTab === ToolTypeEnum.MCP) + mergedTools = mcpTools if (!hasFilter) return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0) @@ -80,7 +84,7 @@ const AllTools = ({ return tool.label[language].toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase()) }) }) - }, [activeTab, buildInTools, customTools, workflowTools, searchText, language, hasFilter]) + }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, searchText, language, hasFilter]) const { queryPluginsWithDebounced: fetchPlugins, @@ -88,7 +92,6 @@ const AllTools = ({ } = useMarketplacePlugins() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) - useEffect(() => { if (!enable_marketplace) return if (searchText || tags.length > 0) { @@ -103,10 +106,11 @@ const AllTools = ({ const pluginRef = useRef(null) const wrapElemRef = useRef(null) + const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab) return ( -
-
+
+
{ tabs.map(tab => ( @@ -124,17 +128,8 @@ const AllTools = ({ )) }
- - {supportAddCustomTool && ( -
-
- - - -
+ {isSupportGroupView && ( + )}
{/* Plugins from marketplace */} {enable_marketplace && { ] } -export const useToolTabs = () => { +export const useToolTabs = (isHideMCPTools?: boolean) => { const { t } = useTranslation() - - return [ + const tabs = [ { key: ToolTypeEnum.All, name: t('workflow.tabs.allTool'), @@ -52,4 +51,12 @@ export const useToolTabs = () => { name: t('workflow.tabs.workflowTool'), }, ] + if(!isHideMCPTools) { + tabs.push({ + key: ToolTypeEnum.MCP, + name: 'MCP', + }) + } + + return tabs } diff --git a/web/app/components/workflow/block-selector/index-bar.tsx b/web/app/components/workflow/block-selector/index-bar.tsx index 4d8bedffbe..097a16eb94 100644 --- a/web/app/components/workflow/block-selector/index-bar.tsx +++ b/web/app/components/workflow/block-selector/index-bar.tsx @@ -83,8 +83,8 @@ const IndexBar: FC = ({ letters, itemRefs, className }) => { element.scrollIntoView({ behavior: 'smooth' }) } return ( -
-
+
+
{letters.map(letter => (
handleIndexClick(letter)}> {letter} diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 9e55a24d9e..0673ca0c0d 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -129,33 +129,35 @@ const NodeSelector: FC = ({
-
e.stopPropagation()}> - {activeTab === TabsEnum.Blocks && ( - setSearchText(e.target.value)} - onClear={() => setSearchText('')} - /> - )} - {activeTab === TabsEnum.Tools && ( - - )} - -
e.stopPropagation()}> + {activeTab === TabsEnum.Blocks && ( + setSearchText(e.target.value)} + onClear={() => setSearchText('')} + /> + )} + {activeTab === TabsEnum.Tools && ( + + )} +
+ } onSelect={handleSelect} searchText={searchText} tags={tags} diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index e2b4a7acc6..dce877ab91 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -80,7 +80,7 @@ const List = forwardRef(({ ) } - const maxWidthClassName = toolContentClassName || 'max-w-[300px]' + const maxWidthClassName = toolContentClassName || 'max-w-[100%]' return ( <> @@ -109,18 +109,20 @@ const List = forwardRef(({ onAction={noop} /> ))} -
-
- - - {t('plugin.searchInMarketplace')} - -
-
+ {list.length > 0 && ( +
+
+ + + {t('plugin.searchInMarketplace')} + +
+
+ )}
) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 67aaaba1a5..3f3fed2ca9 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import { memo } from 'react' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' import type { BlockEnum } from '../types' import { useTabs } from './hooks' import type { ToolDefaultValue } from './types' @@ -16,6 +16,7 @@ export type TabsProps = { tags: string[] onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void availableBlocksTypes?: BlockEnum[] + filterElem: React.ReactNode noBlocks?: boolean } const Tabs: FC = ({ @@ -25,26 +26,28 @@ const Tabs: FC = ({ searchText, onSelect, availableBlocksTypes, + filterElem, noBlocks, }) => { const tabs = useTabs() const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() return (
e.stopPropagation()}> { !noBlocks && ( -
+
{ tabs.map(tab => (
onActiveTabChange(tab.key)} @@ -56,25 +59,30 @@ const Tabs: FC = ({
) } + {filterElem} { activeTab === TabsEnum.Blocks && !noBlocks && ( - +
+ +
) } { activeTab === TabsEnum.Tools && ( ) } diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index dbb49fde75..d97a4f3a1b 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -23,7 +23,7 @@ import { } from '@/service/tools' import type { CustomCollectionBackend } from '@/app/components/tools/types' import Toast from '@/app/components/base/toast' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools' import cn from '@/utils/classnames' type Props = { @@ -35,9 +35,11 @@ type Props = { isShow: boolean onShowChange: (isShow: boolean) => void onSelect: (tool: ToolDefaultValue) => void + onSelectMultiple: (tools: ToolDefaultValue[]) => void supportAddCustomTool?: boolean scope?: string selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const ToolPicker: FC = ({ @@ -48,10 +50,12 @@ const ToolPicker: FC = ({ isShow, onShowChange, onSelect, + onSelectMultiple, supportAddCustomTool, scope = 'all', selectedTools, panelClassName, + canChooseMCPTool, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -61,6 +65,7 @@ const ToolPicker: FC = ({ const { data: customTools } = useAllCustomTools() const invalidateCustomTools = useInvalidateAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const { builtinToolList, customToolList, workflowToolList } = useMemo(() => { if (scope === 'plugins') { @@ -102,6 +107,10 @@ const ToolPicker: FC = ({ onSelect(tool!) } + const handleSelectMultiple = (_type: BlockEnum, tools: ToolDefaultValue[]) => { + onSelectMultiple(tools) + } + const [isShowEditCollectionToolModal, { setFalse: hideEditCustomCollectionModal, setTrue: showEditCustomCollectionModal, @@ -142,7 +151,7 @@ const ToolPicker: FC = ({ -
+
= ({ onTagsChange={setTags} size='small' placeholder={t('plugin.searchTools')!} + supportAddCustomTool={supportAddCustomTool} + onAddedCustomTool={handleAddedCustomTool} + onShowAddCustomCollectionModal={showEditCustomCollectionModal} + inputClassName='grow' + />
diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index dc9b9b9114..e5e33614b0 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -10,13 +10,12 @@ import { useGetLanguage } from '@/context/i18n' import BlockIcon from '../../block-icon' import cn from '@/utils/classnames' import { useTranslation } from 'react-i18next' -import { RiCheckLine } from '@remixicon/react' -import Badge from '@/app/components/base/badge' type Props = { provider: ToolWithProvider payload: Tool disabled?: boolean + isAdded?: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void } @@ -25,6 +24,7 @@ const ToolItem: FC = ({ payload, onSelect, disabled, + isAdded, }) => { const { t } = useTranslation() @@ -71,18 +71,16 @@ const ToolItem: FC = ({ output_schema: payload.output_schema, paramSchemas: payload.parameters, params, + meta: provider.meta, }) }} > -
{payload.label[language]}
- {disabled && - -
{t('tools.addToolModal.added')}
-
- } +
+ {payload.label[language]} +
+ {isAdded && ( +
{t('tools.addToolModal.added')}
+ )}
) diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx index ef671ca1f8..ca462c082e 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx @@ -11,21 +11,29 @@ import { useMemo } from 'react' type Props = { payload: ToolWithProvider[] isShowLetterIndex: boolean + indexBar: React.ReactNode hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void letters: string[] toolRefs: any selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const ToolViewFlatView: FC = ({ letters, payload, isShowLetterIndex, + indexBar, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, toolRefs, selectedTools, + canChooseMCPTool, }) => { const firstLetterToolIds = useMemo(() => { const res: Record = {} @@ -37,26 +45,31 @@ const ToolViewFlatView: FC = ({ return res }, [payload, letters]) return ( -
- {payload.map(tool => ( -
{ - const letter = firstLetterToolIds[tool.id] - if (letter) - toolRefs.current[letter] = el - }} - > - -
- ))} +
+
+ {payload.map(tool => ( +
{ + const letter = firstLetterToolIds[tool.id] + if (letter) + toolRefs.current[letter] = el + }} + > + +
+ ))} +
+ {isShowLetterIndex && indexBar}
) } diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx index d6c567f8e2..b3f7aab4df 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx @@ -12,7 +12,10 @@ type Props = { toolList: ToolWithProvider[] hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const Item: FC = ({ @@ -20,7 +23,10 @@ const Item: FC = ({ toolList, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, selectedTools, + canChooseMCPTool, }) => { return (
@@ -36,7 +42,10 @@ const Item: FC = ({ isShowLetterIndex={false} hasSearchText={hasSearchText} onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} /> ))}
diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx index f3f98279c8..d85d1ea682 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx @@ -12,14 +12,20 @@ type Props = { payload: Record hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const ToolListTreeView: FC = ({ payload, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, selectedTools, + canChooseMCPTool, }) => { const { t } = useTranslation() const getI18nGroupName = useCallback((name: string) => { @@ -46,7 +52,10 @@ const ToolListTreeView: FC = ({ toolList={payload[groupName]} hasSearchText={hasSearchText} onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} /> ))}
diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index d48d0bfc90..83ae062737 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import React, { useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import cn from '@/utils/classnames' import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' import { useGetLanguage } from '@/context/i18n' @@ -13,36 +13,108 @@ import { ViewType } from '../view-type-select' import ActionItem from './action-item' import BlockIcon from '../../block-icon' import { useTranslation } from 'react-i18next' +import { useHover } from 'ahooks' +import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip' +import { Mcp } from '@/app/components/base/icons/src/vender/other' type Props = { className?: string payload: ToolWithProvider viewType: ViewType - isShowLetterIndex: boolean hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const Tool: FC = ({ className, payload, viewType, - isShowLetterIndex, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, selectedTools, + canChooseMCPTool, }) => { const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat + const notShowProvider = payload.type === CollectionType.workflow const actions = payload.tools - const hasAction = true // Now always support actions + const hasAction = !notShowProvider const [isFold, setFold] = React.useState(true) - const getIsDisabled = (tool: ToolType) => { + const ref = useRef(null) + const isHovering = useHover(ref) + const isMCPTool = payload.type === CollectionType.mcp + const isShowCanNotChooseMCPTip = !canChooseMCPTool && isMCPTool + const getIsDisabled = useCallback((tool: ToolType) => { if (!selectedTools || !selectedTools.length) return false - return selectedTools.some(selectedTool => selectedTool.provider_name === payload.name && selectedTool.tool_name === tool.name) - } + return selectedTools.some(selectedTool => (selectedTool.provider_name === payload.name || selectedTool.provider_name === payload.id) && selectedTool.tool_name === tool.name) + }, [payload.id, payload.name, selectedTools]) + + const totalToolsNum = actions.length + const selectedToolsNum = actions.filter(action => getIsDisabled(action)).length + const isAllSelected = selectedToolsNum === totalToolsNum + + const notShowProviderSelectInfo = useMemo(() => { + if (isAllSelected) { + return ( + + {t('tools.addToolModal.added')} + + ) + } + }, [isAllSelected, t]) + const selectedInfo = useMemo(() => { + if (isHovering && !isAllSelected) { + return ( + { + onSelectMultiple?.(BlockEnum.Tool, actions.filter(action => !getIsDisabled(action)).map((tool) => { + const params: Record = {} + if (tool.parameters) { + tool.parameters.forEach((item) => { + params[item.name] = '' + }) + } + return { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + tool_name: tool.name, + tool_label: tool.label[language], + tool_description: tool.description[language], + title: tool.label[language], + is_team_authorization: payload.is_team_authorization, + output_schema: tool.output_schema, + paramSchemas: tool.parameters, + params, + } + })) + }} + > + {t('workflow.tabs.addAll')} + + ) + } + + if (selectedToolsNum === 0) + return <> + + return ( + + {isAllSelected + ? t('workflow.tabs.allAdded') + : `${selectedToolsNum} / ${totalToolsNum}` + } + + ) + }, [actions, getIsDisabled, isAllSelected, isHovering, language, onSelectMultiple, payload.id, payload.is_team_authorization, payload.name, payload.type, selectedToolsNum, t, totalToolsNum]) + useEffect(() => { if (hasSearchText && isFold) { setFold(false) @@ -71,59 +143,73 @@ const Tool: FC = ({ return (
{ - if (hasAction) + if (hasAction) { setFold(!isFold) + return + } - // Now always support actions - // if (payload.parameters) { - // payload.parameters.forEach((item) => { - // params[item.name] = '' - // }) - // } - // onSelect(BlockEnum.Tool, { - // provider_id: payload.id, - // provider_type: payload.type, - // provider_name: payload.name, - // tool_name: payload.name, - // tool_label: payload.label[language], - // title: payload.label[language], - // params: {}, - // }) + const tool = actions[0] + const params: Record = {} + if (tool.parameters) { + tool.parameters.forEach((item) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.Tool, { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + tool_name: tool.name, + tool_label: tool.label[language], + tool_description: tool.description[language], + title: tool.label[language], + is_team_authorization: payload.is_team_authorization, + output_schema: tool.output_schema, + paramSchemas: tool.parameters, + params, + }) }} > -
+
-
{payload.label[language]}
+
+ {notShowProvider ? actions[0]?.label[language] : payload.label[language]} + {isFlatView && groupName && ( + {groupName} + )} + {isMCPTool && } +
-
- {isFlatView && ( -
{groupName}
- )} +
+ {!isShowCanNotChooseMCPTip && !canNotSelectMultiple && (notShowProvider ? notShowProviderSelectInfo : selectedInfo)} + {isShowCanNotChooseMCPTip && } {hasAction && ( - + )}
- {hasAction && !isFold && ( + {!notShowProvider && hasAction && !isFold && ( actions.map(action => ( )) )} diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index 2562501524..da47432b04 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import type { BlockEnum, ToolWithProvider } from '../types' import IndexBar, { groupItems } from './index-bar' import type { ToolDefaultValue, ToolValue } from './types' +import type { ToolTypeEnum } from './types' import { ViewType } from './view-type-select' import Empty from '@/app/components/tools/add-tool-modal/empty' import { useGetLanguage } from '@/context/i18n' @@ -15,25 +16,34 @@ import ToolListFlatView from './tool/tool-list-flat-view/list' import classNames from '@/utils/classnames' type ToolsProps = { - showWorkflowEmpty: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void tools: ToolWithProvider[] viewType: ViewType hasSearchText: boolean + toolType?: ToolTypeEnum + isAgent?: boolean className?: string indexBarClassName?: string selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const Blocks = ({ - showWorkflowEmpty, onSelect, + canNotSelectMultiple, + onSelectMultiple, tools, viewType, hasSearchText, + toolType, + isAgent, className, indexBarClassName, selectedTools, + canChooseMCPTool, }: ToolsProps) => { + // const tools: any = [] const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat @@ -87,15 +97,15 @@ const Blocks = ({ const toolRefs = useRef({}) return ( -
+
{ - !tools.length && !showWorkflowEmpty && ( -
{t('workflow.tabs.noResult')}
+ !tools.length && hasSearchText && ( +
{t('workflow.tabs.noResult')}
) } - {!tools.length && showWorkflowEmpty && ( + {!tools.length && !hasSearchText && (
- +
)} {!!tools.length && ( @@ -107,19 +117,24 @@ const Blocks = ({ isShowLetterIndex={isShowLetterIndex} hasSearchText={hasSearchText} onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + indexBar={} /> ) : ( ) )} - - {isShowLetterIndex && }
) } diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index f1bdbbfbd9..c96a60f674 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,3 +1,5 @@ +import type { PluginMeta } from '../../plugins/types' + export enum TabsEnum { Blocks = 'blocks', Tools = 'tools', @@ -8,6 +10,7 @@ export enum ToolTypeEnum { BuiltIn = 'built-in', Custom = 'custom', Workflow = 'workflow', + MCP = 'mcp', } export enum BlockClassificationEnum { @@ -30,10 +33,12 @@ export type ToolDefaultValue = { params: Record paramSchemas: Record[] output_schema: Record + meta?: PluginMeta } export type ToolValue = { provider_name: string + provider_show_name?: string tool_name: string tool_label: string tool_description?: string diff --git a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts new file mode 100644 index 0000000000..98986cf3b6 --- /dev/null +++ b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' + +const useCheckVerticalScrollbar = (ref: React.RefObject) => { + const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false) + + useEffect(() => { + const elem = ref.current + if (!elem) return + + const checkScrollbar = () => { + setHasVerticalScrollbar(elem.scrollHeight > elem.clientHeight) + } + + checkScrollbar() + + const resizeObserver = new ResizeObserver(checkScrollbar) + resizeObserver.observe(elem) + + const mutationObserver = new MutationObserver(checkScrollbar) + mutationObserver.observe(elem, { childList: true, subtree: true, characterData: true }) + + return () => { + resizeObserver.disconnect() + mutationObserver.disconnect() + } + }, [ref]) + + return hasVerticalScrollbar +} + +export default useCheckVerticalScrollbar diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 1b98178152..8bc9d3436f 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -40,6 +40,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { fetchAllBuiltInTools, fetchAllCustomTools, + fetchAllMCPTools, fetchAllWorkflowTools, } from '@/service/tools' import { CollectionType } from '@/app/components/tools/types' @@ -445,6 +446,13 @@ export const useFetchToolsData = () => { workflowTools: workflowTools || [], }) } + if(type === 'mcp') { + const mcpTools = await fetchAllMCPTools() + + workflowStore.setState({ + mcpTools: mcpTools || [], + }) + } }, [workflowStore]) return { @@ -491,6 +499,8 @@ export const useToolIcon = (data: Node['data']) => { const buildInTools = useStore(s => s.buildInTools) const customTools = useStore(s => s.customTools) const workflowTools = useStore(s => s.workflowTools) + const mcpTools = useStore(s => s.mcpTools) + const toolIcon = useMemo(() => { if(!data) return '' @@ -500,11 +510,13 @@ export const useToolIcon = (data: Node['data']) => { targetTools = buildInTools else if (data.provider_type === CollectionType.custom) targetTools = customTools + else if (data.provider_type === CollectionType.mcp) + targetTools = mcpTools else targetTools = workflowTools return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon } - }, [data, buildInTools, customTools, workflowTools]) + }, [data, buildInTools, customTools, mcpTools, workflowTools]) return toolIcon } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 8631eb58e3..8ea861ebb4 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -234,6 +234,7 @@ export const Workflow: FC = memo(({ handleFetchAllTools('builtin') handleFetchAllTools('custom') handleFetchAllTools('workflow') + handleFetchAllTools('mcp') }, [handleFetchAllTools]) const { diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index f262ae7e34..ba5281870f 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -68,6 +68,7 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s icon: getIcon(item.declaration.identity.icon), label: item.declaration.identity.label as any, type: CollectionType.all, + meta: item.meta, tools: item.declaration.strategies.map(strategy => ({ name: strategy.identity.name, author: strategy.identity.author, @@ -89,10 +90,13 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s export type AgentStrategySelectorProps = { value?: Strategy, onChange: (value?: Strategy) => void, + canChooseMCPTool: boolean, } export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => { - const { value, onChange } = props + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + + const { value, onChange, canChooseMCPTool } = props const [open, setOpen] = useState(false) const [viewType, setViewType] = useState(ViewType.flat) const [query, setQuery] = useState('') @@ -132,8 +136,6 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => plugins: notInstalledPlugins = [], } = useMarketplacePlugins() - const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) - useEffect(() => { if (!enable_marketplace) return if (query) { @@ -214,21 +216,25 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => agent_strategy_label: tool!.tool_label, agent_output_schema: tool!.output_schema, plugin_unique_identifier: tool!.provider_id, + meta: tool!.meta, }) setOpen(false) }} className='h-full max-h-full max-w-none overflow-y-auto' - indexBarClassName='top-0 xl:top-36' showWorkflowEmpty={false} hasSearchText={false} /> - {enable_marketplace - && + {enable_marketplace && - } + />}
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 4ca8746137..31aa91cfdb 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -19,6 +19,8 @@ import { useWorkflowStore } from '../../../store' import { useRenderI18nObject } from '@/hooks/use-i18n' import type { NodeOutPutVar } from '../../../types' import type { Node } from 'reactflow' +import type { PluginMeta } from '@/app/components/plugins/types' +import { noop } from 'lodash' import { useDocLink } from '@/context/i18n' export type Strategy = { @@ -27,6 +29,7 @@ export type Strategy = { agent_strategy_label: string agent_output_schema: Record plugin_unique_identifier: string + meta?: PluginMeta } export type AgentStrategyProps = { @@ -38,6 +41,7 @@ export type AgentStrategyProps = { nodeOutputVars?: NodeOutPutVar[], availableNodes?: Node[], nodeId?: string + canChooseMCPTool: boolean } type CustomSchema = Omit & { type: Type } & Field @@ -48,7 +52,7 @@ type MultipleToolSelectorSchema = CustomSchema<'array[tools]'> type CustomField = ToolSelectorSchema | MultipleToolSelectorSchema export const AgentStrategy = memo((props: AgentStrategyProps) => { - const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId } = props + const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId, canChooseMCPTool } = props const { t } = useTranslation() const docLink = useDocLink() const defaultModel = useDefaultModel(ModelTypeEnum.textGeneration) @@ -57,6 +61,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { const { setControlPromptEditorRerenderKey, } = workflowStore.getState() + const override: ComponentProps>['override'] = [ [FormTypeEnum.textNumber, FormTypeEnum.textInput], (schema, props) => { @@ -168,6 +173,8 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { value={value} onSelect={item => onChange(item)} onDelete={() => onChange(null)} + canChooseMCPTool={canChooseMCPTool} + onSelectMultiple={noop} /> ) @@ -189,13 +196,14 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { onChange={onChange} supportCollapse required={schema.required} + canChooseMCPTool={canChooseMCPTool} /> ) } } } return
- + { strategy ?
@@ -215,6 +223,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { nodeId={nodeId} nodeOutputVars={nodeOutputVars || []} availableNodes={availableNodes || []} + canChooseMCPTool={canChooseMCPTool} />
: = ({ return ( -
+
{title}
{ diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx index 3540c60a39..748698747c 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx @@ -23,7 +23,7 @@ export type Props = { value?: string | object placeholder?: React.JSX.Element | string onChange?: (value: string) => void - title?: React.JSX.Element + title?: string | React.JSX.Element language: CodeLanguage headerRight?: React.JSX.Element readOnly?: boolean diff --git a/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx b/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx new file mode 100644 index 0000000000..07c3a087b9 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx @@ -0,0 +1,35 @@ +'use client' +import type { FC } from 'react' +import cn from '@/utils/classnames' + +type Props = { + value: boolean + onChange: (value: boolean) => void +} + +const FormInputBoolean: FC = ({ + value, + onChange, +}) => { + return ( +
+
onChange(true)} + >True
+
onChange(false)} + >False
+
+ ) +} +export default FormInputBoolean diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx new file mode 100644 index 0000000000..6f8bd17a96 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -0,0 +1,279 @@ +'use client' +import type { FC } from 'react' +import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' + +import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import FormInputTypeSwitch from './form-input-type-switch' +import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' +import Input from '@/app/components/base/input' +import { SimpleSelect } from '@/app/components/base/select' +import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' +import FormInputBoolean from './form-input-boolean' +import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' +import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' +import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import cn from '@/utils/classnames' +import type { Tool } from '@/app/components/tools/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema + value: ToolVarInputs + onChange: (value: any) => void + inPanel?: boolean + currentTool?: Tool + currentProvider?: ToolWithProvider +} + +const FormInputItem: FC = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentTool, + currentProvider, +}) => { + const language = useLanguage() + + const { + placeholder, + variable, + type, + default: defaultValue, + options, + scope, + } = schema as any + const varInput = value[variable] + const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const isNumber = type === FormTypeEnum.textNumber + const isObject = type === FormTypeEnum.object + const isArray = type === FormTypeEnum.array + const isShowJSONEditor = isObject || isArray + const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files + const isBoolean = type === FormTypeEnum.boolean + const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect + const isAppSelector = type === FormTypeEnum.appSelector + const isModelSelector = type === FormTypeEnum.modelSelector + const showTypeSwitch = isNumber || isObject || isArray + const isConstant = varInput?.type === VarKindType.constant || !varInput?.type + const showVariableSelector = isFile || varInput?.type === VarKindType.variable + + const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { + onlyLeafNodeVar: false, + filterVar: (varPayload: Var) => { + return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + }, + }) + + const targetVarType = () => { + if (isString) + return VarType.string + else if (isNumber) + return VarType.number + else if (type === FormTypeEnum.files) + return VarType.arrayFile + else if (type === FormTypeEnum.file) + return VarType.file + // else if (isSelect) + // return VarType.select + // else if (isAppSelector) + // return VarType.appSelector + // else if (isModelSelector) + // return VarType.modelSelector + // else if (isBoolean) + // return VarType.boolean + else if (isObject) + return VarType.object + else if (isArray) + return VarType.arrayObject + else + return VarType.string + } + + const getFilterVar = () => { + if (isNumber) + return (varPayload: any) => varPayload.type === VarType.number + else if (isString) + return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + else if (isFile) + return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + else if (isBoolean) + return (varPayload: any) => varPayload.type === VarType.boolean + else if (isObject) + return (varPayload: any) => varPayload.type === VarType.object + else if (isArray) + return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + return undefined + } + + const getVarKindType = () => { + if (isFile) + return VarKindType.variable + if (isSelect || isBoolean || isNumber || isArray || isObject) + return VarKindType.constant + if (isString) + return VarKindType.mixed + } + + const handleTypeChange = (newType: string) => { + if (newType === VarKindType.variable) { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.variable, + value: '', + }, + }) + } + else { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.constant, + value: defaultValue, + }, + }) + } + } + + const handleValueChange = (newValue: any) => { + onChange({ + ...value, + [variable]: { + ...varInput, + type: getVarKindType(), + value: isNumber ? Number.parseFloat(newValue) : newValue, + }, + }) + } + + const handleAppOrModelSelect = (newValue: any) => { + onChange({ + ...value, + [variable]: { + ...varInput, + ...newValue, + }, + }) + } + + const handleVariableSelectorChange = (newValue: ValueSelector | string, variable: string) => { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.variable, + value: newValue || '', + }, + }) + } + + return ( +
+ {showTypeSwitch && ( + + )} + {isString && ( + + )} + {isNumber && isConstant && ( + handleValueChange(e.target.value)} + placeholder={placeholder?.[language] || placeholder?.en_US} + /> + )} + {isBoolean && ( + + )} + {isSelect && ( + { + if (option.show_on.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + onSelect={item => handleValueChange(item.value as string)} + placeholder={placeholder?.[language] || placeholder?.en_US} + /> + )} + {isShowJSONEditor && isConstant && ( +
+ {placeholder?.[language] || placeholder?.en_US}
} + /> +
+ )} + {isAppSelector && ( + + )} + {isModelSelector && isConstant && ( + + )} + {showVariableSelector && ( + handleVariableSelectorChange(value, variable)} + filterVar={getFilterVar()} + schema={schema} + valueTypePlaceHolder={targetVarType()} + currentTool={currentTool} + currentProvider={currentProvider} + /> + )} +
+ ) +} +export default FormInputItem diff --git a/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx b/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx new file mode 100644 index 0000000000..391e204844 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx @@ -0,0 +1,47 @@ +'use client' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiEditLine, +} from '@remixicon/react' +import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import Tooltip from '@/app/components/base/tooltip' +import { VarType } from '@/app/components/workflow/nodes/tool/types' +import cn from '@/utils/classnames' + +type Props = { + value: VarType + onChange: (value: VarType) => void +} + +const FormInputTypeSwitch: FC = ({ + value, + onChange, +}) => { + const { t } = useTranslation() + return ( +
+ +
onChange(VarType.variable)} + > + +
+
+ +
onChange(VarType.constant)} + > + +
+
+
+ ) +} +export default FormInputTypeSwitch diff --git a/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx new file mode 100644 index 0000000000..8117f7502f --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx @@ -0,0 +1,22 @@ +'use client' +import Tooltip from '@/app/components/base/tooltip' +import { RiAlertFill } from '@remixicon/react' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' + +const McpToolNotSupportTooltip: FC = () => { + const { t } = useTranslation() + return ( + + {t('plugin.detailPanel.toolSelector.unsupportedMCPTool')} +
+ } + > + + + ) +} +export default React.memo(McpToolNotSupportTooltip) diff --git a/web/app/components/workflow/nodes/_base/components/setting-item.tsx b/web/app/components/workflow/nodes/_base/components/setting-item.tsx index 134bf4a551..abbfaef490 100644 --- a/web/app/components/workflow/nodes/_base/components/setting-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/setting-item.tsx @@ -13,7 +13,7 @@ export const SettingItem = memo(({ label, children, status, tooltip }: SettingIt const indicator: ComponentProps['color'] = status === 'error' ? 'red' : status === 'warning' ? 'yellow' : undefined const needTooltip = ['error', 'warning'].includes(status as any) return
-
+
{label}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 23ccea2572..e6f3ce1fa1 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -528,6 +528,7 @@ const VarReferencePicker: FC = ({ onChange={handleVarReferenceChange} itemWidth={isAddBtnTrigger ? 260 : (minWidth || triggerWidth)} isSupportFileVar={isSupportFileVar} + zIndex={zIndex} /> )} 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 9398ae7361..3746a85441 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 @@ -13,6 +13,7 @@ type Props = { onChange: (value: ValueSelector, varDetail: Var) => void itemWidth?: number isSupportFileVar?: boolean + zIndex?: number } const VarReferencePopup: FC = ({ vars, @@ -20,6 +21,7 @@ const VarReferencePopup: FC = ({ onChange, itemWidth, isSupportFileVar = true, + zIndex, }) => { const { t } = useTranslation() const docLink = useDocLink() @@ -60,6 +62,7 @@ const VarReferencePopup: FC = ({ onChange={onChange} itemWidth={itemWidth} isSupportFileVar={isSupportFileVar} + zIndex={zIndex} /> }
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 27063a2ba3..303840d8e7 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -46,6 +46,7 @@ type ItemProps = { isSupportFileVar?: boolean isException?: boolean isLoopVar?: boolean + zIndex?: number } const objVarTypes = [VarType.object, VarType.file] @@ -60,6 +61,7 @@ const Item: FC = ({ isSupportFileVar, isException, isLoopVar, + zIndex, }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties const isFile = itemData.type === VarType.file && !isStructureOutput @@ -171,7 +173,7 @@ const Item: FC = ({
{(isStructureOutput || isObj) && ( void onBlur?: () => void + zIndex?: number autoFocus?: boolean } const VarReferenceVars: FC = ({ @@ -272,6 +275,7 @@ const VarReferenceVars: FC = ({ maxHeightClass, onClose, onBlur, + zIndex, autoFocus = true, }) => { const { t } = useTranslation() @@ -357,6 +361,7 @@ const VarReferenceVars: FC = ({ isSupportFileVar={isSupportFileVar} isException={v.isException} isLoopVar={item.isLoop} + zIndex={zIndex} /> ))}
)) diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 88771d098e..b969702dd7 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -32,6 +32,7 @@ import { import { useNodeIterationInteractions } from '../iteration/use-interactions' import { useNodeLoopInteractions } from '../loop/use-interactions' import type { IterationNodeType } from '../iteration/types' +import CopyID from '../tool/components/copy-id' import { NodeSourceHandle, NodeTargetHandle, @@ -321,6 +322,11 @@ const BaseNode: FC = ({
) } + {data.type === BlockEnum.Tool && ( +
+ +
+ )}
) diff --git a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx index b94258855a..8616f34200 100644 --- a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx +++ b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx @@ -2,10 +2,11 @@ import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import classNames from '@/utils/classnames' import { memo, useMemo, useRef, useState } from 'react' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' import { useTranslation } from 'react-i18next' import { Group } from '@/app/components/base/icons/src/vender/other' +import AppIcon from '@/app/components/base/app-icon' type Status = 'not-installed' | 'not-authorized' | undefined @@ -19,19 +20,21 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const isDataReady = !!buildInTools && !!customTools && !!workflowTools + const { data: mcpTools } = useAllMCPTools() + const isDataReady = !!buildInTools && !!customTools && !!workflowTools && !!mcpTools const currentProvider = useMemo(() => { - const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || [])] + const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || []), ...(mcpTools || [])] return mergedTools.find((toolWithProvider) => { - return toolWithProvider.name === providerName + return toolWithProvider.name === providerName || toolWithProvider.id === providerName }) - }, [buildInTools, customTools, providerName, workflowTools]) + }, [buildInTools, customTools, providerName, workflowTools, mcpTools]) + const providerNameParts = providerName.split('/') const author = providerNameParts[0] const name = providerNameParts[1] const icon = useMemo(() => { if (!isDataReady) return '' - if (currentProvider) return currentProvider.icon as string + if (currentProvider) return currentProvider.icon const iconFromMarketPlace = getIconFromMarketPlace(`${author}/${name}`) return iconFromMarketPlace }, [author, currentProvider, name, isDataReady]) @@ -62,19 +65,32 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { )} ref={containerRef} > - {(!iconFetchError && isDataReady) - - ? tool icon setIconFetchError(true)} - /> - : - } + {(() => { + if (iconFetchError || !icon) + return + if (typeof icon === 'string') { + return tool icon setIconFetchError(true)} + /> + } + if (typeof icon === 'object') { + return + } + return + })()} {indicator && }
diff --git a/web/app/components/workflow/nodes/agent/default.ts b/web/app/components/workflow/nodes/agent/default.ts index d80def7bd2..4f68cfe87c 100644 --- a/web/app/components/workflow/nodes/agent/default.ts +++ b/web/app/components/workflow/nodes/agent/default.ts @@ -7,6 +7,7 @@ import { renderI18nObject } from '@/i18n' const nodeDefault: NodeDefault = { defaultValue: { + version: '2', }, getAvailablePrevNodes(isChatMode) { return isChatMode @@ -60,15 +61,28 @@ const nodeDefault: NodeDefault = { const schemas = toolValue.schemas || [] const userSettings = toolValue.settings const reasoningConfig = toolValue.parameters + const version = payload.version schemas.forEach((schema: any) => { if (schema?.required) { - if (schema.form === 'form' && !userSettings[schema.name]?.value) { + if (schema.form === 'form' && !version && !userSettings[schema.name]?.value) { return { isValid: false, errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), } } - if (schema.form === 'llm' && reasoningConfig[schema.name].auto === 0 && !userSettings[schema.name]?.value) { + if (schema.form === 'form' && version && !userSettings[schema.name]?.value.value) { + return { + isValid: false, + errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + } + } + if (schema.form === 'llm' && !version && reasoningConfig[schema.name].auto === 0 && !reasoningConfig[schema.name]?.value) { + return { + isValid: false, + errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + } + } + if (schema.form === 'llm' && version && reasoningConfig[schema.name].auto === 0 && !reasoningConfig[schema.name]?.value.value) { return { isValid: false, errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), diff --git a/web/app/components/workflow/nodes/agent/node.tsx b/web/app/components/workflow/nodes/agent/node.tsx index d2267fd00f..a2190317af 100644 --- a/web/app/components/workflow/nodes/agent/node.tsx +++ b/web/app/components/workflow/nodes/agent/node.tsx @@ -104,7 +104,7 @@ const AgentNode: FC> = (props) => { {t('workflow.nodes.agent.toolbox')} }>
- {tools.map(tool => )} + {tools.map((tool, i) => )}
}
diff --git a/web/app/components/workflow/nodes/agent/panel.tsx b/web/app/components/workflow/nodes/agent/panel.tsx index 391383031f..6741453944 100644 --- a/web/app/components/workflow/nodes/agent/panel.tsx +++ b/web/app/components/workflow/nodes/agent/panel.tsx @@ -38,11 +38,11 @@ const AgentPanel: FC> = (props) => { readOnly, outputSchema, handleMemoryChange, + canChooseMCPTool, } = useConfig(props.id, props.data) const { t } = useTranslation() const resetEditor = useStore(s => s.setControlPromptEditorRerenderKey) - return
> = (props) => { agent_strategy_label: inputs.agent_strategy_label!, agent_output_schema: inputs.output_schema, plugin_unique_identifier: inputs.plugin_unique_identifier!, + meta: inputs.meta, } : undefined} onStrategyChange={(strategy) => { setInputs({ @@ -65,6 +66,7 @@ const AgentPanel: FC> = (props) => { agent_strategy_label: strategy?.agent_strategy_label, output_schema: strategy!.agent_output_schema, plugin_unique_identifier: strategy!.plugin_unique_identifier, + meta: strategy?.meta, }) resetEditor(Date.now()) }} @@ -74,6 +76,7 @@ const AgentPanel: FC> = (props) => { nodeOutputVars={availableVars} availableNodes={availableNodesWithParent} nodeId={props.id} + canChooseMCPTool={canChooseMCPTool} />
diff --git a/web/app/components/workflow/nodes/agent/types.ts b/web/app/components/workflow/nodes/agent/types.ts index ca8bb5e71d..5a13a4a4f3 100644 --- a/web/app/components/workflow/nodes/agent/types.ts +++ b/web/app/components/workflow/nodes/agent/types.ts @@ -1,14 +1,17 @@ import type { CommonNodeType, Memory } from '@/app/components/workflow/types' import type { ToolVarInputs } from '../tool/types' +import type { PluginMeta } from '@/app/components/plugins/types' export type AgentNodeType = CommonNodeType & { agent_strategy_provider_name?: string agent_strategy_name?: string agent_strategy_label?: string agent_parameters?: ToolVarInputs + meta?: PluginMeta output_schema: Record plugin_unique_identifier?: string memory?: Memory + version?: string } export enum AgentFeature { diff --git a/web/app/components/workflow/nodes/agent/use-config.ts b/web/app/components/workflow/nodes/agent/use-config.ts index c3e07e4e60..50faf03040 100644 --- a/web/app/components/workflow/nodes/agent/use-config.ts +++ b/web/app/components/workflow/nodes/agent/use-config.ts @@ -6,13 +6,16 @@ import { useIsChatMode, useNodesReadOnly, } from '@/app/components/workflow/hooks' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { type ToolVarInputs, VarType } from '../tool/types' import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins' import type { Memory, Var } from '../../types' import { VarType as VarKindType } from '../../types' import useAvailableVarList from '../_base/hooks/use-available-var-list' import produce from 'immer' +import { isSupportMCP } from '@/utils/plugin-version-feature' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { generateAgentToolValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' export type StrategyStatus = { plugin: { @@ -85,11 +88,12 @@ const useConfig = (id: string, payload: AgentNodeType) => { }) const formData = useMemo(() => { const paramNameList = (currentStrategy?.parameters || []).map(item => item.name) - return Object.fromEntries( + const res = Object.fromEntries( Object.entries(inputs.agent_parameters || {}).filter(([name]) => paramNameList.includes(name)).map(([key, value]) => { return [key, value.value] }), ) + return res }, [inputs.agent_parameters, currentStrategy?.parameters]) const onFormChange = (value: Record) => { const res: ToolVarInputs = {} @@ -105,6 +109,42 @@ const useConfig = (id: string, payload: AgentNodeType) => { }) } + const formattingToolData = (data: any) => { + const settingValues = generateAgentToolValue(data.settings, toolParametersToFormSchemas(data.schemas.filter((param: { form: string }) => param.form !== 'llm') as any)) + const paramValues = generateAgentToolValue(data.parameters, toolParametersToFormSchemas(data.schemas.filter((param: { form: string }) => param.form === 'llm') as any), true) + const res = produce(data, (draft: any) => { + draft.settings = settingValues + draft.parameters = paramValues + }) + return res + } + + const formattingLegacyData = () => { + if (inputs.version) + return inputs + const newData = produce(inputs, (draft) => { + const schemas = currentStrategy?.parameters || [] + Object.keys(draft.agent_parameters || {}).forEach((key) => { + const targetSchema = schemas.find(schema => schema.name === key) + if (targetSchema?.type === FormTypeEnum.toolSelector) + draft.agent_parameters![key].value = formattingToolData(draft.agent_parameters![key].value) + if (targetSchema?.type === FormTypeEnum.multiToolSelector) + draft.agent_parameters![key].value = draft.agent_parameters![key].value.map((tool: any) => formattingToolData(tool)) + }) + draft.version = '2' + }) + return newData + } + + // formatting legacy data + useEffect(() => { + if (!currentStrategy) + return + const newData = formattingLegacyData() + setInputs(newData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStrategy]) + // vars const filterMemoryPromptVar = useCallback((varPayload: Var) => { @@ -172,6 +212,7 @@ const useConfig = (id: string, payload: AgentNodeType) => { outputSchema, handleMemoryChange, isChatMode, + canChooseMCPTool: isSupportMCP(inputs.meta?.version), } } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts index 470a322b13..eb3dff83d8 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts @@ -6,6 +6,7 @@ import type { EditData } from './edit-card' import { ArrayType, type Field, Type } from '../../../types' import Toast from '@/app/components/base/toast' import { findPropertyWithPath } from '../../../utils' +import _ from 'lodash' type ChangeEventParams = { path: string[], @@ -19,7 +20,8 @@ type AddEventParams = { } export const useSchemaNodeOperations = (props: VisualEditorProps) => { - const { schema: jsonSchema, onChange } = props + const { schema: jsonSchema, onChange: doOnChange } = props + const onChange = doOnChange || _.noop const backupSchema = useVisualEditorStore(state => state.backupSchema) const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema) const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx index 1df42532a6..d96f856bbb 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx @@ -2,24 +2,29 @@ import type { FC } from 'react' import type { SchemaRoot } from '../../../types' import SchemaNode from './schema-node' import { useSchemaNodeOperations } from './hooks' +import cn from '@/utils/classnames' export type VisualEditorProps = { + className?: string schema: SchemaRoot - onChange: (schema: SchemaRoot) => void + rootName?: string + readOnly?: boolean + onChange?: (schema: SchemaRoot) => void } const VisualEditor: FC = (props) => { - const { schema } = props + const { className, schema, readOnly } = props useSchemaNodeOperations(props) return ( -
+
) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx index 70a6b861ad..96bbf999db 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx @@ -19,6 +19,7 @@ type SchemaNodeProps = { path: string[] parentPath?: string[] depth: number + readOnly?: boolean } // Support 10 levels of indentation @@ -57,6 +58,7 @@ const SchemaNode: FC = ({ path, parentPath, depth, + readOnly, }) => { const [isExpanded, setIsExpanded] = useState(true) const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty) @@ -77,11 +79,13 @@ const SchemaNode: FC = ({ } const handleMouseEnter = () => { + if(!readOnly) return if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(path.join('.')) } const handleMouseLeave = () => { + if(!readOnly) return if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(null) } @@ -183,7 +187,7 @@ const SchemaNode: FC = ({ )} { - depth === 0 && !isAddingNewField && ( + !readOnly && depth === 0 && !isAddingNewField && ( ) } diff --git a/web/app/components/workflow/nodes/tool/components/copy-id.tsx b/web/app/components/workflow/nodes/tool/components/copy-id.tsx new file mode 100644 index 0000000000..3a633e1d2e --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/copy-id.tsx @@ -0,0 +1,51 @@ +'use client' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiFileCopyLine } from '@remixicon/react' +import copy from 'copy-to-clipboard' +import { debounce } from 'lodash-es' +import Tooltip from '@/app/components/base/tooltip' + +type Props = { + content: string +} + +const prefixEmbedded = 'appOverview.overview.appInfo.embedded' + +const CopyFeedbackNew = ({ content }: Props) => { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(false) + + const onClickCopy = debounce(() => { + copy(content) + setIsCopied(true) + }, 100) + + const onMouseLeave = debounce(() => { + setIsCopied(false) + }, 100) + + return ( +
e.stopPropagation()} onMouseLeave={onMouseLeave}> + +
+
{content}
+ +
+
+
+ ) +} + +export default CopyFeedbackNew diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx new file mode 100644 index 0000000000..6680c8ebb6 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -0,0 +1,62 @@ +import { + memo, +} from 'react' +import { useTranslation } from 'react-i18next' +import PromptEditor from '@/app/components/base/prompt-editor' +import Placeholder from './placeholder' +import type { + Node, + NodeOutPutVar, +} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' + +type MixedVariableTextInputProps = { + readOnly?: boolean + nodesOutputVars?: NodeOutPutVar[] + availableNodes?: Node[] + value?: string + onChange?: (text: string) => void +} +const MixedVariableTextInput = ({ + readOnly = false, + nodesOutputVars, + availableNodes = [], + value = '', + onChange, +}: MixedVariableTextInputProps) => { + const { t } = useTranslation() + return ( + { + acc[node.id] = { + title: node.data.title, + type: node.data.type, + } + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t('workflow.blocks.start'), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + }} + placeholder={} + onChange={onChange} + /> + ) +} + +export default memo(MixedVariableTextInput) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx new file mode 100644 index 0000000000..3337d6ae66 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -0,0 +1,51 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { FOCUS_COMMAND } from 'lexical' +import { $insertNodes } from 'lexical' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import Badge from '@/app/components/base/badge' + +const Placeholder = () => { + const { t } = useTranslation() + const [editor] = useLexicalComposerContext() + + const handleInsert = useCallback((text: string) => { + editor.update(() => { + const textNode = new CustomTextNode(text) + $insertNodes([textNode]) + }) + editor.dispatchCommand(FOCUS_COMMAND, undefined as any) + }, [editor]) + + return ( +
{ + e.stopPropagation() + handleInsert('') + }} + > +
+ {t('workflow.nodes.tool.insertPlaceholder1')} +
/
+
{ + e.stopPropagation() + handleInsert('/') + })} + > + {t('workflow.nodes.tool.insertPlaceholder2')} +
+
+ +
+ ) +} + +export default Placeholder diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx new file mode 100644 index 0000000000..a867797473 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx @@ -0,0 +1,51 @@ +'use client' +import type { FC } from 'react' +import type { ToolVarInputs } from '../../types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import ToolFormItem from './item' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { Tool } from '@/app/components/tools/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema[] + value: ToolVarInputs + onChange: (value: ToolVarInputs) => void + onOpen?: (index: number) => void + inPanel?: boolean + currentTool?: Tool + currentProvider?: ToolWithProvider +} + +const ToolForm: FC = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentTool, + currentProvider, +}) => { + return ( +
+ { + schema.map((schema, index) => ( + + )) + } +
+ ) +} +export default ToolForm diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx new file mode 100644 index 0000000000..11de42fe56 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -0,0 +1,105 @@ +'use client' +import type { FC } from 'react' +import { + RiBracesLine, +} from '@remixicon/react' +import type { ToolVarInputs } from '../../types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' +import { useBoolean } from 'ahooks' +import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { Tool } from '@/app/components/tools/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema + value: ToolVarInputs + onChange: (value: ToolVarInputs) => void + inPanel?: boolean + currentTool?: Tool + currentProvider?: ToolWithProvider +} + +const ToolFormItem: FC = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentTool, + currentProvider, +}) => { + const language = useLanguage() + const { name, label, type, required, tooltip, input_schema } = schema + const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array + const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const [isShowSchema, { + setTrue: showSchema, + setFalse: hideSchema, + }] = useBoolean(false) + return ( +
+
+
+
{label[language] || label.en_US}
+ {required && ( +
*
+ )} + {!showDescription && tooltip && ( + + {tooltip[language] || tooltip.en_US} +
} + triggerClassName='ml-1 w-4 h-4' + asChild={false} + /> + )} + {showSchemaButton && ( + <> +
·
+ + + )} +
+ {showDescription && tooltip && ( +
{tooltip[language] || tooltip.en_US}
+ )} +
+ + + {isShowSchema && ( + + )} +
+ ) +} +export default ToolFormItem diff --git a/web/app/components/workflow/nodes/tool/default.ts b/web/app/components/workflow/nodes/tool/default.ts index f245929684..1fdb9eed2d 100644 --- a/web/app/components/workflow/nodes/tool/default.ts +++ b/web/app/components/workflow/nodes/tool/default.ts @@ -10,6 +10,7 @@ const nodeDefault: NodeDefault = { defaultValue: { tool_parameters: {}, tool_configurations: {}, + version: '2', }, getAvailablePrevNodes(isChatMode: boolean) { const nodes = isChatMode @@ -55,6 +56,8 @@ const nodeDefault: NodeDefault = { const value = payload.tool_configurations[field.variable] if (!errorMessages && (value === undefined || value === null || value === '')) errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: field.label[language] }) + if (!errorMessages && typeof value === 'object' && !!value.type && (value.value === undefined || value.value === null || value.value === '' || (Array.isArray(value.value) && value.value.length === 0))) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: field.label[language] }) }) } diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index f3cb4d9fae..e15ddcaaaa 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -21,14 +21,14 @@ const Node: FC> = ({
{key}
- {typeof tool_configurations[key] === 'string' && ( + {typeof tool_configurations[key].value === 'string' && (
- {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key]} + {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value}
)} - {typeof tool_configurations[key] === 'number' && ( + {typeof tool_configurations[key].value === 'number' && (
- {tool_configurations[key]} + {tool_configurations[key].value}
)} {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( @@ -36,11 +36,6 @@ const Node: FC> = ({ {tool_configurations[key].model}
)} - {/* {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.appSelector && ( -
- {tool_configurations[key].app_id} -
- )} */}
))} diff --git a/web/app/components/workflow/nodes/tool/panel.tsx b/web/app/components/workflow/nodes/tool/panel.tsx index 038159870e..936f730a46 100644 --- a/web/app/components/workflow/nodes/tool/panel.tsx +++ b/web/app/components/workflow/nodes/tool/panel.tsx @@ -4,11 +4,10 @@ import { useTranslation } from 'react-i18next' import Split from '../_base/components/split' import type { ToolNodeType } from './types' import useConfig from './use-config' -import InputVarList from './components/input-var-list' +import ToolForm from './components/tool-form' import Button from '@/app/components/base/button' import Field from '@/app/components/workflow/nodes/_base/components/field' import type { NodePanelProps } from '@/app/components/workflow/types' -import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import Loading from '@/app/components/base/loading' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' @@ -28,8 +27,6 @@ const Panel: FC> = ({ inputs, toolInputVarSchema, setInputVar, - handleOnVarOpen, - filterVar, toolSettingSchema, toolSettingValue, setToolSettingValue, @@ -45,6 +42,8 @@ const Panel: FC> = ({ currTool, } = useConfig(id, data) + const [collapsed, setCollapsed] = React.useState(false) + if (isLoading) { return
@@ -66,21 +65,19 @@ const Panel: FC> = ({
)} - {!isShowAuthBtn && <> -
+ {!isShowAuthBtn && ( +
{toolInputVarSchema.length > 0 && ( - @@ -88,24 +85,29 @@ const Panel: FC> = ({ )} {toolInputVarSchema.length > 0 && toolSettingSchema.length > 0 && ( - + )} -
+ {toolSettingSchema.length > 0 && ( + <> + + + + + + )}
- } + )} {showSetAuth && ( output_schema: Record paramSchemas?: Record[] + version?: string } diff --git a/web/app/components/workflow/nodes/tool/use-config.ts b/web/app/components/workflow/nodes/tool/use-config.ts index b83ae8a07f..ea8d0e21ca 100644 --- a/web/app/components/workflow/nodes/tool/use-config.ts +++ b/web/app/components/workflow/nodes/tool/use-config.ts @@ -8,10 +8,12 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { CollectionType } from '@/app/components/tools/types' import { updateBuiltInToolCredential } from '@/service/tools' -import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import { + getConfiguredValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' import Toast from '@/app/components/base/toast' -import { VarType as VarVarType } from '@/app/components/workflow/types' -import type { InputVar, Var } from '@/app/components/workflow/types' +import type { InputVar } from '@/app/components/workflow/types' import { useFetchToolsData, useNodesReadOnly, @@ -26,17 +28,18 @@ const useConfig = (id: string, payload: ToolNodeType) => { const language = useLanguage() const { inputs, setInputs: doSetInputs } = useNodeCrud(id, payload) /* - * tool_configurations: tool setting, not dynamic setting - * tool_parameters: tool dynamic setting(by user) + * tool_configurations: tool setting, not dynamic setting (form type = form) + * tool_parameters: tool dynamic setting(form type = llm) * output_schema: tool dynamic output */ - const { provider_id, provider_type, tool_name, tool_configurations, output_schema } = inputs + const { provider_id, provider_type, tool_name, tool_configurations, output_schema, tool_parameters } = inputs const isBuiltIn = provider_type === CollectionType.builtIn const buildInTools = useStore(s => s.buildInTools) const customTools = useStore(s => s.customTools) const workflowTools = useStore(s => s.workflowTools) + const mcpTools = useStore(s => s.mcpTools) - const currentTools = (() => { + const currentTools = useMemo(() => { switch (provider_type) { case CollectionType.builtIn: return buildInTools @@ -44,10 +47,12 @@ const useConfig = (id: string, payload: ToolNodeType) => { return customTools case CollectionType.workflow: return workflowTools + case CollectionType.mcp: + return mcpTools default: return [] } - })() + }, [buildInTools, customTools, mcpTools, provider_type, workflowTools]) const currCollection = currentTools.find(item => canFindTool(item.id, provider_id)) // Auth @@ -91,10 +96,10 @@ const useConfig = (id: string, payload: ToolNodeType) => { const value = newConfig[key] if (schema?.type === 'boolean') { if (typeof value === 'string') - newConfig[key] = Number.parseInt(value, 10) + newConfig[key] = value === 'true' || value === '1' - if (typeof value === 'boolean') - newConfig[key] = value ? 1 : 0 + if (typeof value === 'number') + newConfig[key] = value === 1 } if (schema?.type === 'number-input') { @@ -107,12 +112,11 @@ const useConfig = (id: string, payload: ToolNodeType) => { doSetInputs(newInputs) }, [doSetInputs, formSchemas, hasShouldTransferTypeSettingInput]) const [notSetDefaultValue, setNotSetDefaultValue] = useState(false) - const toolSettingValue = (() => { + const toolSettingValue = useMemo(() => { if (notSetDefaultValue) return tool_configurations - - return addDefaultValue(tool_configurations, toolSettingSchema) - })() + return getConfiguredValue(tool_configurations, toolSettingSchema) + }, [notSetDefaultValue, toolSettingSchema, tool_configurations]) const setToolSettingValue = useCallback((value: Record) => { setNotSetDefaultValue(true) setInputs({ @@ -121,16 +125,20 @@ const useConfig = (id: string, payload: ToolNodeType) => { }) }, [inputs, setInputs]) + const formattingParameters = () => { + const inputsWithDefaultValue = produce(inputs, (draft) => { + if (!draft.tool_configurations || Object.keys(draft.tool_configurations).length === 0) + draft.tool_configurations = getConfiguredValue(tool_configurations, toolSettingSchema) + if (!draft.tool_parameters || Object.keys(draft.tool_parameters).length === 0) + draft.tool_parameters = getConfiguredValue(tool_parameters, toolInputVarSchema) + }) + return inputsWithDefaultValue + } + useEffect(() => { if (!currTool) return - const inputsWithDefaultValue = produce(inputs, (draft) => { - if (!draft.tool_configurations || Object.keys(draft.tool_configurations).length === 0) - draft.tool_configurations = addDefaultValue(tool_configurations, toolSettingSchema) - - if (!draft.tool_parameters) - draft.tool_parameters = {} - }) + const inputsWithDefaultValue = formattingParameters() setInputs(inputsWithDefaultValue) // eslint-disable-next-line react-hooks/exhaustive-deps }, [currTool]) @@ -143,19 +151,6 @@ const useConfig = (id: string, payload: ToolNodeType) => { }) }, [inputs, setInputs]) - const [currVarIndex, setCurrVarIndex] = useState(-1) - const currVarType = toolInputVarSchema[currVarIndex]?._type - const handleOnVarOpen = useCallback((index: number) => { - setCurrVarIndex(index) - }, []) - - const filterVar = useCallback((varPayload: Var) => { - if (currVarType) - return varPayload.type === currVarType - - return varPayload.type !== VarVarType.arrayFile - }, [currVarType]) - const isLoading = currTool && (isBuiltIn ? !currCollection : false) const getMoreDataForCheckValid = () => { @@ -220,8 +215,6 @@ const useConfig = (id: string, payload: ToolNodeType) => { setToolSettingValue, toolInputVarSchema, setInputVar, - handleOnVarOpen, - filterVar, currCollection, isShowAuthBtn, showSetAuth, diff --git a/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts b/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts index 295cf02639..6fc79beebe 100644 --- a/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts @@ -34,7 +34,12 @@ const useSingleRunFormParams = ({ const hadVarParams = Object.keys(inputs.tool_parameters) .filter(key => inputs.tool_parameters[key].type !== VarType.constant) .map(k => inputs.tool_parameters[k]) - const varInputs = getInputVars(hadVarParams.map((p) => { + + const hadVarSettings = Object.keys(inputs.tool_configurations) + .filter(key => typeof inputs.tool_configurations[key] === 'object' && inputs.tool_configurations[key].type && inputs.tool_configurations[key].type !== VarType.constant) + .map(k => inputs.tool_configurations[k]) + + const varInputs = getInputVars([...hadVarParams, ...hadVarSettings].map((p) => { if (p.type === VarType.variable) { // handle the old wrong value not crash the page if (!(p.value as any).join) @@ -55,8 +60,11 @@ const useSingleRunFormParams = ({ const res = produce(inputVarValues, (draft) => { Object.keys(inputs.tool_parameters).forEach((key: string) => { const { type, value } = inputs.tool_parameters[key] - if (type === VarType.constant && (value === undefined || value === null)) + if (type === VarType.constant && (value === undefined || value === null)) { + if(!draft.tool_parameters || !draft.tool_parameters[key]) + return draft[key] = value + } }) }) return res diff --git a/web/app/components/workflow/store/workflow/tool-slice.ts b/web/app/components/workflow/store/workflow/tool-slice.ts index 2d54bbd925..d6d89abcf0 100644 --- a/web/app/components/workflow/store/workflow/tool-slice.ts +++ b/web/app/components/workflow/store/workflow/tool-slice.ts @@ -10,6 +10,8 @@ export type ToolSliceShape = { setCustomTools: (tools: ToolWithProvider[]) => void workflowTools: ToolWithProvider[] setWorkflowTools: (tools: ToolWithProvider[]) => void + mcpTools: ToolWithProvider[] + setMcpTools: (tools: ToolWithProvider[]) => void toolPublished: boolean setToolPublished: (toolPublished: boolean) => void } @@ -21,6 +23,8 @@ export const createToolSlice: StateCreator = set => ({ setCustomTools: customTools => set(() => ({ customTools })), workflowTools: [], setWorkflowTools: workflowTools => set(() => ({ workflowTools })), + mcpTools: [], + setMcpTools: mcpTools => set(() => ({ mcpTools })), toolPublished: false, setToolPublished: toolPublished => set(() => ({ toolPublished })), }) diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 3248ce798d..507d494c74 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -16,6 +16,7 @@ import type { } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import type { WorkflowRetryConfig } from '@/app/components/workflow/nodes/_base/components/retry/types' import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types' +import type { PluginMeta } from '../plugins/types' export enum BlockEnum { Start = 'start', @@ -410,6 +411,7 @@ export type MoreInfo = { export type ToolWithProvider = Collection & { tools: Tool[] + meta: PluginMeta } export enum SupportUploadFileTypes { diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index 93a61230ba..dc22d61ca5 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -28,6 +28,7 @@ import type { IfElseNodeType } from '../nodes/if-else/types' import { branchNameCorrect } from '../nodes/if-else/utils' import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' +import type { ToolNodeType } from '../nodes/tool/types' import { getIterationStartNode, getLoopStartNode, @@ -276,6 +277,7 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { if (node.data.type === BlockEnum.ParameterExtractor) (node as any).data.model.provider = correctModelProvider((node as any).data.model.provider) + if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) { node.data.retry_config = { retry_enabled: true, @@ -284,6 +286,24 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { } } + if (node.data.type === BlockEnum.Tool && !(node as Node).data.version) { + (node as Node).data.version = '2' + + const toolConfigurations = (node as Node).data.tool_configurations + if (toolConfigurations && Object.keys(toolConfigurations).length > 0) { + const newValues = { ...toolConfigurations } + Object.keys(toolConfigurations).forEach((key) => { + if (typeof toolConfigurations[key] !== 'object' || toolConfigurations[key] === null) { + newValues[key] = { + type: 'constant', + value: toolConfigurations[key], + } + } + }); + (node as Node).data.tool_configurations = newValues + } + } + return node }) } diff --git a/web/app/oauth-callback/page.tsx b/web/app/oauth-callback/page.tsx new file mode 100644 index 0000000000..38355f435e --- /dev/null +++ b/web/app/oauth-callback/page.tsx @@ -0,0 +1,10 @@ +'use client' +import { useOAuthCallback } from '@/hooks/use-oauth' + +const OAuthCallback = () => { + useOAuthCallback() + + return
+} + +export default OAuthCallback diff --git a/web/hooks/use-oauth.ts b/web/hooks/use-oauth.ts new file mode 100644 index 0000000000..ae9c1cda66 --- /dev/null +++ b/web/hooks/use-oauth.ts @@ -0,0 +1,36 @@ +'use client' +import { useEffect } from 'react' + +export const useOAuthCallback = () => { + useEffect(() => { + if (window.opener) { + window.opener.postMessage({ + type: 'oauth_callback', + }, '*') + window.close() + } + }, []) +} + +export const openOAuthPopup = (url: string, callback: () => void) => { + const width = 600 + const height = 600 + const left = window.screenX + (window.outerWidth - width) / 2 + const top = window.screenY + (window.outerHeight - height) / 2 + + const popup = window.open( + url, + 'OAuth', + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`, + ) + + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === 'oauth_callback') { + window.removeEventListener('message', handleMessage) + callback() + } + } + + window.addEventListener('message', handleMessage) + return popup +} diff --git a/web/i18n/de-DE/plugin.ts b/web/i18n/de-DE/plugin.ts index 05988dedf1..87f222be94 100644 --- a/web/i18n/de-DE/plugin.ts +++ b/web/i18n/de-DE/plugin.ts @@ -55,7 +55,7 @@ const translation = { unsupportedContent: 'Die installierte Plug-in-Version bietet diese Aktion nicht.', unsupportedTitle: 'Nicht unterstützte Aktion', descriptionPlaceholder: 'Kurze Beschreibung des Zwecks des Werkzeugs, z. B. um die Temperatur für einen bestimmten Ort zu ermitteln.', - auto: 'Automatisch', + auto: 'Auto', params: 'KONFIGURATION DER ARGUMENTATION', unsupportedContent2: 'Klicken Sie hier, um die Version zu wechseln.', placeholder: 'Wählen Sie ein Werkzeug aus...', diff --git a/web/i18n/de-DE/tools.ts b/web/i18n/de-DE/tools.ts index 2f3c24b9da..6e6eda85e0 100644 --- a/web/i18n/de-DE/tools.ts +++ b/web/i18n/de-DE/tools.ts @@ -137,21 +137,97 @@ const translation = { notAuthorized: 'Werkzeug nicht autorisiert', howToGet: 'Wie erhält man', addToolModal: { + type: 'Art', + category: 'Kategorie', + add: 'hinzufügen', added: 'zugefügt', manageInTools: 'Verwalten in Tools', - add: 'hinzufügen', - category: 'Kategorie', - emptyTitle: 'Kein Workflow-Tool verfügbar', - type: 'Art', - emptyTip: 'Gehen Sie zu "Workflow -> Als Tool veröffentlichen"', - emptyTitleCustom: 'Kein benutzerdefiniertes Tool verfügbar', - emptyTipCustom: 'Erstellen eines benutzerdefinierten Werkzeugs', + custom: { + title: 'Kein benutzerdefiniertes Werkzeug verfügbar', + tip: 'Benutzerdefiniertes Werkzeug erstellen', + }, + workflow: { + title: 'Kein Workflow-Werkzeug verfügbar', + tip: 'Veröffentlichen Sie Workflows als Werkzeuge im Studio', + }, + mcp: { + title: 'Kein MCP-Werkzeug verfügbar', + tip: 'Einen MCP-Server hinzufügen', + }, + agent: { + title: 'Keine Agentenstrategie verfügbar', + }, }, toolNameUsageTip: 'Name des Tool-Aufrufs für die Argumentation und Aufforderung des Agenten', customToolTip: 'Erfahren Sie mehr über benutzerdefinierte Dify-Tools', openInStudio: 'In Studio öffnen', noTools: 'Keine Werkzeuge gefunden', copyToolName: 'Name kopieren', + mcp: { + create: { + cardTitle: 'MCP-Server hinzufügen (HTTP)', + cardLink: 'Mehr über MCP-Server-Integration erfahren', + }, + noConfigured: 'Nicht konfigurierter Server', + updateTime: 'Aktualisiert', + toolsCount: '{{count}} Tools', + noTools: 'Keine Tools verfügbar', + modal: { + title: 'MCP-Server hinzufügen (HTTP)', + editTitle: 'MCP-Server bearbeiten (HTTP)', + name: 'Name & Symbol', + namePlaceholder: 'Benennen Sie Ihren MCP-Server', + serverUrl: 'Server-URL', + serverUrlPlaceholder: 'URL zum Server-Endpunkt', + serverUrlWarning: 'Das Ändern der Serveradresse kann Anwendungen unterbrechen, die von diesem Server abhängen', + serverIdentifier: 'Serverkennung', + serverIdentifierTip: 'Eindeutige Kennung für den MCP-Server im Arbeitsbereich. Nur Kleinbuchstaben, Zahlen, Unterstriche und Bindestriche. Maximal 24 Zeichen.', + serverIdentifierPlaceholder: 'Eindeutige Kennung, z.B. mein-mcp-server', + serverIdentifierWarning: 'Nach einer ID-Änderung wird der Server von vorhandenen Apps nicht erkannt', + cancel: 'Abbrechen', + save: 'Speichern', + confirm: 'Hinzufügen & Autorisieren', + }, + delete: 'MCP-Server entfernen', + deleteConfirmTitle: 'Möchten Sie {{mcp}} entfernen?', + operation: { + edit: 'Bearbeiten', + remove: 'Entfernen', + }, + authorize: 'Autorisieren', + authorizing: 'Wird autorisiert...', + authorizingRequired: 'Autorisierung erforderlich', + authorizeTip: 'Nach der Autorisierung werden Tools hier angezeigt.', + update: 'Aktualisieren', + updating: 'Wird aktualisiert', + gettingTools: 'Tools werden abgerufen...', + updateTools: 'Tools werden aktualisiert...', + toolsEmpty: 'Tools nicht geladen', + getTools: 'Tools abrufen', + toolUpdateConfirmTitle: 'Tool-Liste aktualisieren', + toolUpdateConfirmContent: 'Das Aktualisieren der Tool-Liste kann bestehende Apps beeinflussen. Fortfahren?', + toolsNum: '{{count}} Tools enthalten', + onlyTool: '1 Tool enthalten', + identifier: 'Serverkennung (Zum Kopieren klicken)', + server: { + title: 'MCP-Server', + url: 'Server-URL', + reGen: 'Server-URL neu generieren?', + addDescription: 'Beschreibung hinzufügen', + edit: 'Beschreibung bearbeiten', + modal: { + addTitle: 'Beschreibung hinzufügen, um MCP-Server zu aktivieren', + editTitle: 'Beschreibung bearbeiten', + description: 'Beschreibung', + descriptionPlaceholder: 'Erklären Sie, was dieses Tool tut und wie es vom LLM verwendet werden soll', + parameters: 'Parameter', + parametersTip: 'Fügen Sie Beschreibungen für jeden Parameter hinzu, um dem LLM Zweck und Einschränkungen zu verdeutlichen.', + parametersPlaceholder: 'Zweck und Einschränkungen des Parameters', + confirm: 'MCP-Server aktivieren', + }, + publishTip: 'App nicht veröffentlicht. Bitte zuerst die App veröffentlichen.', + }, + }, } export default translation diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index 9fba3a4714..cf9df89a6b 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -85,8 +85,8 @@ const translation = { settings: 'USER SETTINGS', params: 'REASONING CONFIG', paramsTip1: 'Controls LLM inference parameters.', - paramsTip2: 'When \'Automatic\' is off, the default value is used.', - auto: 'Automatic', + paramsTip2: 'When \'Auto\' is off, the default value is used.', + auto: 'Auto', empty: 'Click the \'+\' button to add tools. You can add multiple tools.', uninstalledTitle: 'Tool not installed', uninstalledContent: 'This plugin is installed from the local/GitHub repository. Please use after installation.', @@ -94,6 +94,7 @@ const translation = { unsupportedTitle: 'Unsupported Action', unsupportedContent: 'The installed plugin version does not provide this action.', unsupportedContent2: 'Click to switch version.', + unsupportedMCPTool: 'Currently selected agent strategy plugin version does not support MCP tools.', }, configureApp: 'Configure App', configureModel: 'Configure model', diff --git a/web/i18n/en-US/tools.ts b/web/i18n/en-US/tools.ts index 433e98720a..418d1cb076 100644 --- a/web/i18n/en-US/tools.ts +++ b/web/i18n/en-US/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'add', added: 'added', manageInTools: 'Manage in Tools', - emptyTitle: 'No workflow tool available', - emptyTip: 'Go to "Workflow -> Publish as Tool"', - emptyTitleCustom: 'No custom tool available', - emptyTipCustom: 'Create a custom tool', + custom: { + title: 'No custom tool available', + tip: 'Create a custom tool', + }, + workflow: { + title: 'No workflow tool available', + tip: 'Publish workflows as tools in Studio', + }, + mcp: { + title: 'No MCP tool available', + tip: 'Add an MCP server', + }, + agent: { + title: 'No agent strategy available', + }, }, createTool: { title: 'Create Custom Tool', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'Tool call name for agent reasoning and prompting', copyToolName: 'Copy Name', noTools: 'No tools found', + mcp: { + create: { + cardTitle: 'Add MCP Server (HTTP)', + cardLink: 'Learn more about MCP server integration', + }, + noConfigured: 'Unconfigured', + updateTime: 'Updated', + toolsCount: '{{count}} tools', + noTools: 'No tools available', + modal: { + title: 'Add MCP Server (HTTP)', + editTitle: 'Edit MCP Server (HTTP)', + name: 'Name & Icon', + namePlaceholder: 'Name your MCP server', + serverUrl: 'Server URL', + serverUrlPlaceholder: 'URL to server endpoint', + serverUrlWarning: 'Updating the server address may disrupt applications that depend on this server', + serverIdentifier: 'Server Identifier', + serverIdentifierTip: 'Unique identifier for the MCP server within the workspace. Lowercase letters, numbers, underscores, and hyphens only. Up to 24 characters.', + serverIdentifierPlaceholder: 'Unique identifier, e.g., my-mcp-server', + serverIdentifierWarning: 'The server won’t be recognized by existing apps after an ID change', + cancel: 'Cancel', + save: 'Save', + confirm: 'Add & Authorize', + }, + delete: 'Remove MCP Server', + deleteConfirmTitle: 'Would you like to remove {{mcp}}?', + operation: { + edit: 'Edit', + remove: 'Remove', + }, + authorize: 'Authorize', + authorizing: 'Authorizing...', + authorizingRequired: 'Authorization is required', + authorizeTip: 'After authorization, tools will be displayed here.', + update: 'Update', + updating: 'Updating', + gettingTools: 'Getting Tools...', + updateTools: 'Updating Tools...', + toolsEmpty: 'Tools not loaded', + getTools: 'Get tools', + toolUpdateConfirmTitle: 'Update Tool List', + toolUpdateConfirmContent: 'Updating the tool list may affect existing apps. Do you wish to proceed?', + toolsNum: '{{count}} tools included', + onlyTool: '1 tool included', + identifier: 'Server Identifier (Click to Copy)', + server: { + title: 'MCP Server', + url: 'Server URL', + reGen: 'Do you want to regenerator server URL?', + addDescription: 'Add description', + edit: 'Edit description', + modal: { + addTitle: 'Add description to enable MCP server', + editTitle: 'Edit description', + description: 'Description', + descriptionPlaceholder: 'Explain what this tool does and how it should be used by the LLM', + parameters: 'Parameters', + parametersTip: 'Add descriptions for each parameter to help the LLM understand their purpose and constraints.', + parametersPlaceholder: 'Parameter purpose and constraints', + confirm: 'Enable MCP Server', + }, + publishTip: 'App not published. Please publish the app first.', + }, + }, } export default translation diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 88873fb27e..c56b497ac2 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -231,6 +231,8 @@ const translation = { 'utilities': 'Utilities', 'noResult': 'No match found', 'agent': 'Agent Strategy', + 'allAdded': 'All added', + 'addAll': 'Add all', }, blocks: { 'start': 'Start', @@ -368,6 +370,10 @@ const translation = { ms: 'ms', retries: '{{num}} Retries', }, + typeSwitch: { + input: 'Input value', + variable: 'Use variable', + }, }, start: { required: 'required', @@ -662,6 +668,9 @@ const translation = { tool: { authorize: 'Authorize', inputVars: 'Input Variables', + settings: 'Settings', + insertPlaceholder1: 'Type or press', + insertPlaceholder2: 'insert variable', outputVars: { text: 'tool generated content', files: { @@ -889,6 +898,8 @@ const translation = { install: 'Install', cancel: 'Cancel', }, + clickToViewParameterSchema: 'Click to view parameter schema', + parameterSchema: 'Parameter Schema', }, }, tracing: { diff --git a/web/i18n/es-ES/plugin.ts b/web/i18n/es-ES/plugin.ts index d8250c6b41..84e317add6 100644 --- a/web/i18n/es-ES/plugin.ts +++ b/web/i18n/es-ES/plugin.ts @@ -51,11 +51,11 @@ const translation = { unsupportedContent2: 'Haga clic para cambiar de versión.', descriptionPlaceholder: 'Breve descripción del propósito de la herramienta, por ejemplo, obtener la temperatura para una ubicación específica.', empty: 'Haga clic en el botón \'+\' para agregar herramientas. Puede agregar varias herramientas.', - paramsTip2: 'Cuando \'Automático\' está desactivado, se utiliza el valor predeterminado.', + paramsTip2: 'Cuando \'Auto\' está desactivado, se utiliza el valor predeterminado.', uninstalledTitle: 'Herramienta no instalada', descriptionLabel: 'Descripción de la herramienta', unsupportedContent: 'La versión del plugin instalado no proporciona esta acción.', - auto: 'Automático', + auto: 'Auto', title: 'Agregar herramienta', placeholder: 'Seleccione una herramienta...', uninstalledContent: 'Este plugin se instala desde el repositorio local/GitHub. Úselo después de la instalación.', diff --git a/web/i18n/es-ES/tools.ts b/web/i18n/es-ES/tools.ts index fd37eef5b1..b503f9c41b 100644 --- a/web/i18n/es-ES/tools.ts +++ b/web/i18n/es-ES/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'agregar', added: 'agregada', manageInTools: 'Administrar en Herramientas', - emptyTitle: 'No hay herramientas de flujo de trabajo disponibles', - emptyTip: 'Ir a "Flujo de Trabajo -> Publicar como Herramienta"', - emptyTitleCustom: 'No hay herramienta personalizada disponible', - emptyTipCustom: 'Crear una herramienta personalizada', + custom: { + title: 'No hay herramienta personalizada disponible', + tip: 'Crear una herramienta personalizada', + }, + workflow: { + title: 'No hay herramienta de flujo de trabajo disponible', + tip: 'Publicar flujos de trabajo como herramientas en el Estudio', + }, + mcp: { + title: 'No hay herramienta MCP disponible', + tip: 'Añadir un servidor MCP', + }, + agent: { + title: 'No hay estrategia de agente disponible', + }, }, createTool: { title: 'Crear Herramienta Personalizada', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'Nombre de llamada de la herramienta para razonamiento y promoción de agentes', copyToolName: 'Nombre de la copia', noTools: 'No se han encontrado herramientas', + mcp: { + create: { + cardTitle: 'Añadir servidor MCP (HTTP)', + cardLink: 'Más información sobre integración de servidores MCP', + }, + noConfigured: 'Servidor no configurado', + updateTime: 'Actualizado', + toolsCount: '{{count}} herramientas', + noTools: 'No hay herramientas disponibles', + modal: { + title: 'Añadir servidor MCP (HTTP)', + editTitle: 'Editar servidor MCP (HTTP)', + name: 'Nombre e Icono', + namePlaceholder: 'Nombre de su servidor MCP', + serverUrl: 'URL del servidor', + serverUrlPlaceholder: 'URL del endpoint del servidor', + serverUrlWarning: 'Actualizar la dirección del servidor puede interrumpir aplicaciones que dependan de él', + serverIdentifier: 'Identificador del servidor', + serverIdentifierTip: 'Identificador único del servidor MCP en el espacio de trabajo. Solo letras minúsculas, números, guiones bajos y guiones. Máximo 24 caracteres.', + serverIdentifierPlaceholder: 'Identificador único, ej. mi-servidor-mcp', + serverIdentifierWarning: 'El servidor no será reconocido por aplicaciones existentes tras cambiar la ID', + cancel: 'Cancelar', + save: 'Guardar', + confirm: 'Añadir y Autorizar', + }, + delete: 'Eliminar servidor MCP', + deleteConfirmTitle: '¿Eliminar {{mcp}}?', + operation: { + edit: 'Editar', + remove: 'Eliminar', + }, + authorize: 'Autorizar', + authorizing: 'Autorizando...', + authorizingRequired: 'Se requiere autorización', + authorizeTip: 'Tras la autorización, las herramientas se mostrarán aquí.', + update: 'Actualizar', + updating: 'Actualizando', + gettingTools: 'Obteniendo herramientas...', + updateTools: 'Actualizando herramientas...', + toolsEmpty: 'Herramientas no cargadas', + getTools: 'Obtener herramientas', + toolUpdateConfirmTitle: 'Actualizar lista de herramientas', + toolUpdateConfirmContent: 'Actualizar la lista puede afectar a aplicaciones existentes. ¿Continuar?', + toolsNum: '{{count}} herramientas incluidas', + onlyTool: '1 herramienta incluida', + identifier: 'Identificador del servidor (Haz clic para copiar)', + server: { + title: 'Servidor MCP', + url: 'URL del servidor', + reGen: '¿Regenerar URL del servidor?', + addDescription: 'Añadir descripción', + edit: 'Editar descripción', + modal: { + addTitle: 'Añade descripción para habilitar el servidor MCP', + editTitle: 'Editar descripción', + description: 'Descripción', + descriptionPlaceholder: 'Explica qué hace esta herramienta y cómo debe usarla el LLM', + parameters: 'Parámetros', + parametersTip: 'Añade descripciones de cada parámetro para ayudar al LLM a entender su propósito y restricciones.', + parametersPlaceholder: 'Propósito y restricciones del parámetro', + confirm: 'Habilitar servidor MCP', + }, + publishTip: 'App no publicada. Publícala primero.', + }, + }, } export default translation diff --git a/web/i18n/fa-IR/tools.ts b/web/i18n/fa-IR/tools.ts index fddfd2d826..942bde7932 100644 --- a/web/i18n/fa-IR/tools.ts +++ b/web/i18n/fa-IR/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'افزودن', added: 'افزوده شد', manageInTools: 'مدیریت در ابزارها', - emptyTitle: 'هیچ ابزار جریان کاری در دسترس نیست', - emptyTip: 'به "جریان کاری -> انتشار به عنوان ابزار" بروید', - emptyTipCustom: 'ایجاد یک ابزار سفارشی', - emptyTitleCustom: 'هیچ ابزار سفارشی در دسترس نیست', + custom: { + title: 'هیچ ابزار سفارشی موجود نیست', + tip: 'یک ابزار سفارشی ایجاد کنید', + }, + workflow: { + title: 'هیچ ابزار جریان کاری موجود نیست', + tip: 'جریان‌های کاری را به عنوان ابزار در استودیو منتشر کنید', + }, + mcp: { + title: 'هیچ ابزار MCP موجود نیست', + tip: 'یک سرور MCP اضافه کنید', + }, + agent: { + title: 'هیچ استراتژی عاملی موجود نیست', + }, }, createTool: { title: 'ایجاد ابزار سفارشی', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'نام فراخوانی ابزار برای استدلال و پرامپت‌های عامل', copyToolName: 'کپی نام', noTools: 'هیچ ابزاری یافت نشد', + mcp: { + create: { + cardTitle: 'افزودن سرور MCP (HTTP)', + cardLink: 'اطلاعات بیشتر درباره یکپارچه‌سازی سرور MCP', + }, + noConfigured: 'سرور پیکربندی نشده', + updateTime: 'آخرین بروزرسانی', + toolsCount: '{count} ابزار', + noTools: 'ابزاری موجود نیست', + modal: { + title: 'افزودن سرور MCP (HTTP)', + editTitle: 'ویرایش سرور MCP (HTTP)', + name: 'نام و آیکون', + namePlaceholder: 'برای سرور MCP خود نام انتخاب کنید', + serverUrl: 'آدرس سرور', + serverUrlPlaceholder: 'URL نقطه پایانی سرور', + serverUrlWarning: 'به‌روزرسانی آدرس سرور ممکن است برنامه‌های وابسته به آن را مختل کند', + serverIdentifier: 'شناسه سرور', + serverIdentifierTip: 'شناسه منحصر به فرد برای سرور MCP در فضای کاری. فقط حروف کوچک، اعداد، زیرخط و خط تیره. حداکثر 24 کاراکتر.', + serverIdentifierPlaceholder: 'شناسه منحصر به فرد، مثال: my-mcp-server', + serverIdentifierWarning: 'پس از تغییر شناسه، سرور توسط برنامه‌های موجود شناسایی نخواهد شد', + cancel: 'لغو', + save: 'ذخیره', + confirm: 'افزودن و مجوزدهی', + }, + delete: 'حذف سرور MCP', + deleteConfirmTitle: 'آیا مایل به حذف {mcp} هستید؟', + operation: { + edit: 'ویرایش', + remove: 'حذف', + }, + authorize: 'مجوزدهی', + authorizing: 'در حال مجوزدهی...', + authorizingRequired: 'مجوز مورد نیاز است', + authorizeTip: 'پس از مجوزدهی، ابزارها در اینجا نمایش داده می‌شوند.', + update: 'به‌روزرسانی', + updating: 'در حال به‌روزرسانی...', + gettingTools: 'دریافت ابزارها...', + updateTools: 'به‌روزرسانی ابزارها...', + toolsEmpty: 'ابزارها بارگیری نشدند', + getTools: 'دریافت ابزارها', + toolUpdateConfirmTitle: 'به‌روزرسانی فهرست ابزارها', + toolUpdateConfirmContent: 'به‌روزرسانی فهرست ابزارها ممکن است بر برنامه‌های موجود تأثیر بگذارد. آیا ادامه می‌دهید؟', + toolsNum: '{count} ابزار موجود است', + onlyTool: '1 ابزار موجود است', + identifier: 'شناسه سرور (کلیک برای کپی)', + server: { + title: 'سرور MCP', + url: 'آدرس سرور', + reGen: 'تولید مجدد آدرس سرور؟', + addDescription: 'افزودن توضیحات', + edit: 'ویرایش توضیحات', + modal: { + addTitle: 'برای فعال‌سازی سرور MCP توضیحات اضافه کنید', + editTitle: 'ویرایش توضیحات', + description: 'توضیحات', + descriptionPlaceholder: 'عملکرد این ابزار و نحوه استفاده LLM از آن را توضیح دهید', + parameters: 'پارامترها', + parametersTip: 'برای کمک به LLM در درک هدف و محدودیت‌ها، برای هر پارامتر توضیحات اضافه کنید.', + parametersPlaceholder: 'هدف و محدودیت‌های پارامتر', + confirm: 'فعال‌سازی سرور MCP', + }, + publishTip: 'برنامه منتشر نشده است. لطفاً ابتدا برنامه را منتشر کنید.', + }, + }, } export default translation diff --git a/web/i18n/fr-FR/plugin.ts b/web/i18n/fr-FR/plugin.ts index 35d36b425a..60366e28cf 100644 --- a/web/i18n/fr-FR/plugin.ts +++ b/web/i18n/fr-FR/plugin.ts @@ -53,14 +53,14 @@ const translation = { placeholder: 'Sélectionnez un outil...', params: 'CONFIGURATION DE RAISONNEMENT', unsupportedContent: 'La version du plugin installée ne fournit pas cette action.', - auto: 'Automatique', + auto: 'Auto', descriptionPlaceholder: 'Brève description de l’objectif de l’outil, par exemple, obtenir la température d’un endroit spécifique.', unsupportedContent2: 'Cliquez pour changer de version.', uninstalledTitle: 'Outil non installé', empty: 'Cliquez sur le bouton « + » pour ajouter des outils. Vous pouvez ajouter plusieurs outils.', toolLabel: 'Outil', settings: 'PARAMÈTRES UTILISATEUR', - paramsTip2: 'Lorsque « Automatique » est désactivé, la valeur par défaut est utilisée.', + paramsTip2: 'Lorsque « Auto » est désactivé, la valeur par défaut est utilisée.', paramsTip1: 'Contrôle les paramètres d’inférence LLM.', toolSetting: 'Paramètres de l\'outil', }, diff --git a/web/i18n/fr-FR/tools.ts b/web/i18n/fr-FR/tools.ts index 8f2362daf1..fdbe213df8 100644 --- a/web/i18n/fr-FR/tools.ts +++ b/web/i18n/fr-FR/tools.ts @@ -138,20 +138,96 @@ const translation = { howToGet: 'Comment obtenir', addToolModal: { type: 'type', - emptyTitle: 'Aucun outil de flux de travail disponible', added: 'supplémentaire', add: 'ajouter', category: 'catégorie', manageInTools: 'Gérer dans Outils', - emptyTip: 'Allez dans « Flux de travail -> Publier en tant qu’outil »', - emptyTitleCustom: 'Aucun outil personnalisé disponible', - emptyTipCustom: 'Créer un outil personnalisé', + custom: { + title: 'Aucun outil personnalisé disponible', + tip: 'Créer un outil personnalisé', + }, + workflow: { + title: 'Aucun outil de workflow disponible', + tip: 'Publier des workflows en tant qu\'outils dans le Studio', + }, + mcp: { + title: 'Aucun outil MCP disponible', + tip: 'Ajouter un serveur MCP', + }, + agent: { + title: 'Aucune stratégie d\'agent disponible', + }, }, openInStudio: 'Ouvrir dans Studio', customToolTip: 'En savoir plus sur les outils personnalisés Dify', toolNameUsageTip: 'Nom de l’appel de l’outil pour le raisonnement et l’invite de l’agent', copyToolName: 'Copier le nom', noTools: 'Aucun outil trouvé', + mcp: { + create: { + cardTitle: 'Ajouter un Serveur MCP (HTTP)', + cardLink: 'En savoir plus sur l\'intégration du serveur MCP', + }, + noConfigured: 'Serveur Non Configuré', + updateTime: 'Mis à jour', + toolsCount: '{count} outils', + noTools: 'Aucun outil disponible', + modal: { + title: 'Ajouter un Serveur MCP (HTTP)', + editTitle: 'Modifier le Serveur MCP (HTTP)', + name: 'Nom & Icône', + namePlaceholder: 'Nommez votre serveur MCP', + serverUrl: 'URL du Serveur', + serverUrlPlaceholder: 'URL de l\'endpoint du serveur', + serverUrlWarning: 'Mettre à jour l\'adresse du serveur peut perturber les applications qui dépendent de ce serveur', + serverIdentifier: 'Identifiant du Serveur', + serverIdentifierTip: 'Identifiant unique pour le serveur MCP au sein de l\'espace de travail. Seulement des lettres minuscules, des chiffres, des traits de soulignement et des tirets. Jusqu\'à 24 caractères.', + serverIdentifierPlaceholder: 'Identifiant unique, par ex. mon-serveur-mcp', + serverIdentifierWarning: 'Le serveur ne sera pas reconnu par les applications existantes après un changement d\'ID', + cancel: 'Annuler', + save: 'Enregistrer', + confirm: 'Ajouter & Authoriser', + }, + delete: 'Supprimer le Serveur MCP', + deleteConfirmTitle: 'Souhaitez-vous supprimer {mcp}?', + operation: { + edit: 'Modifier', + remove: 'Supprimer', + }, + authorize: 'Autoriser', + authorizing: 'Autorisation en cours...', + authorizingRequired: 'L\'autorisation est requise', + authorizeTip: 'Après autorisation, les outils seront affichés ici.', + update: 'Mettre à jour', + updating: 'Mise à jour en cours', + gettingTools: 'Obtention des Outils...', + updateTools: 'Mise à jour des Outils...', + toolsEmpty: 'Outils non chargés', + getTools: 'Obtenir des outils', + toolUpdateConfirmTitle: 'Mettre à jour la Liste des Outils', + toolUpdateConfirmContent: 'La mise à jour de la liste des outils peut affecter les applications existantes. Souhaitez-vous continuer?', + toolsNum: '{count} outils inclus', + onlyTool: '1 outil inclus', + identifier: 'Identifiant du Serveur (Cliquez pour Copier)', + server: { + title: 'Serveur MCP', + url: 'URL du Serveur', + reGen: 'Voulez-vous régénérer l\'URL du serveur?', + addDescription: 'Ajouter une description', + edit: 'Modifier la description', + modal: { + addTitle: 'Ajouter une description pour activer le serveur MCP', + editTitle: 'Modifier la description', + description: 'Description', + descriptionPlaceholder: 'Expliquez ce que fait cet outil et comment il doit être utilisé par le LLM', + parameters: 'Paramètres', + parametersTip: 'Ajoutez des descriptions pour chaque paramètre afin d\'aider le LLM à comprendre leur objectif et leurs contraintes.', + parametersPlaceholder: 'Objectif et contraintes du paramètre', + confirm: 'Activer le Serveur MCP', + }, + publishTip: 'Application non publiée. Merci de publier l\'application en premier.', + }, + }, } export default translation diff --git a/web/i18n/hi-IN/tools.ts b/web/i18n/hi-IN/tools.ts index 105e7e5fa6..8f721da44e 100644 --- a/web/i18n/hi-IN/tools.ts +++ b/web/i18n/hi-IN/tools.ts @@ -29,10 +29,21 @@ const translation = { add: 'जोड़ें', added: 'जोड़ा गया', manageInTools: 'उपकरणों में प्रबंधित करें', - emptyTitle: 'कोई कार्यप्रवाह उपकरण उपलब्ध नहीं', - emptyTip: 'कार्यप्रवाह -> उपकरण के रूप में प्रकाशित पर जाएं', - emptyTipCustom: 'एक कस्टम टूल बनाएं', - emptyTitleCustom: 'कोई कस्टम टूल उपलब्ध नहीं है', + custom: { + title: 'कोई कस्टम टूल उपलब्ध नहीं है', + tip: 'एक कस्टम टूल बनाएं', + }, + workflow: { + title: 'कोई वर्कफ़्लो टूल उपलब्ध नहीं है', + tip: 'स्टूडियो में टूल के रूप में वर्कफ़्लो प्रकाशित करें', + }, + mcp: { + title: 'कोई MCP टूल उपलब्ध नहीं है', + tip: 'एक MCP सर्वर जोड़ें', + }, + agent: { + title: 'कोई एजेंट रणनीति उपलब्ध नहीं है', + }, }, createTool: { title: 'कस्टम उपकरण बनाएं', @@ -157,6 +168,71 @@ const translation = { toolNameUsageTip: 'एजेंट तर्क और प्रेरण के लिए उपकरण कॉल नाम', noTools: 'कोई उपकरण नहीं मिला', copyToolName: 'नाम कॉपी करें', + mcp: { + create: { + cardTitle: 'MCP सर्वर जोड़ें (HTTP)', + cardLink: 'MCP सर्वर एकीकरण के बारे में अधिक जानें', + }, + noConfigured: 'कॉन्फ़िगर न किया गया सर्वर', + updateTime: 'अपडेट किया गया', + toolsCount: '{count} टूल्स', + noTools: 'कोई टूल उपलब्ध नहीं', + modal: { + title: 'MCP सर्वर जोड़ें (HTTP)', + editTitle: 'MCP सर्वर संपादित करें (HTTP)', + name: 'नाम और आइकन', + namePlaceholder: 'अपने MCP सर्वर को नाम दें', + serverUrl: 'सर्वर URL', + serverUrlPlaceholder: 'सर्वर एंडपॉइंट का URL', + serverUrlWarning: 'सर्वर पता अपडेट करने से इस सर्वर पर निर्भर एप्लिकेशन बाधित हो सकते हैं', + serverIdentifier: 'सर्वर आईडेंटिफ़ायर', + serverIdentifierTip: 'वर्कस्पेस में MCP सर्वर के लिए अद्वितीय आईडेंटिफ़ायर। केवल लोअरकेस अक्षर, संख्याएँ, अंडरस्कोर और हाइफ़न। अधिकतम 24 वर्ण।', + serverIdentifierPlaceholder: 'अद्वितीय आईडेंटिफ़ायर, उदा. my-mcp-server', + serverIdentifierWarning: 'आईडी बदलने के बाद सर्वर को मौजूदा ऐप्स द्वारा पहचाना नहीं जाएगा', + cancel: 'रद्द करें', + save: 'सहेजें', + confirm: 'जोड़ें और अधिकृत करें', + }, + delete: 'MCP सर्वर हटाएँ', + deleteConfirmTitle: '{mcp} हटाना चाहते हैं?', + operation: { + edit: 'संपादित करें', + remove: 'हटाएँ', + }, + authorize: 'अधिकृत करें', + authorizing: 'अधिकृत किया जा रहा है...', + authorizingRequired: 'प्राधिकरण आवश्यक है', + authorizeTip: 'अधिकृत होने के बाद, टूल यहाँ प्रदर्शित होंगे।', + update: 'अपडेट करें', + updating: 'अपडेट हो रहा है...', + gettingTools: 'टूल्स प्राप्त किए जा रहे हैं...', + updateTools: 'टूल्स अपडेट किए जा रहे हैं...', + toolsEmpty: 'टूल्स लोड नहीं हुए', + getTools: 'टूल्स प्राप्त करें', + toolUpdateConfirmTitle: 'टूल सूची अपडेट करें', + toolUpdateConfirmContent: 'टूल सूची अपडेट करने से मौजूदा ऐप्स प्रभावित हो सकते हैं। आगे बढ़ना चाहते हैं?', + toolsNum: '{count} टूल्स शामिल', + onlyTool: '1 टूल शामिल', + identifier: 'सर्वर आईडेंटिफ़ायर (कॉपी करने के लिए क्लिक करें)', + server: { + title: 'MCP सर्वर', + url: 'सर्वर URL', + reGen: 'सर्वर URL पुनः उत्पन्न करना चाहते हैं?', + addDescription: 'विवरण जोड़ें', + edit: 'विवरण संपादित करें', + modal: { + addTitle: 'MCP सर्वर सक्षम करने के लिए विवरण जोड़ें', + editTitle: 'विवरण संपादित करें', + description: 'विवरण', + descriptionPlaceholder: 'समझाएं कि यह टूल क्या करता है और LLM द्वारा इसका उपयोग कैसे किया जाना चाहिए', + parameters: 'पैरामीटर्स', + parametersTip: 'प्रत्येक पैरामीटर के लिए विवरण जोड़ें ताकि LLM को उनके उद्देश्य और बाधाओं को समझने में मदद मिले।', + parametersPlaceholder: 'पैरामीटर उद्देश्य और बाधाएँ', + confirm: 'MCP सर्वर सक्षम करें', + }, + publishTip: 'ऐप प्रकाशित नहीं हुआ। कृपया पहले ऐप प्रकाशित करें।', + }, + }, } export default translation diff --git a/web/i18n/it-IT/tools.ts b/web/i18n/it-IT/tools.ts index 3c89d3a749..8aa119b45a 100644 --- a/web/i18n/it-IT/tools.ts +++ b/web/i18n/it-IT/tools.ts @@ -29,10 +29,21 @@ const translation = { add: 'aggiungi', added: 'aggiunto', manageInTools: 'Gestisci in Strumenti', - emptyTitle: 'Nessun strumento di flusso di lavoro disponibile', - emptyTip: 'Vai a `Flusso di lavoro -> Pubblica come Strumento`', - emptyTitleCustom: 'Nessun attrezzo personalizzato disponibile', - emptyTipCustom: 'Creare uno strumento personalizzato', + custom: { + title: 'Nessuno strumento personalizzato disponibile', + tip: 'Crea uno strumento personalizzato', + }, + workflow: { + title: 'Nessuno strumento workflow disponibile', + tip: 'Pubblica i workflow come strumenti nello Studio', + }, + mcp: { + title: 'Nessuno strumento MCP disponibile', + tip: 'Aggiungi un server MCP', + }, + agent: { + title: 'Nessuna strategia agente disponibile', + }, }, createTool: { title: 'Crea Strumento Personalizzato', @@ -162,6 +173,71 @@ const translation = { 'Nome chiamata strumento per il ragionamento e il prompting dell\'agente', noTools: 'Nessun utensile trovato', copyToolName: 'Copia nome', + mcp: { + create: { + cardTitle: 'Aggiungi Server MCP (HTTP)', + cardLink: 'Scopri di più sull\'integrazione del server MCP', + }, + noConfigured: 'Server Non Configurato', + updateTime: 'Aggiornato', + toolsCount: '{count} strumenti', + noTools: 'Nessuno strumento disponibile', + modal: { + title: 'Aggiungi Server MCP (HTTP)', + editTitle: 'Modifica Server MCP (HTTP)', + name: 'Nome & Icona', + namePlaceholder: 'Dai un nome al tuo server MCP', + serverUrl: 'URL del Server', + serverUrlPlaceholder: 'URL dell\'endpoint del server', + serverUrlWarning: 'L\'aggiornamento dell\'indirizzo del server può interrompere le applicazioni che dipendono da questo server', + serverIdentifier: 'Identificatore del Server', + serverIdentifierTip: 'Identificatore unico per il server MCP all\'interno dello spazio di lavoro. Solo lettere minuscole, numeri, underscore e trattini. Fino a 24 caratteri.', + serverIdentifierPlaceholder: 'Identificatore unico, es. mio-server-mcp', + serverIdentifierWarning: 'Il server non sarà riconosciuto dalle app esistenti dopo una modifica dell\'ID', + cancel: 'Annulla', + save: 'Salva', + confirm: 'Aggiungi & Autorizza', + }, + delete: 'Rimuovi Server MCP', + deleteConfirmTitle: 'Vuoi rimuovere {mcp}?', + operation: { + edit: 'Modifica', + remove: 'Rimuovi', + }, + authorize: 'Autorizza', + authorizing: 'Autorizzando...', + authorizingRequired: 'Autorizzazione richiesta', + authorizeTip: 'Dopo l\'autorizzazione, gli strumenti verranno visualizzati qui.', + update: 'Aggiorna', + updating: 'Aggiornamento in corso', + gettingTools: 'Ottimizzando Strumenti...', + updateTools: 'Aggiornando Strumenti...', + toolsEmpty: 'Strumenti non caricati', + getTools: 'Ottieni strumenti', + toolUpdateConfirmTitle: 'Aggiorna Lista Strumenti', + toolUpdateConfirmContent: 'L\'aggiornamento della lista degli strumenti può influire sulle app esistenti. Vuoi procedere?', + toolsNum: '{count} strumenti inclusi', + onlyTool: '1 strumento incluso', + identifier: 'Identificatore del Server (Fai clic per Copiare)', + server: { + title: 'Server MCP', + url: 'URL del Server', + reGen: 'Vuoi rigenerare l\'URL del server?', + addDescription: 'Aggiungi descrizione', + edit: 'Modifica descrizione', + modal: { + addTitle: 'Aggiungi descrizione per abilitare il server MCP', + editTitle: 'Modifica descrizione', + description: 'Descrizione', + descriptionPlaceholder: 'Spiega cosa fa questo strumento e come dovrebbe essere utilizzato dal LLM', + parameters: 'Parametri', + parametersTip: 'Aggiungi descrizioni per ogni parametro per aiutare il LLM a comprendere il loro scopo e le loro restrizioni.', + parametersPlaceholder: 'Scopo e restrizioni del parametro', + confirm: 'Abilitare Server MCP', + }, + publishTip: 'App non pubblicata. Pubblica l\'app prima.', + }, + }, } export default translation diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index cf9dad95b3..d69cd4a6f5 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -28,10 +28,21 @@ const translation = { add: '追加', added: '追加済', manageInTools: 'ツールリストに移動して管理する', - emptyTitle: '利用可能なワークフローツールはありません', - emptyTip: '追加するには、「ワークフロー -> ツールとして公開」に移動する', - emptyTitleCustom: 'カスタムツールはありません', - emptyTipCustom: 'カスタムツールの作成', + custom: { + title: 'カスタムツールはありません', + tip: 'カスタムツールを作成する', + }, + workflow: { + title: '利用可能なワークフローツールはありません', + tip: 'スタジオでワークフローをツールに公開する', + }, + mcp: { + title: '利用可能なMCPツールはありません', + tip: 'MCPサーバーを追加する', + }, + agent: { + title: 'Agent strategy は利用できません', + }, }, createTool: { title: 'カスタムツールを作成する', @@ -152,6 +163,72 @@ const translation = { toolNameUsageTip: 'ツール呼び出し名、エージェントの推論とプロンプトの単語に使用されます', copyToolName: '名前をコピー', noTools: 'ツールが見つかりませんでした', + mcp: { + create: { + cardTitle: 'MCPサーバー(HTTP)を追加', + cardLink: 'MCPサーバー統合について詳しく知る', + }, + noConfigured: '未設定', + updateTime: '更新日時', + toolsCount: '{{count}} 個のツール', + noTools: '利用可能なツールはありません', + modal: { + title: 'MCPサーバー(HTTP)を追加', + editTitle: 'MCPサーバー(HTTP)を編集', + name: '名前とアイコン', + namePlaceholder: 'MCPサーバーの名前を入力', + serverUrl: 'サーバーURL', + serverUrlPlaceholder: 'サーバーエンドポイントのURLを入力', + serverUrlWarning: 'サーバーアドレスを更新すると、このサーバーに依存するアプリケーションに影響を与える可能性があります。', + serverIdentifier: 'サーバー識別子', + serverIdentifierTip: 'ワークスペース内でのMCPサーバーのユニーク識別子です。使用可能な文字は小文字、数字、アンダースコア、ハイフンで、最大24文字です。', + serverIdentifierPlaceholder: 'ユニーク識別子(例:my-mcp-server)', + serverIdentifierWarning: 'IDを変更すると、既存のアプリケーションではサーバーが認識できなくなります。', + cancel: 'キャンセル', + save: '保存', + confirm: '追加して承認', + }, + delete: 'MCPサーバーを削除', + deleteConfirmTitle: '{{mcp}} を削除しますか?', + operation: { + edit: '編集', + remove: '削除', + }, + authorize: '承認', + authorizing: '承認中...', + authorizingRequired: '承認が必要です。', + authorizeTip: '承認後、このページにツールが表示されるようになります。', + update: '更新', + updating: '更新中...', + gettingTools: 'ツール取得中...', + updateTools: 'ツール更新中...', + toolsEmpty: 'ツールが読み込まれていません', + getTools: 'ツールを取得', + toolUpdateConfirmTitle: 'ツールリストの更新', + toolUpdateConfirmContent: 'ツールリストを更新すると、既存のアプリケーションに重大な影響を与える可能性があります。続行しますか?', + toolsNum: '{{count}} 個のツールが含まれています', + onlyTool: '1つのツールが含まれています', + identifier: 'サーバー識別子(クリックしてコピー)', + server: { + title: 'MCPサーバー', + url: 'サーバーURL', + reGen: 'サーバーURLを再生成しますか?', + addDescription: '説明を追加', + edit: '説明を編集', + modal: { + addTitle: 'MCPサーバーを有効化するための説明を追加', + editTitle: '説明を編集', + description: '説明', + descriptionPlaceholder: 'このツールの機能とLLM(大規模言語モデル)での使用方法を説明してください。', + parameters: 'パラメータ', + parametersTip: '各パラメータの説明を追加して、LLMがその目的と制約を理解できるようにします。', + parametersPlaceholder: 'パラメータの目的と制約', + confirm: 'MCPサーバーを有効にする', + }, + publishTip: 'アプリが公開されていません。まずアプリを公開してください。', + }, + }, + } export default translation diff --git a/web/i18n/ko-KR/tools.ts b/web/i18n/ko-KR/tools.ts index 45c63b5f80..f660790265 100644 --- a/web/i18n/ko-KR/tools.ts +++ b/web/i18n/ko-KR/tools.ts @@ -28,10 +28,21 @@ const translation = { add: '추가', added: '추가됨', manageInTools: '도구에서 관리', - emptyTitle: '사용 가능한 워크플로우 도구 없음', - emptyTip: '"워크플로우 -> 도구로 등록하기"로 이동', - emptyTipCustom: '사용자 지정 도구 만들기', - emptyTitleCustom: '사용 가능한 사용자 지정 도구가 없습니다.', + custom: { + title: '사용자 정의 도구 없음', + tip: '사용자 정의 도구 생성', + }, + workflow: { + title: '워크플로우 도구 없음', + tip: '스튜디오에서 워크플로우를 도구로 게시', + }, + mcp: { + title: 'MCP 도구 없음', + tip: 'MCP 서버 추가', + }, + agent: { + title: '에이전트 전략 없음', + }, }, createTool: { title: '커스텀 도구 만들기', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'Agent 추리와 프롬프트를 위한 도구 호출 이름', noTools: '도구를 찾을 수 없습니다.', copyToolName: '이름 복사', + mcp: { + create: { + cardTitle: 'MCP 서버 추가 (HTTP)', + cardLink: 'MCP 서버 통합에 대해 자세히 알아보기', + }, + noConfigured: '구성되지 않은 서버', + updateTime: '업데이트됨', + toolsCount: '{count} 도구', + noTools: '사용 가능한 도구 없음', + modal: { + title: 'MCP 서버 추가 (HTTP)', + editTitle: 'MCP 서버 수정 (HTTP)', + name: '이름 및 아이콘', + namePlaceholder: 'MCP 서버 이름 지정', + serverUrl: '서버 URL', + serverUrlPlaceholder: '서버 엔드포인트 URL', + serverUrlWarning: '서버 주소를 업데이트하면 이 서버에 의존하는 응용 프로그램에 지장이 발생할 수 있습니다', + serverIdentifier: '서버 식별자', + serverIdentifierTip: '작업 공간 내에서 MCP 서버의 고유 식별자. 소문자, 숫자, 밑줄 및 하이픈만 사용 가능. 최대 24자.', + serverIdentifierPlaceholder: '고유 식별자, 예: my-mcp-server', + serverIdentifierWarning: 'ID 변경 후 기존 앱에서 서버를 인식하지 못합니다', + cancel: '취소', + save: '저장', + confirm: '추가 및 승인', + }, + delete: 'MCP 서버 제거', + deleteConfirmTitle: '{mcp}를 제거하시겠습니까?', + operation: { + edit: '편집', + remove: '제거', + }, + authorize: '권한 부여', + authorizing: '권한 부여 중...', + authorizingRequired: '권한이 필요합니다', + authorizeTip: '권한 부여 후 도구가 여기에 표시됩니다.', + update: '업데이트', + updating: '업데이트 중', + gettingTools: '도구 가져오는 중...', + updateTools: '도구 업데이트 중...', + toolsEmpty: '도구가 로드되지 않음', + getTools: '도구 가져오기', + toolUpdateConfirmTitle: '도구 목록 업데이트', + toolUpdateConfirmContent: '도구 목록을 업데이트하면 기존 앱에 영향을 줄 수 있습니다. 계속하시겠습니까?', + toolsNum: '{count} 도구가 포함됨', + onlyTool: '1개 도구 포함', + identifier: '서버 식별자 (클릭하여 복사)', + server: { + title: 'MCP 서버', + url: '서버 URL', + reGen: '서버 URL을 다시 생성하시겠습니까?', + addDescription: '설명 추가', + edit: '설명 수정', + modal: { + addTitle: 'MCP 서버를 활성화하기 위한 설명 추가', + editTitle: '설명 수정', + description: '설명', + descriptionPlaceholder: '이 도구가 수행하는 작업과 LLM이 사용하는 방법을 설명하세요.', + parameters: '매개변수', + parametersTip: '각 매개변수의 설명을 추가하여 LLM이 목적과 제한 사항을 이해할 수 있도록 도와주세요.', + parametersPlaceholder: '매개변수의 목적 및 제한 사항', + confirm: 'MCP 서버 활성화', + }, + publishTip: '앱이 게시되지 않았습니다. 먼저 앱을 게시하십시오.', + }, + }, } export default translation diff --git a/web/i18n/pl-PL/plugin.ts b/web/i18n/pl-PL/plugin.ts index 948bf6e8fb..d5c05d0df8 100644 --- a/web/i18n/pl-PL/plugin.ts +++ b/web/i18n/pl-PL/plugin.ts @@ -51,7 +51,7 @@ const translation = { paramsTip1: 'Steruje parametrami wnioskowania LLM.', unsupportedContent: 'Zainstalowana wersja wtyczki nie zapewnia tej akcji.', params: 'KONFIGURACJA ROZUMOWANIA', - auto: 'Automatyczne', + auto: 'Auto', empty: 'Kliknij przycisk "+", aby dodać narzędzia. Możesz dodać wiele narzędzi.', descriptionLabel: 'Opis narzędzia', title: 'Dodaj narzędzie', @@ -60,7 +60,7 @@ const translation = { uninstalledContent: 'Ta wtyczka jest instalowana z repozytorium lokalnego/GitHub. Proszę użyć po instalacji.', unsupportedTitle: 'Nieobsługiwana akcja', uninstalledTitle: 'Narzędzie nie jest zainstalowane', - paramsTip2: 'Gdy opcja "Automatycznie" jest wyłączona, używana jest wartość domyślna.', + paramsTip2: 'Gdy opcja "Auto" jest wyłączona, używana jest wartość domyślna.', toolLabel: 'Narzędzie', toolSetting: 'Ustawienia narzędzi', }, diff --git a/web/i18n/pl-PL/tools.ts b/web/i18n/pl-PL/tools.ts index e9d92d150e..183abc3f31 100644 --- a/web/i18n/pl-PL/tools.ts +++ b/web/i18n/pl-PL/tools.ts @@ -146,16 +146,92 @@ const translation = { type: 'typ', category: 'kategoria', add: 'dodawać', - emptyTitle: 'Brak dostępnego narzędzia do przepływu pracy', - emptyTip: 'Przejdź do "Przepływ pracy -> Opublikuj jako narzędzie"', - emptyTitleCustom: 'Brak dostępnego narzędzia niestandardowego', - emptyTipCustom: 'Tworzenie narzędzia niestandardowego', + custom: { + title: 'Brak dostępnego narzędzia niestandardowego', + tip: 'Utwórz narzędzie niestandardowe', + }, + workflow: { + title: 'Brak dostępnego narzędzia workflow', + tip: 'Publikuj przepływy pracy jako narzędzia w Studio', + }, + mcp: { + title: 'Brak dostępnego narzędzia MCP', + tip: 'Dodaj serwer MCP', + }, + agent: { + title: 'Brak dostępnej strategii agenta', + }, }, openInStudio: 'Otwieranie w Studio', customToolTip: 'Dowiedz się więcej o niestandardowych narzędziach Dify', toolNameUsageTip: 'Nazwa wywołania narzędzia do wnioskowania i podpowiadania agentowi', noTools: 'Nie znaleziono narzędzi', copyToolName: 'Kopiuj nazwę', + mcp: { + create: { + cardTitle: 'Dodaj serwer MCP (HTTP)', + cardLink: 'Dowiedz się więcej o integracji serwera MCP', + }, + noConfigured: 'Serwer nieskonfigurowany', + updateTime: 'Zaktualizowano', + toolsCount: '{count} narzędzi', + noTools: 'Brak dostępnych narzędzi', + modal: { + title: 'Dodaj serwer MCP (HTTP)', + editTitle: 'Edytuj serwer MCP (HTTP)', + name: 'Nazwa i ikona', + namePlaceholder: 'Nazwij swój serwer MCP', + serverUrl: 'URL serwera', + serverUrlPlaceholder: 'URL do punktu końcowego serwera', + serverUrlWarning: 'Aktualizacja adresu serwera może zakłócić działanie aplikacji od niego zależnych', + serverIdentifier: 'Identyfikator serwera', + serverIdentifierTip: 'Unikalny identyfikator serwera MCP w obszarze roboczym. Tylko małe litery, cyfry, podkreślenia i myślniki. Maks. 24 znaki.', + serverIdentifierPlaceholder: 'Unikalny identyfikator, np. my-mcp-server', + serverIdentifierWarning: 'Po zmianie ID serwer nie będzie rozpoznawany przez istniejące aplikacje', + cancel: 'Anuluj', + save: 'Zapisz', + confirm: 'Dodaj i autoryzuj', + }, + delete: 'Usuń serwer MCP', + deleteConfirmTitle: 'Usunąć {mcp}?', + operation: { + edit: 'Edytuj', + remove: 'Usuń', + }, + authorize: 'Autoryzuj', + authorizing: 'Autoryzowanie...', + authorizingRequired: 'Wymagana autoryzacja', + authorizeTip: 'Po autoryzacji narzędzia będą wyświetlane tutaj.', + update: 'Aktualizuj', + updating: 'Aktualizowanie...', + gettingTools: 'Pobieranie narzędzi...', + updateTools: 'Aktualizowanie narzędzi...', + toolsEmpty: 'Narzędzia niezaładowane', + getTools: 'Pobierz narzędzia', + toolUpdateConfirmTitle: 'Aktualizuj listę narzędzi', + toolUpdateConfirmContent: 'Aktualizacja listy narzędzi może wpłynąć na istniejące aplikacje. Kontynuować?', + toolsNum: '{count} narzędzi zawartych', + onlyTool: '1 narzędzie zawarte', + identifier: 'Identyfikator serwera (Kliknij, aby skopiować)', + server: { + title: 'Serwer MCP', + url: 'URL serwera', + reGen: 'Wygenerować ponownie URL serwera?', + addDescription: 'Dodaj opis', + edit: 'Edytuj opis', + modal: { + addTitle: 'Dodaj opis, aby aktywować serwer MCP', + editTitle: 'Edytuj opis', + description: 'Opis', + descriptionPlaceholder: 'Wyjaśnij funkcjonalność tego narzędzia i sposób użycia przez LLM', + parameters: 'Parametry', + parametersTip: 'Dodaj opisy każdego parametru, aby pomóc LLM zrozumieć ich cel i ograniczenia.', + parametersPlaceholder: 'Cel i ograniczenia parametru', + confirm: 'Aktywuj serwer MCP', + }, + publishTip: 'Aplikacja nieopublikowana. Najpierw opublikuj aplikację.', + }, + }, } export default translation diff --git a/web/i18n/pt-BR/plugin.ts b/web/i18n/pt-BR/plugin.ts index 8f6501ec93..be8e7e7f97 100644 --- a/web/i18n/pt-BR/plugin.ts +++ b/web/i18n/pt-BR/plugin.ts @@ -47,14 +47,14 @@ const translation = { toolSelector: { uninstalledLink: 'Gerenciar em plug-ins', unsupportedContent2: 'Clique para mudar de versão.', - auto: 'Automático', + auto: 'Auto', title: 'Adicionar ferramenta', params: 'CONFIGURAÇÃO DE RACIOCÍNIO', toolLabel: 'Ferramenta', paramsTip1: 'Controla os parâmetros de inferência do LLM.', descriptionLabel: 'Descrição da ferramenta', uninstalledContent: 'Este plug-in é instalado a partir do repositório local/GitHub. Por favor, use após a instalação.', - paramsTip2: 'Quando \'Automático\' está desativado, o valor padrão é usado.', + paramsTip2: 'Quando \'Auto\' está desativado, o valor padrão é usado.', placeholder: 'Selecione uma ferramenta...', empty: 'Clique no botão \'+\' para adicionar ferramentas. Você pode adicionar várias ferramentas.', settings: 'CONFIGURAÇÕES DO USUÁRIO', diff --git a/web/i18n/pt-BR/tools.ts b/web/i18n/pt-BR/tools.ts index dde7add80a..bd57de362f 100644 --- a/web/i18n/pt-BR/tools.ts +++ b/web/i18n/pt-BR/tools.ts @@ -139,19 +139,95 @@ const translation = { addToolModal: { category: 'categoria', type: 'tipo', - emptyTip: 'Vá para "Fluxo de trabalho - > Publicar como ferramenta"', add: 'adicionar', - emptyTitle: 'Nenhuma ferramenta de fluxo de trabalho disponível', added: 'Adicionado', manageInTools: 'Gerenciar em Ferramentas', - emptyTitleCustom: 'Nenhuma ferramenta personalizada disponível', - emptyTipCustom: 'Criar uma ferramenta personalizada', + custom: { + title: 'Nenhuma ferramenta personalizada disponível', + tip: 'Crie uma ferramenta personalizada', + }, + workflow: { + title: 'Nenhuma ferramenta de fluxo de trabalho disponível', + tip: 'Publique fluxos de trabalho como ferramentas no Studio', + }, + mcp: { + title: 'Nenhuma ferramenta MCP disponível', + tip: 'Adicionar um servidor MCP', + }, + agent: { + title: 'Nenhuma estratégia de agente disponível', + }, }, openInStudio: 'Abrir no Studio', customToolTip: 'Saiba mais sobre as ferramentas personalizadas da Dify', toolNameUsageTip: 'Nome da chamada da ferramenta para raciocínio e solicitação do agente', copyToolName: 'Nome da cópia', noTools: 'Nenhuma ferramenta encontrada', + mcp: { + create: { + cardTitle: 'Adicionar Servidor MCP (HTTP)', + cardLink: 'Saiba mais sobre a integração do servidor MCP', + }, + noConfigured: 'Servidor Não Configurado', + updateTime: 'Atualizado', + toolsCount: '{{count}} ferramentas', + noTools: 'Nenhuma ferramenta disponível', + modal: { + title: 'Adicionar Servidor MCP (HTTP)', + editTitle: 'Editar Servidor MCP (HTTP)', + name: 'Nome & Ícone', + namePlaceholder: 'Dê um nome ao seu servidor MCP', + serverUrl: 'URL do Servidor', + serverUrlPlaceholder: 'URL para o endpoint do servidor', + serverUrlWarning: 'Atualizar o endereço do servidor pode interromper aplicações que dependem deste servidor', + serverIdentifier: 'Identificador do Servidor', + serverIdentifierTip: 'Identificador único para o servidor MCP dentro do espaço de trabalho. Apenas letras minúsculas, números, sublinhados e hífens. Até 24 caracteres.', + serverIdentifierPlaceholder: 'Identificador único, ex: meu-servidor-mcp', + serverIdentifierWarning: 'O servidor não será reconhecido por aplicativos existentes após uma mudança de ID', + cancel: 'Cancelar', + save: 'Salvar', + confirm: 'Adicionar e Autorizar', + }, + delete: 'Remover Servidor MCP', + deleteConfirmTitle: 'Você gostaria de remover {{mcp}}?', + operation: { + edit: 'Editar', + remove: 'Remover', + }, + authorize: 'Autorizar', + authorizing: 'Autorizando...', + authorizingRequired: 'Autorização é necessária', + authorizeTip: 'Após a autorização, as ferramentas serão exibidas aqui.', + update: 'Atualizar', + updating: 'Atualizando', + gettingTools: 'Obtendo Ferramentas...', + updateTools: 'Atualizando Ferramentas...', + toolsEmpty: 'Ferramentas não carregadas', + getTools: 'Obter ferramentas', + toolUpdateConfirmTitle: 'Atualizar Lista de Ferramentas', + toolUpdateConfirmContent: 'Atualizar a lista de ferramentas pode afetar aplicativos existentes. Você deseja continuar?', + toolsNum: '{{count}} ferramentas incluídas', + onlyTool: '1 ferramenta incluída', + identifier: 'Identificador do Servidor (Clique para Copiar)', + server: { + title: 'Servidor MCP', + url: 'URL do Servidor', + reGen: 'Você deseja regenerar a URL do servidor?', + addDescription: 'Adicionar descrição', + edit: 'Editar descrição', + modal: { + addTitle: 'Adicionar descrição para habilitar o servidor MCP', + editTitle: 'Editar descrição', + description: 'Descrição', + descriptionPlaceholder: 'Explique o que esta ferramenta faz e como deve ser utilizada pelo LLM', + parameters: 'Parâmetros', + parametersTip: 'Adicione descrições para cada parâmetro para ajudar o LLM a entender seus propósitos e restrições.', + parametersPlaceholder: 'Propósito e restrições do parâmetro', + confirm: 'Habilitar Servidor MCP', + }, + publishTip: 'Aplicativo não publicado. Por favor, publique o aplicativo primeiro.', + }, + }, } export default translation diff --git a/web/i18n/ro-RO/plugin.ts b/web/i18n/ro-RO/plugin.ts index a88a841e51..1c7d173f8f 100644 --- a/web/i18n/ro-RO/plugin.ts +++ b/web/i18n/ro-RO/plugin.ts @@ -46,7 +46,7 @@ const translation = { }, toolSelector: { unsupportedContent: 'Versiunea de plugin instalată nu oferă această acțiune.', - auto: 'Automat', + auto: 'Auto', empty: 'Faceți clic pe butonul "+" pentru a adăuga instrumente. Puteți adăuga mai multe instrumente.', uninstalledContent: 'Acest plugin este instalat din depozitul local/GitHub. Vă rugăm să utilizați după instalare.', descriptionLabel: 'Descrierea instrumentului', @@ -54,7 +54,7 @@ const translation = { uninstalledLink: 'Gestionați în pluginuri', paramsTip1: 'Controlează parametrii de inferență LLM.', params: 'CONFIGURAREA RAȚIONAMENTULUI', - paramsTip2: 'Când "Automat" este dezactivat, se folosește valoarea implicită.', + paramsTip2: 'Când "Auto" este dezactivat, se folosește valoarea implicită.', settings: 'SETĂRI UTILIZATOR', unsupportedTitle: 'Acțiune neacceptată', placeholder: 'Selectați un instrument...', diff --git a/web/i18n/ro-RO/tools.ts b/web/i18n/ro-RO/tools.ts index 44530754e3..8d8c77a911 100644 --- a/web/i18n/ro-RO/tools.ts +++ b/web/i18n/ro-RO/tools.ts @@ -142,16 +142,92 @@ const translation = { manageInTools: 'Gestionați în Instrumente', add: 'adăuga', type: 'tip', - emptyTitle: 'Nu este disponibil niciun instrument de flux de lucru', - emptyTip: 'Accesați "Flux de lucru -> Publicați ca instrument"', - emptyTitleCustom: 'Nu este disponibil niciun instrument personalizat', - emptyTipCustom: 'Crearea unui instrument personalizat', + custom: { + title: 'Niciun instrument personalizat disponibil', + tip: 'Creează un instrument personalizat', + }, + workflow: { + title: 'Niciun instrument de flux de lucru disponibil', + tip: 'Publicați fluxuri de lucru ca instrumente în Studio', + }, + mcp: { + title: 'Niciun instrument MCP disponibil', + tip: 'Adăugați un server MCP', + }, + agent: { + title: 'Nicio strategie de agent disponibilă', + }, }, openInStudio: 'Deschide în Studio', customToolTip: 'Aflați mai multe despre instrumentele personalizate Dify', toolNameUsageTip: 'Numele de apel al instrumentului pentru raționamentul și solicitarea agentului', copyToolName: 'Copiază numele', noTools: 'Nu s-au găsit unelte', + mcp: { + create: { + cardTitle: 'Adăugare Server MCP (HTTP)', + cardLink: 'Aflați mai multe despre integrarea serverului MCP', + }, + noConfigured: 'Server Neconfigurat', + updateTime: 'Actualizat', + toolsCount: '{count} unelte', + noTools: 'Nu există unelte disponibile', + modal: { + title: 'Adăugare Server MCP (HTTP)', + editTitle: 'Editare Server MCP (HTTP)', + name: 'Nume și Pictogramă', + namePlaceholder: 'Denumiți-vă serverul MCP', + serverUrl: 'URL Server', + serverUrlPlaceholder: 'URL către endpoint-ul serverului', + serverUrlWarning: 'Actualizarea adresei serverului poate întrerupe aplicațiile care depind de acesta', + serverIdentifier: 'Identificator Server', + serverIdentifierTip: 'Identificator unic pentru serverul MCP în spațiul de lucru. Doar litere mici, cifre, underscore și cratime. Maxim 24 de caractere.', + serverIdentifierPlaceholder: 'Identificator unic, ex: my-mcp-server', + serverIdentifierWarning: 'Serverul nu va fi recunoscut de aplicațiile existente după schimbarea ID-ului', + cancel: 'Anulare', + save: 'Salvare', + confirm: 'Adăugare și Autorizare', + }, + delete: 'Eliminare Server MCP', + deleteConfirmTitle: 'Ștergeți {mcp}?', + operation: { + edit: 'Editare', + remove: 'Eliminare', + }, + authorize: 'Autorizare', + authorizing: 'Se autorizează...', + authorizingRequired: 'Autorizare necesară', + authorizeTip: 'După autorizare, uneltele vor fi afișate aici.', + update: 'Actualizare', + updating: 'Se actualizează...', + gettingTools: 'Se obțin unelte...', + updateTools: 'Se actualizează unelte...', + toolsEmpty: 'Unelte neîncărcate', + getTools: 'Obține unelte', + toolUpdateConfirmTitle: 'Actualizare Listă Unelte', + toolUpdateConfirmContent: 'Actualizarea listei de unelte poate afecta aplicațiile existente. Continuați?', + toolsNum: '{count} unelte incluse', + onlyTool: '1 unealtă inclusă', + identifier: 'Identificator Server (Clic pentru Copiere)', + server: { + title: 'Server MCP', + url: 'URL Server', + reGen: 'Regenerați URL server?', + addDescription: 'Adăugare descriere', + edit: 'Editare descriere', + modal: { + addTitle: 'Adăugați descriere pentru activarea serverului MCP', + editTitle: 'Editare descriere', + description: 'Descriere', + descriptionPlaceholder: 'Explicați funcționalitatea acestei unelte și cum ar trebui să fie utilizată de LLM', + parameters: 'Parametri', + parametersTip: 'Adăugați descrieri pentru fiecare parametru pentru a ajuta LLM să înțeleagă scopul și constrângerile.', + parametersPlaceholder: 'Scopul și constrângerile parametrului', + confirm: 'Activare Server MCP', + }, + publishTip: 'Aplicație nepublicată. Publicați aplicația mai întâi.', + }, + }, } export default translation diff --git a/web/i18n/ru-RU/tools.ts b/web/i18n/ru-RU/tools.ts index e1975ee538..caa1959318 100644 --- a/web/i18n/ru-RU/tools.ts +++ b/web/i18n/ru-RU/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'добавить', added: 'добавлено', manageInTools: 'Управлять в инструментах', - emptyTitle: 'Нет доступных инструментов рабочего процесса', - emptyTip: 'Перейдите в "Рабочий процесс -> Опубликовать как инструмент"', - emptyTitleCustom: 'Нет пользовательского инструмента', - emptyTipCustom: 'Создание пользовательского инструмента', + custom: { + title: 'Нет доступного пользовательского инструмента', + tip: 'Создать пользовательский инструмент', + }, + workflow: { + title: 'Нет доступного инструмента рабочего процесса', + tip: 'Публиковать рабочие процессы как инструменты в Студии', + }, + mcp: { + title: 'Нет доступного инструмента MCP', + tip: 'Добавить сервер MCP', + }, + agent: { + title: 'Нет доступной стратегии агента', + }, }, createTool: { title: 'Создать пользовательский инструмент', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'Название вызова инструмента для рассуждений агента и подсказок', copyToolName: 'Копировать имя', noTools: 'Инструменты не найдены', + mcp: { + create: { + cardTitle: 'Добавить MCP сервер (HTTP)', + cardLink: 'Узнайте больше об интеграции MCP сервера', + }, + noConfigured: 'Неконфигурированный сервер', + updateTime: 'Обновлено', + toolsCount: '{count} инструментов', + noTools: 'Нет доступных инструментов', + modal: { + title: 'Добавить MCP сервер (HTTP)', + editTitle: 'Редактировать MCP сервер (HTTP)', + name: 'Имя и иконка', + namePlaceholder: 'Назовите ваш MCP сервер', + serverUrl: 'URL сервера', + serverUrlPlaceholder: 'URL конечной точки сервера', + serverUrlWarning: 'Обновление адреса сервера может нарушить работу приложений, которые зависят от этого сервера', + serverIdentifier: 'Идентификатор сервера', + serverIdentifierTip: 'Уникальный идентификатор MCP сервера в рабочем пространстве. Только строчные буквы, цифры, подчеркивания и дефисы. Максимум 24 символа.', + serverIdentifierPlaceholder: 'Уникальный идентификатор, например, мой-сервер-mcp', + serverIdentifierWarning: 'Сервер не будет распознан существующими приложениями после изменения ID', + cancel: 'Отмена', + save: 'Сохранить', + confirm: 'Добавить и авторизовать', + }, + delete: 'Удалить MCP сервер', + deleteConfirmTitle: 'Вы действительно хотите удалить {mcp}?', + operation: { + edit: 'Редактировать', + remove: 'Удалить', + }, + authorize: 'Авторизовать', + authorizing: 'Авторизация...', + authorizingRequired: 'Требуется авторизация', + authorizeTip: 'После авторизации инструменты будут отображены здесь.', + update: 'Обновить', + updating: 'Обновление', + gettingTools: 'Получение инструментов...', + updateTools: 'Обновление инструментов...', + toolsEmpty: 'Инструменты не загружены', + getTools: 'Получить инструменты', + toolUpdateConfirmTitle: 'Обновить список инструментов', + toolUpdateConfirmContent: 'Обновление списка инструментов может повлиять на существующие приложения. Вы хотите продолжить?', + toolsNum: '{count} инструментов включено', + onlyTool: '1 инструмент включен', + identifier: 'Идентификатор сервера (Нажмите, чтобы скопировать)', + server: { + title: 'MCP Сервер', + url: 'URL сервера', + reGen: 'Хотите регенерировать URL сервера?', + addDescription: 'Добавить описание', + edit: 'Редактировать описание', + modal: { + addTitle: 'Добавить описание, чтобы включить MCP сервер', + editTitle: 'Редактировать описание', + description: 'Описание', + descriptionPlaceholder: 'Объясните, что делает этот инструмент и как его должен использовать LLM', + parameters: 'Параметры', + parametersTip: 'Добавьте описания для каждого параметра, чтобы помочь LLM понять их назначение и ограничения.', + parametersPlaceholder: 'Назначение и ограничения параметра', + confirm: 'Активировать MCP сервер', + }, + publishTip: 'Приложение не опубликовано. Пожалуйста, сначала опубликуйте приложение.', + }, + }, } export default translation diff --git a/web/i18n/sl-SI/tools.ts b/web/i18n/sl-SI/tools.ts index e557725462..d83f218f68 100644 --- a/web/i18n/sl-SI/tools.ts +++ b/web/i18n/sl-SI/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'dodaj', added: 'dodano', manageInTools: 'Upravljaj v Orodjih', - emptyTitle: 'Orodje za potek dela ni na voljo', - emptyTip: 'Pojdite na "Potek dela -> Objavi kot orodje"', - emptyTipCustom: 'Ustvarjanje orodja po meri', - emptyTitleCustom: 'Orodje po meri ni na voljo', + custom: { + title: 'Žiadne prispôsobené nástroje nie sú k dispozícii', + tip: 'Vytvorte prispôsobený nástroj', + }, + workflow: { + title: 'Žiadny nástroj pracovného postupu nie je k dispozícii', + tip: 'Publikujte pracovné postupy ako nástroje v Studio', + }, + mcp: { + title: 'Žiadny nástroj MCP nie je k dispozícii', + tip: 'Pridajte server MCP', + }, + agent: { + title: 'Žiadna stratégia agenta nie je k dispozícii', + }, }, createTool: { title: 'Ustvari prilagojeno orodje', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'Ime klica orodja za sklepanja in pozivanje agenta', copyToolName: 'Kopiraj ime', noTools: 'Orodja niso bila najdena', + mcp: { + create: { + cardTitle: 'Dodaj strežnik MCP (HTTP)', + cardLink: 'Več o integraciji strežnika MCP', + }, + noConfigured: 'Nekonfiguriran strežnik', + updateTime: 'Posodobljeno', + toolsCount: '{count} orodij', + noTools: 'Na voljo ni orodij', + modal: { + title: 'Dodaj strežnik MCP (HTTP)', + editTitle: 'Uredi strežnik MCP (HTTP)', + name: 'Ime in ikona', + namePlaceholder: 'Poimenuj svoj strežnik MCP', + serverUrl: 'URL strežnika', + serverUrlPlaceholder: 'URL do končne točke strežnika', + serverUrlWarning: 'Posodobitev naslova strežnika lahko prekine aplikacije, ki so odvisne od tega strežnika', + serverIdentifier: 'Identifikator strežnika', + serverIdentifierTip: 'Edinstven identifikator za strežnik MCP v delovnem prostoru. Samo male črke, številke, podčrtaji in vezaji. Največ 24 znakov.', + serverIdentifierPlaceholder: 'Edinstven identifikator, npr. moj-mcp-streznik', + serverIdentifierWarning: 'Strežnik po spremembi ID-ja ne bo prepoznan s strani obstoječih aplikacij', + cancel: 'Prekliči', + save: 'Shrani', + confirm: 'Dodaj in avtoriziraj', + }, + delete: 'Odstrani strežnik MCP', + deleteConfirmTitle: 'Odstraniti {mcp}?', + operation: { + edit: 'Uredi', + remove: 'Odstrani', + }, + authorize: 'Avtoriziraj', + authorizing: 'Avtoriziranje...', + authorizingRequired: 'Avtorizacija je zahtevana', + authorizeTip: 'Po avtorizaciji bodo orodja prikazana tukaj.', + update: 'Posodobi', + updating: 'Posodabljanje...', + gettingTools: 'Pridobivanje orodij...', + updateTools: 'Posodabljanje orodij...', + toolsEmpty: 'Orodja niso naložena', + getTools: 'Pridobi orodja', + toolUpdateConfirmTitle: 'Posodobi seznam orodij', + toolUpdateConfirmContent: 'Posodobitev seznama orodij lahko vpliva na obstoječe aplikacije. Želite nadaljevati?', + toolsNum: 'Vključenih {count} orodij', + onlyTool: 'Vključeno 1 orodje', + identifier: 'Identifikator strežnika (Kliknite za kopiranje)', + server: { + title: 'Strežnik MCP', + url: 'URL strežnika', + reGen: 'Želite ponovno ustvariti URL strežnika?', + addDescription: 'Dodaj opis', + edit: 'Uredi opis', + modal: { + addTitle: 'Dodajte opis za omogočitev strežnika MCP', + editTitle: 'Uredi opis', + description: 'Opis', + descriptionPlaceholder: 'Pojasnite, kaj to orodje počne in kako naj ga uporablja LLM', + parameters: 'Parametri', + parametersTip: 'Dodajte opise za vsak parameter, da pomagate LLM razumeti njihov namen in omejitve.', + parametersPlaceholder: 'Namen in omejitve parametra', + confirm: 'Omogoči strežnik MCP', + }, + publishTip: 'Aplikacija ni objavljena. Najprej objavite aplikacijo.', + }, + }, } export default translation diff --git a/web/i18n/th-TH/tools.ts b/web/i18n/th-TH/tools.ts index 14c9457c4e..df36463e57 100644 --- a/web/i18n/th-TH/tools.ts +++ b/web/i18n/th-TH/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'เพิ่ม', added: 'เพิ่ม', manageInTools: 'จัดการในเครื่องมือ', - emptyTitle: 'ไม่มีเครื่องมือเวิร์กโฟลว์', - emptyTip: 'ไปที่ "เวิร์กโฟลว์ -> เผยแพร่เป็นเครื่องมือ"', - emptyTitleCustom: 'ไม่มีเครื่องมือที่กําหนดเอง', - emptyTipCustom: 'สร้างเครื่องมือแบบกําหนดเอง', + custom: { + title: 'ไม่มีเครื่องมือกำหนดเอง', + tip: 'สร้างเครื่องมือกำหนดเอง', + }, + workflow: { + title: 'ไม่มีเครื่องมือเวิร์กโฟลว์', + tip: 'เผยแพร่เวิร์กโฟลว์เป็นเครื่องมือใน Studio', + }, + mcp: { + title: 'ไม่มีเครื่องมือ MCP', + tip: 'เพิ่มเซิร์ฟเวอร์ MCP', + }, + agent: { + title: 'ไม่มีกลยุทธ์เอเจนต์', + }, }, createTool: { title: 'สร้างเครื่องมือที่กําหนดเอง', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'ชื่อการเรียกเครื่องมือสําหรับการใช้เหตุผลและการแจ้งเตือนของตัวแทน', noTools: 'ไม่พบเครื่องมือ', copyToolName: 'คัดลอกชื่อ', + mcp: { + create: { + cardTitle: 'เพิ่มเซิร์ฟเวอร์ MCP (HTTP)', + cardLink: 'เรียนรู้เพิ่มเติมเกี่ยวกับการรวมเซิร์ฟเวอร์ MCP', + }, + noConfigured: 'เซิร์ฟเวอร์ที่ยังไม่ได้กำหนดค่า', + updateTime: 'อัปเดตแล้ว', + toolsCount: '{count} เครื่องมือ', + noTools: 'ไม่มีเครื่องมือที่ใช้ได้', + modal: { + title: 'เพิ่มเซิร์ฟเวอร์ MCP (HTTP)', + editTitle: 'แก้ไขเซิร์ฟเวอร์ MCP (HTTP)', + name: 'ชื่อ & ไอคอน', + namePlaceholder: 'ตั้งชื่อเซิร์ฟเวอร์ MCP ของคุณ', + serverUrl: 'URL ของเซิร์ฟเวอร์', + serverUrlPlaceholder: 'URL สำหรับจุดสิ้นสุดของเซิร์ฟเวอร์', + serverUrlWarning: 'การอัปเดตที่อยู่เซิร์ฟเวอร์อาจทำให้แอปพลิเคชันที่พึ่งพาเซิร์ฟเวอร์นี้หยุดทำงาน', + serverIdentifier: 'ตัวระบุเซิร์ฟเวอร์', + serverIdentifierTip: 'ตัวระบุที่ไม่ซ้ำกันสำหรับเซิร์ฟเวอร์ MCP ภายในพื้นที่ทำงาน ตัวอักษรเล็ก ตัวเลข ขีดล่าง และขีดกลางเท่านั้น ความยาวไม่เกิน 24 ตัวอักษร', + serverIdentifierPlaceholder: 'ตัวระบุที่ไม่ซ้ำกัน เช่น my-mcp-server', + serverIdentifierWarning: 'เซิร์ฟเวอร์จะไม่ถูกต้องในแอปพลิเคชันที่มีอยู่หลังจากการเปลี่ยน ID', + cancel: 'ยกเลิก', + save: 'บันทึก', + confirm: 'เพิ่มและอนุญาต', + }, + delete: 'ลบเซิร์ฟเวอร์ MCP', + deleteConfirmTitle: 'คุณต้องการลบ {mcp} หรือไม่?', + operation: { + edit: 'แก้ไข', + remove: 'ลบ', + }, + authorize: 'อนุญาต', + authorizing: 'กำลังอนุญาต...', + authorizingRequired: 'ต้องมีการอนุญาต', + authorizeTip: 'หลังจากอนุญาต เครื่องมือจะถูกแสดงที่นี่', + update: 'อัปเดต', + updating: 'กำลังอัปเดต', + gettingTools: 'กำลังโหลดเครื่องมือ...', + updateTools: 'กำลังอัปเดตเครื่องมือ...', + toolsEmpty: 'ยังไม่โหลดเครื่องมือ', + getTools: 'รับเครื่องมือ', + toolUpdateConfirmTitle: 'อัปเดตรายการเครื่องมือ', + toolUpdateConfirmContent: 'การอัปเดตรายการเครื่องมืออาจส่งผลต่อแอปพลิเคชันที่มีอยู่ คุณต้องการดำเนินการต่อหรือไม่?', + toolsNum: '{count} เครื่องมือที่รวมอยู่', + onlyTool: 'รวม 1 เครื่องมือ', + identifier: 'ตัวระบุเซิร์ฟเวอร์ (คลิกเพื่อคัดลอก)', + server: { + title: 'เซิร์ฟเวอร์ MCP', + url: 'URL ของเซิร์ฟเวอร์', + reGen: 'คุณต้องการสร้าง URL ของเซิร์ฟเวอร์ใหม่หรือไม่?', + addDescription: 'เพิ่มคำอธิบาย', + edit: 'แก้ไขคำอธิบาย', + modal: { + addTitle: 'เพิ่มคำอธิบายเพื่อเปิดใช้งานเซิร์ฟเวอร์ MCP', + editTitle: 'แก้ไขคำอธิบาย', + description: 'คำอธิบาย', + descriptionPlaceholder: 'อธิบายว่าเครื่องมือนี้ทำอะไรและควรใช้กับ LLM อย่างไร', + parameters: 'พารามิเตอร์', + parametersTip: 'เพิ่มคำอธิบายสำหรับแต่ละพารามิเตอร์เพื่อช่วยให้ LLM เข้าใจวัตถุประสงค์และข้อจำกัดของมัน', + parametersPlaceholder: 'วัตถุประสงค์และข้อจำกัดของพารามิเตอร์', + confirm: 'เปิดใช้งานเซิร์ฟเวอร์ MCP', + }, + publishTip: 'แอปไม่ถูกเผยแพร่ กรุณาเผยแพร่แอปก่อน', + }, + }, } export default translation diff --git a/web/i18n/tr-TR/tools.ts b/web/i18n/tr-TR/tools.ts index af9ddf182f..6e641165e2 100644 --- a/web/i18n/tr-TR/tools.ts +++ b/web/i18n/tr-TR/tools.ts @@ -28,10 +28,21 @@ const translation = { add: 'Ekle', added: 'Eklendi', manageInTools: 'Araçlarda Yönet', - emptyTitle: 'Kullanılabilir workflow aracı yok', - emptyTip: 'Git "Workflow -> Araç olarak Yayınla"', - emptyTitleCustom: 'Özel bir araç yok', - emptyTipCustom: 'Özel bir araç oluşturun', + custom: { + title: 'Mevcut özel araç yok', + tip: 'Özel bir araç oluşturun', + }, + workflow: { + title: 'Mevcut iş akışı aracı yok', + tip: 'İş akışlarını Studio\'da araç olarak yayınlayın', + }, + mcp: { + title: 'Mevcut MCP aracı yok', + tip: 'Bir MCP sunucusu ekleyin', + }, + agent: { + title: 'Mevcut ajan stratejisi yok', + }, }, createTool: { title: 'Özel Araç Oluştur', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: 'Agent akıl yürütme ve prompt için araç çağrı adı', copyToolName: 'Adı Kopyala', noTools: 'Araç bulunamadı', + mcp: { + create: { + cardTitle: 'MCP Sunucusu Ekle (HTTP)', + cardLink: 'MCP sunucu entegrasyonu hakkında daha fazla bilgi edinin', + }, + noConfigured: 'Yapılandırılmamış Sunucu', + updateTime: 'Güncellendi', + toolsCount: '{count} araç', + noTools: 'Kullanılabilir araç yok', + modal: { + title: 'MCP Sunucusu Ekle (HTTP)', + editTitle: 'MCP Sunucusunu Düzenle (HTTP)', + name: 'Ad ve Simge', + namePlaceholder: 'MCP sunucunuza ad verin', + serverUrl: 'Sunucu URL', + serverUrlPlaceholder: 'Sunucu endpoint URL', + serverUrlWarning: 'Sunucu adresini güncellemek, bu sunucuya bağımlı uygulamaları kesintiye uğratabilir', + serverIdentifier: 'Sunucu Tanımlayıcı', + serverIdentifierTip: 'Çalışma alanındaki MCP sunucusu için benzersiz tanımlayıcı. Sadece küçük harf, rakam, alt çizgi ve tire. En fazla 24 karakter.', + serverIdentifierPlaceholder: 'Benzersiz tanımlayıcı, örn. my-mcp-server', + serverIdentifierWarning: 'ID değiştirildikten sonra sunucu mevcut uygulamalar tarafından tanınmayacak', + cancel: 'İptal', + save: 'Kaydet', + confirm: 'Ekle ve Yetkilendir', + }, + delete: 'MCP Sunucusunu Kaldır', + deleteConfirmTitle: '{mcp} kaldırılsın mı?', + operation: { + edit: 'Düzenle', + remove: 'Kaldır', + }, + authorize: 'Yetkilendir', + authorizing: 'Yetkilendiriliyor...', + authorizingRequired: 'Yetkilendirme gerekli', + authorizeTip: 'Yetkilendirmeden sonra araçlar burada görüntülenecektir.', + update: 'Güncelle', + updating: 'Güncelleniyor...', + gettingTools: 'Araçlar alınıyor...', + updateTools: 'Araçlar güncelleniyor...', + toolsEmpty: 'Araçlar yüklenmedi', + getTools: 'Araçları al', + toolUpdateConfirmTitle: 'Araç Listesini Güncelle', + toolUpdateConfirmContent: 'Araç listesini güncellemek mevcut uygulamaları etkileyebilir. Devam etmek istiyor musunuz?', + toolsNum: '{count} araç dahil', + onlyTool: '1 araç dahil', + identifier: 'Sunucu Tanımlayıcı (Kopyalamak için Tıklayın)', + server: { + title: 'MCP Sunucusu', + url: 'Sunucu URL', + reGen: 'Sunucu URL yeniden oluşturulsun mu?', + addDescription: 'Açıklama ekle', + edit: 'Açıklamayı düzenle', + modal: { + addTitle: 'MCP Sunucusunu etkinleştirmek için açıklama ekleyin', + editTitle: 'Açıklamayı düzenle', + description: 'Açıklama', + descriptionPlaceholder: 'Bu aracın ne yaptığını ve LLM tarafından nasıl kullanılması gerektiğini açıklayın', + parameters: 'Parametreler', + parametersTip: 'LLM\'nin amaçlarını ve kısıtlamalarını anlamasına yardımcı olmak için her parametreye açıklamalar ekleyin.', + parametersPlaceholder: 'Parametre amacı ve kısıtlamaları', + confirm: 'MCP Sunucusunu Etkinleştir', + }, + publishTip: 'Uygulama yayınlanmadı. Lütfen önce uygulamayı yayınlayın.', + }, + }, } export default translation diff --git a/web/i18n/uk-UA/tools.ts b/web/i18n/uk-UA/tools.ts index d390b500d3..535c17b1ef 100644 --- a/web/i18n/uk-UA/tools.ts +++ b/web/i18n/uk-UA/tools.ts @@ -142,16 +142,92 @@ const translation = { added: 'Додано', type: 'тип', manageInTools: 'Керування в інструментах', - emptyTip: 'Перейдіть до розділу "Робочий процес -> Опублікувати як інструмент"', - emptyTitle: 'Немає доступного інструменту для роботи з робочими процесами', - emptyTitleCustom: 'Немає доступного спеціального інструменту', - emptyTipCustom: 'Створення власного інструмента', + custom: { + title: 'Немає доступного користувацького інструмента', + tip: 'Створити користувацький інструмент', + }, + workflow: { + title: 'Немає доступного інструмента робочого процесу', + tip: 'Опублікуйте робочі процеси як інструменти в Studio', + }, + mcp: { + title: 'Немає доступного інструмента MCP', + tip: 'Додати сервер MCP', + }, + agent: { + title: 'Немає доступної стратегії агента', + }, }, openInStudio: 'Відкрити в Студії', customToolTip: 'Дізнайтеся більше про користувацькі інструменти Dify', toolNameUsageTip: 'Ім\'я виклику інструменту для міркувань і підказок агента', copyToolName: 'Ім\'я копії', noTools: 'Інструментів не знайдено', + mcp: { + create: { + cardTitle: 'Додати сервер MCP (HTTP)', + cardLink: 'Дізнатися більше про інтеграцію сервера MCP', + }, + noConfigured: 'Сервер не налаштовано', + updateTime: 'Оновлено', + toolsCount: '{count} інструментів', + noTools: 'Інструменти відсутні', + modal: { + title: 'Додати сервер MCP (HTTP)', + editTitle: 'Редагувати сервер MCP (HTTP)', + name: 'Назва та значок', + namePlaceholder: 'Назвіть ваш сервер MCP', + serverUrl: 'URL сервера', + serverUrlPlaceholder: 'URL кінцевої точки сервера', + serverUrlWarning: 'Оновлення адреси сервера може порушити роботу додатків, що залежать від нього', + serverIdentifier: 'Ідентифікатор сервера', + serverIdentifierTip: 'Унікальний ідентифікатор сервера MCP у робочому просторі. Лише малі літери, цифри, підкреслення та дефіси. До 24 символів.', + serverIdentifierPlaceholder: 'Унікальний ідентифікатор, напр. my-mcp-server', + serverIdentifierWarning: 'Після зміни ID існуючі додатки не зможуть розпізнати сервер', + cancel: 'Скасувати', + save: 'Зберегти', + confirm: 'Додати та Авторизувати', + }, + delete: 'Видалити сервер MCP', + deleteConfirmTitle: 'Видалити {mcp}?', + operation: { + edit: 'Редагувати', + remove: 'Видалити', + }, + authorize: 'Авторизувати', + authorizing: 'Авторизація...', + authorizingRequired: 'Потрібна авторизація', + authorizeTip: 'Після авторизації інструменти відображатимуться тут.', + update: 'Оновити', + updating: 'Оновлення...', + gettingTools: 'Отримання інструментів...', + updateTools: 'Оновлення інструментів...', + toolsEmpty: 'Інструменти не завантажено', + getTools: 'Отримати інструменти', + toolUpdateConfirmTitle: 'Оновити список інструментів', + toolUpdateConfirmContent: 'Оновлення списку інструментів може вплинути на існуючі додатки. Продовжити?', + toolsNum: '{count} інструментів включено', + onlyTool: '1 інструмент включено', + identifier: 'Ідентифікатор сервера (Натисніть, щоб скопіювати)', + server: { + title: 'Сервер MCP', + url: 'URL сервера', + reGen: 'Згенерувати URL сервера знову?', + addDescription: 'Додати опис', + edit: 'Редагувати опис', + modal: { + addTitle: 'Додайте опис для активації сервера MCP', + editTitle: 'Редагувати опис', + description: 'Опис', + descriptionPlaceholder: 'Поясніть функціонал інструменту та його використання LLM', + parameters: 'Параметри', + parametersTip: 'Додайте описи параметрів, щоб допомогти LLM зрозуміти їх призначення та обмеження.', + parametersPlaceholder: 'Призначення та обмеження параметра', + confirm: 'Активувати сервер MCP', + }, + publishTip: 'Додаток не опубліковано. Спочатку опублікуйте додаток.', + }, + }, } export default translation diff --git a/web/i18n/vi-VN/tools.ts b/web/i18n/vi-VN/tools.ts index ec4665cbf5..4f3893cade 100644 --- a/web/i18n/vi-VN/tools.ts +++ b/web/i18n/vi-VN/tools.ts @@ -142,16 +142,92 @@ const translation = { type: 'kiểu', add: 'thêm', added: 'Thêm', - emptyTip: 'Đi tới "Quy trình làm việc -> Xuất bản dưới dạng công cụ"', - emptyTitle: 'Không có sẵn công cụ quy trình làm việc', - emptyTitleCustom: 'Không có công cụ tùy chỉnh nào có sẵn', - emptyTipCustom: 'Tạo công cụ tùy chỉnh', + custom: { + title: 'Không có công cụ tùy chỉnh nào', + tip: 'Tạo một công cụ tùy chỉnh', + }, + workflow: { + title: 'Không có công cụ quy trình nào', + tip: 'Xuất bản các quy trình dưới dạng công cụ trong Studio', + }, + mcp: { + title: 'Không có công cụ MCP nào', + tip: 'Thêm máy chủ MCP', + }, + agent: { + title: 'Không có chiến lược đại lý nào', + }, }, toolNameUsageTip: 'Tên cuộc gọi công cụ để lý luận và nhắc nhở tổng đài viên', customToolTip: 'Tìm hiểu thêm về các công cụ tùy chỉnh Dify', openInStudio: 'Mở trong Studio', noTools: 'Không tìm thấy công cụ', copyToolName: 'Sao chép tên', + mcp: { + create: { + cardTitle: 'Thêm Máy chủ MCP (HTTP)', + cardLink: 'Tìm hiểu thêm về tích hợp máy chủ MCP', + }, + noConfigured: 'Máy chủ Chưa được Cấu hình', + updateTime: 'Cập nhật', + toolsCount: '{count} công cụ', + noTools: 'Không có công cụ nào', + modal: { + title: 'Thêm Máy chủ MCP (HTTP)', + editTitle: 'Sửa Máy chủ MCP (HTTP)', + name: 'Tên & Biểu tượng', + namePlaceholder: 'Đặt tên máy chủ MCP', + serverUrl: 'URL Máy chủ', + serverUrlPlaceholder: 'URL đến điểm cuối máy chủ', + serverUrlWarning: 'Cập nhật địa chỉ máy chủ có thể làm gián đoạn ứng dụng phụ thuộc vào máy chủ này', + serverIdentifier: 'Định danh Máy chủ', + serverIdentifierTip: 'Định danh duy nhất cho máy chủ MCP trong không gian làm việc. Chỉ chữ thường, số, gạch dưới và gạch ngang. Tối đa 24 ký tự.', + serverIdentifierPlaceholder: 'Định danh duy nhất, VD: my-mcp-server', + serverIdentifierWarning: 'Máy chủ sẽ không được nhận diện bởi ứng dụng hiện có sau khi thay đổi ID', + cancel: 'Hủy', + save: 'Lưu', + confirm: 'Thêm & Ủy quyền', + }, + delete: 'Xóa Máy chủ MCP', + deleteConfirmTitle: 'Xóa {mcp}?', + operation: { + edit: 'Sửa', + remove: 'Xóa', + }, + authorize: 'Ủy quyền', + authorizing: 'Đang ủy quyền...', + authorizingRequired: 'Cần ủy quyền', + authorizeTip: 'Sau khi ủy quyền, công cụ sẽ hiển thị tại đây.', + update: 'Cập nhật', + updating: 'Đang cập nhật...', + gettingTools: 'Đang lấy công cụ...', + updateTools: 'Đang cập nhật công cụ...', + toolsEmpty: 'Công cụ chưa tải', + getTools: 'Lấy công cụ', + toolUpdateConfirmTitle: 'Cập nhật Danh sách Công cụ', + toolUpdateConfirmContent: 'Cập nhật danh sách công cụ có thể ảnh hưởng ứng dụng hiện có. Tiếp tục?', + toolsNum: 'Bao gồm {count} công cụ', + onlyTool: 'Bao gồm 1 công cụ', + identifier: 'Định danh Máy chủ (Nhấn để Sao chép)', + server: { + title: 'Máy chủ MCP', + url: 'URL Máy chủ', + reGen: 'Tạo lại URL máy chủ?', + addDescription: 'Thêm mô tả', + edit: 'Sửa mô tả', + modal: { + addTitle: 'Thêm mô tả để kích hoạt máy chủ MCP', + editTitle: 'Sửa mô tả', + description: 'Mô tả', + descriptionPlaceholder: 'Giải thích chức năng công cụ và cách LLM sử dụng', + parameters: 'Tham số', + parametersTip: 'Thêm mô tả cho từng tham số để giúp LLM hiểu mục đích và ràng buộc.', + parametersPlaceholder: 'Mục đích và ràng buộc của tham số', + confirm: 'Kích hoạt Máy chủ MCP', + }, + publishTip: 'Ứng dụng chưa xuất bản. Vui lòng xuất bản ứng dụng trước.', + }, + }, } export default translation diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index eddd117012..89cdddc1e3 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -94,6 +94,7 @@ const translation = { unsupportedTitle: '不支持的 Action', unsupportedContent: '已安装的插件版本不提供这个 action。', unsupportedContent2: '点击切换版本', + unsupportedMCPTool: '当前选定的 Agent 策略插件版本不支持 MCP 工具。', }, configureApp: '应用设置', configureModel: '模型设置', diff --git a/web/i18n/zh-Hans/tools.ts b/web/i18n/zh-Hans/tools.ts index 9a573ad308..4e0ccf476f 100644 --- a/web/i18n/zh-Hans/tools.ts +++ b/web/i18n/zh-Hans/tools.ts @@ -28,10 +28,21 @@ const translation = { add: '添加', added: '已添加', manageInTools: '去工具列表管理', - emptyTitle: '没有可用的工作流工具', - emptyTip: '去“工作流 -> 发布为工具”添加', - emptyTitleCustom: '没有可用的自定义工具', - emptyTipCustom: '创建自定义工具', + custom: { + title: '没有可用的自定义工具', + tip: '创建自定义工具', + }, + workflow: { + title: '没有可用的工作流工具', + tip: '在工作室中发布工作流作为工具', + }, + mcp: { + title: '没有可用的 MCP 工具', + tip: '添加 MCP 服务器', + }, + agent: { + title: '没有可用的 agent 策略', + }, }, createTool: { title: '创建自定义工具', @@ -152,6 +163,71 @@ const translation = { toolNameUsageTip: '工具调用名称,用于 Agent 推理和提示词', copyToolName: '复制名称', noTools: '没有工具', + mcp: { + create: { + cardTitle: '添加 MCP 服务 (HTTP)', + cardLink: '了解更多关于 MCP 服务集成的信息', + }, + noConfigured: '未配置', + updateTime: '更新于', + toolsCount: '{{count}} 个工具', + noTools: '没有可用的工具', + modal: { + title: '添加 MCP 服务 (HTTP)', + editTitle: '修改 MCP 服务 (HTTP)', + name: '名称和图标', + namePlaceholder: '命名你的 MCP 服务', + serverUrl: '服务端点 URL', + serverUrlPlaceholder: '服务端点的 URL', + serverUrlWarning: '修改服务端点 URL 可能会影响使用当前 MCP 的应用。', + serverIdentifier: '服务器标识符', + serverIdentifierTip: '工作空间内服务器的唯一标识。支持小写字母、数字、下划线和连字符,最多 24 个字符。', + serverIdentifierPlaceholder: '服务器唯一标识,例如 my-mcp-server', + serverIdentifierWarning: '更改服务器标识符后,现有应用将无法识别此服务器', + cancel: '取消', + save: '保存', + confirm: '添加并授权', + }, + delete: '删除 MCP 服务', + deleteConfirmTitle: '你想要删除 {{mcp}} 吗?', + operation: { + edit: '修改', + remove: '删除', + }, + authorize: '授权', + authorizing: '授权中...', + authorizingRequired: '需要授权', + authorizeTip: '授权后,工具将显示在这里。', + update: '更新', + updating: '更新中', + gettingTools: '获取工具中...', + updateTools: '更新工具中...', + toolsEmpty: '工具未加载', + getTools: '获取工具', + toolUpdateConfirmTitle: '更新工具列表', + toolUpdateConfirmContent: '更新工具列表可能影响现有应用。您想继续吗?', + toolsNum: '包含 {{count}} 个工具', + onlyTool: '包含 1 个工具', + identifier: '服务器标识符 (点击复制)', + server: { + title: 'MCP 服务', + url: '服务端点 URL', + reGen: '你想要重新生成服务端点 URL 吗?', + addDescription: '添加描述', + edit: '编辑描述', + modal: { + addTitle: '添加描述以启用 MCP 服务', + editTitle: '编辑 MCP 服务描述', + description: '描述', + descriptionPlaceholder: '解释此工具的功能以及 LLM 应如何使用它', + parameters: '参数', + parametersTip: '为每个参数添加描述,以帮助 LLM 理解其目的和约束条件。', + parametersPlaceholder: '参数的用途和约束条件', + confirm: '启用 MCP 服务', + }, + publishTip: '应用未发布。请先发布应用。', + }, + }, } export default translation diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 28a5f8aa95..6bd202d58f 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -232,6 +232,8 @@ const translation = { 'utilities': '工具', 'noResult': '未找到匹配项', 'agent': 'Agent 策略', + 'allAdded': '已添加全部', + 'addAll': '添加全部', }, blocks: { 'start': '开始', @@ -369,6 +371,10 @@ const translation = { ms: '毫秒', retries: '{{num}} 重试次数', }, + typeSwitch: { + input: '输入值', + variable: '使用变量', + }, }, start: { required: '必填', @@ -663,6 +669,9 @@ const translation = { tool: { authorize: '授权', inputVars: '输入变量', + settings: '设置', + insertPlaceholder1: '键入', + insertPlaceholder2: '插入变量', outputVars: { text: '工具生成的内容', files: { @@ -890,6 +899,8 @@ const translation = { install: '安装', cancel: '取消', }, + clickToViewParameterSchema: '点击查看参数 schema', + parameterSchema: '参数 Schema', }, }, tracing: { diff --git a/web/i18n/zh-Hant/tools.ts b/web/i18n/zh-Hant/tools.ts index 6e5a95f2a5..93c3fda5c4 100644 --- a/web/i18n/zh-Hant/tools.ts +++ b/web/i18n/zh-Hant/tools.ts @@ -142,16 +142,92 @@ const translation = { added: '添加', manageInTools: '在工具中管理', category: '類別', - emptyTitle: '沒有可用的工作流程工具', - emptyTip: '轉到“工作流 - >發佈為工具”', - emptyTipCustom: '創建自訂工具', - emptyTitleCustom: '沒有可用的自訂工具', + custom: { + title: '沒有可用的自訂工具', + tip: '創建一個自訂工具', + }, + workflow: { + title: '沒有可用的工作流程工具', + tip: '在 Studio 中將工作流程發佈為工具', + }, + mcp: { + title: '沒有可用的 MCP 工具', + tip: '新增一個 MCP 伺服器', + }, + agent: { + title: '沒有可用的代理策略', + }, }, customToolTip: '瞭解有關 Dify 自訂工具的更多資訊', toolNameUsageTip: '用於代理推理和提示的工具調用名稱', openInStudio: '在 Studio 中打開', noTools: '未找到工具', copyToolName: '複製名稱', + mcp: { + create: { + cardTitle: '新增 MCP 伺服器 (HTTP)', + cardLink: '了解更多關於 MCP 伺服器整合', + }, + noConfigured: '未配置的伺服器', + updateTime: '已更新', + toolsCount: '{{count}} 個工具', + noTools: '沒有可用的工具', + modal: { + title: '新增 MCP 伺服器 (HTTP)', + editTitle: '編輯 MCP 伺服器 (HTTP)', + name: '名稱與圖示', + namePlaceholder: '為您的 MCP 伺服器命名', + serverUrl: '伺服器 URL', + serverUrlPlaceholder: '伺服器端點的 URL', + serverUrlWarning: '更新伺服器地址可能會干擾依賴於此伺服器的應用程式', + serverIdentifier: '伺服器識別碼', + serverIdentifierTip: '在工作區內 MCP 伺服器的唯一識別碼。僅限小寫字母、數字、底線和連字符。最多 24 個字元。', + serverIdentifierPlaceholder: '唯一識別碼,例如:my-mcp-server', + serverIdentifierWarning: '更改 ID 之後,現有應用程式將無法識別伺服器', + cancel: '取消', + save: '儲存', + confirm: '新增並授權', + }, + delete: '刪除 MCP 伺服器', + deleteConfirmTitle: '您確定要刪除 {{mcp}} 嗎?', + operation: { + edit: '編輯', + remove: '移除', + }, + authorize: '授權', + authorizing: '正在授權...', + authorizingRequired: '需要授權', + authorizeTip: '授權後,這裡將顯示工具。', + update: '更新', + updating: '更新中', + gettingTools: '獲取工具...', + updateTools: '更新工具...', + toolsEmpty: '工具未加載', + getTools: '獲取工具', + toolUpdateConfirmTitle: '更新工具列表', + toolUpdateConfirmContent: '更新工具列表可能會影響現有應用程式。您要繼續嗎?', + toolsNum: '{{count}} 個工具包含', + onlyTool: '包含 1 個工具', + identifier: '伺服器識別碼 (點擊複製)', + server: { + title: 'MCP 伺服器', + url: '伺服器 URL', + reGen: '您想要重新生成伺服器 URL 嗎?', + addDescription: '新增描述', + edit: '編輯描述', + modal: { + addTitle: '新增描述以啟用 MCP 伺服器', + editTitle: '編輯描述', + description: '描述', + descriptionPlaceholder: '說明此工具的用途及如何被 LLM 使用', + parameters: '參數', + parametersTip: '為每個參數添加描述,以幫助 LLM 理解其目的和約束。', + parametersPlaceholder: '參數的目的和約束', + confirm: '啟用 MCP 伺服器', + }, + publishTip: '應用程式尚未發布。請先發布應用程式。', + }, + }, } export default translation diff --git a/web/package.json b/web/package.json index 0862ddbb07..254c2ec1fd 100644 --- a/web/package.json +++ b/web/package.json @@ -144,6 +144,7 @@ "sortablejs": "^1.15.0", "swr": "^2.3.0", "tailwind-merge": "^2.5.4", + "tldts": "^7.0.9", "use-context-selector": "^2.0.0", "uuid": "^10.0.0", "zod": "^3.23.8", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ef945dfc54..a69dea9088 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -333,6 +333,9 @@ importers: tailwind-merge: specifier: ^2.5.4 version: 2.6.0 + tldts: + specifier: ^7.0.9 + version: 7.0.9 use-context-selector: specifier: ^2.0.0 version: 2.0.0(react@19.0.0)(scheduler@0.23.2) @@ -8042,6 +8045,13 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.9: + resolution: {integrity: sha512-/FGY1+CryHsxF9SFiPZlMOcwQsfABkAvOJO5VEKE8TNifVEqgMF7+UVXHGhm1z4gPUfvVS/EYcwhiRU3vUa1ag==} + + tldts@7.0.9: + resolution: {integrity: sha512-/nFtBeNs9nAKIAZE1i3ssOAroci8UqRldFVw5H6RCsNZw7NzDr+Yc3Ek7Tm8XSQKMzw7NSyRSszNxCM0ENsUbg==} + hasBin: true + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -18054,6 +18064,12 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.0.9: {} + + tldts@7.0.9: + dependencies: + tldts-core: 7.0.9 + tmpl@1.0.5: {} to-buffer@1.2.1: diff --git a/web/service/common.ts b/web/service/common.ts index 700cd4bf51..e071d556d1 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -337,8 +337,8 @@ export const verifyWebAppForgotPasswordToken: Fetcher = ({ url, body }) => post(url, { body }, { isPublicAPI: true }) -export const uploadRemoteFileInfo = (url: string, isPublic?: boolean) => { - return post<{ id: string; name: string; size: number; mime_type: string; url: string }>('/remote-files/upload', { body: { url } }, { isPublicAPI: isPublic }) +export const uploadRemoteFileInfo = (url: string, isPublic?: boolean, silent?: boolean) => { + return post<{ id: string; name: string; size: number; mime_type: string; url: string }>('/remote-files/upload', { body: { url } }, { isPublicAPI: isPublic, silent }) } export const sendEMailLoginCode = (email: string, language = 'en-US') => diff --git a/web/service/tools.ts b/web/service/tools.ts index 38dcf382e6..6a88d8d567 100644 --- a/web/service/tools.ts +++ b/web/service/tools.ts @@ -124,6 +124,10 @@ export const fetchAllWorkflowTools = () => { return get('/workspaces/current/tools/workflow') } +export const fetchAllMCPTools = () => { + return get('/workspaces/current/tools/mcp') +} + export const fetchLabelList = () => { return get('/workspaces/current/tool-labels') } diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index ceaa4b14b3..64a3ce7a1f 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -1,9 +1,11 @@ -import { get, post } from './base' +import { del, get, post, put } from './base' import type { Collection, + MCPServerDetail, Tool, } from '@/app/components/tools/types' import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { AppIconType } from '@/types/app' import { useInvalid } from './use-base' import { useMutation, @@ -61,6 +63,191 @@ export const useInvalidateAllWorkflowTools = () => { return useInvalid(useAllWorkflowToolsKey) } +const useAllMCPToolsKey = [NAME_SPACE, 'MCPTools'] +export const useAllMCPTools = () => { + return useQuery({ + queryKey: useAllMCPToolsKey, + queryFn: () => get('/workspaces/current/tools/mcp'), + }) +} + +export const useInvalidateAllMCPTools = () => { + return useInvalid(useAllMCPToolsKey) +} + +export const useCreateMCP = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'create-mcp'], + mutationFn: (payload: { + name: string + server_url: string + icon_type: AppIconType + icon: string + icon_background?: string | null + }) => { + return post('workspaces/current/tool-provider/mcp', { + body: { + ...payload, + }, + }) + }, + }) +} + +export const useUpdateMCP = ({ + onSuccess, +}: { + onSuccess?: () => void +}) => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-mcp'], + mutationFn: (payload: { + name: string + server_url: string + icon_type: AppIconType + icon: string + icon_background?: string | null + provider_id: string + }) => { + return put('workspaces/current/tool-provider/mcp', { + body: { + ...payload, + }, + }) + }, + onSuccess, + }) +} + +export const useDeleteMCP = ({ + onSuccess, +}: { + onSuccess?: () => void +}) => { + return useMutation({ + mutationKey: [NAME_SPACE, 'delete-mcp'], + mutationFn: (id: string) => { + return del('/workspaces/current/tool-provider/mcp', { + body: { + provider_id: id, + }, + }) + }, + onSuccess, + }) +} + +export const useAuthorizeMCP = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'authorize-mcp'], + mutationFn: (payload: { provider_id: string; }) => { + return post<{ result?: string; authorization_url?: string }>('/workspaces/current/tool-provider/mcp/auth', { + body: payload, + }) + }, + }) +} + +export const useUpdateMCPAuthorizationToken = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'refresh-mcp-server-code'], + mutationFn: (payload: { provider_id: string; authorization_code: string }) => { + return get('/workspaces/current/tool-provider/mcp/token', { + params: { + ...payload, + }, + }) + }, + }) +} + +export const useMCPTools = (providerID: string) => { + return useQuery({ + enabled: !!providerID, + queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], + queryFn: () => get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/tools/${providerID}`), + }) +} +export const useInvalidateMCPTools = () => { + const queryClient = useQueryClient() + return (providerID: string) => { + queryClient.invalidateQueries( + { + queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], + }) + } +} + +export const useUpdateMCPTools = () => { + return useMutation({ + mutationFn: (providerID: string) => get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/update/${providerID}`), + }) +} + +export const useMCPServerDetail = (appID: string) => { + return useQuery({ + queryKey: [NAME_SPACE, 'MCPServerDetail', appID], + queryFn: () => get(`/apps/${appID}/server`), + }) +} + +export const useInvalidateMCPServerDetail = () => { + const queryClient = useQueryClient() + return (appID: string) => { + queryClient.invalidateQueries( + { + queryKey: [NAME_SPACE, 'MCPServerDetail', appID], + }) + } +} + +export const useCreateMCPServer = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'create-mcp-server'], + mutationFn: (payload: { + appID: string + description: string + parameters?: Record + }) => { + const { appID, ...rest } = payload + return post(`apps/${appID}/server`, { + body: { + ...rest, + }, + }) + }, + }) +} + +export const useUpdateMCPServer = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-mcp-server'], + mutationFn: (payload: { + appID: string + id: string + description?: string + status?: string + parameters?: Record + }) => { + const { appID, ...rest } = payload + return put(`apps/${appID}/server`, { + body: { + ...rest, + }, + }) + }, + }) +} + +export const useRefreshMCPServerCode = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'refresh-mcp-server-code'], + mutationFn: (appID: string) => { + return get(`apps/${appID}/server/refresh`) + }, + }) +} + export const useBuiltinProviderInfo = (providerName: string) => { return useQuery({ queryKey: [NAME_SPACE, 'builtin-provider-info', providerName], diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts index eb07109857..2c8f86ae5d 100644 --- a/web/service/use-workflow.ts +++ b/web/service/use-workflow.ts @@ -1,5 +1,5 @@ import { del, get, patch, post, put } from './base' -import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { FetchWorkflowDraftPageParams, FetchWorkflowDraftPageResponse, @@ -23,6 +23,16 @@ export const useAppWorkflow = (appID: string) => { }) } +export const useInvalidateAppWorkflow = () => { + const queryClient = useQueryClient() + return (appID: string) => { + queryClient.invalidateQueries( + { + queryKey: [NAME_SPACE, 'publish', appID], + }) + } +} + export const useWorkflowConfig = (appId: string, onSuccess: (v: WorkflowConfigResponse) => void) => { return useQuery({ queryKey: [NAME_SPACE, 'config', appId], diff --git a/web/tailwind-common-config.ts b/web/tailwind-common-config.ts index 3f64afcc29..eff1530017 100644 --- a/web/tailwind-common-config.ts +++ b/web/tailwind-common-config.ts @@ -71,6 +71,7 @@ const config = { boxShadow: { 'xs': '0px 1px 2px 0px rgba(16, 24, 40, 0.05)', 'sm': '0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.10)', + 'sm-no-bottom': '0px -1px 2px 0px rgba(16, 24, 40, 0.06), 0px -1px 3px 0px rgba(16, 24, 40, 0.10)', 'md': '0px 2px 4px -2px rgba(16, 24, 40, 0.06), 0px 4px 8px -2px rgba(16, 24, 40, 0.10)', 'lg': '0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08)', 'xl': '0px 8px 8px -4px rgba(16, 24, 40, 0.03), 0px 20px 24px -4px rgba(16, 24, 40, 0.08)', diff --git a/web/utils/plugin-version-feature.spec.ts b/web/utils/plugin-version-feature.spec.ts new file mode 100644 index 0000000000..12ca239aa9 --- /dev/null +++ b/web/utils/plugin-version-feature.spec.ts @@ -0,0 +1,26 @@ +import { isSupportMCP } from './plugin-version-feature' + +describe('plugin-version-feature', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('isSupportMCP', () => { + it('should call isEqualOrLaterThanVersion with the correct parameters', () => { + expect(isSupportMCP('0.0.3')).toBe(true) + expect(isSupportMCP('1.0.0')).toBe(true) + }) + + it('should return true when version is equal to the supported MCP version', () => { + const mockVersion = '0.0.2' + const result = isSupportMCP(mockVersion) + expect(result).toBe(true) + }) + + it('should return false when version is less than the supported MCP version', () => { + const mockVersion = '0.0.1' + const result = isSupportMCP(mockVersion) + expect(result).toBe(false) + }) + }) +}) diff --git a/web/utils/plugin-version-feature.ts b/web/utils/plugin-version-feature.ts new file mode 100644 index 0000000000..51d366bf9c --- /dev/null +++ b/web/utils/plugin-version-feature.ts @@ -0,0 +1,10 @@ +import { isEqualOrLaterThanVersion } from './semver' + +const SUPPORT_MCP_VERSION = '0.0.2' + +export const isSupportMCP = (version?: string): boolean => { + if (!version) + return false + + return isEqualOrLaterThanVersion(version, SUPPORT_MCP_VERSION) +} diff --git a/web/utils/semver.spec.ts b/web/utils/semver.spec.ts new file mode 100644 index 0000000000..c2188a976c --- /dev/null +++ b/web/utils/semver.spec.ts @@ -0,0 +1,75 @@ +import { compareVersion, getLatestVersion, isEqualOrLaterThanVersion } from './semver' + +describe('semver utilities', () => { + describe('getLatestVersion', () => { + it('should return the latest version from a list of versions', () => { + expect(getLatestVersion(['1.0.0', '1.1.0', '1.0.1'])).toBe('1.1.0') + expect(getLatestVersion(['2.0.0', '1.9.9', '1.10.0'])).toBe('2.0.0') + expect(getLatestVersion(['1.0.0-alpha', '1.0.0-beta', '1.0.0'])).toBe('1.0.0') + }) + + it('should handle patch versions correctly', () => { + expect(getLatestVersion(['1.0.1', '1.0.2', '1.0.0'])).toBe('1.0.2') + expect(getLatestVersion(['1.0.10', '1.0.9', '1.0.11'])).toBe('1.0.11') + }) + + it('should handle mixed version formats', () => { + expect(getLatestVersion(['v1.0.0', '1.1.0', 'v1.2.0'])).toBe('v1.2.0') + expect(getLatestVersion(['1.0.0-rc.1', '1.0.0', '1.0.0-beta'])).toBe('1.0.0') + }) + + it('should return the only version if only one version is provided', () => { + expect(getLatestVersion(['1.0.0'])).toBe('1.0.0') + }) + }) + + describe('compareVersion', () => { + it('should return 1 when first version is greater', () => { + expect(compareVersion('1.1.0', '1.0.0')).toBe(1) + expect(compareVersion('2.0.0', '1.9.9')).toBe(1) + expect(compareVersion('1.0.1', '1.0.0')).toBe(1) + }) + + it('should return -1 when first version is less', () => { + expect(compareVersion('1.0.0', '1.1.0')).toBe(-1) + expect(compareVersion('1.9.9', '2.0.0')).toBe(-1) + expect(compareVersion('1.0.0', '1.0.1')).toBe(-1) + }) + + it('should return 0 when versions are equal', () => { + expect(compareVersion('1.0.0', '1.0.0')).toBe(0) + expect(compareVersion('2.1.3', '2.1.3')).toBe(0) + }) + + it('should handle pre-release versions correctly', () => { + expect(compareVersion('1.0.0-beta', '1.0.0-alpha')).toBe(1) + expect(compareVersion('1.0.0', '1.0.0-beta')).toBe(1) + expect(compareVersion('1.0.0-alpha', '1.0.0-beta')).toBe(-1) + }) + }) + + describe('isEqualOrLaterThanVersion', () => { + it('should return true when baseVersion is greater than targetVersion', () => { + expect(isEqualOrLaterThanVersion('1.1.0', '1.0.0')).toBe(true) + expect(isEqualOrLaterThanVersion('2.0.0', '1.9.9')).toBe(true) + expect(isEqualOrLaterThanVersion('1.0.1', '1.0.0')).toBe(true) + }) + + it('should return true when baseVersion is equal to targetVersion', () => { + expect(isEqualOrLaterThanVersion('1.0.0', '1.0.0')).toBe(true) + expect(isEqualOrLaterThanVersion('2.1.3', '2.1.3')).toBe(true) + }) + + it('should return false when baseVersion is less than targetVersion', () => { + expect(isEqualOrLaterThanVersion('1.0.0', '1.1.0')).toBe(false) + expect(isEqualOrLaterThanVersion('1.9.9', '2.0.0')).toBe(false) + expect(isEqualOrLaterThanVersion('1.0.0', '1.0.1')).toBe(false) + }) + + it('should handle pre-release versions correctly', () => { + expect(isEqualOrLaterThanVersion('1.0.0', '1.0.0-beta')).toBe(true) + expect(isEqualOrLaterThanVersion('1.0.0-beta', '1.0.0-alpha')).toBe(true) + expect(isEqualOrLaterThanVersion('1.0.0-alpha', '1.0.0')).toBe(false) + }) + }) +}) diff --git a/web/utils/semver.ts b/web/utils/semver.ts index f1b9eb8d7e..aea84153ec 100644 --- a/web/utils/semver.ts +++ b/web/utils/semver.ts @@ -7,3 +7,7 @@ export const getLatestVersion = (versionList: string[]) => { export const compareVersion = (v1: string, v2: string) => { return semver.compare(v1, v2) } + +export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => { + return semver.gte(baseVersion, targetVersion) +} From b834131f50a86ef03f3e96b9bc8fe4640cd3c172 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:19:26 +0800 Subject: [PATCH 048/241] chore: translate i18n files (#22132) Co-authored-by: iamjoel <2120155+iamjoel@users.noreply.github.com> --- web/i18n/de-DE/app.ts | 1 + web/i18n/de-DE/workflow.ts | 1 + web/i18n/es-ES/app.ts | 1 + web/i18n/es-ES/workflow.ts | 1 + web/i18n/fa-IR/app.ts | 1 + web/i18n/fa-IR/workflow.ts | 1 + web/i18n/fr-FR/app.ts | 1 + web/i18n/fr-FR/workflow.ts | 1 + web/i18n/hi-IN/app.ts | 1 + web/i18n/hi-IN/workflow.ts | 1 + web/i18n/it-IT/app.ts | 1 + web/i18n/it-IT/workflow.ts | 1 + web/i18n/ja-JP/app.ts | 1 + web/i18n/ja-JP/tools.ts | 1 - web/i18n/ja-JP/workflow.ts | 1 + web/i18n/ko-KR/app.ts | 1 + web/i18n/ko-KR/workflow.ts | 1 + web/i18n/pl-PL/app.ts | 1 + web/i18n/pl-PL/workflow.ts | 1 + web/i18n/pt-BR/app.ts | 1 + web/i18n/pt-BR/workflow.ts | 1 + web/i18n/ro-RO/app.ts | 3 +++ web/i18n/ro-RO/workflow.ts | 1 + web/i18n/ru-RU/app.ts | 1 + web/i18n/ru-RU/workflow.ts | 1 + web/i18n/sl-SI/app.ts | 1 + web/i18n/sl-SI/workflow.ts | 1 + web/i18n/th-TH/app.ts | 1 + web/i18n/th-TH/workflow.ts | 1 + web/i18n/tr-TR/app.ts | 1 + web/i18n/tr-TR/workflow.ts | 1 + web/i18n/uk-UA/app.ts | 1 + web/i18n/uk-UA/workflow.ts | 1 + web/i18n/vi-VN/app.ts | 1 + web/i18n/vi-VN/workflow.ts | 1 + web/i18n/zh-Hant/app.ts | 1 + web/i18n/zh-Hant/workflow.ts | 1 + 37 files changed, 38 insertions(+), 1 deletion(-) diff --git a/web/i18n/de-DE/app.ts b/web/i18n/de-DE/app.ts index d29a475bcd..52819d0c7e 100644 --- a/web/i18n/de-DE/app.ts +++ b/web/i18n/de-DE/app.ts @@ -174,6 +174,7 @@ const translation = { title: 'Weben', description: 'Weave ist eine Open-Source-Plattform zur Bewertung, Testung und Überwachung von LLM-Anwendungen.', }, + aliyun: {}, }, answerIcon: { descriptionInExplore: 'Gibt an, ob das web app Symbol zum Ersetzen 🤖 in Explore verwendet werden soll', diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index f7f757f91b..ba49f72b69 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -364,6 +364,7 @@ const translation = { ms: 'Frau', retries: '{{num}} Wiederholungen', }, + typeSwitch: {}, }, start: { required: 'erforderlich', diff --git a/web/i18n/es-ES/app.ts b/web/i18n/es-ES/app.ts index 18741226fa..4c9497e16d 100644 --- a/web/i18n/es-ES/app.ts +++ b/web/i18n/es-ES/app.ts @@ -172,6 +172,7 @@ const translation = { description: 'Weave es una plataforma de código abierto para evaluar, probar y monitorear aplicaciones de LLM.', title: 'Tejer', }, + aliyun: {}, }, answerIcon: { title: 'Usar el icono de la aplicación web para reemplazar 🤖', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 519577150d..44516317e8 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -364,6 +364,7 @@ const translation = { retries: '{{num}} Reintentos', retry: 'Reintentar', }, + typeSwitch: {}, }, start: { required: 'requerido', diff --git a/web/i18n/fa-IR/app.ts b/web/i18n/fa-IR/app.ts index 5e9bd938f2..bf2fa00c11 100644 --- a/web/i18n/fa-IR/app.ts +++ b/web/i18n/fa-IR/app.ts @@ -176,6 +176,7 @@ const translation = { title: 'بافندگی', description: 'ویو یک پلتفرم متن باز برای ارزیابی، آزمایش و نظارت بر برنامه‌های LLM است.', }, + aliyun: {}, }, answerIcon: { descriptionInExplore: 'آیا از نماد web app برای جایگزینی 🤖 در Explore استفاده کنیم یا خیر', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 94903c2bd0..800dba06b8 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -364,6 +364,7 @@ const translation = { retrySuccessful: 'امتحان مجدد با موفقیت انجام دهید', retryFailedTimes: '{{بار}} تلاش های مجدد ناموفق بود', }, + typeSwitch: {}, }, start: { required: 'الزامی', diff --git a/web/i18n/fr-FR/app.ts b/web/i18n/fr-FR/app.ts index 29d7a9b3de..18cd04a1e1 100644 --- a/web/i18n/fr-FR/app.ts +++ b/web/i18n/fr-FR/app.ts @@ -172,6 +172,7 @@ const translation = { title: 'Tisser', description: 'Weave est une plateforme open-source pour évaluer, tester et surveiller les applications LLM.', }, + aliyun: {}, }, answerIcon: { description: 'S’il faut utiliser l’icône web app pour remplacer 🤖 dans l’application partagée', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index a008a2a735..8c8180abff 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -364,6 +364,7 @@ const translation = { ms: 'ms', retries: '{{num}} Tentatives', }, + typeSwitch: {}, }, start: { required: 'requis', diff --git a/web/i18n/hi-IN/app.ts b/web/i18n/hi-IN/app.ts index 9485d7359b..e9073722f7 100644 --- a/web/i18n/hi-IN/app.ts +++ b/web/i18n/hi-IN/app.ts @@ -172,6 +172,7 @@ const translation = { title: 'बुनना', description: 'वीव एक ओपन-सोर्स प्लेटफ़ॉर्म है जो LLM अनुप्रयोगों का मूल्यांकन, परीक्षण और निगरानी करने के लिए है।', }, + aliyun: {}, }, answerIcon: { title: 'बदलने 🤖 के लिए web app चिह्न का उपयोग करें', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 34293d95ba..ffddacaf3a 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -376,6 +376,7 @@ const translation = { retry: 'पुनर्प्रयास', retryOnFailure: 'विफलता पर पुनः प्रयास करें', }, + typeSwitch: {}, }, start: { required: 'आवश्यक', diff --git a/web/i18n/it-IT/app.ts b/web/i18n/it-IT/app.ts index feade00168..a08714df71 100644 --- a/web/i18n/it-IT/app.ts +++ b/web/i18n/it-IT/app.ts @@ -184,6 +184,7 @@ const translation = { title: 'Intrecciare', description: 'Weave è una piattaforma open-source per valutare, testare e monitorare le applicazioni LLM.', }, + aliyun: {}, }, answerIcon: { description: 'Se utilizzare l\'icona web app per la sostituzione 🤖 nell\'applicazione condivisa', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 0f1837e084..d7e85dcc19 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -379,6 +379,7 @@ const translation = { retryFailed: 'Nuovo tentativo non riuscito', ms: 'ms', }, + typeSwitch: {}, }, start: { required: 'richiesto', diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index 1d94d67eb0..2058b29d9e 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -180,6 +180,7 @@ const translation = { title: '織る', description: 'Weave は、LLM アプリケーションを評価、テスト、および監視するためのオープンソースプラットフォームです。', }, + aliyun: {}, }, answerIcon: { title: 'Web アプリアイコンを使用して🤖を置き換える', diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index d69cd4a6f5..f96a5f4182 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -228,7 +228,6 @@ const translation = { publishTip: 'アプリが公開されていません。まずアプリを公開してください。', }, }, - } export default translation diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index e4b31548bf..04702194f8 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -369,6 +369,7 @@ const translation = { ms: 'ミリ秒', retries: '再試行回数:{{num}}', }, + typeSwitch: {}, }, start: { required: '必須', diff --git a/web/i18n/ko-KR/app.ts b/web/i18n/ko-KR/app.ts index e9634bfbde..96407f829b 100644 --- a/web/i18n/ko-KR/app.ts +++ b/web/i18n/ko-KR/app.ts @@ -192,6 +192,7 @@ const translation = { description: 'Weave 는 LLM 애플리케이션을 평가하고 테스트하며 모니터링하기 위한 오픈 소스 플랫폼입니다.', }, + aliyun: {}, }, answerIcon: { description: diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 1270408c6c..6a9f97862e 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -388,6 +388,7 @@ const translation = { ms: '미에스', retries: '{{숫자}} 재시도', }, + typeSwitch: {}, }, start: { required: '필수', diff --git a/web/i18n/pl-PL/app.ts b/web/i18n/pl-PL/app.ts index ee422a8b56..141ab190e0 100644 --- a/web/i18n/pl-PL/app.ts +++ b/web/i18n/pl-PL/app.ts @@ -179,6 +179,7 @@ const translation = { title: 'Tkaj', description: 'Weave to platforma open-source do oceny, testowania i monitorowania aplikacji LLM.', }, + aliyun: {}, }, answerIcon: { description: 'Czy w aplikacji udostępnionej ma być używana ikona aplikacji internetowej do zamiany 🤖.', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 07c5fa1f92..6d1c9ccc8c 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -364,6 +364,7 @@ const translation = { retryFailedTimes: '{{times}} ponawianie prób nie powiodło się', ms: 'Ms', }, + typeSwitch: {}, }, start: { required: 'wymagane', diff --git a/web/i18n/pt-BR/app.ts b/web/i18n/pt-BR/app.ts index 742159692c..099d0a33ea 100644 --- a/web/i18n/pt-BR/app.ts +++ b/web/i18n/pt-BR/app.ts @@ -172,6 +172,7 @@ const translation = { description: 'Weave é uma plataforma de código aberto para avaliar, testar e monitorar aplicações de LLM.', title: 'Trançar', }, + aliyun: {}, }, answerIcon: { descriptionInExplore: 'Se o ícone do web app deve ser usado para substituir 🤖 no Explore', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 85b05ef06f..1cb323f59a 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -364,6 +364,7 @@ const translation = { ms: 'ms', retries: '{{num}} Tentativas', }, + typeSwitch: {}, }, start: { required: 'requerido', diff --git a/web/i18n/ro-RO/app.ts b/web/i18n/ro-RO/app.ts index 5fdb32945c..2d63bdfba7 100644 --- a/web/i18n/ro-RO/app.ts +++ b/web/i18n/ro-RO/app.ts @@ -172,6 +172,9 @@ const translation = { title: 'Împletește', description: 'Weave este o platformă open-source pentru evaluarea, testarea și monitorizarea aplicațiilor LLM.', }, + aliyun: { + description: 'Platforma de observabilitate SaaS oferită de Alibaba Cloud permite monitorizarea, urmărirea și evaluarea aplicațiilor Dify din cutie.', + }, }, answerIcon: { descriptionInExplore: 'Dacă să utilizați pictograma web app pentru a înlocui 🤖 în Explore', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 6942c1eea5..886dc3b790 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -364,6 +364,7 @@ const translation = { retries: '{{num}} Încercări', retryTimes: 'Reîncercați {{times}} ori în caz de eșec', }, + typeSwitch: {}, }, start: { required: 'necesar', diff --git a/web/i18n/ru-RU/app.ts b/web/i18n/ru-RU/app.ts index 03283180dc..de8047b080 100644 --- a/web/i18n/ru-RU/app.ts +++ b/web/i18n/ru-RU/app.ts @@ -176,6 +176,7 @@ const translation = { description: 'Weave — это открытая платформа для оценки, тестирования и мониторинга приложений LLM.', title: 'Ткать', }, + aliyun: {}, }, answerIcon: { title: 'Использование значка web app для замены 🤖', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index dfdbac3ce9..aecd9e652c 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -364,6 +364,7 @@ const translation = { retryFailedTimes: 'Повторные попытки {{times}} не увенчались успехом', retries: '{{число}} Повторных попыток', }, + typeSwitch: {}, }, start: { required: 'обязательно', diff --git a/web/i18n/sl-SI/app.ts b/web/i18n/sl-SI/app.ts index bba09351aa..152b7d63dc 100644 --- a/web/i18n/sl-SI/app.ts +++ b/web/i18n/sl-SI/app.ts @@ -181,6 +181,7 @@ const translation = { title: 'Tkanje', description: 'Weave je odprtokodna platforma za vrednotenje, testiranje in spremljanje aplikacij LLM.', }, + aliyun: {}, }, mermaid: { handDrawn: 'Ročno narisano', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index 16e3ae72cb..a7c2626264 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -366,6 +366,7 @@ const translation = { }, insertVarTip: 'Vstavite spremenljivko', outputVars: 'Izhodne spremenljivke', + typeSwitch: {}, }, start: { outputVars: { diff --git a/web/i18n/th-TH/app.ts b/web/i18n/th-TH/app.ts index 3d4809e874..9d2b9af398 100644 --- a/web/i18n/th-TH/app.ts +++ b/web/i18n/th-TH/app.ts @@ -177,6 +177,7 @@ const translation = { title: 'ทอ', description: 'Weave เป็นแพลตฟอร์มโอเพนซอร์สสำหรับการประเมินผล ทดสอบ และตรวจสอบแอปพลิเคชัน LLM', }, + aliyun: {}, }, mermaid: { handDrawn: 'วาดด้วยมือ', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 8d99c1c514..28be5c57e8 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -364,6 +364,7 @@ const translation = { retries: '{{num}} ลอง', ms: 'นางสาว', }, + typeSwitch: {}, }, start: { required: 'ต้องระบุ', diff --git a/web/i18n/tr-TR/app.ts b/web/i18n/tr-TR/app.ts index 639319fd81..16bad22231 100644 --- a/web/i18n/tr-TR/app.ts +++ b/web/i18n/tr-TR/app.ts @@ -172,6 +172,7 @@ const translation = { title: 'Dokuma', description: 'Weave, LLM uygulamalarını değerlendirmek, test etmek ve izlemek için açık kaynaklı bir platformdur.', }, + aliyun: {}, }, answerIcon: { descriptionInExplore: 'Keşfet\'te değiştirilecek 🤖 web app simgesinin kullanılıp kullanılmayacağı', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 63eed4bb9c..a09e5d9068 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -364,6 +364,7 @@ const translation = { retrying: 'Yeniden deneniyor...', ms: 'Ms', }, + typeSwitch: {}, }, start: { required: 'gerekli', diff --git a/web/i18n/uk-UA/app.ts b/web/i18n/uk-UA/app.ts index 9e1d3c60c6..9786fd36db 100644 --- a/web/i18n/uk-UA/app.ts +++ b/web/i18n/uk-UA/app.ts @@ -172,6 +172,7 @@ const translation = { title: 'Ткати', description: 'Weave є платформою з відкритим кодом для оцінки, тестування та моніторингу LLM додатків.', }, + aliyun: {}, }, answerIcon: { title: 'Використовуйте піктограму web app для заміни 🤖', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index a6a04246c2..dd61582129 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -364,6 +364,7 @@ const translation = { retryFailedTimes: '{{times}} повторні спроби не вдалися', retryTimes: 'Повторіть спробу {{times}} у разі невдачі', }, + typeSwitch: {}, }, start: { required: 'обов\'язковий', diff --git a/web/i18n/vi-VN/app.ts b/web/i18n/vi-VN/app.ts index 0968fcb458..d8f80d9df0 100644 --- a/web/i18n/vi-VN/app.ts +++ b/web/i18n/vi-VN/app.ts @@ -172,6 +172,7 @@ const translation = { title: 'Dệt', description: 'Weave là một nền tảng mã nguồn mở để đánh giá, thử nghiệm và giám sát các ứng dụng LLM.', }, + aliyun: {}, }, answerIcon: { description: 'Có nên sử dụng biểu tượng web app để thay thế 🤖 trong ứng dụng được chia sẻ hay không', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 47d8c13f96..05cd279728 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -364,6 +364,7 @@ const translation = { times: 'lần', ms: 'Ms', }, + typeSwitch: {}, }, start: { required: 'bắt buộc', diff --git a/web/i18n/zh-Hant/app.ts b/web/i18n/zh-Hant/app.ts index 41cd6324c9..e5f997daff 100644 --- a/web/i18n/zh-Hant/app.ts +++ b/web/i18n/zh-Hant/app.ts @@ -171,6 +171,7 @@ const translation = { title: '編織', description: 'Weave 是一個開源平台,用於評估、測試和監控大型語言模型應用程序。', }, + aliyun: {}, }, answerIcon: { descriptionInExplore: '是否使用 web app 圖示在 Explore 中取代 🤖', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 3368aee1b7..1d29d2f5ab 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -364,6 +364,7 @@ const translation = { ms: '毫秒', retries: '{{num}}重試', }, + typeSwitch: {}, }, start: { required: '必填', From edf5fd28c9279cb3b53d65591b671aba497582b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E9=9C=B2=E5=85=88=E7=94=9F?= Date: Thu, 10 Jul 2025 14:21:34 +0800 Subject: [PATCH 049/241] update worklow events logs. (#19871) Signed-off-by: zhanluxianshen --- api/core/workflow/callbacks/workflow_logging_callback.py | 6 +++--- api/core/workflow/graph_engine/entities/graph.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/core/workflow/callbacks/workflow_logging_callback.py b/api/core/workflow/callbacks/workflow_logging_callback.py index e6813a3997..12b5203ca3 100644 --- a/api/core/workflow/callbacks/workflow_logging_callback.py +++ b/api/core/workflow/callbacks/workflow_logging_callback.py @@ -232,14 +232,14 @@ class WorkflowLoggingCallback(WorkflowCallback): Publish loop started """ self.print_text("\n[LoopRunStartedEvent]", color="blue") - self.print_text(f"Loop Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue") def on_workflow_loop_next(self, event: LoopRunNextEvent) -> None: """ Publish loop next """ self.print_text("\n[LoopRunNextEvent]", color="blue") - self.print_text(f"Loop Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue") self.print_text(f"Loop Index: {event.index}", color="blue") def on_workflow_loop_completed(self, event: LoopRunSucceededEvent | LoopRunFailedEvent) -> None: @@ -250,7 +250,7 @@ class WorkflowLoggingCallback(WorkflowCallback): "\n[LoopRunSucceededEvent]" if isinstance(event, LoopRunSucceededEvent) else "\n[LoopRunFailedEvent]", color="blue", ) - self.print_text(f"Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue") def print_text(self, text: str, color: Optional[str] = None, end: str = "\n") -> None: """Print text with highlighting and no end characters.""" diff --git a/api/core/workflow/graph_engine/entities/graph.py b/api/core/workflow/graph_engine/entities/graph.py index 8e5b1e7142..362777a199 100644 --- a/api/core/workflow/graph_engine/entities/graph.py +++ b/api/core/workflow/graph_engine/entities/graph.py @@ -334,7 +334,7 @@ class Graph(BaseModel): parallel = GraphParallel( start_from_node_id=start_node_id, - parent_parallel_id=parent_parallel.id if parent_parallel else None, + parent_parallel_id=parent_parallel_id, parent_parallel_start_node_id=parent_parallel.start_from_node_id if parent_parallel else None, ) parallel_mapping[parallel.id] = parallel From 94a13d7d62067f427d2419a5b8541c2236d95a40 Mon Sep 17 00:00:00 2001 From: Yeuoly <45712896+Yeuoly@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:43:31 +0800 Subject: [PATCH 050/241] feat: add support for dark icons in provider and tool entities (#22081) --- api/core/model_runtime/entities/provider_entities.py | 2 ++ api/core/plugin/entities/plugin.py | 1 + api/core/tools/entities/api_entities.py | 2 ++ api/core/tools/entities/tool_entities.py | 1 + api/core/workflow/nodes/tool/tool_node.py | 2 ++ api/services/tools/tools_transform_service.py | 10 ++++++++++ 6 files changed, 18 insertions(+) diff --git a/api/core/model_runtime/entities/provider_entities.py b/api/core/model_runtime/entities/provider_entities.py index d0f9ee13e5..c9aa8d1474 100644 --- a/api/core/model_runtime/entities/provider_entities.py +++ b/api/core/model_runtime/entities/provider_entities.py @@ -123,6 +123,8 @@ class ProviderEntity(BaseModel): description: Optional[I18nObject] = None icon_small: Optional[I18nObject] = None icon_large: Optional[I18nObject] = None + icon_small_dark: Optional[I18nObject] = None + icon_large_dark: Optional[I18nObject] = None background: Optional[str] = None help: Optional[ProviderHelpEntity] = None supported_model_types: Sequence[ModelType] diff --git a/api/core/plugin/entities/plugin.py b/api/core/plugin/entities/plugin.py index d6bbf05097..e5cf7ee03a 100644 --- a/api/core/plugin/entities/plugin.py +++ b/api/core/plugin/entities/plugin.py @@ -79,6 +79,7 @@ class PluginDeclaration(BaseModel): name: str = Field(..., pattern=r"^[a-z0-9_-]{1,128}$") description: I18nObject icon: str + icon_dark: Optional[str] = Field(default=None) label: I18nObject category: PluginCategory created_at: datetime.datetime diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index b94d6bba21..90134ba71d 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -28,6 +28,7 @@ class ToolProviderApiEntity(BaseModel): name: str # identifier description: I18nObject icon: str | dict + icon_dark: Optional[str | dict] = Field(default=None, description="The dark icon of the tool") label: I18nObject # label type: ToolProviderType masked_credentials: Optional[dict] = None @@ -72,6 +73,7 @@ class ToolProviderApiEntity(BaseModel): "plugin_unique_identifier": self.plugin_unique_identifier, "description": self.description.to_dict(), "icon": self.icon, + "icon_dark": self.icon_dark, "label": self.label.to_dict(), "type": self.type.value, "team_credentials": self.masked_credentials, diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index ba5fa5e156..bd216dad64 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -317,6 +317,7 @@ class ToolProviderIdentity(BaseModel): name: str = Field(..., description="The name of the tool") description: I18nObject = Field(..., description="The description of the tool") icon: str = Field(..., description="The icon of the tool") + icon_dark: Optional[str] = Field(default=None, description="The dark icon of the tool") label: I18nObject = Field(..., description="The label of the tool") tags: Optional[list[ToolLabelEnum]] = Field( default=[], diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index f5898dd605..5c6fac9080 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -339,10 +339,12 @@ class ToolNode(BaseNode[ToolNodeData]): if provider.name == dict_metadata["provider"] ) icon = builtin_tool.icon + icon_dark = builtin_tool.icon_dark except StopIteration: pass dict_metadata["icon"] = icon + dict_metadata["icon_dark"] = icon_dark message.message.metadata = dict_metadata agent_log = AgentLogEvent( id=message.message.id, diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index 8009c384b7..ac127ae93e 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -75,10 +75,18 @@ class ToolTransformService: provider.icon = ToolTransformService.get_plugin_icon_url( tenant_id=tenant_id, filename=provider.icon ) + if isinstance(provider.icon_dark, str) and provider.icon_dark: + provider.icon_dark = ToolTransformService.get_plugin_icon_url( + tenant_id=tenant_id, filename=provider.icon_dark + ) else: provider.icon = ToolTransformService.get_tool_provider_icon_url( provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon ) + if provider.icon_dark: + provider.icon_dark = ToolTransformService.get_tool_provider_icon_url( + provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon_dark + ) @classmethod def builtin_provider_to_user_provider( @@ -96,6 +104,7 @@ class ToolTransformService: name=provider_controller.entity.identity.name, description=provider_controller.entity.identity.description, icon=provider_controller.entity.identity.icon, + icon_dark=provider_controller.entity.identity.icon_dark, label=provider_controller.entity.identity.label, type=ToolProviderType.BUILT_IN, masked_credentials={}, @@ -179,6 +188,7 @@ class ToolTransformService: name=provider_controller.entity.identity.name, description=provider_controller.entity.identity.description, icon=provider_controller.entity.identity.icon, + icon_dark=provider_controller.entity.identity.icon_dark, label=provider_controller.entity.identity.label, type=ToolProviderType.WORKFLOW, masked_credentials={}, From c51b4290dc77d194f1c54778dbe14e3eced9ac77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Thu, 10 Jul 2025 16:14:18 +0800 Subject: [PATCH 051/241] fix: mcp server card button display (#22141) --- web/app/components/tools/mcp/mcp-service-card.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx index 443d7a1d1f..c0c542da26 100644 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -1,9 +1,7 @@ 'use client' import React, { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - RiLoopLeftLine, -} from '@remixicon/react' +import { RiEditLine, RiLoopLeftLine } from '@remixicon/react' import { Mcp, } from '@/app/components/base/icons/src/vender/other' @@ -209,7 +207,11 @@ function MCPServiceCard({ variant='ghost' onClick={() => setShowMCPServerModal(true)} > - {serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')} + +
+ +
{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}
+
From 7b2cab576775b4c36bcfd4763b0227c8bf52ffaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Thu, 10 Jul 2025 16:14:46 +0800 Subject: [PATCH 052/241] feat: support ping method for MCP server (#22144) --- api/core/mcp/server/streamable_http.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py index 6b422ae4ae..37eec3cd9c 100644 --- a/api/core/mcp/server/streamable_http.py +++ b/api/core/mcp/server/streamable_http.py @@ -89,6 +89,7 @@ class MCPServerStreamableHTTPRequestHandler: types.ListToolsRequest: self.list_tools, types.CallToolRequest: self.invoke_tool, types.InitializedNotification: self.handle_notification, + types.PingRequest: self.handle_ping, } try: if self.request_type in handle_map: @@ -105,6 +106,9 @@ class MCPServerStreamableHTTPRequestHandler: def handle_notification(self): return "ping" + def handle_ping(self): + return types.EmptyResult() + def initialize(self): request = cast(types.InitializeRequest, self.request.root) client_info = request.params.clientInfo From 0e793a660de9f7f47a177ad76cdb36071972c2e7 Mon Sep 17 00:00:00 2001 From: Novice Date: Thu, 10 Jul 2025 17:13:48 +0800 Subject: [PATCH 053/241] fix: add the default value to the dark icon (#22149) --- api/core/workflow/nodes/tool/tool_node.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 5c6fac9080..48627a229d 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -329,6 +329,7 @@ class ToolNode(BaseNode[ToolNodeData]): icon = current_plugin.declaration.icon except StopIteration: pass + icon_dark = None try: builtin_tool = next( provider From 11f9a897e89a383c08730193b0de2660cdfc59af Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 10 Jul 2025 17:33:11 +0800 Subject: [PATCH 054/241] chore: fix schema editor can not hover item (#22155) --- .../visual-editor/schema-node.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx index 96bbf999db..36671ab050 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx @@ -79,13 +79,13 @@ const SchemaNode: FC = ({ } const handleMouseEnter = () => { - if(!readOnly) return + if(readOnly) return if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(path.join('.')) } const handleMouseLeave = () => { - if(!readOnly) return + if(readOnly) return if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(null) } @@ -95,7 +95,7 @@ const SchemaNode: FC = ({
{depth > 0 && hasChildren && (
Date: Thu, 10 Jul 2025 17:49:32 +0800 Subject: [PATCH 055/241] chore(version): bump to 1.6.0 (#22136) --- api/pyproject.toml | 2 +- api/uv.lock | 2 +- docker/docker-compose-template.yaml | 6 +++--- docker/docker-compose.yaml | 6 +++--- web/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 9f2e3ed331..420bc771b6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.5.1" +version = "1.6.0" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/api/uv.lock b/api/uv.lock index 45831e24a1..e108e0c445 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1217,7 +1217,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.5.1" +version = "1.6.0" source = { virtual = "." } dependencies = [ { name = "arize-phoenix-otel" }, diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index fd7c78c7e7..7c1544acb9 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env services: # API service api: - image: langgenius/dify-api:1.5.1 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -31,7 +31,7 @@ services: # worker service # The Celery worker for processing the queue. worker: - image: langgenius/dify-api:1.5.1 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -57,7 +57,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.5.1 + image: langgenius/dify-web:1.6.0 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0a95251ff0..647af62d96 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -518,7 +518,7 @@ x-shared-env: &shared-api-worker-env services: # API service api: - image: langgenius/dify-api:1.5.1 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -547,7 +547,7 @@ services: # worker service # The Celery worker for processing the queue. worker: - image: langgenius/dify-api:1.5.1 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -573,7 +573,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.5.1 + image: langgenius/dify-web:1.6.0 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/web/package.json b/web/package.json index 254c2ec1fd..c9219b53d0 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "dify-web", - "version": "1.5.1", + "version": "1.6.0", "private": true, "engines": { "node": ">=v22.11.0" From f4df80e093dfc433013fde12f52bb1b32b3f7b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E6=B5=93?= Date: Thu, 10 Jul 2025 20:56:45 +0800 Subject: [PATCH 056/241] fix(custom_tool): omit optional parameters instead of setting them to None (#22171) --- api/core/tools/custom_tool/tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/core/tools/custom_tool/tool.py b/api/core/tools/custom_tool/tool.py index 2f5cc6d4c0..5cba4cf7f5 100644 --- a/api/core/tools/custom_tool/tool.py +++ b/api/core/tools/custom_tool/tool.py @@ -213,7 +213,8 @@ class ApiTool(Tool): elif "default" in property: body[name] = property["default"] else: - body[name] = None + # omit optional parameters that weren't provided, instead of setting them to None + pass break # replace path parameters From f929bfb94c240d87dbd22599f091129e0c45ef68 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:40:17 +0800 Subject: [PATCH 057/241] minor fix: remove duplicates, fix typo, and add restriction for get mcp server (#22170) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/controllers/console/app/mcp_server.py | 6 +++++- api/core/mcp/server/streamable_http.py | 4 ++-- api/services/tools/mcp_tools_mange_service.py | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 4f9e75c0d3..ccda97d80c 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -90,7 +90,11 @@ class AppMCPServerRefreshController(Resource): def get(self, server_id): if not current_user.is_editor: raise NotFound() - server = db.session.query(AppMCPServer).filter(AppMCPServer.id == server_id).first() + server = ( + db.session.query(AppMCPServer) + .filter(AppMCPServer.id == server_id and AppMCPServer.tenant_id == current_user.current_tenant_id) + .first() + ) if not server: raise NotFound() server.server_code = AppMCPServer.generate_server_code(16) diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py index 37eec3cd9c..1c2cf570e2 100644 --- a/api/core/mcp/server/streamable_http.py +++ b/api/core/mcp/server/streamable_http.py @@ -112,13 +112,13 @@ class MCPServerStreamableHTTPRequestHandler: def initialize(self): request = cast(types.InitializeRequest, self.request.root) client_info = request.params.clientInfo - clinet_name = f"{client_info.name}@{client_info.version}" + client_name = f"{client_info.name}@{client_info.version}" if not self.end_user: end_user = EndUser( tenant_id=self.app.tenant_id, app_id=self.app.id, type="mcp", - name=clinet_name, + name=client_name, session_id=generate_session_id(), external_user_id=self.mcp_server.id, ) diff --git a/api/services/tools/mcp_tools_mange_service.py b/api/services/tools/mcp_tools_mange_service.py index 3b1592230a..7c23abda4b 100644 --- a/api/services/tools/mcp_tools_mange_service.py +++ b/api/services/tools/mcp_tools_mange_service.py @@ -69,7 +69,6 @@ class MCPToolManageService: MCPToolProvider.server_url_hash == server_url_hash, MCPToolProvider.server_identifier == server_identifier, ), - MCPToolProvider.tenant_id == tenant_id, ) .first() ) From e576b989b8eca4a7d8cfe6bdc91e9a451580489b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E6=B5=93?= Date: Fri, 11 Jul 2025 10:39:20 +0800 Subject: [PATCH 058/241] feat(tool): add support for API key authentication via query parameter (#21656) --- api/core/tools/custom_tool/provider.py | 28 ++++++++-- api/core/tools/custom_tool/tool.py | 18 ++++++- api/core/tools/entities/tool_entities.py | 3 +- api/core/tools/tool_manager.py | 18 ++++++- api/services/tools/tools_transform_service.py | 11 ++-- .../config-credentials.tsx | 52 ++++++++++++++++--- web/app/components/tools/types.ts | 4 +- web/i18n/en-US/tools.ts | 6 ++- web/i18n/zh-Hans/tools.ts | 6 ++- 9 files changed, 126 insertions(+), 20 deletions(-) diff --git a/api/core/tools/custom_tool/provider.py b/api/core/tools/custom_tool/provider.py index 3137d32013..fbe1d79137 100644 --- a/api/core/tools/custom_tool/provider.py +++ b/api/core/tools/custom_tool/provider.py @@ -39,19 +39,22 @@ class ApiToolProviderController(ToolProviderController): type=ProviderConfig.Type.SELECT, options=[ ProviderConfig.Option(value="none", label=I18nObject(en_US="None", zh_Hans="无")), - ProviderConfig.Option(value="api_key", label=I18nObject(en_US="api_key", zh_Hans="api_key")), + ProviderConfig.Option(value="api_key_header", label=I18nObject(en_US="Header", zh_Hans="请求头")), + ProviderConfig.Option( + value="api_key_query", label=I18nObject(en_US="Query Param", zh_Hans="查询参数") + ), ], default="none", help=I18nObject(en_US="The auth type of the api provider", zh_Hans="api provider 的认证类型"), ) ] - if auth_type == ApiProviderAuthType.API_KEY: + if auth_type == ApiProviderAuthType.API_KEY_HEADER: credentials_schema = [ *credentials_schema, ProviderConfig( name="api_key_header", required=False, - default="api_key", + default="Authorization", type=ProviderConfig.Type.TEXT_INPUT, help=I18nObject(en_US="The header name of the api key", zh_Hans="携带 api key 的 header 名称"), ), @@ -74,6 +77,25 @@ class ApiToolProviderController(ToolProviderController): ], ), ] + elif auth_type == ApiProviderAuthType.API_KEY_QUERY: + credentials_schema = [ + *credentials_schema, + ProviderConfig( + name="api_key_query_param", + required=False, + default="key", + type=ProviderConfig.Type.TEXT_INPUT, + help=I18nObject( + en_US="The query parameter name of the api key", zh_Hans="携带 api key 的查询参数名称" + ), + ), + ProviderConfig( + name="api_key_value", + required=True, + type=ProviderConfig.Type.SECRET_INPUT, + help=I18nObject(en_US="The api key", zh_Hans="api key 的值"), + ), + ] elif auth_type == ApiProviderAuthType.NONE: pass diff --git a/api/core/tools/custom_tool/tool.py b/api/core/tools/custom_tool/tool.py index 5cba4cf7f5..10653b9948 100644 --- a/api/core/tools/custom_tool/tool.py +++ b/api/core/tools/custom_tool/tool.py @@ -78,8 +78,8 @@ class ApiTool(Tool): if "auth_type" not in credentials: raise ToolProviderCredentialValidationError("Missing auth_type") - if credentials["auth_type"] == "api_key": - api_key_header = "api_key" + if credentials["auth_type"] in ("api_key_header", "api_key"): # backward compatibility: + api_key_header = "Authorization" if "api_key_header" in credentials: api_key_header = credentials["api_key_header"] @@ -100,6 +100,11 @@ class ApiTool(Tool): headers[api_key_header] = credentials["api_key_value"] + elif credentials["auth_type"] == "api_key_query": + # For query parameter authentication, we don't add anything to headers + # The query parameter will be added in do_http_request method + pass + needed_parameters = [parameter for parameter in (self.api_bundle.parameters or []) if parameter.required] for parameter in needed_parameters: if parameter.required and parameter.name not in parameters: @@ -154,6 +159,15 @@ class ApiTool(Tool): cookies = {} files = [] + # Add API key to query parameters if auth_type is api_key_query + if self.runtime and self.runtime.credentials: + credentials = self.runtime.credentials + if credentials.get("auth_type") == "api_key_query": + api_key_query_param = credentials.get("api_key_query_param", "key") + api_key_value = credentials.get("api_key_value") + if api_key_value: + params[api_key_query_param] = api_key_value + # check parameters for parameter in self.api_bundle.openapi.get("parameters", []): value = self.get_parameter_value(parameter, parameters) diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index bd216dad64..b5148e245f 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -96,7 +96,8 @@ class ApiProviderAuthType(Enum): """ NONE = "none" - API_KEY = "api_key" + API_KEY_HEADER = "api_key_header" + API_KEY_QUERY = "api_key_query" @classmethod def value_of(cls, value: str) -> "ApiProviderAuthType": diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index adae56cd27..22a9853b41 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -684,9 +684,16 @@ class ToolManager: if provider is None: raise ToolProviderNotFoundError(f"api provider {provider_id} not found") + auth_type = ApiProviderAuthType.NONE + provider_auth_type = provider.credentials.get("auth_type") + if provider_auth_type in ("api_key_header", "api_key"): # backward compatibility + auth_type = ApiProviderAuthType.API_KEY_HEADER + elif provider_auth_type == "api_key_query": + auth_type = ApiProviderAuthType.API_KEY_QUERY + controller = ApiToolProviderController.from_db( provider, - ApiProviderAuthType.API_KEY if provider.credentials["auth_type"] == "api_key" else ApiProviderAuthType.NONE, + auth_type, ) controller.load_bundled_tools(provider.tools) @@ -745,9 +752,16 @@ class ToolManager: credentials = {} # package tool provider controller + auth_type = ApiProviderAuthType.NONE + credentials_auth_type = credentials.get("auth_type") + if credentials_auth_type in ("api_key_header", "api_key"): # backward compatibility + auth_type = ApiProviderAuthType.API_KEY_HEADER + elif credentials_auth_type == "api_key_query": + auth_type = ApiProviderAuthType.API_KEY_QUERY + controller = ApiToolProviderController.from_db( provider_obj, - ApiProviderAuthType.API_KEY if credentials["auth_type"] == "api_key" else ApiProviderAuthType.NONE, + auth_type, ) # init tool configuration tool_configuration = ProviderConfigEncrypter( diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index ac127ae93e..3d0c35cd9b 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -159,11 +159,16 @@ class ToolTransformService: convert provider controller to user provider """ # package tool provider controller + auth_type = ApiProviderAuthType.NONE + credentials_auth_type = db_provider.credentials.get("auth_type") + if credentials_auth_type in ("api_key_header", "api_key"): # backward compatibility + auth_type = ApiProviderAuthType.API_KEY_HEADER + elif credentials_auth_type == "api_key_query": + auth_type = ApiProviderAuthType.API_KEY_QUERY + controller = ApiToolProviderController.from_db( db_provider=db_provider, - auth_type=ApiProviderAuthType.API_KEY - if db_provider.credentials["auth_type"] == "api_key" - else ApiProviderAuthType.NONE, + auth_type=auth_type, ) return controller diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx index cbf1048b09..f0ad13f9b1 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx @@ -68,23 +68,34 @@ const ConfigCredential: FC = ({ text={t('tools.createTool.authMethod.types.none')} value={AuthType.none} isChecked={tempCredential.auth_type === AuthType.none} - onClick={value => setTempCredential({ ...tempCredential, auth_type: value as AuthType })} + onClick={value => setTempCredential({ + auth_type: value as AuthType, + })} /> setTempCredential({ - ...tempCredential, auth_type: value as AuthType, api_key_header: tempCredential.api_key_header || 'Authorization', api_key_value: tempCredential.api_key_value || '', api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom, })} /> + setTempCredential({ + auth_type: value as AuthType, + api_key_query_param: tempCredential.api_key_query_param || 'key', + api_key_value: tempCredential.api_key_value || '', + })} + />
- {tempCredential.auth_type === AuthType.apiKey && ( + {tempCredential.auth_type === AuthType.apiKeyHeader && ( <>
{t('tools.createTool.authHeaderPrefix.title')}
@@ -136,6 +147,35 @@ const ConfigCredential: FC = ({ />
)} + {tempCredential.auth_type === AuthType.apiKeyQuery && ( + <> +
+
+ {t('tools.createTool.authMethod.queryParam')} + + {t('tools.createTool.authMethod.queryParamTooltip')} +
+ } + triggerClassName='ml-0.5 w-4 h-4' + /> +
+ setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })} + placeholder={t('tools.createTool.authMethod.types.queryParamPlaceholder')!} + /> +
+
+
{t('tools.createTool.authMethod.value')}
+ setTempCredential({ ...tempCredential, api_key_value: e.target.value })} + placeholder={t('tools.createTool.authMethod.types.apiValuePlaceholder')!} + /> +
+ )}
diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index d444ee1f38..b83919ad18 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -7,7 +7,8 @@ export enum LOC { export enum AuthType { none = 'none', - apiKey = 'api_key', + apiKeyHeader = 'api_key_header', + apiKeyQuery = 'api_key_query', } export enum AuthHeaderPrefix { @@ -21,6 +22,7 @@ export type Credential = { api_key_header?: string api_key_value?: string api_key_header_prefix?: AuthHeaderPrefix + api_key_query_param?: string } export enum CollectionType { diff --git a/web/i18n/en-US/tools.ts b/web/i18n/en-US/tools.ts index 418d1cb076..4e1ce1308a 100644 --- a/web/i18n/en-US/tools.ts +++ b/web/i18n/en-US/tools.ts @@ -80,11 +80,15 @@ const translation = { title: 'Authorization method', type: 'Authorization type', keyTooltip: 'Http Header Key, You can leave it with "Authorization" if you have no idea what it is or set it to a custom value', + queryParam: 'Query Parameter', + queryParamTooltip: 'The name of the API key query parameter to pass, e.g. "key" in "https://example.com/test?key=API_KEY".', types: { none: 'None', - api_key: 'API Key', + api_key_header: 'Header', + api_key_query: 'Query Param', apiKeyPlaceholder: 'HTTP header name for API Key', apiValuePlaceholder: 'Enter API Key', + queryParamPlaceholder: 'Query parameter name for API Key', }, key: 'Key', value: 'Value', diff --git a/web/i18n/zh-Hans/tools.ts b/web/i18n/zh-Hans/tools.ts index 4e0ccf476f..5c1eb13236 100644 --- a/web/i18n/zh-Hans/tools.ts +++ b/web/i18n/zh-Hans/tools.ts @@ -80,11 +80,15 @@ const translation = { title: '鉴权方法', type: '鉴权类型', keyTooltip: 'HTTP 头部名称,如果你不知道是什么,可以将其保留为 Authorization 或设置为自定义值', + queryParam: '查询参数', + queryParamTooltip: '用于传递 API 密钥查询参数的名称, 如 "https://example.com/test?key=API_KEY" 中的 "key"参数', types: { none: '无', - api_key: 'API Key', + api_key_header: '请求头', + api_key_query: '查询参数', apiKeyPlaceholder: 'HTTP 头部名称,用于传递 API Key', apiValuePlaceholder: '输入 API Key', + queryParamPlaceholder: '查询参数名称,用于传递 API Key', }, key: '键', value: '值', From c805238471eb4f15daa830730dbc828bd468f051 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:17:28 +0800 Subject: [PATCH 059/241] fix: adjust layout styles for header and dataset update (#22182) --- web/app/components/datasets/create/index.tsx | 2 +- .../notion-page-preview/index.module.css | 2 +- .../datasets/create/step-one/index.tsx | 43 ++++++++++++------- web/app/components/header/index.tsx | 2 +- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index b1e4087226..a1ff2f5d87 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -122,7 +122,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { return return ( -
+
{step === 1 && setShowModal(true) const modalCloseHandle = () => setShowModal(false) - const updateCurrentFile = (file: File) => { + const updateCurrentFile = useCallback((file: File) => { setCurrentFile(file) - } - const hideFilePreview = () => { + }, []) + + const hideFilePreview = useCallback(() => { setCurrentFile(undefined) - } + }, []) - const updateCurrentPage = (page: NotionPage) => { + const updateCurrentPage = useCallback((page: NotionPage) => { setCurrentNotionPage(page) - } + }, []) - const hideNotionPagePreview = () => { + const hideNotionPagePreview = useCallback(() => { setCurrentNotionPage(undefined) - } + }, []) - const hideWebsitePreview = () => { + const updateWebsite = useCallback((website: CrawlResultItem) => { + setCurrentWebsite(website) + }, []) + + const hideWebsitePreview = useCallback(() => { setCurrentWebsite(undefined) - } + }, []) const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type) const isInCreatePage = shouldShowDataSourceTypeList @@ -139,7 +144,7 @@ const StepOne = ({
{ shouldShowDataSourceTypeList && ( -
+
{t('datasetCreation.steps.one')}
) @@ -158,8 +163,8 @@ const StepOne = ({ if (dataSourceTypeDisable) return changeType(DataSourceType.FILE) - hideFilePreview() hideNotionPagePreview() + hideWebsitePreview() }} > @@ -182,7 +187,7 @@ const StepOne = ({ return changeType(DataSourceType.NOTION) hideFilePreview() - hideNotionPagePreview() + hideWebsitePreview() }} > @@ -201,7 +206,13 @@ const StepOne = ({ dataSourceType === DataSourceType.WEB && s.active, dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled, )} - onClick={() => changeType(DataSourceType.WEB)} + onClick={() => { + if (dataSourceTypeDisable) + return + changeType(DataSourceType.WEB) + hideFilePreview() + hideNotionPagePreview() + }} >
{ } return ( -
+
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo From d5624ba671201137c32b80ad6e2131feb85fed19 Mon Sep 17 00:00:00 2001 From: K <84141602+krikera@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:41:59 +0530 Subject: [PATCH 060/241] fix: resolve Docker file URL networking issue for plugins (#21334) (#21382) Co-authored-by: crazywoola <427733928@qq.com> --- api/.env.example | 5 +++++ api/configs/feature/__init__.py | 7 +++++++ api/core/file/helpers.py | 4 +++- api/core/tools/signature.py | 5 +++-- api/core/tools/tool_file_manager.py | 5 +++-- docker/.env.example | 5 +++++ docker/docker-compose.yaml | 1 + 7 files changed, 27 insertions(+), 5 deletions(-) diff --git a/api/.env.example b/api/.env.example index baa9c382c8..a7ea6cf937 100644 --- a/api/.env.example +++ b/api/.env.example @@ -17,6 +17,11 @@ APP_WEB_URL=http://127.0.0.1:3000 # Files URL FILES_URL=http://127.0.0.1:5001 +# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. +# Set this to the internal Docker service URL for proper plugin file access. +# Example: INTERNAL_FILES_URL=http://api:5001 +INTERNAL_FILES_URL=http://127.0.0.1:5001 + # The time in seconds after the signature is rejected FILES_ACCESS_TIMEOUT=300 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index df15b92c35..963fcbedf9 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -237,6 +237,13 @@ class FileAccessConfig(BaseSettings): default="", ) + INTERNAL_FILES_URL: str = Field( + description="Internal base URL for file access within Docker network," + " used for plugin daemon and internal service communication." + " Falls back to FILES_URL if not specified.", + default="", + ) + FILES_ACCESS_TIMEOUT: int = Field( description="Expiration time in seconds for file access URLs", default=300, diff --git a/api/core/file/helpers.py b/api/core/file/helpers.py index 73fabdb11b..335ad2266a 100644 --- a/api/core/file/helpers.py +++ b/api/core/file/helpers.py @@ -21,7 +21,9 @@ def get_signed_file_url(upload_file_id: str) -> str: def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, user_id: str) -> str: - url = f"{dify_config.FILES_URL}/files/upload/for-plugin" + # Plugin access should use internal URL for Docker network communication + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + url = f"{base_url}/files/upload/for-plugin" if user_id is None: user_id = "DEFAULT-USER" diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index e80005d7bf..5cdf473542 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -9,9 +9,10 @@ from configs import dify_config def sign_tool_file(tool_file_id: str, extension: str) -> str: """ - sign file to get a temporary url + sign file to get a temporary url for plugin access """ - base_url = dify_config.FILES_URL + # Use internal URL for plugin/tool file access in Docker environments + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}" timestamp = str(int(time.time())) diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index b849f51064..ece02f9d59 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -35,9 +35,10 @@ class ToolFileManager: @staticmethod def sign_file(tool_file_id: str, extension: str) -> str: """ - sign file to get a temporary url + sign file to get a temporary url for plugin access """ - base_url = dify_config.FILES_URL + # Use internal URL for plugin/tool file access in Docker environments + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}" timestamp = str(int(time.time())) diff --git a/docker/.env.example b/docker/.env.example index a403f25cb2..84b6152f0a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -47,6 +47,11 @@ APP_WEB_URL= # ensuring port 5001 is externally accessible (see docker-compose.yaml). FILES_URL= +# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. +# Set this to the internal Docker service URL for proper plugin file access. +# Example: INTERNAL_FILES_URL=http://api:5001 +INTERNAL_FILES_URL= + # ------------------------------ # Server Configuration # ------------------------------ diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 647af62d96..ac9953aa33 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -11,6 +11,7 @@ x-shared-env: &shared-api-worker-env APP_API_URL: ${APP_API_URL:-} APP_WEB_URL: ${APP_WEB_URL:-} FILES_URL: ${FILES_URL:-} + INTERNAL_FILES_URL: ${INTERNAL_FILES_URL:-} LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_FILE: ${LOG_FILE:-/app/logs/server.log} LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20} From 9a9ec0c99bd7ddbf4b1aaa8767e4280510f8c67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20D=C3=ADaz?= <40875838+marcelodiaz558@users.noreply.github.com> Date: Fri, 11 Jul 2025 02:04:42 -0400 Subject: [PATCH 061/241] feat: Add Audio configuration setting to app configuration UI (#21957) --- .../app/configuration/config/config-audio.tsx | 78 +++++++++++++++++++ .../app/configuration/config/index.tsx | 3 + .../components/app/configuration/index.tsx | 2 + web/context/debug-configuration.ts | 2 + web/i18n/en-US/app-debug.ts | 4 + 5 files changed, 89 insertions(+) create mode 100644 web/app/components/app/configuration/config/config-audio.tsx diff --git a/web/app/components/app/configuration/config/config-audio.tsx b/web/app/components/app/configuration/config/config-audio.tsx new file mode 100644 index 0000000000..5600f8cbb6 --- /dev/null +++ b/web/app/components/app/configuration/config/config-audio.tsx @@ -0,0 +1,78 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import produce from 'immer' +import { useContext } from 'use-context-selector' + +import { Microphone01 } from '@/app/components/base/icons/src/vender/features' +import Tooltip from '@/app/components/base/tooltip' +import ConfigContext from '@/context/debug-configuration' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' +import Switch from '@/app/components/base/switch' + +const ConfigAudio: FC = () => { + const { t } = useTranslation() + const file = useFeatures(s => s.features.file) + const featuresStore = useFeaturesStore() + const { isShowAudioConfig } = useContext(ConfigContext) + + const isAudioEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.audio) ?? false + + const handleChange = useCallback((value: boolean) => { + const { + features, + setFeatures, + } = featuresStore!.getState() + + const newFeatures = produce(features, (draft) => { + if (value) { + draft.file!.allowed_file_types = Array.from(new Set([ + ...(draft.file?.allowed_file_types || []), + SupportUploadFileTypes.audio, + ])) + } + else { + draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( + type => type !== SupportUploadFileTypes.audio, + ) + } + if (draft.file) + draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 + }) + setFeatures(newFeatures) + }, [featuresStore]) + + if (!isShowAudioConfig) + return null + + return ( +
+
+
+ +
+
+
+
{t('appDebug.feature.audioUpload.title')}
+ + {t('appDebug.feature.audioUpload.description')} +
+ } + /> +
+
+
+ +
+
+ ) +} +export default React.memo(ConfigAudio) diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index dc2095502e..d0375c6de9 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -8,6 +8,7 @@ import DatasetConfig from '../dataset-config' import HistoryPanel from '../config-prompt/conversation-history/history-panel' import ConfigVision from '../config-vision' import ConfigDocument from './config-document' +import ConfigAudio from './config-audio' import AgentTools from './agent/agent-tools' import ConfigContext from '@/context/debug-configuration' import ConfigPrompt from '@/app/components/app/configuration/config-prompt' @@ -85,6 +86,8 @@ const Config: FC = () => { + + {/* Chat History */} {isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && ( { 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) // *** web app features *** const featuresData: FeaturesData = useMemo(() => { @@ -920,6 +921,7 @@ const Configuration: FC = () => { setVisionConfig: handleSetVisionConfig, isAllowVideoUpload, isShowDocumentConfig, + isShowAudioConfig, rerankSettingModalOpen, setRerankSettingModalOpen, }} diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index 47710c8fc1..cb737c5288 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -102,6 +102,7 @@ type IDebugConfiguration = { setVisionConfig: (visionConfig: VisionSettings, noNotice?: boolean) => void isAllowVideoUpload: boolean isShowDocumentConfig: boolean + isShowAudioConfig: boolean rerankSettingModalOpen: boolean setRerankSettingModalOpen: (rerankSettingModalOpen: boolean) => void } @@ -254,6 +255,7 @@ const DebugConfigurationContext = createContext({ setVisionConfig: noop, isAllowVideoUpload: false, isShowDocumentConfig: false, + isShowAudioConfig: false, rerankSettingModalOpen: false, setRerankSettingModalOpen: noop, }) diff --git a/web/i18n/en-US/app-debug.ts b/web/i18n/en-US/app-debug.ts index 349ff37118..5282dab360 100644 --- a/web/i18n/en-US/app-debug.ts +++ b/web/i18n/en-US/app-debug.ts @@ -222,6 +222,10 @@ const translation = { title: 'Document', description: 'Enable Document will allows the model to take in documents and answer questions about them.', }, + audioUpload: { + title: 'Audio', + description: 'Enable Audio will allow the model to process audio files for transcription and analysis.', + }, }, codegen: { title: 'Code Generator', From fe4e2f7921e4362f699cb07f2bcd75f5774632ba Mon Sep 17 00:00:00 2001 From: le0zh Date: Fri, 11 Jul 2025 15:07:32 +0800 Subject: [PATCH 062/241] feat: support var in suggested questions (#17340) Co-authored-by: crazywoola <427733928@qq.com> --- web/app/components/base/chat/chat/hooks.ts | 5 ++--- .../features/new-feature-panel/conversation-opener/modal.tsx | 1 + web/app/components/workflow/panel/debug-and-preview/hooks.ts | 4 ++-- web/i18n/de-DE/app-debug.ts | 1 + web/i18n/en-US/app-debug.ts | 1 + web/i18n/es-ES/app-debug.ts | 1 + web/i18n/fa-IR/app-debug.ts | 1 + web/i18n/fr-FR/app-debug.ts | 1 + web/i18n/hi-IN/app-debug.ts | 1 + web/i18n/it-IT/app-debug.ts | 1 + web/i18n/ja-JP/app-debug.ts | 1 + web/i18n/ko-KR/app-debug.ts | 1 + web/i18n/pl-PL/app-debug.ts | 1 + web/i18n/pt-BR/app-debug.ts | 1 + web/i18n/ru-RU/app-debug.ts | 1 + web/i18n/tr-TR/app-debug.ts | 1 + web/i18n/uk-UA/app-debug.ts | 1 + web/i18n/vi-VN/app-debug.ts | 1 + web/i18n/zh-Hans/app-debug.ts | 1 + web/i18n/zh-Hant/app-debug.ts | 1 + 20 files changed, 22 insertions(+), 5 deletions(-) diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 10fb455d33..17373cec9d 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -83,12 +83,11 @@ export const useChat = ( const ret = [...threadMessages] if (config?.opening_statement) { const index = threadMessages.findIndex(item => item.isOpeningStatement) - if (index > -1) { ret[index] = { ...ret[index], content: getIntroduction(config.opening_statement), - suggestedQuestions: config.suggested_questions, + suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)), } } else { @@ -97,7 +96,7 @@ export const useChat = ( content: getIntroduction(config.opening_statement), isAnswer: true, isOpeningStatement: true, - suggestedQuestions: config.suggested_questions, + suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)), }) } } diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index 117c8a5558..51e33c43d2 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -130,6 +130,7 @@ const OpeningSettingModal = ({ { const value = e.target.value setTempSuggestedQuestions(tempSuggestedQuestions.map((item, i) => { diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 26d604ef11..d98cf49812 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -92,7 +92,7 @@ export const useChat = ( ret[index] = { ...ret[index], content: getIntroduction(config.opening_statement), - suggestedQuestions: config.suggested_questions, + suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)), } } else { @@ -101,7 +101,7 @@ export const useChat = ( content: getIntroduction(config.opening_statement), isAnswer: true, isOpeningStatement: true, - suggestedQuestions: config.suggested_questions, + suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)), }) } } diff --git a/web/i18n/de-DE/app-debug.ts b/web/i18n/de-DE/app-debug.ts index 4022e755e9..1adaf6f05d 100644 --- a/web/i18n/de-DE/app-debug.ts +++ b/web/i18n/de-DE/app-debug.ts @@ -298,6 +298,7 @@ const translation = { add: 'Hinzufügen', writeOpener: 'Eröffnung schreiben', placeholder: 'Schreiben Sie hier Ihre Eröffnungsnachricht, Sie können Variablen verwenden, versuchen Sie {{Variable}} zu tippen.', + openingQuestionPlaceholder: 'Sie können Variablen verwenden, versuchen Sie {{variable}} einzugeben.', openingQuestion: 'Eröffnungsfragen', noDataPlaceHolder: 'Den Dialog mit dem Benutzer zu beginnen, kann helfen, in konversationellen Anwendungen eine engere Verbindung mit ihnen herzustellen.', diff --git a/web/i18n/en-US/app-debug.ts b/web/i18n/en-US/app-debug.ts index 5282dab360..938cb27c12 100644 --- a/web/i18n/en-US/app-debug.ts +++ b/web/i18n/en-US/app-debug.ts @@ -446,6 +446,7 @@ const translation = { writeOpener: 'Edit opener', placeholder: 'Write your opener message here, you can use variables, try type {{variable}}.', openingQuestion: 'Opening Questions', + openingQuestionPlaceholder: 'You can use variables, try typing {{variable}}.', noDataPlaceHolder: 'Starting the conversation with the user can help AI establish a closer connection with them in conversational applications.', varTip: 'You can use variables, try type {{variable}}', diff --git a/web/i18n/es-ES/app-debug.ts b/web/i18n/es-ES/app-debug.ts index 8c986bf669..afdea66338 100644 --- a/web/i18n/es-ES/app-debug.ts +++ b/web/i18n/es-ES/app-debug.ts @@ -329,6 +329,7 @@ const translation = { writeOpener: 'Escribir apertura', placeholder: 'Escribe tu mensaje de apertura aquí, puedes usar variables, intenta escribir {{variable}}.', openingQuestion: 'Preguntas de Apertura', + openingQuestionPlaceholder: 'Puede usar variables, intente escribir {{variable}}.', noDataPlaceHolder: 'Iniciar la conversación con el usuario puede ayudar a la IA a establecer una conexión más cercana con ellos en aplicaciones de conversación.', varTip: 'Puedes usar variables, intenta escribir {{variable}}', tooShort: 'Se requieren al menos 20 palabras en la indicación inicial para generar una apertura de conversación.', diff --git a/web/i18n/fa-IR/app-debug.ts b/web/i18n/fa-IR/app-debug.ts index 5cf9c15efe..75085ef30e 100644 --- a/web/i18n/fa-IR/app-debug.ts +++ b/web/i18n/fa-IR/app-debug.ts @@ -364,6 +364,7 @@ const translation = { writeOpener: 'نوشتن آغازگر', placeholder: 'پیام آغازگر خود را اینجا بنویسید، می‌توانید از متغیرها استفاده کنید، سعی کنید {{variable}} را تایپ کنید.', openingQuestion: 'سوالات آغازین', + openingQuestionPlaceholder: 'می‌توانید از متغیرها استفاده کنید، سعی کنید {{variable}} را تایپ کنید.', noDataPlaceHolder: 'شروع مکالمه با کاربر می‌تواند به AI کمک کند تا ارتباط نزدیک‌تری با آنها برقرار کند.', varTip: 'می‌توانید از متغیرها استفاده کنید، سعی کنید {{variable}} را تایپ کنید', tooShort: 'حداقل 20 کلمه از پرسش اولیه برای تولید نظرات آغازین مکالمه مورد نیاز است.', diff --git a/web/i18n/fr-FR/app-debug.ts b/web/i18n/fr-FR/app-debug.ts index 6671092930..f3984c0435 100644 --- a/web/i18n/fr-FR/app-debug.ts +++ b/web/i18n/fr-FR/app-debug.ts @@ -317,6 +317,7 @@ const translation = { writeOpener: 'Écrire l\'introduction', placeholder: 'Rédigez votre message d\'ouverture ici, vous pouvez utiliser des variables, essayez de taper {{variable}}.', openingQuestion: 'Questions d\'ouverture', + openingQuestionPlaceholder: 'Vous pouvez utiliser des variables, essayez de taper {{variable}}.', noDataPlaceHolder: 'Commencer la conversation avec l\'utilisateur peut aider l\'IA à établir une connexion plus proche avec eux dans les applications conversationnelles.', varTip: 'Vous pouvez utiliser des variables, essayez de taper {{variable}}', diff --git a/web/i18n/hi-IN/app-debug.ts b/web/i18n/hi-IN/app-debug.ts index 3f4b06c08b..ded2af4132 100644 --- a/web/i18n/hi-IN/app-debug.ts +++ b/web/i18n/hi-IN/app-debug.ts @@ -362,6 +362,7 @@ const translation = { placeholder: 'यहां अपना प्रारंभक संदेश लिखें, आप वेरिएबल्स का उपयोग कर सकते हैं, {{variable}} टाइप करने का प्रयास करें।', openingQuestion: 'प्रारंभिक प्रश्न', + openingQuestionPlaceholder: 'आप वेरिएबल्स का उपयोग कर सकते हैं, {{variable}} टाइप करके देखें।', noDataPlaceHolder: 'उपयोगकर्ता के साथ संवाद प्रारंभ करने से एआई को संवादात्मक अनुप्रयोगों में उनके साथ निकट संबंध स्थापित करने में मदद मिल सकती है।', varTip: diff --git a/web/i18n/it-IT/app-debug.ts b/web/i18n/it-IT/app-debug.ts index c8b3c08302..bfa75b282b 100644 --- a/web/i18n/it-IT/app-debug.ts +++ b/web/i18n/it-IT/app-debug.ts @@ -365,6 +365,7 @@ const translation = { placeholder: 'Scrivi qui il tuo messaggio introduttivo, puoi usare variabili, prova a scrivere {{variable}}.', openingQuestion: 'Domande iniziali', + openingQuestionPlaceholder: 'Puoi usare variabili, prova a digitare {{variable}}.', noDataPlaceHolder: 'Iniziare la conversazione con l\'utente può aiutare l\'IA a stabilire un legame più stretto con loro nelle applicazioni conversazionali.', varTip: 'Puoi usare variabili, prova a scrivere {{variable}}', diff --git a/web/i18n/ja-JP/app-debug.ts b/web/i18n/ja-JP/app-debug.ts index f862f3f2f7..decbe4863e 100644 --- a/web/i18n/ja-JP/app-debug.ts +++ b/web/i18n/ja-JP/app-debug.ts @@ -434,6 +434,7 @@ const translation = { writeOpener: 'オープナーを書く', placeholder: 'ここにオープナーメッセージを書いてください。変数を使用できます。{{variable}} を入力してみてください。', openingQuestion: '開始質問', + openingQuestionPlaceholder: '変数を使用できます。{{variable}} と入力してみてください。', noDataPlaceHolder: 'ユーザーとの会話を開始すると、会話アプリケーションで彼らとのより密接な関係を築くのに役立ちます。', varTip: '変数を使用できます。{{variable}} を入力してみてください', diff --git a/web/i18n/ko-KR/app-debug.ts b/web/i18n/ko-KR/app-debug.ts index 3c5a3f4b1f..b84946841f 100644 --- a/web/i18n/ko-KR/app-debug.ts +++ b/web/i18n/ko-KR/app-debug.ts @@ -328,6 +328,7 @@ const translation = { writeOpener: '오프너 작성', placeholder: '여기에 오프너 메시지를 작성하세요. 변수를 사용할 수 있습니다. {{variable}}를 입력해보세요.', openingQuestion: '시작 질문', + openingQuestionPlaceholder: '변수를 사용할 수 있습니다. {{variable}}을(를) 입력해 보세요.', noDataPlaceHolder: '사용자와의 대화를 시작하면 대화 애플리케이션에서 그들과 더 밀접한 관계를 구축하는 데 도움이 됩니다.', varTip: '변수를 사용할 수 있습니다. {{variable}}를 입력해보세요.', tooShort: '대화 시작에는 최소 20 단어의 초기 프롬프트가 필요합니다.', diff --git a/web/i18n/pl-PL/app-debug.ts b/web/i18n/pl-PL/app-debug.ts index 48b44c0cbb..dcd286d351 100644 --- a/web/i18n/pl-PL/app-debug.ts +++ b/web/i18n/pl-PL/app-debug.ts @@ -360,6 +360,7 @@ const translation = { placeholder: 'Tutaj napisz swoją wiadomość wprowadzającą, możesz użyć zmiennych, spróbuj wpisać {{variable}}.', openingQuestion: 'Pytania otwierające', + openingQuestionPlaceholder: 'Możesz używać zmiennych, spróbuj wpisać {{variable}}.', noDataPlaceHolder: 'Rozpoczynanie rozmowy z użytkownikiem może pomóc AI nawiązać bliższe połączenie z nim w aplikacjach konwersacyjnych.', varTip: 'Możesz używać zmiennych, spróbuj wpisać {{variable}}', diff --git a/web/i18n/pt-BR/app-debug.ts b/web/i18n/pt-BR/app-debug.ts index 64f7a85fe7..96d78dc9a3 100644 --- a/web/i18n/pt-BR/app-debug.ts +++ b/web/i18n/pt-BR/app-debug.ts @@ -334,6 +334,7 @@ const translation = { writeOpener: 'Escrever abertura', placeholder: 'Escreva sua mensagem de abertura aqui, você pode usar variáveis, tente digitar {{variável}}.', openingQuestion: 'Perguntas de Abertura', + openingQuestionPlaceholder: 'Você pode usar variáveis, tente digitar {{variable}}.', noDataPlaceHolder: 'Iniciar a conversa com o usuário pode ajudar a IA a estabelecer uma conexão mais próxima com eles em aplicativos de conversação.', varTip: 'Você pode usar variáveis, tente digitar {{variável}}', diff --git a/web/i18n/ru-RU/app-debug.ts b/web/i18n/ru-RU/app-debug.ts index 00cd6e8a75..5d4dbb53d3 100644 --- a/web/i18n/ru-RU/app-debug.ts +++ b/web/i18n/ru-RU/app-debug.ts @@ -370,6 +370,7 @@ const translation = { writeOpener: 'Написать начальное сообщение', placeholder: 'Напишите здесь свое начальное сообщение, вы можете использовать переменные, попробуйте ввести {{variable}}.', openingQuestion: 'Начальные вопросы', + openingQuestionPlaceholder: 'Вы можете использовать переменные, попробуйте ввести {{variable}}.', noDataPlaceHolder: 'Начало разговора с пользователем может помочь ИИ установить более тесную связь с ним в диалоговых приложениях.', varTip: 'Вы можете использовать переменные, попробуйте ввести {{variable}}', diff --git a/web/i18n/tr-TR/app-debug.ts b/web/i18n/tr-TR/app-debug.ts index f08d221d45..6ed0e0a5eb 100644 --- a/web/i18n/tr-TR/app-debug.ts +++ b/web/i18n/tr-TR/app-debug.ts @@ -368,6 +368,7 @@ const translation = { writeOpener: 'Başlangıç mesajı yaz', placeholder: 'Başlangıç mesajınızı buraya yazın, değişkenler kullanabilirsiniz, örneğin {{variable}} yazmayı deneyin.', openingQuestion: 'Açılış Soruları', + openingQuestionPlaceholder: 'Değişkenler kullanabilirsiniz, {{variable}} yazmayı deneyin.', noDataPlaceHolder: 'Kullanıcı ile konuşmayı başlatmak, AI\'ın konuşma uygulamalarında onlarla daha yakın bir bağlantı kurmasına yardımcı olabilir.', varTip: 'Değişkenler kullanabilirsiniz, örneğin {{variable}} yazmayı deneyin', diff --git a/web/i18n/uk-UA/app-debug.ts b/web/i18n/uk-UA/app-debug.ts index 7e410ffef9..70bbebe37e 100644 --- a/web/i18n/uk-UA/app-debug.ts +++ b/web/i18n/uk-UA/app-debug.ts @@ -328,6 +328,7 @@ const translation = { writeOpener: 'Напишіть вступне повідомлення', // Write opener placeholder: 'Напишіть тут своє вступне повідомлення, ви можете використовувати змінні, спробуйте ввести {{variable}}.', // Write your opener message here... openingQuestion: 'Відкриваючі питання', // Opening Questions + openingQuestionPlaceholder: 'Ви можете використовувати змінні, спробуйте ввести {{variable}}.', noDataPlaceHolder: 'Початок розмови з користувачем може допомогти ШІ встановити більш тісний зв’язок з ним у розмовних застосунках.', // ... conversational applications. varTip: 'Ви можете використовувати змінні, спробуйте ввести {{variable}}', // You can use variables, try type {{variable}} tooShort: 'Для створення вступних зауважень для розмови потрібно принаймні 20 слів вступного запиту.', // ... are required to generate an opening remarks for the conversation. diff --git a/web/i18n/vi-VN/app-debug.ts b/web/i18n/vi-VN/app-debug.ts index c091cb5abb..cd57f78e79 100644 --- a/web/i18n/vi-VN/app-debug.ts +++ b/web/i18n/vi-VN/app-debug.ts @@ -328,6 +328,7 @@ const translation = { writeOpener: 'Viết câu mở đầu', placeholder: 'Viết thông điệp mở đầu của bạn ở đây, bạn có thể sử dụng biến, hãy thử nhập {{biến}}.', openingQuestion: 'Câu hỏi mở đầu', + openingQuestionPlaceholder: 'Bạn có thể sử dụng biến, hãy thử nhập {{variable}}.', noDataPlaceHolder: 'Bắt đầu cuộc trò chuyện với người dùng có thể giúp AI thiết lập mối quan hệ gần gũi hơn với họ trong các ứng dụng trò chuyện.', varTip: 'Bạn có thể sử dụng biến, hãy thử nhập {{biến}}', tooShort: 'Cần ít nhất 20 từ trong lời nhắc ban đầu để tạo ra các câu mở đầu cho cuộc trò chuyện.', diff --git a/web/i18n/zh-Hans/app-debug.ts b/web/i18n/zh-Hans/app-debug.ts index 4f84b396d0..3728d86e58 100644 --- a/web/i18n/zh-Hans/app-debug.ts +++ b/web/i18n/zh-Hans/app-debug.ts @@ -436,6 +436,7 @@ const translation = { writeOpener: '编写开场白', placeholder: '在这里写下你的开场白,你可以使用变量,尝试输入 {{variable}}。', openingQuestion: '开场问题', + openingQuestionPlaceholder: '可以使用变量,尝试输入 {{variable}}。', noDataPlaceHolder: '在对话型应用中,让 AI 主动说第一段话可以拉近与用户间的距离。', varTip: '你可以使用变量,试试输入 {{variable}}', diff --git a/web/i18n/zh-Hant/app-debug.ts b/web/i18n/zh-Hant/app-debug.ts index 16374992b5..b31b9a9d66 100644 --- a/web/i18n/zh-Hant/app-debug.ts +++ b/web/i18n/zh-Hant/app-debug.ts @@ -313,6 +313,7 @@ const translation = { writeOpener: '編寫開場白', placeholder: '在這裡寫下你的開場白,你可以使用變數,嘗試輸入 {{variable}}。', openingQuestion: '開場問題', + openingQuestionPlaceholder: '可以使用變量,嘗試輸入 {{variable}}。', noDataPlaceHolder: '在對話型應用中,讓 AI 主動說第一段話可以拉近與使用者間的距離。', varTip: '你可以使用變數,試試輸入 {{variable}}', From 2a85f28963b6e055ec0b439704d10d66475c4784 Mon Sep 17 00:00:00 2001 From: Garden12138 <847686279@qq.com> Date: Fri, 11 Jul 2025 15:18:42 +0800 Subject: [PATCH 063/241] =?UTF-8?q?fix=EF=BC=9AFixed=20the=20problem=20of?= =?UTF-8?q?=20plugin=20installation=20failure=20caused=20by=20incons?= =?UTF-8?q?=E2=80=A6=20(#22156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/services/plugin/plugin_service.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index d7fb4a7c1b..0f22afd8dd 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -427,6 +427,9 @@ class PluginService: manager = PluginInstaller() + # collect actual plugin_unique_identifiers + actual_plugin_unique_identifiers = [] + metas = [] features = FeatureService.get_system_features() # check if already downloaded @@ -437,6 +440,8 @@ class PluginService: # check if the plugin is available to install PluginService._check_plugin_installation_scope(plugin_decode_response.verification) # already downloaded, skip + actual_plugin_unique_identifiers.append(plugin_unique_identifier) + metas.append({"plugin_unique_identifier": plugin_unique_identifier}) except Exception: # plugin not installed, download and upload pkg pkg = download_plugin_pkg(plugin_unique_identifier) @@ -447,17 +452,15 @@ class PluginService: ) # check if the plugin is available to install PluginService._check_plugin_installation_scope(response.verification) + # use response plugin_unique_identifier + actual_plugin_unique_identifiers.append(response.unique_identifier) + metas.append({"plugin_unique_identifier": response.unique_identifier}) return manager.install_from_identifiers( tenant_id, - plugin_unique_identifiers, + actual_plugin_unique_identifiers, PluginInstallationSource.Marketplace, - [ - { - "plugin_unique_identifier": plugin_unique_identifier, - } - for plugin_unique_identifier in plugin_unique_identifiers - ], + metas, ) @staticmethod From 1d85979a7464d8d4d01e582eb6083d4206ea46ba Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 11 Jul 2025 16:41:25 +0800 Subject: [PATCH 064/241] chore:extract last run common logic (#22214) --- .../workflow-app/components/workflow-main.tsx | 8 +- .../components/workflow-app/hooks/index.ts | 2 +- .../hooks/use-inspect-vars-crud.ts | 234 +---------------- .../workflow-app/hooks/use-workflow-run.ts | 9 +- .../hooks/use-fetch-workflow-inspect-vars.ts | 19 +- .../hooks/use-inspect-vars-crud-common.ts | 240 ++++++++++++++++++ 6 files changed, 275 insertions(+), 237 deletions(-) rename web/app/components/{workflow-app => workflow}/hooks/use-fetch-workflow-inspect-vars.ts (85%) create mode 100644 web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index f0b9bab2cf..d425e6f595 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -15,7 +15,7 @@ import { useWorkflowRun, useWorkflowStartRun, } from '../hooks' -import { useWorkflowStore } from '@/app/components/workflow/store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' type WorkflowMainProps = Pick const WorkflowMain = ({ @@ -64,7 +64,11 @@ const WorkflowMain = ({ handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, } = useWorkflowStartRun() - const { fetchInspectVars } = useSetWorkflowVarsWithValue() + const appId = useStore(s => s.appId) + const { fetchInspectVars } = useSetWorkflowVarsWithValue({ + flowId: appId, + ...useConfigsMap(), + }) const { hasNodeInspectVars, hasSetInspectVar, diff --git a/web/app/components/workflow-app/hooks/index.ts b/web/app/components/workflow-app/hooks/index.ts index 1ee7c030b9..9e4f94965b 100644 --- a/web/app/components/workflow-app/hooks/index.ts +++ b/web/app/components/workflow-app/hooks/index.ts @@ -5,6 +5,6 @@ export * from './use-workflow-run' export * from './use-workflow-start-run' export * from './use-is-chat-mode' export * from './use-workflow-refresh-draft' -export * from './use-fetch-workflow-inspect-vars' +export * from '../../workflow/hooks/use-fetch-workflow-inspect-vars' export * from './use-inspect-vars-crud' export * from './use-configs-map' diff --git a/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts index ce052b7ed4..109ee85f96 100644 --- a/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts @@ -1,234 +1,16 @@ -import { fetchNodeInspectVars } from '@/service/workflow' -import { useStore, useWorkflowStore } from '@/app/components/workflow/store' -import type { ValueSelector } from '@/app/components/workflow/types' -import type { VarInInspect } from '@/types/workflow' -import { VarInInspectType } from '@/types/workflow' -import { - useDeleteAllInspectorVars, - useDeleteInspectVar, - useDeleteNodeInspectorVars, - useEditInspectorVar, - useInvalidateConversationVarValues, - useInvalidateSysVarValues, - useResetConversationVar, - useResetToLastRunValue, -} from '@/service/use-workflow' -import { useCallback } from 'react' -import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import produce from 'immer' -import type { Node } from '@/app/components/workflow/types' -import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' -import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync' +import { useStore } from '@/app/components/workflow/store' +import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common' import { useConfigsMap } from './use-configs-map' export const useInspectVarsCrud = () => { - const workflowStore = useWorkflowStore() const appId = useStore(s => s.appId) - const { conversationVarsUrl, systemVarsUrl } = useConfigsMap() - const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl) - const { mutateAsync: doResetConversationVar } = useResetConversationVar(appId) - const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(appId) - const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl) - - const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(appId) - const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(appId) - const { mutate: doDeleteInspectVar } = useDeleteInspectVar(appId) - - const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(appId) - const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync() - const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() - const getNodeInspectVars = useCallback((nodeId: string) => { - const { nodesWithInspectVars } = workflowStore.getState() - const node = nodesWithInspectVars.find(node => node.nodeId === nodeId) - return node - }, [workflowStore]) - - const getVarId = useCallback((nodeId: string, varName: string) => { - const node = getNodeInspectVars(nodeId) - if (!node) - return undefined - const varId = node.vars.find((varItem) => { - return varItem.selector[1] === varName - })?.id - return varId - }, [getNodeInspectVars]) - - const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => { - const node = getNodeInspectVars(nodeId) - if (!node) - return undefined - - const variable = node.vars.find((varItem) => { - return varItem.name === name - }) - return variable - }, [getNodeInspectVars]) - - const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => { - const isEnv = isENV([nodeId]) - if (isEnv) // always have value - return true - const isSys = isSystemVar([nodeId]) - if (isSys) - return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name) - const isChatVar = isConversationVar([nodeId]) - if (isChatVar) - return conversationVars.some(varItem => varItem.selector?.[1] === name) - return getInspectVar(nodeId, name) !== undefined - }, [getInspectVar]) - - const hasNodeInspectVars = useCallback((nodeId: string) => { - return !!getNodeInspectVars(nodeId) - }, [getNodeInspectVars]) - - const fetchInspectVarValue = useCallback(async (selector: ValueSelector) => { - const { - appId, - setNodeInspectVars, - } = workflowStore.getState() - const nodeId = selector[0] - const isSystemVar = nodeId === 'sys' - const isConversationVar = nodeId === 'conversation' - if (isSystemVar) { - invalidateSysVarValues() - return - } - if (isConversationVar) { - invalidateConversationVarValues() - return - } - const vars = await fetchNodeInspectVars(appId, nodeId) - setNodeInspectVars(nodeId, vars) - }, [workflowStore, invalidateSysVarValues, invalidateConversationVarValues]) - - // after last run would call this - const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { - const { - nodesWithInspectVars, - setNodesWithInspectVars, - } = workflowStore.getState() - const nodes = produce(nodesWithInspectVars, (draft) => { - const nodeInfo = allNodes.find(node => node.id === nodeId) - if (nodeInfo) { - const index = draft.findIndex(node => node.nodeId === nodeId) - if (index === -1) { - draft.unshift({ - nodeId, - nodeType: nodeInfo.data.type, - title: nodeInfo.data.title, - vars: payload, - nodePayload: nodeInfo.data, - }) - } - else { - draft[index].vars = payload - // put the node to the topAdd commentMore actions - draft.unshift(draft.splice(index, 1)[0]) - } - } - }) - setNodesWithInspectVars(nodes) - handleCancelNodeSuccessStatus(nodeId) - }, [workflowStore, handleCancelNodeSuccessStatus]) - - const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => { - const { nodesWithInspectVars } = workflowStore.getState() - const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId) - if(!targetNode || !targetNode.vars) - return false - return targetNode.vars.some(item => item.id === varId) - }, [workflowStore]) - - const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => { - const { deleteInspectVar } = workflowStore.getState() - if(hasNodeInspectVar(nodeId, varId)) { - await doDeleteInspectVar(varId) - deleteInspectVar(nodeId, varId) - } - }, [doDeleteInspectVar, workflowStore, hasNodeInspectVar]) - - const resetConversationVar = useCallback(async (varId: string) => { - await doResetConversationVar(varId) - invalidateConversationVarValues() - }, [doResetConversationVar, invalidateConversationVarValues]) - - const deleteNodeInspectorVars = useCallback(async (nodeId: string) => { - const { deleteNodeInspectVars } = workflowStore.getState() - if (hasNodeInspectVars(nodeId)) { - await doDeleteNodeInspectorVars(nodeId) - deleteNodeInspectVars(nodeId) - } - }, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars]) - - const deleteAllInspectorVars = useCallback(async () => { - const { deleteAllInspectVars } = workflowStore.getState() - await doDeleteAllInspectorVars() - await invalidateConversationVarValues() - await invalidateSysVarValues() - deleteAllInspectVars() - handleEdgeCancelRunningStatus() - }, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus]) - - const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => { - const { setInspectVarValue } = workflowStore.getState() - await doEditInspectorVar({ - varId, - value, - }) - setInspectVarValue(nodeId, varId, value) - if (nodeId === VarInInspectType.conversation) - invalidateConversationVarValues() - if (nodeId === VarInInspectType.system) - invalidateSysVarValues() - }, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore]) - - const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => { - const { renameInspectVarName } = workflowStore.getState() - const varId = getVarId(nodeId, oldName) - if (!varId) - return - - const newSelector = [nodeId, newName] - await doEditInspectorVar({ - varId, - name: newName, - }) - renameInspectVarName(nodeId, varId, newSelector) - }, [doEditInspectorVar, getVarId, workflowStore]) - - const isInspectVarEdited = useCallback((nodeId: string, name: string) => { - const inspectVar = getInspectVar(nodeId, name) - if (!inspectVar) - return false - - return inspectVar.edited - }, [getInspectVar]) - - const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => { - const { resetToLastRunVar } = workflowStore.getState() - const isSysVar = nodeId === 'sys' - const data = await doResetToLastRunValue(varId) - - if(isSysVar) - invalidateSysVarValues() - else - resetToLastRunVar(nodeId, varId, data.value) - }, [doResetToLastRunValue, invalidateSysVarValues, workflowStore]) + const configsMap = useConfigsMap() + const apis = useInspectVarsCrudCommon({ + flowId: appId, + ...configsMap, + }) return { - hasNodeInspectVars, - hasSetInspectVar, - fetchInspectVarValue, - editInspectVarValue, - renameInspectVarName, - appendNodeInspectVars, - deleteInspectVar, - deleteNodeInspectorVars, - deleteAllInspectorVars, - isInspectVarEdited, - resetToLastRunVar, - invalidateSysVarValues, - resetConversationVar, - invalidateConversationVarValues, + ...apis, } } diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index 4c34d2ffb1..c303211715 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -20,7 +20,8 @@ import type { VersionHistory } from '@/types/workflow' import { noop } from 'lodash-es' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useInvalidAllLastRun } from '@/service/use-workflow' -import { useSetWorkflowVarsWithValue } from './use-fetch-workflow-inspect-vars' +import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars' +import { useConfigsMap } from './use-configs-map' export const useWorkflowRun = () => { const store = useStoreApi() @@ -32,7 +33,11 @@ export const useWorkflowRun = () => { const pathname = usePathname() const appId = useAppStore.getState().appDetail?.id const invalidAllLastRun = useInvalidAllLastRun(appId as string) - const { fetchInspectVars } = useSetWorkflowVarsWithValue() + const configsMap = useConfigsMap() + const { fetchInspectVars } = useSetWorkflowVarsWithValue({ + flowId: appId as string, + ...configsMap, + }) const { handleWorkflowStarted, diff --git a/web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts similarity index 85% rename from web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts rename to web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts index 07580c097e..27a9ea9d2d 100644 --- a/web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts +++ b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts @@ -6,12 +6,20 @@ import type { Node } from '@/app/components/workflow/types' import { fetchAllInspectVars } from '@/service/workflow' import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow' import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' -import { useConfigsMap } from './use-configs-map' -export const useSetWorkflowVarsWithValue = () => { +type Params = { + flowId: string + conversationVarsUrl: string + systemVarsUrl: string +} + +export const useSetWorkflowVarsWithValue = ({ + flowId, + conversationVarsUrl, + systemVarsUrl, +}: Params) => { const workflowStore = useWorkflowStore() const store = useStoreApi() - const { conversationVarsUrl, systemVarsUrl } = useConfigsMap() const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl) const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl) const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync() @@ -58,13 +66,12 @@ export const useSetWorkflowVarsWithValue = () => { }, [workflowStore, store]) const fetchInspectVars = useCallback(async () => { - const { appId } = workflowStore.getState() invalidateConversationVarValues() invalidateSysVarValues() - const data = await fetchAllInspectVars(appId) + const data = await fetchAllInspectVars(flowId) setInspectVarsToStore(data) handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status - }, [workflowStore, invalidateConversationVarValues, invalidateSysVarValues, setInspectVarsToStore, handleCancelAllNodeSuccessStatus]) + }, [invalidateConversationVarValues, invalidateSysVarValues, flowId, setInspectVarsToStore, handleCancelAllNodeSuccessStatus]) return { fetchInspectVars, } diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts new file mode 100644 index 0000000000..ffcfd81666 --- /dev/null +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts @@ -0,0 +1,240 @@ +import { fetchNodeInspectVars } from '@/service/workflow' +import { useWorkflowStore } from '@/app/components/workflow/store' +import type { ValueSelector } from '@/app/components/workflow/types' +import type { VarInInspect } from '@/types/workflow' +import { VarInInspectType } from '@/types/workflow' +import { + useDeleteAllInspectorVars, + useDeleteInspectVar, + useDeleteNodeInspectorVars, + useEditInspectorVar, + useInvalidateConversationVarValues, + useInvalidateSysVarValues, + useResetConversationVar, + useResetToLastRunValue, +} from '@/service/use-workflow' +import { useCallback } from 'react' +import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import produce from 'immer' +import type { Node } from '@/app/components/workflow/types' +import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' +import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync' + +type Params = { + flowId: string + conversationVarsUrl: string + systemVarsUrl: string +} +export const useInspectVarsCrudCommon = ({ + flowId, + conversationVarsUrl, + systemVarsUrl, +}: Params) => { + const workflowStore = useWorkflowStore() + const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl!) + const { mutateAsync: doResetConversationVar } = useResetConversationVar(flowId) + const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(flowId) + const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl!) + + const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(flowId) + const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(flowId) + const { mutate: doDeleteInspectVar } = useDeleteInspectVar(flowId) + + const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(flowId) + const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync() + const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() + const getNodeInspectVars = useCallback((nodeId: string) => { + const { nodesWithInspectVars } = workflowStore.getState() + const node = nodesWithInspectVars.find(node => node.nodeId === nodeId) + return node + }, [workflowStore]) + + const getVarId = useCallback((nodeId: string, varName: string) => { + const node = getNodeInspectVars(nodeId) + if (!node) + return undefined + const varId = node.vars.find((varItem) => { + return varItem.selector[1] === varName + })?.id + return varId + }, [getNodeInspectVars]) + + const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => { + const node = getNodeInspectVars(nodeId) + if (!node) + return undefined + + const variable = node.vars.find((varItem) => { + return varItem.name === name + }) + return variable + }, [getNodeInspectVars]) + + const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => { + const isEnv = isENV([nodeId]) + if (isEnv) // always have value + return true + const isSys = isSystemVar([nodeId]) + if (isSys) + return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name) + const isChatVar = isConversationVar([nodeId]) + if (isChatVar) + return conversationVars.some(varItem => varItem.selector?.[1] === name) + return getInspectVar(nodeId, name) !== undefined + }, [getInspectVar]) + + const hasNodeInspectVars = useCallback((nodeId: string) => { + return !!getNodeInspectVars(nodeId) + }, [getNodeInspectVars]) + + const fetchInspectVarValue = useCallback(async (selector: ValueSelector) => { + const { + appId, + setNodeInspectVars, + } = workflowStore.getState() + const nodeId = selector[0] + const isSystemVar = nodeId === 'sys' + const isConversationVar = nodeId === 'conversation' + if (isSystemVar) { + invalidateSysVarValues() + return + } + if (isConversationVar) { + invalidateConversationVarValues() + return + } + const vars = await fetchNodeInspectVars(appId, nodeId) + setNodeInspectVars(nodeId, vars) + }, [workflowStore, invalidateSysVarValues, invalidateConversationVarValues]) + + // after last run would call this + const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { + const { + nodesWithInspectVars, + setNodesWithInspectVars, + } = workflowStore.getState() + const nodes = produce(nodesWithInspectVars, (draft) => { + const nodeInfo = allNodes.find(node => node.id === nodeId) + if (nodeInfo) { + const index = draft.findIndex(node => node.nodeId === nodeId) + if (index === -1) { + draft.unshift({ + nodeId, + nodeType: nodeInfo.data.type, + title: nodeInfo.data.title, + vars: payload, + nodePayload: nodeInfo.data, + }) + } + else { + draft[index].vars = payload + // put the node to the topAdd commentMore actions + draft.unshift(draft.splice(index, 1)[0]) + } + } + }) + setNodesWithInspectVars(nodes) + handleCancelNodeSuccessStatus(nodeId) + }, [workflowStore, handleCancelNodeSuccessStatus]) + + const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => { + const { nodesWithInspectVars } = workflowStore.getState() + const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId) + if(!targetNode || !targetNode.vars) + return false + return targetNode.vars.some(item => item.id === varId) + }, [workflowStore]) + + const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => { + const { deleteInspectVar } = workflowStore.getState() + if(hasNodeInspectVar(nodeId, varId)) { + await doDeleteInspectVar(varId) + deleteInspectVar(nodeId, varId) + } + }, [doDeleteInspectVar, workflowStore, hasNodeInspectVar]) + + const resetConversationVar = useCallback(async (varId: string) => { + await doResetConversationVar(varId) + invalidateConversationVarValues() + }, [doResetConversationVar, invalidateConversationVarValues]) + + const deleteNodeInspectorVars = useCallback(async (nodeId: string) => { + const { deleteNodeInspectVars } = workflowStore.getState() + if (hasNodeInspectVars(nodeId)) { + await doDeleteNodeInspectorVars(nodeId) + deleteNodeInspectVars(nodeId) + } + }, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars]) + + const deleteAllInspectorVars = useCallback(async () => { + const { deleteAllInspectVars } = workflowStore.getState() + await doDeleteAllInspectorVars() + await invalidateConversationVarValues() + await invalidateSysVarValues() + deleteAllInspectVars() + handleEdgeCancelRunningStatus() + }, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus]) + + const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => { + const { setInspectVarValue } = workflowStore.getState() + await doEditInspectorVar({ + varId, + value, + }) + setInspectVarValue(nodeId, varId, value) + if (nodeId === VarInInspectType.conversation) + invalidateConversationVarValues() + if (nodeId === VarInInspectType.system) + invalidateSysVarValues() + }, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore]) + + const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => { + const { renameInspectVarName } = workflowStore.getState() + const varId = getVarId(nodeId, oldName) + if (!varId) + return + + const newSelector = [nodeId, newName] + await doEditInspectorVar({ + varId, + name: newName, + }) + renameInspectVarName(nodeId, varId, newSelector) + }, [doEditInspectorVar, getVarId, workflowStore]) + + const isInspectVarEdited = useCallback((nodeId: string, name: string) => { + const inspectVar = getInspectVar(nodeId, name) + if (!inspectVar) + return false + + return inspectVar.edited + }, [getInspectVar]) + + const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => { + const { resetToLastRunVar } = workflowStore.getState() + const isSysVar = nodeId === 'sys' + const data = await doResetToLastRunValue(varId) + + if(isSysVar) + invalidateSysVarValues() + else + resetToLastRunVar(nodeId, varId, data.value) + }, [doResetToLastRunValue, invalidateSysVarValues, workflowStore]) + + return { + hasNodeInspectVars, + hasSetInspectVar, + fetchInspectVarValue, + editInspectVarValue, + renameInspectVarName, + appendNodeInspectVars, + deleteInspectVar, + deleteNodeInspectorVars, + deleteAllInspectorVars, + isInspectVarEdited, + resetToLastRunVar, + invalidateSysVarValues, + resetConversationVar, + invalidateConversationVarValues, + } +} From 2d3c5b3b7c3365a5e037e85dbbb9ced174a51862 Mon Sep 17 00:00:00 2001 From: HyaCinth <88471803+HyaCiovo@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:52:16 +0800 Subject: [PATCH 065/241] fix(emoji-picker): Adjust the style of the emoji picker (#22161) (#22231) --- web/app/components/base/emoji-picker/Inner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx index 8d05967f33..6fc4b67181 100644 --- a/web/app/components/base/emoji-picker/Inner.tsx +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -101,7 +101,7 @@ const EmojiPickerInner: FC = ({
-
+
{isSearching && <>

Search

@@ -170,7 +170,7 @@ const EmojiPickerInner: FC = ({ 'flex h-8 w-8 items-center justify-center rounded-lg p-1', ) } style={{ background: color }}> - {selectedEmoji !== '' && } + {selectedEmoji !== '' && }
})} From 76d21743fd9f164bbca1e97f224246750b5e83d3 Mon Sep 17 00:00:00 2001 From: HyaCinth <88471803+HyaCiovo@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:54:09 +0800 Subject: [PATCH 066/241] fix(web): Optimize AppInfo Component Layout (#22212) (#22218) --- web/app/components/app-sidebar/app-info.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index c28cc20df5..3817ebf5a4 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -308,13 +308,11 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx operations={operations} />
-
- -
+
{canNotRun && ( - + @@ -53,7 +53,7 @@ export default function SocialAuth(props: SocialAuthProps) { 'mr-2 h-5 w-5', ) } /> - {t('login.withGoogle')} + {t('login.withGoogle')} diff --git a/web/app/signin/page.module.css b/web/app/signin/page.module.css index eda396f763..72ce7fbd8a 100644 --- a/web/app/signin/page.module.css +++ b/web/app/signin/page.module.css @@ -1,7 +1,7 @@ .githubIcon { - background: center/contain url('./assets/github.svg'); + background: center/contain url('./assets/github.svg') no-repeat; } .googleIcon { - background: center/contain url('./assets/google.svg'); + background: center/contain url('./assets/google.svg') no-repeat; } From a324d3942e05c48a11429e8e51f5887b0330f1b1 Mon Sep 17 00:00:00 2001 From: NFish Date: Thu, 17 Jul 2025 10:52:10 +0800 Subject: [PATCH 127/241] Perf/web app authrozation (#22524) --- .../explore/installed/[appId]/page.tsx | 14 +-- web/app/(shareLayout)/chat/[token]/page.tsx | 5 +- .../(shareLayout)/chatbot/[token]/page.tsx | 5 +- .../(shareLayout)/completion/[token]/page.tsx | 5 +- .../components/authenticated-layout.tsx | 84 ++++++++++++++++ web/app/(shareLayout)/components/splash.tsx | 80 +++++++++++++++ web/app/(shareLayout)/layout.tsx | 57 ++--------- .../(shareLayout)/webapp-signin/layout.tsx | 9 +- .../webapp-signin/normalForm.tsx | 1 + web/app/(shareLayout)/webapp-signin/page.tsx | 83 ++-------------- .../(shareLayout)/workflow/[token]/page.tsx | 5 +- .../base/chat/chat-with-history/context.tsx | 8 +- .../base/chat/chat-with-history/hooks.tsx | 22 +---- .../base/chat/chat-with-history/index.tsx | 50 +--------- .../base/chat/embedded-chatbot/index.tsx | 94 ------------------ web/app/components/base/chat/types.ts | 10 ++ web/app/components/explore/index.tsx | 3 + .../explore/installed-app/index.tsx | 97 ++++++++++++++++--- web/app/components/explore/sidebar/index.tsx | 30 +++--- .../share/text-generation/index.tsx | 96 ++++-------------- .../share/text-generation/menu-dropdown.tsx | 4 +- web/app/components/share/utils.ts | 2 +- web/context/explore-context.ts | 4 + web/context/global-public-context.tsx | 5 - web/context/web-app-context.tsx | 87 +++++++++++++++++ web/i18n/en-US/login.ts | 1 + web/i18n/ja-JP/login.ts | 1 + web/i18n/zh-Hans/login.ts | 1 + web/models/share.ts | 2 +- web/service/access-control.ts | 28 +++--- web/service/base.ts | 4 +- web/service/explore.ts | 5 + web/service/share.ts | 7 -- web/service/use-explore.ts | 81 ++++++++++++++++ web/service/use-share.ts | 43 +++++++- 35 files changed, 592 insertions(+), 441 deletions(-) create mode 100644 web/app/(shareLayout)/components/authenticated-layout.tsx create mode 100644 web/app/(shareLayout)/components/splash.tsx create mode 100644 web/context/web-app-context.tsx create mode 100644 web/service/use-explore.ts diff --git a/web/app/(commonLayout)/explore/installed/[appId]/page.tsx b/web/app/(commonLayout)/explore/installed/[appId]/page.tsx index 938a03992b..e288c62b5d 100644 --- a/web/app/(commonLayout)/explore/installed/[appId]/page.tsx +++ b/web/app/(commonLayout)/explore/installed/[appId]/page.tsx @@ -1,16 +1,18 @@ -import type { FC } from 'react' import React from 'react' import Main from '@/app/components/explore/installed-app' export type IInstalledAppProps = { - params: Promise<{ + params: { appId: string - }> + } } -const InstalledApp: FC = async ({ params }) => { +// Using Next.js page convention for async server components +async function InstalledApp({ params }: IInstalledAppProps) { + const appId = (await params).appId return ( -
+
) } -export default React.memo(InstalledApp) + +export default InstalledApp diff --git a/web/app/(shareLayout)/chat/[token]/page.tsx b/web/app/(shareLayout)/chat/[token]/page.tsx index 640c40378f..8ce67585f0 100644 --- a/web/app/(shareLayout)/chat/[token]/page.tsx +++ b/web/app/(shareLayout)/chat/[token]/page.tsx @@ -1,10 +1,13 @@ 'use client' import React from 'react' import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history' +import AuthenticatedLayout from '../../components/authenticated-layout' const Chat = () => { return ( - + + + ) } diff --git a/web/app/(shareLayout)/chatbot/[token]/page.tsx b/web/app/(shareLayout)/chatbot/[token]/page.tsx index 6196afecc4..5323d0dacc 100644 --- a/web/app/(shareLayout)/chatbot/[token]/page.tsx +++ b/web/app/(shareLayout)/chatbot/[token]/page.tsx @@ -1,10 +1,13 @@ 'use client' import React from 'react' import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot' +import AuthenticatedLayout from '../../components/authenticated-layout' const Chatbot = () => { return ( - + + + ) } diff --git a/web/app/(shareLayout)/completion/[token]/page.tsx b/web/app/(shareLayout)/completion/[token]/page.tsx index e8bc9d79f5..ae91338b9a 100644 --- a/web/app/(shareLayout)/completion/[token]/page.tsx +++ b/web/app/(shareLayout)/completion/[token]/page.tsx @@ -1,9 +1,12 @@ import React from 'react' import Main from '@/app/components/share/text-generation' +import AuthenticatedLayout from '../../components/authenticated-layout' const Completion = () => { return ( -
+ +
+ ) } diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx new file mode 100644 index 0000000000..e3cfc8e6a8 --- /dev/null +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -0,0 +1,84 @@ +'use client' + +import AppUnavailable from '@/app/components/base/app-unavailable' +import Loading from '@/app/components/base/loading' +import { removeAccessToken } from '@/app/components/share/utils' +import { useWebAppStore } from '@/context/web-app-context' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import React, { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { + const { t } = useTranslation() + const updateAppInfo = useWebAppStore(s => s.updateAppInfo) + const updateAppParams = useWebAppStore(s => s.updateAppParams) + const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) + const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) + const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams() + const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo() + const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta() + const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false }) + + useEffect(() => { + if (appInfo) + updateAppInfo(appInfo) + if (appParams) + updateAppParams(appParams) + if (appMeta) + updateWebAppMeta(appMeta) + updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) + }, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp]) + + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + params.set('redirect_url', pathname) + return `/webapp-signin?${params.toString()}` + }, [searchParams, pathname]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + if (appInfoError) { + return
+ +
+ } + if (appParamsError) { + return
+ +
+ } + if (appMetaError) { + return
+ +
+ } + if (useCanAccessAppError) { + return
+ +
+ } + if (userCanAccessApp && !userCanAccessApp.result) { + return
+ + {t('common.userProfile.logout')} +
+ } + if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) { + return
+ +
+ } + return <>{children} +} + +export default React.memo(AuthenticatedLayout) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx new file mode 100644 index 0000000000..4fe9efe4dd --- /dev/null +++ b/web/app/(shareLayout)/components/splash.tsx @@ -0,0 +1,80 @@ +'use client' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useCallback } from 'react' +import { useWebAppStore } from '@/context/web-app-context' +import { useRouter, useSearchParams } from 'next/navigation' +import AppUnavailable from '@/app/components/base/app-unavailable' +import { checkOrSetAccessToken, removeAccessToken, setAccessToken } from '@/app/components/share/utils' +import { useTranslation } from 'react-i18next' +import { fetchAccessToken } from '@/service/share' +import Loading from '@/app/components/base/loading' +import { AccessMode } from '@/models/access-control' + +const Splash: FC = ({ children }) => { + const { t } = useTranslation() + const shareCode = useWebAppStore(s => s.shareCode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) + const searchParams = useSearchParams() + const router = useRouter() + const redirectUrl = searchParams.get('redirect_url') + const tokenFromUrl = searchParams.get('web_sso_token') + const message = searchParams.get('message') + const code = searchParams.get('code') + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + params.delete('code') + return `/webapp-signin?${params.toString()}` + }, [searchParams]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + useEffect(() => { + (async () => { + if (message) + return + if (shareCode && tokenFromUrl && redirectUrl) { + localStorage.setItem('webapp_access_token', tokenFromUrl) + const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: tokenFromUrl }) + await setAccessToken(shareCode, tokenResp.access_token) + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (shareCode && redirectUrl && localStorage.getItem('webapp_access_token')) { + const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) + await setAccessToken(shareCode, tokenResp.access_token) + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) { + await checkOrSetAccessToken(shareCode) + router.replace(decodeURIComponent(redirectUrl)) + } + })() + }, [shareCode, redirectUrl, router, tokenFromUrl, message, webAppAccessMode]) + + if (message) { + return
+ + {code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')} +
+ } + if (tokenFromUrl) { + return
+ +
+ } + if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) { + return
+ +
+ } + return <>{children} +} + +export default Splash diff --git a/web/app/(shareLayout)/layout.tsx b/web/app/(shareLayout)/layout.tsx index d057ba7599..5af913cac9 100644 --- a/web/app/(shareLayout)/layout.tsx +++ b/web/app/(shareLayout)/layout.tsx @@ -1,54 +1,15 @@ -'use client' -import React, { useEffect, useState } from 'react' -import type { FC } from 'react' -import { usePathname, useSearchParams } from 'next/navigation' -import Loading from '../components/base/loading' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' -import { getAppAccessModeByAppCode } from '@/service/share' +import type { FC, PropsWithChildren } from 'react' +import WebAppStoreProvider from '@/context/web-app-context' +import Splash from './components/splash' -const Layout: FC<{ - children: React.ReactNode -}> = ({ children }) => { - const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) - const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const pathname = usePathname() - const searchParams = useSearchParams() - const redirectUrl = searchParams.get('redirect_url') - const [isLoading, setIsLoading] = useState(true) - useEffect(() => { - (async () => { - if (!isGlobalPending && !systemFeatures.webapp_auth.enabled) { - setIsLoading(false) - return - } - - let appCode: string | null = null - if (redirectUrl) { - const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) - appCode = url.pathname.split('/').pop() || null - } - else { - appCode = pathname.split('/').pop() || null - } - - if (!appCode) - return - setIsLoading(true) - const ret = await getAppAccessModeByAppCode(appCode) - setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC) - setIsLoading(false) - })() - }, [pathname, redirectUrl, setWebAppAccessMode, isGlobalPending, systemFeatures.webapp_auth.enabled]) - if (isLoading || isGlobalPending) { - return
- -
- } +const Layout: FC = ({ children }) => { return (
- {children} + + + {children} + +
) } diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index a03364d326..7649982072 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -3,10 +3,13 @@ import cn from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import type { PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' -export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() - useDocumentTitle('') +export default function SignInLayout({ children }: PropsWithChildren) { + const { t } = useTranslation() + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + useDocumentTitle(t('login.webapp.login')) return <>
diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index d6bdf607ba..44006a9f1e 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,3 +1,4 @@ +'use client' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Link from 'next/link' diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 967516c416..1c6209b902 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,36 +1,30 @@ 'use client' import { useRouter, useSearchParams } from 'next/navigation' import type { FC } from 'react' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' -import { removeAccessToken, setAccessToken } from '@/app/components/share/utils' +import { removeAccessToken } from '@/app/components/share/utils' import { useGlobalPublicStore } from '@/context/global-public-context' -import Loading from '@/app/components/base/loading' import AppUnavailable from '@/app/components/base/app-unavailable' import NormalForm from './normalForm' import { AccessMode } from '@/models/access-control' import ExternalMemberSsoAuth from './components/external-member-sso-auth' -import { fetchAccessToken } from '@/service/share' +import { useWebAppStore } from '@/context/web-app-context' const WebSSOForm: FC = () => { const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() const redirectUrl = searchParams.get('redirect_url') - const tokenFromUrl = searchParams.get('web_sso_token') - const message = searchParams.get('message') - const code = searchParams.get('code') const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.delete('code') + const params = new URLSearchParams() + params.append('redirect_url', redirectUrl || '') return `/webapp-signin?${params.toString()}` - }, [searchParams]) + }, [redirectUrl]) const backToHome = useCallback(() => { removeAccessToken() @@ -38,73 +32,12 @@ const WebSSOForm: FC = () => { router.replace(url) }, [getSigninUrl, router]) - const showErrorToast = (msg: string) => { - Toast.notify({ - type: 'error', - message: msg, - }) - } - - const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null - const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) - const appCode = url.pathname.split('/').pop() - if (!appCode) - return null - - return appCode - }, [redirectUrl]) - - useEffect(() => { - (async () => { - if (message) - return - - const appCode = getAppCodeFromRedirectUrl() - if (appCode && tokenFromUrl && redirectUrl) { - localStorage.setItem('webapp_access_token', tokenFromUrl) - const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl }) - await setAccessToken(appCode, tokenResp.access_token) - router.replace(decodeURIComponent(redirectUrl)) - return - } - if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) { - const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) - await setAccessToken(appCode, tokenResp.access_token) - router.replace(decodeURIComponent(redirectUrl)) - } - })() - }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message]) - - useEffect(() => { - if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl) - router.replace(decodeURIComponent(redirectUrl)) - }, [webAppAccessMode, router, redirectUrl]) - - if (tokenFromUrl) { - return
- -
- } - - if (message) { - return
- - {code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')} -
- } if (!redirectUrl) { - showErrorToast('redirect url is invalid.') return
} - if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) { - return
- -
- } + if (!systemFeatures.webapp_auth.enabled) { return

{t('login.webapp.disabled')}

diff --git a/web/app/(shareLayout)/workflow/[token]/page.tsx b/web/app/(shareLayout)/workflow/[token]/page.tsx index e93bc8c1af..4f5923e91f 100644 --- a/web/app/(shareLayout)/workflow/[token]/page.tsx +++ b/web/app/(shareLayout)/workflow/[token]/page.tsx @@ -1,10 +1,13 @@ import React from 'react' import Main from '@/app/components/share/text-generation' +import AuthenticatedLayout from '../../components/authenticated-layout' const Workflow = () => { return ( -
+ +
+ ) } diff --git a/web/app/components/base/chat/chat-with-history/context.tsx b/web/app/components/base/chat/chat-with-history/context.tsx index 5bf1514774..3a5dc793d6 100644 --- a/web/app/components/base/chat/chat-with-history/context.tsx +++ b/web/app/components/base/chat/chat-with-history/context.tsx @@ -18,11 +18,8 @@ import type { import { noop } from 'lodash-es' export type ChatWithHistoryContextValue = { - appInfoError?: any - appInfoLoading?: boolean - appMeta?: AppMeta - appData?: AppData - userCanAccess?: boolean + appMeta?: AppMeta | null + appData?: AppData | null appParams?: ChatConfig appChatListDataLoading?: boolean currentConversationId: string @@ -62,7 +59,6 @@ export type ChatWithHistoryContextValue = { } export const ChatWithHistoryContext = createContext({ - userCanAccess: false, currentConversationId: '', appPrevChatTree: [], pinnedConversationList: [], 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 32f74e6457..be935a70ba 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -21,9 +21,6 @@ import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { delConversation, - fetchAppInfo, - fetchAppMeta, - fetchAppParams, fetchChatList, fetchConversations, generationConversationName, @@ -43,8 +40,7 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { noop } from 'lodash-es' -import { useGetUserCanAccessApp } from '@/service/access-control' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWebAppStore } from '@/context/web-app-context' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -74,13 +70,9 @@ function getFormattedChatList(messages: any[]) { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) - const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ - appId: installedAppInfo?.app.id || appInfo?.app_id, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) + const appInfo = useWebAppStore(s => s.appInfo) + const appParams = useWebAppStore(s => s.appParams) + const appMeta = useWebAppStore(s => s.appMeta) useAppFavicon({ enable: !installedAppInfo, @@ -107,6 +99,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { use_icon_as_answer_icon: app.use_icon_as_answer_icon, }, plan: 'basic', + custom_config: null, } as AppData } @@ -166,8 +159,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return currentConversationId }, [currentConversationId, newConversationId]) - const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId)) - const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId)) const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100)) const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100)) const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId)) @@ -485,9 +476,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [isInstalledApp, appId, t, notify]) return { - appInfoError, - appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), - userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, isInstalledApp, appId, currentConversationId, diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index fe8e7b430d..cceb21b295 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import { - useCallback, useEffect, useState, } from 'react' @@ -19,12 +18,10 @@ import ChatWrapper from './chat-wrapper' import type { InstalledApp } from '@/models/explore' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils' +import { checkOrSetAccessToken } from '@/app/components/share/utils' import AppUnavailable from '@/app/components/base/app-unavailable' import cn from '@/utils/classnames' import useDocumentTitle from '@/hooks/use-document-title' -import { useTranslation } from 'react-i18next' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' type ChatWithHistoryProps = { className?: string @@ -33,16 +30,12 @@ const ChatWithHistory: FC = ({ className, }) => { const { - userCanAccess, - appInfoError, appData, - appInfoLoading, appChatListDataLoading, chatShouldReloadKey, isMobile, themeBuilder, sidebarCollapseState, - isInstalledApp, } = useChatWithHistoryContext() const isSidebarCollapsed = sidebarCollapseState const customConfig = appData?.custom_config @@ -56,41 +49,6 @@ const ChatWithHistory: FC = ({ useDocumentTitle(site?.title || 'Chat') - const { t } = useTranslation() - const searchParams = useSearchParams() - const router = useRouter() - const pathname = usePathname() - const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.set('redirect_url', pathname) - return `/webapp-signin?${params.toString()}` - }, [searchParams, pathname]) - - const backToHome = useCallback(() => { - removeAccessToken() - const url = getSigninUrl() - router.replace(url) - }, [getSigninUrl, router]) - - if (appInfoLoading) { - return ( - - ) - } - if (!userCanAccess) { - return
- - {!isInstalledApp && {t('common.userProfile.logout')}} -
- } - - if (appInfoError) { - return ( - - ) - } - return (
= ({ const themeBuilder = useThemeContext() const { - appInfoError, - appInfoLoading, - userCanAccess, appData, appParams, appMeta, @@ -191,10 +146,7 @@ const ChatWithHistoryWrap: FC = ({ return ( { const { - userCanAccess, isMobile, allowResetChat, - appInfoError, - appInfoLoading, appData, appChatListDataLoading, chatShouldReloadKey, handleNewConversation, themeBuilder, - isInstalledApp, } = useEmbeddedChatbotContext() const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) @@ -55,58 +45,6 @@ const Chatbot = () => { useDocumentTitle(site?.title || 'Chat') - const searchParams = useSearchParams() - const router = useRouter() - const pathname = usePathname() - const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.set('redirect_url', pathname) - return `/webapp-signin?${params.toString()}` - }, [searchParams, pathname]) - - const backToHome = useCallback(() => { - removeAccessToken() - const url = getSigninUrl() - router.replace(url) - }, [getSigninUrl, router]) - - if (appInfoLoading) { - return ( - <> - {!isMobile && } - {isMobile && ( -
-
- -
-
- )} - - ) - } - - if (!userCanAccess) { - return
- - {!isInstalledApp && {t('common.userProfile.logout')}} -
- } - - if (appInfoError) { - return ( - <> - {!isMobile && } - {isMobile && ( -
-
- -
-
- )} - - ) - } return (
{ const themeBuilder = useThemeContext() const { - appInfoError, - appInfoLoading, appData, userCanAccess, appParams, @@ -200,8 +136,6 @@ const EmbeddedChatbotWrapper = () => { return { } const EmbeddedChatbot = () => { - const [initialized, setInitialized] = useState(false) - const [appUnavailable, setAppUnavailable] = useState(false) - const [isUnknownReason, setIsUnknownReason] = useState(false) - - useAsyncEffect(async () => { - if (!initialized) { - try { - await checkOrSetAccessToken() - } - catch (e: any) { - if (e.status === 404) { - setAppUnavailable(true) - } - else { - setIsUnknownReason(true) - setAppUnavailable(true) - } - } - setInitialized(true) - } - }, []) - - if (!initialized) - return null - - if (appUnavailable) - return - return } diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index 91f9bc976b..c463879a53 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -49,6 +49,16 @@ export type ChatConfig = Omit & { questionEditEnable?: boolean supportFeedback?: boolean supportCitationHitInfo?: boolean + system_parameters: { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number + } + more_like_this: { + enabled: boolean + } } export type WorkflowProcess = { diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index bae2610cba..e716de96f1 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -22,6 +22,7 @@ const Explore: FC = ({ const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext() const [hasEditPermission, setHasEditPermission] = useState(false) const [installedApps, setInstalledApps] = useState([]) + const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false) const { t } = useTranslation() useDocumentTitle(t('common.menus.explore')) @@ -51,6 +52,8 @@ const Explore: FC = ({ hasEditPermission, installedApps, setInstalledApps, + isFetchingInstalledApps, + setIsFetchingInstalledApps, } } > diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 71013fc2e1..8032e173c6 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -1,11 +1,17 @@ 'use client' import type { FC } from 'react' +import { useEffect } from 'react' import React from 'react' import { useContext } from 'use-context-selector' import ExploreContext from '@/context/explore-context' import TextGenerationApp from '@/app/components/share/text-generation' import Loading from '@/app/components/base/loading' import ChatWithHistory from '@/app/components/base/chat/chat-with-history' +import { useWebAppStore } from '@/context/web-app-context' +import AppUnavailable from '../../base/app-unavailable' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' +import type { AppData } from '@/models/share' export type IInstalledAppProps = { id: string @@ -14,26 +20,95 @@ export type IInstalledAppProps = { const InstalledApp: FC = ({ id, }) => { - const { installedApps } = useContext(ExploreContext) + const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext) + const updateAppInfo = useWebAppStore(s => s.updateAppInfo) const installedApp = installedApps.find(item => item.id === id) + const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode) + const updateAppParams = useWebAppStore(s => s.updateAppParams) + const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) + const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) + const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null) + const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null) + const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null) + const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true }) - if (!installedApp) { - return ( -
- -
- ) + useEffect(() => { + if (!installedApp) { + updateAppInfo(null) + } + else { + const { id, app } = installedApp + updateAppInfo({ + app_id: id, + site: { + title: app.name, + icon_type: app.icon_type, + icon: app.icon, + icon_background: app.icon_background, + icon_url: app.icon_url, + prompt_public: false, + copyright: '', + show_workflow_steps: true, + use_icon_as_answer_icon: app.use_icon_as_answer_icon, + }, + plan: 'basic', + custom_config: null, + } as AppData) + } + + if (appParams) + updateAppParams(appParams) + if (appMeta) + updateWebAppMeta(appMeta) + if (webAppAccessMode) + updateWebAppAccessMode(webAppAccessMode.accessMode) + updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) + }, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode]) + + if (appParamsError) { + return
+ +
+ } + if (appMetaError) { + return
+ +
+ } + if (useCanAccessAppError) { + return
+ +
+ } + if (webAppAccessModeError) { + return
+ +
+ } + if (userCanAccessApp && !userCanAccessApp.result) { + return
+ +
+ } + if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) { + return
+ +
+ } + if (!installedApp) { + return
+ +
} - return (
- {installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && ( + {installedApp?.app.mode !== 'completion' && installedApp?.app.mode !== 'workflow' && ( )} - {installedApp.app.mode === 'completion' && ( + {installedApp?.app.mode === 'completion' && ( )} - {installedApp.app.mode === 'workflow' && ( + {installedApp?.app.mode === 'workflow' && ( )}
diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index fe5935bcd3..74c397f4fd 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -8,11 +8,11 @@ import Link from 'next/link' import Toast from '../../base/toast' import Item from './app-nav-item' import cn from '@/utils/classnames' -import { fetchInstalledAppList as doFetchInstalledAppList, uninstallApp, updatePinStatus } from '@/service/explore' import ExploreContext from '@/context/explore-context' import Confirm from '@/app/components/base/confirm' import Divider from '@/app/components/base/divider' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' const SelectedDiscoveryIcon = () => ( @@ -50,16 +50,14 @@ const SideBar: FC = ({ const lastSegment = segments.slice(-1)[0] const isDiscoverySelected = lastSegment === 'apps' const isChatSelected = lastSegment === 'chat' - const { installedApps, setInstalledApps } = useContext(ExploreContext) + const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext) + const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps() + const { mutateAsync: uninstallApp } = useUninstallApp() + const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus() const media = useBreakpoints() const isMobile = media === MediaType.mobile - const fetchInstalledAppList = async () => { - const { installed_apps }: any = await doFetchInstalledAppList() - setInstalledApps(installed_apps) - } - const [showConfirm, setShowConfirm] = useState(false) const [currId, setCurrId] = useState('') const handleDelete = async () => { @@ -70,25 +68,31 @@ const SideBar: FC = ({ type: 'success', message: t('common.api.remove'), }) - fetchInstalledAppList() } const handleUpdatePinStatus = async (id: string, isPinned: boolean) => { - await updatePinStatus(id, isPinned) + await updatePinStatus({ appId: id, isPinned }) Toast.notify({ type: 'success', message: t('common.api.success'), }) - fetchInstalledAppList() } useEffect(() => { - fetchInstalledAppList() - }, []) + const installed_apps = (ret as any)?.installed_apps + if (installed_apps && installed_apps.length > 0) + setInstalledApps(installed_apps) + else + setInstalledApps([]) + }, [ret, setInstalledApps]) + + useEffect(() => { + setIsFetchingInstalledApps(isFetchingInstalledApps) + }, [isFetchingInstalledApps, setIsFetchingInstalledApps]) useEffect(() => { fetchInstalledAppList() - }, [controlUpdateInstalledApps]) + }, [controlUpdateInstalledApps, fetchInstalledAppList]) const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length return ( diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 4be6b18958..ae6e733e49 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -7,16 +7,14 @@ import { RiErrorWarningFill, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import TabHeader from '../../base/tab-header' -import { checkOrSetAccessToken, removeAccessToken } from '../utils' import MenuDropdown from './menu-dropdown' import RunBatch from './run-batch' import ResDownload from './run-batch/res-download' -import AppUnavailable from '../../base/app-unavailable' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import RunOnce from '@/app/components/share/text-generation/run-once' -import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share' +import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' import type { SiteInfo } from '@/models/share' import type { MoreLikeThisConfig, @@ -39,10 +37,10 @@ import { Resolution, TransferMethod } from '@/types/app' import { useAppFavicon } from '@/hooks/use-app-favicon' import DifyLogo from '@/app/components/base/logo/dify-logo' import cn from '@/utils/classnames' -import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' import { AccessMode } from '@/models/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import { useWebAppStore } from '@/context/web-app-context' const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group. enum TaskStatus { @@ -83,9 +81,6 @@ const TextGeneration: FC = ({ const mode = searchParams.get('mode') || 'create' const [currentTab, setCurrentTab] = useState(['create', 'batch'].includes(mode) ? mode : 'create') - const router = useRouter() - const pathname = usePathname() - // Notice this situation isCallBatchAPI but not in batch tab const [isCallBatchAPI, setIsCallBatchAPI] = useState(false) const isInBatchTab = currentTab === 'batch' @@ -103,30 +98,19 @@ const TextGeneration: FC = ({ const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) - const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ - appId, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) - const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ - appId, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) - // save message const [savedMessages, setSavedMessages] = useState([]) - const fetchSavedMessage = async () => { - const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id) + const fetchSavedMessage = useCallback(async () => { + const res: any = await doFetchSavedMessage(isInstalledApp, appId) setSavedMessages(res.data) - } + }, [isInstalledApp, appId]) const handleSaveMessage = async (messageId: string) => { - await saveMessage(messageId, isInstalledApp, installedAppInfo?.id) + await saveMessage(messageId, isInstalledApp, appId) notify({ type: 'success', message: t('common.api.saved') }) fetchSavedMessage() } const handleRemoveSavedMessage = async (messageId: string) => { - await removeMessage(messageId, isInstalledApp, installedAppInfo?.id) + await removeMessage(messageId, isInstalledApp, appId) notify({ type: 'success', message: t('common.api.remove') }) fetchSavedMessage() } @@ -375,34 +359,14 @@ const TextGeneration: FC = ({ } } - const fetchInitData = async () => { - if (!isInstalledApp) - await checkOrSetAccessToken() - - return Promise.all([ - isInstalledApp - ? { - app_id: installedAppInfo?.id, - site: { - title: installedAppInfo?.app.name, - prompt_public: false, - copyright: '', - icon: installedAppInfo?.app.icon, - icon_background: installedAppInfo?.app.icon_background, - }, - plan: 'basic', - } - : fetchAppInfo(), - fetchAppParams(isInstalledApp, installedAppInfo?.id), - !isWorkflow - ? fetchSavedMessage() - : {}, - ]) - } - + const appData = useWebAppStore(s => s.appInfo) + const appParams = useWebAppStore(s => s.appParams) + const accessMode = useWebAppStore(s => s.webAppAccessMode) useEffect(() => { (async () => { - const [appData, appParams]: any = await fetchInitData() + if (!appData || !appParams) + return + !isWorkflow && fetchSavedMessage() const { app_id: appId, site: siteInfo, custom_config } = appData setAppId(appId) setSiteInfo(siteInfo as SiteInfo) @@ -413,11 +377,11 @@ const TextGeneration: FC = ({ setVisionConfig({ // legacy of image upload compatible ...file_upload, - transfer_methods: file_upload.allowed_file_upload_methods || file_upload.allowed_upload_methods, + 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, + image_file_size_limit: appParams?.system_parameters.image_file_size_limit, fileUploadConfig: appParams?.system_parameters, - }) + } as any) const prompt_variables = userInputsFormToPromptVariables(user_input_form) setPromptConfig({ prompt_template: '', // placeholder for future @@ -426,7 +390,7 @@ const TextGeneration: FC = ({ setMoreLikeThisConfig(more_like_this) setTextToSpeechConfig(text_to_speech) })() - }, []) + }, [appData, appParams, fetchSavedMessage, isWorkflow]) // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client. useDocumentTitle(siteInfo?.title || t('share.generation.title')) @@ -528,32 +492,12 @@ const TextGeneration: FC = ({
) - const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.set('redirect_url', pathname) - return `/webapp-signin?${params.toString()}` - }, [searchParams, pathname]) - - const backToHome = useCallback(() => { - removeAccessToken() - const url = getSigninUrl() - router.replace(url) - }, [getSigninUrl, router]) - - if (!appId || !siteInfo || !promptConfig || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission))) { + if (!appId || !siteInfo || !promptConfig) { return (
) } - if (systemFeatures.webapp_auth.enabled && !userCanAccessResult?.result) { - return
- - {!isInstalledApp && {t('common.userProfile.logout')}} -
- } - return (
= ({ imageUrl={siteInfo.icon_url} />
{siteInfo.title}
- +
{siteInfo.description && (
{siteInfo.description}
diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index adb926c7ca..1c1b6adfe8 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -18,8 +18,8 @@ import { import ThemeSwitcher from '@/app/components/base/theme-switcher' import type { SiteInfo } from '@/models/share' import cn from '@/utils/classnames' -import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode } from '@/models/access-control' +import { useWebAppStore } from '@/context/web-app-context' type Props = { data?: SiteInfo @@ -32,7 +32,7 @@ const MenuDropdown: FC = ({ placement, hideLogout, }) => { - const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const router = useRouter() const pathname = usePathname() const { t } = useTranslation() diff --git a/web/app/components/share/utils.ts b/web/app/components/share/utils.ts index 8a897ab59a..0c6457fb0c 100644 --- a/web/app/components/share/utils.ts +++ b/web/app/components/share/utils.ts @@ -10,7 +10,7 @@ export const getInitialTokenV2 = (): Record => ({ version: 2, }) -export const checkOrSetAccessToken = async (appCode?: string) => { +export const checkOrSetAccessToken = async (appCode?: string | null) => { const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0] const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) diff --git a/web/context/explore-context.ts b/web/context/explore-context.ts index 11124bd54a..d8d64fb34c 100644 --- a/web/context/explore-context.ts +++ b/web/context/explore-context.ts @@ -8,6 +8,8 @@ type IExplore = { hasEditPermission: boolean installedApps: InstalledApp[] setInstalledApps: (installedApps: InstalledApp[]) => void + isFetchingInstalledApps: boolean + setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void } const ExploreContext = createContext({ @@ -16,6 +18,8 @@ const ExploreContext = createContext({ hasEditPermission: false, installedApps: [], setInstalledApps: noop, + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: noop, }) export default ExploreContext diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 26ad84be65..324ac019c8 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -7,15 +7,12 @@ import type { SystemFeatures } from '@/types/feature' import { defaultSystemFeatures } from '@/types/feature' import { getSystemFeatures } from '@/service/common' import Loading from '@/app/components/base/loading' -import { AccessMode } from '@/models/access-control' type GlobalPublicStore = { isGlobalPending: boolean setIsGlobalPending: (isPending: boolean) => void systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void - webAppAccessMode: AccessMode, - setWebAppAccessMode: (webAppAccessMode: AccessMode) => void } export const useGlobalPublicStore = create(set => ({ @@ -23,8 +20,6 @@ export const useGlobalPublicStore = create(set => ({ setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })), systemFeatures: defaultSystemFeatures, setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), - webAppAccessMode: AccessMode.PUBLIC, - setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })), })) const GlobalPublicStoreProvider: FC = ({ diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx new file mode 100644 index 0000000000..55f95e4811 --- /dev/null +++ b/web/context/web-app-context.tsx @@ -0,0 +1,87 @@ +'use client' + +import type { ChatConfig } from '@/app/components/base/chat/types' +import Loading from '@/app/components/base/loading' +import { AccessMode } from '@/models/access-control' +import type { AppData, AppMeta } from '@/models/share' +import { useGetWebAppAccessModeByCode } from '@/service/use-share' +import { usePathname, useSearchParams } from 'next/navigation' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useState } from 'react' +import { create } from 'zustand' + +type WebAppStore = { + shareCode: string | null + updateShareCode: (shareCode: string | null) => void + appInfo: AppData | null + updateAppInfo: (appInfo: AppData | null) => void + appParams: ChatConfig | null + updateAppParams: (appParams: ChatConfig | null) => void + webAppAccessMode: AccessMode + updateWebAppAccessMode: (accessMode: AccessMode) => void + appMeta: AppMeta | null + updateWebAppMeta: (appMeta: AppMeta | null) => void + userCanAccessApp: boolean + updateUserCanAccessApp: (canAccess: boolean) => void +} + +export const useWebAppStore = create(set => ({ + shareCode: null, + updateShareCode: (shareCode: string | null) => set(() => ({ shareCode })), + appInfo: null, + updateAppInfo: (appInfo: AppData | null) => set(() => ({ appInfo })), + appParams: null, + updateAppParams: (appParams: ChatConfig | null) => set(() => ({ appParams })), + webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })), + appMeta: null, + updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })), + userCanAccessApp: false, + updateUserCanAccessApp: (canAccess: boolean) => set(() => ({ userCanAccessApp: canAccess })), +})) + +const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => { + if (!redirectUrl || redirectUrl.length === 0) + return null + const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) + return url.pathname.split('/').pop() || null +} +const getShareCodeFromPathname = (pathname: string): string | null => { + const code = pathname.split('/').pop() || null + if (code === 'webapp-signin') + return null + return code +} + +const WebAppStoreProvider: FC = ({ children }) => { + const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) + const updateShareCode = useWebAppStore(state => state.updateShareCode) + const pathname = usePathname() + const searchParams = useSearchParams() + const redirectUrlParam = searchParams.get('redirect_url') + const [shareCode, setShareCode] = useState(null) + useEffect(() => { + const shareCodeFromRedirect = getShareCodeFromRedirectUrl(redirectUrlParam) + const shareCodeFromPathname = getShareCodeFromPathname(pathname) + const newShareCode = shareCodeFromRedirect || shareCodeFromPathname + setShareCode(newShareCode) + updateShareCode(newShareCode) + }, [pathname, redirectUrlParam, updateShareCode]) + const { isFetching, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode) + useEffect(() => { + if (accessModeResult?.accessMode) + updateWebAppAccessMode(accessModeResult.accessMode) + }, [accessModeResult, updateWebAppAccessMode]) + if (isFetching) { + return
+ +
+ } + return ( + <> + {children} + + ) +} +export default WebAppStoreProvider diff --git a/web/i18n/en-US/login.ts b/web/i18n/en-US/login.ts index 0beb631d24..d47eb7c079 100644 --- a/web/i18n/en-US/login.ts +++ b/web/i18n/en-US/login.ts @@ -105,6 +105,7 @@ const translation = { licenseInactive: 'License Inactive', licenseInactiveTip: 'The Dify Enterprise license for your workspace is inactive. Please contact your administrator to continue using Dify.', webapp: { + login: 'Login', noLoginMethod: 'Authentication method not configured for web app', noLoginMethodTip: 'Please contact the system admin to add an authentication method.', disabled: 'Webapp authentication is disabled. Please contact the system admin to enable it. You can try to use the app directly.', diff --git a/web/i18n/ja-JP/login.ts b/web/i18n/ja-JP/login.ts index 84ab9eecd0..b37700eba2 100644 --- a/web/i18n/ja-JP/login.ts +++ b/web/i18n/ja-JP/login.ts @@ -106,6 +106,7 @@ const translation = { licenseExpired: 'ライセンスの有効期限が切れています', licenseLostTip: 'Dify ライセンスサーバーへの接続に失敗しました。続けて Dify を使用するために管理者に連絡してください。', webapp: { + login: 'ログイン', noLoginMethod: 'Web アプリに対して認証方法が構成されていません', noLoginMethodTip: 'システム管理者に連絡して、認証方法を追加してください。', disabled: 'Web アプリの認証が無効になっています。システム管理者に連絡して有効にしてください。直接アプリを使用してみてください。', diff --git a/web/i18n/zh-Hans/login.ts b/web/i18n/zh-Hans/login.ts index a37fc104eb..b63630e288 100644 --- a/web/i18n/zh-Hans/login.ts +++ b/web/i18n/zh-Hans/login.ts @@ -106,6 +106,7 @@ const translation = { licenseInactive: '许可证未激活', licenseInactiveTip: '您所在空间的 Dify Enterprise 许可证尚未激活,请联系管理员以继续使用 Dify。', webapp: { + login: '登录', noLoginMethod: 'Web 应用未配置身份认证方式', noLoginMethodTip: '请联系系统管理员添加身份认证方式', disabled: 'Web 应用身份认证已禁用,请联系系统管理员启用。您也可以尝试直接使用应用。', diff --git a/web/models/share.ts b/web/models/share.ts index 3521365e82..1e3b6d6bb7 100644 --- a/web/models/share.ts +++ b/web/models/share.ts @@ -35,7 +35,7 @@ export type AppMeta = { export type AppData = { app_id: string can_replace_logo?: boolean - custom_config?: Record + custom_config: Record | null enable_site?: boolean end_user_id?: string site: SiteInfo diff --git a/web/service/access-control.ts b/web/service/access-control.ts index 36999bf8f3..d4cc9eb792 100644 --- a/web/service/access-control.ts +++ b/web/service/access-control.ts @@ -1,8 +1,9 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { get, post } from './base' -import { getAppAccessMode, getUserCanAccess } from './share' +import { getUserCanAccess } from './share' import type { AccessControlAccount, AccessControlGroup, AccessMode, Subject } from '@/models/access-control' import type { App } from '@/types/app' +import { useGlobalPublicStore } from '@/context/global-public-context' const NAME_SPACE = 'access-control' @@ -69,25 +70,18 @@ export const useUpdateAccessMode = () => { }) } -export const useGetAppAccessMode = ({ appId, isInstalledApp = true, enabled }: { appId?: string; isInstalledApp?: boolean; enabled: boolean }) => { - return useQuery({ - queryKey: [NAME_SPACE, 'app-access-mode', appId], - queryFn: () => getAppAccessMode(appId!, isInstalledApp), - enabled: !!appId && enabled, - staleTime: 0, - gcTime: 0, - }) -} - -export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true, enabled }: { appId?: string; isInstalledApp?: boolean; enabled: boolean }) => { +export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true }: { appId?: string; isInstalledApp?: boolean; }) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ queryKey: [NAME_SPACE, 'user-can-access-app', appId], - queryFn: () => getUserCanAccess(appId!, isInstalledApp), - enabled: !!appId && enabled, + queryFn: () => { + if (systemFeatures.webapp_auth.enabled) + return getUserCanAccess(appId!, isInstalledApp) + else + return { result: true } + }, + enabled: !!appId, staleTime: 0, gcTime: 0, - initialData: { - result: !enabled, - }, }) } diff --git a/web/service/base.ts b/web/service/base.ts index 49377be912..8ffacaa0f1 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -413,7 +413,7 @@ export const ssePost = async ( if (data.code === 'unauthorized') { removeAccessToken() - globalThis.location.reload() + requiredWebSSOLogin() } } }) @@ -507,7 +507,7 @@ export const request = async(url: string, options = {}, otherOptions?: IOther } = otherOptionsForBaseFetch if (isPublicAPI && code === 'unauthorized') { removeAccessToken() - globalThis.location.reload() + requiredWebSSOLogin() return Promise.reject(err) } if (code === 'init_validate_failed' && IS_CE_EDITION && !silent) { diff --git a/web/service/explore.ts b/web/service/explore.ts index e9e17416d1..6a440d7f5d 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -1,5 +1,6 @@ import { del, get, patch, post } from './base' import type { App, AppCategory } from '@/models/explore' +import type { AccessMode } from '@/models/access-control' export const fetchAppList = () => { return get<{ @@ -39,3 +40,7 @@ export const updatePinStatus = (id: string, isPinned: boolean) => { export const getToolProviders = () => { return get('/workspaces/current/tool-providers') } + +export const getAppAccessModeByAppId = (appId: string) => { + return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`) +} diff --git a/web/service/share.ts b/web/service/share.ts index 6a2a7e5b16..8c33b85522 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -296,13 +296,6 @@ export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { return get(url, { headers }) as Promise<{ access_token: string }> } -export const getAppAccessMode = (appId: string, isInstalledApp: boolean) => { - if (isInstalledApp) - return consoleGet<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`) - - return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appId=${appId}`) -} - export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => { if (isInstalledApp) return consoleGet<{ result: boolean }>(`/enterprise/webapp/permission?appId=${appId}`) diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts new file mode 100644 index 0000000000..b7d078edbc --- /dev/null +++ b/web/service/use-explore.ts @@ -0,0 +1,81 @@ +import { useGlobalPublicStore } from '@/context/global-public-context' +import { AccessMode } from '@/models/access-control' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore' +import { fetchAppMeta, fetchAppParams } from './share' + +const NAME_SPACE = 'explore' + +export const useGetInstalledApps = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'installedApps'], + queryFn: () => { + return fetchInstalledAppList() + }, + }) +} + +export const useUninstallApp = () => { + const client = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'uninstallApp'], + mutationFn: (appId: string) => uninstallApp(appId), + onSuccess: () => { + client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] }) + }, + }) +} + +export const useUpdateAppPinStatus = () => { + const client = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'updateAppPinStatus'], + mutationFn: ({ appId, isPinned }: { appId: string; isPinned: boolean }) => updatePinStatus(appId, isPinned), + onSuccess: () => { + client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] }) + }, + }) +} + +export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + return useQuery({ + queryKey: [NAME_SPACE, 'appAccessMode', appId], + queryFn: () => { + if (systemFeatures.webapp_auth.enabled === false) { + return { + accessMode: AccessMode.PUBLIC, + } + } + if (!appId || appId.length === 0) + return Promise.reject(new Error('App code is required to get access mode')) + + return getAppAccessModeByAppId(appId) + }, + enabled: !!appId, + }) +} + +export const useGetInstalledAppParams = (appId: string | null) => { + return useQuery({ + queryKey: [NAME_SPACE, 'appParams', appId], + queryFn: () => { + if (!appId || appId.length === 0) + return Promise.reject(new Error('App ID is required to get app params')) + return fetchAppParams(true, appId) + }, + enabled: !!appId, + }) +} + +export const useGetInstalledAppMeta = (appId: string | null) => { + return useQuery({ + queryKey: [NAME_SPACE, 'appMeta', appId], + queryFn: () => { + if (!appId || appId.length === 0) + return Promise.reject(new Error('App ID is required to get app meta')) + return fetchAppMeta(true, appId) + }, + enabled: !!appId, + }) +} diff --git a/web/service/use-share.ts b/web/service/use-share.ts index b8f96f6cc5..63f18bf0e0 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -1,17 +1,52 @@ +import { useGlobalPublicStore } from '@/context/global-public-context' +import { AccessMode } from '@/models/access-control' import { useQuery } from '@tanstack/react-query' -import { getAppAccessModeByAppCode } from './share' +import { fetchAppInfo, fetchAppMeta, fetchAppParams, getAppAccessModeByAppCode } from './share' const NAME_SPACE = 'webapp' -export const useAppAccessModeByCode = (code: string | null) => { +export const useGetWebAppAccessModeByCode = (code: string | null) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ queryKey: [NAME_SPACE, 'appAccessMode', code], queryFn: () => { - if (!code) - return null + if (systemFeatures.webapp_auth.enabled === false) { + return { + accessMode: AccessMode.PUBLIC, + } + } + if (!code || code.length === 0) + return Promise.reject(new Error('App code is required to get access mode')) return getAppAccessModeByAppCode(code) }, enabled: !!code, }) } + +export const useGetWebAppInfo = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appInfo'], + queryFn: () => { + return fetchAppInfo() + }, + }) +} + +export const useGetWebAppParams = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appParams'], + queryFn: () => { + return fetchAppParams(false) + }, + }) +} + +export const useGetWebAppMeta = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appMeta'], + queryFn: () => { + return fetchAppMeta(false) + }, + }) +} From a4f421028c9abe46c05250de61c9fb454a449cc0 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Thu, 17 Jul 2025 10:55:59 +0800 Subject: [PATCH 128/241] Feat/change user email (#22213) Co-authored-by: NFish Co-authored-by: JzoNg Co-authored-by: Garfield Dai --- api/.env.example | 2 + api/configs/feature/__init__.py | 19 + api/controllers/console/auth/error.py | 48 +++ api/controllers/console/workspace/account.py | 147 ++++++- api/controllers/console/workspace/members.py | 156 +++++++- api/controllers/console/wraps.py | 26 ++ api/services/account_service.py | 225 +++++++++++ api/services/feature_service.py | 6 +- api/tasks/mail_change_mail_task.py | 78 ++++ api/tasks/mail_owner_transfer_task.py | 152 +++++++ ...hange_mail_confirm_new_template_en-US.html | 125 ++++++ ...hange_mail_confirm_new_template_zh-CN.html | 125 ++++++ ...hange_mail_confirm_old_template_en-US.html | 125 ++++++ ...hange_mail_confirm_old_template_zh-CN.html | 125 ++++++ .../clean_document_job_mail_template-US.html | 152 ++++--- .../invite_member_mail_template_en-US.html | 149 ++++--- .../invite_member_mail_template_zh-CN.html | 147 ++++--- ...space_new_owner_notify_template_en-US.html | 92 +++++ ...space_new_owner_notify_template_zh-CN.html | 92 +++++ ...space_old_owner_notify_template_en-US.html | 122 ++++++ ...space_old_owner_notify_template_zh-CN.html | 122 ++++++ ...orkspace_owner_confirm_template_en-US.html | 153 ++++++++ ...orkspace_owner_confirm_template_zh-CN.html | 153 ++++++++ ...hange_mail_confirm_new_template_en-US.html | 122 ++++++ ...hange_mail_confirm_new_template_zh-CN.html | 122 ++++++ ...hange_mail_confirm_old_template_en-US.html | 122 ++++++ ...hange_mail_confirm_old_template_zh-CN.html | 122 ++++++ .../invite_member_mail_template_en-US.html | 145 ++++--- .../invite_member_mail_template_zh-CN.html | 142 ++++--- ...space_new_owner_notify_template_en-US.html | 89 +++++ ...space_new_owner_notify_template_zh-CN.html | 89 +++++ ...space_old_owner_notify_template_en-US.html | 119 ++++++ ...space_old_owner_notify_template_zh-CN.html | 119 ++++++ ...orkspace_owner_confirm_template_en-US.html | 150 +++++++ ...orkspace_owner_confirm_template_zh-CN.html | 150 +++++++ api/tests/integration_tests/.env.example | 2 + docker/.env.example | 2 + docker/docker-compose.yaml | 2 + .../account-page/email-change-modal.tsx | 371 ++++++++++++++++++ web/app/account/account-page/index.module.css | 9 - web/app/account/account-page/index.tsx | 30 +- web/app/components/billing/type.ts | 3 +- .../account-setting/members-page/index.tsx | 28 +- .../operation/transfer-ownership.tsx | 54 +++ .../transfer-ownership-modal/index.tsx | 253 ++++++++++++ .../member-selector.tsx | 112 ++++++ web/context/provider-context.tsx | 6 + web/i18n/en-US/common.ts | 42 ++ web/i18n/ja-JP/common.ts | 42 ++ web/i18n/zh-Hans/common.ts | 42 ++ web/service/common.ts | 21 + web/types/feature.ts | 2 + 52 files changed, 4726 insertions(+), 327 deletions(-) create mode 100644 api/tasks/mail_change_mail_task.py create mode 100644 api/tasks/mail_owner_transfer_task.py create mode 100644 api/templates/change_mail_confirm_new_template_en-US.html create mode 100644 api/templates/change_mail_confirm_new_template_zh-CN.html create mode 100644 api/templates/change_mail_confirm_old_template_en-US.html create mode 100644 api/templates/change_mail_confirm_old_template_zh-CN.html create mode 100644 api/templates/transfer_workspace_new_owner_notify_template_en-US.html create mode 100644 api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html create mode 100644 api/templates/transfer_workspace_old_owner_notify_template_en-US.html create mode 100644 api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html create mode 100644 api/templates/transfer_workspace_owner_confirm_template_en-US.html create mode 100644 api/templates/transfer_workspace_owner_confirm_template_zh-CN.html create mode 100644 api/templates/without-brand/change_mail_confirm_new_template_en-US.html create mode 100644 api/templates/without-brand/change_mail_confirm_new_template_zh-CN.html create mode 100644 api/templates/without-brand/change_mail_confirm_old_template_en-US.html create mode 100644 api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html create mode 100644 api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html create mode 100644 api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html create mode 100644 api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html create mode 100644 api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html create mode 100644 api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html create mode 100644 api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html create mode 100644 web/app/account/account-page/email-change-modal.tsx delete mode 100644 web/app/account/account-page/index.module.css create mode 100644 web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx diff --git a/api/.env.example b/api/.env.example index c09c6c230e..3fe95c44b5 100644 --- a/api/.env.example +++ b/api/.env.example @@ -495,6 +495,8 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} # Reset password token expiry minutes RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 +OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 CREATE_TIDB_SERVICE_JOB_ENABLED=false diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index f6a8b037ca..f1d529355d 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -31,6 +31,15 @@ class SecurityConfig(BaseSettings): description="Duration in minutes for which a password reset token remains valid", default=5, ) + CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( + description="Duration in minutes for which a change email token remains valid", + default=5, + ) + + OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( + description="Duration in minutes for which a owner transfer token remains valid", + default=5, + ) LOGIN_DISABLED: bool = Field( description="Whether to disable login checks", @@ -614,6 +623,16 @@ class AuthConfig(BaseSettings): default=86400, ) + CHANGE_EMAIL_LOCKOUT_DURATION: PositiveInt = Field( + description="Time (in seconds) a user must wait before retrying change email after exceeding the rate limit.", + default=86400, + ) + + OWNER_TRANSFER_LOCKOUT_DURATION: PositiveInt = Field( + description="Time (in seconds) a user must wait before retrying owner transfer after exceeding the rate limit.", + default=86400, + ) + class ModerationConfig(BaseSettings): """ diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index b40934dbf5..f4a8b97483 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -31,6 +31,18 @@ class PasswordResetRateLimitExceededError(BaseHTTPException): code = 429 +class EmailChangeRateLimitExceededError(BaseHTTPException): + error_code = "email_change_rate_limit_exceeded" + description = "Too many email change emails have been sent. Please try again in 1 minutes." + code = 429 + + +class OwnerTransferRateLimitExceededError(BaseHTTPException): + error_code = "owner_transfer_rate_limit_exceeded" + description = "Too many owner tansfer emails have been sent. Please try again in 1 minutes." + code = 429 + + class EmailCodeError(BaseHTTPException): error_code = "email_code_error" description = "Email code is invalid or expired." @@ -65,3 +77,39 @@ class EmailPasswordResetLimitError(BaseHTTPException): error_code = "email_password_reset_limit" description = "Too many failed password reset attempts. Please try again in 24 hours." code = 429 + + +class EmailChangeLimitError(BaseHTTPException): + error_code = "email_change_limit" + description = "Too many failed email change attempts. Please try again in 24 hours." + code = 429 + + +class EmailAlreadyInUseError(BaseHTTPException): + error_code = "email_already_in_use" + description = "A user with this email already exists." + code = 400 + + +class OwnerTransferLimitError(BaseHTTPException): + error_code = "owner_transfer_limit" + description = "Too many failed owner transfer attempts. Please try again in 24 hours." + code = 429 + + +class NotOwnerError(BaseHTTPException): + error_code = "not_owner" + description = "You are not the owner of the workspace." + code = 400 + + +class CannotTransferOwnerToSelfError(BaseHTTPException): + error_code = "cannot_transfer_owner_to_self" + description = "You cannot transfer ownership to yourself." + code = 400 + + +class MemberNotInTenantError(BaseHTTPException): + error_code = "member_not_in_tenant" + description = "The member is not in the workspace." + code = 400 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index a9dbf44456..1f22e3fd01 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -4,10 +4,20 @@ import pytz from flask import request from flask_login import current_user from flask_restful import Resource, fields, marshal_with, reqparse +from sqlalchemy import select +from sqlalchemy.orm import Session from configs import dify_config from constants.languages import supported_language from controllers.console import api +from controllers.console.auth.error import ( + EmailAlreadyInUseError, + EmailChangeLimitError, + EmailCodeError, + InvalidEmailError, + InvalidTokenError, +) +from controllers.console.error import AccountNotFound, EmailSendIpLimitError from controllers.console.workspace.error import ( AccountAlreadyInitedError, CurrentPasswordIncorrectError, @@ -18,15 +28,17 @@ from controllers.console.workspace.error import ( from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_enabled, + enable_change_email, enterprise_license_required, only_edition_cloud, setup_required, ) from extensions.ext_database import db from fields.member_fields import account_fields -from libs.helper import TimestampField, timezone +from libs.helper import TimestampField, email, extract_remote_ip, timezone from libs.login import login_required from models import AccountIntegrate, InvitationCode +from models.account import Account from services.account_service import AccountService from services.billing_service import BillingService from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError @@ -369,6 +381,134 @@ class EducationAutoCompleteApi(Resource): return BillingService.EducationIdentity.autocomplete(args["keywords"], args["page"], args["limit"]) +class ChangeEmailSendEmailApi(Resource): + @enable_change_email + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + parser.add_argument("language", type=str, required=False, location="json") + parser.add_argument("phase", type=str, required=False, location="json") + parser.add_argument("token", type=str, required=False, location="json") + args = parser.parse_args() + + ip_address = extract_remote_ip(request) + if AccountService.is_email_send_ip_limit(ip_address): + raise EmailSendIpLimitError() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + account = None + user_email = args["email"] + if args["phase"] is not None and args["phase"] == "new_email": + if args["token"] is None: + raise InvalidTokenError() + + reset_data = AccountService.get_change_email_data(args["token"]) + if reset_data is None: + raise InvalidTokenError() + user_email = reset_data.get("email", "") + + if user_email != current_user.email: + raise InvalidEmailError() + else: + with Session(db.engine) as session: + account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() + if account is None: + raise AccountNotFound() + + token = AccountService.send_change_email_email( + account=account, email=args["email"], old_email=user_email, language=language, phase=args["phase"] + ) + return {"result": "success", "data": token} + + +class ChangeEmailCheckApi(Resource): + @enable_change_email + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + user_email = args["email"] + + is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(args["email"]) + if is_change_email_error_rate_limit: + raise EmailChangeLimitError() + + token_data = AccountService.get_change_email_data(args["token"]) + if token_data is None: + raise InvalidTokenError() + + if user_email != token_data.get("email"): + raise InvalidEmailError() + + if args["code"] != token_data.get("code"): + AccountService.add_change_email_error_rate_limit(args["email"]) + raise EmailCodeError() + + # Verified, revoke the first token + AccountService.revoke_change_email_token(args["token"]) + + # Refresh token data by generating a new token + _, new_token = AccountService.generate_change_email_token( + user_email, code=args["code"], old_email=token_data.get("old_email"), additional_data={} + ) + + AccountService.reset_change_email_error_rate_limit(args["email"]) + return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + + +class ChangeEmailResetApi(Resource): + @enable_change_email + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("new_email", type=email, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + reset_data = AccountService.get_change_email_data(args["token"]) + if not reset_data: + raise InvalidTokenError() + + AccountService.revoke_change_email_token(args["token"]) + + if not AccountService.check_email_unique(args["new_email"]): + raise EmailAlreadyInUseError() + + old_email = reset_data.get("old_email", "") + if current_user.email != old_email: + raise AccountNotFound() + + updated_account = AccountService.update_account(current_user, email=args["new_email"]) + + return updated_account + + +class CheckEmailUnique(Resource): + @setup_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + args = parser.parse_args() + if not AccountService.check_email_unique(args["email"]): + raise EmailAlreadyInUseError() + return {"result": "success"} + + # Register API resources api.add_resource(AccountInitApi, "/account/init") api.add_resource(AccountProfileApi, "/account/profile") @@ -385,5 +525,10 @@ api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback") api.add_resource(EducationVerifyApi, "/account/education/verify") api.add_resource(EducationApi, "/account/education") api.add_resource(EducationAutoCompleteApi, "/account/education/autocomplete") +# Change email +api.add_resource(ChangeEmailSendEmailApi, "/account/change-email") +api.add_resource(ChangeEmailCheckApi, "/account/change-email/validity") +api.add_resource(ChangeEmailResetApi, "/account/change-email/reset") +api.add_resource(CheckEmailUnique, "/account/change-email/check-email-unique") # api.add_resource(AccountEmailApi, '/account/email') # api.add_resource(AccountEmailVerifyApi, '/account/email-verify') diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 48225ac90d..30a4148dbb 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,22 +1,34 @@ from urllib import parse +from flask import request from flask_login import current_user from flask_restful import Resource, abort, marshal_with, reqparse import services from configs import dify_config from controllers.console import api -from controllers.console.error import WorkspaceMembersLimitExceeded +from controllers.console.auth.error import ( + CannotTransferOwnerToSelfError, + EmailCodeError, + InvalidEmailError, + InvalidTokenError, + MemberNotInTenantError, + NotOwnerError, + OwnerTransferLimitError, +) +from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, + is_allow_transfer_owner, setup_required, ) from extensions.ext_database import db from fields.member_fields import account_with_role_list_fields +from libs.helper import extract_remote_ip from libs.login import login_required from models.account import Account, TenantAccountRole -from services.account_service import RegisterService, TenantService +from services.account_service import AccountService, RegisterService, TenantService from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService @@ -156,8 +168,148 @@ class DatasetOperatorMemberListApi(Resource): return {"result": "success", "accounts": members}, 200 +class SendOwnerTransferEmailApi(Resource): + """Send owner transfer email.""" + + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("language", type=str, required=False, location="json") + args = parser.parse_args() + ip_address = extract_remote_ip(request) + if AccountService.is_email_send_ip_limit(ip_address): + raise EmailSendIpLimitError() + + # check if the current user is the owner of the workspace + if not TenantService.is_owner(current_user, current_user.current_tenant): + raise NotOwnerError() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + + email = current_user.email + + token = AccountService.send_owner_transfer_email( + account=current_user, + email=email, + language=language, + workspace_name=current_user.current_tenant.name, + ) + + return {"result": "success", "data": token} + + +class OwnerTransferCheckApi(Resource): + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + # check if the current user is the owner of the workspace + if not TenantService.is_owner(current_user, current_user.current_tenant): + raise NotOwnerError() + + user_email = current_user.email + + is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email) + if is_owner_transfer_error_rate_limit: + raise OwnerTransferLimitError() + + token_data = AccountService.get_owner_transfer_data(args["token"]) + if token_data is None: + raise InvalidTokenError() + + if user_email != token_data.get("email"): + raise InvalidEmailError() + + if args["code"] != token_data.get("code"): + AccountService.add_owner_transfer_error_rate_limit(user_email) + raise EmailCodeError() + + # Verified, revoke the first token + AccountService.revoke_owner_transfer_token(args["token"]) + + # Refresh token data by generating a new token + _, new_token = AccountService.generate_owner_transfer_token(user_email, code=args["code"], additional_data={}) + + AccountService.reset_owner_transfer_error_rate_limit(user_email) + return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + + +class OwnerTransfer(Resource): + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self, member_id): + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + # check if the current user is the owner of the workspace + if not TenantService.is_owner(current_user, current_user.current_tenant): + raise NotOwnerError() + + if current_user.id == str(member_id): + raise CannotTransferOwnerToSelfError() + + transfer_token_data = AccountService.get_owner_transfer_data(args["token"]) + if not transfer_token_data: + print(transfer_token_data, "transfer_token_data") + raise InvalidTokenError() + + if transfer_token_data.get("email") != current_user.email: + print(transfer_token_data.get("email"), current_user.email) + raise InvalidEmailError() + + AccountService.revoke_owner_transfer_token(args["token"]) + + member = db.session.get(Account, str(member_id)) + if not member: + abort(404) + else: + member_account = member + if not TenantService.is_member(member_account, current_user.current_tenant): + raise MemberNotInTenantError() + + try: + assert member is not None, "Member not found" + TenantService.update_member_role(current_user.current_tenant, member, "owner", current_user) + + AccountService.send_new_owner_transfer_notify_email( + account=member, + email=member.email, + workspace_name=current_user.current_tenant.name, + ) + + AccountService.send_old_owner_transfer_notify_email( + account=current_user, + email=current_user.email, + workspace_name=current_user.current_tenant.name, + new_owner_email=member.email, + ) + + except Exception as e: + raise ValueError(str(e)) + + return {"result": "success"} + + api.add_resource(MemberListApi, "/workspaces/current/members") api.add_resource(MemberInviteEmailApi, "/workspaces/current/members/invite-email") api.add_resource(MemberCancelInviteApi, "/workspaces/current/members/") api.add_resource(MemberUpdateRoleApi, "/workspaces/current/members//update-role") api.add_resource(DatasetOperatorMemberListApi, "/workspaces/current/dataset-operators") +# owner transfer +api.add_resource(SendOwnerTransferEmailApi, "/workspaces/current/members/send-owner-transfer-confirm-email") +api.add_resource(OwnerTransferCheckApi, "/workspaces/current/members/owner-transfer-check") +api.add_resource(OwnerTransfer, "/workspaces/current/members//owner-transfer") diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index ca122772de..d862dac373 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -235,3 +235,29 @@ def email_password_login_enabled(view): abort(403) return decorated + + +def enable_change_email(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_system_features() + if features.enable_change_email: + return view(*args, **kwargs) + + # otherwise, return 403 + abort(403) + + return decorated + + +def is_allow_transfer_owner(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_features(current_user.current_tenant_id) + if features.is_allow_transfer_workspace: + return view(*args, **kwargs) + + # otherwise, return 403 + abort(403) + + return decorated diff --git a/api/services/account_service.py b/api/services/account_service.py index 2ba6f4345b..4d5366f47f 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -52,8 +52,14 @@ from services.errors.workspace import WorkSpaceNotAllowedCreateError, Workspaces from services.feature_service import FeatureService from tasks.delete_account_task import delete_account_task from tasks.mail_account_deletion_task import send_account_deletion_verification_code +from tasks.mail_change_mail_task import send_change_mail_task from tasks.mail_email_code_login import send_email_code_login_mail_task from tasks.mail_invite_member_task import send_invite_member_mail_task +from tasks.mail_owner_transfer_task import ( + send_new_owner_transfer_notify_email_task, + send_old_owner_transfer_notify_email_task, + send_owner_transfer_confirm_task, +) from tasks.mail_reset_password_task import send_reset_password_mail_task @@ -75,8 +81,13 @@ class AccountService: email_code_account_deletion_rate_limiter = RateLimiter( prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 ) + change_email_rate_limiter = RateLimiter(prefix="change_email_rate_limit", max_attempts=1, time_window=60 * 1) + owner_transfer_rate_limiter = RateLimiter(prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60 * 1) + LOGIN_MAX_ERROR_LIMITS = 5 FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 + CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 + OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: @@ -419,6 +430,101 @@ class AccountService: cls.reset_password_rate_limiter.increment_rate_limit(account_email) return token + @classmethod + def send_change_email_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + old_email: Optional[str] = None, + language: Optional[str] = "en-US", + phase: Optional[str] = None, + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + if cls.change_email_rate_limiter.is_rate_limited(account_email): + from controllers.console.auth.error import EmailChangeRateLimitExceededError + + raise EmailChangeRateLimitExceededError() + + code, token = cls.generate_change_email_token(account_email, account, old_email=old_email) + + send_change_mail_task.delay( + language=language, + to=account_email, + code=code, + phase=phase, + ) + cls.change_email_rate_limiter.increment_rate_limit(account_email) + return token + + @classmethod + def send_owner_transfer_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace_name: Optional[str] = "", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + if cls.owner_transfer_rate_limiter.is_rate_limited(account_email): + from controllers.console.auth.error import OwnerTransferRateLimitExceededError + + raise OwnerTransferRateLimitExceededError() + + code, token = cls.generate_owner_transfer_token(account_email, account) + + send_owner_transfer_confirm_task.delay( + language=language, + to=account_email, + code=code, + workspace=workspace_name, + ) + cls.owner_transfer_rate_limiter.increment_rate_limit(account_email) + return token + + @classmethod + def send_old_owner_transfer_notify_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace_name: Optional[str] = "", + new_owner_email: Optional[str] = "", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + send_old_owner_transfer_notify_email_task.delay( + language=language, + to=account_email, + workspace=workspace_name, + new_owner_email=new_owner_email, + ) + + @classmethod + def send_new_owner_transfer_notify_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace_name: Optional[str] = "", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + send_new_owner_transfer_notify_email_task.delay( + language=language, + to=account_email, + workspace=workspace_name, + ) + @classmethod def generate_reset_password_token( cls, @@ -435,14 +541,64 @@ class AccountService: ) return code, token + @classmethod + def generate_change_email_token( + cls, + email: str, + account: Optional[Account] = None, + code: Optional[str] = None, + old_email: Optional[str] = None, + additional_data: dict[str, Any] = {}, + ): + if not code: + code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) + additional_data["code"] = code + additional_data["old_email"] = old_email + token = TokenManager.generate_token( + account=account, email=email, token_type="change_email", additional_data=additional_data + ) + return code, token + + @classmethod + def generate_owner_transfer_token( + cls, + email: str, + account: Optional[Account] = None, + code: Optional[str] = None, + additional_data: dict[str, Any] = {}, + ): + if not code: + code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) + additional_data["code"] = code + token = TokenManager.generate_token( + account=account, email=email, token_type="owner_transfer", additional_data=additional_data + ) + return code, token + @classmethod def revoke_reset_password_token(cls, token: str): TokenManager.revoke_token(token, "reset_password") + @classmethod + def revoke_change_email_token(cls, token: str): + TokenManager.revoke_token(token, "change_email") + + @classmethod + def revoke_owner_transfer_token(cls, token: str): + TokenManager.revoke_token(token, "owner_transfer") + @classmethod def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "reset_password") + @classmethod + def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: + return TokenManager.get_token_data(token, "change_email") + + @classmethod + def get_owner_transfer_data(cls, token: str) -> Optional[dict[str, Any]]: + return TokenManager.get_token_data(token, "owner_transfer") + @classmethod def send_email_code_login_email( cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US" @@ -552,6 +708,62 @@ class AccountService: key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) + @staticmethod + @redis_fallback(default_return=None) + def add_change_email_error_rate_limit(email: str) -> None: + key = f"change_email_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.CHANGE_EMAIL_LOCKOUT_DURATION, count) + + @staticmethod + @redis_fallback(default_return=False) + def is_change_email_error_rate_limit(email: str) -> bool: + key = f"change_email_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.CHANGE_EMAIL_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_change_email_error_rate_limit(email: str): + key = f"change_email_error_rate_limit:{email}" + redis_client.delete(key) + + @staticmethod + @redis_fallback(default_return=None) + def add_owner_transfer_error_rate_limit(email: str) -> None: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.OWNER_TRANSFER_LOCKOUT_DURATION, count) + + @staticmethod + @redis_fallback(default_return=False) + def is_owner_transfer_error_rate_limit(email: str) -> bool: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.OWNER_TRANSFER_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_owner_transfer_error_rate_limit(email: str): + key = f"owner_transfer_error_rate_limit:{email}" + redis_client.delete(key) + @staticmethod @redis_fallback(default_return=False) def is_email_send_ip_limit(ip_address: str): @@ -593,6 +805,10 @@ class AccountService: return False + @staticmethod + def check_email_unique(email: str) -> bool: + return db.session.query(Account).filter_by(email=email).first() is None + class TenantService: @staticmethod @@ -865,6 +1081,15 @@ class TenantService: return cast(dict, tenant.custom_config_dict) + @staticmethod + def is_owner(account: Account, tenant: Tenant) -> bool: + return TenantService.get_user_role(account, tenant) == TenantAccountRole.OWNER + + @staticmethod + def is_member(account: Account, tenant: Tenant) -> bool: + """Check if the account is a member of the tenant""" + return TenantService.get_user_role(account, tenant) is not None + class RegisterService: @classmethod diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 188caf3505..1441e6ce16 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -123,7 +123,7 @@ class FeatureModel(BaseModel): dataset_operator_enabled: bool = False webapp_copyright_enabled: bool = False workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) - + is_allow_transfer_workspace: bool = True # pydantic configs model_config = ConfigDict(protected_namespaces=()) @@ -149,6 +149,7 @@ class SystemFeatureModel(BaseModel): branding: BrandingModel = BrandingModel() webapp_auth: WebAppAuthModel = WebAppAuthModel() plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() + enable_change_email: bool = True class FeatureService: @@ -186,6 +187,7 @@ class FeatureService: if dify_config.ENTERPRISE_ENABLED: system_features.branding.enabled = True system_features.webapp_auth.enabled = True + system_features.enable_change_email = False cls._fulfill_params_from_enterprise(system_features) if dify_config.MARKETPLACE_ENABLED: @@ -228,6 +230,8 @@ class FeatureService: if features.billing.subscription.plan != "sandbox": features.webapp_copyright_enabled = True + else: + features.is_allow_transfer_workspace = False if "members" in billing_info: features.members.size = billing_info["members"]["size"] diff --git a/api/tasks/mail_change_mail_task.py b/api/tasks/mail_change_mail_task.py new file mode 100644 index 0000000000..da44040b7d --- /dev/null +++ b/api/tasks/mail_change_mail_task.py @@ -0,0 +1,78 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail +from services.feature_service import FeatureService + + +@shared_task(queue="mail") +def send_change_mail_task(language: str, to: str, code: str, phase: str): + """ + Async Send change email mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param code: Change email code + :param phase: Change email phase (new_email, old_email) + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + + email_config = { + "zh-Hans": { + "old_email": { + "subject": "检测您现在的邮箱", + "template_with_brand": "change_mail_confirm_old_template_zh-CN.html", + "template_without_brand": "without-brand/change_mail_confirm_old_template_zh-CN.html", + }, + "new_email": { + "subject": "确认您的邮箱地址变更", + "template_with_brand": "change_mail_confirm_new_template_zh-CN.html", + "template_without_brand": "without-brand/change_mail_confirm_new_template_zh-CN.html", + }, + }, + "en": { + "old_email": { + "subject": "Check your current email", + "template_with_brand": "change_mail_confirm_old_template_en-US.html", + "template_without_brand": "without-brand/change_mail_confirm_old_template_en-US.html", + }, + "new_email": { + "subject": "Confirm your new email address", + "template_with_brand": "change_mail_confirm_new_template_en-US.html", + "template_without_brand": "without-brand/change_mail_confirm_new_template_en-US.html", + }, + }, + } + + # send change email mail using different languages + try: + system_features = FeatureService.get_system_features() + lang_key = "zh-Hans" if language == "zh-Hans" else "en" + + if phase not in ["old_email", "new_email"]: + raise ValueError("Invalid phase") + + config = email_config[lang_key][phase] + subject = config["subject"] + + if system_features.branding.enabled: + template = config["template_without_brand"] + else: + template = config["template_with_brand"] + + html_content = render_template(template, to=to, code=code) + mail.send(to=to, subject=subject, html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style("Send change email mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green") + ) + except Exception: + logging.exception("Send change email mail to {} failed".format(to)) diff --git a/api/tasks/mail_owner_transfer_task.py b/api/tasks/mail_owner_transfer_task.py new file mode 100644 index 0000000000..8d05c6dc0f --- /dev/null +++ b/api/tasks/mail_owner_transfer_task.py @@ -0,0 +1,152 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail +from services.feature_service import FeatureService + + +@shared_task(queue="mail") +def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param workspace: Workspace name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_owner_confirm_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + template = "transfer_workspace_owner_confirm_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param workspace: Workspace name + :param new_owner_email: New owner email + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_old_owner_notify_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html" + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="工作区所有权已转移", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="工作区所有权已转移", html=html_content) + else: + template = "transfer_workspace_old_owner_notify_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_old_owner_notify_template_en-US.html" + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param code: Change email code + :param workspace: Workspace name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_new_owner_notify_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html" + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) + else: + template = "transfer_workspace_new_owner_notify_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_new_owner_notify_template_en-US.html" + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) diff --git a/api/templates/change_mail_confirm_new_template_en-US.html b/api/templates/change_mail_confirm_new_template_en-US.html new file mode 100644 index 0000000000..88721e787c --- /dev/null +++ b/api/templates/change_mail_confirm_new_template_en-US.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Confirm Your New Email Address

+
+

You’re updating the email address linked to your Dify account.

+

To confirm this action, please use the verification code below.

+

This code will only be valid for the next 5 minutes:

+
+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/change_mail_confirm_new_template_zh-CN.html b/api/templates/change_mail_confirm_new_template_zh-CN.html new file mode 100644 index 0000000000..25336ea1a1 --- /dev/null +++ b/api/templates/change_mail_confirm_new_template_zh-CN.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

确认您的邮箱地址变更

+
+

您正在更新与您的 Dify 账户关联的邮箱地址。

+

为了确认此操作,请使用以下验证码。

+

此验证码仅在接下来的5分钟内有效:

+
+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/change_mail_confirm_old_template_en-US.html b/api/templates/change_mail_confirm_old_template_en-US.html new file mode 100644 index 0000000000..b20306aa87 --- /dev/null +++ b/api/templates/change_mail_confirm_old_template_en-US.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Verify Your Request to Change Email

+
+

We received a request to change the email address associated with your Dify account.

+

To confirm this action, please use the verification code below.

+

This code will only be valid for the next 5 minutes:

+
+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/change_mail_confirm_old_template_zh-CN.html b/api/templates/change_mail_confirm_old_template_zh-CN.html new file mode 100644 index 0000000000..4a3e35cfb6 --- /dev/null +++ b/api/templates/change_mail_confirm_old_template_zh-CN.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

验证您的邮箱变更请求

+
+

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

+

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

+

此验证码仅在接下来的5分钟内有效:

+
+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/clean_document_job_mail_template-US.html b/api/templates/clean_document_job_mail_template-US.html index 2d8f78b46a..b26e494f80 100644 --- a/api/templates/clean_document_job_mail_template-US.html +++ b/api/templates/clean_document_job_mail_template-US.html @@ -6,94 +6,136 @@ Documents Disabled Notification -