mirror of
https://github.com/langgenius/dify.git
synced 2026-02-03 18:36:50 +08:00
Compare commits
280 Commits
refactor/d
...
feat/hitl-
| Author | SHA1 | Date | |
|---|---|---|---|
| 52b9299e43 | |||
| 757317f965 | |||
| e119d1a16c | |||
| 0187838a54 | |||
| 68c36655a7 | |||
| 0357530468 | |||
| 966e308e8b | |||
| e405e801ce | |||
| 3170e12b0a | |||
| 8bbf3ef261 | |||
| e09bd3e977 | |||
| b1f7602dc8 | |||
| 2ac20ced26 | |||
| 4e34fa3d70 | |||
| bf161d01a8 | |||
| fe5a65b21c | |||
| b3be035c64 | |||
| 91f118c82c | |||
| 3eaafccff2 | |||
| 9d3b4749ae | |||
| 67bbfd972b | |||
| 1968589fdf | |||
| fc2acc2c53 | |||
| e945f3d439 | |||
| 1b0abdf642 | |||
| 441104ac3c | |||
| 8897633e42 | |||
| 8a20a59d72 | |||
| 7cd4a7c1de | |||
| b503da0942 | |||
| 94671d42c1 | |||
| 8cf817a5fd | |||
| 3e0b3fae37 | |||
| 19664daeef | |||
| fe5b5bc4c3 | |||
| 07d8eaae82 | |||
| e8130fc509 | |||
| 4ea3cf46fa | |||
| 824f139b46 | |||
| f2d98e832f | |||
| 5f73fed1e7 | |||
| 2ccef3ac84 | |||
| 9341227cf1 | |||
| d9982b8dc4 | |||
| d3d42e3a8e | |||
| f73db70cec | |||
| bcd6e22735 | |||
| 9aa674a04f | |||
| a4b87be5f4 | |||
| c8c98519ad | |||
| 6aa452d4e3 | |||
| 45c2167e0f | |||
| b242578b86 | |||
| d65523aa0b | |||
| ac46cf499f | |||
| 8f780cad4c | |||
| 0389dd459d | |||
| a19ae24adb | |||
| 42e80659b6 | |||
| 59cb447e05 | |||
| e63f7b2249 | |||
| 307f0d5827 | |||
| 757b1c7190 | |||
| c42b78f2d2 | |||
| 645793c48c | |||
| 528e3400da | |||
| c69a26b1ca | |||
| 663f320d86 | |||
| 6e9facd9b5 | |||
| 52e9342af5 | |||
| 0138dc45b6 | |||
| aaf6e8f978 | |||
| 55be933342 | |||
| 711cca01b8 | |||
| 2b28074f4c | |||
| c5721184e9 | |||
| f3ec6ad53c | |||
| 1014852ebd | |||
| 5e644315e4 | |||
| e3a22e5027 | |||
| 977cef5a22 | |||
| 1353eec9ca | |||
| 73343f03c1 | |||
| d401a29bd9 | |||
| 3bf8f19874 | |||
| 51a7ddba81 | |||
| bd634b165d | |||
| a298140d8f | |||
| 3db3a18eff | |||
| 91c35c2f0a | |||
| 61c7fdc614 | |||
| 88c2483192 | |||
| ca58055a39 | |||
| 368e38d593 | |||
| 7463bc9199 | |||
| 35a707199f | |||
| dfb25df5ec | |||
| cdc24696e4 | |||
| 8f6a0e2d8e | |||
| 20ba0de47d | |||
| f13ace9dd9 | |||
| 23f5427349 | |||
| 7deaab116a | |||
| b6db6d9305 | |||
| 2fa688cefd | |||
| b24fafe901 | |||
| c837def205 | |||
| 1a7eac192c | |||
| d21cf87bb6 | |||
| ef41325ad1 | |||
| 57c33d5869 | |||
| 4484261023 | |||
| 72bc396646 | |||
| 68885afac6 | |||
| 18e57096d2 | |||
| b6c6d52725 | |||
| bd104557ef | |||
| 55b18bb79e | |||
| 3f8f158b04 | |||
| 471d14f882 | |||
| a280df2c07 | |||
| b479a36273 | |||
| 9136bf48f5 | |||
| ac70069847 | |||
| 27da1e72eb | |||
| 467119d186 | |||
| 1cdbfa2539 | |||
| d3299db915 | |||
| ebb816b90b | |||
| 0b794ad9cc | |||
| a19c0023f9 | |||
| c7fa7009b1 | |||
| 3e1a96ad64 | |||
| d500a631f0 | |||
| 607e77e0a7 | |||
| 46ec24cf8a | |||
| f654a7f704 | |||
| cf9b72d574 | |||
| 5a92bbdab5 | |||
| d0a713e117 | |||
| 9ec127ea8f | |||
| d478822fe1 | |||
| 4eac271adc | |||
| 0f13b6b26d | |||
| 52ce46c364 | |||
| f0f1ae0b49 | |||
| 0c69466b0f | |||
| 44a688cb81 | |||
| 0e2b59d661 | |||
| 501c3bcc94 | |||
| bf6a2c22eb | |||
| 8c0365c71e | |||
| c0a4f3b715 | |||
| d308c34842 | |||
| 68615eec04 | |||
| eca3e23af0 | |||
| c716c4ccbe | |||
| 32366774a1 | |||
| 640661983a | |||
| f528f2eafc | |||
| 0994953728 | |||
| d80167d9ec | |||
| 2969a77b15 | |||
| 062896cb9c | |||
| 5a1e6269d5 | |||
| e744d4de80 | |||
| be0f493e61 | |||
| b8d4f60782 | |||
| 8b65b689f7 | |||
| 8b9846f52b | |||
| 1c4c1b5cb1 | |||
| ec0c144eb2 | |||
| afddc56bb4 | |||
| 57dde4a4d8 | |||
| 3c0fd213bf | |||
| ddfd1cb1f5 | |||
| 138922f3f4 | |||
| aa9d0bd655 | |||
| ccef15aafa | |||
| 202f924d99 | |||
| 882d2f5b4c | |||
| 93ec42344c | |||
| 330585ec9b | |||
| 8f2660cea8 | |||
| 87f6ac78c5 | |||
| 6c7d58aa11 | |||
| 8bca22a94b | |||
| 17aa11da79 | |||
| 63bbcff496 | |||
| f71a632cdc | |||
| 4e3bded902 | |||
| e75bbdbc0d | |||
| 331a5edbf9 | |||
| 6afc99a5ad | |||
| a4e2ef6b0c | |||
| 3632b473df | |||
| e35dd14c59 | |||
| 495f901798 | |||
| 8703515153 | |||
| be3c6da654 | |||
| d51db3afb3 | |||
| 22683fba3f | |||
| ed16265eee | |||
| 463ea14d44 | |||
| 527736b8e4 | |||
| f22dcee6d9 | |||
| 9bdd7e5465 | |||
| ad0e79372f | |||
| a362114486 | |||
| 79ab253c26 | |||
| 783b78cc0a | |||
| b1e123c3aa | |||
| 84709a7941 | |||
| ec07636ce9 | |||
| 4d4c8b21ac | |||
| ec8754173f | |||
| 7920b89714 | |||
| 19e152fd0c | |||
| 076a8ecff4 | |||
| 40591b2196 | |||
| 5156b8f9c9 | |||
| 286ab0d468 | |||
| 71a511a470 | |||
| 6b11973151 | |||
| 949a894f03 | |||
| 305b5da764 | |||
| fda19d3f0e | |||
| e5a2172a85 | |||
| 2d89d59d74 | |||
| 4f56acd432 | |||
| c1b7412465 | |||
| 62b9a20115 | |||
| 5392401e60 | |||
| baa77d3cda | |||
| a41176b66d | |||
| 465e978209 | |||
| a9ea8cfd1c | |||
| c771f4dbc7 | |||
| ebbed8f863 | |||
| bdf1e9ed3b | |||
| 36acd0b9dd | |||
| a4049e1ea7 | |||
| 114dfe038c | |||
| 81f6344aaa | |||
| 89963ecf59 | |||
| a18bcf3957 | |||
| c28720529e | |||
| 242826013e | |||
| 05453cb22f | |||
| da211d3009 | |||
| f8a249de03 | |||
| e2e5dedceb | |||
| bd7ba85471 | |||
| 7d5f6bc255 | |||
| fcc8789cc3 | |||
| 6da1a48cad | |||
| 465ff7838a | |||
| a4bf493343 | |||
| c27f20b4b7 | |||
| a9e6140dc6 | |||
| 792f28451c | |||
| 82530df38f | |||
| ce8325c83c | |||
| 3ed561d943 | |||
| bb8d54c48b | |||
| 922aeb7c21 | |||
| 177be06d09 | |||
| 736ec55f86 | |||
| ab373197f9 | |||
| a6d2392c6c | |||
| 3371989572 | |||
| f04daf056d | |||
| fb6c8fa01f | |||
| b02199145e | |||
| e257455c9c | |||
| 63af5305e6 | |||
| 95b88a0621 | |||
| 1ab02c6e9a | |||
| 1099ab5d91 | |||
| 6485adae35 |
1
.github/workflows/build-push.yml
vendored
1
.github/workflows/build-push.yml
vendored
@ -8,6 +8,7 @@ on:
|
||||
- "build/**"
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-frontend"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.12.0"
|
||||
version = "1.11.4"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1368,7 +1368,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.12.0"
|
||||
version = "1.11.4"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.12.0
|
||||
image: langgenius/dify-web:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@ -707,7 +707,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -749,7 +749,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -788,7 +788,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -818,7 +818,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.12.0
|
||||
image: langgenius/dify-web:1.11.4
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@ -35,6 +35,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
}
|
||||
|
||||
export const createMockProviderContextValue = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => {
|
||||
|
||||
@ -8,7 +8,6 @@ describe('SVG Attribute Error Reproduction', () => {
|
||||
// Capture console errors
|
||||
const originalError = console.error
|
||||
let errorMessages: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
errorMessages = []
|
||||
console.error = vi.fn((message) => {
|
||||
|
||||
289
web/app/(humanInputLayout)/form/[token]/form.tsx
Normal file
289
web/app/(humanInputLayout)/form/[token]/form.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { HumanInputFormError } from '@/service/use-share'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import { useParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
|
||||
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
|
||||
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type FormData = {
|
||||
site: { site: SiteInfo }
|
||||
form_content: string
|
||||
inputs: FormInputItem[]
|
||||
resolved_default_values: Record<string, string>
|
||||
user_actions: UserAction[]
|
||||
expiration_time: number
|
||||
}
|
||||
|
||||
const FormContent = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { token } = useParams<{ token: string }>()
|
||||
useDocumentTitle('')
|
||||
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({})
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
|
||||
|
||||
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
|
||||
|
||||
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
|
||||
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
|
||||
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
|
||||
|
||||
const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
const contentList = useMemo(() => {
|
||||
if (!formData?.form_content)
|
||||
return []
|
||||
return splitByOutputVar(formData.form_content)
|
||||
}, [formData?.form_content])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData?.inputs)
|
||||
return
|
||||
const initialInputs: Record<string, string> = {}
|
||||
formData.inputs.forEach((item) => {
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
|
||||
})
|
||||
setInputs(initialInputs)
|
||||
}, [formData?.inputs, formData?.resolved_default_values])
|
||||
|
||||
// use immer
|
||||
const handleInputsChange = (name: string, value: string) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft[name] = value
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
|
||||
const submit = (actionID: string) => {
|
||||
submitForm(
|
||||
{ token, data: { inputs, action: actionID } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loading type="app" />
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (rateLimitExceeded) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!formData) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const site = formData.site.site
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="system-xl-semibold grow text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-sm">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FormContent)
|
||||
13
web/app/(humanInputLayout)/form/[token]/page.tsx
Normal file
13
web/app/(humanInputLayout)/form/[token]/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import FormContent from './form'
|
||||
|
||||
const FormPage = () => {
|
||||
return (
|
||||
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
|
||||
<FormContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FormPage)
|
||||
@ -47,7 +47,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
|
||||
@ -31,7 +31,7 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
|
||||
@ -115,6 +115,7 @@ export type AppPublisherProps = {
|
||||
missingStartNode?: boolean
|
||||
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
|
||||
startNodeLimitExceeded?: boolean
|
||||
hasHumanInputNode?: boolean
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
@ -138,13 +139,14 @@ const AppPublisher = ({
|
||||
missingStartNode = false,
|
||||
hasTriggerNode = false,
|
||||
startNodeLimitExceeded = false,
|
||||
hasHumanInputNode = false,
|
||||
}: AppPublisherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
@ -161,6 +163,13 @@ const AppPublisher = ({
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = useMemo(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
}
|
||||
return true
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
|
||||
@ -171,25 +180,13 @@ const AppPublisher = ({
|
||||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
}, [missingStartNode, noAccessPermission, publishedAt])
|
||||
}, [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
refetch()
|
||||
}, [open, appDetail, refetch, systemFeatures])
|
||||
|
||||
useEffect(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
setIsAppAccessSet(false)
|
||||
else
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
else {
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
@ -461,7 +458,7 @@ const AppPublisher = ({
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && (
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
|
||||
@ -62,19 +62,19 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{(canCreate || isTrialApp) && (
|
||||
{canCreate && (
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && 'grid-cols-2')}>
|
||||
{canCreate && (
|
||||
<Button variant="primary" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', isTrialApp && 'grid-cols-2')}>
|
||||
<Button variant="primary" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
|
||||
</Button>
|
||||
{isTrialApp && (
|
||||
<Button onClick={showTryAPPPanel(app.app_id)}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={showTryAPPPanel(app.app_id)}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -14,8 +13,8 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import CreateAppModal from './index'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounceFn: <T extends (...args: unknown[]) => unknown>(fn: T) => {
|
||||
const run = (...args: Parameters<T>) => fn(...args)
|
||||
useDebounceFn: (fn: (...args: any[]) => any) => {
|
||||
const run = (...args: any[]) => fn(...args)
|
||||
const cancel = vi.fn()
|
||||
const flush = vi.fn()
|
||||
return { run, cancel, flush }
|
||||
@ -84,7 +83,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as any)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: AppModeEnum.ADVANCED_CHAT,
|
||||
@ -93,10 +92,10 @@ describe('CreateAppModal', () => {
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: true,
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
} as any)
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
} as any)
|
||||
mockSetItem.mockClear()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
@ -119,8 +118,8 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('creates an app, notifies success, and fires callbacks', async () => {
|
||||
const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
|
||||
mockCreateApp.mockResolvedValue(mockApp as App)
|
||||
const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
|
||||
mockCreateApp.mockResolvedValue(mockApp as any)
|
||||
const { onClose, onSuccess } = renderModal()
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
|
||||
@ -68,6 +68,7 @@ type IDrawerContext = {
|
||||
}
|
||||
|
||||
type StatusCount = {
|
||||
paused: number
|
||||
success: number
|
||||
failed: number
|
||||
partial_success: number
|
||||
@ -93,7 +94,15 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
if (!statusCount)
|
||||
return null
|
||||
|
||||
if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
if (statusCount.paused > 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="green" />
|
||||
@ -296,7 +305,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
if (abortControllerRef.current === controller)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}, [detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
}, [detail.id, hasMore, timezone, t, appDetail])
|
||||
|
||||
// Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems
|
||||
useEffect(() => {
|
||||
@ -411,7 +420,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
return false
|
||||
}
|
||||
}, [allChatItems, appDetail?.id, t])
|
||||
}, [allChatItems, appDetail?.id, notify, t])
|
||||
|
||||
const fetchInitiated = useRef(false)
|
||||
|
||||
@ -504,7 +513,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [detail.id, hasMore, isLoading, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
}, [detail.id, hasMore, isLoading, timezone, t, appDetail])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv')
|
||||
|
||||
@ -53,6 +53,7 @@ const defaultProviderContext = {
|
||||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
}
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
RiClipboardLine,
|
||||
RiFileList3Line,
|
||||
RiPlayList2Line,
|
||||
RiReplay15Line,
|
||||
RiResetLeftLine,
|
||||
RiSparklingFill,
|
||||
RiSparklingLine,
|
||||
RiThumbDownLine,
|
||||
@ -18,10 +18,12 @@ import { useBoolean } from 'ahooks'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list'
|
||||
import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list'
|
||||
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@ -29,7 +31,8 @@ import { Markdown } from '@/app/components/base/markdown'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { fetchTextGenerationMessage } from '@/service/debug'
|
||||
import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share'
|
||||
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ResultTab from './result-tab'
|
||||
|
||||
@ -121,7 +124,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
|
||||
|
||||
const childProps = {
|
||||
isInWebApp: true,
|
||||
isInWebApp,
|
||||
content: completionRes,
|
||||
messageId: childMessageId,
|
||||
depth: depth + 1,
|
||||
@ -202,16 +205,22 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
}
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length)
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0))
|
||||
switchTab('RESULT')
|
||||
else
|
||||
switchTab('DETAIL')
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText])
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList])
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
|
||||
if (appSourceType === AppSourceType.installedApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [appSourceType])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -275,7 +284,24 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{!isError && (
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
<>
|
||||
{currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={workflowProcessData.humanInputFormDataList}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<HumanInputFilledFormList
|
||||
humanInputFilledFormDataList={workflowProcessData.humanInputFilledFormDataList}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -348,7 +374,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
)}
|
||||
{isInWebApp && isError && (
|
||||
<ActionButton onClick={onRetry}>
|
||||
<RiReplay15Line className="h-4 w-4" />
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && !isWorkflow && !isTryApp && (
|
||||
|
||||
@ -81,6 +81,14 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'paused') {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
|
||||
@ -26,6 +26,10 @@
|
||||
@apply p-0.5 w-6 h-6 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-s {
|
||||
@apply w-5 h-5 rounded-[6px]
|
||||
}
|
||||
|
||||
.action-btn-xs {
|
||||
@apply p-0 w-4 h-4 rounded
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ const actionButtonVariants = cva(
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
s: 'action-btn-s',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
|
||||
@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@ -16,7 +17,9 @@ import {
|
||||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
@ -73,9 +76,9 @@ const ChatWrapper = () => {
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@ -122,8 +125,11 @@ const ChatWrapper = () => {
|
||||
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
|
||||
if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0))
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
}, [allInputsHidden, inputsForms, chatList, inputsFormValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
@ -134,6 +140,40 @@ const ChatWrapper = () => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatTree || appPrevChatTree.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatTree)
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
@ -149,10 +189,10 @@ const ChatWrapper = () => {
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: !isInstalledApp,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
|
||||
}, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
@ -160,12 +200,27 @@ const ChatWrapper = () => {
|
||||
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const doSwitchSibling = useCallback((siblingMessageId: string) => {
|
||||
handleSwitchSibling(siblingMessageId, {
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
})
|
||||
}, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
|
||||
return chatList.filter(item => !item.isOpeningStatement)
|
||||
}, [chatList])
|
||||
}, [chatList, currentConversationId])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [isInstalledApp])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
@ -274,6 +329,7 @@ const ChatWrapper = () => {
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{chatNode}
|
||||
@ -286,7 +342,7 @@ const ChatWrapper = () => {
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
sidebarCollapseState={sidebarCollapseState}
|
||||
questionIcon={
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ExtraContent } from '../chat/type'
|
||||
import type {
|
||||
Callback,
|
||||
ChatConfig,
|
||||
@ -9,6 +10,7 @@ import type {
|
||||
AppData,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) {
|
||||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
const humanInputFormDataList: HumanInputFormData[] = []
|
||||
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
|
||||
let workflowRunId = ''
|
||||
if (item.status === 'paused') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && !content.submitted) {
|
||||
humanInputFormDataList.push(content.form_definition)
|
||||
workflowRunId = content.workflow_run_id
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (item.status === 'normal') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && content.submitted) {
|
||||
humanInputFilledFormDataList.push(content.form_submission_data)
|
||||
}
|
||||
})
|
||||
}
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
@ -66,6 +86,9 @@ function getFormattedChatList(messages: any[]) {
|
||||
citation: item.retriever_resources,
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
workflow_run_id: workflowRunId,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import type { ContentItemProps } from './type'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const ContentItem = ({
|
||||
content,
|
||||
formInputFields,
|
||||
inputs,
|
||||
onInputChange,
|
||||
}: ContentItemProps) => {
|
||||
const isInputField = (field: string) => {
|
||||
const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/
|
||||
return outputVarRegex.test(field)
|
||||
}
|
||||
|
||||
const extractFieldName = (str: string): string => {
|
||||
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
|
||||
const match = str.match(outputVarRegex)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
const fieldName = useMemo(() => {
|
||||
return extractFieldName(content)
|
||||
}, [content])
|
||||
|
||||
const formInputField = useMemo(() => {
|
||||
return formInputFields.find(field => field.output_variable_name === fieldName)
|
||||
}, [formInputFields, fieldName])
|
||||
|
||||
if (!isInputField(content)) {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
if (!formInputField)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
{formInputField.type === 'paragraph' && (
|
||||
<Textarea
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ContentItem)
|
||||
@ -0,0 +1,64 @@
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ContentWrapperProps = {
|
||||
nodeTitle: string
|
||||
children: React.ReactNode
|
||||
showExpandIcon?: boolean
|
||||
className?: string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
const ContentWrapper = ({
|
||||
nodeTitle,
|
||||
children,
|
||||
showExpandIcon = false,
|
||||
className,
|
||||
expanded = false,
|
||||
}: ContentWrapperProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(expanded)
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}, [isExpanded])
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{/* node icon */}
|
||||
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
|
||||
{/* node name */}
|
||||
<div
|
||||
className="system-sm-semibold-uppercase grow truncate text-text-primary"
|
||||
title={nodeTitle}
|
||||
>
|
||||
{nodeTitle}
|
||||
</div>
|
||||
{showExpandIcon && (
|
||||
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
|
||||
{
|
||||
isExpanded
|
||||
? (
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
)
|
||||
: (
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!showExpandIcon || isExpanded) && (
|
||||
<div className="px-2 py-1">
|
||||
{/* human input form content */}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContentWrapper
|
||||
@ -0,0 +1,30 @@
|
||||
import type { ExecutedAction as ExecutedActionType } from './type'
|
||||
import { memo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
type ExecutedActionProps = {
|
||||
executedAction: ExecutedActionType
|
||||
}
|
||||
|
||||
const ExecutedAction = ({
|
||||
executedAction,
|
||||
}: ExecutedActionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 py-1">
|
||||
<Divider className="mb-2 mt-1 w-[30px]" />
|
||||
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
|
||||
<TriggerAll className="size-3.5 shrink-0" />
|
||||
<Trans
|
||||
i18nKey="nodes.humanInput.userActions.triggered"
|
||||
ns="workflow"
|
||||
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
|
||||
values={{ actionName: executedAction.id }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ExecutedAction)
|
||||
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getRelativeTime, isRelativeTimeSameOrAfter } from './utils'
|
||||
|
||||
type ExpirationTimeProps = {
|
||||
expirationTime: number
|
||||
}
|
||||
|
||||
const ExpirationTime = ({
|
||||
expirationTime,
|
||||
}: ExpirationTimeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const relativeTime = getRelativeTime(expirationTime, locale)
|
||||
const isSameOrAfter = isRelativeTimeSameOrAfter(expirationTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
|
||||
!isSameOrAfter && 'text-text-warning',
|
||||
)}
|
||||
>
|
||||
{
|
||||
isSameOrAfter
|
||||
? (
|
||||
<>
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiAlertFill className="size-3.5" />
|
||||
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExpirationTime
|
||||
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import type { HumanInputFormProps } from './type'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentItem from './content-item'
|
||||
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
|
||||
|
||||
const HumanInputForm = ({
|
||||
formData,
|
||||
onSubmit,
|
||||
}: HumanInputFormProps) => {
|
||||
const formToken = formData.form_token
|
||||
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
|
||||
const contentList = splitByOutputVar(formData.form_content)
|
||||
const [inputs, setInputs] = useState(defaultInputs)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleInputsChange = useCallback((name: string, value: string) => {
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
|
||||
setIsSubmitting(true)
|
||||
await onSubmit?.(formToken, { inputs, action: actionID })
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(formToken, action.id, inputs)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HumanInputForm)
|
||||
@ -0,0 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
|
||||
type SubmittedContentProps = {
|
||||
content: string
|
||||
}
|
||||
|
||||
const SubmittedContent = ({
|
||||
content,
|
||||
}: SubmittedContentProps) => {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SubmittedContent)
|
||||
@ -0,0 +1,25 @@
|
||||
import type { SubmittedHumanInputContentProps } from './type'
|
||||
import { useMemo } from 'react'
|
||||
import ExecutedAction from './executed-action'
|
||||
import SubmittedContent from './submitted-content'
|
||||
|
||||
export const SubmittedHumanInputContent = ({
|
||||
formData,
|
||||
}: SubmittedHumanInputContentProps) => {
|
||||
const { rendered_content, action_id, action_text } = formData
|
||||
|
||||
const executedAction = useMemo(() => {
|
||||
return {
|
||||
id: action_id,
|
||||
title: action_text,
|
||||
}
|
||||
}, [action_id, action_text])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubmittedContent content={rendered_content} />
|
||||
{/* Executed Action */}
|
||||
<ExecutedAction executedAction={executedAction} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { memo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { useSelector as useAppSelector } from '@/context/app-context'
|
||||
|
||||
type TipsProps = {
|
||||
showEmailTip: boolean
|
||||
isEmailDebugMode: boolean
|
||||
showDebugModeTip: boolean
|
||||
}
|
||||
|
||||
const Tips = ({
|
||||
showEmailTip,
|
||||
isEmailDebugMode,
|
||||
showDebugModeTip,
|
||||
}: TipsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const email = useAppSelector(s => s.userProfile.email)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="!my-2 w-[30px]" />
|
||||
<div className="space-y-1 pt-1">
|
||||
{showEmailTip && !isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
{showEmailTip && isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<Trans
|
||||
i18nKey="common.humanInputEmailTipInDebugMode"
|
||||
ns="workflow"
|
||||
components={{ email: <span className="system-xs-semibold"></span> }}
|
||||
values={{ email }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Tips)
|
||||
@ -0,0 +1,31 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
|
||||
export type ExecutedAction = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type UnsubmittedHumanInputContentProps = {
|
||||
formData: HumanInputFormData
|
||||
showEmailTip?: boolean
|
||||
isEmailDebugMode?: boolean
|
||||
showDebugModeTip?: boolean
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export type SubmittedHumanInputContentProps = {
|
||||
formData: HumanInputFilledFormData
|
||||
}
|
||||
|
||||
export type HumanInputFormProps = {
|
||||
formData: HumanInputFormData
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export type ContentItemProps = {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
inputs: Record<string, string>
|
||||
onInputChange: (name: string, value: string) => void
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import type { UnsubmittedHumanInputContentProps } from './type'
|
||||
import ExpirationTime from './expiration-time'
|
||||
import HumanInputForm from './human-input-form'
|
||||
import Tips from './tips'
|
||||
|
||||
export const UnsubmittedHumanInputContent = ({
|
||||
formData,
|
||||
showEmailTip = false,
|
||||
isEmailDebugMode = false,
|
||||
showDebugModeTip = false,
|
||||
onSubmit,
|
||||
}: UnsubmittedHumanInputContentProps) => {
|
||||
const { expiration_time } = formData
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Form */}
|
||||
<HumanInputForm
|
||||
formData={formData}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{/* Tips */}
|
||||
{(showEmailTip || showDebugModeTip) && (
|
||||
<Tips
|
||||
showEmailTip={showEmailTip}
|
||||
isEmailDebugMode={isEmailDebugMode}
|
||||
showDebugModeTip={showDebugModeTip}
|
||||
/>
|
||||
)}
|
||||
{/* Expiration Time */}
|
||||
{typeof expiration_time === 'number' && (
|
||||
<ExpirationTime expirationTime={expiration_time * 1000} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import dayjs from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
|
||||
export const getButtonStyle = (style: UserActionButtonType) => {
|
||||
if (style === UserActionButtonType.Primary)
|
||||
return 'primary'
|
||||
if (style === UserActionButtonType.Default)
|
||||
return 'secondary'
|
||||
if (style === UserActionButtonType.Accent)
|
||||
return 'secondary-accent'
|
||||
if (style === UserActionButtonType.Ghost)
|
||||
return 'ghost'
|
||||
}
|
||||
|
||||
export const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
|
||||
const initialInputs: Record<string, any> = {}
|
||||
formInputs.forEach((item) => {
|
||||
if (item.type === 'text-input' || item.type === 'paragraph')
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
|
||||
else
|
||||
initialInputs[item.output_variable_name] = undefined
|
||||
})
|
||||
return initialInputs
|
||||
}
|
||||
|
||||
const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'ja-JP': 'ja',
|
||||
}
|
||||
|
||||
export const getRelativeTime = (
|
||||
utcTimestamp: string | number,
|
||||
locale: Locale = 'en-US',
|
||||
) => {
|
||||
const dayjsLocale = localeMap[locale] ?? 'en'
|
||||
|
||||
return dayjs
|
||||
.utc(utcTimestamp)
|
||||
.locale(dayjsLocale)
|
||||
.fromNow()
|
||||
}
|
||||
|
||||
export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => {
|
||||
return dayjs.utc(utcTimestamp).isSameOrAfter(dayjs())
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import ContentWrapper from './human-input-content/content-wrapper'
|
||||
import { SubmittedHumanInputContent } from './human-input-content/submitted'
|
||||
|
||||
type HumanInputFilledFormListProps = {
|
||||
humanInputFilledFormDataList: HumanInputFilledFormData[]
|
||||
}
|
||||
|
||||
const HumanInputFilledFormList = ({
|
||||
humanInputFilledFormDataList,
|
||||
}: HumanInputFilledFormListProps) => {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
{
|
||||
humanInputFilledFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
key={formData.node_id}
|
||||
nodeTitle={formData.node_title}
|
||||
showExpandIcon
|
||||
>
|
||||
<SubmittedHumanInputContent
|
||||
key={formData.node_id}
|
||||
formData={formData}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HumanInputFilledFormList
|
||||
@ -0,0 +1,70 @@
|
||||
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { useMemo } from 'react'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import ContentWrapper from './human-input-content/content-wrapper'
|
||||
import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
|
||||
|
||||
type HumanInputFormListProps = {
|
||||
humanInputFormDataList: HumanInputFormData[]
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
|
||||
}
|
||||
|
||||
const HumanInputFormList = ({
|
||||
humanInputFormDataList,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
}: HumanInputFormListProps) => {
|
||||
const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => {
|
||||
if (!humanInputFormDataList.length)
|
||||
return {}
|
||||
return humanInputFormDataList.reduce((acc, formData) => {
|
||||
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
|
||||
if (!deliveryMethodsConfig.length) {
|
||||
acc[formData.node_id] = {
|
||||
showEmailTip: false,
|
||||
isEmailDebugMode: false,
|
||||
showDebugModeTip: false,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
|
||||
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
|
||||
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
|
||||
acc[formData.node_id] = {
|
||||
showEmailTip: isEmailEnabled,
|
||||
isEmailDebugMode,
|
||||
showDebugModeTip: !isWebappEnabled,
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>)
|
||||
}, [getHumanInputNodeData, humanInputFormDataList])
|
||||
|
||||
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
{
|
||||
filteredHumanInputFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
key={formData.form_id}
|
||||
nodeTitle={formData.node_title}
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
key={formData.form_id}
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HumanInputFormList
|
||||
@ -16,8 +16,11 @@ import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ContentSwitch from '../content-switch'
|
||||
import { useChatContext } from '../context'
|
||||
import AgentContent from './agent-content'
|
||||
import BasicContent from './basic-content'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
import More from './more'
|
||||
import Operation from './operation'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
@ -36,6 +39,8 @@ type AnswerProps = {
|
||||
appData?: AppData
|
||||
noChatInput?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
}
|
||||
const Answer: FC<AnswerProps> = ({
|
||||
item,
|
||||
@ -50,6 +55,8 @@ const Answer: FC<AnswerProps> = ({
|
||||
appData,
|
||||
noChatInput,
|
||||
switchSibling,
|
||||
hideAvatar,
|
||||
onHumanInputFormSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@ -61,13 +68,22 @@ const Answer: FC<AnswerProps> = ({
|
||||
workflowProcess,
|
||||
allFiles,
|
||||
message_files,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
} = item
|
||||
const hasAgentThoughts = !!agent_thoughts?.length
|
||||
const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const humanInputFormContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
getHumanInputNodeData,
|
||||
} = useChatContext()
|
||||
|
||||
const getContainerWidth = () => {
|
||||
if (containerRef.current)
|
||||
@ -87,12 +103,23 @@ const Answer: FC<AnswerProps> = ({
|
||||
getContentWidth()
|
||||
}, [responding])
|
||||
|
||||
const getHumanInputFormContainerWidth = () => {
|
||||
if (humanInputFormContainerRef.current)
|
||||
setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHumanInputs)
|
||||
getHumanInputFormContainerWidth()
|
||||
}, [hasHumanInputs])
|
||||
|
||||
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current)
|
||||
return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
getContentWidth()
|
||||
getHumanInputFormContainerWidth()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
return () => {
|
||||
@ -115,115 +142,285 @@ const Answer: FC<AnswerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex last:mb-0">
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
|
||||
<LoadingAnim type="avatar" />
|
||||
{!hideAvatar && (
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
|
||||
<LoadingAnim type="avatar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
{/* Block 1: Workflow Process + Human Input Forms */}
|
||||
{hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={humanInputFormContainerRef}
|
||||
className={cn('body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary')}
|
||||
>
|
||||
{
|
||||
!responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - humanInputFormContainerWidth - 4}
|
||||
contentWidth={humanInputFormContainerWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
humanInputFormDataList && humanInputFormDataList.length > 0 && (
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={humanInputFormDataList}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
|
||||
<HumanInputFilledFormList
|
||||
humanInputFilledFormDataList={humanInputFilledFormDataList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& !responding
|
||||
&& contentIsEmpty
|
||||
&& !hasAgentThoughts
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
(hasAgentThoughts) && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Block 2: Response Content (when human inputs exist) */}
|
||||
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && (
|
||||
<div className={cn('group relative mt-2 pr-10', chatAnswerContainerInner)}>
|
||||
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary"
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasAgentThoughts && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Original single block layout (when no human inputs) */}
|
||||
{!hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasAgentThoughts && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1 && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<More more={more} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,6 +69,7 @@ const Operation: FC<OperationProps> = ({
|
||||
feedback,
|
||||
adminFeedback,
|
||||
agent_thoughts,
|
||||
humanInputFormDataList,
|
||||
} = item
|
||||
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
|
||||
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||
@ -186,7 +187,7 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
>
|
||||
{shouldShowUserFeedbackBar && (
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
|
||||
@ -226,7 +227,7 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && (
|
||||
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
|
||||
@ -305,26 +306,28 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
|
||||
{(config?.text_to_speech?.enabled) && (
|
||||
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
value={content}
|
||||
voice={config?.text_to_speech?.voice}
|
||||
/>
|
||||
)}
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
{!humanInputFormDataList?.length && (
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!noChatInput && (
|
||||
<ActionButton onClick={() => onRegenerate?.(item)}>
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
|
||||
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
|
||||
<AnnotationCtrlButton
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
RiArrowRightSLine,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
RiPauseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useEffect,
|
||||
@ -34,6 +35,8 @@ const WorkflowProcessItem = ({
|
||||
const running = data.status === WorkflowRunningStatus.Running
|
||||
const succeeded = data.status === WorkflowRunningStatus.Succeeded
|
||||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
const paused = data.status === WorkflowRunningStatus.Paused
|
||||
const latestNode = data.tracing[data.tracing.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
setCollapse(!expand)
|
||||
@ -50,7 +53,10 @@ const WorkflowProcessItem = ({
|
||||
running && !collapse && 'bg-background-section-burn',
|
||||
succeeded && !collapse && 'bg-state-success-hover',
|
||||
failed && !collapse && 'bg-state-destructive-hover',
|
||||
collapse && 'bg-workflow-process-bg',
|
||||
paused && !collapse && 'bg-state-warning-hover',
|
||||
collapse && !failed && !paused && 'bg-workflow-process-bg',
|
||||
collapse && paused && 'bg-workflow-process-paused-bg',
|
||||
collapse && failed && 'bg-workflow-process-failed-bg',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -72,8 +78,13 @@ const WorkflowProcessItem = ({
|
||||
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
|
||||
)
|
||||
}
|
||||
{
|
||||
paused && (
|
||||
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
{t('common.workflowProcess', { ns: 'workflow' })}
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
</div>
|
||||
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,8 @@ export type ChatContextValue = Pick<ChatProps, 'config'
|
||||
| 'onAnnotationAdded'
|
||||
| 'onAnnotationRemoved'
|
||||
| 'disableFeedback'
|
||||
| 'onFeedback'> & {
|
||||
| 'onFeedback'
|
||||
| 'getHumanInputNodeData'> & {
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
@ -45,6 +46,7 @@ export const ChatContextProvider = ({
|
||||
onAnnotationRemoved,
|
||||
disableFeedback,
|
||||
onFeedback,
|
||||
getHumanInputNodeData,
|
||||
}: ChatContextProviderProps) => {
|
||||
return (
|
||||
<ChatContext.Provider value={{
|
||||
@ -62,6 +64,7 @@ export const ChatContextProvider = ({
|
||||
onAnnotationRemoved,
|
||||
disableFeedback,
|
||||
onFeedback,
|
||||
getHumanInputNodeData,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -75,6 +75,9 @@ export type ChatProps = {
|
||||
noSpacing?: boolean
|
||||
inputDisabled?: boolean
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => any
|
||||
}
|
||||
|
||||
const Chat: FC<ChatProps> = ({
|
||||
@ -116,6 +119,9 @@ const Chat: FC<ChatProps> = ({
|
||||
noSpacing,
|
||||
inputDisabled,
|
||||
sidebarCollapseState,
|
||||
hideAvatar,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||
@ -265,6 +271,7 @@ const Chat: FC<ChatProps> = ({
|
||||
onAnnotationRemoved={onAnnotationRemoved}
|
||||
disableFeedback={disableFeedback}
|
||||
onFeedback={onFeedback}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
>
|
||||
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div
|
||||
@ -295,6 +302,8 @@ const Chat: FC<ChatProps> = ({
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
noChatInput={noChatInput}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -306,6 +315,7 @@ const Chat: FC<ChatProps> = ({
|
||||
theme={themeBuilder?.theme}
|
||||
enableEdit={config?.questionEditEnable}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -32,6 +32,7 @@ type QuestionProps = {
|
||||
theme: Theme | null | undefined
|
||||
enableEdit?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
}
|
||||
|
||||
const Question: FC<QuestionProps> = ({
|
||||
@ -40,6 +41,7 @@ const Question: FC<QuestionProps> = ({
|
||||
theme,
|
||||
enableEdit = true,
|
||||
switchSibling,
|
||||
hideAvatar,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -174,15 +176,17 @@ const Question: FC<QuestionProps> = ({
|
||||
</div>
|
||||
<div className="mt-1 h-[18px]" />
|
||||
</div>
|
||||
<div className="h-10 w-10 shrink-0">
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!hideAvatar && (
|
||||
<div className="h-10 w-10 shrink-0">
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,11 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { Annotation, MessageRating } from '@/models/log'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import type {
|
||||
FileResponse,
|
||||
HumanInputFilledFormData,
|
||||
HumanInputFormData,
|
||||
} from '@/types/workflow'
|
||||
|
||||
export type MessageMore = {
|
||||
time: string
|
||||
@ -64,6 +68,19 @@ export type CitationItem = {
|
||||
word_count: number
|
||||
}
|
||||
|
||||
export type ExtraContent
|
||||
= {
|
||||
type: 'human_input'
|
||||
submitted: false
|
||||
form_definition: HumanInputFormData
|
||||
workflow_run_id: string
|
||||
}
|
||||
| {
|
||||
type: 'human_input'
|
||||
submitted: true
|
||||
form_submission_data: HumanInputFilledFormData
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
content: string
|
||||
@ -104,6 +121,10 @@ export type IChatItem = {
|
||||
siblingIndex?: number
|
||||
prevSibling?: string
|
||||
nextSibling?: string
|
||||
// for human input
|
||||
humanInputFormDataList?: HumanInputFormData[]
|
||||
humanInputFilledFormDataList?: HumanInputFilledFormData[]
|
||||
extra_contents?: ExtraContent[]
|
||||
}
|
||||
|
||||
export type Metadata = {
|
||||
|
||||
@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@ -17,7 +18,9 @@ import {
|
||||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Avatar from '../../avatar'
|
||||
@ -70,9 +73,9 @@ const ChatWrapper = () => {
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@ -130,6 +133,40 @@ const ChatWrapper = () => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatList || appPrevChatList.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatList)
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
@ -147,7 +184,7 @@ const ChatWrapper = () => {
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
@ -155,6 +192,14 @@ const ChatWrapper = () => {
|
||||
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const doSwitchSibling = useCallback((siblingMessageId: string) => {
|
||||
handleSwitchSibling(siblingMessageId, {
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
})
|
||||
}, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
@ -178,6 +223,13 @@ const ChatWrapper = () => {
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [isInstalledApp])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
if (respondingState)
|
||||
@ -223,7 +275,7 @@ const ChatWrapper = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
|
||||
|
||||
const answerIcon = isDify()
|
||||
? <LogoAvatar className="relative shrink-0" />
|
||||
@ -253,6 +305,7 @@ const ChatWrapper = () => {
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{chatNode}
|
||||
@ -266,7 +319,7 @@ const ChatWrapper = () => {
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
questionIcon={
|
||||
initUserVariables?.avatar_url
|
||||
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
ModelConfig,
|
||||
VisionSettings,
|
||||
} from '@/types/app'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
|
||||
|
||||
export type {
|
||||
Inputs,
|
||||
@ -67,6 +67,8 @@ export type WorkflowProcess = {
|
||||
expand?: boolean // for UI
|
||||
resultText?: string
|
||||
files?: FileEntity[]
|
||||
humanInputFormDataList?: HumanInputFormData[]
|
||||
humanInputFilledFormDataList?: HumanInputFilledFormData[]
|
||||
}
|
||||
|
||||
export type ChatItem = IChatItem & {
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z" fill="#44BEDF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z" fill="#2EB67D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z" fill="#ECB22E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z" fill="#E01E5A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
19
web/app/components/base/icons/assets/public/other/teams.svg
Normal file
19
web/app/components/base/icons/assets/public/other/teams.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_20307_29670)">
|
||||
<path opacity="0.1" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
|
||||
<path opacity="0.1" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
|
||||
<path d="M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z" fill="#5059C9"/>
|
||||
<path d="M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z" fill="#5059C9"/>
|
||||
<path d="M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z" fill="#7B83EB"/>
|
||||
<path d="M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z" fill="#7B83EB"/>
|
||||
<path opacity="0.5" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
|
||||
<path opacity="0.5" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
|
||||
<path d="M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z" fill="#4B53BC"/>
|
||||
<path d="M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_20307_29670">
|
||||
<rect width="28" height="28" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
61
web/app/components/base/icons/src/public/other/Slack.json
Normal file
61
web/app/components/base/icons/src/public/other/Slack.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "27",
|
||||
"height": "27",
|
||||
"viewBox": "0 0 27 27",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z",
|
||||
"fill": "#44BEDF"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z",
|
||||
"fill": "#2EB67D"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z",
|
||||
"fill": "#ECB22E"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z",
|
||||
"fill": "#E01E5A"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Slack"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/other/Slack.tsx
Normal file
20
web/app/components/base/icons/src/public/other/Slack.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Slack.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Slack'
|
||||
|
||||
export default Icon
|
||||
146
web/app/components/base/icons/src/public/other/Teams.json
Normal file
146
web/app/components/base/icons/src/public/other/Teams.json
Normal file
@ -0,0 +1,146 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "28",
|
||||
"height": "28",
|
||||
"viewBox": "0 0 28 28",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"clip-path": "url(#clip0_20307_29670)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.1",
|
||||
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.1",
|
||||
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z",
|
||||
"fill": "#5059C9"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z",
|
||||
"fill": "#5059C9"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z",
|
||||
"fill": "#7B83EB"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z",
|
||||
"fill": "#7B83EB"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z",
|
||||
"fill": "#4B53BC"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_20307_29670"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "28",
|
||||
"height": "28",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Teams"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/other/Teams.tsx
Normal file
20
web/app/components/base/icons/src/public/other/Teams.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Teams.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Teams'
|
||||
|
||||
export default Icon
|
||||
@ -2,3 +2,5 @@ export { default as DefaultToolIcon } from './DefaultToolIcon'
|
||||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
export { default as Message3Fill } from './Message3Fill'
|
||||
export { default as RowStruct } from './RowStruct'
|
||||
export { default as Slack } from './Slack'
|
||||
export { default as Teams } from './Teams'
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "HumanInLoop"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './HumanInLoop.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'HumanInLoop'
|
||||
|
||||
export default Icon
|
||||
@ -10,6 +10,7 @@ export { default as DocsExtractor } from './DocsExtractor'
|
||||
export { default as End } from './End'
|
||||
export { default as Home } from './Home'
|
||||
export { default as Http } from './Http'
|
||||
export { default as HumanInLoop } from './HumanInLoop'
|
||||
export { default as IfElse } from './IfElse'
|
||||
export { default as Iteration } from './Iteration'
|
||||
export { default as IterationStart } from './IterationStart'
|
||||
|
||||
@ -18,7 +18,7 @@ export type MarkdownProps = {
|
||||
content: string
|
||||
className?: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements'>
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins'>
|
||||
|
||||
export const Markdown = (props: MarkdownProps) => {
|
||||
const { customComponents = {}, pluginInfo } = props
|
||||
@ -29,7 +29,13 @@ export const Markdown = (props: MarkdownProps) => {
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
|
||||
<ReactMarkdown pluginInfo={pluginInfo} latexContent={latexContent} customComponents={customComponents} customDisallowedElements={props.customDisallowedElements} />
|
||||
<ReactMarkdown
|
||||
pluginInfo={pluginInfo}
|
||||
latexContent={latexContent}
|
||||
customComponents={customComponents}
|
||||
customDisallowedElements={props.customDisallowedElements}
|
||||
rehypePlugins={props.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ export type ReactMarkdownWrapperProps = {
|
||||
customDisallowedElements?: string[]
|
||||
customComponents?: Record<string, React.ComponentType<any>>
|
||||
pluginInfo?: SimplePluginInfo
|
||||
rehypePlugins?: any// js: PluggableList[]
|
||||
}
|
||||
|
||||
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
||||
@ -55,6 +56,7 @@ export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
||||
tree.children.forEach(iterate)
|
||||
}
|
||||
},
|
||||
...(props.rehypePlugins || []),
|
||||
]}
|
||||
urlTransform={customUrlTransform}
|
||||
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
|
||||
|
||||
@ -4,6 +4,7 @@ import { SupportUploadFileTypes } from '../../workflow/types'
|
||||
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
|
||||
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
|
||||
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
|
||||
export const REQUEST_URL_PLACEHOLDER_TEXT = '{{#url#}}'
|
||||
export const CURRENT_PLACEHOLDER_TEXT = '{{#current#}}'
|
||||
export const ERROR_MESSAGE_PLACEHOLDER_TEXT = '{{#error_message#}}'
|
||||
export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
|
||||
@ -30,6 +31,12 @@ export const checkHasQueryBlock = (text: string) => {
|
||||
return text.includes(QUERY_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
export const checkHasRequestURLBlock = (text: string) => {
|
||||
if (!text)
|
||||
return false
|
||||
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
/*
|
||||
* {{#1711617514996.name#}} => [1711617514996, name]
|
||||
* {{#1711617514996.sys.query#}} => [sys, query]
|
||||
|
||||
@ -2,16 +2,20 @@
|
||||
|
||||
import type {
|
||||
EditorState,
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
ErrorMessageBlockType,
|
||||
ExternalToolBlockType,
|
||||
HistoryBlockType,
|
||||
HITLInputBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from './types'
|
||||
@ -27,7 +31,7 @@ import {
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
@ -46,17 +50,23 @@ import {
|
||||
CurrentBlockReplacementBlock,
|
||||
} from './plugins/current-block'
|
||||
import { CustomTextNode } from './plugins/custom-text/node'
|
||||
import DraggableBlockPlugin from './plugins/draggable-plugin'
|
||||
import {
|
||||
ErrorMessageBlock,
|
||||
ErrorMessageBlockNode,
|
||||
ErrorMessageBlockReplacementBlock,
|
||||
} from './plugins/error-message-block'
|
||||
|
||||
import {
|
||||
HistoryBlock,
|
||||
HistoryBlockNode,
|
||||
HistoryBlockReplacementBlock,
|
||||
} from './plugins/history-block'
|
||||
|
||||
import {
|
||||
HITLInputBlock,
|
||||
HITLInputBlockReplacementBlock,
|
||||
HITLInputNode,
|
||||
} from './plugins/hitl-input-block'
|
||||
import {
|
||||
LastRunBlock,
|
||||
LastRunBlockNode,
|
||||
@ -70,6 +80,12 @@ import {
|
||||
QueryBlockNode,
|
||||
QueryBlockReplacementBlock,
|
||||
} from './plugins/query-block'
|
||||
import {
|
||||
RequestURLBlock,
|
||||
RequestURLBlockNode,
|
||||
RequestURLBlockReplacementBlock,
|
||||
} from './plugins/request-url-block'
|
||||
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
|
||||
import UpdateBlock from './plugins/update-block'
|
||||
import VariableBlock from './plugins/variable-block'
|
||||
import VariableValueBlock from './plugins/variable-value-block'
|
||||
@ -96,14 +112,17 @@ export type PromptEditorProps = {
|
||||
onFocus?: () => void
|
||||
contextBlock?: ContextBlockType
|
||||
queryBlock?: QueryBlockType
|
||||
requestURLBlock?: RequestURLBlockType
|
||||
historyBlock?: HistoryBlockType
|
||||
variableBlock?: VariableBlockType
|
||||
externalToolBlock?: ExternalToolBlockType
|
||||
workflowVariableBlock?: WorkflowVariableBlockType
|
||||
hitlInputBlock?: HITLInputBlockType
|
||||
currentBlock?: CurrentBlockType
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportFileVar?: boolean
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
@ -121,14 +140,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
onFocus,
|
||||
contextBlock,
|
||||
queryBlock,
|
||||
requestURLBlock,
|
||||
historyBlock,
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
workflowVariableBlock,
|
||||
hitlInputBlock,
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
isSupportFileVar,
|
||||
shortcutPopups = [],
|
||||
}) => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const initialConfig = {
|
||||
@ -143,8 +165,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
ContextBlockNode,
|
||||
HistoryBlockNode,
|
||||
QueryBlockNode,
|
||||
RequestURLBlockNode,
|
||||
WorkflowVariableBlockNode,
|
||||
VariableValueBlockNode,
|
||||
HITLInputNode,
|
||||
CurrentBlockNode,
|
||||
ErrorMessageBlockNode,
|
||||
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
|
||||
@ -176,9 +200,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
} as any)
|
||||
}, [eventEmitter, historyBlock?.history])
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
|
||||
|
||||
const onRef = (_floatingAnchorElem: any) => {
|
||||
if (_floatingAnchorElem !== null)
|
||||
setFloatingAnchorElem(_floatingAnchorElem)
|
||||
}
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||
<div className={cn('relative', wrapperClassName)}>
|
||||
<div className={cn('relative', wrapperClassName)} ref={onRef}>
|
||||
<RichTextPlugin
|
||||
contentEditable={(
|
||||
<ContentEditable
|
||||
@ -199,11 +230,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
)}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{shortcutPopups?.map(({ hotkey, Popup }, idx) => (
|
||||
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
|
||||
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
|
||||
</ShortcutsPopupPlugin>
|
||||
))}
|
||||
<ComponentPickerBlock
|
||||
triggerString="/"
|
||||
contextBlock={contextBlock}
|
||||
historyBlock={historyBlock}
|
||||
queryBlock={queryBlock}
|
||||
requestURLBlock={requestURLBlock}
|
||||
variableBlock={variableBlock}
|
||||
externalToolBlock={externalToolBlock}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
@ -217,6 +254,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
contextBlock={contextBlock}
|
||||
historyBlock={historyBlock}
|
||||
queryBlock={queryBlock}
|
||||
requestURLBlock={requestURLBlock}
|
||||
variableBlock={variableBlock}
|
||||
externalToolBlock={externalToolBlock}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
@ -265,6 +303,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
hitlInputBlock?.show && (
|
||||
<>
|
||||
<HITLInputBlock {...hitlInputBlock} />
|
||||
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentBlock?.show && (
|
||||
<>
|
||||
@ -273,6 +319,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
requestURLBlock?.show && (
|
||||
<>
|
||||
<RequestURLBlock {...requestURLBlock} />
|
||||
<RequestURLBlockReplacementBlock {...requestURLBlock} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
errorMessageBlock?.show && (
|
||||
<>
|
||||
@ -298,6 +352,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
<HistoryPlugin />
|
||||
{floatingAnchorElem && (
|
||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||
)}
|
||||
{/* <TreeView /> */}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
|
||||
@ -6,10 +6,12 @@ import type {
|
||||
HistoryBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import { $insertNodes } from 'lexical'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -27,6 +29,7 @@ import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
|
||||
import { $createCustomTextNode } from '../custom-text/node'
|
||||
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
|
||||
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
|
||||
import { INSERT_REQUEST_URL_BLOCK_COMMAND } from '../request-url-block'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
|
||||
import { PickerBlockMenuOption } from './menu'
|
||||
import { PromptMenuItem } from './prompt-option'
|
||||
@ -36,6 +39,7 @@ export const usePromptOptions = (
|
||||
contextBlock?: ContextBlockType,
|
||||
queryBlock?: QueryBlockType,
|
||||
historyBlock?: HistoryBlockType,
|
||||
requestURLBlock?: RequestURLBlockType,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
@ -91,6 +95,30 @@ export const usePromptOptions = (
|
||||
)
|
||||
}
|
||||
|
||||
if (requestURLBlock?.show) {
|
||||
promptOptions.push(new PickerBlockMenuOption({
|
||||
key: t('promptEditor.requestURL.item.title', { ns: 'common' }),
|
||||
group: 'request URL',
|
||||
render: ({ isSelected, onSelect, onSetHighlight }) => {
|
||||
return (
|
||||
<PromptMenuItem
|
||||
title={t('promptEditor.requestURL.item.title', { ns: 'common' })}
|
||||
icon={<RiGlobalLine className="h-4 w-4 text-util-colors-violet-violet-600" />}
|
||||
disabled={!requestURLBlock.selectable}
|
||||
isSelected={isSelected}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onSetHighlight}
|
||||
/>
|
||||
)
|
||||
},
|
||||
onSelect: () => {
|
||||
if (!requestURLBlock?.selectable)
|
||||
return
|
||||
editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (historyBlock?.show) {
|
||||
promptOptions.push(
|
||||
new PickerBlockMenuOption({
|
||||
@ -272,12 +300,13 @@ export const useOptions = (
|
||||
variableBlock?: VariableBlockType,
|
||||
externalToolBlockType?: ExternalToolBlockType,
|
||||
workflowVariableBlockType?: WorkflowVariableBlockType,
|
||||
requestURLBlock?: RequestURLBlockType,
|
||||
currentBlockType?: CurrentBlockType,
|
||||
errorMessageBlockType?: ErrorMessageBlockType,
|
||||
lastRunBlockType?: LastRunBlockType,
|
||||
queryString?: string,
|
||||
) => {
|
||||
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
|
||||
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock, requestURLBlock)
|
||||
const variableOptions = useVariableOptions(variableBlock, queryString)
|
||||
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
HistoryBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
@ -44,6 +45,7 @@ type ComponentPickerProps = {
|
||||
triggerString: string
|
||||
contextBlock?: ContextBlockType
|
||||
queryBlock?: QueryBlockType
|
||||
requestURLBlock?: RequestURLBlockType
|
||||
historyBlock?: HistoryBlockType
|
||||
variableBlock?: VariableBlockType
|
||||
externalToolBlock?: ExternalToolBlockType
|
||||
@ -57,6 +59,7 @@ const ComponentPicker = ({
|
||||
triggerString,
|
||||
contextBlock,
|
||||
queryBlock,
|
||||
requestURLBlock,
|
||||
historyBlock,
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
@ -100,6 +103,7 @@ const ComponentPicker = ({
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
workflowVariableBlock,
|
||||
requestURLBlock,
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
import type { JSX } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
|
||||
import { RiDraggable } from '@remixicon/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
|
||||
|
||||
function isOnMenu(element: HTMLElement): boolean {
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
|
||||
}
|
||||
|
||||
const SUPPORT_DRAG_CLASS = 'support-drag'
|
||||
function checkSupportDrag(element: Element | null): boolean {
|
||||
if (!element)
|
||||
return false
|
||||
|
||||
if (element.classList.contains(SUPPORT_DRAG_CLASS))
|
||||
return true
|
||||
|
||||
if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`))
|
||||
return true
|
||||
|
||||
return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
|
||||
}
|
||||
|
||||
export default function DraggableBlockPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const targetLineRef = useRef<HTMLDivElement>(null)
|
||||
const [, setDraggableElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
)
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [isSupportDrag, setIsSupportDrag] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const root = editor.getRootElement()
|
||||
if (!root)
|
||||
return
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const isSupportDrag = checkSupportDrag(e.target as Element)
|
||||
setIsSupportDrag(isSupportDrag)
|
||||
}
|
||||
|
||||
root.addEventListener('mousemove', onMove)
|
||||
return () => root.removeEventListener('mousemove', onMove)
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<DraggableBlockPlugin_EXPERIMENTAL
|
||||
anchorElem={anchorElem}
|
||||
menuRef={menuRef as any}
|
||||
targetLineRef={targetLineRef as any}
|
||||
menuComponent={
|
||||
isSupportDrag
|
||||
? (
|
||||
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')}>
|
||||
<RiDraggable className="size-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
targetLineComponent={(
|
||||
<div
|
||||
ref={targetLineRef}
|
||||
className="pointer-events-none absolute left-[-21px] top-0 opacity-0 will-change-transform"
|
||||
// style={{ width: 500 }} // width not worked here
|
||||
>
|
||||
<div
|
||||
className="absolute -right-10 left-0 top-0 h-[2px] bg-text-accent-secondary"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
isOnMenu={isOnMenu}
|
||||
onElementChanged={setDraggableElement}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ActionButton from '../../../action-button'
|
||||
import { VariableX } from '../../../icons/src/vender/workflow'
|
||||
import Modal from '../../../modal'
|
||||
import InputField from './input-field'
|
||||
import VariableBlock from './variable-block'
|
||||
|
||||
type HITLInputComponentUIProps = {
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInput?: FormInputItem
|
||||
onChange: (input: FormInputItem) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId,
|
||||
varName,
|
||||
formInput = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: varName,
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}) => {
|
||||
const [isShowEditModal, {
|
||||
setTrue: showEditModal,
|
||||
setFalse: hideEditModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
// Lexical delegate the click make it unable to add click by the method of react
|
||||
const editBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const editBtn = editBtnRef.current
|
||||
if (editBtn)
|
||||
editBtn.addEventListener('click', showEditModal)
|
||||
|
||||
return () => {
|
||||
if (editBtn)
|
||||
editBtn.removeEventListener('click', showEditModal)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const removeBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const removeBtn = removeBtnRef.current
|
||||
const removeHandler = () => onRemove(varName)
|
||||
if (removeBtn)
|
||||
removeBtn.addEventListener('click', removeHandler)
|
||||
|
||||
return () => {
|
||||
if (removeBtn)
|
||||
removeBtn.removeEventListener('click', removeHandler)
|
||||
}
|
||||
}, [onRemove, varName])
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (varName === newPayload.output_variable_name)
|
||||
onChange(newPayload)
|
||||
else
|
||||
onRename(newPayload, varName)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, onChange, onRename, varName])
|
||||
|
||||
const isDefaultValueVariable = useMemo(() => {
|
||||
return formInput.default?.type === 'variable'
|
||||
}, [formInput.default?.type])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex h-8 w-full select-none items-center rounded-[8px] border-[1.5px] border-components-input-border-active bg-background-default-hover pl-1.5 pr-0.5"
|
||||
>
|
||||
<div className="absolute left-2.5 top-[-12px]">
|
||||
<div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div>
|
||||
<div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only">
|
||||
<VariableX className="size-3" />
|
||||
<div className="system-xs-medium">{varName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-x-0.5 pr-5">
|
||||
<div className="min-w-0 grow">
|
||||
{/* Default Value Info */}
|
||||
{isDefaultValueVariable && (
|
||||
<VariableBlock
|
||||
variables={formInput.default?.selector}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
/>
|
||||
)}
|
||||
{!isDefaultValueVariable && (
|
||||
<div className="system-xs-medium max-w-full truncate text-components-input-text-filled">{formInput.default?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!readonly && (
|
||||
<div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex">
|
||||
<div className="flex h-full items-center" ref={editBtnRef}>
|
||||
<ActionButton size="s">
|
||||
<RiEditLine className="size-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full items-center" ref={removeBtnRef}>
|
||||
<ActionButton size="s">
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isShowEditModal && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={hideEditModal}
|
||||
wrapperClassName="z-[999]"
|
||||
className="max-w-[372px] !p-0"
|
||||
>
|
||||
<InputField
|
||||
nodeId={nodeId}
|
||||
isEdit
|
||||
payload={formInput}
|
||||
onChange={handleChange}
|
||||
onCancel={hideEditModal}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HITLInputComponentUI)
|
||||
@ -0,0 +1,86 @@
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
|
||||
import ComponentUi from './component-ui'
|
||||
|
||||
type HITLInputComponentProps = {
|
||||
nodeKey: string
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInputs?: FormInputItem[]
|
||||
onChange: (inputs: FormInputItem[]) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
nodeKey,
|
||||
nodeId,
|
||||
varName,
|
||||
formInputs = [],
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}) => {
|
||||
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
|
||||
const payload = formInputs.find(item => item.output_variable_name === varName)
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (!payload) {
|
||||
onChange([...formInputs, newPayload])
|
||||
return
|
||||
}
|
||||
if (payload?.output_variable_name !== newPayload.output_variable_name) {
|
||||
onChange(produce(formInputs, (draft) => {
|
||||
draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload)
|
||||
}))
|
||||
return
|
||||
}
|
||||
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
|
||||
}, [formInputs, onChange, payload, varName])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full pb-1 pt-3"
|
||||
>
|
||||
<ComponentUi
|
||||
nodeId={nodeId}
|
||||
varName={varName}
|
||||
formInput={payload}
|
||||
onChange={handleChange}
|
||||
onRename={onRename}
|
||||
onRemove={onRemove}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HITLInputComponent
|
||||
@ -0,0 +1,89 @@
|
||||
import type { TextNode } from 'lexical'
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { HITL_INPUT_REG } from '@/config'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import { $createHITLInputNode, HITLInputNode } from './node'
|
||||
|
||||
const REGEX = new RegExp(HITL_INPUT_REG)
|
||||
|
||||
const HITLInputReplacementBlock = ({
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
variables,
|
||||
readonly,
|
||||
}: HITLInputBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
|
||||
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
|
||||
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
acc.push(...curr.vars.filter(v => v.isRagVariable))
|
||||
return acc
|
||||
}, []), [variables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
|
||||
}, [editor])
|
||||
|
||||
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
|
||||
const varName = textNode.getTextContent().split('.')[1].replace(/#\}\}$/, '')
|
||||
return $applyNodeReplacement($createHITLInputNode(
|
||||
varName,
|
||||
nodeId,
|
||||
formInputs || [],
|
||||
onFormInputsChange!,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove!,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
))
|
||||
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
if (matchArr === null)
|
||||
return null
|
||||
|
||||
const startOffset = matchArr.index
|
||||
const endOffset = startOffset + matchArr[0].length
|
||||
return {
|
||||
end: endOffset,
|
||||
start: startOffset,
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
REGEX.lastIndex = 0
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default memo(HITLInputReplacementBlock)
|
||||
@ -0,0 +1,106 @@
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import type {
|
||||
HITLNodeProps,
|
||||
} from './node'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import {
|
||||
$createHITLInputNode,
|
||||
HITLInputNode,
|
||||
} from './node'
|
||||
|
||||
export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
|
||||
export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
|
||||
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
|
||||
|
||||
export type HITLInputProps = {
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
const HITLInputBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
readonly,
|
||||
}: HITLInputBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
|
||||
})
|
||||
}, [editor, workflowNodesMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_HITL_INPUT_BLOCK_COMMAND,
|
||||
(nodeProps: HITLNodeProps) => {
|
||||
const {
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
} = nodeProps
|
||||
const currentHITLNode = $createHITLInputNode(
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
readonly,
|
||||
)
|
||||
const prev = new CustomTextNode('\n')
|
||||
$insertNodes([prev])
|
||||
$insertNodes([currentHITLNode])
|
||||
const next = new CustomTextNode('\n')
|
||||
$insertNodes([next])
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DELETE_HITL_INPUT_BLOCK_COMMAND,
|
||||
() => {
|
||||
if (onDelete)
|
||||
onDelete()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
HITLInputBlock.displayName = 'HITLInputBlock'
|
||||
|
||||
export { HITLInputBlock }
|
||||
export { default as HITLInputBlockReplacementBlock } from './hitl-input-block-replacement-block'
|
||||
export { HITLInputNode } from './node'
|
||||
@ -0,0 +1,153 @@
|
||||
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import Button from '../../../button'
|
||||
import PrePopulate from './pre-populate'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type InputFieldProps = {
|
||||
nodeId: string
|
||||
isEdit: boolean
|
||||
payload?: FormInputItem
|
||||
onChange: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
const defaultPayload: FormInputItem = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: '',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}
|
||||
const InputField: React.FC<InputFieldProps> = ({
|
||||
nodeId,
|
||||
isEdit,
|
||||
payload,
|
||||
onChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
|
||||
const nameValid = useMemo(() => {
|
||||
const name = tempPayload.output_variable_name.trim()
|
||||
if (!name)
|
||||
return false
|
||||
if (name.includes(' '))
|
||||
return false
|
||||
return /^[a-z_]\w{0,29}$/.test(name)
|
||||
}, [tempPayload.output_variable_name])
|
||||
const handleSave = useCallback(() => {
|
||||
if (!nameValid)
|
||||
return
|
||||
onChange(tempPayload)
|
||||
}, [nameValid, onChange, tempPayload])
|
||||
const defaultValueConfig = tempPayload.default
|
||||
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
|
||||
return (value: ValueSelector | string) => {
|
||||
const nextValue = produce(tempPayload, (draft) => {
|
||||
if (!draft.default)
|
||||
draft.default = { type: 'constant', selector: [], value: '' }
|
||||
if (key === 'selector') {
|
||||
draft.default.type = 'variable'
|
||||
draft.default.selector = value as ValueSelector
|
||||
}
|
||||
else if (key === 'value') {
|
||||
draft.default.type = 'constant'
|
||||
draft.default.value = value as string
|
||||
}
|
||||
else if (key === 'type') {
|
||||
draft.default.type = value as 'constant' | 'variable'
|
||||
}
|
||||
})
|
||||
setTempPayload(nextValue)
|
||||
}
|
||||
}, [tempPayload])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
|
||||
<span className="system-xs-regular relative text-text-destructive-secondary">*</span>
|
||||
</div>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
|
||||
value={tempPayload.output_variable_name}
|
||||
onChange={(e) => {
|
||||
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{tempPayload.output_variable_name && !nameValid && (
|
||||
<div className="system-xs-regular mt-1 px-1 text-text-destructive-secondary">
|
||||
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="system-xs-medium mb-1.5 text-text-secondary">
|
||||
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
|
||||
</div>
|
||||
<PrePopulate
|
||||
isVariable={defaultValueConfig?.type === 'variable'}
|
||||
onIsVariableChange={(isVariable) => {
|
||||
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
|
||||
}}
|
||||
nodeId={nodeId}
|
||||
valueSelector={defaultValueConfig?.selector}
|
||||
onValueSelectorChange={handleDefaultValueChange('selector')}
|
||||
value={defaultValueConfig?.value}
|
||||
onValueChange={handleDefaultValueChange('value')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
{isEdit
|
||||
? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!nameValid}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
className="flex"
|
||||
variant="primary"
|
||||
disabled={!nameValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
|
||||
<span className="system-kbd mr-0.5 flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">{getKeyboardKeyNameBySystem('ctrl')}</span>
|
||||
<span className=" system-kbd flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">↩︎</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputField
|
||||
@ -0,0 +1,272 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { GetVarType } from '../../types'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import HILTInputBlockComponent from './component'
|
||||
|
||||
export type HITLNodeProps = {
|
||||
variableName: string
|
||||
nodeId: string
|
||||
formInputs: FormInputItem[]
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
getVarType?: GetVarType
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export type SerializedNode = SerializedLexicalNode & HITLNodeProps
|
||||
|
||||
export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
|
||||
__variableName: string
|
||||
__nodeId: string
|
||||
__formInputs?: FormInputItem[]
|
||||
__onFormInputsChange: (inputs: FormInputItem[]) => void
|
||||
__onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
__onFormInputItemRemove: (varName: string) => void
|
||||
__workflowNodesMap: WorkflowNodesMap
|
||||
__getVarType?: GetVarType
|
||||
__environmentVariables?: Var[]
|
||||
__conversationVariables?: Var[]
|
||||
__ragVariables?: Var[]
|
||||
__readonly?: boolean
|
||||
|
||||
isIsolated(): boolean {
|
||||
return true // This is necessary for drag-and-drop to work correctly
|
||||
}
|
||||
|
||||
isTopLevel(): boolean {
|
||||
return true // This is necessary for drag-and-drop to work correctly
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'hitl-input-block'
|
||||
}
|
||||
|
||||
getVariableName(): string {
|
||||
const self = this.getLatest()
|
||||
return self.__variableName
|
||||
}
|
||||
|
||||
getNodeId(): string {
|
||||
const self = this.getLatest()
|
||||
return self.__nodeId
|
||||
}
|
||||
|
||||
getFormInputs(): FormInputItem[] {
|
||||
const self = this.getLatest()
|
||||
return self.__formInputs || []
|
||||
}
|
||||
|
||||
getOnFormInputsChange(): (inputs: FormInputItem[]) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputsChange
|
||||
}
|
||||
|
||||
getOnFormInputItemRename(): (payload: FormInputItem, oldName: string) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputItemRename
|
||||
}
|
||||
|
||||
getOnFormInputItemRemove(): (varName: string) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputItemRemove
|
||||
}
|
||||
|
||||
getWorkflowNodesMap(): WorkflowNodesMap {
|
||||
const self = this.getLatest()
|
||||
return self.__workflowNodesMap
|
||||
}
|
||||
|
||||
getGetVarType(): GetVarType | undefined {
|
||||
const self = this.getLatest()
|
||||
return self.__getVarType
|
||||
}
|
||||
|
||||
getEnvironmentVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__environmentVariables || []
|
||||
}
|
||||
|
||||
getConversationVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__conversationVariables || []
|
||||
}
|
||||
|
||||
getRagVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__ragVariables || []
|
||||
}
|
||||
|
||||
getReadonly(): boolean {
|
||||
const self = this.getLatest()
|
||||
return self.__readonly || false
|
||||
}
|
||||
|
||||
static clone(node: HITLInputNode): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
node.__variableName,
|
||||
node.__nodeId,
|
||||
node.__formInputs || [],
|
||||
node.__onFormInputsChange,
|
||||
node.__onFormInputItemRename,
|
||||
node.__onFormInputItemRemove,
|
||||
node.__workflowNodesMap,
|
||||
node.__getVarType,
|
||||
node.__environmentVariables,
|
||||
node.__conversationVariables,
|
||||
node.__ragVariables,
|
||||
node.__readonly,
|
||||
node.__key,
|
||||
)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor(
|
||||
varName: string,
|
||||
nodeId: string,
|
||||
formInputs: FormInputItem[],
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void,
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
|
||||
onFormInputItemRemove: (varName: string) => void,
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType?: GetVarType,
|
||||
environmentVariables?: Var[],
|
||||
conversationVariables?: Var[],
|
||||
ragVariables?: Var[],
|
||||
readonly?: boolean,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key)
|
||||
|
||||
this.__variableName = varName
|
||||
this.__nodeId = nodeId
|
||||
this.__formInputs = formInputs
|
||||
this.__onFormInputsChange = onFormInputsChange
|
||||
this.__onFormInputItemRename = onFormInputItemRename
|
||||
this.__onFormInputItemRemove = onFormInputItemRemove
|
||||
this.__workflowNodesMap = workflowNodesMap
|
||||
this.__getVarType = getVarType
|
||||
this.__environmentVariables = environmentVariables
|
||||
this.__conversationVariables = conversationVariables
|
||||
this.__ragVariables = ragVariables
|
||||
this.__readonly = readonly
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag')
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
decorate(): React.JSX.Element {
|
||||
return (
|
||||
<HILTInputBlockComponent
|
||||
nodeKey={this.getKey()}
|
||||
varName={this.getVariableName()}
|
||||
nodeId={this.getNodeId()}
|
||||
formInputs={this.getFormInputs()}
|
||||
onChange={this.getOnFormInputsChange()}
|
||||
onRename={this.getOnFormInputItemRename()}
|
||||
onRemove={this.getOnFormInputItemRemove()}
|
||||
workflowNodesMap={this.getWorkflowNodesMap()}
|
||||
getVarType={this.getGetVarType()}
|
||||
environmentVariables={this.getEnvironmentVariables()}
|
||||
conversationVariables={this.getConversationVariables()}
|
||||
ragVariables={this.getRagVariables()}
|
||||
readonly={this.getReadonly()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedNode): HITLInputNode {
|
||||
const node = $createHITLInputNode(
|
||||
serializedNode.variableName,
|
||||
serializedNode.nodeId,
|
||||
serializedNode.formInputs,
|
||||
serializedNode.onFormInputsChange,
|
||||
serializedNode.onFormInputItemRename,
|
||||
serializedNode.onFormInputItemRemove,
|
||||
serializedNode.workflowNodesMap,
|
||||
serializedNode.getVarType,
|
||||
serializedNode.environmentVariables,
|
||||
serializedNode.conversationVariables,
|
||||
serializedNode.ragVariables,
|
||||
serializedNode.readonly,
|
||||
)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedNode {
|
||||
return {
|
||||
type: 'hitl-input-block',
|
||||
version: 1,
|
||||
variableName: this.getVariableName(),
|
||||
nodeId: this.getNodeId(),
|
||||
formInputs: this.getFormInputs(),
|
||||
onFormInputsChange: this.getOnFormInputsChange(),
|
||||
onFormInputItemRename: this.getOnFormInputItemRename(),
|
||||
onFormInputItemRemove: this.getOnFormInputItemRemove(),
|
||||
workflowNodesMap: this.getWorkflowNodesMap(),
|
||||
getVarType: this.getGetVarType(),
|
||||
environmentVariables: this.getEnvironmentVariables(),
|
||||
conversationVariables: this.getConversationVariables(),
|
||||
ragVariables: this.getRagVariables(),
|
||||
readonly: this.getReadonly(),
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `{{#$output.${this.getVariableName()}#}}`
|
||||
}
|
||||
}
|
||||
|
||||
export function $createHITLInputNode(
|
||||
variableName: string,
|
||||
nodeId: string,
|
||||
formInputs: FormInputItem[],
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void,
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
|
||||
onFormInputItemRemove: (varName: string) => void,
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType?: GetVarType,
|
||||
environmentVariables?: Var[],
|
||||
conversationVariables?: Var[],
|
||||
ragVariables?: Var[],
|
||||
readonly?: boolean,
|
||||
): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
)
|
||||
}
|
||||
|
||||
export function $isHITLInputNode(
|
||||
node: HITLInputNode | LexicalNode | null | undefined,
|
||||
): node is HITLInputNode {
|
||||
return node instanceof HITLInputNode
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Textarea from '../../../textarea'
|
||||
import TagLabel from './tag-label'
|
||||
import TypeSwitch from './type-switch'
|
||||
|
||||
type Props = {
|
||||
isVariable?: boolean
|
||||
onIsVariableChange?: (isVariable: boolean) => void
|
||||
nodeId: string
|
||||
valueSelector?: ValueSelector
|
||||
onValueSelectorChange?: (valueSelector: ValueSelector | string) => void
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type PlaceholderProps = {
|
||||
varPickerProps: {
|
||||
nodeId: string
|
||||
value: ValueSelector
|
||||
onChange: (valueSelector: ValueSelector | string) => void
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
filterVar: (varPayload: Var) => boolean
|
||||
isJustShowValue?: boolean
|
||||
}
|
||||
onTypeClick: (isVariable: boolean) => void
|
||||
}
|
||||
const Placeholder = ({
|
||||
varPickerProps,
|
||||
onTypeClick,
|
||||
}: PlaceholderProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="system-sm-regular mt-1 h-[80px] rounded-lg bg-components-input-bg-normal px-3 pt-2 text-text-tertiary">
|
||||
<div className="flex flex-wrap items-center leading-5">
|
||||
<Trans
|
||||
i18nKey={`${i18nPrefix}.prePopulateFieldPlaceholder`}
|
||||
ns="workflow"
|
||||
components={{
|
||||
staticContent: <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}>{t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}</TagLabel>,
|
||||
variable: (
|
||||
<VarReferencePicker
|
||||
{...varPickerProps}
|
||||
trigger={
|
||||
<TagLabel type="variable" className="mx-1">{t(`${i18nPrefix}.variable`, { ns: 'workflow' })}</TagLabel>
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PrePopulate: FC<Props> = ({
|
||||
isVariable = false,
|
||||
onIsVariableChange,
|
||||
nodeId,
|
||||
valueSelector,
|
||||
onValueSelectorChange,
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
|
||||
const handleTypeChange = useCallback((isVar: boolean) => {
|
||||
setOnPlaceholderClicked(true)
|
||||
onIsVariableChange?.(isVar)
|
||||
}, [onIsVariableChange])
|
||||
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
|
||||
const varPickerProps = {
|
||||
nodeId,
|
||||
value: valueSelector || [],
|
||||
onChange: onValueSelectorChange!,
|
||||
readonly: false,
|
||||
zIndex: 1000000, // bigger than shortcut plugin popup
|
||||
filterVar: (varPayload: Var) => {
|
||||
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
|
||||
},
|
||||
}
|
||||
|
||||
const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab' && !onPlaceholderClicked) {
|
||||
e.preventDefault()
|
||||
setOnPlaceholderClicked(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onPlaceholderClicked, setOnPlaceholderClicked])
|
||||
|
||||
if (isShowPlaceholder)
|
||||
return <Placeholder varPickerProps={varPickerProps} onTypeClick={handleTypeChange} />
|
||||
|
||||
if (isVariable) {
|
||||
return (
|
||||
<div className="relative h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2">
|
||||
<VarReferencePicker
|
||||
{...varPickerProps}
|
||||
isJustShowValue
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable={isVariable}
|
||||
onIsVariableChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
|
||||
onChange={e => onValueChange?.(e.target.value)}
|
||||
onFocus={() => {
|
||||
setOnPlaceholderClicked(true)
|
||||
setIsFocus(true)
|
||||
}}
|
||||
onBlur={() => setIsFocus(false)}
|
||||
autoFocus
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable={isVariable}
|
||||
onIsVariableChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(PrePopulate)
|
||||
@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Variable02 } from '../../../icons/src/vender/solid/development'
|
||||
|
||||
type Props = {
|
||||
type: 'edit' | 'variable'
|
||||
children: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const TagLabel: FC<Props> = ({
|
||||
type,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const Icon = type === 'edit' ? RiEditLine : Variable02
|
||||
return (
|
||||
<div
|
||||
className={cn('inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<div className="system-xs-medium ">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TagLabel)
|
||||
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Variable02 } from '../../../icons/src/vender/solid/development'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
isVariable?: boolean
|
||||
onIsVariableChange?: (isVariable: boolean) => void
|
||||
}
|
||||
|
||||
const TypeSwitch: FC<Props> = ({
|
||||
className,
|
||||
isVariable,
|
||||
onIsVariableChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('inline-flex h-6 cursor-pointer select-none items-center space-x-1 rounded-md pl-1.5 pr-2 text-text-tertiary hover:bg-components-button-ghost-bg-hover', className)} onClick={() => onIsVariableChange?.(!isVariable)}>
|
||||
<Variable02 className="size-3.5" />
|
||||
<div className="system-xs-medium">{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TypeSwitch)
|
||||
@ -0,0 +1,148 @@
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
isConversationVar,
|
||||
isENV,
|
||||
isGlobalVar,
|
||||
isRagVariableVar,
|
||||
isSystemVar,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
|
||||
import {
|
||||
VariableLabelInEditor,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { isExceptionVariable } from '@/app/components/workflow/utils'
|
||||
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
|
||||
import { HITLInputNode } from './node'
|
||||
|
||||
type HITLInputVariableBlockComponentProps = {
|
||||
variables: string[]
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
}
|
||||
|
||||
const HITLInputVariableBlockComponent = ({
|
||||
variables,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
}: HITLInputVariableBlockComponentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const variablesLength = variables.length
|
||||
const isRagVar = isRagVariableVar(variables)
|
||||
const isShowAPart = variablesLength > 2 && !isRagVar
|
||||
const varName = (
|
||||
() => {
|
||||
const isSystem = isSystemVar(variables)
|
||||
const varName = variables[variablesLength - 1]
|
||||
return `${isSystem ? 'sys.' : ''}${varName}`
|
||||
}
|
||||
)()
|
||||
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
|
||||
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
|
||||
|
||||
const isException = isExceptionVariable(varName, node?.type)
|
||||
const variableValid = useMemo(() => {
|
||||
let variableValid = true
|
||||
const isEnv = isENV(variables)
|
||||
const isChatVar = isConversationVar(variables)
|
||||
const isGlobal = isGlobalVar(variables)
|
||||
if (isGlobal)
|
||||
return true
|
||||
|
||||
if (isEnv) {
|
||||
if (environmentVariables)
|
||||
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isChatVar) {
|
||||
if (conversationVariables)
|
||||
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isRagVar) {
|
||||
if (ragVariables)
|
||||
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
|
||||
}
|
||||
else {
|
||||
variableValid = !!node
|
||||
}
|
||||
return variableValid
|
||||
}, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
(workflowNodesMap: WorkflowNodesMap) => {
|
||||
setLocalWorkflowNodesMap(workflowNodesMap)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
const Item = (
|
||||
<VariableLabelInEditor
|
||||
nodeType={node?.type}
|
||||
nodeTitle={node?.title}
|
||||
variables={variables}
|
||||
isExceptionVariable={isException}
|
||||
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
|
||||
notShowFullPath={isShowAPart}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!node)
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
varType={getVarType
|
||||
? getVarType({
|
||||
nodeId: variables[0],
|
||||
valueSelector: variables,
|
||||
})
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
)}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
<div>{Item}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HITLInputVariableBlockComponent)
|
||||
@ -0,0 +1,33 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
|
||||
|
||||
type RequestURLBlockComponentProps = {
|
||||
nodeKey: string
|
||||
}
|
||||
|
||||
const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({
|
||||
nodeKey,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border border-components-panel-border-subtle bg-components-badge-white-to-dark px-1 hover:border-[#7839ee]',
|
||||
isSelected && '!border-[#7839ee] hover:!border-[#7839ee]',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<RiGlobalLine className="mr-0.5 h-3.5 w-3.5 text-util-colors-violet-violet-600" />
|
||||
<div className="system-xs-medium text-util-colors-violet-violet-600">{t('promptEditor.requestURL.item.title', { ns: 'common' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RequestURLBlockComponent
|
||||
@ -0,0 +1,64 @@
|
||||
import type { RequestURLBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from './node'
|
||||
|
||||
export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND')
|
||||
export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND')
|
||||
|
||||
const RequestURLBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
}: RequestURLBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([RequestURLBlockNode]))
|
||||
throw new Error('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_REQUEST_URL_BLOCK_COMMAND,
|
||||
() => {
|
||||
const contextBlockNode = $createRequestURLBlockNode()
|
||||
|
||||
$insertNodes([contextBlockNode])
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DELETE_REQUEST_URL_BLOCK_COMMAND,
|
||||
() => {
|
||||
if (onDelete)
|
||||
onDelete()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
|
||||
return null
|
||||
})
|
||||
RequestURLBlock.displayName = 'RequestURLBlock'
|
||||
|
||||
export { RequestURLBlock }
|
||||
export { RequestURLBlockNode } from './node'
|
||||
export { default as RequestURLBlockReplacementBlock } from './request-url-block-replacement-block'
|
||||
@ -0,0 +1,59 @@
|
||||
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import RequestURLBlockComponent from './component'
|
||||
|
||||
export type SerializedNode = SerializedLexicalNode
|
||||
|
||||
export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
static getType(): string {
|
||||
return 'request-url-block'
|
||||
}
|
||||
|
||||
static clone(node: RequestURLBlockNode): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode(node.__key)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'items-center', 'align-middle')
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
decorate(): React.JSX.Element {
|
||||
return <RequestURLBlockComponent nodeKey={this.getKey()} />
|
||||
}
|
||||
|
||||
static importJSON(): RequestURLBlockNode {
|
||||
const node = $createRequestURLBlockNode()
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedNode {
|
||||
return {
|
||||
type: 'request-url-block',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return '{{#url#}}'
|
||||
}
|
||||
}
|
||||
export function $createRequestURLBlockNode(): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode()
|
||||
}
|
||||
|
||||
export function $isRequestURLBlockNode(
|
||||
node: RequestURLBlockNode | LexicalNode | null | undefined,
|
||||
): node is RequestURLBlockNode {
|
||||
return node instanceof RequestURLBlockNode
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import type { RequestURLBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from '../request-url-block/node'
|
||||
|
||||
const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
|
||||
const RequestURLBlockReplacementBlock = ({
|
||||
onInsert,
|
||||
}: RequestURLBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([RequestURLBlockNode]))
|
||||
throw new Error('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
|
||||
}, [editor])
|
||||
|
||||
const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => {
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
return $applyNodeReplacement($createRequestURLBlockNode())
|
||||
}, [onInsert])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
if (matchArr === null)
|
||||
return null
|
||||
|
||||
const startOffset = matchArr.index
|
||||
const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length
|
||||
return {
|
||||
end: endOffset,
|
||||
start: startOffset,
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
REGEX.lastIndex = 0
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default memo(RequestURLBlockReplacementBlock)
|
||||
@ -0,0 +1,134 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Range.getClientRects and getBoundingClientRect for JSDOM
|
||||
const mockDOMRect = {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 100,
|
||||
right: 200,
|
||||
bottom: 120,
|
||||
left: 100,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock getClientRects on Range prototype
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
const rectList = [mockDOMRect] as unknown as DOMRectList
|
||||
Object.defineProperty(rectList, 'length', { value: 1 })
|
||||
Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
|
||||
return rectList
|
||||
})
|
||||
|
||||
// Mock getBoundingClientRect on Range prototype
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
})
|
||||
|
||||
const CONTAINER_ID = 'host'
|
||||
const CONTENT_EDITABLE_ID = 'ce'
|
||||
|
||||
const MinimalEditor: React.FC<{
|
||||
withContainer?: boolean
|
||||
}> = ({ withContainer = true }) => {
|
||||
const initialConfig = {
|
||||
namespace: 'shortcuts-popup-plugin-test',
|
||||
onError: (e: Error) => {
|
||||
throw e
|
||||
},
|
||||
}
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ShortcutsPopupPlugin
|
||||
container={withContainer ? containerEl : undefined}
|
||||
/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ShortcutsPopupPlugin', () => {
|
||||
it('opens on hotkey when editor is focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not open when editor is not focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
// 未聚焦
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes on click outside', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(ce)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('portals into provided container when container is set', async () => {
|
||||
render(<MinimalEditor withContainer />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
const host = screen.getByTestId(CONTAINER_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(host).toContainElement(portalContent)
|
||||
})
|
||||
|
||||
it('falls back to document.body when container is not provided', async () => {
|
||||
render(<MinimalEditor withContainer={false} />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,305 @@
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from 'lexical'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
|
||||
|
||||
// Hotkey can be:
|
||||
// - string: 'mod+/'
|
||||
// - string[]: ['mod', '/']
|
||||
// - string[][]: [['mod', '/'], ['mod', 'shift', '/']] (any combo matches)
|
||||
// - function: custom matcher
|
||||
export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
|
||||
|
||||
type ShortcutPopupPluginProps = {
|
||||
hotkey?: Hotkey
|
||||
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode)
|
||||
className?: string
|
||||
container?: Element | null
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
|
||||
const CTRL_ALIASES = new Set(['ctrl'])
|
||||
const ALT_ALIASES = new Set(['alt', 'option'])
|
||||
const SHIFT_ALIASES = new Set(['shift'])
|
||||
|
||||
function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) {
|
||||
if (!hotkey)
|
||||
return false
|
||||
|
||||
if (typeof hotkey === 'function')
|
||||
return hotkey(event)
|
||||
|
||||
const matchCombo = (tokens: string[]) => {
|
||||
const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean)
|
||||
let expectedKey: string | null = null
|
||||
|
||||
let needMod = false
|
||||
let needCtrl = false
|
||||
let needMeta = false
|
||||
let needAlt = false
|
||||
let needShift = false
|
||||
|
||||
for (const p of parts) {
|
||||
if (p === 'mod') {
|
||||
needMod = true
|
||||
continue
|
||||
}
|
||||
if (CTRL_ALIASES.has(p)) {
|
||||
needCtrl = true
|
||||
continue
|
||||
}
|
||||
if (META_ALIASES.has(p)) {
|
||||
needMeta = true
|
||||
continue
|
||||
}
|
||||
if (ALT_ALIASES.has(p)) {
|
||||
needAlt = true
|
||||
continue
|
||||
}
|
||||
if (SHIFT_ALIASES.has(p)) {
|
||||
needShift = true
|
||||
continue
|
||||
}
|
||||
expectedKey = p
|
||||
}
|
||||
|
||||
if (needMod && !(event.metaKey || event.ctrlKey))
|
||||
return false
|
||||
if (needCtrl && !event.ctrlKey)
|
||||
return false
|
||||
if (needMeta && !event.metaKey)
|
||||
return false
|
||||
if (needAlt && !event.altKey)
|
||||
return false
|
||||
if (needShift && !event.shiftKey)
|
||||
return false
|
||||
|
||||
if (expectedKey) {
|
||||
const k = event.key.toLowerCase()
|
||||
const normalized = k === ' ' ? 'space' : k
|
||||
if (normalized !== expectedKey)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(hotkey)) {
|
||||
const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0])
|
||||
if (isNested) {
|
||||
const combos = hotkey as string[][]
|
||||
return combos.some(tokens => matchCombo(tokens))
|
||||
}
|
||||
else {
|
||||
const tokens = hotkey as string[]
|
||||
return matchCombo(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
const tokensFromString = hotkey
|
||||
.toLowerCase()
|
||||
.split('+')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
return matchCombo(tokensFromString)
|
||||
}
|
||||
|
||||
export default function ShortcutsPopupPlugin({
|
||||
hotkey = 'mod+/',
|
||||
children,
|
||||
className,
|
||||
container,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: ShortcutPopupPluginProps): React.ReactPortal | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const portalRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastSelectionRef = useRef<Range | null>(null)
|
||||
|
||||
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
|
||||
const useContainer = !!containerEl && containerEl !== document.body
|
||||
|
||||
const { refs, floatingStyles, isPositioned } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(0), // fix hide cursor
|
||||
shift({
|
||||
padding: 8,
|
||||
altBoundary: true,
|
||||
}),
|
||||
flip(),
|
||||
size({
|
||||
apply({ availableWidth, availableHeight, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${Math.min(400, availableWidth)}px`,
|
||||
maxHeight: `${Math.min(300, availableHeight)}px`,
|
||||
overflow: 'auto',
|
||||
})
|
||||
},
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const domSelection = window.getSelection()
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
const isEditorFocused = useCallback(() => {
|
||||
const root = editor.getRootElement()
|
||||
if (!root)
|
||||
return false
|
||||
return root.contains(document.activeElement)
|
||||
}, [editor])
|
||||
|
||||
const openPortal = useCallback(() => {
|
||||
const domSelection = window.getSelection()
|
||||
let range: Range | null = null
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
range = domSelection.getRangeAt(0).cloneRange()
|
||||
else
|
||||
range = lastSelectionRef.current
|
||||
|
||||
if (range) {
|
||||
const rects = range.getClientRects()
|
||||
let rect: DOMRect | null = null
|
||||
|
||||
if (rects && rects.length)
|
||||
rect = rects[rects.length - 1]
|
||||
|
||||
else
|
||||
rect = range.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
const root = editor.getRootElement()
|
||||
if (root) {
|
||||
const sc = range.startContainer
|
||||
const node = sc.nodeType === Node.ELEMENT_NODE
|
||||
? sc as Element
|
||||
: (sc.parentElement || root)
|
||||
|
||||
rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0)
|
||||
rect = root.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
|
||||
const virtualEl = {
|
||||
getBoundingClientRect() {
|
||||
return rect!
|
||||
},
|
||||
}
|
||||
refs.setReference(virtualEl as Element)
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
}, [onOpen])
|
||||
|
||||
const closePortal = useCallback(() => {
|
||||
setOpen(false)
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (open && event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
closePortal()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditorFocused())
|
||||
return
|
||||
|
||||
if (matchHotkey(event, hotkey)) {
|
||||
event.preventDefault()
|
||||
openPortal()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [hotkey, open, isEditorFocused, openPortal, closePortal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!portalRef.current)
|
||||
return
|
||||
if (!portalRef.current.contains(e.target as Node))
|
||||
closePortal()
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown, false)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown, false)
|
||||
}, [open, closePortal])
|
||||
|
||||
const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => {
|
||||
editor.dispatchCommand(command, params)
|
||||
closePortal()
|
||||
}, [editor, closePortal])
|
||||
|
||||
if (!open || !containerEl)
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
portalRef.current = node
|
||||
refs.setFloating(node)
|
||||
}}
|
||||
className={cn(
|
||||
useContainer ? '' : 'z-[999999]',
|
||||
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
}
|
||||
@ -40,7 +40,7 @@ const WorkflowVariableBlockReplacementBlock = ({
|
||||
|
||||
const nodePathString = textNode.getTextContent().slice(3, -3)
|
||||
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables])
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { GeneratorType } from '../../app/configuration/config/automatic/types'
|
||||
import type { FormInputItem } from '../../workflow/nodes/human-input/types'
|
||||
import type { Type } from '../../workflow/nodes/llm/types'
|
||||
import type { Dataset } from './plugins/context-block'
|
||||
import type { RoleName } from './plugins/history-block'
|
||||
@ -46,6 +47,13 @@ export type HistoryBlockType = {
|
||||
onEditRole?: () => void
|
||||
}
|
||||
|
||||
export type RequestURLBlockType = {
|
||||
show?: boolean
|
||||
selectable?: boolean
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export type VariableBlockType = {
|
||||
show?: boolean
|
||||
variables?: Option[]
|
||||
@ -73,6 +81,21 @@ export type WorkflowVariableBlockType = {
|
||||
onManageInputField?: () => void
|
||||
}
|
||||
|
||||
export type HITLInputBlockType = {
|
||||
show?: boolean
|
||||
nodeId: string
|
||||
formInputs?: FormInputItem[]
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
getVarType?: GetVarType
|
||||
onFormInputsChange?: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export type MenuTextMatch = {
|
||||
leadOffset: number
|
||||
matchingString: string
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
@ -188,7 +188,9 @@ describe('PlanComp', () => {
|
||||
expect(lastCall.onCancel).toBeDefined()
|
||||
|
||||
// Call onConfirm to close modal
|
||||
lastCall.onConfirm()
|
||||
lastCall.onCancel()
|
||||
act(() => {
|
||||
lastCall.onConfirm()
|
||||
lastCall.onCancel()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -118,6 +118,7 @@ export type CurrentPlanInfoBackend = {
|
||||
knowledge_pipeline: {
|
||||
publish_enabled: boolean
|
||||
}
|
||||
human_input_email_delivery_enabled: boolean
|
||||
}
|
||||
|
||||
export type SubscriptionItem = {
|
||||
|
||||
@ -94,6 +94,7 @@ describe('billing utils', () => {
|
||||
knowledge_pipeline: {
|
||||
publish_enabled: false,
|
||||
},
|
||||
human_input_email_delivery_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@ -1,262 +0,0 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import DocumentSourceIcon from './document-source-icon'
|
||||
|
||||
const createMockDoc = (overrides: Record<string, unknown> = {}): SimpleDocumentDetail => ({
|
||||
id: 'doc-1',
|
||||
position: 1,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
data_source_info: {},
|
||||
data_source_detail_dict: {},
|
||||
dataset_process_rule_id: 'rule-1',
|
||||
dataset_id: 'dataset-1',
|
||||
batch: 'batch-1',
|
||||
name: 'test-document.txt',
|
||||
created_from: 'web',
|
||||
created_by: 'user-1',
|
||||
created_at: Date.now(),
|
||||
tokens: 100,
|
||||
indexing_status: 'completed',
|
||||
error: null,
|
||||
enabled: true,
|
||||
disabled_at: null,
|
||||
disabled_by: null,
|
||||
archived: false,
|
||||
archived_reason: null,
|
||||
archived_by: null,
|
||||
archived_at: null,
|
||||
updated_at: Date.now(),
|
||||
doc_type: null,
|
||||
doc_metadata: undefined,
|
||||
doc_language: 'en',
|
||||
display_status: 'available',
|
||||
word_count: 100,
|
||||
hit_count: 10,
|
||||
doc_form: 'text_model',
|
||||
...overrides,
|
||||
}) as unknown as SimpleDocumentDetail
|
||||
|
||||
describe('DocumentSourceIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const doc = createMockDoc()
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local File Icon', () => {
|
||||
it('should render FileTypeIcon for FILE data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.FILE,
|
||||
data_source_info: {
|
||||
upload_file: { extension: 'pdf' },
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} fileType="pdf" />)
|
||||
const icon = container.querySelector('svg, img')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileTypeIcon for localFile data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.localFile,
|
||||
created_from: 'rag-pipeline',
|
||||
data_source_info: {
|
||||
extension: 'docx',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
const icon = container.querySelector('svg, img')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use extension from upload_file for legacy data source', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.FILE,
|
||||
created_from: 'web',
|
||||
data_source_info: {
|
||||
upload_file: { extension: 'txt' },
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use fileType prop as fallback for extension', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.FILE,
|
||||
created_from: 'web',
|
||||
data_source_info: {},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} fileType="csv" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Notion Icon', () => {
|
||||
it('should render NotionIcon for NOTION data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.NOTION,
|
||||
created_from: 'web',
|
||||
data_source_info: {
|
||||
notion_page_icon: 'https://notion.so/icon.png',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render NotionIcon for onlineDocument data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.onlineDocument,
|
||||
created_from: 'rag-pipeline',
|
||||
data_source_info: {
|
||||
page: { page_icon: 'https://notion.so/icon.png' },
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use page_icon for rag-pipeline created documents', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.NOTION,
|
||||
created_from: 'rag-pipeline',
|
||||
data_source_info: {
|
||||
page: { page_icon: 'https://notion.so/custom-icon.png' },
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Web Crawl Icon', () => {
|
||||
it('should render globe icon for WEB data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.WEB,
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveClass('mr-1.5')
|
||||
expect(icon).toHaveClass('size-4')
|
||||
})
|
||||
|
||||
it('should render globe icon for websiteCrawl data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.websiteCrawl,
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Drive Icon', () => {
|
||||
it('should render FileTypeIcon for onlineDrive data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.onlineDrive,
|
||||
data_source_info: {
|
||||
name: 'document.xlsx',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should extract extension from file name', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.onlineDrive,
|
||||
data_source_info: {
|
||||
name: 'spreadsheet.xlsx',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle file name without extension', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.onlineDrive,
|
||||
data_source_info: {
|
||||
name: 'noextension',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty file name', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.onlineDrive,
|
||||
data_source_info: {
|
||||
name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle hidden files (starting with dot)', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DatasourceType.onlineDrive,
|
||||
data_source_info: {
|
||||
name: '.gitignore',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unknown Data Source Type', () => {
|
||||
it('should return null for unknown data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: 'unknown',
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined data_source_info', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.FILE,
|
||||
data_source_info: undefined,
|
||||
})
|
||||
|
||||
const { container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should memoize the component', () => {
|
||||
const doc = createMockDoc()
|
||||
const { rerender, container } = render(<DocumentSourceIcon doc={doc} />)
|
||||
|
||||
const firstRender = container.innerHTML
|
||||
rerender(<DocumentSourceIcon doc={doc} />)
|
||||
expect(container.innerHTML).toBe(firstRender)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,100 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
|
||||
type DocumentSourceIconProps = {
|
||||
doc: SimpleDocumentDetail
|
||||
fileType?: string
|
||||
}
|
||||
|
||||
const isLocalFile = (dataSourceType: DataSourceType | DatasourceType) => {
|
||||
return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE
|
||||
}
|
||||
|
||||
const isOnlineDocument = (dataSourceType: DataSourceType | DatasourceType) => {
|
||||
return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION
|
||||
}
|
||||
|
||||
const isWebsiteCrawl = (dataSourceType: DataSourceType | DatasourceType) => {
|
||||
return dataSourceType === DatasourceType.websiteCrawl || dataSourceType === DataSourceType.WEB
|
||||
}
|
||||
|
||||
const isOnlineDrive = (dataSourceType: DataSourceType | DatasourceType) => {
|
||||
return dataSourceType === DatasourceType.onlineDrive
|
||||
}
|
||||
|
||||
const isCreateFromRAGPipeline = (createdFrom: string) => {
|
||||
return createdFrom === 'rag-pipeline'
|
||||
}
|
||||
|
||||
const getFileExtension = (fileName: string): string => {
|
||||
if (!fileName)
|
||||
return ''
|
||||
const parts = fileName.split('.')
|
||||
if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
|
||||
return ''
|
||||
return parts[parts.length - 1].toLowerCase()
|
||||
}
|
||||
|
||||
const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({
|
||||
doc,
|
||||
fileType,
|
||||
}) => {
|
||||
if (isOnlineDocument(doc.data_source_type)) {
|
||||
return (
|
||||
<NotionIcon
|
||||
className="mr-1.5"
|
||||
type="page"
|
||||
src={
|
||||
isCreateFromRAGPipeline(doc.created_from)
|
||||
? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
|
||||
: (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLocalFile(doc.data_source_type)) {
|
||||
return (
|
||||
<FileTypeIcon
|
||||
type={
|
||||
extensionToFileType(
|
||||
isCreateFromRAGPipeline(doc.created_from)
|
||||
? (doc?.data_source_info as LocalFileInfo)?.extension
|
||||
: ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
|
||||
)
|
||||
}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOnlineDrive(doc.data_source_type)) {
|
||||
return (
|
||||
<FileTypeIcon
|
||||
type={
|
||||
extensionToFileType(
|
||||
getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
|
||||
)
|
||||
}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isWebsiteCrawl(doc.data_source_type)) {
|
||||
return <RiGlobalLine className="mr-1.5 size-4" />
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
DocumentSourceIcon.displayName = 'DocumentSourceIcon'
|
||||
|
||||
export default DocumentSourceIcon
|
||||
@ -1,342 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import DocumentTableRow from './document-table-row'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<table>
|
||||
<tbody>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const createMockDoc = (overrides: Record<string, unknown> = {}): LocalDoc => ({
|
||||
id: 'doc-1',
|
||||
position: 1,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
data_source_info: {},
|
||||
data_source_detail_dict: {
|
||||
upload_file: { name: 'test.txt', extension: 'txt' },
|
||||
},
|
||||
dataset_process_rule_id: 'rule-1',
|
||||
dataset_id: 'dataset-1',
|
||||
batch: 'batch-1',
|
||||
name: 'test-document.txt',
|
||||
created_from: 'web',
|
||||
created_by: 'user-1',
|
||||
created_at: Date.now(),
|
||||
tokens: 100,
|
||||
indexing_status: 'completed',
|
||||
error: null,
|
||||
enabled: true,
|
||||
disabled_at: null,
|
||||
disabled_by: null,
|
||||
archived: false,
|
||||
archived_reason: null,
|
||||
archived_by: null,
|
||||
archived_at: null,
|
||||
updated_at: Date.now(),
|
||||
doc_type: null,
|
||||
doc_metadata: undefined,
|
||||
doc_language: 'en',
|
||||
display_status: 'available',
|
||||
word_count: 500,
|
||||
hit_count: 10,
|
||||
doc_form: 'text_model',
|
||||
...overrides,
|
||||
}) as unknown as LocalDoc
|
||||
|
||||
// Helper to find the custom checkbox div (Checkbox component renders as a div, not a native checkbox)
|
||||
const findCheckbox = (container: HTMLElement): HTMLElement | null => {
|
||||
return container.querySelector('[class*="shadow-xs"]')
|
||||
}
|
||||
|
||||
describe('DocumentTableRow', () => {
|
||||
const defaultProps = {
|
||||
doc: createMockDoc(),
|
||||
index: 0,
|
||||
datasetId: 'dataset-1',
|
||||
isSelected: false,
|
||||
isGeneralMode: true,
|
||||
isQAMode: false,
|
||||
embeddingAvailable: true,
|
||||
selectedIds: [],
|
||||
onSelectOne: vi.fn(),
|
||||
onSelectedIdChange: vi.fn(),
|
||||
onShowRenameModal: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render index number correctly', () => {
|
||||
render(<DocumentTableRow {...defaultProps} index={5} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document name with tooltip', () => {
|
||||
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render checkbox element', () => {
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
|
||||
const checkbox = findCheckbox(container)
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection', () => {
|
||||
it('should show check icon when isSelected is true', () => {
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} isSelected />, { wrapper: createWrapper() })
|
||||
// When selected, the checkbox should have a check icon (RiCheckLine svg)
|
||||
const checkbox = findCheckbox(container)
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
const checkIcon = checkbox?.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show check icon when isSelected is false', () => {
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} isSelected={false} />, { wrapper: createWrapper() })
|
||||
const checkbox = findCheckbox(container)
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
// When not selected, there should be no check icon inside the checkbox
|
||||
const checkIcon = checkbox?.querySelector('svg')
|
||||
expect(checkIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelectOne when checkbox is clicked', () => {
|
||||
const onSelectOne = vi.fn()
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} onSelectOne={onSelectOne} />, { wrapper: createWrapper() })
|
||||
|
||||
const checkbox = findCheckbox(container)
|
||||
if (checkbox) {
|
||||
fireEvent.click(checkbox)
|
||||
expect(onSelectOne).toHaveBeenCalledWith('doc-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('should stop propagation when checkbox container is clicked', () => {
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Click the div containing the checkbox (which has stopPropagation)
|
||||
const checkboxContainer = container.querySelector('td')?.querySelector('div')
|
||||
if (checkboxContainer) {
|
||||
fireEvent.click(checkboxContainer)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Row Navigation', () => {
|
||||
it('should navigate to document detail on row click', () => {
|
||||
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const row = screen.getByRole('row')
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1')
|
||||
})
|
||||
|
||||
it('should navigate with correct datasetId and documentId', () => {
|
||||
render(
|
||||
<DocumentTableRow
|
||||
{...defaultProps}
|
||||
datasetId="custom-dataset"
|
||||
doc={createMockDoc({ id: 'custom-doc' })}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const row = screen.getByRole('row')
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/custom-dataset/documents/custom-doc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Word Count Display', () => {
|
||||
it('should display word count less than 1000 as is', () => {
|
||||
const doc = createMockDoc({ word_count: 500 })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display word count 1000 or more in k format', () => {
|
||||
const doc = createMockDoc({ word_count: 1500 })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('1.5k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display 0 with empty style when word_count is 0', () => {
|
||||
const doc = createMockDoc({ word_count: 0 })
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
const zeroCells = container.querySelectorAll('.text-text-tertiary')
|
||||
expect(zeroCells.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle undefined word_count', () => {
|
||||
const doc = createMockDoc({ word_count: undefined as unknown as number })
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hit Count Display', () => {
|
||||
it('should display hit count less than 1000 as is', () => {
|
||||
const doc = createMockDoc({ hit_count: 100 })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display hit count 1000 or more in k format', () => {
|
||||
const doc = createMockDoc({ hit_count: 2500 })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('2.5k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display 0 with empty style when hit_count is 0', () => {
|
||||
const doc = createMockDoc({ hit_count: 0 })
|
||||
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
const zeroCells = container.querySelectorAll('.text-text-tertiary')
|
||||
expect(zeroCells.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chunking Mode', () => {
|
||||
it('should render ChunkingModeLabel with general mode', () => {
|
||||
render(<DocumentTableRow {...defaultProps} isGeneralMode isQAMode={false} />, { wrapper: createWrapper() })
|
||||
// ChunkingModeLabel should be rendered
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChunkingModeLabel with QA mode', () => {
|
||||
render(<DocumentTableRow {...defaultProps} isGeneralMode={false} isQAMode />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Summary Status', () => {
|
||||
it('should render SummaryStatus when summary_index_status is present', () => {
|
||||
const doc = createMockDoc({ summary_index_status: 'completed' })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render SummaryStatus when summary_index_status is absent', () => {
|
||||
const doc = createMockDoc({ summary_index_status: undefined })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rename Action', () => {
|
||||
it('should call onShowRenameModal when rename button is clicked', () => {
|
||||
const onShowRenameModal = vi.fn()
|
||||
const { container } = render(
|
||||
<DocumentTableRow {...defaultProps} onShowRenameModal={onShowRenameModal} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Find the rename button by finding the RiEditLine icon's parent
|
||||
const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md')
|
||||
if (renameButtons.length > 0) {
|
||||
fireEvent.click(renameButtons[0])
|
||||
expect(onShowRenameModal).toHaveBeenCalledWith(defaultProps.doc)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Operations', () => {
|
||||
it('should pass selectedIds to Operations component', () => {
|
||||
render(<DocumentTableRow {...defaultProps} selectedIds={['doc-1', 'doc-2']} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass onSelectedIdChange to Operations component', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
render(<DocumentTableRow {...defaultProps} onSelectedIdChange={onSelectedIdChange} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Source Icon', () => {
|
||||
it('should render with FILE data source type', () => {
|
||||
const doc = createMockDoc({ data_source_type: DataSourceType.FILE })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with NOTION data source type', () => {
|
||||
const doc = createMockDoc({
|
||||
data_source_type: DataSourceType.NOTION,
|
||||
data_source_info: { notion_page_icon: 'icon.png' },
|
||||
})
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with WEB data source type', () => {
|
||||
const doc = createMockDoc({ data_source_type: DataSourceType.WEB })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle document with very long name', () => {
|
||||
const doc = createMockDoc({ name: `${'a'.repeat(500)}.txt` })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle document with special characters in name', () => {
|
||||
const doc = createMockDoc({ name: '<script>test</script>.txt' })
|
||||
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('<script>test</script>.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should memoize the component', () => {
|
||||
const wrapper = createWrapper()
|
||||
const { rerender } = render(<DocumentTableRow {...defaultProps} />, { wrapper })
|
||||
|
||||
rerender(<DocumentTableRow {...defaultProps} />)
|
||||
expect(screen.getByRole('row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,152 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { pick } from 'es-toolkit/object'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label'
|
||||
import Operations from '@/app/components/datasets/documents/components/operations'
|
||||
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
|
||||
import StatusItem from '@/app/components/datasets/documents/status-item'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import DocumentSourceIcon from './document-source-icon'
|
||||
import { renderTdValue } from './utils'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
type DocumentTableRowProps = {
|
||||
doc: LocalDoc
|
||||
index: number
|
||||
datasetId: string
|
||||
isSelected: boolean
|
||||
isGeneralMode: boolean
|
||||
isQAMode: boolean
|
||||
embeddingAvailable: boolean
|
||||
selectedIds: string[]
|
||||
onSelectOne: (docId: string) => void
|
||||
onSelectedIdChange: (ids: string[]) => void
|
||||
onShowRenameModal: (doc: LocalDoc) => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
const renderCount = (count: number | undefined) => {
|
||||
if (!count)
|
||||
return renderTdValue(0, true)
|
||||
|
||||
if (count < 1000)
|
||||
return count
|
||||
|
||||
return `${formatNumber((count / 1000).toFixed(1))}k`
|
||||
}
|
||||
|
||||
const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
doc,
|
||||
index,
|
||||
datasetId,
|
||||
isSelected,
|
||||
isGeneralMode,
|
||||
isQAMode,
|
||||
embeddingAvailable,
|
||||
selectedIds,
|
||||
onSelectOne,
|
||||
onSelectedIdChange,
|
||||
onShowRenameModal,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const router = useRouter()
|
||||
|
||||
const isFile = doc.data_source_type === DataSourceType.FILE
|
||||
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
|
||||
}, [router, datasetId, doc.id])
|
||||
|
||||
const handleCheckboxClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleRenameClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onShowRenameModal(doc)
|
||||
}, [doc, onShowRenameModal])
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover"
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<td className="text-left align-middle text-xs text-text-tertiary">
|
||||
<div className="flex items-center" onClick={handleCheckboxClick}>
|
||||
<Checkbox
|
||||
className="mr-2 shrink-0"
|
||||
checked={isSelected}
|
||||
onCheck={() => onSelectOne(doc.id)}
|
||||
/>
|
||||
{index + 1}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="group mr-6 flex max-w-[460px] items-center hover:mr-0">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<DocumentSourceIcon doc={doc} fileType={fileType} />
|
||||
</div>
|
||||
<Tooltip popupContent={doc.name}>
|
||||
<span className="grow-1 truncate text-sm">{doc.name}</span>
|
||||
</Tooltip>
|
||||
{doc.summary_index_status && (
|
||||
<div className="ml-1 hidden shrink-0 group-hover:flex">
|
||||
<SummaryStatus status={doc.summary_index_status} />
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
|
||||
<Tooltip popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={handleRenameClick}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ChunkingModeLabel
|
||||
isGeneralMode={isGeneralMode}
|
||||
isQAMode={isQAMode}
|
||||
/>
|
||||
</td>
|
||||
<td>{renderCount(doc.word_count)}</td>
|
||||
<td>{renderCount(doc.hit_count)}</td>
|
||||
<td className="text-[13px] text-text-secondary">
|
||||
{formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)}
|
||||
</td>
|
||||
<td>
|
||||
<StatusItem status={doc.display_status} />
|
||||
</td>
|
||||
<td>
|
||||
<Operations
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdChange={onSelectedIdChange}
|
||||
embeddingAvailable={embeddingAvailable}
|
||||
datasetId={datasetId}
|
||||
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
DocumentTableRow.displayName = 'DocumentTableRow'
|
||||
|
||||
export default DocumentTableRow
|
||||
@ -1,4 +0,0 @@
|
||||
export { default as DocumentSourceIcon } from './document-source-icon'
|
||||
export { default as DocumentTableRow } from './document-table-row'
|
||||
export { default as SortHeader } from './sort-header'
|
||||
export { renderTdValue } from './utils'
|
||||
@ -1,124 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SortHeader from './sort-header'
|
||||
|
||||
describe('SortHeader', () => {
|
||||
const defaultProps = {
|
||||
field: 'name' as const,
|
||||
label: 'File Name',
|
||||
currentSortField: null,
|
||||
sortOrder: 'desc' as const,
|
||||
onSort: vi.fn(),
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the label', () => {
|
||||
render(<SortHeader {...defaultProps} />)
|
||||
expect(screen.getByText('File Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the sort icon', () => {
|
||||
const { container } = render(<SortHeader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inactive state', () => {
|
||||
it('should have disabled text color when not active', () => {
|
||||
const { container } = render(<SortHeader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-disabled')
|
||||
})
|
||||
|
||||
it('should not be rotated when not active', () => {
|
||||
const { container } = render(<SortHeader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).not.toHaveClass('rotate-180')
|
||||
})
|
||||
})
|
||||
|
||||
describe('active state', () => {
|
||||
it('should have tertiary text color when active', () => {
|
||||
const { container } = render(
|
||||
<SortHeader {...defaultProps} currentSortField="name" />,
|
||||
)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should not be rotated when active and desc', () => {
|
||||
const { container } = render(
|
||||
<SortHeader {...defaultProps} currentSortField="name" sortOrder="desc" />,
|
||||
)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).not.toHaveClass('rotate-180')
|
||||
})
|
||||
|
||||
it('should be rotated when active and asc', () => {
|
||||
const { container } = render(
|
||||
<SortHeader {...defaultProps} currentSortField="name" sortOrder="asc" />,
|
||||
)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('rotate-180')
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction', () => {
|
||||
it('should call onSort when clicked', () => {
|
||||
const onSort = vi.fn()
|
||||
render(<SortHeader {...defaultProps} onSort={onSort} />)
|
||||
|
||||
fireEvent.click(screen.getByText('File Name'))
|
||||
|
||||
expect(onSort).toHaveBeenCalledWith('name')
|
||||
})
|
||||
|
||||
it('should call onSort with correct field', () => {
|
||||
const onSort = vi.fn()
|
||||
render(<SortHeader {...defaultProps} field="word_count" onSort={onSort} />)
|
||||
|
||||
fireEvent.click(screen.getByText('File Name'))
|
||||
|
||||
expect(onSort).toHaveBeenCalledWith('word_count')
|
||||
})
|
||||
})
|
||||
|
||||
describe('different fields', () => {
|
||||
it('should work with word_count field', () => {
|
||||
render(
|
||||
<SortHeader
|
||||
{...defaultProps}
|
||||
field="word_count"
|
||||
label="Words"
|
||||
currentSortField="word_count"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Words')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with hit_count field', () => {
|
||||
render(
|
||||
<SortHeader
|
||||
{...defaultProps}
|
||||
field="hit_count"
|
||||
label="Hit Count"
|
||||
currentSortField="hit_count"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Hit Count')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with created_at field', () => {
|
||||
render(
|
||||
<SortHeader
|
||||
{...defaultProps}
|
||||
field="created_at"
|
||||
label="Upload Time"
|
||||
currentSortField="created_at"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Upload Time')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,44 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SortField, SortOrder } from '../hooks'
|
||||
import { RiArrowDownLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SortHeaderProps = {
|
||||
field: Exclude<SortField, null>
|
||||
label: string
|
||||
currentSortField: SortField
|
||||
sortOrder: SortOrder
|
||||
onSort: (field: SortField) => void
|
||||
}
|
||||
|
||||
const SortHeader: FC<SortHeaderProps> = React.memo(({
|
||||
field,
|
||||
label,
|
||||
currentSortField,
|
||||
sortOrder,
|
||||
onSort,
|
||||
}) => {
|
||||
const isActive = currentSortField === field
|
||||
const isDesc = isActive && sortOrder === 'desc'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center hover:text-text-secondary"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label}
|
||||
<RiArrowDownLine
|
||||
className={cn(
|
||||
'ml-0.5 h-3 w-3 transition-all',
|
||||
isActive ? 'text-text-tertiary' : 'text-text-disabled',
|
||||
isActive && !isDesc ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SortHeader.displayName = 'SortHeader'
|
||||
|
||||
export default SortHeader
|
||||
@ -1,90 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderTdValue } from './utils'
|
||||
|
||||
describe('renderTdValue', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render string value correctly', () => {
|
||||
const { container } = render(<>{renderTdValue('test value')}</>)
|
||||
expect(screen.getByText('test value')).toBeInTheDocument()
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should render number value correctly', () => {
|
||||
const { container } = render(<>{renderTdValue(42)}</>)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should render zero correctly', () => {
|
||||
const { container } = render(<>{renderTdValue(0)}</>)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Null and undefined handling', () => {
|
||||
it('should render dash for null value', () => {
|
||||
render(<>{renderTdValue(null)}</>)
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dash for null value with empty style', () => {
|
||||
const { container } = render(<>{renderTdValue(null, true)}</>)
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty style', () => {
|
||||
it('should apply text-text-tertiary class when isEmptyStyle is true', () => {
|
||||
const { container } = render(<>{renderTdValue('value', true)}</>)
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class when isEmptyStyle is false', () => {
|
||||
const { container } = render(<>{renderTdValue('value', false)}</>)
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class when isEmptyStyle is not provided', () => {
|
||||
const { container } = render(<>{renderTdValue('value')}</>)
|
||||
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string', () => {
|
||||
render(<>{renderTdValue('')}</>)
|
||||
// Empty string should still render but with no visible text
|
||||
const div = document.querySelector('div')
|
||||
expect(div).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
render(<>{renderTdValue(1234567890)}</>)
|
||||
expect(screen.getByText('1234567890')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
render(<>{renderTdValue(-42)}</>)
|
||||
expect(screen.getByText('-42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
render(<>{renderTdValue('<script>alert("xss")</script>')}</>)
|
||||
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
render(<>{renderTdValue('Test Unicode: \u4E2D\u6587')}</>)
|
||||
expect(screen.getByText('Test Unicode: \u4E2D\u6587')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long strings', () => {
|
||||
const longString = 'a'.repeat(1000)
|
||||
render(<>{renderTdValue(longString)}</>)
|
||||
expect(screen.getByText(longString)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,16 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from '../../../style.module.css'
|
||||
|
||||
export const renderTdValue = (value: string | number | null, isEmptyStyle = false): ReactNode => {
|
||||
const className = cn(
|
||||
isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary',
|
||||
s.tdValue,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{value ?? '-'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export { useDocumentActions } from './use-document-actions'
|
||||
export { useDocumentSelection } from './use-document-selection'
|
||||
export { useDocumentSort } from './use-document-sort'
|
||||
export type { SortField, SortOrder } from './use-document-sort'
|
||||
@ -1,438 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DocumentActionType } from '@/models/datasets'
|
||||
import * as useDocument from '@/service/knowledge/use-document'
|
||||
import { useDocumentActions } from './use-document-actions'
|
||||
|
||||
vi.mock('@/service/knowledge/use-document')
|
||||
|
||||
const mockUseDocumentArchive = vi.mocked(useDocument.useDocumentArchive)
|
||||
const mockUseDocumentSummary = vi.mocked(useDocument.useDocumentSummary)
|
||||
const mockUseDocumentEnable = vi.mocked(useDocument.useDocumentEnable)
|
||||
const mockUseDocumentDisable = vi.mocked(useDocument.useDocumentDisable)
|
||||
const mockUseDocumentDelete = vi.mocked(useDocument.useDocumentDelete)
|
||||
const mockUseDocumentBatchRetryIndex = vi.mocked(useDocument.useDocumentBatchRetryIndex)
|
||||
const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip)
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useDocumentActions', () => {
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup all mocks with default values
|
||||
const createMockMutation = () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: true,
|
||||
data: undefined,
|
||||
error: null,
|
||||
mutate: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
status: 'idle' as const,
|
||||
variables: undefined,
|
||||
context: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
})
|
||||
|
||||
mockUseDocumentArchive.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentArchive>)
|
||||
mockUseDocumentSummary.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentSummary>)
|
||||
mockUseDocumentEnable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentEnable>)
|
||||
mockUseDocumentDisable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDisable>)
|
||||
mockUseDocumentDelete.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDelete>)
|
||||
mockUseDocumentBatchRetryIndex.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentBatchRetryIndex>)
|
||||
mockUseDocumentDownloadZip.mockReturnValue({
|
||||
...createMockMutation(),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
|
||||
})
|
||||
|
||||
describe('handleAction', () => {
|
||||
it('should call archive mutation when archive action is triggered', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAction(DocumentActionType.archive)()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentIds: ['doc1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onUpdate on successful action', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAction(DocumentActionType.enable)()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClearSelection on delete action', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAction(DocumentActionType.delete)()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClearSelection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBatchReIndex', () => {
|
||||
it('should call retry index mutation', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchReIndex()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentIds: ['doc1', 'doc2'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClearSelection on success', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchReIndex()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClearSelection).toHaveBeenCalled()
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBatchDownload', () => {
|
||||
it('should not proceed when already downloading', async () => {
|
||||
mockUseDocumentDownloadZip.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: ['doc1'],
|
||||
onUpdate: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchDownload()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call download mutation with downloadable ids', async () => {
|
||||
const mockBlob = new Blob(['test'])
|
||||
mockMutateAsync.mockResolvedValue(mockBlob)
|
||||
|
||||
mockUseDocumentDownloadZip.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
downloadableSelectedIds: ['doc1'],
|
||||
onUpdate: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchDownload()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
datasetId: 'ds1',
|
||||
documentIds: ['doc1'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDownloadingZip', () => {
|
||||
it('should reflect isPending state from mutation', () => {
|
||||
mockUseDocumentDownloadZip.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: [],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isDownloadingZip).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should show error toast when handleAction fails', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Action failed'))
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAction(DocumentActionType.archive)()
|
||||
})
|
||||
|
||||
// onUpdate should not be called on error
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when handleBatchReIndex fails', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Re-index failed'))
|
||||
const onUpdate = vi.fn()
|
||||
const onClearSelection = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchReIndex()
|
||||
})
|
||||
|
||||
// onUpdate and onClearSelection should not be called on error
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(onClearSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when handleBatchDownload fails', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Download failed'))
|
||||
|
||||
mockUseDocumentDownloadZip.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: ['doc1'],
|
||||
onUpdate: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchDownload()
|
||||
})
|
||||
|
||||
// Mutation was called but failed
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when handleBatchDownload returns null blob', async () => {
|
||||
mockMutateAsync.mockResolvedValue(null)
|
||||
|
||||
mockUseDocumentDownloadZip.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: ['doc1'],
|
||||
onUpdate: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBatchDownload()
|
||||
})
|
||||
|
||||
// Mutation was called but returned null
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('all action types', () => {
|
||||
it('should handle summary action', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAction(DocumentActionType.summary)()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle disable action', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ result: 'success' })
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDocumentActions({
|
||||
datasetId: 'ds1',
|
||||
selectedIds: ['doc1'],
|
||||
downloadableSelectedIds: [],
|
||||
onUpdate,
|
||||
onClearSelection: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAction(DocumentActionType.disable)()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,126 +0,0 @@
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { DocumentActionType } from '@/models/datasets'
|
||||
import {
|
||||
useDocumentArchive,
|
||||
useDocumentBatchRetryIndex,
|
||||
useDocumentDelete,
|
||||
useDocumentDisable,
|
||||
useDocumentDownloadZip,
|
||||
useDocumentEnable,
|
||||
useDocumentSummary,
|
||||
} from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type UseDocumentActionsOptions = {
|
||||
datasetId: string
|
||||
selectedIds: string[]
|
||||
downloadableSelectedIds: string[]
|
||||
onUpdate: () => void
|
||||
onClearSelection: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random ZIP filename for bulk document downloads.
|
||||
* We intentionally avoid leaking dataset info in the exported archive name.
|
||||
*/
|
||||
const generateDocsZipFileName = (): string => {
|
||||
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
return `${randomPart}-docs.zip`
|
||||
}
|
||||
|
||||
export const useDocumentActions = ({
|
||||
datasetId,
|
||||
selectedIds,
|
||||
downloadableSelectedIds,
|
||||
onUpdate,
|
||||
onClearSelection,
|
||||
}: UseDocumentActionsOptions) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { mutateAsync: archiveDocument } = useDocumentArchive()
|
||||
const { mutateAsync: generateSummary } = useDocumentSummary()
|
||||
const { mutateAsync: enableDocument } = useDocumentEnable()
|
||||
const { mutateAsync: disableDocument } = useDocumentDisable()
|
||||
const { mutateAsync: deleteDocument } = useDocumentDelete()
|
||||
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
|
||||
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
|
||||
|
||||
type SupportedActionType
|
||||
= | typeof DocumentActionType.archive
|
||||
| typeof DocumentActionType.summary
|
||||
| typeof DocumentActionType.enable
|
||||
| typeof DocumentActionType.disable
|
||||
| typeof DocumentActionType.delete
|
||||
|
||||
const actionMutationMap = useMemo(() => ({
|
||||
[DocumentActionType.archive]: archiveDocument,
|
||||
[DocumentActionType.summary]: generateSummary,
|
||||
[DocumentActionType.enable]: enableDocument,
|
||||
[DocumentActionType.disable]: disableDocument,
|
||||
[DocumentActionType.delete]: deleteDocument,
|
||||
} as const), [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument])
|
||||
|
||||
const handleAction = useCallback((actionName: SupportedActionType) => {
|
||||
return async () => {
|
||||
const opApi = actionMutationMap[actionName]
|
||||
if (!opApi)
|
||||
return
|
||||
|
||||
const [e] = await asyncRunSafe<CommonResponse>(
|
||||
opApi({ datasetId, documentIds: selectedIds }),
|
||||
)
|
||||
|
||||
if (!e) {
|
||||
if (actionName === DocumentActionType.delete)
|
||||
onClearSelection()
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
onUpdate()
|
||||
}
|
||||
else {
|
||||
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
}
|
||||
}
|
||||
}, [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t])
|
||||
|
||||
const handleBatchReIndex = useCallback(async () => {
|
||||
const [e] = await asyncRunSafe<CommonResponse>(
|
||||
retryIndexDocument({ datasetId, documentIds: selectedIds }),
|
||||
)
|
||||
if (!e) {
|
||||
onClearSelection()
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
onUpdate()
|
||||
}
|
||||
else {
|
||||
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
}
|
||||
}, [retryIndexDocument, datasetId, selectedIds, onClearSelection, onUpdate, t])
|
||||
|
||||
const handleBatchDownload = useCallback(async () => {
|
||||
if (isDownloadingZip)
|
||||
return
|
||||
|
||||
const [e, blob] = await asyncRunSafe(
|
||||
requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }),
|
||||
)
|
||||
if (e || !blob) {
|
||||
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
|
||||
return
|
||||
}
|
||||
|
||||
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
|
||||
}, [datasetId, downloadableSelectedIds, isDownloadingZip, requestDocumentsZip, t])
|
||||
|
||||
return {
|
||||
handleAction,
|
||||
handleBatchReIndex,
|
||||
handleBatchDownload,
|
||||
isDownloadingZip,
|
||||
}
|
||||
}
|
||||
@ -1,317 +0,0 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useDocumentSelection } from './use-document-selection'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
|
||||
id: 'doc1',
|
||||
name: 'Test Document',
|
||||
data_source_type: DataSourceType.FILE,
|
||||
data_source_info: {},
|
||||
data_source_detail_dict: {},
|
||||
word_count: 100,
|
||||
hit_count: 10,
|
||||
created_at: 1000000,
|
||||
position: 1,
|
||||
doc_form: 'text_model',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
display_status: 'available',
|
||||
created_from: 'api',
|
||||
...overrides,
|
||||
} as LocalDoc)
|
||||
|
||||
describe('useDocumentSelection', () => {
|
||||
describe('isAllSelected', () => {
|
||||
it('should return false when documents is empty', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: [],
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when all documents are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1' }),
|
||||
createMockDocument({ id: 'doc2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.isAllSelected).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when not all documents are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1' }),
|
||||
createMockDocument({ id: 'doc2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSomeSelected', () => {
|
||||
it('should return false when no documents are selected', () => {
|
||||
const docs = [createMockDocument({ id: 'doc1' })]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.isSomeSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when some documents are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1' }),
|
||||
createMockDocument({ id: 'doc2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.isSomeSelected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSelectAll', () => {
|
||||
it('should select all documents when none are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1' }),
|
||||
createMockDocument({ id: 'doc2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectAll()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2'])
|
||||
})
|
||||
|
||||
it('should deselect all when all are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1' }),
|
||||
createMockDocument({ id: 'doc2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectAll()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should add to existing selection when some are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1' }),
|
||||
createMockDocument({ id: 'doc2' }),
|
||||
createMockDocument({ id: 'doc3' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectAll()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2', 'doc3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSelectOne', () => {
|
||||
it('should add document to selection when not selected', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: [],
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectOne('doc1')
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1'])
|
||||
})
|
||||
|
||||
it('should remove document from selection when already selected', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: [],
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectOne('doc1')
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasErrorDocumentsSelected', () => {
|
||||
it('should return false when no error documents are selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', display_status: 'available' }),
|
||||
createMockDocument({ id: 'doc2', display_status: 'error' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.hasErrorDocumentsSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when an error document is selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', display_status: 'available' }),
|
||||
createMockDocument({ id: 'doc2', display_status: 'error' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc2'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.hasErrorDocumentsSelected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadableSelectedIds', () => {
|
||||
it('should return only FILE type documents from selection', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', data_source_type: DataSourceType.FILE }),
|
||||
createMockDocument({ id: 'doc2', data_source_type: DataSourceType.NOTION }),
|
||||
createMockDocument({ id: 'doc3', data_source_type: DataSourceType.FILE }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1', 'doc2', 'doc3'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.downloadableSelectedIds).toEqual(['doc1', 'doc3'])
|
||||
})
|
||||
|
||||
it('should return empty array when no FILE documents selected', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', data_source_type: DataSourceType.NOTION }),
|
||||
createMockDocument({ id: 'doc2', data_source_type: DataSourceType.WEB }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.downloadableSelectedIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('should call onSelectedIdChange with empty array', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSelection({
|
||||
documents: [],
|
||||
selectedIds: ['doc1', 'doc2'],
|
||||
onSelectedIdChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.clearSelection()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,66 +0,0 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { uniq } from 'es-toolkit/array'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
type UseDocumentSelectionOptions = {
|
||||
documents: LocalDoc[]
|
||||
selectedIds: string[]
|
||||
onSelectedIdChange: (selectedIds: string[]) => void
|
||||
}
|
||||
|
||||
export const useDocumentSelection = ({
|
||||
documents,
|
||||
selectedIds,
|
||||
onSelectedIdChange,
|
||||
}: UseDocumentSelectionOptions) => {
|
||||
const isAllSelected = useMemo(() => {
|
||||
return documents.length > 0 && documents.every(doc => selectedIds.includes(doc.id))
|
||||
}, [documents, selectedIds])
|
||||
|
||||
const isSomeSelected = useMemo(() => {
|
||||
return documents.some(doc => selectedIds.includes(doc.id))
|
||||
}, [documents, selectedIds])
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
if (isAllSelected)
|
||||
onSelectedIdChange([])
|
||||
else
|
||||
onSelectedIdChange(uniq([...selectedIds, ...documents.map(doc => doc.id)]))
|
||||
}, [isAllSelected, documents, onSelectedIdChange, selectedIds])
|
||||
|
||||
const onSelectOne = useCallback((docId: string) => {
|
||||
onSelectedIdChange(
|
||||
selectedIds.includes(docId)
|
||||
? selectedIds.filter(id => id !== docId)
|
||||
: [...selectedIds, docId],
|
||||
)
|
||||
}, [selectedIds, onSelectedIdChange])
|
||||
|
||||
const hasErrorDocumentsSelected = useMemo(() => {
|
||||
return documents.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error')
|
||||
}, [documents, selectedIds])
|
||||
|
||||
const downloadableSelectedIds = useMemo(() => {
|
||||
const selectedSet = new Set(selectedIds)
|
||||
return documents
|
||||
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
|
||||
.map(doc => doc.id)
|
||||
}, [documents, selectedIds])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectedIdChange([])
|
||||
}, [onSelectedIdChange])
|
||||
|
||||
return {
|
||||
isAllSelected,
|
||||
isSomeSelected,
|
||||
onSelectAll,
|
||||
onSelectOne,
|
||||
hasErrorDocumentsSelected,
|
||||
downloadableSelectedIds,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@ -1,340 +0,0 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useDocumentSort } from './use-document-sort'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
|
||||
id: 'doc1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
data_source_info: {},
|
||||
data_source_detail_dict: {},
|
||||
word_count: 100,
|
||||
hit_count: 10,
|
||||
created_at: 1000000,
|
||||
position: 1,
|
||||
doc_form: 'text_model',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
display_status: 'available',
|
||||
created_from: 'api',
|
||||
...overrides,
|
||||
} as LocalDoc)
|
||||
|
||||
describe('useDocumentSort', () => {
|
||||
describe('initial state', () => {
|
||||
it('should return null sortField initially', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should return documents unchanged when no sort is applied', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', name: 'B' }),
|
||||
createMockDocument({ id: 'doc2', name: 'A' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.sortedDocuments).toEqual(docs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSort', () => {
|
||||
it('should set sort field when called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should toggle sort order when same field is clicked twice', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should reset to desc when different field is selected', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('word_count')
|
||||
})
|
||||
expect(result.current.sortField).toBe('word_count')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should not change state when null is passed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort(null)
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting documents', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', name: 'Banana', word_count: 200, hit_count: 5, created_at: 3000 }),
|
||||
createMockDocument({ id: 'doc2', name: 'Apple', word_count: 100, hit_count: 10, created_at: 1000 }),
|
||||
createMockDocument({ id: 'doc3', name: 'Cherry', word_count: 300, hit_count: 1, created_at: 2000 }),
|
||||
]
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Cherry', 'Banana', 'Apple'])
|
||||
})
|
||||
|
||||
it('should sort by name ascending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Apple', 'Banana', 'Cherry'])
|
||||
})
|
||||
|
||||
it('should sort by word_count descending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('word_count')
|
||||
})
|
||||
|
||||
const counts = result.current.sortedDocuments.map(d => d.word_count)
|
||||
expect(counts).toEqual([300, 200, 100])
|
||||
})
|
||||
|
||||
it('should sort by hit_count ascending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
const counts = result.current.sortedDocuments.map(d => d.hit_count)
|
||||
expect(counts).toEqual([1, 5, 10])
|
||||
})
|
||||
|
||||
it('should sort by created_at descending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('created_at')
|
||||
})
|
||||
|
||||
const times = result.current.sortedDocuments.map(d => d.created_at)
|
||||
expect(times).toEqual([3000, 2000, 1000])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status filtering', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', display_status: 'available' }),
|
||||
createMockDocument({ id: 'doc2', display_status: 'error' }),
|
||||
createMockDocument({ id: 'doc3', display_status: 'available' }),
|
||||
]
|
||||
|
||||
it('should not filter when statusFilterValue is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.sortedDocuments.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should not filter when statusFilterValue is all', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: 'all',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.sortedDocuments.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remoteSortValue reset', () => {
|
||||
it('should reset sort state when remoteSortValue changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ remoteSortValue }) =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue,
|
||||
}),
|
||||
{ initialProps: { remoteSortValue: 'initial' } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
|
||||
rerender({ remoteSortValue: 'changed' })
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle documents with missing values', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', name: undefined as unknown as string, word_count: undefined }),
|
||||
createMockDocument({ id: 'doc2', name: 'Test', word_count: 100 }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortedDocuments.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle empty documents array', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortedDocuments).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,102 +0,0 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
|
||||
|
||||
export type SortField = 'name' | 'word_count' | 'hit_count' | 'created_at' | null
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
type UseDocumentSortOptions = {
|
||||
documents: LocalDoc[]
|
||||
statusFilterValue: string
|
||||
remoteSortValue: string
|
||||
}
|
||||
|
||||
export const useDocumentSort = ({
|
||||
documents,
|
||||
statusFilterValue,
|
||||
remoteSortValue,
|
||||
}: UseDocumentSortOptions) => {
|
||||
const [sortField, setSortField] = useState<SortField>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const prevRemoteSortValueRef = useRef(remoteSortValue)
|
||||
|
||||
// Reset sort when remote sort changes
|
||||
if (prevRemoteSortValueRef.current !== remoteSortValue) {
|
||||
prevRemoteSortValueRef.current = remoteSortValue
|
||||
setSortField(null)
|
||||
setSortOrder('desc')
|
||||
}
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (field === null)
|
||||
return
|
||||
|
||||
if (sortField === field) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
else {
|
||||
setSortField(field)
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}, [sortField])
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
let filteredDocs = documents
|
||||
|
||||
if (statusFilterValue && statusFilterValue !== 'all') {
|
||||
filteredDocs = filteredDocs.filter(doc =>
|
||||
typeof doc.display_status === 'string'
|
||||
&& normalizeStatusForQuery(doc.display_status) === statusFilterValue,
|
||||
)
|
||||
}
|
||||
|
||||
if (!sortField)
|
||||
return filteredDocs
|
||||
|
||||
const sortedDocs = [...filteredDocs].sort((a, b) => {
|
||||
let aValue: string | number
|
||||
let bValue: string | number
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
aValue = a.name?.toLowerCase() || ''
|
||||
bValue = b.name?.toLowerCase() || ''
|
||||
break
|
||||
case 'word_count':
|
||||
aValue = a.word_count || 0
|
||||
bValue = b.word_count || 0
|
||||
break
|
||||
case 'hit_count':
|
||||
aValue = a.hit_count || 0
|
||||
bValue = b.hit_count || 0
|
||||
break
|
||||
case 'created_at':
|
||||
aValue = a.created_at
|
||||
bValue = b.created_at
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
if (sortField === 'name') {
|
||||
const result = (aValue as string).localeCompare(bValue as string)
|
||||
return sortOrder === 'asc' ? result : -result
|
||||
}
|
||||
else {
|
||||
const result = (aValue as number) - (bValue as number)
|
||||
return sortOrder === 'asc' ? result : -result
|
||||
}
|
||||
})
|
||||
|
||||
return sortedDocs
|
||||
}, [documents, sortField, sortOrder, statusFilterValue])
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
handleSort,
|
||||
sortedDocuments,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user