Compare commits

..

2 Commits

31 changed files with 171 additions and 277 deletions

View File

@ -617,7 +617,6 @@ PLUGIN_DAEMON_URL=http://127.0.0.1:5002
PLUGIN_REMOTE_INSTALL_PORT=5003
PLUGIN_REMOTE_INSTALL_HOST=localhost
PLUGIN_MAX_PACKAGE_SIZE=15728640
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
# Marketplace configuration
@ -717,3 +716,4 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000

View File

@ -245,7 +245,7 @@ class PluginConfig(BaseSettings):
PLUGIN_MODEL_SCHEMA_CACHE_TTL: PositiveInt = Field(
description="TTL in seconds for caching plugin model schemas in Redis",
default=60 * 60,
default=24 * 60 * 60,
)

View File

@ -1375,7 +1375,6 @@ PLUGIN_DAEMON_PORT=5002
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
PLUGIN_DAEMON_URL=http://plugin_daemon:5002
PLUGIN_MAX_PACKAGE_SIZE=52428800
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
PLUGIN_PPROF_ENABLED=false
PLUGIN_DEBUGGING_HOST=0.0.0.0

View File

@ -589,7 +589,6 @@ x-shared-env: &shared-api-worker-env
PLUGIN_DAEMON_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}
PLUGIN_DAEMON_URL: ${PLUGIN_DAEMON_URL:-http://plugin_daemon:5002}
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
PLUGIN_MODEL_SCHEMA_CACHE_TTL: ${PLUGIN_MODEL_SCHEMA_CACHE_TTL:-3600}
PLUGIN_PPROF_ENABLED: ${PLUGIN_PPROF_ENABLED:-false}
PLUGIN_DEBUGGING_HOST: ${PLUGIN_DEBUGGING_HOST:-0.0.0.0}
PLUGIN_DEBUGGING_PORT: ${PLUGIN_DEBUGGING_PORT:-5003}

View File

@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import AppIcon from '../base/app-icon'
import AppOperations from './app-operations'
@ -145,13 +146,8 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
appID: appDetail.id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@ -11,6 +11,7 @@ import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/kn
import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
@ -64,13 +65,8 @@ const DropDown = ({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${name}.pipeline` })
}
catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@ -21,6 +21,7 @@ import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal'
import BatchAddModal from '../batch-add-annotation-modal'
@ -56,28 +57,23 @@ const HeaderOptions: FC<Props> = ({
)
const JSONLOutput = () => {
const a = document.createElement('a')
const content = listTransformer(list).join('\n')
const file = new Blob([content], { type: 'application/jsonl' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `annotations-${locale}.jsonl`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` })
}
const fetchList = async () => {
const fetchList = React.useCallback(async () => {
const { data }: any = await fetchExportAnnotationList(appId)
setList(data as AnnotationItemBasic[])
}
}, [appId])
useEffect(() => {
fetchList()
}, [])
}, [fetchList])
useEffect(() => {
if (controlUpdateList)
fetchList()
}, [controlUpdateList])
}, [controlUpdateList, fetchList])
const [showBulkImportModal, setShowBulkImportModal] = useState(false)
const [showClearConfirm, setShowClearConfirm] = useState(false)

View File

