mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
fix(chat-deploy): fixed form submission access patterns, fixed kb block filters (#839)
* fix(chat-deploy): fixed form submission access patterns * fix(kb-block): fix tag filters component, removed unused component * fixed kb block subcomponents --------- Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local>
This commit is contained in:
@@ -13,9 +13,9 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
|
||||
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
|
||||
import { SubdomainInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/subdomain-input'
|
||||
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
|
||||
import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment'
|
||||
import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
|
||||
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components'
|
||||
@@ -31,6 +31,7 @@ interface ChatDeployProps {
|
||||
chatSubmitting: boolean
|
||||
setChatSubmitting: (submitting: boolean) => void
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
onPreDeployWorkflow?: () => Promise<void>
|
||||
}
|
||||
|
||||
interface ExistingChat {
|
||||
@@ -54,22 +55,15 @@ export function ChatDeploy({
|
||||
chatSubmitting,
|
||||
setChatSubmitting,
|
||||
onValidationChange,
|
||||
onPreDeployWorkflow,
|
||||
}: ChatDeployProps) {
|
||||
// Loading and existing chat state
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
|
||||
|
||||
// Form and deployment hooks
|
||||
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
|
||||
const { deployedUrl, deployChat } = useChatDeployment()
|
||||
|
||||
// Refs
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// Subdomain validation state
|
||||
const [isSubdomainValid, setIsSubdomainValid] = useState(false)
|
||||
|
||||
// Track overall form validity
|
||||
const isFormValid =
|
||||
isSubdomainValid &&
|
||||
Boolean(formData.title.trim()) &&
|
||||
@@ -79,12 +73,10 @@ export function ChatDeploy({
|
||||
Boolean(existingChat)) &&
|
||||
(formData.authType !== 'email' || formData.emails.length > 0)
|
||||
|
||||
// Notify parent of validation changes
|
||||
useEffect(() => {
|
||||
onValidationChange?.(isFormValid)
|
||||
}, [isFormValid, onValidationChange])
|
||||
|
||||
// Fetch existing chat data when component mounts
|
||||
useEffect(() => {
|
||||
if (workflowId) {
|
||||
fetchExistingChat()
|
||||
@@ -106,13 +98,12 @@ export function ChatDeploy({
|
||||
const chatDetail = await detailResponse.json()
|
||||
setExistingChat(chatDetail)
|
||||
|
||||
// Populate form with existing data
|
||||
setFormData({
|
||||
subdomain: chatDetail.subdomain || '',
|
||||
title: chatDetail.title || '',
|
||||
description: chatDetail.description || '',
|
||||
authType: chatDetail.authType || 'public',
|
||||
password: '', // Never populate password for security
|
||||
password: '',
|
||||
emails: Array.isArray(chatDetail.allowedEmails) ? [...chatDetail.allowedEmails] : [],
|
||||
welcomeMessage:
|
||||
chatDetail.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
|
||||
@@ -141,32 +132,28 @@ export function ChatDeploy({
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault()
|
||||
|
||||
// Prevent double submission
|
||||
if (chatSubmitting) return
|
||||
|
||||
setChatSubmitting(true)
|
||||
|
||||
try {
|
||||
// Validate form
|
||||
await onPreDeployWorkflow?.()
|
||||
|
||||
if (!validateForm()) {
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check subdomain validation
|
||||
if (!isSubdomainValid && formData.subdomain !== existingChat?.subdomain) {
|
||||
setError('subdomain', 'Please wait for subdomain validation to complete')
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Deploy chat
|
||||
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id)
|
||||
|
||||
// Success - component will show success state via deployedUrl
|
||||
onChatExistsChange?.(true)
|
||||
} catch (error: any) {
|
||||
// Handle subdomain conflict specifically
|
||||
if (error.message?.includes('subdomain')) {
|
||||
setError('subdomain', error.message)
|
||||
} else {
|
||||
@@ -177,21 +164,6 @@ export function ChatDeploy({
|
||||
}
|
||||
}
|
||||
|
||||
// Setup form for external submission
|
||||
useEffect(() => {
|
||||
const form = formRef.current
|
||||
if (form) {
|
||||
const handleDOMSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
form.addEventListener('submit', handleDOMSubmit)
|
||||
return () => {
|
||||
form.removeEventListener('submit', handleDOMSubmit)
|
||||
}
|
||||
}
|
||||
}, [handleSubmit])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
@@ -202,9 +174,10 @@ export function ChatDeploy({
|
||||
|
||||
return (
|
||||
<form
|
||||
id='chat-deploy-form'
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className='chat-deploy-form -mx-1 space-y-4 overflow-y-auto px-1'
|
||||
className='-mx-1 space-y-4 overflow-y-auto px-1'
|
||||
>
|
||||
{errors.general && (
|
||||
<Alert variant='destructive'>
|
||||
@@ -214,7 +187,6 @@ export function ChatDeploy({
|
||||
)}
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Subdomain Input */}
|
||||
<SubdomainInput
|
||||
value={formData.subdomain}
|
||||
onChange={(value) => updateField('subdomain', value)}
|
||||
@@ -222,8 +194,6 @@ export function ChatDeploy({
|
||||
disabled={chatSubmitting}
|
||||
onValidationChange={setIsSubdomainValid}
|
||||
/>
|
||||
|
||||
{/* Title Input */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='title' className='font-medium text-sm'>
|
||||
Chat Title
|
||||
@@ -238,8 +208,6 @@ export function ChatDeploy({
|
||||
/>
|
||||
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='description' className='font-medium text-sm'>
|
||||
Description (Optional)
|
||||
@@ -253,8 +221,6 @@ export function ChatDeploy({
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Configuration */}
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Chat Output</Label>
|
||||
<Card className='rounded-md border-input shadow-none'>
|
||||
@@ -274,7 +240,6 @@ export function ChatDeploy({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication Selector */}
|
||||
<AuthSelector
|
||||
authType={formData.authType}
|
||||
password={formData.password}
|
||||
@@ -286,8 +251,6 @@ export function ChatDeploy({
|
||||
isExistingChat={!!existingChat}
|
||||
error={errors.password || errors.emails}
|
||||
/>
|
||||
|
||||
{/* Welcome Message */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
|
||||
Welcome Message
|
||||
@@ -331,64 +294,3 @@ function LoadingSkeleton() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuccessView({
|
||||
deployedUrl,
|
||||
existingChat,
|
||||
}: {
|
||||
deployedUrl: string
|
||||
existingChat: ExistingChat | null
|
||||
}) {
|
||||
const url = new URL(deployedUrl)
|
||||
const hostname = url.hostname
|
||||
const isDevelopmentUrl = hostname.includes('localhost')
|
||||
|
||||
let domainSuffix
|
||||
if (isDevelopmentUrl) {
|
||||
const baseDomain = getBaseDomain()
|
||||
const baseHost = baseDomain.split(':')[0]
|
||||
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
|
||||
domainSuffix = `.${baseHost}:${port}`
|
||||
} else {
|
||||
domainSuffix = `.${getEmailDomain()}`
|
||||
}
|
||||
|
||||
const baseDomainForSplit = getEmailDomain()
|
||||
const subdomainPart = isDevelopmentUrl
|
||||
? hostname.split('.')[0]
|
||||
: hostname.split(`.${baseDomainForSplit}`)[0]
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>
|
||||
Chat {existingChat ? 'Update' : 'Deployment'} Successful
|
||||
</Label>
|
||||
<div className='relative flex items-center rounded-md ring-offset-background'>
|
||||
<a
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-primary text-sm'
|
||||
>
|
||||
{subdomainPart}
|
||||
</a>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{domainSuffix}
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Your chat is now live at{' '}
|
||||
<a
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
this URL
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ export function AuthSelector({
|
||||
disabled={disabled}
|
||||
className='pr-28'
|
||||
required={!isExistingChat}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
<div className='absolute top-0 right-0 flex h-full'>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Label } from '@/components/ui'
|
||||
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
|
||||
|
||||
interface ExistingChat {
|
||||
id: string
|
||||
subdomain: string
|
||||
title: string
|
||||
description: string
|
||||
authType: 'public' | 'password' | 'email'
|
||||
allowedEmails: string[]
|
||||
outputConfigs: Array<{ blockId: string; path: string }>
|
||||
customizations?: {
|
||||
welcomeMessage?: string
|
||||
}
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface SuccessViewProps {
|
||||
deployedUrl: string
|
||||
existingChat: ExistingChat | null
|
||||
}
|
||||
|
||||
export function SuccessView({ deployedUrl, existingChat }: SuccessViewProps) {
|
||||
const url = new URL(deployedUrl)
|
||||
const hostname = url.hostname
|
||||
const isDevelopmentUrl = hostname.includes('localhost')
|
||||
|
||||
let domainSuffix
|
||||
if (isDevelopmentUrl) {
|
||||
const baseDomain = getBaseDomain()
|
||||
const baseHost = baseDomain.split(':')[0]
|
||||
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
|
||||
domainSuffix = `.${baseHost}:${port}`
|
||||
} else {
|
||||
domainSuffix = `.${getEmailDomain()}`
|
||||
}
|
||||
|
||||
const baseDomainForSplit = getEmailDomain()
|
||||
const subdomainPart = isDevelopmentUrl
|
||||
? hostname.split('.')[0]
|
||||
: hostname.split(`.${baseDomainForSplit}`)[0]
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>
|
||||
Chat {existingChat ? 'Update' : 'Deployment'} Successful
|
||||
</Label>
|
||||
<div className='relative flex items-center rounded-md ring-offset-background'>
|
||||
<a
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-primary text-sm'
|
||||
>
|
||||
{subdomainPart}
|
||||
</a>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{domainSuffix}
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Your chat is now live at{' '}
|
||||
<a
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
this URL
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useChatDeployment } from './use-chat-deployment'
|
||||
import { useChatForm } from './use-chat-form'
|
||||
|
||||
export { useChatDeployment, useChatForm }
|
||||
|
||||
export type { ChatFormData, ChatFormErrors } from './use-chat-form'
|
||||
@@ -64,14 +64,11 @@ export function DeployModal({
|
||||
isLoadingDeployedState,
|
||||
refetchDeployedState,
|
||||
}: DeployModalProps) {
|
||||
// Use registry store for deployment-related functions
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
state.getWorkflowDeploymentStatus(workflowId)
|
||||
)
|
||||
const isDeployed = deploymentStatus?.isDeployed || false
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
|
||||
// Local state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isUndeploying, setIsUndeploying] = useState(false)
|
||||
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
|
||||
@@ -84,7 +81,6 @@ export function DeployModal({
|
||||
const [chatExists, setChatExists] = useState(false)
|
||||
const [isChatFormValid, setIsChatFormValid] = useState(false)
|
||||
|
||||
// Generate an example input format for the API request
|
||||
const getInputFormatExample = () => {
|
||||
let inputFormatExample = ''
|
||||
try {
|
||||
@@ -128,7 +124,6 @@ export function DeployModal({
|
||||
return inputFormatExample
|
||||
}
|
||||
|
||||
// Fetch API keys when modal opens
|
||||
const fetchApiKeys = async () => {
|
||||
if (!open) return
|
||||
|
||||
@@ -147,7 +142,6 @@ export function DeployModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch chat deployment info when modal opens
|
||||
const fetchChatDeploymentInfo = async () => {
|
||||
if (!open || !workflowId) return
|
||||
|
||||
@@ -173,10 +167,8 @@ export function DeployModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Call fetchApiKeys when the modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Set loading state immediately when modal opens
|
||||
setIsLoading(true)
|
||||
fetchApiKeys()
|
||||
fetchChatDeploymentInfo()
|
||||
@@ -184,12 +176,10 @@ export function DeployModal({
|
||||
}
|
||||
}, [open, workflowId])
|
||||
|
||||
// Fetch deployment info when the modal opens and the workflow is deployed
|
||||
useEffect(() => {
|
||||
async function fetchDeploymentInfo() {
|
||||
if (!open || !workflowId || !isDeployed) {
|
||||
setDeploymentInfo(null)
|
||||
// Only reset loading if modal is closed
|
||||
if (!open) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -199,7 +189,6 @@ export function DeployModal({
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Get deployment info
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -228,15 +217,12 @@ export function DeployModal({
|
||||
fetchDeploymentInfo()
|
||||
}, [open, workflowId, isDeployed, needsRedeployment])
|
||||
|
||||
// Handle form submission for deployment
|
||||
const onDeploy = async (data: DeployFormValues) => {
|
||||
// Reset any previous errors
|
||||
setApiDeployError(null)
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Deploy the workflow with the selected API key
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -244,7 +230,7 @@ export function DeployModal({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: data.apiKey,
|
||||
deployChatEnabled: false, // Separate chat deployment
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -255,7 +241,6 @@ export function DeployModal({
|
||||
|
||||
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
|
||||
|
||||
// Update the store with the deployment status
|
||||
setDeploymentStatus(
|
||||
workflowId,
|
||||
newDeployStatus,
|
||||
@@ -263,13 +248,10 @@ export function DeployModal({
|
||||
data.apiKey
|
||||
)
|
||||
|
||||
// Reset the needs redeployment flag
|
||||
setNeedsRedeployment(false)
|
||||
if (workflowId) {
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
|
||||
}
|
||||
|
||||
// Update the local deployment info
|
||||
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample()
|
||||
|
||||
@@ -284,10 +266,7 @@ export function DeployModal({
|
||||
|
||||
setDeploymentInfo(newDeploymentInfo)
|
||||
|
||||
// Fetch the updated deployed state after deployment
|
||||
await refetchDeployedState()
|
||||
|
||||
// No notification on successful deploy
|
||||
} catch (error: any) {
|
||||
logger.error('Error deploying workflow:', { error })
|
||||
} finally {
|
||||
@@ -295,7 +274,6 @@ export function DeployModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle workflow undeployment
|
||||
const handleUndeploy = async () => {
|
||||
try {
|
||||
setIsUndeploying(true)
|
||||
@@ -309,13 +287,8 @@ export function DeployModal({
|
||||
throw new Error(errorData.error || 'Failed to undeploy workflow')
|
||||
}
|
||||
|
||||
// Update deployment status in the store
|
||||
setDeploymentStatus(workflowId, false)
|
||||
|
||||
// Reset chat deployment info
|
||||
setChatExists(false)
|
||||
|
||||
// Close the modal
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
logger.error('Error undeploying workflow:', { error })
|
||||
@@ -324,7 +297,6 @@ export function DeployModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle redeployment of workflow
|
||||
const handleRedeploy = async () => {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
@@ -335,7 +307,7 @@ export function DeployModal({
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deployChatEnabled: false, // Separate chat deployment
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -346,7 +318,6 @@ export function DeployModal({
|
||||
|
||||
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
|
||||
|
||||
// Update deployment status in the store
|
||||
setDeploymentStatus(
|
||||
workflowId,
|
||||
newDeployStatus,
|
||||
@@ -354,13 +325,11 @@ export function DeployModal({
|
||||
apiKey
|
||||
)
|
||||
|
||||
// Reset the needs redeployment flag
|
||||
setNeedsRedeployment(false)
|
||||
if (workflowId) {
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
|
||||
}
|
||||
|
||||
// Fetch the updated deployed state after redeployment
|
||||
await refetchDeployedState()
|
||||
} catch (error: any) {
|
||||
logger.error('Error redeploying workflow:', { error })
|
||||
@@ -369,56 +338,47 @@ export function DeployModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Custom close handler to ensure we clean up loading states
|
||||
const handleCloseModal = () => {
|
||||
setIsSubmitting(false)
|
||||
setChatSubmitting(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// Handle chat form submission
|
||||
const handleChatSubmit = async () => {
|
||||
setChatSubmitting(true)
|
||||
const handleWorkflowPreDeploy = async () => {
|
||||
if (!isDeployed) {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deployApiEnabled: true,
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
// Check if workflow is deployed first
|
||||
if (!isDeployed) {
|
||||
// Deploy workflow first
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deployApiEnabled: true,
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to deploy workflow')
|
||||
}
|
||||
|
||||
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
|
||||
|
||||
// Update the store with the deployment status
|
||||
setDeploymentStatus(
|
||||
workflowId,
|
||||
newDeployStatus,
|
||||
deployedAt ? new Date(deployedAt) : undefined,
|
||||
apiKey
|
||||
)
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to deploy workflow')
|
||||
}
|
||||
|
||||
// Trigger form submission in the ChatDeploy component
|
||||
const form = document.querySelector('.chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error auto-deploying workflow for chat:', { error })
|
||||
setChatSubmitting(false)
|
||||
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
|
||||
|
||||
setDeploymentStatus(
|
||||
workflowId,
|
||||
newDeployStatus,
|
||||
deployedAt ? new Date(deployedAt) : undefined,
|
||||
apiKey
|
||||
)
|
||||
|
||||
setDeploymentInfo((prev) => (prev ? { ...prev, apiKey } : null))
|
||||
}
|
||||
}
|
||||
|
||||
const handleChatFormSubmit = () => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,13 +473,13 @@ export function DeployModal({
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
onPreDeployWorkflow={handleWorkflowPreDeploy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
{activeTab === 'api' && !isDeployed && (
|
||||
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
|
||||
<Button variant='outline' onClick={handleCloseModal}>
|
||||
@@ -558,7 +518,7 @@ export function DeployModal({
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleChatSubmit}
|
||||
onClick={handleChatFormSubmit}
|
||||
disabled={chatSubmitting || !isChatFormValid}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
|
||||
@@ -5,13 +5,6 @@ import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -19,13 +12,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
|
||||
export interface DocumentTag {
|
||||
slot: string
|
||||
displayName: string
|
||||
fieldType: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface DocumentTagRow {
|
||||
id: string
|
||||
cells: {
|
||||
@@ -63,6 +49,8 @@ export function DocumentTagEntry({
|
||||
|
||||
// State for dropdown visibility - one for each row
|
||||
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
|
||||
// State for type dropdown visibility - one for each row
|
||||
const [typeDropdownStates, setTypeDropdownStates] = useState<Record<number, boolean>>({})
|
||||
|
||||
// State for managing tag dropdown
|
||||
const [activeTagDropdown, setActiveTagDropdown] = useState<{
|
||||
@@ -84,7 +72,7 @@ export function DocumentTagEntry({
|
||||
const tagData = JSON.parse(currentValue)
|
||||
if (Array.isArray(tagData) && tagData.length > 0) {
|
||||
return tagData.map((tag: any, index: number) => ({
|
||||
id: `tag-${index}`,
|
||||
id: tag.id || `tag-${index}`,
|
||||
cells: {
|
||||
tagName: tag.tagName || '',
|
||||
type: tag.fieldType || 'text',
|
||||
@@ -100,7 +88,7 @@ export function DocumentTagEntry({
|
||||
// Default: just one empty row
|
||||
return [
|
||||
{
|
||||
id: 'empty-row',
|
||||
id: 'empty-row-0',
|
||||
cells: { tagName: '', type: 'text', value: '' },
|
||||
},
|
||||
]
|
||||
@@ -129,7 +117,8 @@ export function DocumentTagEntry({
|
||||
const handlePreFillTags = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const existingTagRows = tagDefinitions.map((tagDef) => ({
|
||||
const existingTagRows = tagDefinitions.map((tagDef, index) => ({
|
||||
id: `prefill-${tagDef.id}-${index}`,
|
||||
tagName: tagDef.displayName,
|
||||
fieldType: tagDef.fieldType,
|
||||
value: '',
|
||||
@@ -192,6 +181,7 @@ export function DocumentTagEntry({
|
||||
|
||||
// Store all rows including empty ones - don't auto-remove
|
||||
const dataToStore = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
fieldType: row.cells.type || 'text',
|
||||
value: row.cells.value || '',
|
||||
@@ -206,7 +196,8 @@ export function DocumentTagEntry({
|
||||
|
||||
// Get current data and add a new empty row
|
||||
const currentData = currentValue ? JSON.parse(currentValue) : []
|
||||
const newData = [...currentData, { tagName: '', fieldType: 'text', value: '' }]
|
||||
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
|
||||
setStoreValue(JSON.stringify(newData))
|
||||
}
|
||||
|
||||
@@ -216,6 +207,7 @@ export function DocumentTagEntry({
|
||||
|
||||
// Store all remaining rows including empty ones - don't auto-remove
|
||||
const tableDataForStorage = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
fieldType: row.cells.type || 'text',
|
||||
value: row.cells.value || '',
|
||||
@@ -244,8 +236,8 @@ export function DocumentTagEntry({
|
||||
const renderHeader = () => (
|
||||
<thead>
|
||||
<tr className='border-b'>
|
||||
<th className='border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
|
||||
<th className='border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
|
||||
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
|
||||
<th className='w-1/5 border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
|
||||
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -260,35 +252,70 @@ export function DocumentTagEntry({
|
||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
|
||||
const handleDropdownClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isConnecting) {
|
||||
if (!showDropdown) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!disabled && !isConnecting) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => setShowDropdown(false), 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className='relative border-r p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled || isConnecting}
|
||||
className={cn(isDuplicate && 'border-red-500 bg-red-50')}
|
||||
className={cn(
|
||||
'w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
isDuplicate && 'border-red-500 bg-red-50'
|
||||
)}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>{formatDisplayText(cellValue)}</div>
|
||||
</div>
|
||||
{showDropdown && availableTagDefinitions.length > 0 && (
|
||||
<div className='absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover shadow-md'>
|
||||
{availableTagDefinitions
|
||||
.filter((tagDef) =>
|
||||
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
|
||||
)
|
||||
.map((tagDef) => (
|
||||
<div
|
||||
key={tagDef.id}
|
||||
className='cursor-pointer px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={() => {
|
||||
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
|
||||
setShowDropdown(false)
|
||||
}}
|
||||
>
|
||||
{tagDef.displayName}
|
||||
</div>
|
||||
))}
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
className='allow-scroll max-h-48 overflow-y-auto p-1'
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{availableTagDefinitions
|
||||
.filter((tagDef) =>
|
||||
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
|
||||
)
|
||||
.map((tagDef) => (
|
||||
<div
|
||||
key={tagDef.id}
|
||||
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
|
||||
setShowDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{tagDef.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -306,25 +333,77 @@ export function DocumentTagEntry({
|
||||
)
|
||||
const isReadOnly = !!existingTag
|
||||
|
||||
const showTypeDropdown = typeDropdownStates[rowIndex] || false
|
||||
|
||||
const setShowTypeDropdown = (show: boolean) => {
|
||||
setTypeDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
|
||||
const handleTypeDropdownClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isConnecting && !isReadOnly) {
|
||||
if (!showTypeDropdown) {
|
||||
setShowTypeDropdown(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeFocus = () => {
|
||||
if (!disabled && !isConnecting && !isReadOnly) {
|
||||
setShowTypeDropdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeBlur = () => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => setShowTypeDropdown(false), 150)
|
||||
}
|
||||
|
||||
const typeOptions = [{ value: 'text', label: 'Text' }]
|
||||
|
||||
return (
|
||||
<td className='border-r p-1'>
|
||||
<Select
|
||||
value={cellValue}
|
||||
onValueChange={(value) => handleCellChange(rowIndex, 'type', value)}
|
||||
disabled={disabled || isConnecting || isReadOnly}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
isReadOnly && 'bg-gray-50 dark:bg-gray-800',
|
||||
'text-foreground' // Ensure proper text color in dark mode
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
readOnly
|
||||
disabled={disabled || isConnecting || isReadOnly}
|
||||
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
onClick={handleTypeDropdownClick}
|
||||
onFocus={handleTypeFocus}
|
||||
onBlur={handleTypeBlur}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre text-muted-foreground'>
|
||||
{formatDisplayText(cellValue)}
|
||||
</div>
|
||||
</div>
|
||||
{showTypeDropdown && !isReadOnly && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
className='allow-scroll max-h-48 overflow-y-auto p-1'
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{typeOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleCellChange(rowIndex, 'type', option.value)
|
||||
setShowTypeDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@@ -458,8 +537,14 @@ export function DocumentTagEntry({
|
||||
{/* Add Row Button and Tag slots usage indicator */}
|
||||
{!isPreview && !disabled && (
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<Button variant='outline' size='sm' onClick={handleAddRow} disabled={!canAddMoreTags}>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={!canAddMoreTags}
|
||||
className='h-7 px-2 text-xs'
|
||||
>
|
||||
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||
Add Tag
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
|
||||
interface KnowledgeTagFilterProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
isConnecting?: boolean
|
||||
}
|
||||
|
||||
export function KnowledgeTagFilter({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
isConnecting = false,
|
||||
}: KnowledgeTagFilterProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID and document ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseIds')
|
||||
const [knowledgeBaseIdSingleValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const [documentIdValue] = useSubBlockValue(blockId, 'documentId')
|
||||
|
||||
// Determine which knowledge base ID to use
|
||||
const knowledgeBaseId =
|
||||
knowledgeBaseIdSingleValue ||
|
||||
(typeof knowledgeBaseIdValue === 'string' ? knowledgeBaseIdValue.split(',')[0] : null)
|
||||
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading, getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// Parse the current value to extract tag name and value
|
||||
const parseTagFilter = (filterValue: string) => {
|
||||
if (!filterValue) return { tagName: '', tagValue: '' }
|
||||
const [tagName, ...valueParts] = filterValue.split(':')
|
||||
return { tagName: tagName?.trim() || '', tagValue: valueParts.join(':').trim() || '' }
|
||||
}
|
||||
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const { tagName, tagValue } = parseTagFilter(currentValue || '')
|
||||
|
||||
const handleTagNameChange = (newTagName: string) => {
|
||||
if (isPreview) return
|
||||
const newValue =
|
||||
newTagName && tagValue ? `${newTagName}:${tagValue}` : newTagName || tagValue || ''
|
||||
setStoreValue(newValue.trim() || null)
|
||||
}
|
||||
|
||||
const handleTagValueChange = (newTagValue: string) => {
|
||||
if (isPreview) return
|
||||
const newValue =
|
||||
tagName && newTagValue ? `${tagName}:${newTagValue}` : tagName || newTagValue || ''
|
||||
setStoreValue(newValue.trim() || null)
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Tag Filter</Label>
|
||||
<Input
|
||||
value={currentValue || ''}
|
||||
disabled
|
||||
placeholder='Tag filter preview'
|
||||
className='text-sm'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{/* Tag Name Selector */}
|
||||
<Select
|
||||
value={tagName}
|
||||
onValueChange={handleTagNameChange}
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
>
|
||||
<SelectTrigger className='text-sm'>
|
||||
<SelectValue placeholder='Select tag' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tagDefinitions.map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.displayName}>
|
||||
{tag.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Tag Value Input - only show if tag is selected */}
|
||||
{tagName && (
|
||||
<Input
|
||||
value={tagValue}
|
||||
onChange={(e) => handleTagValueChange(e.target.value)}
|
||||
placeholder={`Enter ${tagName} value`}
|
||||
disabled={disabled || isConnecting}
|
||||
className='text-sm'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
@@ -21,6 +17,14 @@ interface TagFilter {
|
||||
tagValue: string
|
||||
}
|
||||
|
||||
interface TagFilterRow {
|
||||
id: string
|
||||
cells: {
|
||||
tagName: string
|
||||
value: string
|
||||
}
|
||||
}
|
||||
|
||||
interface KnowledgeTagFiltersProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
@@ -38,7 +42,7 @@ export function KnowledgeTagFilters({
|
||||
previewValue,
|
||||
isConnecting = false,
|
||||
}: KnowledgeTagFiltersProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
@@ -47,8 +51,20 @@ export function KnowledgeTagFilters({
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// State for managing tag dropdown
|
||||
const [activeTagDropdown, setActiveTagDropdown] = useState<{
|
||||
rowIndex: number
|
||||
showTags: boolean
|
||||
cursorPosition: number
|
||||
activeSourceBlockId: string | null
|
||||
element?: HTMLElement | null
|
||||
} | null>(null)
|
||||
|
||||
// State for dropdown visibility - one for each row
|
||||
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Parse the current value to extract filters
|
||||
const parseFilters = (filterValue: string): TagFilter[] => {
|
||||
const parseFilters = (filterValue: string | null): TagFilter[] => {
|
||||
if (!filterValue) return []
|
||||
try {
|
||||
return JSON.parse(filterValue)
|
||||
@@ -58,7 +74,24 @@ export function KnowledgeTagFilters({
|
||||
}
|
||||
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const filters = parseFilters(currentValue || '')
|
||||
const filters = parseFilters(currentValue || null)
|
||||
|
||||
// Transform filters to table format for display
|
||||
const rows: TagFilterRow[] =
|
||||
filters.length > 0
|
||||
? filters.map((filter) => ({
|
||||
id: filter.id,
|
||||
cells: {
|
||||
tagName: filter.tagName || '',
|
||||
value: filter.tagValue || '',
|
||||
},
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 'empty-row-0',
|
||||
cells: { tagName: '', value: '' },
|
||||
},
|
||||
]
|
||||
|
||||
const updateFilters = (newFilters: TagFilter[]) => {
|
||||
if (isPreview) return
|
||||
@@ -66,21 +99,48 @@ export function KnowledgeTagFilters({
|
||||
setStoreValue(value)
|
||||
}
|
||||
|
||||
const addFilter = () => {
|
||||
const newFilter: TagFilter = {
|
||||
id: Date.now().toString(),
|
||||
tagName: '',
|
||||
tagValue: '',
|
||||
}
|
||||
updateFilters([...filters, newFilter])
|
||||
const handleCellChange = (rowIndex: number, column: string, value: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const updatedRows = [...rows].map((row, idx) => {
|
||||
if (idx === rowIndex) {
|
||||
return {
|
||||
...row,
|
||||
cells: { ...row.cells, [column]: value },
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
// Convert back to TagFilter format - keep all rows, even empty ones
|
||||
const updatedFilters = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
tagValue: row.cells.value || '',
|
||||
}))
|
||||
|
||||
updateFilters(updatedFilters)
|
||||
}
|
||||
|
||||
const removeFilter = (filterId: string) => {
|
||||
updateFilters(filters.filter((f) => f.id !== filterId))
|
||||
const handleAddRow = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
|
||||
updateFilters(newFilters)
|
||||
}
|
||||
|
||||
const updateFilter = (filterId: string, field: keyof TagFilter, value: string) => {
|
||||
updateFilters(filters.map((f) => (f.id === filterId ? { ...f, [field]: value } : f)))
|
||||
const handleDeleteRow = (rowIndex: number) => {
|
||||
if (isPreview || disabled || rows.length <= 1) return
|
||||
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
|
||||
|
||||
const updatedFilters = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
tagValue: row.cells.value || '',
|
||||
}))
|
||||
|
||||
updateFilters(updatedFilters)
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
@@ -88,82 +148,227 @@ export function KnowledgeTagFilters({
|
||||
<div className='space-y-1'>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Tag Filters</Label>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{filters.length > 0 ? `${filters.length} filter(s)` : 'No filters'}
|
||||
{filters.length > 0 ? `${filters.length} filter(s) applied` : 'No filters'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-end'>
|
||||
const renderHeader = () => (
|
||||
<thead>
|
||||
<tr className='border-b'>
|
||||
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
|
||||
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.tagName || ''
|
||||
const showDropdown = dropdownStates[rowIndex] || false
|
||||
|
||||
const setShowDropdown = (show: boolean) => {
|
||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
|
||||
const handleDropdownClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isConnecting && !isLoading) {
|
||||
if (!showDropdown) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!disabled && !isConnecting && !isLoading) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => setShowDropdown(false), 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className='relative border-r p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
readOnly
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
onClick={handleDropdownClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>{formatDisplayText(cellValue || 'Select tag')}</div>
|
||||
</div>
|
||||
{showDropdown && tagDefinitions.length > 0 && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
className='allow-scroll max-h-48 overflow-y-auto p-1'
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{tagDefinitions.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleCellChange(rowIndex, 'tagName', tag.displayName)
|
||||
setShowDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{tag.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.value || ''
|
||||
|
||||
return (
|
||||
<td className='p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPosition = e.target.selectionStart ?? 0
|
||||
|
||||
handleCellChange(rowIndex, 'value', newValue)
|
||||
|
||||
// Check for tag trigger
|
||||
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setActiveTagDropdown({
|
||||
rowIndex,
|
||||
showTags: tagTrigger.show,
|
||||
cursorPosition,
|
||||
activeSourceBlockId: null,
|
||||
element: e.target,
|
||||
})
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (!disabled && !isConnecting) {
|
||||
setActiveTagDropdown({
|
||||
rowIndex,
|
||||
showTags: false,
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
element: e.target,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setActiveTagDropdown(null), 200)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setActiveTagDropdown(null)
|
||||
}
|
||||
}}
|
||||
disabled={disabled || isConnecting}
|
||||
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>{formatDisplayText(cellValue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDeleteButton = (rowIndex: number) => {
|
||||
const canDelete = !isPreview && !disabled
|
||||
|
||||
return canDelete ? (
|
||||
<td className='w-0 p-0'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={addFilter}
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
className='h-6 px-2 text-xs'
|
||||
size='icon'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
||||
onClick={() => handleDeleteRow(rowIndex)}
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Add Filter
|
||||
<Trash2 className='h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</td>
|
||||
) : null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='overflow-visible rounded-md border'>
|
||||
<table className='w-full'>
|
||||
{renderHeader()}
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={row.id} className='group relative border-t'>
|
||||
{renderTagNameCell(row, rowIndex)}
|
||||
{renderValueCell(row, rowIndex)}
|
||||
{renderDeleteButton(rowIndex)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filters.length === 0 && (
|
||||
<div className='py-4 text-center text-muted-foreground text-sm'>
|
||||
No tag filters. Click "Add Filter" to add one.
|
||||
</div>
|
||||
{/* Tag Dropdown */}
|
||||
{activeTagDropdown?.element && (
|
||||
<TagDropdown
|
||||
visible={activeTagDropdown.showTags}
|
||||
onSelect={(newValue) => {
|
||||
handleCellChange(activeTagDropdown.rowIndex, 'value', newValue)
|
||||
setActiveTagDropdown(null)
|
||||
}}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeTagDropdown.activeSourceBlockId}
|
||||
inputValue={rows[activeTagDropdown.rowIndex]?.cells.value || ''}
|
||||
cursorPosition={activeTagDropdown.cursorPosition}
|
||||
onClose={() => {
|
||||
setActiveTagDropdown((prev) => (prev ? { ...prev, showTags: false } : null))
|
||||
}}
|
||||
className='absolute z-[9999] mt-0'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.id} className='flex items-center gap-2 rounded-md border p-2'>
|
||||
{/* Tag Name Selector */}
|
||||
<div className='flex-1'>
|
||||
<Select
|
||||
value={filter.tagName}
|
||||
onValueChange={(value) => updateFilter(filter.id, 'tagName', value)}
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
>
|
||||
<SelectTrigger className='h-8 text-sm'>
|
||||
<SelectValue placeholder='Select tag' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tagDefinitions.map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.displayName}>
|
||||
{tag.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Add Filter Button */}
|
||||
{!isPreview && !disabled && (
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<Button variant='outline' size='sm' onClick={handleAddRow} className='h-7 px-2 text-xs'>
|
||||
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||
Add Filter
|
||||
</Button>
|
||||
|
||||
{/* Tag Value Input */}
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
value={filter.tagValue}
|
||||
onChange={(e) => updateFilter(filter.id, 'tagValue', e.target.value)}
|
||||
placeholder={filter.tagName ? `Enter ${filter.tagName} value` : 'Enter value'}
|
||||
disabled={disabled || isConnecting}
|
||||
className='h-8 text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeFilter(filter.id)}
|
||||
disabled={disabled || isConnecting}
|
||||
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Filter count indicator */}
|
||||
{(() => {
|
||||
const appliedFilters = filters.filter(
|
||||
(f) => f.tagName.trim() || f.tagValue.trim()
|
||||
).length
|
||||
return (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{appliedFilters} filter{appliedFilters !== 1 ? 's' : ''} applied
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { DocumentTagEntry } from './components/document-tag-entry/document-tag-entry'
|
||||
import { KnowledgeTagFilter } from './components/knowledge-tag-filter/knowledge-tag-filter'
|
||||
import { KnowledgeTagFilters } from './components/knowledge-tag-filters/knowledge-tag-filters'
|
||||
|
||||
interface SubBlockProps {
|
||||
@@ -355,17 +354,6 @@ export function SubBlock({
|
||||
previewValue={previewValue}
|
||||
/>
|
||||
)
|
||||
case 'knowledge-tag-filter':
|
||||
return (
|
||||
<KnowledgeTagFilter
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
)
|
||||
case 'knowledge-tag-filters':
|
||||
return (
|
||||
<KnowledgeTagFilters
|
||||
|
||||
@@ -42,7 +42,6 @@ export type SubBlockType =
|
||||
| 'channel-selector' // Channel selector for Slack, Discord, etc.
|
||||
| 'folder-selector' // Folder selector for Gmail, etc.
|
||||
| 'knowledge-base-selector' // Knowledge base selector
|
||||
| 'knowledge-tag-filter' // Dynamic tag filter for knowledge bases
|
||||
| 'knowledge-tag-filters' // Multiple tag filters for knowledge bases
|
||||
| 'document-selector' // Document selector for knowledge bases
|
||||
| 'document-tag-entry' // Document tag entry for creating documents
|
||||
|
||||
Reference in New Issue
Block a user