Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

# Conflicts:
#	api/.env.example
#	api/uv.lock
#	web/app/components/app/create-app-modal/index.tsx
#	web/app/components/app/create-from-dsl-modal/index.tsx
#	web/app/components/apps/app-card.tsx
#	web/pnpm-lock.yaml
This commit is contained in:
yyh
2026-01-29 21:25:28 +08:00
137 changed files with 5382 additions and 812 deletions

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 AppIcon from '../base/app-icon'
import AppOperations from './app-operations'
@ -161,13 +162,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

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import Button from '../base/button'
import Tooltip from '../base/tooltip'
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
import ShortcutsName from '../workflow/shortcuts-name'
type TooltipContentProps = {
expand: boolean
@ -20,18 +20,7 @@ const TooltipContent = ({
return (
<div className="flex items-center gap-x-1">
<span className="system-xs-medium px-0.5 text-text-secondary">{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })}</span>
<div className="flex items-center gap-x-0.5">
{
TOGGLE_SHORTCUT.map(key => (
<span
key={key}
className="system-kbd inline-flex items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-1 text-text-tertiary"
>
{getKeyboardKeyNameBySystem(key)}
</span>
))
}
</div>
<ShortcutsName keys={TOGGLE_SHORTCUT} textColor="secondary" />
</div>
)
}

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

@ -57,7 +57,8 @@ 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 ShortcutsName from '../../workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
@ -410,13 +411,7 @@ const AppPublisher = ({
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<div className="flex gap-0.5">
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className="system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface">
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
</div>
)
}

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