@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
@ -240,7 +240,9 @@ describe('ConfigVar', () => {
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButton)
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
})
})
it('should show error when variable key is duplicated', async () => {

View File

@ -33,6 +33,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var'
@ -161,13 +162,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
appID: app.id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${app.name}.yml`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${app.name}.yml` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
@ -346,7 +342,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
})
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
}, [app.updated_at, app.created_at])
}, [app.updated_at, app.created_at, t])
return (
<>

View File

@ -15,11 +15,11 @@ import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { formatFileSize } from '@/utils/format'
import FileImageRender from '../file-image-render'
import FileTypeIcon from '../file-type-icon'
import {
downloadFile,
fileIsUploaded,
getFileAppearanceType,
getFileExtension,
@ -140,7 +140,7 @@ const FileInAttachmentItem = ({
showDownloadAction && (
<ActionButton onClick={(e) => {
e.stopPropagation()
downloadFile(url || base64Url || '', name)
downloadUrl({ url: url || base64Url || '', fileName: name, target: '_blank' })
}}
>
<RiDownloadLine className="h-4 w-4" />

View File

@ -8,9 +8,9 @@ import Button from '@/app/components/base/button'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { downloadUrl } from '@/utils/download'
import FileImageRender from '../file-image-render'
import {
downloadFile,
fileIsUploaded,
} from '../utils'
@ -85,7 +85,7 @@ const FileImageItem = ({
className="absolute bottom-0.5 right-0.5 flex h-6 w-6 items-center justify-center rounded-lg bg-components-actionbar-bg shadow-md"
onClick={(e) => {
e.stopPropagation()
downloadFile(download_url || '', name)
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
}}
>
<RiDownloadLine className="h-4 w-4 text-text-tertiary" />

View File

@ -12,10 +12,10 @@ import VideoPreview from '@/app/components/base/file-uploader/video-preview'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { formatFileSize } from '@/utils/format'
import FileTypeIcon from '../file-type-icon'
import {
downloadFile,
fileIsUploaded,
getFileAppearanceType,
getFileExtension,
@ -100,7 +100,7 @@ const FileItem = ({
className="absolute -right-1 -top-1 hidden group-hover/file-item:flex"
onClick={(e) => {
e.stopPropagation()
downloadFile(download_url || '', name)
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
}}
>
<RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" />

View File

@ -1,4 +1,3 @@
import type { MockInstance } from 'vitest'
import mime from 'mime'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { upload } from '@/service/base'
@ -6,7 +5,6 @@ import { TransferMethod } from '@/types/app'
import { FILE_EXTS } from '../prompt-editor/constants'
import { FileAppearanceTypeEnum } from './types'
import {
downloadFile,
fileIsUploaded,
fileUpload,
getFileAppearanceType,
@ -782,74 +780,4 @@ describe('file-uploader utils', () => {
} as any)).toBe(true)
})
})
describe('downloadFile', () => {
let mockAnchor: HTMLAnchorElement
let createElementMock: MockInstance
let appendChildMock: MockInstance
let removeChildMock: MockInstance
beforeEach(() => {
// Mock createElement and appendChild
mockAnchor = {
href: '',
download: '',
style: { display: '' },
target: '',
title: '',
click: vi.fn(),
} as unknown as HTMLAnchorElement
createElementMock = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any)
appendChildMock = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
return node
})
removeChildMock = vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => {
return node
})
})
afterEach(() => {
vi.resetAllMocks()
})
it('should create and trigger download with correct attributes', () => {
const url = 'https://example.com/test.pdf'
const filename = 'test.pdf'
downloadFile(url, filename)
// Verify anchor element was created with correct properties
expect(createElementMock).toHaveBeenCalledWith('a')
expect(mockAnchor.href).toBe(url)
expect(mockAnchor.download).toBe(filename)
expect(mockAnchor.style.display).toBe('none')
expect(mockAnchor.target).toBe('_blank')
expect(mockAnchor.title).toBe(filename)
// Verify DOM operations
expect(appendChildMock).toHaveBeenCalledWith(mockAnchor)
expect(mockAnchor.click).toHaveBeenCalled()
expect(removeChildMock).toHaveBeenCalledWith(mockAnchor)
})
it('should handle empty filename', () => {
const url = 'https://example.com/test.pdf'
const filename = ''
downloadFile(url, filename)
expect(mockAnchor.download).toBe('')
expect(mockAnchor.title).toBe('')
})
it('should handle empty url', () => {
const url = ''
const filename = 'test.pdf'
downloadFile(url, filename)
expect(mockAnchor.href).toBe('')
})
})
})

View File

@ -249,15 +249,3 @@ export const fileIsUploaded = (file: FileEntity) => {
if (file.transferMethod === TransferMethod.remote_url && file.progress === 100)
return true
}
export const downloadFile = (url: string, filename: string) => {
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.style.display = 'none'
anchor.target = '_blank'
anchor.title = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}

View File

