mirror of
https://github.com/langgenius/webapp-conversation.git
synced 2026-02-03 00:55:29 +08:00
Compare commits
43 Commits
feat/suppo
...
fix/file-i
| Author | SHA1 | Date | |
|---|---|---|---|
| d3482db74d | |||
| beda954867 | |||
| 4ae03c2101 | |||
| 7216f40bee | |||
| e9923e8220 | |||
| 9a7e1be35d | |||
| 9d2d092e9e | |||
| 1f5607221a | |||
| 009674b231 | |||
| 25ef02d2aa | |||
| f6f65cff68 | |||
| 8c6302d1fc | |||
| 291e9a067b | |||
| ac0e3e807d | |||
| b7f703852e | |||
| ef15747e4a | |||
| f9bd745bb0 | |||
| e2b37c1a9c | |||
| 0f490de7ff | |||
| aaeb440210 | |||
| b45262add9 | |||
| 368c6b3dae | |||
| f6fb9c7cea | |||
| 69044eb8a3 | |||
| cafd643c00 | |||
| 1c12b1dce3 | |||
| 94d09ed23b | |||
| 5d313f7463 | |||
| 97203f5ac6 | |||
| 349e081f1f | |||
| 7f24387eef | |||
| 5a1c84e79f | |||
| 8d21cbc2da | |||
| 7bb19ed8ec | |||
| 5a85f0d427 | |||
| 96bd12af44 | |||
| 484a5dc102 | |||
| 10eb176f72 | |||
| fcd6a0215d | |||
| f6b4b4a361 | |||
| df0ae34be1 | |||
| 884e72b4f0 | |||
| f7ff288ff1 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -4,7 +4,7 @@
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnType": true
|
||||
|
||||
12
README.md
12
README.md
@ -4,11 +4,15 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
|
||||
## Config App
|
||||
Create a file named `.env.local` in the current directory and copy the contents from `.env.example`. Setting the following content:
|
||||
```
|
||||
# APP ID
|
||||
# APP ID: This is the unique identifier for your app. You can find it in the app's detail page URL.
|
||||
# For example, in the URL `https://cloud.dify.ai/app/xxx/workflow`, the value `xxx` is your APP ID.
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
# APP API key
|
||||
|
||||
# APP API Key: This is the key used to authenticate your app's API requests.
|
||||
# You can generate it on the app's "API Access" page by clicking the "API Key" button in the top-right corner.
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
# APP URL
|
||||
|
||||
# APP URL: This is the API's base URL. If you're using the Dify cloud service, set it to: https://api.dify.ai/v1.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
```
|
||||
|
||||
@ -68,7 +72,7 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
> ⚠️ If you are using [Vercel Hobby](https://vercel.com/pricing), your message will be trucated due to the limitation of vercel.
|
||||
> ⚠️ If you are using [Vercel Hobby](https://vercel.com/pricing), your message will be truncated due to the limitation of vercel.
|
||||
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { client, getInfo, setSession } from '@/app/api/utils/common'
|
||||
@ -11,7 +9,11 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(data, {
|
||||
headers: setSession(sessionId),
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
catch (error: any) {
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,25 +4,25 @@ import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IAppUnavailableProps = {
|
||||
isUnknwonReason: boolean
|
||||
isUnknownReason: boolean
|
||||
errMessage?: string
|
||||
}
|
||||
|
||||
const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
isUnknwonReason,
|
||||
isUnknownReason,
|
||||
errMessage,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
let message = errMessage
|
||||
if (!errMessage)
|
||||
message = (isUnknwonReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string
|
||||
message = (isUnknownReason ? t('app.common.appUnkonwError') : t('app.common.appUnavailable')) as string
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center w-screen h-screen'>
|
||||
<h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
|
||||
style={{
|
||||
borderRight: '1px solid rgba(0,0,0,.3)',
|
||||
}}>{(errMessage || isUnknwonReason) ? 500 : 404}</h1>
|
||||
}}>{(errMessage || isUnknownReason) ? 500 : 404}</h1>
|
||||
<div className='text-sm'>{message}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
45
app/components/base/action-button/index.css
Normal file
45
app/components/base/action-button/index.css
Normal file
@ -0,0 +1,45 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.action-btn {
|
||||
@apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover
|
||||
}
|
||||
|
||||
.action-btn-hover {
|
||||
@apply bg-state-base-hover
|
||||
}
|
||||
|
||||
.action-btn-disabled {
|
||||
@apply cursor-not-allowed
|
||||
}
|
||||
|
||||
.action-btn-xl {
|
||||
@apply p-2 w-9 h-9 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-l {
|
||||
@apply p-1.5 w-8 h-8 rounded-lg
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
.action-btn-m {
|
||||
@apply p-0.5 w-6 h-6 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-xs {
|
||||
@apply p-0 w-4 h-4 rounded
|
||||
}
|
||||
|
||||
.action-btn.action-btn-active {
|
||||
@apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt
|
||||
}
|
||||
|
||||
.action-btn.action-btn-disabled {
|
||||
@apply text-text-disabled
|
||||
}
|
||||
|
||||
.action-btn.action-btn-destructive {
|
||||
@apply text-text-destructive bg-state-destructive-hover
|
||||
}
|
||||
|
||||
}
|
||||
73
app/components/base/action-button/index.tsx
Normal file
73
app/components/base/action-button/index.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
enum ActionButtonState {
|
||||
Destructive = 'destructive',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
Default = '',
|
||||
Hover = 'hover',
|
||||
}
|
||||
|
||||
const actionButtonVariants = cva(
|
||||
'action-btn',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ActionButtonProps = {
|
||||
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
|
||||
state?: ActionButtonState
|
||||
styleCss?: CSSProperties
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
|
||||
|
||||
function getActionButtonState(state: ActionButtonState) {
|
||||
switch (state) {
|
||||
case ActionButtonState.Destructive:
|
||||
return 'action-btn-destructive'
|
||||
case ActionButtonState.Active:
|
||||
return 'action-btn-active'
|
||||
case ActionButtonState.Disabled:
|
||||
return 'action-btn-disabled'
|
||||
case ActionButtonState.Hover:
|
||||
return 'action-btn-hover'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
actionButtonVariants({ className, size }),
|
||||
getActionButtonState(state),
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
export default ActionButton
|
||||
export { ActionButton, ActionButtonState, actionButtonVariants }
|
||||
16
app/components/base/file-uploader-in-attachment/constants.ts
Normal file
16
app/components/base/file-uploader-in-attachment/constants.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
// fallback for file size limit of dify_config
|
||||
export const IMG_SIZE_LIMIT = 10 * 1024 * 1024
|
||||
export const FILE_SIZE_LIMIT = 15 * 1024 * 1024
|
||||
export const AUDIO_SIZE_LIMIT = 50 * 1024 * 1024
|
||||
export const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024
|
||||
export const MAX_FILE_UPLOAD_LIMIT = 10
|
||||
|
||||
export const FILE_URL_REGEX = /^(https?|ftp):\/\//
|
||||
|
||||
export const FILE_EXTS: Record<string, string[]> = {
|
||||
[SupportUploadFileTypes.image]: ['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'SVG'],
|
||||
[SupportUploadFileTypes.document]: ['TXT', 'MD', 'MDX', 'MARKDOWN', 'PDF', 'HTML', 'XLSX', 'XLS', 'DOC', 'DOCX', 'CSV', 'EML', 'MSG', 'PPTX', 'PPT', 'XML', 'EPUB'],
|
||||
[SupportUploadFileTypes.audio]: ['MP3', 'M4A', 'WAV', 'AMR', 'MPGA'],
|
||||
[SupportUploadFileTypes.video]: ['MP4', 'MOV', 'MPEG', 'WEBM'],
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import FileInput from '../file-input'
|
||||
import { useFile } from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import { FILE_URL_REGEX } from '../constants'
|
||||
import type { FileUpload } from '../types'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileFromLinkOrLocalProps = {
|
||||
showFromLink?: boolean
|
||||
showFromLocal?: boolean
|
||||
trigger: (open: boolean) => React.ReactNode
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileFromLinkOrLocal = ({
|
||||
showFromLink = true,
|
||||
showFromLocal = true,
|
||||
trigger,
|
||||
fileConfig,
|
||||
}: FileFromLinkOrLocalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useStore(s => s.files)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [showError, setShowError] = useState(false)
|
||||
const { handleLoadFileFromLink } = useFile(fileConfig)
|
||||
const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits
|
||||
|
||||
const handleSaveUrl = () => {
|
||||
if (!url)
|
||||
return
|
||||
|
||||
if (!FILE_URL_REGEX.test(url)) {
|
||||
setShowError(true)
|
||||
return
|
||||
}
|
||||
handleLoadFileFromLink(url)
|
||||
setUrl('')
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='top'
|
||||
offset={4}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1001]'>
|
||||
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg'>
|
||||
{
|
||||
showFromLink && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex h-8 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-active p-1 shadow-xs',
|
||||
showError && 'border-components-input-border-destructive',
|
||||
)}>
|
||||
<input
|
||||
className='system-sm-regular mr-0.5 block grow appearance-none bg-transparent px-1 outline-none'
|
||||
placeholder={t('common.fileUploader.pasteFileLinkInputPlaceholder') || ''}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setShowError(false)
|
||||
setUrl(e.target.value.trim())
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
// size='small'
|
||||
// variant='primary'
|
||||
type='primary'
|
||||
disabled={!url || disabled}
|
||||
onClick={handleSaveUrl}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
showError && (
|
||||
<div className='body-xs-regular mt-0.5 text-text-destructive'>
|
||||
{t('common.fileUploader.pasteFileLinkInvalid')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLink && showFromLocal && (
|
||||
<div className='system-2xs-medium-uppercase flex h-7 items-center p-2 text-text-quaternary'>
|
||||
<div className='mr-2 h-[1px] w-[93px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 h-[1px] w-[93px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLocal && (
|
||||
<Button
|
||||
className='relative w-full'
|
||||
// variant='secondary-accent'
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiUploadCloud2Line className='mr-1 h-4 w-4' />
|
||||
{t('common.fileUploader.uploadFromComputer')}
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileFromLinkOrLocal)
|
||||
@ -0,0 +1,32 @@
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileImageRenderProps = {
|
||||
imageUrl: string
|
||||
className?: string
|
||||
alt?: string
|
||||
onLoad?: () => void
|
||||
onError?: () => void
|
||||
showDownloadAction?: boolean
|
||||
}
|
||||
const FileImageRender = ({
|
||||
imageUrl,
|
||||
className,
|
||||
alt,
|
||||
onLoad,
|
||||
onError,
|
||||
showDownloadAction,
|
||||
}: FileImageRenderProps) => {
|
||||
return (
|
||||
<div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}>
|
||||
<img
|
||||
className={cn('h-full w-full object-cover', showDownloadAction && 'cursor-pointer')}
|
||||
alt={alt || 'Preview'}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageRender
|
||||
@ -0,0 +1,49 @@
|
||||
import { useFile } from './hooks'
|
||||
import { useStore } from './store'
|
||||
import type { FileUpload } from './types'
|
||||
import { FILE_EXTS } from './constants'
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
|
||||
type FileInputProps = {
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileInput = ({
|
||||
fileConfig,
|
||||
}: FileInputProps) => {
|
||||
const files = useStore(s => s.files)
|
||||
const { handleLocalFileUpload } = useFile(fileConfig)
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const targetFiles = e.target.files
|
||||
|
||||
if (targetFiles) {
|
||||
if (fileConfig.number_limits) {
|
||||
for (let i = 0; i < targetFiles.length; i++) {
|
||||
if (i + 1 + files.length <= fileConfig.number_limits)
|
||||
handleLocalFileUpload(targetFiles[i])
|
||||
}
|
||||
}
|
||||
else {
|
||||
handleLocalFileUpload(targetFiles[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
const isCustom = allowedFileTypes?.includes(SupportUploadFileTypes.custom)
|
||||
const exts = isCustom ? (fileConfig.allowed_file_extensions || []) : (allowedFileTypes?.map(type => FILE_EXTS[type]) || []).flat().map(item => `.${item}`)
|
||||
const accept = exts.join(',')
|
||||
|
||||
return (
|
||||
<input
|
||||
className='absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed'
|
||||
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||
type='file'
|
||||
onChange={handleChange}
|
||||
accept={accept}
|
||||
disabled={!!(fileConfig.number_limits && files.length >= fileConfig?.number_limits)}
|
||||
multiple={!!fileConfig.number_limits && fileConfig.number_limits > 1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileInput
|
||||
154
app/components/base/file-uploader-in-attachment/file-item.tsx
Normal file
154
app/components/base/file-uploader-in-attachment/file-item.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiDownloadLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
import FileImageRender from './file-image-render'
|
||||
import type { FileEntity } from './types'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
} from './utils'
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import cn from '@/utils/classnames'
|
||||
import ReplayLine from '@/app/components/base/icons/other/ReplayLine'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
|
||||
type FileInAttachmentItemProps = {
|
||||
file: FileEntity
|
||||
showDeleteAction?: boolean
|
||||
showDownloadAction?: boolean
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
canPreview?: boolean
|
||||
}
|
||||
const FileInAttachmentItem = ({
|
||||
file,
|
||||
showDeleteAction,
|
||||
showDownloadAction = true,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
canPreview,
|
||||
}: FileInAttachmentItemProps) => {
|
||||
const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
|
||||
const ext = getFileExtension(name, type, isRemote)
|
||||
const isImageFile = supportFileType === SupportUploadFileTypes.image
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs',
|
||||
progress === -1 && 'border-state-destructive-border bg-state-destructive-hover',
|
||||
)}>
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{
|
||||
isImageFile && (
|
||||
<FileImageRender
|
||||
className='h-8 w-8'
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isImageFile && (
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='lg'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='mr-1 w-0 grow'>
|
||||
<div
|
||||
className='system-xs-medium mb-0.5 flex items-center truncate text-text-secondary'
|
||||
title={file.name}
|
||||
>
|
||||
<div className='truncate'>{name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase flex items-center text-text-tertiary'>
|
||||
{
|
||||
ext && (
|
||||
<span>{ext.toLowerCase()}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
ext && (
|
||||
<span className='system-2xs-medium mx-1'>•</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!file.size && (
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{
|
||||
progress >= 0 && !fileIsUploaded(file) && (
|
||||
<ProgressCircle
|
||||
className='mr-2.5'
|
||||
percentage={progress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
progress === -1 && (
|
||||
<ActionButton
|
||||
className='mr-1'
|
||||
onClick={() => onReUpload?.(id)}
|
||||
>
|
||||
<ReplayLine className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDeleteAction && (
|
||||
<ActionButton onClick={() => onRemove?.(id)}>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
canPreview && isImageFile && (
|
||||
<ActionButton className='mr-1' onClick={() => setImagePreviewUrl(url || '')}>
|
||||
<RiEyeLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
{
|
||||
showDownloadAction && (
|
||||
<ActionButton onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(url || base64Url || '', name)
|
||||
}}>
|
||||
<RiDownloadLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
imagePreviewUrl && canPreview && (
|
||||
<ImagePreview
|
||||
title={name}
|
||||
url={imagePreviewUrl}
|
||||
onCancel={() => setImagePreviewUrl('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileInAttachmentItem)
|
||||
@ -0,0 +1,91 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
RiFile3Fill,
|
||||
RiFileCodeFill,
|
||||
RiFileExcelFill,
|
||||
RiFileGifFill,
|
||||
RiFileImageFill,
|
||||
RiFileMusicFill,
|
||||
RiFilePdf2Fill,
|
||||
RiFilePpt2Fill,
|
||||
RiFileTextFill,
|
||||
RiFileVideoFill,
|
||||
RiFileWordFill,
|
||||
RiMarkdownFill,
|
||||
} from '@remixicon/react'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
import type { FileAppearanceType } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const FILE_TYPE_ICON_MAP = {
|
||||
[FileAppearanceTypeEnum.pdf]: {
|
||||
component: RiFilePdf2Fill,
|
||||
color: 'text-[#EA3434]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.image]: {
|
||||
component: RiFileImageFill,
|
||||
color: 'text-[#00B2EA]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.video]: {
|
||||
component: RiFileVideoFill,
|
||||
color: 'text-[#844FDA]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.audio]: {
|
||||
component: RiFileMusicFill,
|
||||
color: 'text-[#FF3093]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.document]: {
|
||||
component: RiFileTextFill,
|
||||
color: 'text-[#6F8BB5]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.code]: {
|
||||
component: RiFileCodeFill,
|
||||
color: 'text-[#BCC0D1]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.markdown]: {
|
||||
component: RiMarkdownFill,
|
||||
color: 'text-[#309BEC]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.custom]: {
|
||||
component: RiFile3Fill,
|
||||
color: 'text-[#BCC0D1]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.excel]: {
|
||||
component: RiFileExcelFill,
|
||||
color: 'text-[#01AC49]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.word]: {
|
||||
component: RiFileWordFill,
|
||||
color: 'text-[#2684FF]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.ppt]: {
|
||||
component: RiFilePpt2Fill,
|
||||
color: 'text-[#FF650F]',
|
||||
},
|
||||
[FileAppearanceTypeEnum.gif]: {
|
||||
component: RiFileGifFill,
|
||||
color: 'text-[#00B2EA]',
|
||||
},
|
||||
}
|
||||
type FileTypeIconProps = {
|
||||
type: FileAppearanceType
|
||||
size?: 'sm' | 'lg' | 'md'
|
||||
className?: string
|
||||
}
|
||||
const SizeMap = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
}
|
||||
const FileTypeIcon = ({
|
||||
type,
|
||||
size = 'sm',
|
||||
className,
|
||||
}: FileTypeIconProps) => {
|
||||
const Icon = FILE_TYPE_ICON_MAP[type]?.component || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].component
|
||||
const color = FILE_TYPE_ICON_MAP[type]?.color || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].color
|
||||
|
||||
return <Icon className={cn('shrink-0', SizeMap[size], color, className)} />
|
||||
}
|
||||
|
||||
export default memo(FileTypeIcon)
|
||||
368
app/components/base/file-uploader-in-attachment/hooks.ts
Normal file
368
app/components/base/file-uploader-in-attachment/hooks.ts
Normal file
@ -0,0 +1,368 @@
|
||||
import type { ClipboardEvent } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import produce from 'immer'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { noop } from 'lodash-es'
|
||||
import type { FileEntity, FileUpload, FileUploadConfigResponse } from './types'
|
||||
import { useFileStore } from './store'
|
||||
import {
|
||||
fileUpload,
|
||||
getSupportFileType,
|
||||
isAllowedFileExtension,
|
||||
} from './utils'
|
||||
import {
|
||||
AUDIO_SIZE_LIMIT,
|
||||
FILE_SIZE_LIMIT,
|
||||
IMG_SIZE_LIMIT,
|
||||
MAX_FILE_UPLOAD_LIMIT,
|
||||
VIDEO_SIZE_LIMIT,
|
||||
} from './constants'
|
||||
import { SupportUploadFileTypes } from './types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
const uploadRemoteFileInfo = () => {
|
||||
console.log('TODO')
|
||||
}
|
||||
|
||||
export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => {
|
||||
const imgSizeLimit = Number(fileUploadConfig?.image_file_size_limit) * 1024 * 1024 || IMG_SIZE_LIMIT
|
||||
const docSizeLimit = Number(fileUploadConfig?.file_size_limit) * 1024 * 1024 || FILE_SIZE_LIMIT
|
||||
const audioSizeLimit = Number(fileUploadConfig?.audio_file_size_limit) * 1024 * 1024 || AUDIO_SIZE_LIMIT
|
||||
const videoSizeLimit = Number(fileUploadConfig?.video_file_size_limit) * 1024 * 1024 || VIDEO_SIZE_LIMIT
|
||||
const maxFileUploadLimit = Number(fileUploadConfig?.workflow_file_upload_limit) || MAX_FILE_UPLOAD_LIMIT
|
||||
|
||||
return {
|
||||
imgSizeLimit,
|
||||
docSizeLimit,
|
||||
audioSizeLimit,
|
||||
videoSizeLimit,
|
||||
maxFileUploadLimit,
|
||||
}
|
||||
}
|
||||
|
||||
export const useFile = (fileConfig: FileUpload) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const fileStore = useFileStore()
|
||||
const params = useParams()
|
||||
const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig)
|
||||
|
||||
const checkSizeLimit = useCallback((fileType: string, fileSize: number) => {
|
||||
switch (fileType) {
|
||||
case SupportUploadFileTypes.image: {
|
||||
if (fileSize > imgSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.image,
|
||||
size: formatFileSize(imgSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.document: {
|
||||
if (fileSize > docSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.document,
|
||||
size: formatFileSize(docSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.audio: {
|
||||
if (fileSize > audioSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.audio,
|
||||
size: formatFileSize(audioSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.video: {
|
||||
if (fileSize > videoSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.video,
|
||||
size: formatFileSize(videoSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
case SupportUploadFileTypes.custom: {
|
||||
if (fileSize > docSizeLimit) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.uploadFromComputerLimit', {
|
||||
type: SupportUploadFileTypes.document,
|
||||
size: formatFileSize(docSizeLimit),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
default: {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}, [audioSizeLimit, docSizeLimit, imgSizeLimit, notify, t, videoSizeLimit])
|
||||
|
||||
const handleAddFile = useCallback((newFile: FileEntity) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = produce(files, (draft) => {
|
||||
draft.push(newFile)
|
||||
})
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleUpdateFile = useCallback((newFile: FileEntity) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = produce(files, (draft) => {
|
||||
const index = draft.findIndex(file => file.id === newFile.id)
|
||||
|
||||
if (index > -1)
|
||||
draft[index] = newFile
|
||||
})
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleRemoveFile = useCallback((fileId: string) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
|
||||
const newFiles = files.filter(file => file.id !== fileId)
|
||||
setFiles(newFiles)
|
||||
}, [fileStore])
|
||||
|
||||
const handleReUploadFile = useCallback((fileId: string) => {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
const index = files.findIndex(file => file.id === fileId)
|
||||
|
||||
if (index > -1) {
|
||||
const uploadingFile = files[index]
|
||||
const newFiles = produce(files, (draft) => {
|
||||
draft[index].progress = 0
|
||||
})
|
||||
setFiles(newFiles)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile!,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: () => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [fileStore, notify, t, handleUpdateFile])
|
||||
|
||||
const startProgressTimer = useCallback((fileId: string) => {
|
||||
const timer = setInterval(() => {
|
||||
const files = fileStore.getState().files
|
||||
const file = files.find(file => file.id === fileId)
|
||||
|
||||
if (file && file.progress < 80 && file.progress >= 0)
|
||||
handleUpdateFile({ ...file, progress: file.progress + 20 })
|
||||
else
|
||||
clearTimeout(timer)
|
||||
}, 200)
|
||||
}, [fileStore, handleUpdateFile])
|
||||
const handleLoadFileFromLink = useCallback((url: string) => {
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
|
||||
const uploadingFile = {
|
||||
id: uuid4(),
|
||||
name: url,
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: 0,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: '',
|
||||
url,
|
||||
isRemote: true,
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
startProgressTimer(uploadingFile.id)
|
||||
|
||||
uploadRemoteFileInfo(url, !!params.token).then((res) => {
|
||||
const newFile = {
|
||||
...uploadingFile,
|
||||
type: res.mime_type,
|
||||
size: res.size,
|
||||
progress: 100,
|
||||
supportFileType: getSupportFileType(res.name, res.mime_type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)),
|
||||
uploadedId: res.id,
|
||||
url: res.url,
|
||||
}
|
||||
if (!isAllowedFileExtension(res.name, res.mime_type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
}
|
||||
if (!checkSizeLimit(newFile.supportFileType, newFile.size))
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
else
|
||||
handleUpdateFile(newFile)
|
||||
}).catch(() => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.pasteFileLinkInvalid') })
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
})
|
||||
}, [checkSizeLimit, handleAddFile, handleUpdateFile, notify, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, params.token])
|
||||
|
||||
const handleLoadFileFromLinkSuccess = useCallback(noop, [])
|
||||
|
||||
const handleLoadFileFromLinkError = useCallback(noop, [])
|
||||
|
||||
const handleClearFiles = useCallback(() => {
|
||||
const {
|
||||
setFiles,
|
||||
} = fileStore.getState()
|
||||
setFiles([])
|
||||
}, [fileStore])
|
||||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
if (!isAllowedFileExtension(file.name, file.type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
return
|
||||
}
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
|
||||
if (!checkSizeLimit(fileType, file.size))
|
||||
return
|
||||
|
||||
const reader = new FileReader()
|
||||
const isImage = file.type.startsWith('image')
|
||||
|
||||
reader.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const uploadingFile = {
|
||||
id: uuid4(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)),
|
||||
originalFile: file,
|
||||
base64Url: isImage ? reader.result as string : '',
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
fileUpload({
|
||||
file: uploadingFile.originalFile,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: () => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
})
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerReadError') })
|
||||
},
|
||||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions])
|
||||
|
||||
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.clipboardData?.files[0]
|
||||
const text = e.clipboardData?.getData('text/plain')
|
||||
if (file && !text) {
|
||||
e.preventDefault()
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}, [handleLocalFileUpload])
|
||||
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(true)
|
||||
}, [])
|
||||
|
||||
const handleDragFileOver = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragFileLeave = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDropFile = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
|
||||
const file = e.dataTransfer.files[0]
|
||||
|
||||
if (file)
|
||||
handleLocalFileUpload(file)
|
||||
}, [handleLocalFileUpload])
|
||||
|
||||
return {
|
||||
handleAddFile,
|
||||
handleUpdateFile,
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
handleLoadFileFromLink,
|
||||
handleLoadFileFromLinkSuccess,
|
||||
handleLoadFileFromLinkError,
|
||||
handleClearFiles,
|
||||
handleLocalFileUpload,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
handleDragFileEnter,
|
||||
handleDragFileOver,
|
||||
handleDragFileLeave,
|
||||
handleDropFile,
|
||||
}
|
||||
}
|
||||
132
app/components/base/file-uploader-in-attachment/index.tsx
Normal file
132
app/components/base/file-uploader-in-attachment/index.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiLink,
|
||||
RiUploadCloud2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFile } from './hooks'
|
||||
import type { FileEntity, FileUpload } from './types'
|
||||
import FileFromLinkOrLocal from './file-from-link-or-local'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useStore,
|
||||
} from './store'
|
||||
import FileInput from './file-input'
|
||||
import FileItem from './file-item'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type Option = {
|
||||
value: string
|
||||
label: string
|
||||
icon: JSX.Element
|
||||
}
|
||||
type FileUploaderInAttachmentProps = {
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileUploaderInAttachment = ({
|
||||
fileConfig,
|
||||
}: FileUploaderInAttachmentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useStore(s => s.files)
|
||||
const {
|
||||
handleRemoveFile,
|
||||
handleReUploadFile,
|
||||
} = useFile(fileConfig)
|
||||
const options = [
|
||||
{
|
||||
value: TransferMethod.local_file,
|
||||
label: t('common.fileUploader.uploadFromComputer'),
|
||||
icon: <RiUploadCloud2Line className='h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: TransferMethod.remote_url,
|
||||
label: t('common.fileUploader.pasteFileLink'),
|
||||
icon: <RiLink className='h-4 w-4' />,
|
||||
},
|
||||
]
|
||||
|
||||
const renderButton = useCallback((option: Option, open?: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
// variant='tertiary'
|
||||
className={cn('relative grow', open && 'bg-components-button-tertiary-bg-hover')}
|
||||
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
|
||||
>
|
||||
{option.icon}
|
||||
<span className='ml-1'>{option.label}</span>
|
||||
{
|
||||
option.value === TransferMethod.local_file && (
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
)
|
||||
}, [fileConfig, files.length])
|
||||
const renderTrigger = useCallback((option: Option) => {
|
||||
return (open: boolean) => renderButton(option, open)
|
||||
}, [renderButton])
|
||||
const renderOption = useCallback((option: Option) => {
|
||||
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file))
|
||||
return renderButton(option)
|
||||
|
||||
if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) {
|
||||
return (
|
||||
<FileFromLinkOrLocal
|
||||
key={option.value}
|
||||
showFromLocal={false}
|
||||
trigger={renderTrigger(option)}
|
||||
fileConfig={fileConfig}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [renderButton, renderTrigger, fileConfig])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{options.map(renderOption)}
|
||||
</div>
|
||||
<div className='mt-1 space-y-1'>
|
||||
{
|
||||
files.map(file => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
showDeleteAction
|
||||
showDownloadAction={false}
|
||||
onRemove={() => handleRemoveFile(file.id)}
|
||||
onReUpload={() => handleReUploadFile(file.id)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FileUploaderInAttachmentWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileUploaderInAttachmentWrapper = ({
|
||||
value,
|
||||
onChange,
|
||||
fileConfig,
|
||||
}: FileUploaderInAttachmentWrapperProps) => {
|
||||
return (
|
||||
<FileContextProvider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FileUploaderInAttachment fileConfig={fileConfig} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploaderInAttachmentWrapper
|
||||
67
app/components/base/file-uploader-in-attachment/store.tsx
Normal file
67
app/components/base/file-uploader-in-attachment/store.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
create,
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
import type {
|
||||
FileEntity,
|
||||
} from './types'
|
||||
|
||||
type Shape = {
|
||||
files: FileEntity[]
|
||||
setFiles: (files: FileEntity[]) => void
|
||||
}
|
||||
|
||||
export const createFileStore = (
|
||||
value: FileEntity[] = [],
|
||||
onChange?: (files: FileEntity[]) => void,
|
||||
) => {
|
||||
return create<Shape>(set => ({
|
||||
files: value ? [...value] : [],
|
||||
setFiles: (files) => {
|
||||
set({ files })
|
||||
onChange?.(files)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type FileStore = ReturnType<typeof createFileStore>
|
||||
export const FileContext = createContext<FileStore | null>(null)
|
||||
|
||||
export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(FileContext)
|
||||
if (!store)
|
||||
throw new Error('Missing FileContext.Provider in the tree')
|
||||
|
||||
return useZustandStore(store, selector)
|
||||
}
|
||||
|
||||
export const useFileStore = () => {
|
||||
return useContext(FileContext)!
|
||||
}
|
||||
|
||||
type FileProviderProps = {
|
||||
children: React.ReactNode
|
||||
value?: FileEntity[]
|
||||
onChange?: (files: FileEntity[]) => void
|
||||
}
|
||||
export const FileContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
}: FileProviderProps) => {
|
||||
const storeRef = useRef<FileStore | undefined>(undefined)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createFileStore(value, onChange)
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</FileContext.Provider>
|
||||
)
|
||||
}
|
||||
83
app/components/base/file-uploader-in-attachment/types.ts
Normal file
83
app/components/base/file-uploader-in-attachment/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
|
||||
export enum FileAppearanceTypeEnum {
|
||||
image = 'image',
|
||||
video = 'video',
|
||||
audio = 'audio',
|
||||
document = 'document',
|
||||
code = 'code',
|
||||
pdf = 'pdf',
|
||||
markdown = 'markdown',
|
||||
excel = 'excel',
|
||||
word = 'word',
|
||||
ppt = 'ppt',
|
||||
gif = 'gif',
|
||||
custom = 'custom',
|
||||
}
|
||||
|
||||
export type FileAppearanceType = keyof typeof FileAppearanceTypeEnum
|
||||
|
||||
export type FileEntity = {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
progress: number
|
||||
transferMethod: TransferMethod
|
||||
supportFileType: string
|
||||
originalFile?: File
|
||||
uploadedId?: string
|
||||
base64Url?: string
|
||||
url?: string
|
||||
isRemote?: boolean
|
||||
}
|
||||
|
||||
export type EnabledOrDisabled = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export enum Resolution {
|
||||
low = 'low',
|
||||
high = 'high',
|
||||
}
|
||||
|
||||
export type FileUploadConfigResponse = {
|
||||
batch_count_limit: number
|
||||
image_file_size_limit?: number | string // default is 10MB
|
||||
file_size_limit: number // default is 15MB
|
||||
audio_file_size_limit?: number // default is 50MB
|
||||
video_file_size_limit?: number // default is 100MB
|
||||
workflow_file_upload_limit?: number // default is 10
|
||||
}
|
||||
|
||||
export type FileUpload = {
|
||||
image?: EnabledOrDisabled & {
|
||||
detail?: Resolution
|
||||
number_limits?: number
|
||||
transfer_methods?: TransferMethod[]
|
||||
}
|
||||
allowed_file_types?: string[]
|
||||
allowed_file_extensions?: string[]
|
||||
allowed_file_upload_methods?: TransferMethod[]
|
||||
number_limits?: number
|
||||
fileUploadConfig?: FileUploadConfigResponse
|
||||
} & EnabledOrDisabled
|
||||
|
||||
export enum SupportUploadFileTypes {
|
||||
image = 'image',
|
||||
document = 'document',
|
||||
audio = 'audio',
|
||||
video = 'video',
|
||||
custom = 'custom',
|
||||
}
|
||||
|
||||
export type FileResponse = {
|
||||
related_id: string
|
||||
extension: string
|
||||
filename: string
|
||||
size: number
|
||||
mime_type: string
|
||||
transfer_method: TransferMethod
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
194
app/components/base/file-uploader-in-attachment/utils.ts
Normal file
194
app/components/base/file-uploader-in-attachment/utils.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import mime from 'mime'
|
||||
import { FileAppearanceTypeEnum, SupportUploadFileTypes } from './types'
|
||||
import type { FileEntity, FileResponse } from './types'
|
||||
import { FILE_EXTS } from './constants'
|
||||
import { upload } from '@/service/base'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type FileUploadParams = {
|
||||
file: File
|
||||
onProgressCallback: (progress: number) => void
|
||||
onSuccessCallback: (res: { id: string }) => void
|
||||
onErrorCallback: () => void
|
||||
}
|
||||
type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void
|
||||
export const fileUpload: FileUpload = ({
|
||||
file,
|
||||
onProgressCallback,
|
||||
onSuccessCallback,
|
||||
onErrorCallback,
|
||||
}) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const onProgress = (e: ProgressEvent) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.floor(e.loaded / e.total * 100)
|
||||
onProgressCallback(percent)
|
||||
}
|
||||
}
|
||||
|
||||
upload({
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: formData,
|
||||
onprogress: onProgress,
|
||||
})
|
||||
.then((res: { id: string }) => {
|
||||
onSuccessCallback(res)
|
||||
})
|
||||
.catch(() => {
|
||||
onErrorCallback()
|
||||
})
|
||||
}
|
||||
|
||||
export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => {
|
||||
let extension = ''
|
||||
if (fileMimetype)
|
||||
extension = mime.getExtension(fileMimetype) || ''
|
||||
|
||||
if (fileName && !extension) {
|
||||
const fileNamePair = fileName.split('.')
|
||||
const fileNamePairLength = fileNamePair.length
|
||||
|
||||
if (fileNamePairLength > 1)
|
||||
extension = fileNamePair[fileNamePairLength - 1]
|
||||
else
|
||||
extension = ''
|
||||
}
|
||||
|
||||
if (isRemote)
|
||||
extension = ''
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
export const getFileAppearanceType = (fileName: string, fileMimetype: string) => {
|
||||
const extension = getFileExtension(fileName, fileMimetype)
|
||||
|
||||
if (extension === 'gif')
|
||||
return FileAppearanceTypeEnum.gif
|
||||
|
||||
if (FILE_EXTS.image.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.image
|
||||
|
||||
if (FILE_EXTS.video.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.video
|
||||
|
||||
if (FILE_EXTS.audio.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.audio
|
||||
|
||||
if (extension === 'html')
|
||||
return FileAppearanceTypeEnum.code
|
||||
|
||||
if (extension === 'pdf')
|
||||
return FileAppearanceTypeEnum.pdf
|
||||
|
||||
if (extension === 'md' || extension === 'markdown' || extension === 'mdx')
|
||||
return FileAppearanceTypeEnum.markdown
|
||||
|
||||
if (extension === 'xlsx' || extension === 'xls')
|
||||
return FileAppearanceTypeEnum.excel
|
||||
|
||||
if (extension === 'docx' || extension === 'doc')
|
||||
return FileAppearanceTypeEnum.word
|
||||
|
||||
if (extension === 'pptx' || extension === 'ppt')
|
||||
return FileAppearanceTypeEnum.ppt
|
||||
|
||||
if (FILE_EXTS.document.includes(extension.toUpperCase()))
|
||||
return FileAppearanceTypeEnum.document
|
||||
|
||||
return FileAppearanceTypeEnum.custom
|
||||
}
|
||||
|
||||
export const getSupportFileType = (fileName: string, fileMimetype: string, isCustom?: boolean) => {
|
||||
if (isCustom)
|
||||
return SupportUploadFileTypes.custom
|
||||
|
||||
const extension = getFileExtension(fileName, fileMimetype)
|
||||
for (const key in FILE_EXTS) {
|
||||
if ((FILE_EXTS[key]).includes(extension.toUpperCase()))
|
||||
return key
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getProcessedFiles = (files: FileEntity[]) => {
|
||||
return files.filter(file => file.progress !== -1).map(fileItem => ({
|
||||
type: fileItem.supportFileType,
|
||||
transfer_method: fileItem.transferMethod,
|
||||
url: fileItem.url || '',
|
||||
upload_file_id: fileItem.uploadedId || '',
|
||||
}))
|
||||
}
|
||||
|
||||
export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
|
||||
return files.map((fileItem) => {
|
||||
return {
|
||||
id: fileItem.related_id,
|
||||
name: fileItem.filename,
|
||||
size: fileItem.size || 0,
|
||||
type: fileItem.mime_type,
|
||||
progress: 100,
|
||||
transferMethod: fileItem.transfer_method,
|
||||
supportFileType: fileItem.type,
|
||||
uploadedId: fileItem.related_id,
|
||||
url: fileItem.url,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getFileNameFromUrl = (url: string) => {
|
||||
const urlParts = url.split('/')
|
||||
return urlParts[urlParts.length - 1] || ''
|
||||
}
|
||||
|
||||
export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => {
|
||||
if (allowFileTypes.includes(SupportUploadFileTypes.custom))
|
||||
return allowFileExtensions.map(item => item.slice(1).toUpperCase())
|
||||
|
||||
return allowFileTypes.map(type => FILE_EXTS[type]).flat()
|
||||
}
|
||||
|
||||
export const isAllowedFileExtension = (fileName: string, fileMimetype: string, allowFileTypes: string[], allowFileExtensions: string[]) => {
|
||||
return getSupportFileExtensionList(allowFileTypes, allowFileExtensions).includes(getFileExtension(fileName, fileMimetype).toUpperCase())
|
||||
}
|
||||
|
||||
export const getFilesInLogs = (rawData: any) => {
|
||||
const result = Object.keys(rawData || {}).map((key) => {
|
||||
if (typeof rawData[key] === 'object' && rawData[key]?.dify_model_identity === '__dify__file__') {
|
||||
return {
|
||||
varName: key,
|
||||
list: getProcessedFilesFromResponse([rawData[key]]),
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rawData[key]) && rawData[key].some(item => item?.dify_model_identity === '__dify__file__')) {
|
||||
return {
|
||||
varName: key,
|
||||
list: getProcessedFilesFromResponse(rawData[key]),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}).filter(Boolean)
|
||||
return result
|
||||
}
|
||||
|
||||
export const fileIsUploaded = (file: FileEntity) => {
|
||||
if (file.uploadedId)
|
||||
return true
|
||||
|
||||
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)
|
||||
}
|
||||
36
app/components/base/icons/other/ReplayLine.json
Normal file
36
app/components/base/icons/other/ReplayLine.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "20",
|
||||
"height": "20",
|
||||
"viewBox": "0 0 20 20",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Retry"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Vector",
|
||||
"d": "M9.99996 1.66669C14.6023 1.66669 18.3333 5.39765 18.3333 10C18.3333 14.6024 14.6023 18.3334 9.99996 18.3334C5.39758 18.3334 1.66663 14.6024 1.66663 10H3.33329C3.33329 13.6819 6.31806 16.6667 9.99996 16.6667C13.6819 16.6667 16.6666 13.6819 16.6666 10C16.6666 6.31812 13.6819 3.33335 9.99996 3.33335C7.70848 3.33335 5.68702 4.48947 4.48705 6.25022L6.66663 6.25002V7.91669H1.66663V2.91669H3.33329L3.3332 4.99934C4.85358 2.97565 7.2739 1.66669 9.99996 1.66669Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "ReplayLine"
|
||||
}
|
||||
20
app/components/base/icons/other/ReplayLine.tsx
Normal file
20
app/components/base/icons/other/ReplayLine.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './ReplayLine.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'ReplayLine'
|
||||
|
||||
export default Icon
|
||||
20
app/components/base/progress-bar/index.tsx
Normal file
20
app/components/base/progress-bar/index.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
type ProgressBarProps = {
|
||||
percent: number
|
||||
}
|
||||
const ProgressBar = ({
|
||||
percent = 0,
|
||||
}: ProgressBarProps) => {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-2 w-[100px] rounded-lg bg-gray-100'>
|
||||
<div
|
||||
className='h-1 rounded-lg bg-[#2970FF]'
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-xs font-medium text-gray-500'>{percent}%</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressBar
|
||||
64
app/components/base/progress-bar/progress-circle.tsx
Normal file
64
app/components/base/progress-bar/progress-circle.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { memo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ProgressCircleProps = {
|
||||
className?: string
|
||||
percentage?: number
|
||||
size?: number
|
||||
circleStrokeWidth?: number
|
||||
circleStrokeColor?: string
|
||||
circleFillColor?: string
|
||||
sectorFillColor?: string
|
||||
}
|
||||
|
||||
const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
||||
className,
|
||||
percentage = 0,
|
||||
size = 12,
|
||||
circleStrokeWidth = 1,
|
||||
circleStrokeColor = 'stroke-components-progress-brand-border',
|
||||
circleFillColor = 'fill-components-progress-brand-bg',
|
||||
sectorFillColor = 'fill-components-progress-brand-progress',
|
||||
}) => {
|
||||
const radius = size / 2
|
||||
const center = size / 2
|
||||
const angle = (percentage / 101) * 360
|
||||
const radians = (angle * Math.PI) / 180
|
||||
const x = center + radius * Math.cos(radians - Math.PI / 2)
|
||||
const y = center + radius * Math.sin(radians - Math.PI / 2)
|
||||
const largeArcFlag = percentage > 50 ? 1 : 0
|
||||
|
||||
const pathData = `
|
||||
M ${center},${center}
|
||||
L ${center},${center - radius}
|
||||
A ${radius},${radius} 0 ${largeArcFlag} 1 ${x},${y}
|
||||
Z
|
||||
`
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size + circleStrokeWidth}
|
||||
height={size + circleStrokeWidth}
|
||||
viewBox={`0 0 ${size + circleStrokeWidth} ${size + circleStrokeWidth}`}
|
||||
className={className}
|
||||
>
|
||||
<circle
|
||||
className={cn(
|
||||
circleFillColor,
|
||||
circleStrokeColor,
|
||||
)}
|
||||
cx={center + circleStrokeWidth / 2}
|
||||
cy={center + circleStrokeWidth / 2}
|
||||
r={radius}
|
||||
strokeWidth={circleStrokeWidth}
|
||||
/>
|
||||
<path
|
||||
className={cn(sectorFillColor)}
|
||||
d={pathData}
|
||||
transform={`translate(${circleStrokeWidth / 2}, ${circleStrokeWidth / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProgressCircle)
|
||||
@ -58,7 +58,7 @@ type IAnswerProps = {
|
||||
item: ChatItem
|
||||
feedbackDisabled: boolean
|
||||
onFeedback?: FeedbackFunc
|
||||
isResponsing?: boolean
|
||||
isResponding?: boolean
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
item,
|
||||
feedbackDisabled = false,
|
||||
onFeedback,
|
||||
isResponsing,
|
||||
isResponding,
|
||||
allToolIcons,
|
||||
}) => {
|
||||
const { id, content, feedback, agent_thoughts, workflowProcess } = item
|
||||
@ -153,7 +153,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
<Thought
|
||||
thought={item}
|
||||
allToolIcons={allToolIcons || {}}
|
||||
isFinished={!!item.observation || !isResponsing}
|
||||
isFinished={!!item.observation || !isResponding}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -169,7 +169,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
<div key={id}>
|
||||
<div className='flex items-start'>
|
||||
<div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
|
||||
{isResponsing
|
||||
{isResponding
|
||||
&& <div className={s.typeingIcon}>
|
||||
<LoadingAnim type='avatar' />
|
||||
</div>
|
||||
@ -181,7 +181,7 @@ const Answer: FC<IAnswerProps> = ({
|
||||
{workflowProcess && (
|
||||
<WorkflowProcess data={workflowProcess} hideInfo />
|
||||
)}
|
||||
{(isResponsing && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content))
|
||||
{(isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content))
|
||||
? (
|
||||
<div className='flex items-center justify-center w-6 h-5'>
|
||||
<LoadingAnim type='text' />
|
||||
|
||||
@ -30,7 +30,7 @@ export type IChatProps = {
|
||||
checkCanSend?: () => boolean
|
||||
onSend?: (message: string, files: VisionFile[]) => void
|
||||
useCurrentUserAvatar?: boolean
|
||||
isResponsing?: boolean
|
||||
isResponding?: boolean
|
||||
controlClearQuery?: number
|
||||
visionConfig?: VisionSettings
|
||||
}
|
||||
@ -43,7 +43,7 @@ const Chat: FC<IChatProps> = ({
|
||||
checkCanSend,
|
||||
onSend = () => { },
|
||||
useCurrentUserAvatar,
|
||||
isResponsing,
|
||||
isResponding,
|
||||
controlClearQuery,
|
||||
visionConfig,
|
||||
}) => {
|
||||
@ -95,7 +95,7 @@ const Chat: FC<IChatProps> = ({
|
||||
if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
|
||||
if (files.length)
|
||||
onClear()
|
||||
if (!isResponsing)
|
||||
if (!isResponding)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
@ -129,7 +129,7 @@ const Chat: FC<IChatProps> = ({
|
||||
item={item}
|
||||
feedbackDisabled={feedbackDisabled}
|
||||
onFeedback={onFeedback}
|
||||
isResponsing={isResponsing && isLast}
|
||||
isResponding={isResponding && isLast}
|
||||
/>
|
||||
}
|
||||
return (
|
||||
|
||||
@ -23,7 +23,11 @@ import { API_KEY, APP_ID, APP_INFO, isShowPrompt, promptTemplate } from '@/confi
|
||||
import type { Annotation as AnnotationType } from '@/types/log'
|
||||
import { addFileInfos, sortAgentSorts } from '@/utils/tools'
|
||||
|
||||
const Main: FC = () => {
|
||||
export type IMainProps = {
|
||||
params: any
|
||||
}
|
||||
|
||||
const Main: FC<IMainProps> = () => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
@ -33,7 +37,7 @@ const Main: FC = () => {
|
||||
* app info
|
||||
*/
|
||||
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
|
||||
const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
|
||||
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
|
||||
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
|
||||
const [inited, setInited] = useState<boolean>(false)
|
||||
// in mobile, show sidebar by click button
|
||||
@ -86,7 +90,7 @@ const Main: FC = () => {
|
||||
setCurrInputs(inputs)
|
||||
setChatStarted()
|
||||
// parse variables in introduction
|
||||
setChatList(generateNewChatListWithOpenstatement('', inputs))
|
||||
setChatList(generateNewChatListWithOpenStatement('', inputs))
|
||||
}
|
||||
const hasSetInputs = (() => {
|
||||
if (!isNewConversation)
|
||||
@ -121,10 +125,10 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
// update chat list of current conversation
|
||||
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
|
||||
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) {
|
||||
fetchChatList(currConversationId).then((res: any) => {
|
||||
const { data } = res
|
||||
const newChatList: ChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
|
||||
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs)
|
||||
|
||||
data.forEach((item: any) => {
|
||||
newChatList.push({
|
||||
@ -148,7 +152,7 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
if (isNewConversation && isChatStarted)
|
||||
setChatList(generateNewChatListWithOpenstatement())
|
||||
setChatList(generateNewChatListWithOpenStatement())
|
||||
}
|
||||
useEffect(handleConversationSwitch, [currConversationId, inited])
|
||||
|
||||
@ -176,7 +180,7 @@ const Main: FC = () => {
|
||||
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
|
||||
}, [chatList, currConversationId])
|
||||
// user can not edit inputs if user had send message
|
||||
const canEditInpus = !chatList.some(item => item.isAnswer === false) && isNewConversation
|
||||
const canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
|
||||
const createNewChat = () => {
|
||||
// if new chat is already exist, do not create new chat
|
||||
if (conversationList.some(item => item.id === '-1'))
|
||||
@ -193,21 +197,21 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
// sometime introduction is not applied to state
|
||||
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
|
||||
let caculatedIntroduction = introduction || conversationIntroduction || ''
|
||||
const caculatedPromptVariables = inputs || currInputs || null
|
||||
if (caculatedIntroduction && caculatedPromptVariables)
|
||||
caculatedIntroduction = replaceVarWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
|
||||
const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null) => {
|
||||
let calculatedIntroduction = introduction || conversationIntroduction || ''
|
||||
const calculatedPromptVariables = inputs || currInputs || null
|
||||
if (calculatedIntroduction && calculatedPromptVariables)
|
||||
calculatedIntroduction = replaceVarWithValues(calculatedIntroduction, promptConfig?.prompt_variables || [], calculatedPromptVariables)
|
||||
|
||||
const openstatement = {
|
||||
const openStatement = {
|
||||
id: `${Date.now()}`,
|
||||
content: caculatedIntroduction,
|
||||
content: calculatedIntroduction,
|
||||
isAnswer: true,
|
||||
feedbackDisabled: true,
|
||||
isOpeningStatement: isShowPrompt,
|
||||
}
|
||||
if (caculatedIntroduction)
|
||||
return [openstatement]
|
||||
if (calculatedIntroduction)
|
||||
return [openStatement]
|
||||
|
||||
return []
|
||||
}
|
||||
@ -221,9 +225,13 @@ const Main: FC = () => {
|
||||
(async () => {
|
||||
try {
|
||||
const [conversationData, appParams] = await Promise.all([fetchConversations(), fetchAppParams()])
|
||||
|
||||
// handle current conversation id
|
||||
const { data: conversations } = conversationData as { data: ConversationItem[] }
|
||||
const { data: conversations, error } = conversationData as { data: ConversationItem[]; error: string }
|
||||
if (error) {
|
||||
Toast.notify({ type: 'error', message: error })
|
||||
throw new Error(error)
|
||||
return
|
||||
}
|
||||
const _conversationId = getConversationIdFromStorage(APP_ID)
|
||||
const isNotNewConversation = conversations.some(item => item.id === _conversationId)
|
||||
|
||||
@ -255,14 +263,14 @@ const Main: FC = () => {
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
else {
|
||||
setIsUnknwonReason(true)
|
||||
setIsUnknownReason(true)
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
|
||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const { notify } = Toast
|
||||
const logError = (message: string) => {
|
||||
@ -279,8 +287,8 @@ const Main: FC = () => {
|
||||
const inputLens = Object.values(currInputs).length
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length
|
||||
|
||||
const emytyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
|
||||
if (emytyInput) {
|
||||
const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v)
|
||||
if (emptyInput) {
|
||||
logError(t('app.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
@ -291,7 +299,7 @@ const Main: FC = () => {
|
||||
const [openingSuggestedQuestions, setOpeningSuggestedQuestions] = useState<string[]>([])
|
||||
const [messageTaskId, setMessageTaskId] = useState('')
|
||||
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
|
||||
const [isResponsingConIsCurrCon, setIsResponsingConCurrCon, getIsResponsingConIsCurrCon] = useGetState(true)
|
||||
const [isRespondingConIsCurrCon, setIsRespondingConCurrCon, getIsRespondingConIsCurrCon] = useGetState(true)
|
||||
const [userQuery, setUserQuery] = useState('')
|
||||
|
||||
const updateCurrentQA = ({
|
||||
@ -317,13 +325,37 @@ const Main: FC = () => {
|
||||
setChatList(newListWithAnswer)
|
||||
}
|
||||
|
||||
const transformToServerFile = (fileItem: any) => {
|
||||
return {
|
||||
type: 'image',
|
||||
transfer_method: fileItem.transferMethod,
|
||||
url: fileItem.url,
|
||||
upload_file_id: fileItem.id,
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async (message: string, files?: VisionFile[]) => {
|
||||
if (isResponsing) {
|
||||
if (isResponding) {
|
||||
notify({ type: 'info', message: t('app.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
const toServerInputs: Record<string, any> = {}
|
||||
if (currInputs) {
|
||||
Object.keys(currInputs).forEach((key) => {
|
||||
const value = currInputs[key]
|
||||
if (value.supportFileType)
|
||||
toServerInputs[key] = transformToServerFile(value)
|
||||
|
||||
else if (value[0]?.supportFileType)
|
||||
toServerInputs[key] = value.map((item: any) => transformToServerFile(item))
|
||||
|
||||
else
|
||||
toServerInputs[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {
|
||||
inputs: currInputs,
|
||||
inputs: toServerInputs,
|
||||
query: message,
|
||||
conversation_id: isNewConversation ? null : currConversationId,
|
||||
}
|
||||
@ -340,7 +372,7 @@ const Main: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// qustion
|
||||
// question
|
||||
const questionId = `question-${Date.now()}`
|
||||
const questionItem = {
|
||||
id: questionId,
|
||||
@ -374,7 +406,7 @@ const Main: FC = () => {
|
||||
const prevTempNewConversationId = getCurrConversationId() || '-1'
|
||||
let tempNewConversationId = ''
|
||||
|
||||
setResponsingTrue()
|
||||
setRespondingTrue()
|
||||
sendChatMessage(data, {
|
||||
getAbortController: (abortController) => {
|
||||
setAbortController(abortController)
|
||||
@ -399,7 +431,7 @@ const Main: FC = () => {
|
||||
setMessageTaskId(taskId)
|
||||
// has switched to other conversation
|
||||
if (prevTempNewConversationId !== getCurrConversationId()) {
|
||||
setIsResponsingConCurrCon(false)
|
||||
setIsRespondingConCurrCon(false)
|
||||
return
|
||||
}
|
||||
updateCurrentQA({
|
||||
@ -426,7 +458,7 @@ const Main: FC = () => {
|
||||
resetNewConversationInputs()
|
||||
setChatNotStarted()
|
||||
setCurrConversationId(tempNewConversationId, APP_ID, true)
|
||||
setResponsingFalse()
|
||||
setRespondingFalse()
|
||||
},
|
||||
onFile(file) {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
@ -465,7 +497,7 @@ const Main: FC = () => {
|
||||
}
|
||||
// has switched to other conversation
|
||||
if (prevTempNewConversationId !== getCurrConversationId()) {
|
||||
setIsResponsingConCurrCon(false)
|
||||
setIsRespondingConCurrCon(false)
|
||||
return false
|
||||
}
|
||||
|
||||
@ -520,7 +552,7 @@ const Main: FC = () => {
|
||||
))
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
setRespondingFalse()
|
||||
// role back placeholder answer
|
||||
setChatList(produce(getChatList(), (draft) => {
|
||||
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
|
||||
@ -604,7 +636,7 @@ const Main: FC = () => {
|
||||
}
|
||||
|
||||
if (appUnavailable)
|
||||
return <AppUnavailable isUnknwonReason={isUnknwonReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} />
|
||||
return <AppUnavailable isUnknownReason={isUnknownReason} errMessage={!hasSetAppConfig ? 'Please set APP_ID and API_KEY in config/index.tsx' : ''} />
|
||||
|
||||
if (!APP_ID || !APP_INFO || !promptConfig)
|
||||
return <Loading type='app' />
|
||||
@ -639,7 +671,7 @@ const Main: FC = () => {
|
||||
siteInfo={APP_INFO}
|
||||
promptConfig={promptConfig}
|
||||
onStartChat={handleStartChat}
|
||||
canEidtInpus={canEditInpus}
|
||||
canEditInputs={canEditInputs}
|
||||
savedInputs={currInputs as Record<string, any>}
|
||||
onInputsChange={setCurrInputs}
|
||||
></ConfigSence>
|
||||
@ -652,7 +684,7 @@ const Main: FC = () => {
|
||||
chatList={chatList}
|
||||
onSend={handleSend}
|
||||
onFeedback={handleFeedback}
|
||||
isResponsing={isResponsing}
|
||||
isResponding={isResponding}
|
||||
checkCanSend={checkCanSend}
|
||||
visionConfig={visionConfig}
|
||||
/>
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'
|
||||
import FileUploaderInAttachmentWrapper from '../base/file-uploader-in-attachment'
|
||||
import s from './style.module.css'
|
||||
import { AppInfoComp, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'
|
||||
import type { AppInfo, PromptConfig } from '@/types/app'
|
||||
@ -20,7 +21,7 @@ export type IWelcomeProps = {
|
||||
siteInfo: AppInfo
|
||||
promptConfig: PromptConfig
|
||||
onStartChat: (inputs: Record<string, any>) => void
|
||||
canEidtInpus: boolean
|
||||
canEditInputs: boolean
|
||||
savedInputs: Record<string, any>
|
||||
onInputsChange: (inputs: Record<string, any>) => void
|
||||
}
|
||||
@ -32,10 +33,11 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
siteInfo,
|
||||
promptConfig,
|
||||
onStartChat,
|
||||
canEidtInpus,
|
||||
canEditInputs,
|
||||
savedInputs,
|
||||
onInputsChange,
|
||||
}) => {
|
||||
console.log(promptConfig)
|
||||
const { t } = useTranslation()
|
||||
const hasVar = promptConfig.prompt_variables.length > 0
|
||||
const [isFold, setIsFold] = useState<boolean>(true)
|
||||
@ -107,7 +109,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
)}
|
||||
{item.type === 'string' && (
|
||||
<input
|
||||
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
placeholder={`${item.name}${!item.required ? `(${t('app.variableTable.optional')})` : ''}`}
|
||||
value={inputs?.[item.key] || ''}
|
||||
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }}
|
||||
className={'w-full flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50'}
|
||||
@ -117,11 +119,55 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
{item.type === 'paragraph' && (
|
||||
<textarea
|
||||
className="w-full h-[104px] flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50"
|
||||
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
placeholder={`${item.name}${!item.required ? `(${t('app.variableTable.optional')})` : ''}`}
|
||||
value={inputs?.[item.key] || ''}
|
||||
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
className="block w-full p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 sm:text-xs focus:ring-blue-500 focus:border-blue-500 "
|
||||
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
value={inputs[item.key]}
|
||||
onChange={(e) => { onInputsChange({ ...inputs, [item.key]: e.target.value }) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
item.type === 'file' && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
fileConfig={{
|
||||
allowed_file_types: item.allowed_file_types,
|
||||
allowed_file_extensions: item.allowed_file_extensions,
|
||||
allowed_file_upload_methods: item.allowed_file_upload_methods!,
|
||||
number_limits: 1,
|
||||
fileUploadConfig: {} as any,
|
||||
}}
|
||||
onChange={(files) => {
|
||||
setInputs({ ...inputs, [item.key]: files[0] })
|
||||
}}
|
||||
value={inputs?.[item.key] || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
item.type === 'file-list' && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
fileConfig={{
|
||||
allowed_file_types: item.allowed_file_types,
|
||||
allowed_file_extensions: item.allowed_file_extensions,
|
||||
allowed_file_upload_methods: item.allowed_file_upload_methods!,
|
||||
number_limits: item.max_length,
|
||||
fileUploadConfig: {} as any,
|
||||
}}
|
||||
onChange={(files) => {
|
||||
setInputs({ ...inputs, [item.key]: files })
|
||||
}}
|
||||
value={inputs?.[item.key] || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -131,8 +177,8 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
const canChat = () => {
|
||||
const inputLens = Object.values(inputs).length
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length
|
||||
const emytyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0
|
||||
if (emytyInput) {
|
||||
const emptyInput = inputLens < promptVariablesLens || Object.values(inputs).filter(v => v === '').length > 0
|
||||
if (emptyInput) {
|
||||
logError(t('app.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
@ -217,7 +263,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}
|
||||
|
||||
const renderHasSetInputsPublic = () => {
|
||||
if (!canEidtInpus) {
|
||||
if (!canEditInputs) {
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
@ -260,7 +306,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}
|
||||
|
||||
const renderHasSetInputsPrivate = () => {
|
||||
if (!canEidtInpus || !hasVar)
|
||||
if (!canEditInputs || !hasVar)
|
||||
return null
|
||||
|
||||
return (
|
||||
@ -284,7 +330,7 @@ const Welcome: FC<IWelcomeProps> = ({
|
||||
}
|
||||
|
||||
const renderHasSetInputs = () => {
|
||||
if ((!isPublicVersion && !canEidtInpus) || !hasVar)
|
||||
if ((!isPublicVersion && !canEditInputs) || !hasVar)
|
||||
return null
|
||||
|
||||
return (
|
||||
|
||||
@ -3,13 +3,10 @@ import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import cn from 'classnames'
|
||||
import BlockIcon from './block-icon'
|
||||
import CodeEditor from './code-editor'
|
||||
import { CodeLanguage } from '@/types/app'
|
||||
import AlertCircle from '@/app/components/base/icons/line/alert-circle'
|
||||
import AlertTriangle from '@/app/components/base/icons/line/alert-triangle'
|
||||
import Loading02 from '@/app/components/base/icons/line/loading-02'
|
||||
import CheckCircle from '@/app/components/base/icons/line/check-circle'
|
||||
import ChevronRight from '@/app/components/base/icons/line/chevron-right'
|
||||
import type { NodeTracing } from '@/types/app'
|
||||
|
||||
type Props = {
|
||||
@ -52,12 +49,6 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
|
||||
)}
|
||||
onClick={() => setCollapseState(!collapseState)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'shrink-0 w-3 h-3 mr-1 text-gray-400 transition-all group-hover:text-gray-500',
|
||||
!collapseState && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<BlockIcon size={hideInfo ? 'xs' : 'sm'} className={cn('shrink-0 mr-2', hideInfo && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
|
||||
<div className={cn(
|
||||
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
|
||||
@ -82,48 +73,6 @@ const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapseState && (
|
||||
<div className='pb-2'>
|
||||
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
|
||||
{nodeInfo.status === 'failed' && (
|
||||
<div className='px-3 py-[10px] bg-[#fef3f2] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#d92d20] shadow-xs'>{nodeInfo.error}</div>
|
||||
)}
|
||||
</div>
|
||||
{nodeInfo.inputs && (
|
||||
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>INPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.inputs}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{nodeInfo.process_data && (
|
||||
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>PROCESS DATA</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.process_data}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{nodeInfo.outputs && (
|
||||
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OUTPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.outputs}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,7 +7,7 @@ export const APP_INFO: AppInfo = {
|
||||
description: '',
|
||||
copyright: '',
|
||||
privacy_policy: '',
|
||||
default_language: 'zh-Hans',
|
||||
default_language: 'en',
|
||||
}
|
||||
|
||||
export const isShowPrompt = false
|
||||
|
||||
@ -2,11 +2,20 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import commonEn from './lang/common.en'
|
||||
import commonEs from './lang/common.es'
|
||||
import commonZh from './lang/common.zh'
|
||||
import commonVi from './lang/common.vi'
|
||||
import commonJa from './lang/common.ja'
|
||||
import appEn from './lang/app.en'
|
||||
import appEs from './lang/app.es'
|
||||
import appZh from './lang/app.zh'
|
||||
import appVi from './lang/app.vi'
|
||||
import appJa from './lang/app.ja'
|
||||
import toolsEn from './lang/tools.en'
|
||||
import toolsZh from './lang/tools.zh'
|
||||
import toolsVi from './lang/tools.vi'
|
||||
import toolsJa from './lang/tools.ja'
|
||||
|
||||
import type { Locale } from '.'
|
||||
|
||||
const resources = {
|
||||
@ -18,6 +27,12 @@ const resources = {
|
||||
tools: toolsEn,
|
||||
},
|
||||
},
|
||||
'es': {
|
||||
translation: {
|
||||
common: commonEs,
|
||||
app: appEs,
|
||||
},
|
||||
},
|
||||
'zh-Hans': {
|
||||
translation: {
|
||||
common: commonZh,
|
||||
@ -26,6 +41,22 @@ const resources = {
|
||||
tools: toolsZh,
|
||||
},
|
||||
},
|
||||
'vi': {
|
||||
translation: {
|
||||
common: commonVi,
|
||||
app: appVi,
|
||||
// tools
|
||||
tools: toolsVi,
|
||||
},
|
||||
},
|
||||
'ja': {
|
||||
translation: {
|
||||
common: commonJa,
|
||||
app: appJa,
|
||||
// tools
|
||||
tools: toolsJa,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export const i18n = {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'zh-Hans'],
|
||||
locales: ['en', 'es', 'zh-Hans', 'ja'],
|
||||
} as const
|
||||
|
||||
export type Locale = typeof i18n['locales'][number]
|
||||
|
||||
@ -28,6 +28,9 @@ const translation = {
|
||||
waitForResponse:
|
||||
'Please wait for the response to the previous message to complete.',
|
||||
},
|
||||
variableTable: {
|
||||
optional: 'Optional',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
33
i18n/lang/app.es.ts
Normal file
33
i18n/lang/app.es.ts
Normal file
@ -0,0 +1,33 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: 'Bienvenido a usar',
|
||||
appUnavailable: 'App es inaccesible',
|
||||
appUnkonwError: 'App es inaccesible',
|
||||
},
|
||||
chat: {
|
||||
newChat: 'Nuevo chat',
|
||||
newChatDefaultName: 'Nueva conversación',
|
||||
openingStatementTitle: 'Frase de apertura',
|
||||
powerBy: 'Desarrollado por',
|
||||
prompt: 'Prompt',
|
||||
privatePromptConfigTitle: 'Ajustes de conversación',
|
||||
publicPromptConfigTitle: 'Prompt inicial',
|
||||
configStatusDes: 'Antes de comenzar, puede modificar la configuración de la conversación',
|
||||
configDisabled:
|
||||
'La configuración de la sesión anterior se ha utilizado para esta sesión.',
|
||||
startChat: 'Comenzar chat',
|
||||
privacyPolicyLeft:
|
||||
'Por favor lea la ',
|
||||
privacyPolicyMiddle:
|
||||
'política de privacidad',
|
||||
privacyPolicyRight:
|
||||
' proporcionada por el desarrollador de la aplicación.',
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: 'El valor de las variables no puede estar vacío',
|
||||
waitForResponse:
|
||||
'Por favor espere a que la respuesta al mensaje anterior se complete.',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
36
i18n/lang/app.ja.ts
Normal file
36
i18n/lang/app.ja.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: 'ご利用いただきありがとうございます',
|
||||
appUnavailable: 'アプリは利用できません',
|
||||
appUnkonwError: 'アプリは利用できません',
|
||||
},
|
||||
chat: {
|
||||
newChat: '新しいチャット',
|
||||
newChatDefaultName: '新しい会話',
|
||||
openingStatementTitle: 'オープニングステートメント',
|
||||
powerBy: '提供元',
|
||||
prompt: 'プロンプト',
|
||||
privatePromptConfigTitle: '会話設定',
|
||||
publicPromptConfigTitle: '初期プロンプト',
|
||||
configStatusDes: '開始前に、会話設定を変更できます',
|
||||
configDisabled:
|
||||
'前回のセッション設定がこのセッションで使用されています。',
|
||||
startChat: '開始',
|
||||
privacyPolicyLeft:
|
||||
'ご利用前に、',
|
||||
privacyPolicyMiddle:
|
||||
'プライバシーポリシー',
|
||||
privacyPolicyRight:
|
||||
' をお読みください。',
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: '変数の値は空にできません',
|
||||
waitForResponse:
|
||||
'前のメッセージの応答が完了するまでお待ちください。',
|
||||
},
|
||||
variableTable: {
|
||||
optional: '任意',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
36
i18n/lang/app.vi.ts
Normal file
36
i18n/lang/app.vi.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const translation = {
|
||||
common: {
|
||||
welcome: 'Chào mừng bạn sử dụng',
|
||||
appUnavailable: 'Ứng dụng không khả dụng',
|
||||
appUnkonwError: 'Ứng dụng không khả dụng',
|
||||
},
|
||||
chat: {
|
||||
newChat: 'Cuộc trò chuyện mới',
|
||||
newChatDefaultName: 'Cuộc trò chuyện mới',
|
||||
openingStatementTitle: 'Lời mở đầu',
|
||||
powerBy: 'Được hỗ trợ bởi',
|
||||
prompt: 'Nhắc nhở',
|
||||
privatePromptConfigTitle: 'Cài đặt cuộc trò chuyện',
|
||||
publicPromptConfigTitle: 'Nhắc nhở ban đầu',
|
||||
configStatusDes: 'Trước khi bắt đầu, bạn có thể chỉnh sửa cài đặt cuộc trò chuyện',
|
||||
configDisabled:
|
||||
'Cài đặt của phiên trước đã được sử dụng cho phiên này.',
|
||||
startChat: 'Bắt đầu trò chuyện',
|
||||
privacyPolicyLeft:
|
||||
'Vui lòng đọc ',
|
||||
privacyPolicyMiddle:
|
||||
'chính sách bảo mật',
|
||||
privacyPolicyRight:
|
||||
' được cung cấp bởi nhà phát triển ứng dụng.',
|
||||
},
|
||||
errorMessage: {
|
||||
valueOfVarRequired: 'Giá trị của biến không thể để trống',
|
||||
waitForResponse:
|
||||
'Vui lòng đợi phản hồi từ tin nhắn trước khi gửi tin nhắn mới.',
|
||||
},
|
||||
variableTable: {
|
||||
optional: 'Tùy chọn',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation;
|
||||
@ -23,6 +23,9 @@ const translation = {
|
||||
valueOfVarRequired: '变量值必填',
|
||||
waitForResponse: '请等待上条信息响应完成',
|
||||
},
|
||||
variableTable: {
|
||||
optional: '可选',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -28,6 +28,16 @@ const translation = {
|
||||
pasteImageLinkInvalid: 'Invalid image link',
|
||||
imageUpload: 'Image Upload',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'Local upload',
|
||||
pasteFileLink: 'Paste file link',
|
||||
pasteFileLinkInputPlaceholder: 'Enter URL...',
|
||||
uploadFromComputerReadError: 'File reading failed, please try again.',
|
||||
uploadFromComputerUploadError: 'File upload failed, please upload again.',
|
||||
uploadFromComputerLimit: 'Upload {{type}} cannot exceed {{size}}',
|
||||
pasteFileLinkInvalid: 'Invalid file link',
|
||||
fileExtensionNotSupport: 'File extension not supported',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
43
i18n/lang/common.es.ts
Normal file
43
i18n/lang/common.es.ts
Normal file
@ -0,0 +1,43 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: 'Éxito',
|
||||
saved: 'Guardado',
|
||||
create: 'Creado',
|
||||
},
|
||||
operation: {
|
||||
confirm: 'Confirmar',
|
||||
cancel: 'Cancelar',
|
||||
clear: 'Limpiar',
|
||||
save: 'Guardar',
|
||||
edit: 'Editar',
|
||||
refresh: 'Reiniciar',
|
||||
search: 'Buscar',
|
||||
send: 'Enviar',
|
||||
lineBreak: 'Salto de línea',
|
||||
like: 'Me gusta',
|
||||
dislike: 'No me gusta',
|
||||
ok: 'OK',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: 'Subir desde el ordenador',
|
||||
uploadFromComputerReadError: 'La lectura de la imagen falló, por favor inténtelo de nuevo.',
|
||||
uploadFromComputerUploadError: 'Error al subir la imagen, por favor inténtelo de nuevo.',
|
||||
uploadFromComputerLimit: 'Las imágenes subidas no pueden superar los {{size}} MB',
|
||||
pasteImageLink: 'Pegar enlace de imagen',
|
||||
pasteImageLinkInputPlaceholder: 'Pegar enlace de imagen aquí',
|
||||
pasteImageLinkInvalid: 'Enlace de imagen no válido',
|
||||
imageUpload: 'Subir imagen',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'Carga local',
|
||||
pasteFileLink: 'Pegar enlace de archivo',
|
||||
uploadFromComputerReadError: 'Error en la lectura del archivo, inténtelo de nuevo.',
|
||||
uploadFromComputerUploadError: 'Error en la carga del archivo, vuelva a cargarlo.',
|
||||
pasteFileLinkInvalid: 'Enlace de archivo no válido',
|
||||
fileExtensionNotSupport: 'Extensión de archivo no compatible',
|
||||
pasteFileLinkInputPlaceholder: 'Introduzca la URL...',
|
||||
uploadFromComputerLimit: 'El archivo de carga no puede exceder {{size}}',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
43
i18n/lang/common.ja.ts
Normal file
43
i18n/lang/common.ja.ts
Normal file
@ -0,0 +1,43 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: '成功',
|
||||
saved: '保存しました',
|
||||
create: '作成しました',
|
||||
},
|
||||
operation: {
|
||||
confirm: '確認',
|
||||
cancel: 'キャンセル',
|
||||
clear: 'クリア',
|
||||
save: '保存',
|
||||
edit: '編集',
|
||||
refresh: '再起動',
|
||||
search: '検索',
|
||||
send: '送信',
|
||||
lineBreak: '改行',
|
||||
like: 'いいね',
|
||||
dislike: 'よくないね',
|
||||
ok: 'OK',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: 'コンピューターからアップロード',
|
||||
uploadFromComputerReadError: '画像の読み込みに失敗しました。もう一度お試しください。',
|
||||
uploadFromComputerUploadError: '画像のアップロードに失敗しました。もう一度アップロードしてください。',
|
||||
uploadFromComputerLimit: 'アップロードする画像は{{size}} MBを超えてはいけません',
|
||||
pasteImageLink: '画像リンクを貼り付け',
|
||||
pasteImageLinkInputPlaceholder: 'ここに画像リンクを貼り付けてください',
|
||||
pasteImageLinkInvalid: '無効な画像リンクです',
|
||||
imageUpload: '画像アップロード',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'ローカルアップロード',
|
||||
pasteFileLink: 'ファイルリンクの貼り付け',
|
||||
pasteFileLinkInputPlaceholder: 'URLを入力...',
|
||||
uploadFromComputerLimit: 'アップロードファイルは{{size}}を超えてはなりません',
|
||||
uploadFromComputerUploadError: 'ファイルのアップロードに失敗しました。再度アップロードしてください。',
|
||||
uploadFromComputerReadError: 'ファイルの読み取りに失敗しました。もう一度やり直してください。',
|
||||
fileExtensionNotSupport: 'ファイル拡張子はサポートされていません',
|
||||
pasteFileLinkInvalid: '無効なファイルリンク',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
43
i18n/lang/common.vi.ts
Normal file
43
i18n/lang/common.vi.ts
Normal file
@ -0,0 +1,43 @@
|
||||
const translation = {
|
||||
api: {
|
||||
success: 'Thành công',
|
||||
saved: 'Đã lưu',
|
||||
create: 'Đã tạo',
|
||||
},
|
||||
operation: {
|
||||
confirm: 'Xác nhận',
|
||||
cancel: 'Hủy',
|
||||
clear: 'Xóa',
|
||||
save: 'Lưu',
|
||||
edit: 'Chỉnh sửa',
|
||||
refresh: 'Khởi động lại',
|
||||
search: 'Tìm kiếm',
|
||||
send: 'Gửi',
|
||||
lineBreak: 'Xuống dòng',
|
||||
like: 'thích',
|
||||
dislike: 'không thích',
|
||||
ok: 'OK',
|
||||
},
|
||||
imageUploader: {
|
||||
uploadFromComputer: 'Tải lên từ máy tính',
|
||||
uploadFromComputerReadError: 'Đọc ảnh thất bại, vui lòng thử lại.',
|
||||
uploadFromComputerUploadError: 'Tải ảnh lên thất bại, vui lòng tải lại.',
|
||||
uploadFromComputerLimit: 'Ảnh tải lên không được vượt quá {{size}} MB',
|
||||
pasteImageLink: 'Dán liên kết ảnh',
|
||||
pasteImageLinkInputPlaceholder: 'Dán liên kết ảnh vào đây',
|
||||
pasteImageLinkInvalid: 'Liên kết ảnh không hợp lệ',
|
||||
imageUpload: 'Tải ảnh lên',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: 'Tải lên cục bộ',
|
||||
pasteFileLink: 'Dán liên kết tệp',
|
||||
pasteFileLinkInputPlaceholder: 'Nhập URL...',
|
||||
uploadFromComputerLimit: 'Tải lên tệp không được vượt quá {{size}}',
|
||||
fileExtensionNotSupport: 'Phần mở rộng tệp không được hỗ trợ',
|
||||
pasteFileLinkInvalid: 'Liên kết tệp không hợp lệ',
|
||||
uploadFromComputerUploadError: 'Tải lên tệp không thành công, vui lòng tải lên lại.',
|
||||
uploadFromComputerReadError: 'Đọc tệp không thành công, vui lòng thử lại.',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
@ -28,6 +28,16 @@ const translation = {
|
||||
pasteImageLinkInvalid: '图片链接无效',
|
||||
imageUpload: '图片上传',
|
||||
},
|
||||
fileUploader: {
|
||||
uploadFromComputer: '从本地上传',
|
||||
pasteFileLink: '粘贴文件链接',
|
||||
pasteFileLinkInputPlaceholder: '输入文件链接',
|
||||
uploadFromComputerReadError: '文件读取失败,请重新选择。',
|
||||
uploadFromComputerUploadError: '文件上传失败,请重新上传。',
|
||||
uploadFromComputerLimit: '上传 {{type}} 不能超过 {{size}}',
|
||||
pasteFileLinkInvalid: '文件链接无效',
|
||||
fileExtensionNotSupport: '文件类型不支持',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
103
i18n/lang/tools.ja.ts
Normal file
103
i18n/lang/tools.ja.ts
Normal file
@ -0,0 +1,103 @@
|
||||
const translation = {
|
||||
title: 'ツール',
|
||||
createCustomTool: 'カスタムツールの作成',
|
||||
type: {
|
||||
all: 'すべて',
|
||||
builtIn: '内蔵',
|
||||
custom: 'カスタム',
|
||||
},
|
||||
contribute: {
|
||||
line1: '興味があります ',
|
||||
line2: 'Difyにツールを貢献すること。',
|
||||
viewGuide: 'ガイドを見る',
|
||||
},
|
||||
author: '作成者',
|
||||
auth: {
|
||||
unauthorized: '認証が必要',
|
||||
authorized: '認証済み',
|
||||
setup: '使用するために認証を設定',
|
||||
setupModalTitle: '認証設定',
|
||||
setupModalTitleDescription: '資格情報を設定すると、ワークスペース内のすべてのメンバーがアプリケーションを編成する際にこのツールを使用できるようになります。',
|
||||
},
|
||||
includeToolNum: '{{num}} のツールが含まれています',
|
||||
addTool: 'ツールを追加',
|
||||
createTool: {
|
||||
title: 'カスタムツールの作成',
|
||||
editAction: '設定',
|
||||
editTitle: 'カスタムツールの編集',
|
||||
name: '名前',
|
||||
toolNamePlaceHolder: 'ツール名を入力してください',
|
||||
schema: 'スキーマ',
|
||||
schemaPlaceHolder: 'ここにOpenAPIスキーマを入力してください',
|
||||
viewSchemaSpec: 'OpenAPI-Swagger仕様を見る',
|
||||
importFromUrl: 'URLからインポート',
|
||||
importFromUrlPlaceHolder: 'https://...',
|
||||
urlError: '有効なURLを入力してください',
|
||||
examples: '例',
|
||||
exampleOptions: {
|
||||
json: '天気予報(JSON)',
|
||||
yaml: 'ペットストア(YAML)',
|
||||
blankTemplate: '空のテンプレート',
|
||||
},
|
||||
availableTools: {
|
||||
title: '利用可能なツール',
|
||||
name: '名前',
|
||||
description: '説明',
|
||||
method: 'メソッド',
|
||||
path: 'パス',
|
||||
action: 'アクション',
|
||||
test: 'テスト',
|
||||
},
|
||||
authMethod: {
|
||||
title: '認証方法',
|
||||
type: '認証タイプ',
|
||||
types: {
|
||||
none: 'なし',
|
||||
api_key: 'APIキー',
|
||||
},
|
||||
key: 'キー',
|
||||
value: '値',
|
||||
},
|
||||
privacyPolicy: 'プライバシーポリシー',
|
||||
privacyPolicyPlaceholder: 'プライバシーポリシーを入力してください',
|
||||
},
|
||||
test: {
|
||||
title: 'テスト',
|
||||
parametersValue: 'パラメータと値',
|
||||
parameters: 'パラメータ',
|
||||
value: '値',
|
||||
testResult: 'テスト結果',
|
||||
testResultPlaceholder: 'テスト結果はここに表示されます',
|
||||
},
|
||||
thought: {
|
||||
using: '使用中',
|
||||
used: '使用済み',
|
||||
requestTitle: 'リクエスト先',
|
||||
responseTitle: 'レスポンス元',
|
||||
},
|
||||
setBuiltInTools: {
|
||||
info: '情報',
|
||||
setting: '設定',
|
||||
toolDescription: 'ツールの説明',
|
||||
parameters: 'パラメータ',
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
required: '必須',
|
||||
infoAndSetting: '情報と設定',
|
||||
},
|
||||
noCustomTool: {
|
||||
title: 'カスタムツールがありません!',
|
||||
content: 'ここでカスタムツールを追加および管理して、AIアプリを構築します。',
|
||||
createTool: 'ツールの作成',
|
||||
},
|
||||
noSearchRes: {
|
||||
title: '申し訳ありません、結果が見つかりません!',
|
||||
content: '検索条件に一致するツールは見つかりませんでした。',
|
||||
reset: '検索をリセット',
|
||||
},
|
||||
builtInPromptTitle: 'プロンプト',
|
||||
toolRemoved: 'ツールが削除されました',
|
||||
notAuthorized: 'ツールが認証されていません',
|
||||
}
|
||||
|
||||
export default translation
|
||||
103
i18n/lang/tools.vi.ts
Normal file
103
i18n/lang/tools.vi.ts
Normal file
@ -0,0 +1,103 @@
|
||||
const translation = {
|
||||
title: 'Công cụ',
|
||||
createCustomTool: 'Tạo công cụ tùy chỉnh',
|
||||
type: {
|
||||
all: 'Tất cả',
|
||||
builtIn: 'Có sẵn',
|
||||
custom: 'Tùy chỉnh',
|
||||
},
|
||||
contribute: {
|
||||
line1: 'Tôi quan tâm đến việc ',
|
||||
line2: 'đóng góp công cụ cho Dify.',
|
||||
viewGuide: 'Xem hướng dẫn',
|
||||
},
|
||||
author: 'Bởi',
|
||||
auth: {
|
||||
unauthorized: 'Chưa ủy quyền',
|
||||
authorized: 'Đã ủy quyền',
|
||||
setup: 'Thiết lập ủy quyền để sử dụng',
|
||||
setupModalTitle: 'Thiết lập ủy quyền',
|
||||
setupModalTitleDescription: 'Sau khi cấu hình thông tin xác thực, tất cả các thành viên trong không gian làm việc đều có thể sử dụng công cụ này khi sắp xếp các ứng dụng.',
|
||||
},
|
||||
includeToolNum: 'Bao gồm {{num}} công cụ',
|
||||
addTool: 'Thêm công cụ',
|
||||
createTool: {
|
||||
title: 'Tạo công cụ tùy chỉnh',
|
||||
editAction: 'Cấu hình',
|
||||
editTitle: 'Chỉnh sửa công cụ tùy chỉnh',
|
||||
name: 'Tên',
|
||||
toolNamePlaceHolder: 'Nhập tên công cụ',
|
||||
schema: 'Schema',
|
||||
schemaPlaceHolder: 'Nhập schema OpenAPI của bạn tại đây',
|
||||
viewSchemaSpec: 'Xem đặc tả OpenAPI-Swagger',
|
||||
importFromUrl: 'Nhập từ URL',
|
||||
importFromUrlPlaceHolder: 'https://...',
|
||||
urlError: 'Vui lòng nhập URL hợp lệ',
|
||||
examples: 'Ví dụ',
|
||||
exampleOptions: {
|
||||
json: 'Thời tiết(JSON)',
|
||||
yaml: 'Cửa hàng thú cưng(YAML)',
|
||||
blankTemplate: 'Mẫu trống',
|
||||
},
|
||||
availableTools: {
|
||||
title: 'Công cụ có sẵn',
|
||||
name: 'Tên',
|
||||
description: 'Mô tả',
|
||||
method: 'Phương thức',
|
||||
path: 'Đường dẫn',
|
||||
action: 'Hành động',
|
||||
test: 'Kiểm tra',
|
||||
},
|
||||
authMethod: {
|
||||
title: 'Phương thức ủy quyền',
|
||||
type: 'Loại ủy quyền',
|
||||
types: {
|
||||
none: 'Không có',
|
||||
api_key: 'API Key',
|
||||
},
|
||||
key: 'Khóa',
|
||||
value: 'Giá trị',
|
||||
},
|
||||
privacyPolicy: 'Chính sách bảo mật',
|
||||
privacyPolicyPlaceholder: 'Vui lòng nhập chính sách bảo mật',
|
||||
},
|
||||
test: {
|
||||
title: 'Kiểm tra',
|
||||
parametersValue: 'Tham số & Giá trị',
|
||||
parameters: 'Tham số',
|
||||
value: 'Giá trị',
|
||||
testResult: 'Kết quả kiểm tra',
|
||||
testResultPlaceholder: 'Kết quả kiểm tra sẽ hiển thị ở đây',
|
||||
},
|
||||
thought: {
|
||||
using: 'Đang sử dụng',
|
||||
used: 'Đã sử dụng',
|
||||
requestTitle: 'Yêu cầu đến',
|
||||
responseTitle: 'Phản hồi từ',
|
||||
},
|
||||
setBuiltInTools: {
|
||||
info: 'Thông tin',
|
||||
setting: 'Cài đặt',
|
||||
toolDescription: 'Mô tả công cụ',
|
||||
parameters: 'tham số',
|
||||
string: 'chuỗi',
|
||||
number: 'số',
|
||||
required: 'Bắt buộc',
|
||||
infoAndSetting: 'Thông tin & Cài đặt',
|
||||
},
|
||||
noCustomTool: {
|
||||
title: 'Không có công cụ tùy chỉnh!',
|
||||
content: 'Thêm và quản lý các công cụ tùy chỉnh của bạn tại đây để xây dựng các ứng dụng AI.',
|
||||
createTool: 'Tạo công cụ',
|
||||
},
|
||||
noSearchRes: {
|
||||
title: 'Xin lỗi, không tìm thấy kết quả!',
|
||||
content: 'Chúng tôi không thể tìm thấy bất kỳ công cụ nào phù hợp với tìm kiếm của bạn.',
|
||||
reset: 'Đặt lại tìm kiếm',
|
||||
},
|
||||
builtInPromptTitle: 'Nhắc nhở',
|
||||
toolRemoved: 'Công cụ đã được xóa',
|
||||
notAuthorized: 'Công cụ chưa được ủy quyền',
|
||||
}
|
||||
|
||||
export default translation;
|
||||
@ -19,6 +19,7 @@
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@remixicon/react": "^4.6.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
@ -26,6 +27,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"ahooks": "^3.7.5",
|
||||
"axios": "^1.3.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.3.2",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dify-client": "^2.3.1",
|
||||
@ -38,6 +40,8 @@
|
||||
"immer": "^9.0.19",
|
||||
"js-cookie": "^3.0.1",
|
||||
"katex": "^0.16.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mime": "^4.0.7",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.0.4",
|
||||
"rc-textarea": "^1.5.3",
|
||||
@ -57,15 +61,18 @@
|
||||
"scheduler": "^0.23.0",
|
||||
"server-only": "^0.0.1",
|
||||
"swr": "^2.1.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"typescript": "4.9.5",
|
||||
"use-context-selector": "^1.4.1",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "0.36.0",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
|
||||
@ -10,6 +10,9 @@ export type PromptVariable = {
|
||||
options?: string[]
|
||||
max_length?: number
|
||||
required: boolean
|
||||
allowed_file_extensions?: string[]
|
||||
allowed_file_types?: string[]
|
||||
allowed_file_upload_methods?: TransferMethod[]
|
||||
}
|
||||
|
||||
export type PromptConfig = {
|
||||
@ -37,6 +40,8 @@ export type UserInputFormItem = {
|
||||
'text-input': TextTypeFormItem
|
||||
} | {
|
||||
'select': SelectTypeFormItem
|
||||
} | {
|
||||
'paragraph': TextTypeFormItem
|
||||
}
|
||||
|
||||
export const MessageRatings = ['like', 'dislike', null] as const
|
||||
|
||||
8
utils/classnames.ts
Normal file
8
utils/classnames.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import cn from 'classnames'
|
||||
|
||||
const classNames = (...cls: cn.ArgumentArray) => {
|
||||
return twMerge(cn(cls))
|
||||
}
|
||||
|
||||
export default classNames
|
||||
58
utils/format.ts
Normal file
58
utils/format.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Formats a number with comma separators.
|
||||
* @example formatNumber(1234567) will return '1,234,567'
|
||||
* @example formatNumber(1234567.89) will return '1,234,567.89'
|
||||
*/
|
||||
export const formatNumber = (num: number | string) => {
|
||||
if (!num)
|
||||
return num
|
||||
const parts = num.toString().split('.')
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size into standard string format.
|
||||
* @param fileSize file size (Byte)
|
||||
* @example formatFileSize(1024) will return '1.00KB'
|
||||
* @example formatFileSize(1024 * 1024) will return '1.00MB'
|
||||
*/
|
||||
export const formatFileSize = (fileSize: number) => {
|
||||
if (!fileSize)
|
||||
return fileSize
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P']
|
||||
let index = 0
|
||||
while (fileSize >= 1024 && index < units.length) {
|
||||
fileSize = fileSize / 1024
|
||||
index++
|
||||
}
|
||||
return `${fileSize.toFixed(2)}${units[index]}B`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time into standard string format.
|
||||
* @example formatTime(60) will return '1.00 min'
|
||||
* @example formatTime(60 * 60) will return '1.00 h'
|
||||
*/
|
||||
export const formatTime = (seconds: number) => {
|
||||
if (!seconds)
|
||||
return seconds
|
||||
const units = ['sec', 'min', 'h']
|
||||
let index = 0
|
||||
while (seconds >= 60 && index < units.length) {
|
||||
seconds = seconds / 60
|
||||
index++
|
||||
}
|
||||
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)
|
||||
}
|
||||
@ -16,16 +16,11 @@ export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] |
|
||||
return []
|
||||
const promptVariables: PromptVariable[] = []
|
||||
useInputs.forEach((item: any) => {
|
||||
const isParagraph = !!item.paragraph
|
||||
const [type, content] = (() => {
|
||||
if (isParagraph)
|
||||
return ['paragraph', item.paragraph]
|
||||
|
||||
if (item['text-input'])
|
||||
return ['string', item['text-input']]
|
||||
|
||||
return ['select', item.select]
|
||||
const type = Object.keys(item)[0]
|
||||
return [type === 'text-input' ? 'string' : type, item[type]]
|
||||
})()
|
||||
|
||||
if (type === 'string' || type === 'paragraph') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
@ -36,6 +31,26 @@ export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] |
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
else if (type === 'number') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type,
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
else if (type === 'file' || type === 'file-list') {
|
||||
promptVariables.push({
|
||||
...content,
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type,
|
||||
max_length: content.max_length,
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
else {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
|
||||
Reference in New Issue
Block a user