@ -2,8 +2,7 @@
import type { AppIconSelection } from '../../base/app-icon-picker'
import type { RuntimeMode } from '@/types/app'
import { RiArrowRightLine, RiArrowRightSLine, RiCheckLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react'
import { RiArrowRightLine, RiArrowRightSLine, RiCheckLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
@ -32,6 +31,7 @@ import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker'
import ShortcutsName from '../../workflow/shortcuts-name'
type CreateAppProps = {
onSuccess: () => void
@ -342,10 +342,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<Button onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button disabled={isAppsFull || !name} className="gap-1" variant="primary" onClick={handleCreateApp}>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<div className="flex gap-0.5">
<RiCommandLine size={14} className="system-kbd rounded-sm bg-components-kbd-bg-white p-0.5" />
<RiCornerDownLeftLine size={14} className="system-kbd rounded-sm bg-components-kbd-bg-white p-0.5" />
</div>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</div>

View File

@ -29,6 +29,7 @@ import {
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import ShortcutsName from '../../workflow/shortcuts-name'
import DSLConfirmModal from './dsl-confirm-modal'
import Uploader from './uploader'
@ -327,8 +328,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateApp}
className="gap-1"
>
{t('newApp.import', { ns: 'app' })}
<span>{t('newApp.import', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</div>

View File

@ -35,6 +35,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'
@ -172,13 +173,8 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: 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}.${isDownLoadBundle ? 'zip' : 'yaml'}`
a.click()
URL.revokeObjectURL(url)
downloadBlob({ data: file, fileName: `${app.name}.yml` })
}
catch {
notify({
@ -363,7 +359,7 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: 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])
const onlineUserAvatars = useMemo(() => {
if (!onlineUsers.length)

View File

@ -105,6 +105,7 @@ const Apps = () => {
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}
app={currentTryAppParams?.app}
category={currentTryAppParams?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}

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

@ -4,7 +4,8 @@ import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentContext } from '../../context'
@ -54,7 +55,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
>
<div className="flex items-center gap-x-1">
<span className="system-sm-medium text-components-button-secondary-text">{t('operation.cancel', { ns: 'common' })}</span>
<span className="system-kbd rounded-[4px] bg-components-kbd-bg-gray px-[1px] text-text-tertiary">ESC</span>
<ShortcutsName keys={['ESC']} textColor="secondary" />
</div>
</Button>
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton)
@ -76,10 +77,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
>
<div className="flex items-center gap-x-1">
<span className="text-components-button-primary-text">{t('operation.save', { ns: 'common' })}</span>
<div className="flex items-center gap-x-0.5">
<span className="system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white capitalize text-text-primary-on-surface">{getKeyboardKeyNameBySystem('ctrl')}</span>
<span className="system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface">S</span>
</div>
<ShortcutsName keys={['ctrl', 'S']} bgColor="white" />
</div>
</Button>
</div>

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

@ -74,17 +74,15 @@ const AppCard = ({
</div>
{isExplore && (canCreate || isTrialApp) && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', isTrialApp && 'grid-cols-2')}>
<div className={cn('grid h-8 w-full grid-cols-2 space-x-2')}>
<Button variant="primary" className="h-7" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('appCard.addToWorkspace', { ns: 'explore' })}</span>
</Button>
{isTrialApp && (
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
</div>
</div>
)}

View File

@ -251,6 +251,7 @@ const Apps = ({
{isShowTryAppPanel && (
<TryApp
appId={appParams?.appId || ''}
app={appParams?.app}
category={appParams?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}

View File

@ -1,6 +1,6 @@
'use client'
import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { RiCloseLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
@ -17,6 +17,7 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import AppIconPicker from '../../base/app-icon-picker'
import ShortcutsName from '../../workflow/shortcuts-name'
export type CreateAppModalProps = {
show: boolean
@ -198,10 +199,7 @@ const CreateAppModal = ({
onClick={handleSubmit}
>
<span>{!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</span>
<div className="flex gap-0.5">
<RiCommandLine size={14} className="system-kbd rounded-sm bg-components-kbd-bg-white p-0.5" />
<RiCornerDownLeftLine size={14} className="system-kbd rounded-sm bg-components-kbd-bg-white p-0.5" />
</div>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>

View File

@ -1,11 +1,13 @@
/* eslint-disable style/multiline-ternary */
'use client'
import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/index'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetTryAppInfo } from '@/service/use-try-app'
import Button from '../../base/button'
import App from './app'
@ -15,6 +17,7 @@ import Tab, { TypeEnum } from './tab'
type Props = {
appId: string
app?: AppType
category?: string
onClose: () => void
onCreate: () => void
@ -22,13 +25,23 @@ type Props = {
const TryApp: FC<Props> = ({
appId,
app,
category,
onClose,
onCreate,
}) => {
const [type, setType] = useState<TypeEnum>(TypeEnum.TRY)
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const [type, setType] = useState<TypeEnum>(() => (app && !isTrialApp ? TypeEnum.DETAIL : TypeEnum.TRY))
const { data: appDetail, isLoading } = useGetTryAppInfo(appId)
React.useEffect(() => {
if (app && !isTrialApp && type !== TypeEnum.DETAIL)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setType(TypeEnum.DETAIL)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app, isTrialApp])
return (
<Modal
isShow
@ -45,6 +58,7 @@ const TryApp: FC<Props> = ({
<Tab
value={type}
onChange={setType}
disableTry={app ? !isTrialApp : false}
/>
<Button
size="large"

View File

@ -12,15 +12,17 @@ export enum TypeEnum {
type Props = {
value: TypeEnum
onChange: (value: TypeEnum) => void
disableTry?: boolean
}
const Tab: FC<Props> = ({
value,
onChange,
disableTry,
}) => {
const { t } = useTranslation()
const tabs = [
{ id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }) },
{ id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }), disabled: disableTry },
{ id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) },
]
return (

View File

@ -12,7 +12,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { useGetLanguage } from '@/context/i18n'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
@ -356,14 +357,7 @@ const GotoAnything: FC<Props> = ({
</div>
)}
</div>
<div className="text-xs text-text-quaternary">
<span className="system-kbd rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100">
{isMac() ? '⌘' : 'Ctrl'}
</span>
<span className="system-kbd ml-1 rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100">
K
</span>
</div>
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
</div>
<Command.List className="h-[240px] overflow-y-auto">

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

@ -28,11 +28,12 @@ import { useToastContext } from '@/app/components/base/toast'
import {
useChecklistBeforePublish,
} from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
@ -261,13 +262,7 @@ const Popup = () => {
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<div className="flex gap-0.5">
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className="system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface">
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
</div>
)
}

View File

@ -4,9 +4,9 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
@ -78,14 +78,7 @@ const RunMode = ({
)}
{
!isDisabled && (
<div className="system-kbd flex items-center gap-x-0.5 text-text-tertiary">
<div className="flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray">
{getKeyboardKeyNameBySystem('alt')}
</div>
<div className="flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray">
R
</div>
</div>
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
)
}
</button>

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

@ -7,6 +7,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeSelectionPanel from './start-node-selection-panel'
@ -75,9 +76,7 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
{isShow && (
<div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary">
<span>{t('onboarding.escTip.press', { ns: 'workflow' })}</span>
<kbd className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-1 text-text-tertiary">
{t('onboarding.escTip.key', { ns: 'workflow' })}
</kbd>
<ShortcutsName keys={[t('onboarding.escTip.key', { ns: 'workflow' })]} textColor="secondary" />
<span>{t('onboarding.escTip.toDismiss', { ns: 'workflow' })}</span>
</div>
)}

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

@ -7,9 +7,9 @@ import { trackEvent } from '@/app/components/base/amplitude'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useToastContext } from '@/app/components/base/toast'
import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
@ -143,14 +143,7 @@ const RunMode = ({
>
<RiPlayLargeLine className="mr-1 size-4" />
{text ?? t('common.run', { ns: 'workflow' })}
<div className="system-kbd flex items-center gap-x-0.5 text-text-tertiary">
<div className="flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray">
{getKeyboardKeyNameBySystem('alt')}
</div>
<div className="flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray">
R
</div>
</div>
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
</div>
</TestRunMenu>
)

View File

@ -8,7 +8,8 @@ import useTheme from '@/hooks/use-theme'
import { cn } from '@/utils/classnames'
import Button from '../../base/button'
import Tooltip from '../../base/tooltip'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../utils'
import ShortcutsName from '../shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../utils'
type VersionHistoryButtonProps = {
onClick: () => Promise<unknown> | unknown
@ -23,16 +24,7 @@ const PopupContent = React.memo(() => {
<div className="system-xs-medium px-0.5 text-text-secondary">
{t('common.versionHistory', { ns: 'workflow' })}
</div>
<div className="flex items-center gap-x-0.5">
{VERSION_HISTORY_SHORTCUT.map(key => (
<span
key={key}
className="system-kbd rounded-[4px] bg-components-kbd-bg-white px-[1px] text-text-tertiary"
>
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>
<ShortcutsName keys={VERSION_HISTORY_SHORTCUT} bgColor="gray" textColor="secondary" />
</div>
)
})

View File

@ -41,7 +41,7 @@ const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: str
case '--request':
if (i + 1 >= args.length)
return { node: null, error: 'Missing HTTP method after -X or --request.' }
node.method = (args[++i].replace(/^['"]|['"]$/g, '') as Method) || Method.get
node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get
hasData = true
break
case '-H':

View File

@ -3,7 +3,8 @@ import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
type AdvancedActionsProps = {
isConfirmDisabled: boolean
@ -11,15 +12,6 @@ type AdvancedActionsProps = {
onConfirm: () => void
}
const Key = (props: { keyName: string }) => {
const { keyName } = props
return (
<kbd className="system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-white px-px text-text-primary-on-surface">
{keyName}
</kbd>
)
}
const AdvancedActions: FC<AdvancedActionsProps> = ({
isConfirmDisabled,
onCancel,
@ -48,10 +40,7 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
onClick={onConfirm}
>
<span>{t('operation.confirm', { ns: 'common' })}</span>
<div className="flex items-center gap-x-0.5">
<Key keyName={getKeyboardKeyNameBySystem('ctrl')} />
<Key keyName="⏎" />
</div>
<ShortcutsName keys={['ctrl', '⏎']} bgColor="white" />
</Button>
</div>
)

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

@ -6,11 +6,13 @@ type ShortcutsNameProps = {
keys: readonly string[]
className?: string
textColor?: 'default' | 'secondary'
bgColor?: 'gray' | 'white'
}
const ShortcutsName = ({
keys,
className,
textColor = 'default',
bgColor = 'gray',
}: ShortcutsNameProps) => {
return (
<div className={cn(
@ -23,7 +25,9 @@ const ShortcutsName = ({
<div
key={key}
className={cn(
'system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray capitalize',
'system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] px-1 capitalize',
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
textColor === 'secondary' && 'text-text-tertiary',
)}
>

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;
}