From 822298f69d79e064ba14d476b37c220bfbed37dc Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Thu, 5 Jun 2025 10:29:13 +0800 Subject: [PATCH 01/20] Fix 500 error (#20614) --- api/libs/helper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/libs/helper.py b/api/libs/helper.py index afc8f31681..463ba3308b 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -18,6 +18,7 @@ from flask_restful import fields from configs import dify_config from core.app.features.rate_limiting.rate_limit import RateLimitGenerator from core.file import helpers as file_helpers +from core.model_runtime.utils.encoders import jsonable_encoder from extensions.ext_redis import redis_client if TYPE_CHECKING: @@ -196,7 +197,7 @@ def generate_text_hash(text: str) -> str: def compact_generate_response(response: Union[Mapping, Generator, RateLimitGenerator]) -> Response: if isinstance(response, dict): - return Response(response=json.dumps(response), status=200, mimetype="application/json") + return Response(response=json.dumps(jsonable_encoder(response)), status=200, mimetype="application/json") else: def generate() -> Generator: From 9915a70d7f169e3e66f5a18966909b240a6c3320 Mon Sep 17 00:00:00 2001 From: NFish Date: Thu, 5 Jun 2025 10:55:17 +0800 Subject: [PATCH 02/20] Fix/webapp access scope (#20109) --- web/app/(commonLayout)/apps/AppCard.tsx | 7 +- web/app/(shareLayout)/layout.tsx | 40 +++- .../webapp-reset-password/check-code/page.tsx | 96 +++++++++ .../webapp-reset-password/layout.tsx | 30 +++ .../webapp-reset-password/page.tsx | 104 ++++++++++ .../set-password/page.tsx | 188 ++++++++++++++++++ .../webapp-signin/check-code/page.tsx | 115 +++++++++++ .../components/external-member-sso-auth.tsx | 80 ++++++++ .../components/mail-and-code-auth.tsx | 68 +++++++ .../components/mail-and-password-auth.tsx | 171 ++++++++++++++++ .../webapp-signin/components/sso-auth.tsx | 88 ++++++++ .../(shareLayout)/webapp-signin/layout.tsx | 25 +++ .../webapp-signin/normalForm.tsx | 176 ++++++++++++++++ web/app/(shareLayout)/webapp-signin/page.tsx | 158 +++++++-------- .../app/app-access-control/index.tsx | 18 +- .../specific-groups-or-members.tsx | 12 -- .../components/app/app-publisher/index.tsx | 31 ++- web/app/components/app/overview/appCard.tsx | 32 ++- web/app/components/base/app-unavailable.tsx | 5 +- .../base/chat/chat-with-history/context.tsx | 3 - .../base/chat/chat-with-history/hooks.tsx | 11 +- .../base/chat/chat-with-history/index.tsx | 2 - .../chat/chat-with-history/sidebar/index.tsx | 4 +- .../base/chat/embedded-chatbot/context.tsx | 3 - .../base/chat/embedded-chatbot/hooks.tsx | 11 +- .../share/text-generation/menu-dropdown.tsx | 24 ++- web/app/components/share/utils.ts | 11 +- web/app/signin/LoginLogo.tsx | 8 +- web/context/global-public-context.tsx | 15 +- web/hooks/use-document-title.spec.ts | 6 +- web/hooks/use-document-title.ts | 2 +- web/i18n/en-US/app.ts | 14 +- web/i18n/en-US/share-app.ts | 3 + web/i18n/ja-JP/app.ts | 21 +- web/i18n/ja-JP/share-app.ts | 3 + web/i18n/zh-Hans/app.ts | 19 +- web/i18n/zh-Hans/share-app.ts | 3 + web/models/access-control.ts | 1 + web/service/base.ts | 1 + web/service/common.ts | 25 +++ web/service/share.ts | 39 +++- web/service/use-share.ts | 17 ++ 42 files changed, 1484 insertions(+), 206 deletions(-) create mode 100644 web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx create mode 100644 web/app/(shareLayout)/webapp-reset-password/layout.tsx create mode 100644 web/app/(shareLayout)/webapp-reset-password/page.tsx create mode 100644 web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/check-code/page.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/layout.tsx create mode 100644 web/app/(shareLayout)/webapp-signin/normalForm.tsx create mode 100644 web/service/use-share.ts diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index 42967b96f4..31b9ed87c2 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -4,7 +4,7 @@ import { useContext, useContextSelector } from 'use-context-selector' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react' +import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' import type { App } from '@/types/app' import Confirm from '@/app/components/base/confirm' @@ -338,7 +338,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.access_mode === AccessMode.PUBLIC && - + } {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && @@ -346,6 +346,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { {app.access_mode === AccessMode.ORGANIZATION && } + {app.access_mode === AccessMode.EXTERNAL_MEMBERS && + + }
diff --git a/web/app/(shareLayout)/layout.tsx b/web/app/(shareLayout)/layout.tsx index 83adbd3cae..8db336a17d 100644 --- a/web/app/(shareLayout)/layout.tsx +++ b/web/app/(shareLayout)/layout.tsx @@ -1,14 +1,42 @@ -import React from 'react' +'use client' +import React, { useEffect, useState } from 'react' import type { FC } from 'react' -import type { Metadata } from 'next' - -export const metadata: Metadata = { - icons: 'data:,', // prevent browser from using default favicon -} +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' const Layout: FC<{ children: React.ReactNode }> = ({ children }) => { + const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) + const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode) + const pathname = usePathname() + const searchParams = useSearchParams() + const redirectUrl = searchParams.get('redirect_url') + const [isLoading, setIsLoading] = useState(true) + useEffect(() => { + (async () => { + let appCode: string | null = null + if (redirectUrl) + appCode = redirectUrl?.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]) + if (isLoading || isGlobalPending) { + return
+ +
+ } return (
{children} diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx new file mode 100644 index 0000000000..da754794b1 --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -0,0 +1,96 @@ +'use client' +import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Countdown from '@/app/components/signin/countdown' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' +import I18NContext from '@/context/i18n' + +export default function CheckCode() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const email = decodeURIComponent(searchParams.get('email') as string) + const token = decodeURIComponent(searchParams.get('token') as string) + const [code, setVerifyCode] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const verify = async () => { + try { + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + setIsLoading(true) + const ret = await verifyWebAppResetPasswordCode({ email, code, token }) + if (ret.is_valid) { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(ret.token)) + router.push(`/webapp-reset-password/set-password?${params.toString()}`) + } + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const res = await sendWebAppResetPasswordCode(email, locale) + if (res.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + router.replace(`/webapp-reset-password/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx new file mode 100644 index 0000000000..e0ac6b9ad6 --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -0,0 +1,30 @@ +'use client' +import Header from '@/app/signin/_header' + +import cn from '@/utils/classnames' +import { useGlobalPublicStore } from '@/context/global-public-context' + +export default function SignInLayout({ children }: any) { + const { systemFeatures } = useGlobalPublicStore() + return <> +
+
+
+
+
+ {children} +
+
+ {!systemFeatures.branding.enabled &&
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
} +
+
+ +} diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx new file mode 100644 index 0000000000..96cd4c5805 --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -0,0 +1,104 @@ +'use client' +import Link from 'next/link' +import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import { emailRegex } from '@/config' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendResetPasswordCode } from '@/service/common' +import I18NContext from '@/context/i18n' +import { noop } from 'lodash-es' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function CheckCode() { + const { t } = useTranslation() + useDocumentTitle('') + const searchParams = useSearchParams() + const router = useRouter() + const [email, setEmail] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const res = await sendResetPasswordCode(email, locale) + if (res.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + params.set('email', encodeURIComponent(email)) + router.push(`/webapp-reset-password/check-code?${params.toString()}`) + } + else if (res.code === 'account_not_found') { + Toast.notify({ + type: 'error', + message: t('login.error.registrationNotAllowed'), + }) + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return
+
+ +
+
+

{t('login.resetPassword')}

+

+ {t('login.resetPasswordDesc')} +

+
+ +
+ +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+
+
+
+ +
+ +
+ {t('login.backToLogin')} + +
+} diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx new file mode 100644 index 0000000000..9f9a8ad4e3 --- /dev/null +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -0,0 +1,188 @@ +'use client' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import cn from 'classnames' +import { RiCheckboxCircleFill } from '@remixicon/react' +import { useCountDown } from 'ahooks' +import Button from '@/app/components/base/button' +import { changeWebAppPasswordWithToken } from '@/service/common' +import Toast from '@/app/components/base/toast' +import Input from '@/app/components/base/input' + +const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +const ChangePasswordForm = () => { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const token = decodeURIComponent(searchParams.get('token') || '') + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showSuccess, setShowSuccess] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const showErrorMessage = useCallback((message: string) => { + Toast.notify({ + type: 'error', + message, + }) + }, []) + + const getSignInUrl = () => { + return `/webapp-signin?redirect_url=${searchParams.get('redirect_url') || ''}` + } + + const AUTO_REDIRECT_TIME = 5000 + const [leftTime, setLeftTime] = useState(undefined) + const [countdown] = useCountDown({ + leftTime, + onEnd: () => { + router.replace(getSignInUrl()) + }, + }) + + const valid = useCallback(() => { + if (!password.trim()) { + showErrorMessage(t('login.error.passwordEmpty')) + return false + } + if (!validPassword.test(password)) { + showErrorMessage(t('login.error.passwordInvalid')) + return false + } + if (password !== confirmPassword) { + showErrorMessage(t('common.account.notEqual')) + return false + } + return true + }, [password, confirmPassword, showErrorMessage, t]) + + const handleChangePassword = useCallback(async () => { + if (!valid()) + return + try { + await changeWebAppPasswordWithToken({ + url: '/forgot-password/resets', + body: { + token, + new_password: password, + password_confirm: confirmPassword, + }, + }) + setShowSuccess(true) + setLeftTime(AUTO_REDIRECT_TIME) + } + catch (error) { + console.error(error) + } + }, [password, token, valid, confirmPassword]) + + return ( +
+ {!showSuccess && ( +
+
+

+ {t('login.changePassword')} +

+

+ {t('login.changePasswordTip')} +

+
+ +
+
+ {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + /> + +
+ +
+
+
{t('login.error.passwordInvalid')}
+
+ {/* Confirm Password */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder={t('login.confirmPasswordPlaceholder') || ''} + /> +
+ +
+
+
+
+ +
+
+
+
+ )} + {showSuccess && ( +
+
+
+ +
+

+ {t('login.passwordChangedTip')} +

+
+
+ +
+
+ )} +
+ ) +} + +export default ChangePasswordForm diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx new file mode 100644 index 0000000000..1b8f18c98f --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -0,0 +1,115 @@ +'use client' +import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useCallback, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Countdown from '@/app/components/signin/countdown' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' +import I18NContext from '@/context/i18n' +import { setAccessToken } from '@/app/components/share/utils' +import { fetchAccessToken } from '@/service/share' + +export default function CheckCode() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const email = decodeURIComponent(searchParams.get('email') as string) + const token = decodeURIComponent(searchParams.get('token') as string) + const [code, setVerifyCode] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + const redirectUrl = searchParams.get('redirect_url') + + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + + const verify = async () => { + try { + const appCode = getAppCodeFromRedirectUrl() + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + if (!redirectUrl || !appCode) { + Toast.notify({ + type: 'error', + message: t('login.error.redirectUrlMissing'), + }) + return + } + setIsLoading(true) + const ret = await webAppEmailLoginWithCode({ email, code, token }) + if (ret.result === 'success') { + localStorage.setItem('webapp_access_token', ret.data.access_token) + const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: ret.data.access_token }) + await setAccessToken(appCode, tokenResp.access_token) + router.replace(redirectUrl) + } + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const ret = await sendWebAppEMailLoginCode(email, locale) + if (ret.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(ret.data)) + router.replace(`/webapp-signin/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx new file mode 100644 index 0000000000..e9b15ae331 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -0,0 +1,80 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' +import React, { useCallback, useEffect } from 'react' +import Toast from '@/app/components/base/toast' +import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { SSOProtocol } from '@/types/feature' +import Loading from '@/app/components/base/loading' +import AppUnavailable from '@/app/components/base/app-unavailable' + +const ExternalMemberSSOAuth = () => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const searchParams = useSearchParams() + const router = useRouter() + + const redirectUrl = searchParams.get('redirect_url') + + const showErrorToast = (message: string) => { + Toast.notify({ + type: 'error', + message, + }) + } + + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + + const handleSSOLogin = useCallback(async () => { + const appCode = getAppCodeFromRedirectUrl() + if (!appCode || !redirectUrl) { + showErrorToast('redirect url or app code is invalid.') + return + } + + switch (systemFeatures.webapp_auth.sso_config.protocol) { + case SSOProtocol.SAML: { + const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) + router.push(samlRes.url) + break + } + case SSOProtocol.OIDC: { + const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) + router.push(oidcRes.url) + break + } + case SSOProtocol.OAuth2: { + const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) + router.push(oauth2Res.url) + break + } + case '': + break + default: + showErrorToast('SSO protocol is not supported.') + } + }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) + + useEffect(() => { + handleSSOLogin() + }, [handleSSOLogin]) + + if (!systemFeatures.webapp_auth.sso_config.protocol) { + return
+ +
+ } + + return ( +
+ +
+ ) +} + +export default React.memo(ExternalMemberSSOAuth) diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx new file mode 100644 index 0000000000..29af3e3a57 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Input from '@/app/components/base/input' +import Button from '@/app/components/base/button' +import { emailRegex } from '@/config' +import Toast from '@/app/components/base/toast' +import { sendWebAppEMailLoginCode } from '@/service/common' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import I18NContext from '@/context/i18n' +import { noop } from 'lodash-es' + +export default function MailAndCodeAuth() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const ret = await sendWebAppEMailLoginCode(email, locale) + if (ret.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('email', encodeURIComponent(email)) + params.set('token', encodeURIComponent(ret.data)) + router.push(`/webapp-signin/check-code?${params.toString()}`) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return (
+ +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+ ) +} diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx new file mode 100644 index 0000000000..d9e56af1b8 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -0,0 +1,171 @@ +import Link from 'next/link' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import { emailRegex } from '@/config' +import { webAppLogin } from '@/service/common' +import Input from '@/app/components/base/input' +import I18NContext from '@/context/i18n' +import { noop } from 'lodash-es' +import { setAccessToken } from '@/app/components/share/utils' +import { fetchAccessToken } from '@/service/share' + +type MailAndPasswordAuthProps = { + isEmailSetup: boolean +} + +const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) { + const { t } = useTranslation() + const { locale } = useContext(I18NContext) + const router = useRouter() + const searchParams = useSearchParams() + const [showPassword, setShowPassword] = useState(false) + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [password, setPassword] = useState('') + + const [isLoading, setIsLoading] = useState(false) + const redirectUrl = searchParams.get('redirect_url') + + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + const handleEmailPasswordLogin = async () => { + const appCode = getAppCodeFromRedirectUrl() + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + if (!password?.trim()) { + Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') }) + return + } + if (!passwordRegex.test(password)) { + Toast.notify({ + type: 'error', + message: t('login.error.passwordInvalid'), + }) + return + } + if (!redirectUrl || !appCode) { + Toast.notify({ + type: 'error', + message: t('login.error.redirectUrlMissing'), + }) + return + } + try { + setIsLoading(true) + const loginData: Record = { + email, + password, + language: locale, + remember_me: true, + } + + const res = await webAppLogin({ + url: '/login', + body: loginData, + }) + if (res.result === 'success') { + localStorage.setItem('webapp_access_token', res.data.access_token) + const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: res.data.access_token }) + await setAccessToken(appCode, tokenResp.access_token) + router.replace(redirectUrl) + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + + finally { + setIsLoading(false) + } + } + + return
+
+ +
+ setEmail(e.target.value)} + id="email" + type="email" + autoComplete="email" + placeholder={t('login.emailPlaceholder') || ''} + tabIndex={1} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') + handleEmailPasswordLogin() + }} + type={showPassword ? 'text' : 'password'} + autoComplete="current-password" + placeholder={t('login.passwordPlaceholder') || ''} + tabIndex={2} + /> +
+ +
+
+
+ +
+ +
+
+} diff --git a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx new file mode 100644 index 0000000000..5d649322ba --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx @@ -0,0 +1,88 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' +import type { FC } from 'react' +import { useCallback } from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' +import Toast from '@/app/components/base/toast' +import Button from '@/app/components/base/button' +import { SSOProtocol } from '@/types/feature' +import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share' + +type SSOAuthProps = { + protocol: SSOProtocol | '' +} + +const SSOAuth: FC = ({ + protocol, +}) => { + const router = useRouter() + const { t } = useTranslation() + const searchParams = useSearchParams() + + const redirectUrl = searchParams.get('redirect_url') + const getAppCodeFromRedirectUrl = useCallback(() => { + const appCode = redirectUrl?.split('/').pop() + if (!appCode) + return null + + return appCode + }, [redirectUrl]) + + const [isLoading, setIsLoading] = useState(false) + + const handleSSOLogin = () => { + const appCode = getAppCodeFromRedirectUrl() + if (!redirectUrl || !appCode) { + Toast.notify({ + type: 'error', + message: 'invalid redirect URL or app code', + }) + return + } + setIsLoading(true) + if (protocol === SSOProtocol.SAML) { + fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else if (protocol === SSOProtocol.OIDC) { + fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else if (protocol === SSOProtocol.OAuth2) { + fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else { + Toast.notify({ + type: 'error', + message: 'invalid SSO protocol', + }) + setIsLoading(false) + } + } + + return ( + + ) +} + +export default SSOAuth diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx new file mode 100644 index 0000000000..a03364d326 --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -0,0 +1,25 @@ +'use client' + +import cn from '@/utils/classnames' +import { useGlobalPublicStore } from '@/context/global-public-context' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function SignInLayout({ children }: any) { + const { systemFeatures } = useGlobalPublicStore() + useDocumentTitle('') + return <> +
+
+ {/*
*/} +
+
+ {children} +
+
+ {systemFeatures.branding.enabled === false &&
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
} +
+
+ +} diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx new file mode 100644 index 0000000000..d6bdf607ba --- /dev/null +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Link from 'next/link' +import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import MailAndCodeAuth from './components/mail-and-code-auth' +import MailAndPasswordAuth from './components/mail-and-password-auth' +import SSOAuth from './components/sso-auth' +import cn from '@/utils/classnames' +import { LicenseStatus } from '@/types/feature' +import { IS_CE_EDITION } from '@/config' +import { useGlobalPublicStore } from '@/context/global-public-context' + +const NormalForm = () => { + const { t } = useTranslation() + + const [isLoading, setIsLoading] = useState(true) + const { systemFeatures } = useGlobalPublicStore() + const [authType, updateAuthType] = useState<'code' | 'password'>('password') + const [showORLine, setShowORLine] = useState(false) + const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) + + const init = useCallback(async () => { + try { + setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin) + setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login)) + updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code') + } + catch (error) { + console.error(error) + setAllMethodsAreDisabled(true) + } + finally { setIsLoading(false) } + }, [systemFeatures]) + useEffect(() => { + init() + }, [init]) + if (isLoading) { + return
+ +
+ } + if (systemFeatures.license?.status === LicenseStatus.LOST) { + return
+
+
+
+ + +
+

{t('login.licenseLost')}

+

{t('login.licenseLostTip')}

+
+
+
+ } + if (systemFeatures.license?.status === LicenseStatus.EXPIRED) { + return
+
+
+
+ + +
+

{t('login.licenseExpired')}

+

{t('login.licenseExpiredTip')}

+
+
+
+ } + if (systemFeatures.license?.status === LicenseStatus.INACTIVE) { + return
+
+
+
+ + +
+

{t('login.licenseInactive')}

+

{t('login.licenseInactiveTip')}

+
+
+
+ } + + return ( + <> +
+
+

{t('login.pageTitle')}

+ {!systemFeatures.branding.enabled &&

{t('login.welcome')}

} +
+
+
+ {systemFeatures.sso_enforced_for_signin &&
+ +
} +
+ + {showORLine &&
+ +
+ {t('login.or')} +
+
} + { + (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <> + {systemFeatures.enable_email_code_login && authType === 'code' && <> + + {systemFeatures.enable_email_password_login &&
{ updateAuthType('password') }}> + {t('login.usePassword')} +
} + } + {systemFeatures.enable_email_password_login && authType === 'password' && <> + + {systemFeatures.enable_email_code_login &&
{ updateAuthType('code') }}> + {t('login.useVerificationCode')} +
} + } + + } + {allMethodsAreDisabled && <> +
+
+ +
+

{t('login.noLoginMethod')}

+

{t('login.noLoginMethodTip')}

+
+
+ +
+ } + {!systemFeatures.branding.enabled && <> +
+ {t('login.tosDesc')} +   + {t('login.tos')} +  &  + {t('login.pp')} +
+ {IS_CE_EDITION &&
+ {t('login.goToInit')} +   + {t('login.setAdminAccount')} +
} + } + +
+
+ + ) +} + +export default NormalForm diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 668c3f312c..c12fde38dd 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -3,19 +3,20 @@ import { useRouter, useSearchParams } from 'next/navigation' import type { FC } from 'react' import React, { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { RiDoorLockLine } from '@remixicon/react' -import cn from '@/utils/classnames' import Toast from '@/app/components/base/toast' -import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' -import { setAccessToken } from '@/app/components/share/utils' +import { removeAccessToken, setAccessToken } from '@/app/components/share/utils' import { useGlobalPublicStore } from '@/context/global-public-context' -import { SSOProtocol } from '@/types/feature' 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' const WebSSOForm: FC = () => { const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() @@ -23,10 +24,22 @@ const WebSSOForm: FC = () => { const tokenFromUrl = searchParams.get('web_sso_token') const message = searchParams.get('message') - const showErrorToast = (message: string) => { + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + return `/webapp-signin?${params.toString()}` + }, [searchParams]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + const showErrorToast = (msg: string) => { Toast.notify({ type: 'error', - message, + message: msg, }) } @@ -38,102 +51,73 @@ const WebSSOForm: FC = () => { return appCode }, [redirectUrl]) - const processTokenAndRedirect = useCallback(async () => { - const appCode = getAppCodeFromRedirectUrl() - if (!appCode || !tokenFromUrl || !redirectUrl) { - showErrorToast('redirect url or app code or token is invalid.') - return - } + useEffect(() => { + (async () => { + if (message) + return - await setAccessToken(appCode, tokenFromUrl) - router.push(redirectUrl) - }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl]) - - const handleSSOLogin = useCallback(async () => { - const appCode = getAppCodeFromRedirectUrl() - if (!appCode || !redirectUrl) { - showErrorToast('redirect url or app code is invalid.') - return - } - - switch (systemFeatures.webapp_auth.sso_config.protocol) { - case SSOProtocol.SAML: { - const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) - router.push(samlRes.url) - break + 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(redirectUrl) + return } - case SSOProtocol.OIDC: { - const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) - router.push(oidcRes.url) - break + 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(redirectUrl) } - case SSOProtocol.OAuth2: { - const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) - router.push(oauth2Res.url) - break - } - case '': - break - default: - showErrorToast('SSO protocol is not supported.') - } - }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) + })() + }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message]) useEffect(() => { - const init = async () => { - if (message) { - showErrorToast(message) - return - } + if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl) + router.replace(redirectUrl) + }, [webAppAccessMode, router, redirectUrl]) - if (!tokenFromUrl) { - await handleSSOLogin() - return - } - - await processTokenAndRedirect() - } - - init() - }, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin]) - if (tokenFromUrl) - return
- if (message) { + if (tokenFromUrl) { return
- +
} - if (systemFeatures.webapp_auth.enabled) { - if (systemFeatures.webapp_auth.allow_sso) { - return ( -
-
- -
-
- ) - } - return
-
-
- -
-

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

-

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

-
-
- -
+ if (message) { + return
+ + {t('share.login.backToHome')}
} - else { + if (!redirectUrl) { + showErrorToast('redirect url is invalid.') + return
+ +
+ } + if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) { + return
+ +
+ } + if (!systemFeatures.webapp_auth.enabled) { return

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

} + if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) { + return
+ +
+ } + + if (webAppAccessMode && webAppAccessMode === AccessMode.EXTERNAL_MEMBERS) + return + + return
+ + {t('share.login.backToHome')} +
} export default React.memo(WebSSOForm) diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 2f15c8ec48..13faaea957 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { Dialog } from '@headlessui/react' -import { RiBuildingLine, RiGlobalLine } from '@remixicon/react' +import { Description as DialogDescription, DialogTitle } from '@headlessui/react' +import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useCallback, useEffect } from 'react' import Button from '../../base/button' @@ -67,8 +67,8 @@ export default function AccessControl(props: AccessControlProps) { return
- {t('app.accessControlDialog.title')} - {t('app.accessControlDialog.description')} + {t('app.accessControlDialog.title')} + {t('app.accessControlDialog.description')}
@@ -80,12 +80,20 @@ export default function AccessControl(props: AccessControlProps) {

{t('app.accessControlDialog.accessItems.organization')}

- {!hideTip && }
+ +
+
+ +

{t('app.accessControlDialog.accessItems.external')}

+
+ {!hideTip && } +
+
diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index f4872f8c99..b30c8f1ba3 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -3,12 +3,10 @@ import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from import { useTranslation } from 'react-i18next' import { useCallback, useEffect } from 'react' import Avatar from '../../base/avatar' -import Divider from '../../base/divider' import Tooltip from '../../base/tooltip' import Loading from '../../base/loading' import useAccessControlStore from '../../../../context/access-control-store' import AddMemberOrGroupDialog from './add-member-or-group-pop' -import { useGlobalPublicStore } from '@/context/global-public-context' import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects } from '@/service/access-control' @@ -19,11 +17,6 @@ export default function SpecificGroupsOrMembers() { const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const hideTip = systemFeatures.webapp_auth.enabled - && (systemFeatures.webapp_auth.allow_sso - || systemFeatures.webapp_auth.allow_email_password_login - || systemFeatures.webapp_auth.allow_email_code_login) const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) useEffect(() => { @@ -37,7 +30,6 @@ export default function SpecificGroupsOrMembers() {

{t('app.accessControlDialog.accessItems.specific')}

- {!hideTip && }
} @@ -48,10 +40,6 @@ export default function SpecificGroupsOrMembers() {

{t('app.accessControlDialog.accessItems.specific')}

- {!hideTip && <> - - - }
diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 8d0028c7d7..5825bb72ee 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -9,11 +9,14 @@ import dayjs from 'dayjs' import { RiArrowDownSLine, RiArrowRightSLine, + RiBuildingLine, + RiGlobalLine, RiLockLine, RiPlanetLine, RiPlayCircleLine, RiPlayList2Line, RiTerminalBoxLine, + RiVerifiedBadgeLine, } from '@remixicon/react' import { useKeyPress } from 'ahooks' import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' @@ -276,10 +279,30 @@ const AppPublisher = ({ setShowAppAccessControl(true) }}>
- - {appDetail?.access_mode === AccessMode.ORGANIZATION &&

{t('app.accessControlDialog.accessItems.organization')}

} - {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS &&

{t('app.accessControlDialog.accessItems.specific')}

} - {appDetail?.access_mode === AccessMode.PUBLIC &&

{t('app.accessControlDialog.accessItems.anyone')}

} + {appDetail?.access_mode === AccessMode.ORGANIZATION + && <> + +

{t('app.accessControlDialog.accessItems.organization')}

+ + } + {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.specific')}

+ + } + {appDetail?.access_mode === AccessMode.PUBLIC + && <> + +

{t('app.accessControlDialog.accessItems.anyone')}

+ + } + {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.external')}

+ + }
{!isAppAccessSet &&

{t('app.publishApp.notSet')}

}
diff --git a/web/app/components/app/overview/appCard.tsx b/web/app/components/app/overview/appCard.tsx index 9b283cdf5e..9f3b3ac4a6 100644 --- a/web/app/components/app/overview/appCard.tsx +++ b/web/app/components/app/overview/appCard.tsx @@ -5,10 +5,13 @@ import { useTranslation } from 'react-i18next' import { RiArrowRightSLine, RiBookOpenLine, + RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, + RiGlobalLine, RiLockLine, RiPaintBrushLine, + RiVerifiedBadgeLine, RiWindowLine, } from '@remixicon/react' import SettingsModal from './settings' @@ -248,11 +251,30 @@ function AppCard({
- - {appDetail?.access_mode === AccessMode.ORGANIZATION &&

{t('app.accessControlDialog.accessItems.organization')}

} - {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS &&

{t('app.accessControlDialog.accessItems.specific')}

} - {appDetail?.access_mode === AccessMode.PUBLIC &&

{t('app.accessControlDialog.accessItems.anyone')}

} -
+ {appDetail?.access_mode === AccessMode.ORGANIZATION + && <> + +

{t('app.accessControlDialog.accessItems.organization')}

+ + } + {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.specific')}

+ + } + {appDetail?.access_mode === AccessMode.PUBLIC + && <> + +

{t('app.accessControlDialog.accessItems.anyone')}

+ + } + {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS + && <> + +

{t('app.accessControlDialog.accessItems.external')}

+ + }
{!isAppAccessSet &&

{t('app.publishApp.notSet')}

}
diff --git a/web/app/components/base/app-unavailable.tsx b/web/app/components/base/app-unavailable.tsx index 4e835cbfcf..928c850262 100644 --- a/web/app/components/base/app-unavailable.tsx +++ b/web/app/components/base/app-unavailable.tsx @@ -1,4 +1,5 @@ 'use client' +import classNames from '@/utils/classnames' import type { FC } from 'react' import React from 'react' import { useTranslation } from 'react-i18next' @@ -7,17 +8,19 @@ type IAppUnavailableProps = { code?: number | string isUnknownReason?: boolean unknownReason?: string + className?: string } const AppUnavailable: FC = ({ code = 404, isUnknownReason, unknownReason, + className, }) => { const { t } = useTranslation() return ( -
+

({ - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, userCanAccess: false, currentConversationId: '', appPrevChatTree: [], 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 dd7cb14b25..32f74e6457 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -43,9 +43,8 @@ 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 { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -77,11 +76,6 @@ 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: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ - appId: installedAppInfo?.app.id || appInfo?.app_id, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: installedAppInfo?.app.id || appInfo?.app_id, isInstalledApp, @@ -492,8 +486,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return { appInfoError, - appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), - accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, + appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, isInstalledApp, appId, 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 de023e7f58..1fd1383196 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -124,7 +124,6 @@ const ChatWithHistoryWrap: FC = ({ const { appInfoError, appInfoLoading, - accessMode, userCanAccess, appData, appParams, @@ -169,7 +168,6 @@ const ChatWithHistoryWrap: FC = ({ appInfoError, appInfoLoading, appData, - accessMode, userCanAccess, appParams, appMeta, diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index fd317ccf91..4e50c1cb79 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -19,7 +19,6 @@ import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/re import DifyLogo from '@/app/components/base/logo/dify-logo' import type { ConversationItem } from '@/models/share' import cn from '@/utils/classnames' -import { AccessMode } from '@/models/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' type Props = { @@ -30,7 +29,6 @@ const Sidebar = ({ isPanel }: Props) => { const { t } = useTranslation() const { isInstalledApp, - accessMode, appData, handleNewConversation, pinnedConversationList, @@ -140,7 +138,7 @@ const Sidebar = ({ isPanel }: Props) => { )}

- + {/* powered by */}
{!appData?.custom_config?.remove_webapp_brand && ( diff --git a/web/app/components/base/chat/embedded-chatbot/context.tsx b/web/app/components/base/chat/embedded-chatbot/context.tsx index 5964efd806..d24265ed9e 100644 --- a/web/app/components/base/chat/embedded-chatbot/context.tsx +++ b/web/app/components/base/chat/embedded-chatbot/context.tsx @@ -15,10 +15,8 @@ import type { ConversationItem, } from '@/models/share' import { noop } from 'lodash-es' -import { AccessMode } from '@/models/access-control' export type EmbeddedChatbotContextValue = { - accessMode?: AccessMode userCanAccess?: boolean appInfoError?: any appInfoLoading?: boolean @@ -58,7 +56,6 @@ export type EmbeddedChatbotContextValue = { export const EmbeddedChatbotContext = createContext({ userCanAccess: false, - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, currentConversationId: '', appPrevChatList: [], pinnedConversationList: [], diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 40c56eca7b..0158e8d041 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -36,9 +36,8 @@ import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { noop } from 'lodash-es' -import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -70,11 +69,6 @@ export const useEmbeddedChatbot = () => { const isInstalledApp = false const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) - const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ - appId: appInfo?.app_id, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp, @@ -385,8 +379,7 @@ export const useEmbeddedChatbot = () => { return { appInfoError, - appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), - accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, + appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, isInstalledApp, allowResetChat, diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 19b660b083..adb926c7ca 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -6,9 +6,8 @@ import type { Placement } from '@floating-ui/react' import { RiEqualizer2Line, } from '@remixicon/react' -import { useRouter } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import Divider from '../../base/divider' -import { removeAccessToken } from '../utils' import InfoModal from './info-modal' import ActionButton from '@/app/components/base/action-button' import { @@ -19,6 +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' type Props = { data?: SiteInfo @@ -31,7 +32,9 @@ const MenuDropdown: FC = ({ placement, hideLogout, }) => { + const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) const router = useRouter() + const pathname = usePathname() const { t } = useTranslation() const [open, doSetOpen] = useState(false) const openRef = useRef(open) @@ -45,9 +48,10 @@ const MenuDropdown: FC = ({ }, [setOpen]) const handleLogout = useCallback(() => { - removeAccessToken() - router.replace(`/webapp-signin?redirect_url=${window.location.href}`) - }, [router]) + localStorage.removeItem('token') + localStorage.removeItem('webapp_access_token') + router.replace(`/webapp-signin?redirect_url=${pathname}`) + }, [router, pathname]) const [show, setShow] = useState(false) @@ -92,6 +96,16 @@ const MenuDropdown: FC = ({ className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' >{t('common.userProfile.about')}
+ {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( +
+
+ {t('common.userProfile.logout')} +
+
+ )}
diff --git a/web/app/components/share/utils.ts b/web/app/components/share/utils.ts index 9ce891a50c..d793d48b48 100644 --- a/web/app/components/share/utils.ts +++ b/web/app/components/share/utils.ts @@ -10,8 +10,8 @@ export const getInitialTokenV2 = (): Record => ({ version: 2, }) -export const checkOrSetAccessToken = async () => { - const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0] +export const checkOrSetAccessToken = async (appCode?: string) => { + const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0] const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) let accessTokenJson = getInitialTokenV2() @@ -23,8 +23,10 @@ export const checkOrSetAccessToken = async () => { catch { } + if (!accessTokenJson[sharedToken]?.[userId || 'DEFAULT']) { - const res = await fetchAccessToken(sharedToken, userId) + const webAppAccessToken = localStorage.getItem('webapp_access_token') + const res = await fetchAccessToken({ appCode: sharedToken, userId, webAppAccessToken }) accessTokenJson[sharedToken] = { ...accessTokenJson[sharedToken], [userId || 'DEFAULT']: res.access_token, @@ -33,7 +35,7 @@ export const checkOrSetAccessToken = async () => { } } -export const setAccessToken = async (sharedToken: string, token: string, user_id?: string) => { +export const setAccessToken = (sharedToken: string, token: string, user_id?: string) => { const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) let accessTokenJson = getInitialTokenV2() try { @@ -69,6 +71,7 @@ export const removeAccessToken = () => { } localStorage.removeItem(CONVERSATION_ID_INFO) + localStorage.removeItem('webapp_access_token') delete accessTokenJson[sharedToken] localStorage.setItem('token', JSON.stringify(accessTokenJson)) diff --git a/web/app/signin/LoginLogo.tsx b/web/app/signin/LoginLogo.tsx index 0753d1f98a..73dfb88205 100644 --- a/web/app/signin/LoginLogo.tsx +++ b/web/app/signin/LoginLogo.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' import classNames from '@/utils/classnames' -import { useSelector } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' +import { useTheme } from 'next-themes' type LoginLogoProps = { className?: string @@ -12,11 +12,7 @@ const LoginLogo: FC = ({ className, }) => { const { systemFeatures } = useGlobalPublicStore() - const { theme } = useSelector((s) => { - return { - theme: s.theme, - } - }) + const { theme } = useTheme() let src = theme === 'light' ? '/logo/logo-site.png' : `/logo/logo-site-${theme}.png` if (systemFeatures.branding.enabled) diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 5aa5e7a302..26ad84be65 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -7,19 +7,24 @@ 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 = { - isPending: boolean - setIsPending: (isPending: boolean) => void + isGlobalPending: boolean + setIsGlobalPending: (isPending: boolean) => void systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void + webAppAccessMode: AccessMode, + setWebAppAccessMode: (webAppAccessMode: AccessMode) => void } export const useGlobalPublicStore = create(set => ({ - isPending: true, - setIsPending: (isPending: boolean) => set(() => ({ isPending })), + isGlobalPending: true, + setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })), systemFeatures: defaultSystemFeatures, setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), + webAppAccessMode: AccessMode.PUBLIC, + setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })), })) const GlobalPublicStoreProvider: FC = ({ @@ -29,7 +34,7 @@ const GlobalPublicStoreProvider: FC = ({ queryKey: ['systemFeatures'], queryFn: getSystemFeatures, }) - const { setSystemFeatures, setIsPending } = useGlobalPublicStore() + const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore() useEffect(() => { if (data) setSystemFeatures({ ...defaultSystemFeatures, ...data }) diff --git a/web/hooks/use-document-title.spec.ts b/web/hooks/use-document-title.spec.ts index 88239ffbdf..a8d3d56cff 100644 --- a/web/hooks/use-document-title.spec.ts +++ b/web/hooks/use-document-title.spec.ts @@ -11,7 +11,7 @@ describe('title should be empty if systemFeatures is pending', () => { act(() => { useGlobalPublicStore.setState({ systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, - isPending: true, + isGlobalPending: true, }) }) it('document title should be empty if set title', () => { @@ -28,7 +28,7 @@ describe('use default branding', () => { beforeEach(() => { act(() => { useGlobalPublicStore.setState({ - isPending: false, + isGlobalPending: false, systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, }) }) @@ -48,7 +48,7 @@ describe('use specific branding', () => { beforeEach(() => { act(() => { useGlobalPublicStore.setState({ - isPending: false, + isGlobalPending: false, systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } }, }) }) diff --git a/web/hooks/use-document-title.ts b/web/hooks/use-document-title.ts index 10275a196f..2c848a1f56 100644 --- a/web/hooks/use-document-title.ts +++ b/web/hooks/use-document-title.ts @@ -3,7 +3,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useFavicon, useTitle } from 'ahooks' export default function useDocumentTitle(title: string) { - const isPending = useGlobalPublicStore(s => s.isPending) + const isPending = useGlobalPublicStore(s => s.isGlobalPending) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const prefix = title ? `${title} - ` : '' let titleStr = '' diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 20a80ba4cd..ccfe23ead6 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -197,9 +197,10 @@ const translation = { }, accessControl: 'Web App Access Control', accessItemsDescription: { - anyone: 'Anyone can access the web app', - specific: 'Only specific groups or members can access the web app', - organization: 'Anyone in the organization can access the web app', + anyone: 'Anyone can access the web app (no login required)', + specific: 'Only specific members within the platform can access the Web application', + organization: 'All members within the platform can access the Web application', + external: 'Only authenticated external users can access the Web application', }, accessControlDialog: { title: 'Web App Access Control', @@ -207,15 +208,16 @@ const translation = { accessLabel: 'Who has access', accessItems: { anyone: 'Anyone with the link', - specific: 'Specific groups or members', - organization: 'Only members within the enterprise', + specific: 'Specific members within the platform', + organization: 'All members within the platform', + external: 'Authenticated external users', }, groups_one: '{{count}} GROUP', groups_other: '{{count}} GROUPS', members_one: '{{count}} MEMBER', members_other: '{{count}} MEMBERS', noGroupsOrMembers: 'No groups or members selected', - webAppSSONotEnabledTip: 'Please contact enterprise administrator to configure the web app authentication method.', + webAppSSONotEnabledTip: 'Please contact your organization administrator to configure external authentication for the Web application.', operateGroupAndMember: { searchPlaceholder: 'Search groups and members', allMembers: 'All members', diff --git a/web/i18n/en-US/share-app.ts b/web/i18n/en-US/share-app.ts index bf99005d71..ab589ffb76 100644 --- a/web/i18n/en-US/share-app.ts +++ b/web/i18n/en-US/share-app.ts @@ -77,6 +77,9 @@ const translation = { atLeastOne: 'Please input at least one row in the uploaded file.', }, }, + login: { + backToHome: 'Back to Home', + }, } export default translation diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index b4fc8d4d82..b501bc129e 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -210,30 +210,27 @@ const translation = { }, accessControl: 'Web アプリアクセス制御', accessItemsDescription: { - anyone: '誰でも Web アプリにアクセス可能', - specific: '特定のグループまたはメンバーのみが Web アプリにアクセス可能', - organization: '組織内の誰でも Web アプリにアクセス可能', + anyone: '誰でもこの web アプリにアクセスできます(ログイン不要)', + specific: '特定のプラットフォーム内メンバーのみがこの Web アプリにアクセスできます', + organization: 'プラットフォーム内の全メンバーがこの Web アプリにアクセスできます', + external: '認証済みの外部ユーザーのみがこの Web アプリにアクセスできます', }, accessControlDialog: { title: 'アクセス権限', description: 'Web アプリのアクセス権限を設定します', accessLabel: '誰がアクセスできますか', - accessItemsDescription: { - anyone: '誰でも Web アプリにアクセス可能です', - specific: '特定のグループやメンバーが Web アプリにアクセス可能です', - organization: '組織内の誰でも Web アプリにアクセス可能です', - }, accessItems: { - anyone: 'すべてのユーザー', - specific: '特定のグループメンバー', - organization: 'グループ内の全員', + anyone: 'リンクを知っているすべてのユーザー', + specific: '特定のプラットフォーム内メンバー', + organization: 'プラットフォーム内の全メンバー', + external: '認証済みの外部ユーザー', }, groups_one: '{{count}} グループ', groups_other: '{{count}} グループ', members_one: '{{count}} メンバー', members_other: '{{count}} メンバー', noGroupsOrMembers: 'グループまたはメンバーが選択されていません', - webAppSSONotEnabledTip: 'Web アプリの認証方式設定については、企業管理者へご連絡ください。', + webAppSSONotEnabledTip: 'Web アプリの外部認証方式を設定するには、組織の管理者にお問い合わせください。', operateGroupAndMember: { searchPlaceholder: 'グループやメンバーを検索', allMembers: 'すべてのメンバー', diff --git a/web/i18n/ja-JP/share-app.ts b/web/i18n/ja-JP/share-app.ts index 9e76f6518a..20dad7faec 100644 --- a/web/i18n/ja-JP/share-app.ts +++ b/web/i18n/ja-JP/share-app.ts @@ -73,6 +73,9 @@ const translation = { atLeastOne: '1 行以上のデータが必要です', }, }, + login: { + backToHome: 'ホームに戻る', + }, } export default translation diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index bdd7d98d9b..4ec1e65059 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -198,30 +198,27 @@ const translation = { }, accessControl: 'Web 应用访问控制', accessItemsDescription: { - anyone: '任何人可以访问 web 应用', - specific: '特定组或成员可以访问 web 应用', - organization: '组织内任何人可以访问 web 应用', + anyone: '任何人都可以访问该 web 应用(无需登录)', + specific: '仅指定的平台内成员可访问该 Web 应用', + organization: '平台内所有成员均可访问该 Web 应用', + external: '仅经认证的外部用户可访问该 Web 应用', }, accessControlDialog: { title: 'Web 应用访问权限', description: '设置 web 应用访问权限。', accessLabel: '谁可以访问', - accessItemsDescription: { - anyone: '任何人可以访问 web 应用', - specific: '特定组或成员可以访问 web 应用', - organization: '组织内任何人可以访问 web 应用', - }, accessItems: { anyone: '任何人', - specific: '特定组或成员', - organization: '组织内任何人', + specific: '平台内指定成员', + organization: '平台内所有成员', + external: '经认证的外部用户', }, groups_one: '{{count}} 个组', groups_other: '{{count}} 个组', members_one: '{{count}} 个成员', members_other: '{{count}} 个成员', noGroupsOrMembers: '未选择分组或成员', - webAppSSONotEnabledTip: '请联系企业管理员配置 web 应用的身份认证方式。', + webAppSSONotEnabledTip: '请联系企业管理员配置 Web 应用外部认证方式。', operateGroupAndMember: { searchPlaceholder: '搜索组或成员', allMembers: '所有成员', diff --git a/web/i18n/zh-Hans/share-app.ts b/web/i18n/zh-Hans/share-app.ts index 4ea2ad6f49..ce1270dae8 100644 --- a/web/i18n/zh-Hans/share-app.ts +++ b/web/i18n/zh-Hans/share-app.ts @@ -73,6 +73,9 @@ const translation = { atLeastOne: '上传文件的内容不能少于一条', }, }, + login: { + backToHome: '返回首页', + }, } export default translation diff --git a/web/models/access-control.ts b/web/models/access-control.ts index 8ad9cc6491..911662b5c4 100644 --- a/web/models/access-control.ts +++ b/web/models/access-control.ts @@ -7,6 +7,7 @@ export enum AccessMode { PUBLIC = 'public', SPECIFIC_GROUPS_MEMBERS = 'private', ORGANIZATION = 'private_all', + EXTERNAL_MEMBERS = 'sso_verified', } export type AccessControlGroup = { diff --git a/web/service/base.ts b/web/service/base.ts index 4b08736288..c3cafe600b 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -109,6 +109,7 @@ function unicodeToChar(text: string) { } function requiredWebSSOLogin(message?: string) { + removeAccessToken() const params = new URLSearchParams() params.append('redirect_url', globalThis.location.pathname) if (message) diff --git a/web/service/common.ts b/web/service/common.ts index e76cfb4196..700cd4bf51 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -52,6 +52,9 @@ type LoginResponse = LoginSuccess | LoginFail export const login: Fetcher }> = ({ url, body }) => { return post(url, { body }) as Promise } +export const webAppLogin: Fetcher }> = ({ url, body }) => { + return post(url, { body }, { isPublicAPI: true }) as Promise +} export const fetchNewToken: Fetcher }> = ({ body }) => { return post('/refresh-token', { body }) as Promise @@ -324,6 +327,16 @@ export const verifyForgotPasswordToken: Fetcher = ({ url, body }) => post(url, { body }) +export const sendWebAppForgotPasswordEmail: Fetcher = ({ url, body }) => + post(url, { body }, { isPublicAPI: true }) + +export const verifyWebAppForgotPasswordToken: Fetcher = ({ url, body }) => { + return post(url, { body }, { isPublicAPI: true }) as Promise +} + +export const changeWebAppPasswordWithToken: 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 }) } @@ -340,6 +353,18 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') => export const verifyResetPasswordCode = (body: { email: string; code: string; token: string }) => post('/forgot-password/validity', { body }) +export const sendWebAppEMailLoginCode = (email: string, language = 'en-US') => + post('/email-code-login', { body: { email, language } }, { isPublicAPI: true }) + +export const webAppEmailLoginWithCode = (data: { email: string; code: string; token: string }) => + post('/email-code-login/validity', { body: data }, { isPublicAPI: true }) + +export const sendWebAppResetPasswordCode = (email: string, language = 'en-US') => + post('/forgot-password', { body: { email, language } }, { isPublicAPI: true }) + +export const verifyWebAppResetPasswordCode = (body: { email: string; code: string; token: string }) => + post('/forgot-password/validity', { body }, { isPublicAPI: true }) + export const sendDeleteAccountCode = () => get('/account/delete/verify') diff --git a/web/service/share.ts b/web/service/share.ts index 7fb1562185..6a2a7e5b16 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -214,6 +214,34 @@ export const fetchWebOAuth2SSOUrl = async (appCode: string, redirectUrl: string) }) as Promise<{ url: string }> } +export const fetchMembersSAMLSSOUrl = async (appCode: string, redirectUrl: string) => { + return (getAction('get', false))(getUrl('/enterprise/sso/members/saml/login', false, ''), { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, + }) as Promise<{ url: string }> +} + +export const fetchMembersOIDCSSOUrl = async (appCode: string, redirectUrl: string) => { + return (getAction('get', false))(getUrl('/enterprise/sso/members/oidc/login', false, ''), { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, + + }) as Promise<{ url: string }> +} + +export const fetchMembersOAuth2SSOUrl = async (appCode: string, redirectUrl: string) => { + return (getAction('get', false))(getUrl('/enterprise/sso/members/oauth2/login', false, ''), { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, + }) as Promise<{ url: string }> +} + export const fetchAppMeta = async (isInstalledApp: boolean, installedAppId = '') => { return (getAction('get', isInstalledApp))(getUrl('meta', isInstalledApp, installedAppId)) as Promise } @@ -258,10 +286,13 @@ export const textToAudioStream = (url: string, isPublicAPI: boolean, header: { c return (getAction('post', !isPublicAPI))(url, { body, header }, { needAllResponseContent: true }) } -export const fetchAccessToken = async (appCode: string, userId?: string) => { +export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { appCode: string, userId?: string, webAppAccessToken?: string | null }) => { const headers = new Headers() headers.append('X-App-Code', appCode) - const url = userId ? `/passport?user_id=${encodeURIComponent(userId)}` : '/passport' + const params = new URLSearchParams() + webAppAccessToken && params.append('web_app_access_token', webAppAccessToken) + userId && params.append('user_id', userId) + const url = `/passport?${params.toString()}` return get(url, { headers }) as Promise<{ access_token: string }> } @@ -278,3 +309,7 @@ export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => { return get<{ result: boolean }>(`/webapp/permission?appId=${appId}`) } + +export const getAppAccessModeByAppCode = (appCode: string) => { + return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appCode=${appCode}`) +} diff --git a/web/service/use-share.ts b/web/service/use-share.ts new file mode 100644 index 0000000000..b8f96f6cc5 --- /dev/null +++ b/web/service/use-share.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { getAppAccessModeByAppCode } from './share' + +const NAME_SPACE = 'webapp' + +export const useAppAccessModeByCode = (code: string | null) => { + return useQuery({ + queryKey: [NAME_SPACE, 'appAccessMode', code], + queryFn: () => { + if (!code) + return null + + return getAppAccessModeByAppCode(code) + }, + enabled: !!code, + }) +} From 1fbbbb735db1237aa36dcc0d976fda617ab5c71b Mon Sep 17 00:00:00 2001 From: XiaoBa <94062266+XiaoBa-Yu@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:07:54 +0800 Subject: [PATCH 03/20] fix: the locale format(#20662) (#20665) Co-authored-by: Xiaoba Yu --- web/app/components/header/maintenance-notice.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx index 78715bb53e..f9c00dd01e 100644 --- a/web/app/components/header/maintenance-notice.tsx +++ b/web/app/components/header/maintenance-notice.tsx @@ -1,11 +1,10 @@ import { useState } from 'react' -import { useContext } from 'use-context-selector' -import I18n from '@/context/i18n' import { X } from '@/app/components/base/icons/src/vender/line/general' import { NOTICE_I18N } from '@/i18n/language' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' const MaintenanceNotice = () => { - const { locale } = useContext(I18n) + const locale = useLanguage() const [showNotice, setShowNotice] = useState(localStorage.getItem('hide-maintenance-notice') !== '1') const handleJumpNotice = () => { From de9c7f2ea44b55191a01e1c4148e201f72392f4d Mon Sep 17 00:00:00 2001 From: geosmart Date: Thu, 5 Jun 2025 12:11:11 +0800 Subject: [PATCH 04/20] Update template.zh.mdx-fix document update metadata body param (#20659) --- web/app/(commonLayout)/datasets/template/template.zh.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/(commonLayout)/datasets/template/template.zh.mdx b/web/app/(commonLayout)/datasets/template/template.zh.mdx index 08ef5d562a..d121a93df2 100644 --- a/web/app/(commonLayout)/datasets/template/template.zh.mdx +++ b/web/app/(commonLayout)/datasets/template/template.zh.mdx @@ -2223,7 +2223,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi - document_id (string) 文档 ID - metadata_list (list) 元数据列表 - id (string) 元数据 ID - - type (string) 元数据类型 + - value (string) 元数据值 - name (string) 元数据名称 From d608be6e7f17c60109cf0d7f53ce1892ffac5aff Mon Sep 17 00:00:00 2001 From: GuanMu Date: Thu, 5 Jun 2025 13:35:32 +0800 Subject: [PATCH 05/20] Add vscode debugger (#20668) --- .gitignore | 7 ++-- .vscode/README.md | 14 ++++++++ .vscode/launch.json.template | 68 ++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 .vscode/README.md create mode 100644 .vscode/launch.json.template diff --git a/.gitignore b/.gitignore index 8818ab6f65..74a9ef63ef 100644 --- a/.gitignore +++ b/.gitignore @@ -192,12 +192,12 @@ sdks/python-client/dist sdks/python-client/dify_client.egg-info .vscode/* -!.vscode/launch.json +!.vscode/launch.json.template +!.vscode/README.md pyrightconfig.json api/.vscode .idea/ -.vscode # pnpm /.pnpm-store @@ -207,3 +207,6 @@ plugins.jsonl # mise mise.toml + +# Next.js build output +.next/ diff --git a/.vscode/README.md b/.vscode/README.md new file mode 100644 index 0000000000..26516f0540 --- /dev/null +++ b/.vscode/README.md @@ -0,0 +1,14 @@ +# Debugging with VS Code + +This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory. + +## How to Use + +1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory. +2. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file. +3. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D). +4. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button. + +## Tips + +- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging. diff --git a/.vscode/launch.json.template b/.vscode/launch.json.template new file mode 100644 index 0000000000..f5a7f0893b --- /dev/null +++ b/.vscode/launch.json.template @@ -0,0 +1,68 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Flask API", + "type": "debugpy", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "app.py", + "FLASK_ENV": "development", + "GEVENT_SUPPORT": "True" + }, + "args": [ + "run", + "--host=0.0.0.0", + "--port=5001", + "--no-debugger", + "--no-reload" + ], + "jinja": true, + "justMyCode": true, + "cwd": "${workspaceFolder}/api", + "python": "${workspaceFolder}/api/.venv/bin/python" + }, + { + "name": "Python: Celery Worker (Solo)", + "type": "debugpy", + "request": "launch", + "module": "celery", + "env": { + "GEVENT_SUPPORT": "True" + }, + "args": [ + "-A", + "app.celery", + "worker", + "-P", + "solo", + "-c", + "1", + "-Q", + "dataset,generation,mail,ops_trace", + "--loglevel", + "INFO" + ], + "justMyCode": false, + "cwd": "${workspaceFolder}/api", + "python": "${workspaceFolder}/api/.venv/bin/python" + }, + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/web/node_modules/next/dist/bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithChrome", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}/web" + }, + "cwd": "${workspaceFolder}/web" + } + ] +} From 3367d4258d75079afc3e505b909184945adfa535 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:35:40 +0800 Subject: [PATCH 06/20] chore: translate i18n files (#20664) Co-authored-by: douxc <7553076+douxc@users.noreply.github.com> --- web/i18n/de-DE/app.ts | 2 ++ web/i18n/de-DE/share-app.ts | 3 +++ web/i18n/es-ES/app.ts | 2 ++ web/i18n/es-ES/common.ts | 1 + web/i18n/es-ES/share-app.ts | 3 +++ web/i18n/fa-IR/app.ts | 2 ++ web/i18n/fa-IR/share-app.ts | 3 +++ web/i18n/fr-FR/app.ts | 3 +++ web/i18n/fr-FR/share-app.ts | 3 +++ web/i18n/hi-IN/app.ts | 2 ++ web/i18n/hi-IN/share-app.ts | 3 +++ web/i18n/it-IT/app.ts | 2 ++ web/i18n/it-IT/share-app.ts | 3 +++ web/i18n/ko-KR/app.ts | 2 ++ web/i18n/ko-KR/share-app.ts | 3 +++ web/i18n/pl-PL/app.ts | 2 ++ web/i18n/pl-PL/share-app.ts | 3 +++ web/i18n/pt-BR/app.ts | 2 ++ web/i18n/pt-BR/share-app.ts | 3 +++ web/i18n/ro-RO/app.ts | 2 ++ web/i18n/ro-RO/share-app.ts | 3 +++ web/i18n/ru-RU/app.ts | 2 ++ web/i18n/ru-RU/share-app.ts | 3 +++ web/i18n/sl-SI/app.ts | 2 ++ web/i18n/sl-SI/share-app.ts | 3 +++ web/i18n/th-TH/app.ts | 2 ++ web/i18n/th-TH/share-app.ts | 3 +++ web/i18n/tr-TR/app.ts | 2 ++ web/i18n/tr-TR/share-app.ts | 3 +++ web/i18n/uk-UA/app.ts | 2 ++ web/i18n/uk-UA/share-app.ts | 3 +++ web/i18n/vi-VN/app.ts | 2 ++ web/i18n/vi-VN/share-app.ts | 3 +++ web/i18n/zh-Hant/app.ts | 2 ++ web/i18n/zh-Hant/share-app.ts | 3 +++ 35 files changed, 87 insertions(+) diff --git a/web/i18n/de-DE/app.ts b/web/i18n/de-DE/app.ts index b9fdde58ff..1373dd611b 100644 --- a/web/i18n/de-DE/app.ts +++ b/web/i18n/de-DE/app.ts @@ -220,12 +220,14 @@ const translation = { anyone: 'Jeder kann auf die Webanwendung zugreifen.', specific: 'Nur bestimmte Gruppen oder Mitglieder können auf die Webanwendung zugreifen.', organization: 'Jeder in der Organisation kann auf die Webanwendung zugreifen.', + external: 'Nur authentifizierte externe Benutzer können auf die Webanwendung zugreifen.', }, accessControlDialog: { accessItems: { anyone: 'Jeder mit dem Link', specific: 'Spezifische Gruppen oder Mitglieder', organization: 'Nur Mitglieder innerhalb des Unternehmens', + external: 'Authentifizierte externe Benutzer', }, operateGroupAndMember: { searchPlaceholder: 'Gruppen und Mitglieder suchen', diff --git a/web/i18n/de-DE/share-app.ts b/web/i18n/de-DE/share-app.ts index 462286fa23..33c40917dd 100644 --- a/web/i18n/de-DE/share-app.ts +++ b/web/i18n/de-DE/share-app.ts @@ -77,6 +77,9 @@ const translation = { executions: '{{num}} HINRICHTUNGEN', execution: 'AUSFÜHRUNG', }, + login: { + backToHome: 'Zurück zur Startseite', + }, } export default translation diff --git a/web/i18n/es-ES/app.ts b/web/i18n/es-ES/app.ts index c183485294..c1147720e7 100644 --- a/web/i18n/es-ES/app.ts +++ b/web/i18n/es-ES/app.ts @@ -212,12 +212,14 @@ const translation = { anyone: 'Cualquiera puede acceder a la aplicación web.', specific: 'Solo grupos o miembros específicos pueden acceder a la aplicación web', organization: 'Cualquiera en la organización puede acceder a la aplicación web', + external: 'Solo los usuarios externos autenticados pueden acceder a la aplicación web.', }, accessControlDialog: { accessItems: { anyone: 'Cualquiera con el enlace', specific: 'Grupos o miembros específicos', organization: 'Solo miembros dentro de la empresa', + external: 'Usuarios externos autenticados', }, operateGroupAndMember: { searchPlaceholder: 'Buscar grupos y miembros', diff --git a/web/i18n/es-ES/common.ts b/web/i18n/es-ES/common.ts index 22c70f6bff..82ed315f1c 100644 --- a/web/i18n/es-ES/common.ts +++ b/web/i18n/es-ES/common.ts @@ -654,6 +654,7 @@ const translation = { auto: 'sistema', light: 'luz', theme: 'Tema', + dark: 'noche', }, compliance: { iso27001: 'Certificación ISO 27001:2022', diff --git a/web/i18n/es-ES/share-app.ts b/web/i18n/es-ES/share-app.ts index 41aa35c43e..caeb056d89 100644 --- a/web/i18n/es-ES/share-app.ts +++ b/web/i18n/es-ES/share-app.ts @@ -77,6 +77,9 @@ const translation = { execution: 'EJECUCIÓN', executions: '{{num}} EJECUCIONES', }, + login: { + backToHome: 'Volver a Inicio', + }, } export default translation diff --git a/web/i18n/fa-IR/app.ts b/web/i18n/fa-IR/app.ts index d37f4e8f90..13473d21f5 100644 --- a/web/i18n/fa-IR/app.ts +++ b/web/i18n/fa-IR/app.ts @@ -213,12 +213,14 @@ const translation = { specific: 'فقط گروه‌ها یا اعضای خاصی می‌توانند به اپلیکیشن وب دسترسی پیدا کنند.', anyone: 'هر کسی می‌تواند به وب‌اپلیکیشن دسترسی پیدا کند', organization: 'هر کسی در سازمان می‌تواند به اپلیکیشن وب دسترسی پیدا کند.', + external: 'تنها کاربران خارجی تأیید شده می‌توانند به برنامه وب دسترسی پیدا کنند.', }, accessControlDialog: { accessItems: { specific: 'گروه‌ها یا اعضای خاص', organization: 'فقط اعضای داخل سازمان', anyone: 'هر کسی که لینک را داشته باشد', + external: 'کاربران خارجی تأیید شده', }, operateGroupAndMember: { searchPlaceholder: 'گروه‌ها و اعضا را جستجو کنید', diff --git a/web/i18n/fa-IR/share-app.ts b/web/i18n/fa-IR/share-app.ts index bf1c0dec50..03ed4e8ea9 100644 --- a/web/i18n/fa-IR/share-app.ts +++ b/web/i18n/fa-IR/share-app.ts @@ -73,6 +73,9 @@ const translation = { executions: '{{num}} اعدام', execution: 'اجرا', }, + login: { + backToHome: 'بازگشت به خانه', + }, } export default translation diff --git a/web/i18n/fr-FR/app.ts b/web/i18n/fr-FR/app.ts index ffa00c758a..5c0965815e 100644 --- a/web/i18n/fr-FR/app.ts +++ b/web/i18n/fr-FR/app.ts @@ -207,17 +207,20 @@ const translation = { modelNotSupported: 'Modèle non pris en charge', moreFillTip: 'Affichage d\'un maximum de 10 niveaux d\'imbrication', configure: 'Configurer', + structured: 'systématique', }, accessItemsDescription: { anyone: 'Tout le monde peut accéder à l\'application web.', specific: 'Seules des groupes ou membres spécifiques peuvent accéder à l\'application web.', organization: 'Toute personne dans l\'organisation peut accéder à l\'application web.', + external: 'Seuls les utilisateurs externes authentifiés peuvent accéder à l\'application Web.', }, accessControlDialog: { accessItems: { anyone: 'Quiconque avec le lien', specific: 'Groupes ou membres spécifiques', organization: 'Seuls les membres au sein de l\'entreprise', + external: 'Utilisateurs externes authentifiés', }, operateGroupAndMember: { searchPlaceholder: 'Rechercher des groupes et des membres', diff --git a/web/i18n/fr-FR/share-app.ts b/web/i18n/fr-FR/share-app.ts index d0b3a5047e..2374da70e6 100644 --- a/web/i18n/fr-FR/share-app.ts +++ b/web/i18n/fr-FR/share-app.ts @@ -77,6 +77,9 @@ const translation = { executions: '{{num}} EXÉCUTIONS', execution: 'EXÉCUTION', }, + login: { + backToHome: 'Retour à l\'accueil', + }, } export default translation diff --git a/web/i18n/hi-IN/app.ts b/web/i18n/hi-IN/app.ts index f9486b93ec..3929dfeb6a 100644 --- a/web/i18n/hi-IN/app.ts +++ b/web/i18n/hi-IN/app.ts @@ -213,12 +213,14 @@ const translation = { anyone: 'कोई भी वेब ऐप तक पहुँच सकता है', organization: 'संस्थान के किसी भी व्यक्ति को वेब ऐप तक पहुंच प्राप्त है', specific: 'केवल विशेष समूह या सदस्य ही वेब ऐप तक पहुंच सकते हैं', + external: 'केवल प्रमाणित बाहरी उपयोगकर्ता वेब अनुप्रयोग तक पहुँच सकते हैं', }, accessControlDialog: { accessItems: { anyone: 'लिंक के साथ कोई भी', specific: 'विशिष्ट समूह या सदस्य', organization: 'केवल उद्यम के भीतर के सदस्य', + external: 'प्रमाणित बाहरी उपयोगकर्ता', }, operateGroupAndMember: { searchPlaceholder: 'समूहों और सदस्यों की खोज करें', diff --git a/web/i18n/hi-IN/share-app.ts b/web/i18n/hi-IN/share-app.ts index e0296fda83..a1e716b5bc 100644 --- a/web/i18n/hi-IN/share-app.ts +++ b/web/i18n/hi-IN/share-app.ts @@ -80,6 +80,9 @@ const translation = { execution: 'अनु执行', executions: '{{num}} फाँसी', }, + login: { + backToHome: 'होम पर वापस', + }, } export default translation diff --git a/web/i18n/it-IT/app.ts b/web/i18n/it-IT/app.ts index f6855873db..2bd5069b6c 100644 --- a/web/i18n/it-IT/app.ts +++ b/web/i18n/it-IT/app.ts @@ -224,12 +224,14 @@ const translation = { anyone: 'Chiunque può accedere all\'app web', specific: 'Solo gruppi o membri specifici possono accedere all\'app web.', organization: 'Qualsiasi persona nell\'organizzazione può accedere all\'app web', + external: 'Solo gli utenti esterni autenticati possono accedere all\'applicazione Web', }, accessControlDialog: { accessItems: { anyone: 'Chiunque con il link', specific: 'Gruppi o membri specifici', organization: 'Solo i membri all\'interno dell\'impresa', + external: 'Utenti esterni autenticati', }, operateGroupAndMember: { searchPlaceholder: 'Cerca gruppi e membri', diff --git a/web/i18n/it-IT/share-app.ts b/web/i18n/it-IT/share-app.ts index 2e1c96a396..4c6c18ff33 100644 --- a/web/i18n/it-IT/share-app.ts +++ b/web/i18n/it-IT/share-app.ts @@ -79,6 +79,9 @@ const translation = { execution: 'ESECUZIONE', executions: '{{num}} ESECUZIONI', }, + login: { + backToHome: 'Torna alla home', + }, } export default translation diff --git a/web/i18n/ko-KR/app.ts b/web/i18n/ko-KR/app.ts index 0ab08251fb..7227fd3171 100644 --- a/web/i18n/ko-KR/app.ts +++ b/web/i18n/ko-KR/app.ts @@ -209,12 +209,14 @@ const translation = { anyone: '누구나 웹 앱에 접근할 수 있습니다.', specific: '특정 그룹이나 회원만 웹 앱에 접근할 수 있습니다.', organization: '조직 내 모든 사람이 웹 애플리케이션에 접근할 수 있습니다.', + external: '인증된 외부 사용자만 웹 애플리케이션에 접근할 수 있습니다.', }, accessControlDialog: { accessItems: { anyone: '링크가 있는 누구나', specific: '특정 그룹 또는 구성원', organization: '기업 내의 회원만', + external: '인증된 외부 사용자', }, operateGroupAndMember: { searchPlaceholder: '그룹 및 구성원 검색', diff --git a/web/i18n/ko-KR/share-app.ts b/web/i18n/ko-KR/share-app.ts index 1ee44f2816..3958b4f93e 100644 --- a/web/i18n/ko-KR/share-app.ts +++ b/web/i18n/ko-KR/share-app.ts @@ -73,6 +73,9 @@ const translation = { execution: '실행', executions: '{{num}} 처형', }, + login: { + backToHome: '홈으로 돌아가기', + }, } export default translation diff --git a/web/i18n/pl-PL/app.ts b/web/i18n/pl-PL/app.ts index 54759154ca..856a64c868 100644 --- a/web/i18n/pl-PL/app.ts +++ b/web/i18n/pl-PL/app.ts @@ -220,12 +220,14 @@ const translation = { anyone: 'Każdy może uzyskać dostęp do aplikacji webowej', specific: 'Tylko określone grupy lub członkowie mogą uzyskać dostęp do aplikacji internetowej', organization: 'Każdy w organizacji ma dostęp do aplikacji internetowej.', + external: 'Tylko uwierzytelnieni zewnętrzni użytkownicy mogą uzyskać dostęp do aplikacji internetowej.', }, accessControlDialog: { accessItems: { anyone: 'Każdy z linkiem', specific: 'Specyficzne grupy lub członkowie', organization: 'Tylko członkowie w obrębie przedsiębiorstwa', + external: 'Uwierzytelnieni użytkownicy zewnętrzni', }, operateGroupAndMember: { searchPlaceholder: 'Szukaj grup i członków', diff --git a/web/i18n/pl-PL/share-app.ts b/web/i18n/pl-PL/share-app.ts index 80619cf4fc..617f66d994 100644 --- a/web/i18n/pl-PL/share-app.ts +++ b/web/i18n/pl-PL/share-app.ts @@ -78,6 +78,9 @@ const translation = { executions: '{{num}} EGZEKUCJI', execution: 'WYKONANIE', }, + login: { + backToHome: 'Powrót do strony głównej', + }, } export default translation diff --git a/web/i18n/pt-BR/app.ts b/web/i18n/pt-BR/app.ts index 5dd1753cac..766053456a 100644 --- a/web/i18n/pt-BR/app.ts +++ b/web/i18n/pt-BR/app.ts @@ -213,12 +213,14 @@ const translation = { anyone: 'Qualquer pessoa pode acessar o aplicativo web', specific: 'Apenas grupos ou membros específicos podem acessar o aplicativo web', organization: 'Qualquer pessoa na organização pode acessar o aplicativo web', + external: 'Apenas usuários externos autenticados podem acessar o aplicativo Web.', }, accessControlDialog: { accessItems: { anyone: 'Qualquer pessoa com o link', specific: 'Grupos específicos ou membros', organization: 'Apenas membros dentro da empresa', + external: 'Usuários externos autenticados', }, operateGroupAndMember: { searchPlaceholder: 'Pesquisar grupos e membros', diff --git a/web/i18n/pt-BR/share-app.ts b/web/i18n/pt-BR/share-app.ts index d8bca03089..9a9d7db632 100644 --- a/web/i18n/pt-BR/share-app.ts +++ b/web/i18n/pt-BR/share-app.ts @@ -77,6 +77,9 @@ const translation = { executions: '{{num}} EXECUÇÕES', execution: 'EXECUÇÃO', }, + login: { + backToHome: 'Voltar para a página inicial', + }, } export default translation diff --git a/web/i18n/ro-RO/app.ts b/web/i18n/ro-RO/app.ts index adf82aa38e..cd267e7b66 100644 --- a/web/i18n/ro-RO/app.ts +++ b/web/i18n/ro-RO/app.ts @@ -213,12 +213,14 @@ const translation = { specific: 'Numai grupuri sau membri specifici pot accesa aplicația web.', organization: 'Oricine din organizație poate accesa aplicația web', anyone: 'Oricine poate accesa aplicația web', + external: 'Numai utilizatorii externi autentificați pot accesa aplicația web', }, accessControlDialog: { accessItems: { anyone: 'Oricine are linkul', specific: 'Grupuri sau membri specifici', organization: 'Numai membrii din cadrul întreprinderii', + external: 'Utilizatori extern autentificați', }, operateGroupAndMember: { searchPlaceholder: 'Caută grupuri și membri', diff --git a/web/i18n/ro-RO/share-app.ts b/web/i18n/ro-RO/share-app.ts index 2cb39a0485..41e38812c5 100644 --- a/web/i18n/ro-RO/share-app.ts +++ b/web/i18n/ro-RO/share-app.ts @@ -77,6 +77,9 @@ const translation = { execution: 'EXECUȚIE', executions: '{{num}} EXECUȚII', }, + login: { + backToHome: 'Înapoi la Acasă', + }, } export default translation diff --git a/web/i18n/ru-RU/app.ts b/web/i18n/ru-RU/app.ts index fa73e33197..428d4c4e57 100644 --- a/web/i18n/ru-RU/app.ts +++ b/web/i18n/ru-RU/app.ts @@ -213,12 +213,14 @@ const translation = { anyone: 'Любой может получить доступ к веб-приложению', specific: 'Только определенные группы или участники могут получить доступ к веб-приложению.', organization: 'Любой в организации может получить доступ к веб-приложению', + external: 'Только аутентифицированные внешние пользователи могут получить доступ к веб-приложению.', }, accessControlDialog: { accessItems: { anyone: 'Кто угодно с ссылкой', specific: 'Конкретные группы или члены', organization: 'Только члены внутри предприятия', + external: 'Аутентифицированные внешние пользователи', }, operateGroupAndMember: { searchPlaceholder: 'Искать группы и участников', diff --git a/web/i18n/ru-RU/share-app.ts b/web/i18n/ru-RU/share-app.ts index b2850fa276..dafbe9d6b1 100644 --- a/web/i18n/ru-RU/share-app.ts +++ b/web/i18n/ru-RU/share-app.ts @@ -77,6 +77,9 @@ const translation = { execution: 'ИСПОЛНЕНИЕ', executions: '{{num}} ВЫПОЛНЕНИЯ', }, + login: { + backToHome: 'Назад на главную', + }, } export default translation diff --git a/web/i18n/sl-SI/app.ts b/web/i18n/sl-SI/app.ts index 6241d40f30..4ac445872d 100644 --- a/web/i18n/sl-SI/app.ts +++ b/web/i18n/sl-SI/app.ts @@ -213,12 +213,14 @@ const translation = { anyone: 'Vsakdo lahko dostopa do spletne aplikacije', specific: 'Samo določenim skupinam ali članom je omogočen dostop do spletne aplikacije', organization: 'Vsakdo v organizaciji lahko dostopa do spletne aplikacije', + external: 'Samo avtentificirani zunanji uporabniki lahko dostopajo do spletne aplikacije.', }, accessControlDialog: { accessItems: { anyone: 'Kdorkoli s povezavo', specific: 'Specifične skupine ali člani', organization: 'Samo člani znotraj podjetja', + external: 'Avtorizirani zunanji uporabniki', }, operateGroupAndMember: { searchPlaceholder: 'Išči skupine in člane', diff --git a/web/i18n/sl-SI/share-app.ts b/web/i18n/sl-SI/share-app.ts index 28d62b2336..8b7fe87cbd 100644 --- a/web/i18n/sl-SI/share-app.ts +++ b/web/i18n/sl-SI/share-app.ts @@ -74,6 +74,9 @@ const translation = { execution: 'IZVEDBA', executions: '{{num}} IZVRŠITEV', }, + login: { + backToHome: 'Nazaj na začetno stran', + }, } export default translation diff --git a/web/i18n/th-TH/app.ts b/web/i18n/th-TH/app.ts index 9204c71d32..0979d07f51 100644 --- a/web/i18n/th-TH/app.ts +++ b/web/i18n/th-TH/app.ts @@ -209,12 +209,14 @@ const translation = { anyone: 'ใครก็สามารถเข้าถึงเว็บแอปได้', specific: 'สมาชิกหรือกลุ่มเฉพาะเท่านั้นที่สามารถเข้าถึงแอปเว็บได้', organization: 'ใครก็ได้ในองค์กรสามารถเข้าถึงแอปเว็บได้', + external: 'ผู้ใช้งานภายนอกที่ได้รับการยืนยันตัวตนเท่านั้นที่สามารถเข้าถึงแอปพลิเคชันเว็บได้', }, accessControlDialog: { accessItems: { specific: 'กลุ่มหรือสมาชิกเฉพาะ', organization: 'เฉพาะสมาชิกภายในองค์กร', anyone: 'ใครก็ตามที่มีลิงก์', + external: 'ผู้ใช้ภายนอกที่ได้รับการตรวจสอบแล้ว', }, operateGroupAndMember: { searchPlaceholder: 'ค้นหากลุ่มและสมาชิก', diff --git a/web/i18n/th-TH/share-app.ts b/web/i18n/th-TH/share-app.ts index fd4a8f386c..eca049b9a2 100644 --- a/web/i18n/th-TH/share-app.ts +++ b/web/i18n/th-TH/share-app.ts @@ -73,6 +73,9 @@ const translation = { execution: 'การดำเนินการ', executions: '{{num}} การประหารชีวิต', }, + login: { + backToHome: 'กลับไปที่หน้าแรก', + }, } export default translation diff --git a/web/i18n/tr-TR/app.ts b/web/i18n/tr-TR/app.ts index 995cc9c795..5e55ffa349 100644 --- a/web/i18n/tr-TR/app.ts +++ b/web/i18n/tr-TR/app.ts @@ -209,12 +209,14 @@ const translation = { anyone: 'Herkes web uygulamasına erişebilir', organization: 'Kuruluşta herkes web uygulamasına erişebilir.', specific: 'Sadece belirli gruplar veya üyeler web uygulamasına erişebilir.', + external: 'Sadece kimliği doğrulanmış dış kullanıcılar Web uygulamasına erişebilir', }, accessControlDialog: { accessItems: { anyone: 'Bağlantıya sahip olan herkes', organization: 'Sadece işletme içindeki üyeler', specific: 'Belirli gruplar veya üyeler', + external: 'Kimliği onaylanmış harici kullanıcılar', }, operateGroupAndMember: { searchPlaceholder: 'Grupları ve üyeleri ara', diff --git a/web/i18n/tr-TR/share-app.ts b/web/i18n/tr-TR/share-app.ts index 184f44e147..e7ad4fcd68 100644 --- a/web/i18n/tr-TR/share-app.ts +++ b/web/i18n/tr-TR/share-app.ts @@ -73,6 +73,9 @@ const translation = { execution: 'İFRAZAT', executions: '{{num}} İDAM', }, + login: { + backToHome: 'Ana Sayfaya Dön', + }, } export default translation diff --git a/web/i18n/uk-UA/app.ts b/web/i18n/uk-UA/app.ts index 4bbb0dcbf1..6e5ff5dc74 100644 --- a/web/i18n/uk-UA/app.ts +++ b/web/i18n/uk-UA/app.ts @@ -213,12 +213,14 @@ const translation = { anyone: 'Будь-хто може отримати доступ до веб-додатку', specific: 'Тільки окремі групи або члени можуть отримати доступ до веб-додатку.', organization: 'Будь-хто в організації може отримати доступ до веб-додатку.', + external: 'Тільки перевірені зовнішні користувачі можуть отримати доступ до веб-застосунку.', }, accessControlDialog: { accessItems: { anyone: 'Кожен, у кого є посилання', specific: 'Конкретні групи або члени', organization: 'Тільки члени підприємства', + external: 'Аутентифіковані зовнішні користувачі', }, operateGroupAndMember: { searchPlaceholder: 'Шукати групи та учасників', diff --git a/web/i18n/uk-UA/share-app.ts b/web/i18n/uk-UA/share-app.ts index 058925ff15..92f25545d9 100644 --- a/web/i18n/uk-UA/share-app.ts +++ b/web/i18n/uk-UA/share-app.ts @@ -73,6 +73,9 @@ const translation = { execution: 'ВИКОНАННЯ', executions: '{{num}} ВИКОНАНЬ', }, + login: { + backToHome: 'Повернутися на головну', + }, } export default translation diff --git a/web/i18n/vi-VN/app.ts b/web/i18n/vi-VN/app.ts index 243454d011..c5f1a7496d 100644 --- a/web/i18n/vi-VN/app.ts +++ b/web/i18n/vi-VN/app.ts @@ -213,12 +213,14 @@ const translation = { anyone: 'Mọi người đều có thể truy cập ứng dụng web.', specific: 'Chỉ những nhóm hoặc thành viên cụ thể mới có thể truy cập ứng dụng web.', organization: 'Bất kỳ ai trong tổ chức đều có thể truy cập ứng dụng web.', + external: 'Chỉ những người dùng bên ngoài đã xác thực mới có thể truy cập vào ứng dụng Web.', }, accessControlDialog: { accessItems: { anyone: 'Ai có liên kết', specific: 'Các nhóm hoặc thành viên cụ thể', organization: 'Chỉ các thành viên trong doanh nghiệp', + external: 'Người dùng bên ngoài được xác thực', }, operateGroupAndMember: { searchPlaceholder: 'Tìm kiếm nhóm và thành viên', diff --git a/web/i18n/vi-VN/share-app.ts b/web/i18n/vi-VN/share-app.ts index a55f9b8476..12a31bd40b 100644 --- a/web/i18n/vi-VN/share-app.ts +++ b/web/i18n/vi-VN/share-app.ts @@ -73,6 +73,9 @@ const translation = { executions: '{{num}} ÁN TỬ HÌNH', execution: 'THI HÀNH', }, + login: { + backToHome: 'Trở về Trang Chủ', + }, } export default translation diff --git a/web/i18n/zh-Hant/app.ts b/web/i18n/zh-Hant/app.ts index c393fc949e..c43b2ee308 100644 --- a/web/i18n/zh-Hant/app.ts +++ b/web/i18n/zh-Hant/app.ts @@ -212,12 +212,14 @@ const translation = { anyone: '任何人都可以訪問這個網絡應用程式', specific: '只有特定的群體或成員可以訪問這個網絡應用程序', organization: '組織中的任何人都可以訪問該網絡應用程序', + external: '只有經過身份驗證的外部用戶才能訪問該網絡應用程序', }, accessControlDialog: { accessItems: { anyone: '擁有鏈接的人', specific: '特定群體或成員', organization: '只有企業內部成員', + external: '經過驗證的外部用戶', }, operateGroupAndMember: { searchPlaceholder: '搜尋群組和成員', diff --git a/web/i18n/zh-Hant/share-app.ts b/web/i18n/zh-Hant/share-app.ts index 54d2ff98b6..e25aa0c0de 100644 --- a/web/i18n/zh-Hant/share-app.ts +++ b/web/i18n/zh-Hant/share-app.ts @@ -73,6 +73,9 @@ const translation = { execution: '執行', executions: '{{num}} 執行', }, + login: { + backToHome: '返回首頁', + }, } export default translation From 837f769960932b67bf1157bdbe1e0c682ea5e177 Mon Sep 17 00:00:00 2001 From: minglu7 <1347866672@qq.com> Date: Thu, 5 Jun 2025 14:33:24 +0800 Subject: [PATCH 07/20] fix: update text_to_audio method to send data as JSON (#20663) --- sdks/python-client/dify_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python-client/dify_client/client.py b/sdks/python-client/dify_client/client.py index ee1b5c57e1..d885dc6fb7 100644 --- a/sdks/python-client/dify_client/client.py +++ b/sdks/python-client/dify_client/client.py @@ -47,7 +47,7 @@ class DifyClient: def text_to_audio(self, text: str, user: str, streaming: bool = False): data = {"text": text, "user": user, "streaming": streaming} - return self._send_request("POST", "/text-to-audio", data=data) + return self._send_request("POST", "/text-to-audio", json=data) def get_meta(self, user): params = {"user": user} From 0ccf8cb23ebcffd097b6fbee86200239f771473e Mon Sep 17 00:00:00 2001 From: Novice <857526207@qq.com> Date: Thu, 5 Jun 2025 14:56:41 +0800 Subject: [PATCH 08/20] fix: agent moderation not working (#20673) --- web/app/components/base/chat/chat/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 28f297b90e..10fb455d33 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -366,7 +366,7 @@ export const useChat = ( if (!newResponseItem) return - const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 + const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer updateChatTreeNode(responseItem.id, { content: isUseAgentThought ? '' : newResponseItem.answer, log: [ From 3fb9b41fe52472812cb64fd04b6c85b02b8efb90 Mon Sep 17 00:00:00 2001 From: HaiyangP <46739135+HaiyangPeng@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:59:55 +0800 Subject: [PATCH 09/20] A more concise and effective extractor for excel and csv files (#20625) Co-authored-by: haiyangpengai --- .../workflow/nodes/document_extractor/node.py | 48 +++++- .../nodes/test_document_extractor_node.py | 140 +++++++----------- 2 files changed, 94 insertions(+), 94 deletions(-) diff --git a/api/core/workflow/nodes/document_extractor/node.py b/api/core/workflow/nodes/document_extractor/node.py index d39eb9c932..429fed2d04 100644 --- a/api/core/workflow/nodes/document_extractor/node.py +++ b/api/core/workflow/nodes/document_extractor/node.py @@ -397,19 +397,44 @@ def _extract_text_from_csv(file_content: bytes) -> str: if not rows: return "" - # Create Markdown table - markdown_table = "| " + " | ".join(rows[0]) + " |\n" - markdown_table += "| " + " | ".join(["---"] * len(rows[0])) + " |\n" - for row in rows[1:]: - markdown_table += "| " + " | ".join(row) + " |\n" + # Combine multi-line text in the header row + header_row = [cell.replace("\n", " ").replace("\r", "") for cell in rows[0]] - return markdown_table.strip() + # Create Markdown table + markdown_table = "| " + " | ".join(header_row) + " |\n" + markdown_table += "| " + " | ".join(["-" * len(col) for col in rows[0]]) + " |\n" + + # Process each data row and combine multi-line text in each cell + for row in rows[1:]: + processed_row = [cell.replace("\n", " ").replace("\r", "") for cell in row] + markdown_table += "| " + " | ".join(processed_row) + " |\n" + + return markdown_table except Exception as e: raise TextExtractionError(f"Failed to extract text from CSV: {str(e)}") from e def _extract_text_from_excel(file_content: bytes) -> str: """Extract text from an Excel file using pandas.""" + + def _construct_markdown_table(df: pd.DataFrame) -> str: + """Manually construct a Markdown table from a DataFrame.""" + # Construct the header row + header_row = "| " + " | ".join(df.columns) + " |" + + # Construct the separator row + separator_row = "| " + " | ".join(["-" * len(col) for col in df.columns]) + " |" + + # Construct the data rows + data_rows = [] + for _, row in df.iterrows(): + data_row = "| " + " | ".join(map(str, row)) + " |" + data_rows.append(data_row) + + # Combine all rows into a single string + markdown_table = "\n".join([header_row, separator_row] + data_rows) + return markdown_table + try: excel_file = pd.ExcelFile(io.BytesIO(file_content)) markdown_table = "" @@ -417,8 +442,15 @@ def _extract_text_from_excel(file_content: bytes) -> str: try: df = excel_file.parse(sheet_name=sheet_name) df.dropna(how="all", inplace=True) - # Create Markdown table two times to separate tables with a newline - markdown_table += df.to_markdown(index=False, floatfmt="") + "\n\n" + + # Combine multi-line text in each cell into a single line + df = df.applymap(lambda x: " ".join(str(x).splitlines()) if isinstance(x, str) else x) # type: ignore + + # Combine multi-line text in column names into a single line + df.columns = pd.Index([" ".join(col.splitlines()) for col in df.columns]) + + # Manually construct the Markdown table + markdown_table += _construct_markdown_table(df) + "\n\n" except Exception as e: continue return markdown_table diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py index 35d83449c3..4cb1aa93f9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py @@ -1,5 +1,7 @@ +import io from unittest.mock import Mock, patch +import pandas as pd import pytest from docx.oxml.text.paragraph import CT_P @@ -187,145 +189,134 @@ def test_node_type(document_extractor_node): @patch("pandas.ExcelFile") def test_extract_text_from_excel_single_sheet(mock_excel_file): - """Test extracting text from Excel file with single sheet.""" - # Mock DataFrame - mock_df = Mock() - mock_df.dropna = Mock() - mock_df.to_markdown.return_value = "| Name | Age |\n|------|-----|\n| John | 25 |" + """Test extracting text from Excel file with single sheet and multiline content.""" + + # Test multi-line cell + data = {"Name\nwith\nnewline": ["John\nDoe", "Jane\nSmith"], "Age": [25, 30]} + + df = pd.DataFrame(data) # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["Sheet1"] - mock_excel_instance.parse.return_value = mock_df + mock_excel_instance.parse.return_value = df mock_excel_file.return_value = mock_excel_instance file_content = b"fake_excel_content" result = _extract_text_from_excel(file_content) + expected_manual = "| Name with newline | Age |\n| ----------------- | --- |\n\ +| John Doe | 25 |\n| Jane Smith | 30 |\n\n" - expected = "| Name | Age |\n|------|-----|\n| John | 25 |\n\n" - assert result == expected - mock_excel_file.assert_called_once() - mock_df.dropna.assert_called_once_with(how="all", inplace=True) - mock_df.to_markdown.assert_called_once_with(index=False, floatfmt="") + assert expected_manual == result + mock_excel_instance.parse.assert_called_once_with(sheet_name="Sheet1") @patch("pandas.ExcelFile") def test_extract_text_from_excel_multiple_sheets(mock_excel_file): - """Test extracting text from Excel file with multiple sheets.""" - # Mock DataFrames for different sheets - mock_df1 = Mock() - mock_df1.dropna = Mock() - mock_df1.to_markdown.return_value = "| Product | Price |\n|---------|-------|\n| Apple | 1.50 |" + """Test extracting text from Excel file with multiple sheets and multiline content.""" - mock_df2 = Mock() - mock_df2.dropna = Mock() - mock_df2.to_markdown.return_value = "| City | Population |\n|------|------------|\n| NYC | 8000000 |" + # Test multi-line cell + data1 = {"Product\nName": ["Apple\nRed", "Banana\nYellow"], "Price": [1.50, 0.99]} + df1 = pd.DataFrame(data1) + + data2 = {"City\nName": ["New\nYork", "Los\nAngeles"], "Population": [8000000, 3900000]} + df2 = pd.DataFrame(data2) # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["Products", "Cities"] - mock_excel_instance.parse.side_effect = [mock_df1, mock_df2] + mock_excel_instance.parse.side_effect = [df1, df2] mock_excel_file.return_value = mock_excel_instance file_content = b"fake_excel_content_multiple_sheets" result = _extract_text_from_excel(file_content) - expected = ( - "| Product | Price |\n|---------|-------|\n| Apple | 1.50 |\n\n" - "| City | Population |\n|------|------------|\n| NYC | 8000000 |\n\n" - ) - assert result == expected + expected_manual1 = "| Product Name | Price |\n| ------------ | ----- |\n\ +| Apple Red | 1.5 |\n| Banana Yellow | 0.99 |\n\n" + expected_manual2 = "| City Name | Population |\n| --------- | ---------- |\n\ +| New York | 8000000 |\n| Los Angeles | 3900000 |\n\n" + + assert expected_manual1 in result + assert expected_manual2 in result + assert mock_excel_instance.parse.call_count == 2 @patch("pandas.ExcelFile") def test_extract_text_from_excel_empty_sheets(mock_excel_file): """Test extracting text from Excel file with empty sheets.""" - # Mock empty DataFrame - mock_df = Mock() - mock_df.dropna = Mock() - mock_df.to_markdown.return_value = "" + + # Empty excel + df = pd.DataFrame() # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["EmptySheet"] - mock_excel_instance.parse.return_value = mock_df + mock_excel_instance.parse.return_value = df mock_excel_file.return_value = mock_excel_instance file_content = b"fake_excel_empty_content" result = _extract_text_from_excel(file_content) - expected = "\n\n" + expected = "| |\n| |\n\n" assert result == expected + mock_excel_instance.parse.assert_called_once_with(sheet_name="EmptySheet") + @patch("pandas.ExcelFile") def test_extract_text_from_excel_sheet_parse_error(mock_excel_file): """Test handling of sheet parsing errors - should continue with other sheets.""" - # Mock DataFrames - one successful, one that raises exception - mock_df_success = Mock() - mock_df_success.dropna = Mock() - mock_df_success.to_markdown.return_value = "| Data | Value |\n|------|-------|\n| Test | 123 |" + + # Test error + data = {"Data": ["Test"], "Value": [123]} + df = pd.DataFrame(data) # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["GoodSheet", "BadSheet"] - mock_excel_instance.parse.side_effect = [mock_df_success, Exception("Parse error")] + mock_excel_instance.parse.side_effect = [df, Exception("Parse error")] mock_excel_file.return_value = mock_excel_instance file_content = b"fake_excel_mixed_content" result = _extract_text_from_excel(file_content) - expected = "| Data | Value |\n|------|-------|\n| Test | 123 |\n\n" - assert result == expected + expected_manual = "| Data | Value |\n| ---- | ----- |\n| Test | 123 |\n\n" + assert expected_manual == result -@patch("pandas.ExcelFile") -def test_extract_text_from_excel_file_error(mock_excel_file): - """Test handling of Excel file reading errors.""" - mock_excel_file.side_effect = Exception("Invalid Excel file") - - file_content = b"invalid_excel_content" - - with pytest.raises(Exception) as exc_info: - _extract_text_from_excel(file_content) - - # Note: The function should raise TextExtractionError, but since it's not imported in the test, - # we check for the general Exception pattern - assert "Failed to extract text from Excel file" in str(exc_info.value) + assert mock_excel_instance.parse.call_count == 2 @patch("pandas.ExcelFile") def test_extract_text_from_excel_io_bytesio_usage(mock_excel_file): """Test that BytesIO is properly used with the file content.""" - import io - # Mock DataFrame - mock_df = Mock() - mock_df.dropna = Mock() - mock_df.to_markdown.return_value = "| Test | Data |\n|------|------|\n| 1 | A |" + # Test bytesio + data = {"Test": [1], "Data": ["A"]} + df = pd.DataFrame(data) # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["TestSheet"] - mock_excel_instance.parse.return_value = mock_df + mock_excel_instance.parse.return_value = df mock_excel_file.return_value = mock_excel_instance file_content = b"test_excel_bytes" result = _extract_text_from_excel(file_content) - # Verify that ExcelFile was called with a BytesIO object mock_excel_file.assert_called_once() - call_args = mock_excel_file.call_args[0][0] - assert isinstance(call_args, io.BytesIO) + call_arg = mock_excel_file.call_args[0][0] + assert isinstance(call_arg, io.BytesIO) - expected = "| Test | Data |\n|------|------|\n| 1 | A |\n\n" - assert result == expected + expected_manual = "| Test | Data |\n| ---- | ---- |\n| 1 | A |\n\n" + assert expected_manual == result @patch("pandas.ExcelFile") def test_extract_text_from_excel_all_sheets_fail(mock_excel_file): """Test when all sheets fail to parse - should return empty string.""" + # Mock ExcelFile mock_excel_instance = Mock() mock_excel_instance.sheet_names = ["BadSheet1", "BadSheet2"] @@ -335,29 +326,6 @@ def test_extract_text_from_excel_all_sheets_fail(mock_excel_file): file_content = b"fake_excel_all_bad_sheets" result = _extract_text_from_excel(file_content) - # Should return empty string when all sheets fail assert result == "" - -@patch("pandas.ExcelFile") -def test_extract_text_from_excel_markdown_formatting(mock_excel_file): - """Test that markdown formatting parameters are correctly applied.""" - # Mock DataFrame - mock_df = Mock() - mock_df.dropna = Mock() - mock_df.to_markdown.return_value = "| Float | Int |\n|-------|-----|\n| 123456.78 | 42 |" - - # Mock ExcelFile - mock_excel_instance = Mock() - mock_excel_instance.sheet_names = ["NumberSheet"] - mock_excel_instance.parse.return_value = mock_df - mock_excel_file.return_value = mock_excel_instance - - file_content = b"fake_excel_numbers" - result = _extract_text_from_excel(file_content) - - # Verify to_markdown was called with correct parameters - mock_df.to_markdown.assert_called_once_with(index=False, floatfmt="") - - expected = "| Float | Int |\n|-------|-----|\n| 123456.78 | 42 |\n\n" - assert result == expected + assert mock_excel_instance.parse.call_count == 2 From 59dc7c880e6deca3c1f77f726dec435a1544dd01 Mon Sep 17 00:00:00 2001 From: KVOJJJin Date: Thu, 5 Jun 2025 15:47:42 +0800 Subject: [PATCH 10/20] Fix: style of radio checked (#20681) --- web/app/components/base/radio/component/radio/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/base/radio/component/radio/index.tsx b/web/app/components/base/radio/component/radio/index.tsx index 7788226484..aa4e6d0c7f 100644 --- a/web/app/components/base/radio/component/radio/index.tsx +++ b/web/app/components/base/radio/component/radio/index.tsx @@ -38,7 +38,7 @@ export default function Radio({ const divClassName = ` flex items-center py-1 relative px-7 cursor-pointer text-text-secondary rounded - bg-components-option-card-option-bg hover:bg-components-option-card-option-bg-hover hover:shadow-xs + hover:bg-components-option-card-option-bg-hover hover:shadow-xs ` return ( From 7094680e2392159b3bebd6fa6fa782147f295af2 Mon Sep 17 00:00:00 2001 From: Nite Knite Date: Thu, 5 Jun 2025 17:02:26 +0800 Subject: [PATCH 11/20] feat: reorder app types (#20685) --- web/app/(commonLayout)/apps/Apps.tsx | 4 ++-- web/app/components/app/create-app-modal/index.tsx | 15 +++------------ web/app/components/app/type-selector/index.tsx | 2 +- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/web/app/(commonLayout)/apps/Apps.tsx b/web/app/(commonLayout)/apps/Apps.tsx index 1b7ff39383..d0cc7ff91f 100644 --- a/web/app/(commonLayout)/apps/Apps.tsx +++ b/web/app/(commonLayout)/apps/Apps.tsx @@ -88,11 +88,11 @@ const Apps = () => { const anchorRef = useRef(null) const options = [ { value: 'all', text: t('app.types.all'), icon: }, + { value: 'workflow', text: t('app.types.workflow'), icon: }, + { value: 'advanced-chat', text: t('app.types.advanced'), icon: }, { value: 'chat', text: t('app.types.chatbot'), icon: }, { value: 'agent-chat', text: t('app.types.agent'), icon: }, { value: 'completion', text: t('app.types.completion'), icon: }, - { value: 'advanced-chat', text: t('app.types.advanced'), icon: }, - { value: 'workflow', text: t('app.types.workflow'), icon: }, ] useEffect(() => { diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 46cb495801..6e5547d08a 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -1,9 +1,9 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useRouter, useSearchParams } from 'next/navigation' +import { useRouter } from 'next/navigation' import { useContext, useContextSelector } from 'use-context-selector' import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react' import Link from 'next/link' @@ -19,7 +19,6 @@ import AppsContext, { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { ToastContext } from '@/app/components/base/toast' import type { AppMode } from '@/types/app' -import { AppModes } from '@/types/app' import { createApp } from '@/service/apps' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' @@ -56,14 +55,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) const isCreatingRef = useRef(false) - const searchParams = useSearchParams() - - useEffect(() => { - const category = searchParams.get('category') - if (category && AppModes.includes(category as AppMode)) - setAppMode(category as AppMode) - }, [searchParams]) - const onCreate = useCallback(async () => { if (!appMode) { notify({ type: 'error', message: t('app.newApp.appTypeRequired') }) @@ -128,7 +119,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) onClick={() => { setAppMode('workflow') }} /> - void } -const allTypes: AppMode[] = ['chat', 'agent-chat', 'completion', 'advanced-chat', 'workflow'] +const allTypes: AppMode[] = ['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion'] const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { const [open, setOpen] = useState(false) From 350ea6be6e280598c1df2e802f51d213343f6880 Mon Sep 17 00:00:00 2001 From: twwu Date: Thu, 5 Jun 2025 17:32:03 +0800 Subject: [PATCH 12/20] fix: correct spacing and formatting in variable utility functions --- .../workflow/nodes/_base/components/variable/utils.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 1f6a9ea008..0c6b643879 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -53,7 +53,7 @@ export const isConversationVar = (valueSelector: ValueSelector) => { } export const isRagVariableVar = (valueSelector: ValueSelector) => { - if(!valueSelector) + if (!valueSelector) return false return valueSelector[0] === 'rag' } @@ -152,7 +152,7 @@ const findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: Val if (isStructuredOutput) { childrenResult = findExceptVarInStructuredOutput(children, filterVar) } - else if (Array.isArray(children)) { + else if (Array.isArray(children)) { childrenResult = children.filter((item: Var) => { const { children: itemChildren } = item const currSelector = [...value_selector, item.variable] @@ -164,7 +164,7 @@ const findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: Val return filteredObj.children && (filteredObj.children as Var[])?.length > 0 }) } - else { + else { childrenResult = [] } @@ -638,7 +638,6 @@ export const toNodeOutputVars = ( let ragVariablesInDataSource: RAGPipelineVariable[] = [] if (node.data.type === BlockEnum.DataSource) ragVariablesInDataSource = ragVariables.filter(ragVariable => ragVariable.belong_to_node_id === node.id) - console.log(ragVariables, ragVariablesInDataSource, node.id) return { ...formatItem(node, isChatMode, filterVar, ragVariablesInDataSource.map( (ragVariable: RAGPipelineVariable) => ({ @@ -682,9 +681,9 @@ const getIterationItemType = ({ curr = Array.isArray(curr) ? curr.find(v => v.variable === key) : [] if (isLast) - arrayType = curr?.type + arrayType = curr?.type else if (curr?.type === VarType.object || curr?.type === VarType.file) - curr = curr.children || [] + curr = curr.children || [] } } From 2acdb0a4ea5017bcf90fce17f237f4073e3c4ded Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 5 Jun 2025 17:40:43 +0800 Subject: [PATCH 13/20] fix: var type error in cal var type in data source type --- .../nodes/_base/components/variable/utils.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 0c6b643879..c59ebfdc68 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -830,12 +830,20 @@ export const getVarType = ({ const isSystem = isSystemVar(valueSelector) const isEnv = isENV(valueSelector) const isChatVar = isConversationVar(valueSelector) - const isRagVariable = isRagVariableVar(valueSelector) + const isSharedRagVariable = isRagVariableVar(valueSelector) && valueSelector[1] === 'shared' + const isInNodeRagVariable = isRagVariableVar(valueSelector) && valueSelector[1] !== 'shared' + const startNode = availableNodes.find((node: any) => { return node?.data.type === BlockEnum.Start }) - const targetVarNodeId = isSystem ? startNode?.id : valueSelector[0] + const targetVarNodeId = (() => { + if(isSystem) + return startNode?.id + if(isInNodeRagVariable) + return valueSelector[1] + return valueSelector[0] + })() const targetVar = beforeNodesOutputVars.find(v => v.nodeId === targetVarNodeId) if (!targetVar) @@ -844,14 +852,21 @@ export const getVarType = ({ let type: VarType = VarType.string let curr: any = targetVar.vars - if (isSystem || isEnv || isChatVar || isRagVariable) { + if (isSystem || isEnv || isChatVar || isSharedRagVariable) { return curr.find((v: any) => v.variable === (valueSelector as ValueSelector).join('.'))?.type } else { - const targetVar = curr.find((v: any) => v.variable === valueSelector[1]) + const targetVar = curr.find((v: any) => { + if(isInNodeRagVariable) + return v.variable === valueSelector.join('.') + return v.variable === valueSelector[1] + }) if (!targetVar) return VarType.string + if(isInNodeRagVariable) + return targetVar.type + const isStructuredOutputVar = !!targetVar.children?.schema?.properties if (isStructuredOutputVar) { if (valueSelector.length === 2) { // root From 82e7c8a2f96dc5e8124cc8d3c7e186ca1a2a8b9a Mon Sep 17 00:00:00 2001 From: twwu Date: Thu, 5 Jun 2025 18:28:48 +0800 Subject: [PATCH 14/20] refactor: update datasource handling and improve documentation properties in pipeline components --- .../documents/create-from-pipeline/hooks.ts | 4 +-- .../documents/create-from-pipeline/index.tsx | 6 +++- .../test-run/data-source/notion/index.tsx | 7 +++++ .../notion/notion-page-selector.tsx | 22 +++++++++----- .../website-crawl/base/crawler.tsx | 14 +++++---- .../data-source/website-crawl/index.tsx | 6 ++-- .../components/panel/test-run/hooks.ts | 4 +-- .../components/panel/test-run/index.tsx | 7 +++++ web/models/pipeline.ts | 1 + web/service/use-pipeline.ts | 29 ++++++++++++++----- 10 files changed, 72 insertions(+), 28 deletions(-) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks.ts index c8315f565c..9eca3c4b4c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks.ts @@ -51,8 +51,8 @@ export const useDatasourceOptions = (pipelineNodes: Node[]) return { nodeId: node.id, type: node.data.provider_type as DatasourceType, - description: node.data.desc || '', - docTitle: '', // todo: Add docTitle and docLink if needed, or remove these properties if not used + description: node.data.datasource_label, + docTitle: 'How to use?', docLink: '', fileExtensions: node.data.fileExtensions || [], } diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index c435ca108c..b216b577e0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -265,6 +265,11 @@ const CreateFormPipeline = () => { {datasource?.type === DatasourceType.onlineDocument && ( { onCheckedCrawlResultChange={setWebsitePages} onJobIdChange={setWebsiteCrawlJobId} onPreview={updateCurrentWebsite} - usingPublished /> )} {isShowVectorSpaceFull && ( diff --git a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx index cfa2efe564..31c175d0c4 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx @@ -3,6 +3,11 @@ import NotionPageSelector from './notion-page-selector' type NotionProps = { nodeId: string + headerInfo: { + title: string + docTitle: string + docLink: string + } notionPages: NotionPage[] updateNotionPages: (value: NotionPage[]) => void canPreview?: boolean @@ -12,6 +17,7 @@ type NotionProps = { const Notion = ({ nodeId, + headerInfo, notionPages, updateNotionPages, canPreview = false, @@ -21,6 +27,7 @@ const Notion = ({ return ( page.page_id)} onSelect={updateNotionPages} canPreview={canPreview} diff --git a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx b/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx index 988b00a337..672d7eb9d2 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import WorkspaceSelector from '@/app/components/base/notion-page-selector/workspace-selector' import SearchInput from '@/app/components/base/notion-page-selector/search-input' import PageSelector from '@/app/components/base/notion-page-selector/page-selector' import type { DataSourceNotionPageMap, DataSourceNotionWorkspace, NotionPage } from '@/models/common' import Header from '@/app/components/datasets/create/website/base/header' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useDatasourceNodeRun } from '@/service/use-pipeline' -import { useTranslation } from 'react-i18next' +import { useDraftDatasourceNodeRun, usePublishedDatasourceNodeRun } from '@/service/use-pipeline' +import { DatasourceType } from '@/models/pipeline' type NotionPageSelectorProps = { value?: string[] @@ -16,6 +16,11 @@ type NotionPageSelectorProps = { onPreview?: (selectedPage: NotionPage) => void isInPipeline?: boolean nodeId: string + headerInfo: { + title: string + docTitle: string + docLink: string + } } const NotionPageSelector = ({ @@ -26,20 +31,23 @@ const NotionPageSelector = ({ onPreview, isInPipeline = false, nodeId, + headerInfo, }: NotionPageSelectorProps) => { - const { t } = useTranslation() const pipeline_id = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) - const { mutateAsync: getNotionPages } = useDatasourceNodeRun() const [notionData, setNotionData] = useState([]) const [searchValue, setSearchValue] = useState('') const [currentWorkspaceId, setCurrentWorkspaceId] = useState('') + const useDatasourceNodeRun = useRef(!isInPipeline ? usePublishedDatasourceNodeRun : useDraftDatasourceNodeRun) + const { mutateAsync: getNotionPages } = useDatasourceNodeRun.current() + const getNotionData = useCallback(async () => { if (pipeline_id) { await getNotionPages({ pipeline_id, node_id: nodeId, inputs: {}, + datasource_type: DatasourceType.onlineDocument, }, { onSuccess(notionData) { setNotionData(notionData as DataSourceNotionWorkspace[]) @@ -106,9 +114,7 @@ const NotionPageSelector = ({
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/base/crawler.tsx b/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/base/crawler.tsx index 741f5bfcc4..e6397fc10e 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/base/crawler.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/base/crawler.tsx @@ -8,11 +8,13 @@ import Crawling from './crawling' import ErrorMessage from './error-message' import CrawledResult from './crawled-result' import { - useDatasourceNodeRun, + useDraftDatasourceNodeRun, useDraftPipelinePreProcessingParams, + usePublishedDatasourceNodeRun, usePublishedPipelineProcessingParams, } from '@/service/use-pipeline' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' +import { DatasourceType } from '@/models/pipeline' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -27,7 +29,7 @@ type CrawlerProps = { docLink: string } onPreview?: (payload: CrawlResultItem) => void - usingPublished?: boolean + isInPipeline?: boolean } enum Step { @@ -43,14 +45,14 @@ const Crawler = ({ onCheckedCrawlResultChange, onJobIdChange, onPreview, - usingPublished = false, + isInPipeline = false, }: CrawlerProps) => { const { t } = useTranslation() const [step, setStep] = useState(Step.init) const [controlFoldOptions, setControlFoldOptions] = useState(0) const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) - const usePreProcessingParams = useRef(usingPublished ? usePublishedPipelineProcessingParams : useDraftPipelinePreProcessingParams) + const usePreProcessingParams = useRef(!isInPipeline ? usePublishedPipelineProcessingParams : useDraftPipelinePreProcessingParams) const { data: paramsConfig } = usePreProcessingParams.current({ pipeline_id: pipelineId!, node_id: nodeId, @@ -71,7 +73,8 @@ const Crawler = ({ const [crawlErrorMessage, setCrawlErrorMessage] = useState('') const showError = isCrawlFinished && crawlErrorMessage - const { mutateAsync: runDatasourceNode } = useDatasourceNodeRun() + const useDatasourceNodeRun = useRef(!isInPipeline ? usePublishedDatasourceNodeRun : useDraftDatasourceNodeRun) + const { mutateAsync: runDatasourceNode } = useDatasourceNodeRun.current() const handleRun = useCallback(async (value: Record) => { setStep(Step.running) @@ -79,6 +82,7 @@ const Crawler = ({ node_id: nodeId, pipeline_id: pipelineId!, inputs: value, + datasource_type: DatasourceType.websiteCrawl, }, { onSuccess: (res: any) => { const jobId = res.job_id diff --git a/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/index.tsx index 50be76e524..90548c60b3 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl/index.tsx @@ -14,7 +14,7 @@ type WebsiteCrawlProps = { docLink: string } onPreview?: (payload: CrawlResultItem) => void - usingPublished?: boolean + isInPipeline?: boolean } const WebsiteCrawl = ({ @@ -24,7 +24,7 @@ const WebsiteCrawl = ({ onCheckedCrawlResultChange, onJobIdChange, onPreview, - usingPublished, + isInPipeline, }: WebsiteCrawlProps) => { return ( ) } diff --git a/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts b/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts index 3fbb1a07aa..4a247a9599 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts @@ -49,8 +49,8 @@ export const useDatasourceOptions = () => { return { nodeId: node.id, type: node.data.provider_type as DatasourceType, - description: '', // todo: Add description - docTitle: '', // todo: Add docTitle and docLink + description: node.data.datasource_label, + docTitle: 'How to use?', docLink: '', fileExtensions: node.data.fileExtensions || [], } diff --git a/web/app/components/rag-pipeline/components/panel/test-run/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/index.tsx index 97c0a4002f..9eb0e06b4c 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/index.tsx @@ -132,8 +132,14 @@ const TestRunPanel = () => { {datasource?.type === DatasourceType.onlineDocument && ( )} {datasource?.type === DatasourceType.websiteCrawl && ( @@ -147,6 +153,7 @@ const TestRunPanel = () => { }} onCheckedCrawlResultChange={setWebsitePages} onJobIdChange={setWebsiteCrawlJobId} + isInPipeline /> )} {isShowVectorSpaceFull && ( diff --git a/web/models/pipeline.ts b/web/models/pipeline.ts index 3015939132..18df4333da 100644 --- a/web/models/pipeline.ts +++ b/web/models/pipeline.ts @@ -155,6 +155,7 @@ export type PipelineDatasourceNodeRunRequest = { pipeline_id: string node_id: string inputs: Record + datasource_type: DatasourceType } export type PipelineDatasourceNodeRunResponse = Record diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts index f4f183cac3..9b03f9ea33 100644 --- a/web/service/use-pipeline.ts +++ b/web/service/use-pipeline.ts @@ -123,14 +123,29 @@ export const useCheckPipelineDependencies = ( }) } -export const useDatasourceNodeRun = ( +export const useDraftDatasourceNodeRun = ( mutationOptions: MutationOptions = {}, ) => { return useMutation({ - mutationKey: [NAME_SPACE, 'datasource-node-run'], + mutationKey: [NAME_SPACE, 'draft-datasource-node-run'], mutationFn: (request: PipelineDatasourceNodeRunRequest) => { const { pipeline_id, node_id, ...rest } = request - return post(`/rag/pipelines/${pipeline_id}/workflows/published/nodes/${node_id}/run`, { + return post(`/rag/pipelines/${pipeline_id}/workflows/draft/datasource/nodes/${node_id}/run`, { + body: rest, + }) + }, + ...mutationOptions, + }) +} + +export const usePublishedDatasourceNodeRun = ( + mutationOptions: MutationOptions = {}, +) => { + return useMutation({ + mutationKey: [NAME_SPACE, 'published-datasource-node-run'], + mutationFn: (request: PipelineDatasourceNodeRunRequest) => { + const { pipeline_id, node_id, ...rest } = request + return post(`/rag/pipelines/${pipeline_id}/workflows/published/datasource/nodes/${node_id}/run`, { body: rest, }) }, @@ -141,7 +156,7 @@ export const useDatasourceNodeRun = ( export const useDraftPipelineProcessingParams = (params: PipelineProcessingParamsRequest, enabled = true) => { const { pipeline_id, node_id } = params return useQuery({ - queryKey: [NAME_SPACE, 'pipeline-processing-params', pipeline_id, node_id], + queryKey: [NAME_SPACE, 'draft-pipeline-processing-params', pipeline_id, node_id], queryFn: () => { return get(`/rag/pipelines/${pipeline_id}/workflows/draft/processing/parameters`, { params: { @@ -157,7 +172,7 @@ export const useDraftPipelineProcessingParams = (params: PipelineProcessingParam export const usePublishedPipelineProcessingParams = (params: PipelineProcessingParamsRequest) => { const { pipeline_id, node_id } = params return useQuery({ - queryKey: [NAME_SPACE, 'pipeline-processing-params', pipeline_id, node_id], + queryKey: [NAME_SPACE, 'published-pipeline-processing-params', pipeline_id, node_id], queryFn: () => { return get(`/rag/pipelines/${pipeline_id}/workflows/published/processing/parameters`, { params: { @@ -255,7 +270,7 @@ export const useUpdateDataSourceCredentials = ( export const useDraftPipelinePreProcessingParams = (params: PipelinePreProcessingParamsRequest, enabled = true) => { const { pipeline_id, node_id } = params return useQuery({ - queryKey: [NAME_SPACE, 'pipeline-pre-processing-params', pipeline_id, node_id], + queryKey: [NAME_SPACE, 'draft-pipeline-pre-processing-params', pipeline_id, node_id], queryFn: () => { return get(`/rag/pipelines/${pipeline_id}/workflows/draft/pre-processing/parameters`, { params: { @@ -271,7 +286,7 @@ export const useDraftPipelinePreProcessingParams = (params: PipelinePreProcessin export const usePublishedPipelinePreProcessingParams = (params: PipelinePreProcessingParamsRequest, enabled = true) => { const { pipeline_id, node_id } = params return useQuery({ - queryKey: [NAME_SPACE, 'pipeline-pre-processing-params', pipeline_id, node_id], + queryKey: [NAME_SPACE, 'published-pipeline-pre-processing-params', pipeline_id, node_id], queryFn: () => { return get(`/rag/pipelines/${pipeline_id}/workflows/published/processing/parameters`, { params: { From f76f70f0b6119a448f6d7ddf0c27d00009ab060f 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, 5 Jun 2025 23:05:50 +0800 Subject: [PATCH 15/20] Fix builtin_providers for tools. (#20697) Signed-off-by: zhanluxianshen --- api/core/tools/builtin_tool/_position.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/core/tools/builtin_tool/_position.yaml b/api/core/tools/builtin_tool/_position.yaml index b5875e2075..0e811de311 100644 --- a/api/core/tools/builtin_tool/_position.yaml +++ b/api/core/tools/builtin_tool/_position.yaml @@ -1,3 +1,4 @@ +- audio - code - time -- qrcode +- webscraper From 138ad6e8b33368a66a1956ca5ddc087d5928317e Mon Sep 17 00:00:00 2001 From: jefferyvvv <33647240+jefferyvvv@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:09:00 +0800 Subject: [PATCH 16/20] fix: opensearch fulltext search with metadata filtering dsl error (#20702) Co-authored-by: wenjun.gu --- api/core/rag/datasource/vdb/opensearch/opensearch_vector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py b/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py index 6991598ce6..b156be1a22 100644 --- a/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py +++ b/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py @@ -209,10 +209,10 @@ class OpenSearchVector(BaseVector): return docs def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: - full_text_query = {"query": {"match": {Field.CONTENT_KEY.value: query}}} + full_text_query = {"query": {"bool": {"must": [{"match": {Field.CONTENT_KEY.value: query}}]}}} document_ids_filter = kwargs.get("document_ids_filter") if document_ids_filter: - full_text_query["query"]["terms"] = {"metadata.document_id": document_ids_filter} + full_text_query["query"]["bool"]["filter"] = [{"terms": {"metadata.document_id": document_ids_filter}}] response = self._client.search(index=self._collection_name.lower(), body=full_text_query) From 38554c5f3e50eb9a5f28c06d1ecdca669ce8e3c8 Mon Sep 17 00:00:00 2001 From: Yeuoly <45712896+Yeuoly@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:36:33 +0800 Subject: [PATCH 17/20] fix(inner_api/plugin/wraps): refresh user model after creation in get user function (#20704) --- api/controllers/inner_api/plugin/wraps.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index b2849a7962..50408e0929 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -32,6 +32,7 @@ def get_user(tenant_id: str, user_id: str | None) -> Account | EndUser: ) session.add(user_model) session.commit() + session.refresh(user_model) else: user_model = AccountService.load_user(user_id) if not user_model: From 4f14d7c0ca49e44e7b4d0fc1623b2beff9a440ec Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Fri, 6 Jun 2025 09:09:31 +0800 Subject: [PATCH 18/20] chore: bump uv to 0.7.x (#20692) --- .github/actions/setup-uv/action.yml | 2 +- api/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-uv/action.yml b/.github/actions/setup-uv/action.yml index a596be63f7..0499b44dba 100644 --- a/.github/actions/setup-uv/action.yml +++ b/.github/actions/setup-uv/action.yml @@ -8,7 +8,7 @@ inputs: uv-version: description: UV version to set up required: true - default: '0.6.14' + default: '~=0.7.11' uv-lockfile: description: Path to the UV lockfile to restore cache from required: true diff --git a/api/Dockerfile b/api/Dockerfile index cff696ff56..7e4997507f 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.12-slim-bookworm AS base WORKDIR /app/api # Install uv -ENV UV_VERSION=0.6.14 +ENV UV_VERSION=0.7.11 RUN pip install --no-cache-dir uv==${UV_VERSION} From 4271602cfca984aa999f505cf2e30d43c61ad280 Mon Sep 17 00:00:00 2001 From: jefferyvvv <33647240+jefferyvvv@users.noreply.github.com> Date: Fri, 6 Jun 2025 09:10:01 +0800 Subject: [PATCH 19/20] fix: opensearch metadata filtering returns empty (#20701) Co-authored-by: wenjun.gu Co-authored-by: crazywoola <427733928@qq.com> --- api/core/rag/datasource/vdb/opensearch/opensearch_vector.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py b/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py index b156be1a22..6a6c2b73ef 100644 --- a/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py +++ b/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py @@ -255,7 +255,8 @@ class OpenSearchVector(BaseVector): Field.METADATA_KEY.value: { "type": "object", "properties": { - "doc_id": {"type": "keyword"} # Map doc_id to keyword type + "doc_id": {"type": "keyword"}, # Map doc_id to keyword type + "document_id": {"type": "keyword"}, }, }, } From cbf0864edcdbd13af7ef2763da6357ad5c9622d8 Mon Sep 17 00:00:00 2001 From: twwu Date: Fri, 6 Jun 2025 10:08:19 +0800 Subject: [PATCH 20/20] refactor: refactor online documents handling and update related components --- .../documents/create-from-pipeline/hooks.ts | 28 +++++------ .../documents/create-from-pipeline/index.tsx | 46 +++++++++---------- .../preview/chunk-preview.tsx | 22 ++++----- .../{notion => online-documents}/index.tsx | 24 +++++----- .../online-document-selector.tsx} | 36 +++++++-------- .../components/panel/test-run/hooks.ts | 12 ++--- .../components/panel/test-run/index.tsx | 24 +++++----- 7 files changed, 96 insertions(+), 96 deletions(-) rename web/app/components/rag-pipeline/components/panel/test-run/data-source/{notion => online-documents}/index.tsx (53%) rename web/app/components/rag-pipeline/components/panel/test-run/data-source/{notion/notion-page-selector.tsx => online-documents/online-document-selector.tsx} (82%) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks.ts index 9eca3c4b4c..65a0f33786 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks.ts @@ -119,31 +119,31 @@ export const useLocalFile = () => { } } -export const useNotionsPages = () => { - const [notionPages, setNotionPages] = useState([]) - const [currentNotionPage, setCurrentNotionPage] = useState() +export const useOnlineDocuments = () => { + const [onlineDocuments, setOnlineDocuments] = useState([]) + const [currentDocuments, setCurrentDocuments] = useState() - const previewNotionPage = useRef(notionPages[0]) + const previewOnlineDocument = useRef(onlineDocuments[0]) - const updateNotionPages = (value: NotionPage[]) => { - setNotionPages(value) + const updateOnlineDocuments = (value: NotionPage[]) => { + setOnlineDocuments(value) } const updateCurrentPage = useCallback((page: NotionPage) => { - setCurrentNotionPage(page) + setCurrentDocuments(page) }, []) - const hideNotionPagePreview = useCallback(() => { - setCurrentNotionPage(undefined) + const hideOnlineDocumentPreview = useCallback(() => { + setCurrentDocuments(undefined) }, []) return { - notionPages, - previewNotionPage, - updateNotionPages, - currentNotionPage, + onlineDocuments, + previewOnlineDocument, + updateOnlineDocuments, + currentDocuments, updateCurrentPage, - hideNotionPagePreview, + hideOnlineDocumentPreview, } } diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index b216b577e0..a293697790 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -5,7 +5,7 @@ import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEst import LocalFile from '@/app/components/rag-pipeline/components/panel/test-run/data-source/local-file' import { useProviderContextSelector } from '@/context/provider-context' import type { NotionPage } from '@/models/common' -import Notion from '@/app/components/rag-pipeline/components/panel/test-run/data-source/notion' +import OnlineDocuments from '@/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents' import VectorSpaceFull from '@/app/components/billing/vector-space-full' import WebsiteCrawl from '@/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl' import Actions from './data-source/actions' @@ -26,7 +26,7 @@ import Processing from './processing' import type { InitialDocumentDetail, PublishedPipelineRunPreviewResponse, PublishedPipelineRunResponse } from '@/models/pipeline' import { DatasourceType } from '@/models/pipeline' import { TransferMethod } from '@/types/app' -import { useAddDocumentsSteps, useLocalFile, useNotionsPages, useWebsiteCrawl } from './hooks' +import { useAddDocumentsSteps, useLocalFile, useOnlineDocuments, useWebsiteCrawl } from './hooks' const CreateFormPipeline = () => { const { t } = useTranslation() @@ -63,13 +63,13 @@ const CreateFormPipeline = () => { hideFilePreview, } = useLocalFile() const { - notionPages, - previewNotionPage, - updateNotionPages, - currentNotionPage, + onlineDocuments, + previewOnlineDocument, + updateOnlineDocuments, + currentDocuments, updateCurrentPage, - hideNotionPagePreview, - } = useNotionsPages() + hideOnlineDocumentPreview, + } = useOnlineDocuments() const { websitePages, websiteCrawlJobId, @@ -90,11 +90,11 @@ const CreateFormPipeline = () => { if (datasource.type === DatasourceType.localFile) return isShowVectorSpaceFull || !fileList.length || fileList.some(file => !file.file.id) if (datasource.type === DatasourceType.onlineDocument) - return isShowVectorSpaceFull || !notionPages.length + return isShowVectorSpaceFull || !onlineDocuments.length if (datasource.type === DatasourceType.websiteCrawl) return isShowVectorSpaceFull || !websitePages.length return false - }, [datasource, isShowVectorSpaceFull, fileList, notionPages.length, websitePages.length]) + }, [datasource, isShowVectorSpaceFull, fileList, onlineDocuments.length, websitePages.length]) const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline() @@ -117,7 +117,7 @@ const CreateFormPipeline = () => { datasourceInfoList.push(documentInfo) } if (datasource.type === DatasourceType.onlineDocument) { - const { workspace_id, ...rest } = previewNotionPage.current + const { workspace_id, ...rest } = previewOnlineDocument.current const documentInfo = { workspace_id, page: rest, @@ -143,7 +143,7 @@ const CreateFormPipeline = () => { setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) }, }) - }, [datasource, pipelineId, previewFile, previewNotionPage, previewWebsitePage, runPublishedPipeline, websiteCrawlJobId]) + }, [datasource, pipelineId, previewFile, previewOnlineDocument, previewWebsitePage, runPublishedPipeline, websiteCrawlJobId]) const handleProcess = useCallback(async (data: Record) => { if (!datasource) @@ -166,7 +166,7 @@ const CreateFormPipeline = () => { }) } if (datasource.type === DatasourceType.onlineDocument) { - notionPages.forEach((page) => { + onlineDocuments.forEach((page) => { const { workspace_id, ...rest } = page const documentInfo = { workspace_id, @@ -196,7 +196,7 @@ const CreateFormPipeline = () => { handleNextStep() }, }) - }, [datasource, fileList, handleNextStep, notionPages, pipelineId, runPublishedPipeline, websiteCrawlJobId, websitePages]) + }, [datasource, fileList, handleNextStep, onlineDocuments, pipelineId, runPublishedPipeline, websiteCrawlJobId, websitePages]) const onClickProcess = useCallback(() => { isPreview.current = false @@ -217,10 +217,10 @@ const CreateFormPipeline = () => { onClickPreview() }, [onClickPreview, previewFile]) - const handlePreviewNotionPageChange = useCallback((page: NotionPage) => { - previewNotionPage.current = page + const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => { + previewOnlineDocument.current = page onClickPreview() - }, [onClickPreview, previewNotionPage]) + }, [onClickPreview, previewOnlineDocument]) const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => { previewWebsitePage.current = website @@ -263,15 +263,15 @@ const CreateFormPipeline = () => { /> )} {datasource?.type === DatasourceType.onlineDocument && ( - @@ -327,7 +327,7 @@ const CreateFormPipeline = () => { currentStep === 1 && (
{currentFile && } - {currentNotionPage && } + {currentDocuments && } {currentWebsite && }
) @@ -339,14 +339,14 @@ const CreateFormPipeline = () => { file.file)} - notionPages={notionPages} + onlineDocuments={onlineDocuments} websitePages={websitePages} isIdle={isIdle && isPreview.current} isPending={isPending && isPreview.current} estimateData={estimateData} onPreview={onClickPreview} handlePreviewFileChange={handlePreviewFileChange} - handlePreviewNotionPageChange={handlePreviewNotionPageChange} + handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange} handlePreviewWebsitePageChange={handlePreviewWebsiteChange} /> )} diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx index 61779bd57b..cbc3882061 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx @@ -20,35 +20,35 @@ import { DatasourceType } from '@/models/pipeline' type ChunkPreviewProps = { datasource: Datasource files: CustomFile[] - notionPages: NotionPage[] + onlineDocuments: NotionPage[] websitePages: CrawlResultItem[] isIdle: boolean isPending: boolean estimateData: FileIndexingEstimateResponse | undefined onPreview: () => void handlePreviewFileChange: (file: DocumentItem) => void - handlePreviewNotionPageChange: (page: NotionPage) => void + handlePreviewOnlineDocumentChange: (page: NotionPage) => void handlePreviewWebsitePageChange: (page: CrawlResultItem) => void } const ChunkPreview = ({ datasource, files, - notionPages, + onlineDocuments, websitePages, isIdle, isPending, estimateData, onPreview, handlePreviewFileChange, - handlePreviewNotionPageChange, + handlePreviewOnlineDocumentChange, handlePreviewWebsitePageChange, }: ChunkPreviewProps) => { const { t } = useTranslation() const currentDocForm = useDatasetDetailContextWithSelector(s => s.dataset?.doc_form) const [previewFile, setPreviewFile] = useState(files[0] as DocumentItem) - const [previewNotionPage, setPreviewNotionPage] = useState(notionPages[0]) + const [previewOnlineDocument, setPreviewOnlineDocument] = useState(onlineDocuments[0]) const [previewWebsitePage, setPreviewWebsitePage] = useState(websitePages[0]) const dataSourceType = datasource?.type @@ -72,20 +72,20 @@ const ChunkPreview = ({ {dataSourceType === DatasourceType.onlineDocument && ({ + onlineDocuments.map(page => ({ id: page.page_id, name: page.page_name, extension: 'md', })) } onChange={(selected) => { - const selectedPage = notionPages.find(page => page.page_id === selected.id) - setPreviewNotionPage(selectedPage!) - handlePreviewNotionPageChange(selectedPage!) + const selectedPage = onlineDocuments.find(page => page.page_id === selected.id) + setPreviewOnlineDocument(selectedPage!) + handlePreviewOnlineDocumentChange(selectedPage!) }} value={{ - id: previewNotionPage?.page_id || '', - name: previewNotionPage?.page_name || '', + id: previewOnlineDocument?.page_id || '', + name: previewOnlineDocument?.page_name || '', extension: 'md', }} /> diff --git a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents/index.tsx similarity index 53% rename from web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents/index.tsx index 31c175d0c4..1a4abf86e1 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents/index.tsx @@ -1,35 +1,35 @@ import type { NotionPage } from '@/models/common' -import NotionPageSelector from './notion-page-selector' +import OnlineDocumentSelector from './online-document-selector' -type NotionProps = { +type OnlineDocumentsProps = { nodeId: string headerInfo: { title: string docTitle: string docLink: string } - notionPages: NotionPage[] - updateNotionPages: (value: NotionPage[]) => void + onlineDocuments: NotionPage[] + updateOnlineDocuments: (value: NotionPage[]) => void canPreview?: boolean onPreview?: (selectedPage: NotionPage) => void isInPipeline?: boolean } -const Notion = ({ +const OnlineDocuments = ({ nodeId, headerInfo, - notionPages, - updateNotionPages, + onlineDocuments, + updateOnlineDocuments, canPreview = false, onPreview, isInPipeline = false, -}: NotionProps) => { +}: OnlineDocumentsProps) => { return ( - page.page_id)} - onSelect={updateNotionPages} + value={onlineDocuments.map(page => page.page_id)} + onSelect={updateOnlineDocuments} canPreview={canPreview} onPreview={onPreview} isInPipeline={isInPipeline} @@ -37,4 +37,4 @@ const Notion = ({ ) } -export default Notion +export default OnlineDocuments diff --git a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx b/web/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents/online-document-selector.tsx similarity index 82% rename from web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents/online-document-selector.tsx index 672d7eb9d2..e8d8ceb700 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/data-source/notion/notion-page-selector.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents/online-document-selector.tsx @@ -8,7 +8,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDraftDatasourceNodeRun, usePublishedDatasourceNodeRun } from '@/service/use-pipeline' import { DatasourceType } from '@/models/pipeline' -type NotionPageSelectorProps = { +type OnlineDocumentSelectorProps = { value?: string[] onSelect: (selectedPages: NotionPage[]) => void canPreview?: boolean @@ -23,7 +23,7 @@ type NotionPageSelectorProps = { } } -const NotionPageSelector = ({ +const OnlineDocumentSelector = ({ value, onSelect, canPreview, @@ -32,42 +32,42 @@ const NotionPageSelector = ({ isInPipeline = false, nodeId, headerInfo, -}: NotionPageSelectorProps) => { +}: OnlineDocumentSelectorProps) => { const pipeline_id = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) - const [notionData, setNotionData] = useState([]) + const [documentsData, setDocumentsData] = useState([]) const [searchValue, setSearchValue] = useState('') const [currentWorkspaceId, setCurrentWorkspaceId] = useState('') const useDatasourceNodeRun = useRef(!isInPipeline ? usePublishedDatasourceNodeRun : useDraftDatasourceNodeRun) - const { mutateAsync: getNotionPages } = useDatasourceNodeRun.current() + const { mutateAsync: crawlOnlineDocuments } = useDatasourceNodeRun.current() - const getNotionData = useCallback(async () => { + const getOnlineDocuments = useCallback(async () => { if (pipeline_id) { - await getNotionPages({ + await crawlOnlineDocuments({ pipeline_id, node_id: nodeId, inputs: {}, datasource_type: DatasourceType.onlineDocument, }, { - onSuccess(notionData) { - setNotionData(notionData as DataSourceNotionWorkspace[]) + onSuccess(documentsData) { + setDocumentsData(documentsData as DataSourceNotionWorkspace[]) }, }) } - }, [getNotionPages, nodeId, pipeline_id]) + }, [crawlOnlineDocuments, nodeId, pipeline_id]) useEffect(() => { - getNotionData() + getOnlineDocuments() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const firstWorkspaceId = notionData[0]?.workspace_id - const currentWorkspace = notionData.find(workspace => workspace.workspace_id === currentWorkspaceId) + const firstWorkspaceId = documentsData[0]?.workspace_id + const currentWorkspace = documentsData.find(workspace => workspace.workspace_id === currentWorkspaceId) const PagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set, Set] = useMemo(() => { const selectedPagesId = new Set() const boundPagesId = new Set() - const pagesMap = notionData.reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => { + const pagesMap = documentsData.reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => { next.pages.forEach((page) => { if (page.is_bound) { selectedPagesId.add(page.page_id) @@ -82,7 +82,7 @@ const NotionPageSelector = ({ return prev }, {}) return [pagesMap, selectedPagesId, boundPagesId] - }, [notionData]) + }, [documentsData]) const defaultSelectedPagesId = [...Array.from(PagesMapAndSelectedPagesId[1]), ...(value || [])] const [selectedPagesId, setSelectedPagesId] = useState>(new Set(defaultSelectedPagesId)) @@ -107,7 +107,7 @@ const NotionPageSelector = ({ setCurrentWorkspaceId(firstWorkspaceId) }, [firstWorkspaceId]) - if (!notionData?.length) + if (!documentsData?.length) return null return ( @@ -121,7 +121,7 @@ const NotionPageSelector = ({
@@ -148,4 +148,4 @@ const NotionPageSelector = ({ ) } -export default NotionPageSelector +export default OnlineDocumentSelector diff --git a/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts b/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts index 4a247a9599..20dca3b4bc 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/hooks.ts @@ -101,16 +101,16 @@ export const useLocalFile = () => { } } -export const useNotionPages = () => { - const [notionPages, setNotionPages] = useState([]) +export const useOnlineDocuments = () => { + const [onlineDocuments, setOnlineDocuments] = useState([]) - const updateNotionPages = (value: NotionPage[]) => { - setNotionPages(value) + const updateOnlineDocuments = (value: NotionPage[]) => { + setOnlineDocuments(value) } return { - notionPages, - updateNotionPages, + onlineDocuments, + updateOnlineDocuments, } } diff --git a/web/app/components/rag-pipeline/components/panel/test-run/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/index.tsx index 9eb0e06b4c..b924200922 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/index.tsx @@ -1,10 +1,10 @@ import { useStore as useWorkflowStoreWithSelector } from '@/app/components/workflow/store' import { useCallback, useMemo, useState } from 'react' -import { useLocalFile, useNotionPages, useTestRunSteps, useWebsiteCrawl } from './hooks' +import { useLocalFile, useOnlineDocuments, useTestRunSteps, useWebsiteCrawl } from './hooks' import DataSourceOptions from './data-source-options' import LocalFile from './data-source/local-file' import { useProviderContextSelector } from '@/context/provider-context' -import Notion from './data-source/notion' +import OnlineDocuments from './data-source/online-documents' import VectorSpaceFull from '@/app/components/billing/vector-space-full' import WebsiteCrawl from './data-source/website-crawl' import Actions from './data-source/actions' @@ -35,9 +35,9 @@ const TestRunPanel = () => { updateFileList, } = useLocalFile() const { - notionPages, - updateNotionPages, - } = useNotionPages() + onlineDocuments, + updateOnlineDocuments, + } = useOnlineDocuments() const { websitePages, websiteCrawlJobId, @@ -54,11 +54,11 @@ const TestRunPanel = () => { if (datasource.type === DatasourceType.localFile) return isShowVectorSpaceFull || !fileList.length || fileList.some(file => !file.file.id) if (datasource.type === DatasourceType.onlineDocument) - return isShowVectorSpaceFull || !notionPages.length + return isShowVectorSpaceFull || !onlineDocuments.length if (datasource.type === DatasourceType.websiteCrawl) return isShowVectorSpaceFull || !websitePages.length return false - }, [datasource, isShowVectorSpaceFull, fileList, notionPages.length, websitePages.length]) + }, [datasource, isShowVectorSpaceFull, fileList, onlineDocuments.length, websitePages.length]) const handleClose = () => { setShowDebugAndPreviewPanel(false) @@ -83,7 +83,7 @@ const TestRunPanel = () => { datasourceInfoList.push(documentInfo) } if (datasource.type === DatasourceType.onlineDocument) { - const { workspace_id, ...rest } = notionPages[0] + const { workspace_id, ...rest } = onlineDocuments[0] const documentInfo = { workspace_id, page: rest, @@ -103,7 +103,7 @@ const TestRunPanel = () => { datasource_type: datasource.type, datasource_info_list: datasourceInfoList, }) - }, [datasource, fileList, handleRun, notionPages, websiteCrawlJobId, websitePages]) + }, [datasource, fileList, handleRun, onlineDocuments, websiteCrawlJobId, websitePages]) return (
{ /> )} {datasource?.type === DatasourceType.onlineDocument && ( - )}