@ -8,6 +8,7 @@ import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { downloadUrl } from '@/utils/download'
type ImagePreviewProps = {
url: string
@ -60,27 +61,14 @@ const ImagePreview: FC<ImagePreviewProps> = ({
const downloadImage = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https')) {
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.download = title
a.click()
}
else if (url.startsWith('data:image')) {
// Base64 image
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.download = title
a.click()
}
else {
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
if (url.startsWith('http') || url.startsWith('https') || url.startsWith('data:image')) {
downloadUrl({ url, fileName: title, target: '_blank' })
return
}
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
}
const zoomIn = () => {
@ -135,12 +123,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
catch (err) {
console.error('Failed to copy image:', err)
const link = document.createElement('a')
link.href = url
link.download = `${title}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
downloadUrl({ url, fileName: `${title}.png` })
Toast.notify({
type: 'info',
@ -215,6 +198,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
tabIndex={-1}
>
{ }
{/* eslint-disable-next-line next/no-img-element */}
<img
ref={imgRef}
alt={title}

View File

@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import { downloadUrl } from '@/utils/download'
type Props = {
content: string
@ -40,11 +41,10 @@ const ShareQRCode = ({ content }: Props) => {
}, [isShow])
const downloadQR = () => {
const canvas = document.getElementsByTagName('canvas')[0]
const link = document.createElement('a')
link.download = 'qrcode.png'
link.href = canvas.toDataURL()
link.click()
const canvas = qrCodeRef.current?.querySelector('canvas')
if (!(canvas instanceof HTMLCanvasElement))
return
downloadUrl({ url: canvas.toDataURL(), fileName: 'qrcode.png' })
}
const handlePanelClick = (event: React.MouseEvent) => {

View File

@ -179,8 +179,10 @@ describe('RetryButton (IndexFailed)', () => {
}, false),
)
// Delay the response to test loading state
mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100)))
let resolveRetry: ((value: { result: 'success' }) => void) | undefined
mockRetryErrorDocs.mockImplementation(() => new Promise((resolve) => {
resolveRetry = resolve
}))
render(<RetryButton datasetId="test-dataset" />)
@ -193,6 +195,11 @@ describe('RetryButton (IndexFailed)', () => {
expect(button).toHaveClass('cursor-not-allowed')
expect(button).toHaveClass('text-text-disabled')
})
resolveRetry?.({ result: 'success' })
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
})

View File

@ -23,9 +23,10 @@ vi.mock('@/app/components/base/toast', () => ({
},
}))
// Mock downloadFile utility
vi.mock('@/utils/format', () => ({
downloadFile: vi.fn(),
// Mock download utilities
vi.mock('@/utils/download', () => ({
downloadBlob: vi.fn(),
downloadUrl: vi.fn(),
}))
// Capture Confirm callbacks
@ -502,8 +503,8 @@ describe('TemplateCard', () => {
})
})
it('should call downloadFile on successful export', async () => {
const { downloadFile } = await import('@/utils/format')
it('should call downloadBlob on successful export', async () => {
const { downloadBlob } = await import('@/utils/download')
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
@ -514,7 +515,7 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({
expect(downloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Test Pipeline.pipeline',
}))
})

View File

@ -16,7 +16,7 @@ import {
useInvalidCustomizedTemplateList,
usePipelineTemplateById,
} from '@/service/use-pipeline'
import { downloadFile } from '@/utils/format'
import { downloadBlob } from '@/utils/download'
import Actions from './actions'
import Content from './content'
import Details from './details'
@ -108,10 +108,7 @@ const TemplateCard = ({
await exportPipelineDSL(pipeline.id, {
onSuccess: (res) => {
const blob = new Blob([res.data], { type: 'application/yaml' })
downloadFile({
data: blob,
fileName: `${pipeline.name}.pipeline`,
})
downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` })
Toast.notify({
type: 'success',
message: t('exportDSL.successTip', { ns: 'datasetPipeline' }),

View File

@ -125,11 +125,25 @@ const WaterCrawl: FC<Props> = ({
await sleep(2500)
return await waitForCrawlFinished(jobId)
}
catch (e: any) {
const errorBody = await e.json()
catch (error: unknown) {
let errorMessage = ''
const maybeErrorWithJson = error as { json?: () => Promise<unknown>, message?: unknown } | null
if (maybeErrorWithJson?.json) {
try {
const errorBody = await maybeErrorWithJson.json() as { message?: unknown } | null
if (typeof errorBody?.message === 'string')
errorMessage = errorBody.message
}
catch {}
}
if (!errorMessage && typeof maybeErrorWithJson?.message === 'string')
errorMessage = maybeErrorWithJson.message
return {
isError: true,
errorMessage: errorBody.message,
errorMessage,
data: {
data: [],
},

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
type ModalState = {
showRenameModal: boolean
@ -65,13 +66,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${name}.pipeline` })
}
catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@ -10,6 +10,7 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import Button from '../../base/button'
import Gdpr from '../../base/icons/src/public/common/Gdpr'
import Iso from '../../base/icons/src/public/common/Iso'
@ -47,9 +48,7 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
mutationFn: async () => {
try {
const ret = await getDocDownloadUrl(doc_name)
const a = document.createElement('a')
a.href = ret.url
a.click()
downloadUrl({ url: ret.url })
Toast.notify({
type: 'success',
message: t('operation.downloadSuccess', { ns: 'common' }),

View File

@ -11,6 +11,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { fetchWorkflowDraft } from '@/service/workflow'
import { downloadBlob } from '@/utils/download'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
export const useDSL = () => {
@ -37,13 +38,8 @@ export const useDSL = () => {
pipelineId,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${knowledgeName}.pipeline`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${knowledgeName}.pipeline` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@ -11,6 +11,7 @@ import {
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { exportAppConfig } from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { downloadBlob } from '@/utils/download'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
export const useDSL = () => {
@ -37,13 +38,8 @@ export const useDSL = () => {
include,
workflowID: workflowId,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })

View File

@ -15,7 +15,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import { useDownloadPlugin } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { downloadFile } from '@/utils/format'
import { downloadBlob } from '@/utils/download'
import { getMarketplaceUrl } from '@/utils/var'
type Props = {
@ -67,7 +67,7 @@ const OperationDropdown: FC<Props> = ({
if (!needDownload || !blob)
return
const fileName = `${author}-${name}_${version}.zip`
downloadFile({ data: blob, fileName })
downloadBlob({ data: blob, fileName })
setNeedDownload(false)
queryClient.removeQueries({
queryKey: ['plugins', 'downloadPlugin', downloadInfo],

View File

@ -19,6 +19,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import { useStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import { useNodesReadOnly } from '../hooks'
import TipPopup from './tip-popup'
@ -146,26 +147,14 @@ const MoreActions: FC = () => {
}
}
const fileName = `${filename}.${type}`
if (currentWorkflow) {
setPreviewUrl(dataUrl)
setPreviewTitle(`${filename}.${type}`)
setPreviewTitle(fileName)
}
const link = document.createElement('a')
link.href = dataUrl
link.download = `${filename}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
else {
// For current view, just download
const link = document.createElement('a')
link.href = dataUrl
link.download = `${filename}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
downloadUrl({ url: dataUrl, fileName })
}
catch (error) {
console.error('Export image failed:', error)

View File

@ -9,8 +9,7 @@ html[data-theme="dark"] .monaco-editor .sticky-line-content:hover {
background-color: var(--color-components-sticky-header-bg-hover) !important;
}
/* Fallback: any app sticky header using input-bg variables should use the sticky header bg when sticky */
html[data-theme="dark"] .sticky, html[data-theme="dark"] .is-sticky {
/* Monaco editor specific sticky scroll styles in dark mode */
html[data-theme="dark"] .monaco-editor .sticky-line-root {
background-color: var(--color-components-sticky-header-bg) !important;
border-bottom: 1px solid var(--color-components-sticky-header-border) !important;
}

View File

@ -994,7 +994,7 @@
"count": 1
},
"ts/no-explicit-any": {
"count": 3
"count": 2
}
},
"app/components/base/file-uploader/utils.ts": {
@ -1661,7 +1661,7 @@
"count": 1
},
"ts/no-explicit-any": {
"count": 5
"count": 4
}
},
"app/components/datasets/create/website/watercrawl/options.tsx": {
@ -4376,11 +4376,6 @@
"count": 1
}
},
"utils/format.spec.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"utils/get-icon.spec.ts": {
"ts/no-explicit-any": {
"count": 2

View File

@ -0,0 +1,75 @@
import { downloadBlob, downloadUrl } from './download'
describe('downloadUrl', () => {
let mockAnchor: HTMLAnchorElement
beforeEach(() => {
mockAnchor = {
href: '',
download: '',
rel: '',
target: '',
style: { display: '' },
click: vi.fn(),
remove: vi.fn(),
} as unknown as HTMLAnchorElement
vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor)
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should create a link and trigger a download correctly', () => {
downloadUrl({ url: 'https://example.com/file.txt', fileName: 'file.txt', target: '_blank' })
expect(mockAnchor.href).toBe('https://example.com/file.txt')
expect(mockAnchor.download).toBe('file.txt')
expect(mockAnchor.rel).toBe('noopener noreferrer')
expect(mockAnchor.target).toBe('_blank')
expect(mockAnchor.style.display).toBe('none')
expect(mockAnchor.click).toHaveBeenCalled()
expect(mockAnchor.remove).toHaveBeenCalled()
})
it('should skip when url is empty', () => {
downloadUrl({ url: '' })
expect(document.createElement).not.toHaveBeenCalled()
})
})
describe('downloadBlob', () => {
it('should create a blob url, trigger download, and revoke url', () => {
const blob = new Blob(['test'], { type: 'text/plain' })
const mockUrl = 'blob:mock-url'
const createObjectURLMock = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue(mockUrl)
const revokeObjectURLMock = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
const mockAnchor = {
href: '',
download: '',
rel: '',
target: '',
style: { display: '' },
click: vi.fn(),
remove: vi.fn(),
} as unknown as HTMLAnchorElement
vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor)
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node)
downloadBlob({ data: blob, fileName: 'file.txt' })
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
expect(mockAnchor.href).toBe(mockUrl)
expect(mockAnchor.download).toBe('file.txt')
expect(mockAnchor.rel).toBe('noopener noreferrer')
expect(mockAnchor.click).toHaveBeenCalled()
expect(mockAnchor.remove).toHaveBeenCalled()
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
vi.restoreAllMocks()
})
})

View File

@ -1,4 +1,4 @@
import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
import { formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
describe('formatNumber', () => {
it('should correctly format integers', () => {
@ -82,49 +82,6 @@ describe('formatTime', () => {
expect(formatTime(7200)).toBe('2.00 h')
})
})
describe('downloadFile', () => {
it('should create a link and trigger a download correctly', () => {
// Mock data
const blob = new Blob(['test content'], { type: 'text/plain' })
const fileName = 'test-file.txt'
const mockUrl = 'blob:mockUrl'
// Mock URL.createObjectURL
const createObjectURLMock = vi.fn().mockReturnValue(mockUrl)
const revokeObjectURLMock = vi.fn()
Object.defineProperty(window.URL, 'createObjectURL', { value: createObjectURLMock })
Object.defineProperty(window.URL, 'revokeObjectURL', { value: revokeObjectURLMock })
// Mock createElement and appendChild
const mockLink = {
href: '',
download: '',
click: vi.fn(),
remove: vi.fn(),
}
const createElementMock = vi.spyOn(document, 'createElement').mockReturnValue(mockLink as any)
const appendChildMock = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
return node
})
// Call the function
downloadFile({ data: blob, fileName })
// Assertions
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
expect(createElementMock).toHaveBeenCalledWith('a')
expect(mockLink.href).toBe(mockUrl)
expect(mockLink.download).toBe(fileName)
expect(appendChildMock).toHaveBeenCalledWith(mockLink)
expect(mockLink.click).toHaveBeenCalled()
expect(mockLink.remove).toHaveBeenCalled()
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
// Clean up mocks
vi.restoreAllMocks()
})
})
describe('formatNumberAbbreviated', () => {
it('should return number as string when less than 1000', () => {
expect(formatNumberAbbreviated(0)).toBe('0')

View File

@ -100,17 +100,6 @@ export const formatTime = (seconds: number) => {
return `${seconds.toFixed(2)} ${units[index]}`
}
export const downloadFile = ({ data, fileName }: { data: Blob, fileName: string }) => {
const url = window.URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
}
/**
* Formats a number into a readable string using "k", "M", or "B" suffix.
* @example