mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into deploy/dev
This commit is contained in:
commit
42b6e32574
|
|
@ -0,0 +1,21 @@
|
|||
name: Semantic Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Validate PR title
|
||||
permissions:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check title
|
||||
uses: amannn/action-semantic-pull-request@v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -451,12 +451,21 @@ class RetrievalService:
|
|||
"position": child_chunk.position,
|
||||
"score": document.metadata.get("score", 0.0),
|
||||
}
|
||||
segment_child_map[segment.id]["child_chunks"].append(child_chunk_detail)
|
||||
segment_child_map[segment.id]["max_score"] = max(
|
||||
segment_child_map[segment.id]["max_score"], document.metadata.get("score", 0.0)
|
||||
)
|
||||
if segment.id in segment_child_map:
|
||||
segment_child_map[segment.id]["child_chunks"].append(child_chunk_detail)
|
||||
segment_child_map[segment.id]["max_score"] = max(
|
||||
segment_child_map[segment.id]["max_score"], document.metadata.get("score", 0.0)
|
||||
)
|
||||
else:
|
||||
segment_child_map[segment.id] = {
|
||||
"max_score": document.metadata.get("score", 0.0),
|
||||
"child_chunks": [child_chunk_detail],
|
||||
}
|
||||
if attachment_info:
|
||||
segment_file_map[segment.id].append(attachment_info)
|
||||
if segment.id in segment_file_map:
|
||||
segment_file_map[segment.id].append(attachment_info)
|
||||
else:
|
||||
segment_file_map[segment.id] = [attachment_info]
|
||||
else:
|
||||
# Handle normal documents
|
||||
segment = None
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
|||
if dataset.indexing_technique == "high_quality":
|
||||
vector = Vector(dataset)
|
||||
vector.create(documents)
|
||||
if all_multimodal_documents:
|
||||
if all_multimodal_documents and dataset.is_multimodal:
|
||||
vector.create_multimodal(all_multimodal_documents)
|
||||
elif dataset.indexing_technique == "economy":
|
||||
keyword = Keyword(dataset)
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
|||
vector = Vector(dataset)
|
||||
if all_child_documents:
|
||||
vector.create(all_child_documents)
|
||||
if all_multimodal_documents:
|
||||
if all_multimodal_documents and dataset.is_multimodal:
|
||||
vector.create_multimodal(all_multimodal_documents)
|
||||
|
||||
def format_preview(self, chunks: Any) -> Mapping[str, Any]:
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ class WorkflowToolProviderController(ToolProviderController):
|
|||
session.query(WorkflowToolProvider)
|
||||
.where(
|
||||
WorkflowToolProvider.tenant_id == tenant_id,
|
||||
WorkflowToolProvider.app_id == self.provider_id,
|
||||
WorkflowToolProvider.id == self.provider_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -412,16 +412,20 @@ class Executor:
|
|||
body_string += f"--{boundary}\r\n"
|
||||
body_string += f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
|
||||
# decode content safely
|
||||
try:
|
||||
body_string += content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body_string += content.decode("utf-8", errors="replace")
|
||||
body_string += "\r\n"
|
||||
# Do not decode binary content; use a placeholder with file metadata instead.
|
||||
# Includes filename, size, and MIME type for better logging context.
|
||||
body_string += (
|
||||
f"<file_content_binary: '{file_entry[1][0] or 'unknown'}', "
|
||||
f"type='{file_entry[1][2] if len(file_entry[1]) > 2 else 'unknown'}', "
|
||||
f"size={len(content)} bytes>\r\n"
|
||||
)
|
||||
body_string += f"--{boundary}--\r\n"
|
||||
elif self.node_data.body:
|
||||
if self.content:
|
||||
# If content is bytes, do not decode it; show a placeholder with size.
|
||||
# Provides content size information for binary data without exposing the raw bytes.
|
||||
if isinstance(self.content, bytes):
|
||||
body_string = self.content.decode("utf-8", errors="replace")
|
||||
body_string = f"<binary_content: size={len(self.content)} bytes>"
|
||||
else:
|
||||
body_string = self.content
|
||||
elif self.data and self.node_data.body.type == "x-www-form-urlencoded":
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -673,6 +673,8 @@ class DatasetService:
|
|||
Returns:
|
||||
str: Action to perform ('add', 'remove', 'update', or None)
|
||||
"""
|
||||
if "indexing_technique" not in data:
|
||||
return None
|
||||
if dataset.indexing_technique != data["indexing_technique"]:
|
||||
if data["indexing_technique"] == "economy":
|
||||
# Remove embedding model configuration for economy mode
|
||||
|
|
|
|||
|
|
@ -1337,7 +1337,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ services:
|
|||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.10.1-fix.1
|
||||
image: langgenius/dify-api:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
@ -62,7 +62,7 @@ services:
|
|||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.10.1-fix.1
|
||||
image: langgenius/dify-api:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
@ -101,7 +101,7 @@ services:
|
|||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.10.1-fix.1
|
||||
image: langgenius/dify-api:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
@ -131,7 +131,7 @@ services:
|
|||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.10.1-fix.1
|
||||
image: langgenius/dify-web:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
|
@ -268,7 +268,7 @@ services:
|
|||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.4.1-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.1-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ services:
|
|||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.4.1-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.1-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
|
|
|||
|
|
@ -658,7 +658,7 @@ services:
|
|||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.10.1-fix.1
|
||||
image: langgenius/dify-api:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
@ -699,7 +699,7 @@ services:
|
|||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.10.1-fix.1
|
||||
image: langgenius/dify-api:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
@ -738,7 +738,7 @@ services:
|
|||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.10.1-fix.1
|
||||
image: langgenius/dify-api:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
@ -768,7 +768,7 @@ services:
|
|||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.10.1-fix.1
|
||||
image: langgenius/dify-web:1.11.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
|
@ -905,7 +905,7 @@ services:
|
|||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.4.1-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.1-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { useKeyPress } from 'ahooks'
|
||||
import Divider from '../../base/divider'
|
||||
import Loading from '../../base/loading'
|
||||
import Toast from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
|
|
@ -41,6 +42,7 @@ import type { InputVar, Variable } from '@/app/components/workflow/types'
|
|||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
|
|
@ -49,7 +51,6 @@ import { AppModeEnum } from '@/types/app'
|
|||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { basePath } from '@/utils/var'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
|
|
@ -153,6 +154,7 @@ const AppPublisher = ({
|
|||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
|
|
@ -216,23 +218,20 @@ const AppPublisher = ({
|
|||
setPublished(false)
|
||||
}, [disabled, onToggle, open])
|
||||
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenInExplore = useCallback(() => {
|
||||
if (!appDetail?.id) return
|
||||
|
||||
openAsync(
|
||||
async () => {
|
||||
const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(appDetail.id) || {}
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
const handleOpenInExplore = useCallback(async () => {
|
||||
await openAsyncWindow(async () => {
|
||||
if (!appDetail?.id)
|
||||
throw new Error('App not found')
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
Toast.notify({ type: 'error', message: `${err.message || err}` })
|
||||
},
|
||||
{
|
||||
errorMessage: 'Failed to open app in Explore',
|
||||
},
|
||||
)
|
||||
}, [appDetail?.id, openAsync])
|
||||
})
|
||||
}, [appDetail?.id, openAsyncWindow])
|
||||
|
||||
const handleAccessControlUpdate = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Toast, { ToastContext } from '@/app/components/base/toast'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
|
@ -27,11 +27,11 @@ import { fetchWorkflowDraft } from '@/service/workflow'
|
|||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
|
|
@ -65,6 +65,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const { push } = useRouter()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
|
|
@ -243,24 +244,25 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||
e.preventDefault()
|
||||
setShowAccessControl(true)
|
||||
}
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const onClickInstalledApp = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
|
||||
openAsync(
|
||||
async () => {
|
||||
const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
try {
|
||||
await openAsyncWindow(async () => {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
},
|
||||
{
|
||||
errorMessage: 'Failed to open app in Explore',
|
||||
},
|
||||
)
|
||||
}, {
|
||||
onError: (err) => {
|
||||
Toast.notify({ type: 'error', message: `${err.message || err}` })
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import NotionIcon from '../../notion-icon'
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
|
||||
export type NotionCredential = {
|
||||
credentialId: string
|
||||
|
|
@ -23,14 +22,10 @@ const CredentialSelector = ({
|
|||
items,
|
||||
onSelect,
|
||||
}: CredentialSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const currentCredential = items.find(item => item.credentialId === value)!
|
||||
|
||||
const getDisplayName = (item: NotionCredential) => {
|
||||
return item.workspaceName || t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: item.credentialName,
|
||||
pluginName: 'Notion',
|
||||
})
|
||||
return item.workspaceName || item.credentialName
|
||||
}
|
||||
|
||||
const currentDisplayName = useMemo(() => {
|
||||
|
|
@ -43,10 +38,11 @@ const CredentialSelector = ({
|
|||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}>
|
||||
<NotionIcon
|
||||
<CredentialIcon
|
||||
className='mr-2'
|
||||
src={currentCredential?.workspaceIcon}
|
||||
avatarUrl={currentCredential?.workspaceIcon}
|
||||
name={currentDisplayName}
|
||||
size={20}
|
||||
/>
|
||||
<div
|
||||
className='mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary'
|
||||
|
|
@ -80,10 +76,11 @@ const CredentialSelector = ({
|
|||
className='flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={() => onSelect(item.credentialId)}
|
||||
>
|
||||
<NotionIcon
|
||||
<CredentialIcon
|
||||
className='mr-2 shrink-0'
|
||||
src={item.workspaceIcon}
|
||||
avatarUrl={item.workspaceIcon}
|
||||
name={displayName}
|
||||
size={20}
|
||||
/>
|
||||
<div
|
||||
className='system-sm-medium mr-2 grow truncate text-text-secondary'
|
||||
|
|
|
|||
|
|
@ -9,33 +9,28 @@ import PlanComp from '../plan'
|
|||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useBillingUrl } from '@/service/use-billing'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
|
||||
const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { enableBilling } = useProviderContext()
|
||||
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenBilling = async () => {
|
||||
// Open synchronously to preserve user gesture for popup blockers
|
||||
if (billingUrl) {
|
||||
window.open(billingUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
const newWindow = window.open('', '_blank', 'noopener,noreferrer')
|
||||
try {
|
||||
await openAsyncWindow(async () => {
|
||||
const url = (await refetch()).data
|
||||
if (url && newWindow) {
|
||||
newWindow.location.href = url
|
||||
return
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to fetch billing url', err)
|
||||
}
|
||||
// Close the placeholder window if we failed to fetch the URL
|
||||
newWindow?.close()
|
||||
if (url)
|
||||
return url
|
||||
return null
|
||||
}, {
|
||||
immediateUrl: billingUrl,
|
||||
features: 'noopener,noreferrer',
|
||||
onError: (err) => {
|
||||
console.error('Failed to fetch billing url', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
const isCurrentPaidPlan = isCurrent && !isFreePlan
|
||||
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const btnText = useMemo(() => {
|
||||
if (isCurrent)
|
||||
|
|
@ -55,8 +56,6 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
})[plan]
|
||||
}, [isCurrent, plan, t])
|
||||
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const handleGetPayUrl = async () => {
|
||||
if (loading)
|
||||
return
|
||||
|
|
@ -75,13 +74,16 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
setLoading(true)
|
||||
try {
|
||||
if (isCurrentPaidPlan) {
|
||||
await openAsync(
|
||||
() => fetchBillingUrl().then(res => res.url),
|
||||
{
|
||||
errorMessage: 'Failed to open billing page',
|
||||
windowFeatures: 'noopener,noreferrer',
|
||||
await openAsyncWindow(async () => {
|
||||
const res = await fetchBillingUrl()
|
||||
if (res.url)
|
||||
return res.url
|
||||
throw new Error('Failed to open billing page')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
Toast.notify({ type: 'error', message: err.message || String(err) })
|
||||
},
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import cn from '@/utils/classnames'
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
type CredentialIconProps = {
|
||||
avatar_url?: string
|
||||
avatarUrl?: string
|
||||
name: string
|
||||
size?: number
|
||||
className?: string
|
||||
|
|
@ -16,12 +16,12 @@ const ICON_BG_COLORS = [
|
|||
]
|
||||
|
||||
export const CredentialIcon: React.FC<CredentialIconProps> = ({
|
||||
avatar_url,
|
||||
avatarUrl,
|
||||
name,
|
||||
size = 20,
|
||||
className = '',
|
||||
}) => {
|
||||
const [showAvatar, setShowAvatar] = useState(!!avatar_url && avatar_url !== 'default')
|
||||
const [showAvatar, setShowAvatar] = useState(!!avatarUrl && avatarUrl !== 'default')
|
||||
const firstLetter = useMemo(() => name.charAt(0).toUpperCase(), [name])
|
||||
const bgColor = useMemo(() => ICON_BG_COLORS[firstLetter.charCodeAt(0) % ICON_BG_COLORS.length], [firstLetter])
|
||||
|
||||
|
|
@ -29,17 +29,20 @@ export const CredentialIcon: React.FC<CredentialIconProps> = ({
|
|||
setShowAvatar(false)
|
||||
}, [])
|
||||
|
||||
if (avatar_url && avatar_url !== 'default' && showAvatar) {
|
||||
if (avatarUrl && avatarUrl !== 'default' && showAvatar) {
|
||||
return (
|
||||
<div
|
||||
className='flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular'
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular',
|
||||
className,
|
||||
)}
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
>
|
||||
<img
|
||||
src={avatar_url}
|
||||
src={avatarUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0 object-contain', className)}
|
||||
className='shrink-0 object-contain'
|
||||
onError={onImgLoadError}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ const StepOne = ({
|
|||
crawlOptions={crawlOptions}
|
||||
onCrawlOptionsChange={onCrawlOptionsChange}
|
||||
authedDataSourceList={authedDataSourceList}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
</div>
|
||||
{isShowVectorSpaceFull && (
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ import Trigger from './trigger'
|
|||
import List from './list'
|
||||
|
||||
export type CredentialSelectorProps = {
|
||||
pluginName: string
|
||||
currentCredentialId: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
credentials: Array<DataSourceCredential>
|
||||
}
|
||||
|
||||
const CredentialSelector = ({
|
||||
pluginName,
|
||||
currentCredentialId,
|
||||
onCredentialChange,
|
||||
credentials,
|
||||
|
|
@ -50,7 +48,6 @@ const CredentialSelector = ({
|
|||
<PortalToFollowElemTrigger onClick={toggle} className='grow overflow-hidden'>
|
||||
<Trigger
|
||||
currentCredential={currentCredential}
|
||||
pluginName={pluginName}
|
||||
isOpen={open}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
|
|
@ -58,7 +55,6 @@ const CredentialSelector = ({
|
|||
<List
|
||||
currentCredentialId={currentCredentialId}
|
||||
credentials={credentials}
|
||||
pluginName={pluginName}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
|
|
|
|||
|
|
@ -2,22 +2,18 @@ import { CredentialIcon } from '@/app/components/datasets/common/credential-icon
|
|||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ItemProps = {
|
||||
credential: DataSourceCredential
|
||||
pluginName: string
|
||||
isSelected: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const Item = ({
|
||||
credential,
|
||||
pluginName,
|
||||
isSelected,
|
||||
onCredentialChange,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { avatar_url, name } = credential
|
||||
|
||||
const handleCredentialChange = useCallback(() => {
|
||||
|
|
@ -30,15 +26,12 @@ const Item = ({
|
|||
onClick={handleCredentialChange}
|
||||
>
|
||||
<CredentialIcon
|
||||
avatar_url={avatar_url}
|
||||
avatarUrl={avatar_url}
|
||||
name={name}
|
||||
size={20}
|
||||
/>
|
||||
<span className='system-sm-medium grow truncate text-text-secondary'>
|
||||
{t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: name,
|
||||
pluginName,
|
||||
})}
|
||||
{name}
|
||||
</span>
|
||||
{
|
||||
isSelected && (
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@ import Item from './item'
|
|||
type ListProps = {
|
||||
currentCredentialId: string
|
||||
credentials: Array<DataSourceCredential>
|
||||
pluginName: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const List = ({
|
||||
currentCredentialId,
|
||||
credentials,
|
||||
pluginName,
|
||||
onCredentialChange,
|
||||
}: ListProps) => {
|
||||
return (
|
||||
|
|
@ -24,7 +22,6 @@ const List = ({
|
|||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
pluginName={pluginName}
|
||||
isSelected={isSelected}
|
||||
onCredentialChange={onCredentialChange}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,18 @@
|
|||
import React from 'react'
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
|
||||
type TriggerProps = {
|
||||
currentCredential: DataSourceCredential | undefined
|
||||
pluginName: string
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const Trigger = ({
|
||||
currentCredential,
|
||||
pluginName,
|
||||
isOpen,
|
||||
}: TriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
avatar_url,
|
||||
name = '',
|
||||
|
|
@ -31,16 +26,13 @@ const Trigger = ({
|
|||
)}
|
||||
>
|
||||
<CredentialIcon
|
||||
avatar_url={avatar_url}
|
||||
avatarUrl={avatar_url}
|
||||
name={name}
|
||||
size={20}
|
||||
/>
|
||||
<div className='flex grow items-center gap-x-1 overflow-hidden'>
|
||||
<span className='system-md-semibold grow truncate text-text-secondary'>
|
||||
{t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: name,
|
||||
pluginName,
|
||||
})}
|
||||
{name}
|
||||
</span>
|
||||
<RiArrowDownSLine className='size-4 shrink-0 text-text-secondary' />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ type HeaderProps = {
|
|||
docTitle: string
|
||||
docLink: string
|
||||
onClickConfiguration?: () => void
|
||||
pluginName: string
|
||||
} & CredentialSelectorProps
|
||||
|
||||
const Header = ({
|
||||
docTitle,
|
||||
docLink,
|
||||
onClickConfiguration,
|
||||
pluginName,
|
||||
...rest
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -29,7 +31,7 @@ const Header = ({
|
|||
/>
|
||||
<Divider type='vertical' className='mx-1 h-3.5 shrink-0' />
|
||||
<Tooltip
|
||||
popupContent={t('datasetPipeline.configurationTip', { pluginName: rest.pluginName })}
|
||||
popupContent={t('datasetPipeline.configurationTip', { pluginName })}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ export const isSystemVar = (valueSelector: ValueSelector) => {
|
|||
}
|
||||
|
||||
export const isGlobalVar = (valueSelector: ValueSelector) => {
|
||||
if(!isSystemVar(valueSelector)) return false
|
||||
if (!isSystemVar(valueSelector)) return false
|
||||
const second = valueSelector[1]
|
||||
|
||||
if(['query', 'files'].includes(second))
|
||||
if (['query', 'files'].includes(second))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
|
@ -1296,7 +1296,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
|||
case BlockEnum.KnowledgeRetrieval: {
|
||||
const {
|
||||
query_variable_selector,
|
||||
query_attachment_selector,
|
||||
query_attachment_selector = [],
|
||||
} = data as KnowledgeRetrievalNodeType
|
||||
res = [query_variable_selector, query_attachment_selector]
|
||||
break
|
||||
|
|
@ -1638,7 +1638,7 @@ export const updateNodeVars = (
|
|||
)
|
||||
payload.query_variable_selector = newVarSelector
|
||||
if (
|
||||
payload.query_attachment_selector.join('.') === oldVarSelector.join('.')
|
||||
payload.query_attachment_selector?.join('.') === oldVarSelector.join('.')
|
||||
)
|
||||
payload.query_attachment_selector = newVarSelector
|
||||
break
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const useSingleRunFormParams = ({
|
|||
},
|
||||
]
|
||||
if (hasMultiModalDatasets) {
|
||||
const currentVariable = findVariableWhenOnLLMVision(payload.query_attachment_selector, availableFileVars)
|
||||
const currentVariable = findVariableWhenOnLLMVision(payload.query_attachment_selector || [], availableFileVars)
|
||||
inputFields.push(
|
||||
{
|
||||
inputs: [{
|
||||
|
|
@ -98,13 +98,13 @@ const useSingleRunFormParams = ({
|
|||
}, [query, setQuery, t, datasetsDetail, payload.dataset_ids, payload.query_attachment_selector, availableFileVars, queryAttachment, setQueryAttachment])
|
||||
|
||||
const getDependentVars = () => {
|
||||
return [payload.query_variable_selector, payload.query_attachment_selector]
|
||||
return [payload.query_variable_selector, payload.query_attachment_selector || []]
|
||||
}
|
||||
const getDependentVar = (variable: string) => {
|
||||
if (variable === 'query')
|
||||
return payload.query_variable_selector
|
||||
if (variable === 'queryAttachment')
|
||||
return payload.query_attachment_selector
|
||||
return payload.query_attachment_selector || []
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useAsyncWindowOpen } from './use-async-window-open'
|
||||
|
||||
describe('useAsyncWindowOpen', () => {
|
||||
const originalOpen = window.open
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
window.open = originalOpen
|
||||
})
|
||||
|
||||
it('opens immediate url synchronously, clears opener, without calling async getter', async () => {
|
||||
const mockWindow: any = { opener: 'should-clear' }
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const getUrl = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(getUrl, {
|
||||
immediateUrl: 'https://example.com',
|
||||
target: '_blank',
|
||||
features: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer')
|
||||
expect(getUrl).not.toHaveBeenCalled()
|
||||
expect(mockWindow.opener).toBeNull()
|
||||
})
|
||||
|
||||
it('appends noopener,noreferrer when immediate open passes custom features', async () => {
|
||||
const mockWindow: any = { opener: 'should-clear' }
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const getUrl = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(getUrl, {
|
||||
immediateUrl: 'https://example.com',
|
||||
target: '_blank',
|
||||
features: 'width=500',
|
||||
})
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'width=500,noopener,noreferrer')
|
||||
expect(getUrl).not.toHaveBeenCalled()
|
||||
expect(mockWindow.opener).toBeNull()
|
||||
})
|
||||
|
||||
it('reports error when immediate window fails to open', async () => {
|
||||
const openSpy = jest.fn(() => null)
|
||||
window.open = openSpy
|
||||
const getUrl = jest.fn()
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(getUrl, {
|
||||
immediateUrl: 'https://example.com',
|
||||
target: '_blank',
|
||||
onError,
|
||||
})
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
const errArg = onError.mock.calls[0][0] as Error
|
||||
expect(errArg.message).toBe('Failed to open new window')
|
||||
expect(getUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets opener to null and redirects when async url resolves', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: 'should-be-cleared',
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(async () => 'https://example.com/path')
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank', undefined)
|
||||
expect(mockWindow.opener).toBeNull()
|
||||
expect(mockWindow.location.href).toBe('https://example.com/path')
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes placeholder and forwards error when async getter throws', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: null,
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
const error = new Error('fetch failed')
|
||||
await act(async () => {
|
||||
await result.current(async () => {
|
||||
throw error
|
||||
}, { onError })
|
||||
})
|
||||
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
expect(mockWindow.location.href).toBe('')
|
||||
})
|
||||
|
||||
it('preserves custom features as-is for async open', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: 'should-be-cleared',
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(async () => 'https://example.com/path', {
|
||||
target: '_blank',
|
||||
features: 'width=500',
|
||||
})
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank', 'width=500')
|
||||
expect(mockWindow.opener).toBeNull()
|
||||
expect(mockWindow.location.href).toBe('https://example.com/path')
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes placeholder and reports when no url is returned', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: null,
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(async () => null, { onError })
|
||||
})
|
||||
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalled()
|
||||
const errArg = onError.mock.calls[0][0] as Error
|
||||
expect(errArg.message).toBe('No url resolved for new window')
|
||||
})
|
||||
|
||||
it('reports failure when window.open returns null', async () => {
|
||||
const openSpy = jest.fn(() => null)
|
||||
window.open = openSpy
|
||||
const getUrl = jest.fn()
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(getUrl, { onError })
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
const errArg = onError.mock.calls[0][0] as Error
|
||||
expect(errArg.message).toBe('Failed to open new window')
|
||||
expect(getUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,76 +1,59 @@
|
|||
import { useCallback } from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
export type AsyncWindowOpenOptions = {
|
||||
successMessage?: string
|
||||
errorMessage?: string
|
||||
windowFeatures?: string
|
||||
onError?: (error: any) => void
|
||||
onSuccess?: (url: string) => void
|
||||
type GetUrl = () => Promise<string | null | undefined>
|
||||
|
||||
type AsyncWindowOpenOptions = {
|
||||
immediateUrl?: string | null
|
||||
target?: string
|
||||
features?: string
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export const useAsyncWindowOpen = () => {
|
||||
const openAsync = useCallback(async (
|
||||
fetchUrl: () => Promise<string>,
|
||||
options: AsyncWindowOpenOptions = {},
|
||||
) => {
|
||||
const {
|
||||
successMessage,
|
||||
errorMessage = 'Failed to open page',
|
||||
windowFeatures = 'noopener',
|
||||
onError,
|
||||
onSuccess,
|
||||
} = options
|
||||
export const useAsyncWindowOpen = () => useCallback(async (getUrl: GetUrl, options?: AsyncWindowOpenOptions) => {
|
||||
const {
|
||||
immediateUrl,
|
||||
target = '_blank',
|
||||
features,
|
||||
onError,
|
||||
} = options ?? {}
|
||||
|
||||
const newWindow = window.open('', '_blank', windowFeatures)
|
||||
const secureImmediateFeatures = features ? `${features},noopener,noreferrer` : 'noopener,noreferrer'
|
||||
|
||||
if (immediateUrl) {
|
||||
const newWindow = window.open(immediateUrl, target, secureImmediateFeatures)
|
||||
if (!newWindow) {
|
||||
const error = new Error('Failed to open new window')
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
onError?.(new Error('Failed to open new window'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await fetchUrl()
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
newWindow.opener = null
|
||||
}
|
||||
catch { /* noop */ }
|
||||
newWindow.location.href = url
|
||||
onSuccess?.(url)
|
||||
|
||||
if (successMessage) {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: successMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
newWindow?.close()
|
||||
const error = new Error('Invalid URL received')
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
newWindow.opener = null
|
||||
}
|
||||
catch (error) {
|
||||
newWindow?.close()
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
catch { /* noop */ }
|
||||
return
|
||||
}
|
||||
|
||||
return { openAsync }
|
||||
}
|
||||
const newWindow = window.open('about:blank', target, features)
|
||||
if (!newWindow) {
|
||||
onError?.(new Error('Failed to open new window'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
newWindow.opener = null
|
||||
}
|
||||
catch { /* noop */ }
|
||||
|
||||
try {
|
||||
const url = await getUrl()
|
||||
if (url) {
|
||||
newWindow.location.href = url
|
||||
return
|
||||
}
|
||||
newWindow.close()
|
||||
onError?.(new Error('No url resolved for new window'))
|
||||
}
|
||||
catch (error) {
|
||||
newWindow.close()
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} ist nicht verbunden',
|
||||
notConnectedTip: 'Um mit {{name}} zu synchronisieren, muss zuerst eine Verbindung zu {{name}} hergestellt werden.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Bestätigung',
|
||||
|
|
|
|||
|
|
@ -145,9 +145,6 @@ const translation = {
|
|||
emptySearchResult: 'No items were found',
|
||||
resetKeywords: 'Reset keywords',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
configurationTip: 'Configure {{pluginName}}',
|
||||
conversion: {
|
||||
title: 'Convert to Knowledge Pipeline',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} no está conectado',
|
||||
notConnectedTip: 'Para sincronizar con {{name}}, primero se debe establecer conexión con {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}} de {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Confirmación',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} متصل نیست',
|
||||
notConnectedTip: 'برای همگامسازی با {{name}}، ابتدا باید اتصال به {{name}} برقرار شود.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{pluginName}} {{credentialName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'تایید',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} n\'est pas connecté',
|
||||
notConnectedTip: 'Pour se synchroniser avec {{name}}, une connexion à {{name}} doit d\'abord être établie.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}} de {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Confirmation',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} कनेक्ट नहीं है',
|
||||
notConnectedTip: '{{name}} के साथ सिंक करने के लिए, पहले {{name}} से कनेक्शन स्थापित करना आवश्यक है।',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}} का {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'पुष्टि',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} tidak terhubung',
|
||||
notConnectedTip: 'Untuk menyinkronkan dengan {{name}}, koneksi ke {{name}} harus dibuat terlebih dahulu.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Konfirmasi',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} non è connesso',
|
||||
notConnectedTip: 'Per sincronizzarsi con {{name}}, è necessario prima stabilire la connessione a {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
content: 'Questa azione è permanente. Non sarà possibile ripristinare il metodo precedente. Si prega di confermare per convertire.',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
emptySearchResult: 'アイテムは見つかりませんでした',
|
||||
resetKeywords: 'キーワードをリセットする',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}の{{pluginName}}',
|
||||
},
|
||||
configurationTip: '{{pluginName}}を設定',
|
||||
conversion: {
|
||||
confirm: {
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}}가 연결되어 있지 않습니다',
|
||||
notConnectedTip: '{{name}}와(과) 동기화하려면 먼저 {{name}}에 연결해야 합니다.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}의 {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: '확인',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} nie jest połączony',
|
||||
notConnectedTip: 'Aby zsynchronizować się z {{name}}, najpierw należy nawiązać połączenie z {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Potwierdzenie',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} não está conectado',
|
||||
notConnectedTip: 'Para sincronizar com {{name}}, a conexão com {{name}} deve ser estabelecida primeiro.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}} de {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Confirmação',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} nu este conectat',
|
||||
notConnectedTip: 'Pentru a sincroniza cu {{name}}, trebuie mai întâi să se stabilească conexiunea cu {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{pluginName}} al/a lui {{credentialName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Confirmare',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} не подключен',
|
||||
notConnectedTip: 'Чтобы синхронизироваться с {{name}}, сначала необходимо установить соединение с {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Подтверждение',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} ni povezan',
|
||||
notConnectedTip: 'Za sinhronizacijo z {{name}} je treba najprej vzpostaviti povezavo z {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Potrditev',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} ไม่ได้เชื่อมต่อ',
|
||||
notConnectedTip: 'เพื่อซิงค์กับ {{name}} ต้องสร้างการเชื่อมต่อกับ {{name}} ก่อน',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'การยืนยัน',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} bağlı değil',
|
||||
notConnectedTip: '{{name}} ile senkronize olmak için önce {{name}} bağlantısının kurulması gerekir.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'un {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Onay',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} не підключено',
|
||||
notConnectedTip: 'Щоб синхронізувати з {{name}}, спершу потрібно встановити з’єднання з {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Підтвердження',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} không được kết nối',
|
||||
notConnectedTip: 'Để đồng bộ với {{name}}, trước tiên phải thiết lập kết nối với {{name}}.',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: 'Sự xác nhận',
|
||||
|
|
|
|||
|
|
@ -145,9 +145,6 @@ const translation = {
|
|||
emptySearchResult: '未找到任何项目',
|
||||
resetKeywords: '重置关键词',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}} 的 {{pluginName}}',
|
||||
},
|
||||
configurationTip: '配置 {{pluginName}}',
|
||||
conversion: {
|
||||
title: '转换为知识流水线',
|
||||
|
|
|
|||
|
|
@ -137,9 +137,6 @@ const translation = {
|
|||
notConnected: '{{name}} 未連接',
|
||||
notConnectedTip: '要與 {{name}} 同步,必須先建立與 {{name}} 的連線。',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}的{{pluginName}}',
|
||||
},
|
||||
conversion: {
|
||||
confirm: {
|
||||
title: '證實',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dify-web",
|
||||
"version": "1.10.1",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
||||
"engines": {
|
||||
|
|
@ -184,6 +184,7 @@
|
|||
"@types/semver": "^7.7.1",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-loader": "^10.0.0",
|
||||
|
|
|
|||
1466
web/pnpm-lock.yaml
1466
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { Linter } = require('eslint')
|
||||
const sonarPlugin = require('eslint-plugin-sonarjs')
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
|
||||
// ============================================================================
|
||||
// Simple Analyzer
|
||||
|
|
@ -12,7 +15,11 @@ class ComponentAnalyzer {
|
|||
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
|
||||
const fileName = path.basename(filePath, path.extname(filePath))
|
||||
const lineCount = code.split('\n').length
|
||||
const complexity = this.calculateComplexity(code, lineCount)
|
||||
|
||||
// Calculate complexity metrics
|
||||
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
|
||||
const complexity = this.normalizeComplexity(rawComplexity)
|
||||
const maxComplexity = this.normalizeComplexity(rawMaxComplexity)
|
||||
|
||||
// Count usage references (may take a few seconds)
|
||||
const usageCount = this.countUsageReferences(filePath, resolvedPath)
|
||||
|
|
@ -41,6 +48,9 @@ class ComponentAnalyzer {
|
|||
hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
|
||||
hasAhooks: code.includes("from 'ahooks'"),
|
||||
complexity,
|
||||
maxComplexity,
|
||||
rawComplexity,
|
||||
rawMaxComplexity,
|
||||
lineCount,
|
||||
usageCount,
|
||||
priority,
|
||||
|
|
@ -64,193 +74,96 @@ class ComponentAnalyzer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Calculate component complexity score
|
||||
* Based on Cognitive Complexity + React-specific metrics
|
||||
* Calculate Cognitive Complexity using SonarJS ESLint plugin
|
||||
* Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/
|
||||
*
|
||||
* Score Ranges:
|
||||
* 0-10: 🟢 Simple (5-10 min to test)
|
||||
* 11-30: 🟡 Medium (15-30 min to test)
|
||||
* 31-50: 🟠 Complex (30-60 min to test)
|
||||
* 51+: 🔴 Very Complex (60+ min, consider splitting)
|
||||
* Returns raw (unnormalized) complexity values:
|
||||
* - total: sum of all functions' complexity in the file
|
||||
* - max: highest single function complexity in the file
|
||||
*
|
||||
* Raw Score Thresholds (per function):
|
||||
* 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex
|
||||
*
|
||||
* @returns {{ total: number, max: number }} raw total and max complexity
|
||||
*/
|
||||
calculateComplexity(code, lineCount) {
|
||||
let score = 0
|
||||
calculateCognitiveComplexity(code) {
|
||||
const linter = new Linter()
|
||||
const baseConfig = {
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
plugins: { sonarjs: sonarPlugin },
|
||||
}
|
||||
|
||||
const count = pattern => this.countMatches(code, pattern)
|
||||
try {
|
||||
// Get total complexity using 'metric' option (more stable)
|
||||
const totalConfig = {
|
||||
...baseConfig,
|
||||
rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] },
|
||||
}
|
||||
const totalMessages = linter.verify(code, totalConfig)
|
||||
const totalMsg = totalMessages.find(
|
||||
msg => msg.ruleId === 'sonarjs/cognitive-complexity'
|
||||
&& msg.messageId === 'fileComplexity',
|
||||
)
|
||||
const total = totalMsg ? parseInt(totalMsg.message, 10) : 0
|
||||
|
||||
// ===== React Hooks (State Management Complexity) =====
|
||||
const stateHooks = count(/useState/g)
|
||||
const reducerHooks = count(/useReducer/g)
|
||||
const effectHooks = count(/useEffect/g)
|
||||
const callbackHooks = count(/useCallback/g)
|
||||
const memoHooks = count(/useMemo/g)
|
||||
const refHooks = count(/useRef/g)
|
||||
const imperativeHandleHooks = count(/useImperativeHandle/g)
|
||||
// Get max function complexity by analyzing each function
|
||||
const maxConfig = {
|
||||
...baseConfig,
|
||||
rules: { 'sonarjs/cognitive-complexity': ['error', 0] },
|
||||
}
|
||||
const maxMessages = linter.verify(code, maxConfig)
|
||||
let max = 0
|
||||
const complexityPattern = /reduce its Cognitive Complexity from (\d+)/
|
||||
|
||||
const builtinHooks = stateHooks + reducerHooks + effectHooks
|
||||
+ callbackHooks + memoHooks + refHooks + imperativeHandleHooks
|
||||
const totalHooks = count(/use[A-Z]\w+/g)
|
||||
const customHooks = Math.max(0, totalHooks - builtinHooks)
|
||||
maxMessages.forEach((msg) => {
|
||||
if (msg.ruleId === 'sonarjs/cognitive-complexity') {
|
||||
const match = msg.message.match(complexityPattern)
|
||||
if (match && match[1])
|
||||
max = Math.max(max, parseInt(match[1], 10))
|
||||
}
|
||||
})
|
||||
|
||||
score += stateHooks * 5 // Each state +5 (need to test state changes)
|
||||
score += reducerHooks * 6 // Each reducer +6 (complex state management)
|
||||
score += effectHooks * 6 // Each effect +6 (need to test deps & cleanup)
|
||||
score += callbackHooks * 2 // Each callback +2
|
||||
score += memoHooks * 2 // Each memo +2
|
||||
score += refHooks * 1 // Each ref +1
|
||||
score += imperativeHandleHooks * 4 // Each imperative handle +4 (exposes methods)
|
||||
score += customHooks * 3 // Each custom hook +3
|
||||
|
||||
// ===== Control Flow Complexity (Cyclomatic Complexity) =====
|
||||
score += count(/if\s*\(/g) * 2 // if statement
|
||||
score += count(/else\s+if/g) * 2 // else if
|
||||
score += count(/\?\s*[^:]+\s*:/g) * 1 // ternary operator
|
||||
score += count(/switch\s*\(/g) * 3 // switch
|
||||
score += count(/case\s+/g) * 1 // case branch
|
||||
score += count(/&&/g) * 1 // logical AND
|
||||
score += count(/\|\|/g) * 1 // logical OR
|
||||
score += count(/\?\?/g) * 1 // nullish coalescing
|
||||
|
||||
// ===== Loop Complexity =====
|
||||
score += count(/\.map\(/g) * 2 // map
|
||||
score += count(/\.filter\(/g) * 1 // filter
|
||||
score += count(/\.reduce\(/g) * 3 // reduce (complex)
|
||||
score += count(/for\s*\(/g) * 2 // for loop
|
||||
score += count(/while\s*\(/g) * 3 // while loop
|
||||
|
||||
// ===== Props and Events Complexity =====
|
||||
// Count unique props from interface/type definitions only (avoid duplicates)
|
||||
const propsCount = this.countUniqueProps(code)
|
||||
score += Math.floor(propsCount / 2) // Every 2 props +1
|
||||
|
||||
// Count unique event handler names (avoid duplicates from type defs, params, usage)
|
||||
const uniqueEventHandlers = this.countUniqueEventHandlers(code)
|
||||
score += uniqueEventHandlers * 2 // Each unique event handler +2
|
||||
|
||||
// ===== API Call Complexity =====
|
||||
score += count(/fetch\(/g) * 4 // fetch
|
||||
score += count(/axios\./g) * 4 // axios
|
||||
score += count(/useSWR/g) * 4 // SWR
|
||||
score += count(/useQuery/g) * 4 // React Query
|
||||
score += count(/\.then\(/g) * 2 // Promise
|
||||
score += count(/await\s+/g) * 2 // async/await
|
||||
|
||||
// ===== Third-party Library Integration =====
|
||||
// Only count complex UI libraries that require integration testing
|
||||
// Data fetching libs (swr, react-query, ahooks) don't add complexity
|
||||
// because they are already well-tested; we only need to mock them
|
||||
const complexUILibs = [
|
||||
{ pattern: /reactflow|ReactFlow/, weight: 15 },
|
||||
{ pattern: /@monaco-editor/, weight: 12 },
|
||||
{ pattern: /echarts/, weight: 8 },
|
||||
{ pattern: /lexical/, weight: 10 },
|
||||
]
|
||||
|
||||
complexUILibs.forEach(({ pattern, weight }) => {
|
||||
if (pattern.test(code)) score += weight
|
||||
})
|
||||
|
||||
// ===== Code Size Complexity =====
|
||||
if (lineCount > 500) score += 10
|
||||
else if (lineCount > 300) score += 6
|
||||
else if (lineCount > 150) score += 3
|
||||
|
||||
// ===== Nesting Depth (deep nesting reduces readability) =====
|
||||
const maxNesting = this.calculateNestingDepth(code)
|
||||
score += Math.max(0, (maxNesting - 3)) * 2 // Over 3 levels, +2 per level
|
||||
|
||||
// ===== Context and Global State =====
|
||||
score += count(/useContext/g) * 3
|
||||
score += count(/useStore|useAppStore/g) * 4
|
||||
score += count(/zustand|redux/g) * 3
|
||||
|
||||
// ===== React Advanced Features =====
|
||||
score += count(/React\.memo|memo\(/g) * 2 // Component memoization
|
||||
score += count(/forwardRef/g) * 3 // Ref forwarding
|
||||
score += count(/Suspense/g) * 4 // Suspense boundaries
|
||||
score += count(/\blazy\(/g) * 3 // Lazy loading
|
||||
score += count(/createPortal/g) * 3 // Portal rendering
|
||||
|
||||
return Math.min(score, 100) // Max 100 points
|
||||
return { total, max }
|
||||
}
|
||||
catch {
|
||||
return { total: 0, max: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate maximum nesting depth
|
||||
* Normalize cognitive complexity to 0-100 scale
|
||||
*
|
||||
* Mapping (aligned with SonarJS thresholds):
|
||||
* Raw 0-15 (Simple) -> Normalized 0-25
|
||||
* Raw 16-30 (Medium) -> Normalized 25-50
|
||||
* Raw 31-50 (Complex) -> Normalized 50-75
|
||||
* Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic)
|
||||
*/
|
||||
calculateNestingDepth(code) {
|
||||
let maxDepth = 0
|
||||
let currentDepth = 0
|
||||
let inString = false
|
||||
let stringChar = ''
|
||||
let escapeNext = false
|
||||
let inSingleLineComment = false
|
||||
let inMultiLineComment = false
|
||||
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const char = code[i]
|
||||
const nextChar = code[i + 1]
|
||||
|
||||
if (inSingleLineComment) {
|
||||
if (char === '\n') inSingleLineComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (inMultiLineComment) {
|
||||
if (char === '*' && nextChar === '/') {
|
||||
inMultiLineComment = false
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (escapeNext) {
|
||||
escapeNext = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escapeNext = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === stringChar) {
|
||||
inString = false
|
||||
stringChar = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && nextChar === '/') {
|
||||
inSingleLineComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && nextChar === '*') {
|
||||
inMultiLineComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === '\'' || char === '`') {
|
||||
inString = true
|
||||
stringChar = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
currentDepth++
|
||||
maxDepth = Math.max(maxDepth, currentDepth)
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '}') {
|
||||
currentDepth = Math.max(currentDepth - 1, 0)
|
||||
}
|
||||
normalizeComplexity(rawComplexity) {
|
||||
if (rawComplexity <= 15) {
|
||||
// Linear: 0-15 -> 0-25
|
||||
return Math.round((rawComplexity / 15) * 25)
|
||||
}
|
||||
else if (rawComplexity <= 30) {
|
||||
// Linear: 16-30 -> 25-50
|
||||
return Math.round(25 + ((rawComplexity - 15) / 15) * 25)
|
||||
}
|
||||
else if (rawComplexity <= 50) {
|
||||
// Linear: 31-50 -> 50-75
|
||||
return Math.round(50 + ((rawComplexity - 30) / 20) * 25)
|
||||
}
|
||||
else {
|
||||
// Asymptotic: 51+ -> 75-100
|
||||
// Formula ensures score approaches but never exceeds 100
|
||||
return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100)))
|
||||
}
|
||||
|
||||
return maxDepth
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -379,86 +292,41 @@ class ComponentAnalyzer {
|
|||
return true
|
||||
}
|
||||
|
||||
countMatches(code, pattern) {
|
||||
const matches = code.match(pattern)
|
||||
return matches ? matches.length : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unique props from interface/type definitions
|
||||
* Only counts props defined in type/interface blocks, not usage
|
||||
*/
|
||||
countUniqueProps(code) {
|
||||
const uniqueProps = new Set()
|
||||
|
||||
// Match interface or type definition blocks
|
||||
const typeBlockPattern = /(?:interface|type)\s+\w*Props[^{]*\{([^}]+)\}/g
|
||||
let match
|
||||
|
||||
while ((match = typeBlockPattern.exec(code)) !== null) {
|
||||
const blockContent = match[1]
|
||||
// Match prop names (word followed by optional ? and :)
|
||||
const propPattern = /(\w+)\s*\??:/g
|
||||
let propMatch
|
||||
while ((propMatch = propPattern.exec(blockContent)) !== null) {
|
||||
uniqueProps.add(propMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(uniqueProps.size, 20) // Max 20 props
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unique event handler names (on[A-Z]...)
|
||||
* Avoids counting the same handler multiple times across type defs, params, and usage
|
||||
*/
|
||||
countUniqueEventHandlers(code) {
|
||||
const uniqueHandlers = new Set()
|
||||
const pattern = /on[A-Z]\w+/g
|
||||
let match
|
||||
|
||||
while ((match = pattern.exec(code)) !== null) {
|
||||
uniqueHandlers.add(match[0])
|
||||
}
|
||||
|
||||
return uniqueHandlers.size
|
||||
}
|
||||
|
||||
static escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate test priority based on complexity and usage
|
||||
* Calculate test priority based on cognitive complexity and usage
|
||||
*
|
||||
* Priority Score = Complexity Score + Usage Score
|
||||
* - Complexity: 0-100
|
||||
* - Usage: 0-50
|
||||
* - Total: 0-150
|
||||
* Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100)
|
||||
* - Complexity Score: 0-100 (normalized from SonarJS)
|
||||
* - Usage Score: 0-100 (based on reference count)
|
||||
*
|
||||
* Priority Levels:
|
||||
* - 0-30: Low
|
||||
* - 31-70: Medium
|
||||
* - 71-100: High
|
||||
* - 100+: Critical
|
||||
* Priority Levels (0-100):
|
||||
* - 0-25: 🟢 LOW
|
||||
* - 26-50: 🟡 MEDIUM
|
||||
* - 51-75: 🟠 HIGH
|
||||
* - 76-100: 🔴 CRITICAL
|
||||
*/
|
||||
calculateTestPriority(complexity, usageCount) {
|
||||
const complexityScore = complexity
|
||||
|
||||
// Usage score calculation
|
||||
// Normalize usage score to 0-100
|
||||
let usageScore
|
||||
if (usageCount === 0)
|
||||
usageScore = 0
|
||||
else if (usageCount <= 5)
|
||||
usageScore = 10
|
||||
else if (usageCount <= 20)
|
||||
usageScore = 20
|
||||
else if (usageCount <= 20)
|
||||
usageScore = 40
|
||||
else if (usageCount <= 50)
|
||||
usageScore = 35
|
||||
usageScore = 70
|
||||
else
|
||||
usageScore = 50
|
||||
usageScore = 100
|
||||
|
||||
const totalScore = complexityScore + usageScore
|
||||
// Weighted average: complexity (70%) + usage (30%)
|
||||
const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore)
|
||||
|
||||
return {
|
||||
score: totalScore,
|
||||
|
|
@ -469,12 +337,12 @@ class ComponentAnalyzer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get priority level based on score
|
||||
* Get priority level based on score (0-100 scale)
|
||||
*/
|
||||
getPriorityLevel(score) {
|
||||
if (score > 100) return '🔴 CRITICAL'
|
||||
if (score > 70) return '🟠 HIGH'
|
||||
if (score > 30) return '🟡 MEDIUM'
|
||||
if (score > 75) return '🔴 CRITICAL'
|
||||
if (score > 50) return '🟠 HIGH'
|
||||
if (score > 25) return '🟡 MEDIUM'
|
||||
return '🟢 LOW'
|
||||
}
|
||||
}
|
||||
|
|
@ -498,10 +366,11 @@ class TestPromptBuilder {
|
|||
|
||||
📊 Component Analysis:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Type: ${analysis.type}
|
||||
Complexity: ${analysis.complexity} ${this.getComplexityLevel(analysis.complexity)}
|
||||
Lines: ${analysis.lineCount}
|
||||
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
|
||||
Type: ${analysis.type}
|
||||
Total Complexity: ${analysis.complexity}/100 ${this.getComplexityLevel(analysis.complexity)}
|
||||
Max Func Complexity: ${analysis.maxComplexity}/100 ${this.getComplexityLevel(analysis.maxComplexity)}
|
||||
Lines: ${analysis.lineCount}
|
||||
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
|
||||
Test Priority: ${analysis.priority.score} ${analysis.priority.level}
|
||||
|
||||
Features Detected:
|
||||
|
|
@ -549,10 +418,10 @@ Create the test file at: ${testPath}
|
|||
}
|
||||
|
||||
getComplexityLevel(score) {
|
||||
// Aligned with testing.md guidelines
|
||||
if (score <= 10) return '🟢 Simple'
|
||||
if (score <= 30) return '🟡 Medium'
|
||||
if (score <= 50) return '🟠 Complex'
|
||||
// Normalized complexity thresholds (0-100 scale)
|
||||
if (score <= 25) return '🟢 Simple'
|
||||
if (score <= 50) return '🟡 Medium'
|
||||
if (score <= 75) return '🟠 Complex'
|
||||
return '🔴 Very Complex'
|
||||
}
|
||||
|
||||
|
|
@ -605,20 +474,31 @@ Create the test file at: ${testPath}
|
|||
}
|
||||
|
||||
// ===== Complexity Warning =====
|
||||
if (analysis.complexity > 50) {
|
||||
guidelines.push('🔴 VERY COMPLEX component detected. Consider:')
|
||||
if (analysis.complexity > 75) {
|
||||
guidelines.push(`🔴 HIGH Total Complexity (${analysis.complexity}/100). Consider:`)
|
||||
guidelines.push(' - Splitting component into smaller pieces before testing')
|
||||
guidelines.push(' - Creating integration tests for complex workflows')
|
||||
guidelines.push(' - Using test.each() for data-driven tests')
|
||||
guidelines.push(' - Adding performance benchmarks')
|
||||
}
|
||||
else if (analysis.complexity > 30) {
|
||||
guidelines.push('⚠️ This is a COMPLEX component. Consider:')
|
||||
else if (analysis.complexity > 50) {
|
||||
guidelines.push(`⚠️ MODERATE Total Complexity (${analysis.complexity}/100). Consider:`)
|
||||
guidelines.push(' - Breaking tests into multiple describe blocks')
|
||||
guidelines.push(' - Testing integration scenarios')
|
||||
guidelines.push(' - Grouping related test cases')
|
||||
}
|
||||
|
||||
// ===== Max Function Complexity Warning =====
|
||||
if (analysis.maxComplexity > 75) {
|
||||
guidelines.push(`🔴 HIGH Single Function Complexity (max: ${analysis.maxComplexity}/100). Consider:`)
|
||||
guidelines.push(' - Breaking down the complex function into smaller helpers')
|
||||
guidelines.push(' - Extracting logic into custom hooks or utility functions')
|
||||
}
|
||||
else if (analysis.maxComplexity > 50) {
|
||||
guidelines.push(`⚠️ MODERATE Single Function Complexity (max: ${analysis.maxComplexity}/100). Consider:`)
|
||||
guidelines.push(' - Simplifying conditional logic')
|
||||
guidelines.push(' - Using early returns to reduce nesting')
|
||||
}
|
||||
|
||||
// ===== State Management =====
|
||||
if (analysis.hasState && analysis.hasEffects) {
|
||||
guidelines.push('🔄 State + Effects detected:')
|
||||
|
|
@ -976,7 +856,7 @@ function main() {
|
|||
|
||||
// Check if component is too complex - suggest refactoring instead of testing
|
||||
// Skip this check in JSON mode to always output analysis result
|
||||
if (!isReviewMode && !isJsonMode && (analysis.complexity > 50 || analysis.lineCount > 300)) {
|
||||
if (!isReviewMode && !isJsonMode && (analysis.complexity > 75 || analysis.lineCount > 300)) {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ COMPONENT TOO COMPLEX TO TEST ║
|
||||
|
|
@ -987,8 +867,9 @@ function main() {
|
|||
|
||||
📊 Component Metrics:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Complexity: ${analysis.complexity} ${analysis.complexity > 50 ? '🔴 TOO HIGH' : '⚠️ WARNING'}
|
||||
Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '🔴 TOO LARGE' : '⚠️ WARNING'}
|
||||
Total Complexity: ${analysis.complexity}/100 ${analysis.complexity > 75 ? '🔴 TOO HIGH' : analysis.complexity > 50 ? '⚠️ WARNING' : '🟢 OK'}
|
||||
Max Func Complexity: ${analysis.maxComplexity}/100 ${analysis.maxComplexity > 75 ? '🔴 TOO HIGH' : analysis.maxComplexity > 50 ? '⚠️ WARNING' : '🟢 OK'}
|
||||
Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '🔴 TOO LARGE' : '🟢 OK'}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🚫 RECOMMENDATION: REFACTOR BEFORE TESTING
|
||||
|
|
@ -1017,7 +898,7 @@ This component is too complex to test effectively. Please consider:
|
|||
- Tests will be easier to write and maintain
|
||||
|
||||
💡 TIP: Aim for components with:
|
||||
- Complexity score < 30 (preferably < 20)
|
||||
- Cognitive Complexity < 50/100 (preferably < 25/100)
|
||||
- Line count < 300 (preferably < 200)
|
||||
- Single responsibility principle
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue