chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Badge, DocumentAttachment, Tooltip } from '@sim/emcn'
|
||||
import { formatAbsoluteDate, formatRelativeTime } from '@sim/utils/formatting'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorMeta } from '@/connectors/types'
|
||||
import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal'
|
||||
import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal'
|
||||
import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu'
|
||||
|
||||
interface BaseCardProps {
|
||||
id?: string
|
||||
title: string
|
||||
docCount: number
|
||||
description: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
connectorTypes?: string[]
|
||||
chunkingConfig?: { maxSize: number; minSize: number; overlap: number }
|
||||
onUpdate?: (id: string, name: string, description: string) => Promise<void>
|
||||
onDelete?: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTOR_TYPES: string[] = []
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for a knowledge base card
|
||||
*/
|
||||
export function BaseCardSkeleton() {
|
||||
return (
|
||||
<div className='group flex h-full cursor-pointer flex-col gap-3 rounded-sm bg-[var(--surface-3)] px-2 py-1.5 transition-colors hover-hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='h-[17px] w-[120px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
<div className='h-[22px] w-[90px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='size-[12px] animate-pulse rounded-xs bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
<div className='h-[15px] w-[45px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
<div className='h-[15px] w-[120px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<div className='flex h-[36px] flex-col gap-1.5'>
|
||||
<div className='h-[15px] w-full animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
<div className='h-[15px] w-[75%] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders multiple knowledge base card skeletons as a fragment
|
||||
*/
|
||||
export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<BaseCardSkeleton key={i} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base card component displaying overview information
|
||||
*/
|
||||
export function BaseCard({
|
||||
id,
|
||||
title,
|
||||
docCount,
|
||||
description,
|
||||
updatedAt,
|
||||
connectorTypes = EMPTY_CONNECTOR_TYPES,
|
||||
chunkingConfig,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: BaseCardProps) {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
menuRef,
|
||||
handleContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const connectorEntries = useMemo(
|
||||
() =>
|
||||
connectorTypes.reduce<{ type: string; config: ConnectorMeta }[]>((acc, type) => {
|
||||
const config = CONNECTOR_META_REGISTRY[type]
|
||||
if (config?.icon) acc.push({ type, config })
|
||||
return acc
|
||||
}, []),
|
||||
[connectorTypes]
|
||||
)
|
||||
const visibleConnectorEntries = useMemo(() => connectorEntries.slice(0, 3), [connectorEntries])
|
||||
const hiddenConnectorLabels = useMemo(
|
||||
() => connectorEntries.slice(3).map(({ type, config }) => config?.name ?? type),
|
||||
[connectorEntries]
|
||||
)
|
||||
const hiddenConnectorCount = hiddenConnectorLabels.length
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
})
|
||||
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
|
||||
|
||||
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
|
||||
|
||||
const navigateToKnowledgeBase = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isContextMenuOpen) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
router.push(href)
|
||||
},
|
||||
[isContextMenuOpen, router, href]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
router.push(href)
|
||||
}
|
||||
},
|
||||
[router, href]
|
||||
)
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
window.open(href, '_blank')
|
||||
}, [href])
|
||||
|
||||
const handleViewTags = useCallback(() => {
|
||||
setIsTagsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!id || !onDelete) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await onDelete(id)
|
||||
setIsDeleteModalOpen(false)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [id, onDelete])
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (knowledgeBaseId: string, name: string, newDescription: string) => {
|
||||
if (!onUpdate) return
|
||||
await onUpdate(knowledgeBaseId, name, newDescription)
|
||||
},
|
||||
[onUpdate]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='h-full cursor-pointer'
|
||||
onClick={navigateToKnowledgeBase}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-kb-card
|
||||
>
|
||||
<div className='group flex h-full flex-col gap-3 rounded-sm bg-[var(--surface-3)] px-2 py-1.5 transition-colors hover-hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<h3 className='min-w-0 flex-1 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
{title}
|
||||
</h3>
|
||||
{shortId && <Badge className='flex-shrink-0 rounded-sm text-caption'>{shortId}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='flex items-center gap-1.5 text-[var(--text-tertiary)] text-caption'>
|
||||
<DocumentAttachment className='size-[12px]' />
|
||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||
</span>
|
||||
{updatedAt && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='text-[var(--text-tertiary)] text-caption'>
|
||||
last updated: {formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<p className='line-clamp-2 h-[36px] flex-1 text-[var(--text-tertiary)] text-caption leading-[18px]'>
|
||||
{description}
|
||||
</p>
|
||||
{connectorEntries.length > 0 && (
|
||||
<div className='[&>*:not(:first-child)]:-ml-1 flex flex-shrink-0 items-center'>
|
||||
{visibleConnectorEntries.map(({ type, config }) => {
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Tooltip.Root key={type}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='flex size-5 flex-shrink-0 items-center justify-center rounded-md border border-[var(--surface-3)] bg-[var(--surface-5)] transition-[border-color] group-hover:border-[var(--surface-4)] dark:border-[var(--surface-4)] dark:group-hover:border-[var(--surface-5)]'>
|
||||
<Icon className='size-[12px] text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{config.name}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})}
|
||||
{hiddenConnectorCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='flex size-5 flex-shrink-0 items-center justify-center rounded-md border border-[var(--surface-3)] bg-[var(--surface-5)] font-medium text-[var(--text-muted)] text-micro transition-[border-color] group-hover:border-[var(--surface-4)] dark:border-[var(--surface-4)] dark:group-hover:border-[var(--surface-5)]'>
|
||||
+{hiddenConnectorCount}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{hiddenConnectorLabels.join(', ')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KnowledgeBaseContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onCopyId={id ? () => navigator.clipboard.writeText(id) : undefined}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
showOpenInNewTab={true}
|
||||
showViewTags={!!id}
|
||||
showEdit={!!onUpdate}
|
||||
showDelete={!!onDelete}
|
||||
disableEdit={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
/>
|
||||
|
||||
{id && onUpdate && (
|
||||
<EditKnowledgeBaseModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
knowledgeBaseId={id}
|
||||
initialName={title}
|
||||
initialDescription={description === 'No description provided' ? '' : description}
|
||||
chunkingConfig={chunkingConfig}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{id && onDelete && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<BaseTagsModal
|
||||
open={isTagsModalOpen}
|
||||
onOpenChange={setIsTagsModalOpen}
|
||||
knowledgeBaseId={id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card'
|
||||
@@ -0,0 +1,20 @@
|
||||
export const filterButtonClass =
|
||||
'w-full justify-between rounded-[10px] border-[var(--surface-6)] bg-[var(--white)] font-normal text-sm dark:border-[var(--border-muted)] dark:bg-[var(--surface-2)]'
|
||||
|
||||
export const dropdownContentClass =
|
||||
'w-[220px] rounded-lg border-[var(--surface-6)] bg-[var(--white)] p-0 shadow-xs dark:border-[var(--border-muted)] dark:bg-[var(--surface-2)]'
|
||||
|
||||
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
|
||||
|
||||
export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'updatedAt-desc', label: 'Last Updated' },
|
||||
{ value: 'createdAt-desc', label: 'Newest First' },
|
||||
{ value: 'createdAt-asc', label: 'Oldest First' },
|
||||
{ value: 'name-asc', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'docCount-desc', label: 'Most Documents' },
|
||||
{ value: 'docCount-asc', label: 'Least Documents' },
|
||||
] as const
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ChipCombobox,
|
||||
ChipInput,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipTextarea,
|
||||
type ComboboxOption,
|
||||
cn,
|
||||
Loader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { type FieldErrors, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import type { StrategyOptions } from '@/lib/chunkers/types'
|
||||
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
|
||||
const logger = createLogger('CreateBaseModal')
|
||||
|
||||
interface CreateBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto (detect from content)' },
|
||||
{ value: 'text', label: 'Text (word boundary splitting)' },
|
||||
{ value: 'recursive', label: 'Recursive (configurable separators)' },
|
||||
{ value: 'sentence', label: 'Sentence' },
|
||||
{ value: 'token', label: 'Token (fixed-size)' },
|
||||
{ value: 'regex', label: 'Regex (custom pattern)' },
|
||||
] as const
|
||||
|
||||
const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({
|
||||
label: o.label,
|
||||
value: o.value,
|
||||
}))
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
||||
description: z
|
||||
.string()
|
||||
.max(
|
||||
KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH,
|
||||
`Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less`
|
||||
)
|
||||
.optional(),
|
||||
minChunkSize: z
|
||||
.number()
|
||||
.min(1, 'Min chunk size must be at least 1 character')
|
||||
.max(2000, 'Min chunk size must be less than 2000 characters'),
|
||||
maxChunkSize: z
|
||||
.number()
|
||||
.min(100, 'Max chunk size must be at least 100 tokens')
|
||||
.max(4000, 'Max chunk size must be less than 4000 tokens'),
|
||||
overlapSize: z
|
||||
.number()
|
||||
.min(0, 'Overlap must be non-negative')
|
||||
.max(500, 'Overlap must be less than 500 tokens'),
|
||||
strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).default('auto'),
|
||||
regexPattern: z.string().optional(),
|
||||
regexStrictBoundaries: z.boolean().default(false),
|
||||
customSeparators: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const maxChunkSizeInChars = data.maxChunkSize * 4
|
||||
return data.minChunkSize < maxChunkSizeInChars
|
||||
},
|
||||
{
|
||||
message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)',
|
||||
path: ['minChunkSize'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.overlapSize < data.maxChunkSize
|
||||
},
|
||||
{
|
||||
message: 'Overlap must be less than max chunk size',
|
||||
path: ['overlapSize'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.strategy === 'regex' && !data.regexPattern?.trim()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Regex pattern is required when using the regex strategy',
|
||||
path: ['regexPattern'],
|
||||
}
|
||||
)
|
||||
|
||||
type FormInputValues = z.input<typeof FormSchema>
|
||||
type FormValues = z.output<typeof FormSchema>
|
||||
|
||||
interface SubmitStatus {
|
||||
type: 'success' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
|
||||
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
const { uploadFiles, isUploading, uploadProgress, uploadError, clearError } = useKnowledgeUpload({
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
clearError()
|
||||
}
|
||||
onOpenChange(open)
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormInputValues, unknown, FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 100,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
strategy: 'auto',
|
||||
regexPattern: '',
|
||||
regexStrictBoundaries: false,
|
||||
customSeparators: '',
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const nameValue = watch('name')
|
||||
const strategyValue = watch('strategy')
|
||||
const regexStrictBoundariesValue = watch('regexStrictBoundaries')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 100,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
strategy: 'auto',
|
||||
regexPattern: '',
|
||||
regexStrictBoundaries: false,
|
||||
customSeparators: '',
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = (selectedFiles: File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
if (!selectedFiles || selectedFiles.length === 0) return
|
||||
|
||||
try {
|
||||
const newFiles: File[] = []
|
||||
let hasError = false
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
const validationError = validateKnowledgeBaseFile(file)
|
||||
if (validationError) {
|
||||
setFileError(validationError)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
newFiles.push(file)
|
||||
}
|
||||
|
||||
if (!hasError && newFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing files:', error)
|
||||
setFileError('An error occurred while processing files. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
createKnowledgeBaseMutation.isPending || deleteKnowledgeBaseMutation.isPending || isUploading
|
||||
|
||||
const onInvalid = (formErrors: FieldErrors<FormInputValues>) => {
|
||||
const firstMessage = Object.values(formErrors).find(
|
||||
(fieldError) => typeof fieldError?.message === 'string'
|
||||
)?.message
|
||||
toast.error(
|
||||
typeof firstMessage === 'string' ? firstMessage : 'Please fix the highlighted fields'
|
||||
)
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
const strategyOptions: StrategyOptions | undefined =
|
||||
data.strategy === 'regex' && data.regexPattern
|
||||
? {
|
||||
pattern: data.regexPattern,
|
||||
...(data.regexStrictBoundaries && { strictBoundaries: true }),
|
||||
}
|
||||
: data.strategy === 'recursive' && data.customSeparators?.trim()
|
||||
? {
|
||||
separators: data.customSeparators
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
chunkingConfig: {
|
||||
maxSize: data.maxChunkSize,
|
||||
minSize: data.minChunkSize,
|
||||
overlap: data.overlapSize,
|
||||
...(data.strategy !== 'auto' && { strategy: data.strategy }),
|
||||
...(strategyOptions && { strategyOptions }),
|
||||
},
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||
recipe: 'default',
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
} catch (uploadError) {
|
||||
logger.error('File upload failed, deleting knowledge base:', uploadError)
|
||||
try {
|
||||
await deleteKnowledgeBaseMutation.mutateAsync({
|
||||
knowledgeBaseId: newKnowledgeBase.id,
|
||||
})
|
||||
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
|
||||
} catch (deleteError) {
|
||||
logger.error('Failed to delete orphaned knowledge base:', deleteError)
|
||||
}
|
||||
throw uploadError
|
||||
}
|
||||
}
|
||||
|
||||
setFiles([])
|
||||
|
||||
handleClose(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
type: 'error',
|
||||
message: getErrorMessage(error, 'An unknown error occurred'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={handleClose} srTitle='Create Knowledge Base' size='lg'>
|
||||
<ChipModalHeader onClose={() => handleClose(false)}>Create Knowledge Base</ChipModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className='flex min-h-0 flex-1 flex-col'>
|
||||
<button type='submit' hidden disabled={isSubmitting || !nameValue?.trim()} />
|
||||
<ChipModalBody>
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
className='-left-[9999px] pointer-events-none absolute opacity-0'
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
<ChipModalField type='custom' title='Name'>
|
||||
<ChipInput
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
error={Boolean(errors.name)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField type='custom' title='Description' error={errors.description?.message}>
|
||||
<ChipTextarea
|
||||
placeholder='Describe this knowledge base (optional)'
|
||||
rows={4}
|
||||
{...register('description')}
|
||||
error={Boolean(errors.description)}
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<ChipModalField type='custom' title='Min Chunk Size (characters)' className='flex-1'>
|
||||
<ChipInput
|
||||
type='number'
|
||||
min={1}
|
||||
max={2000}
|
||||
step={1}
|
||||
placeholder='100'
|
||||
{...register('minChunkSize', { valueAsNumber: true })}
|
||||
error={Boolean(errors.minChunkSize)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField type='custom' title='Max Chunk Size (tokens)' className='flex-1'>
|
||||
<ChipInput
|
||||
type='number'
|
||||
min={100}
|
||||
max={4000}
|
||||
step={1}
|
||||
placeholder='1024'
|
||||
{...register('maxChunkSize', { valueAsNumber: true })}
|
||||
error={Boolean(errors.maxChunkSize)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</div>
|
||||
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Overlap (tokens)'
|
||||
hint='1 token ≈ 4 characters. Max chunk size and overlap are in tokens.'
|
||||
>
|
||||
<ChipInput
|
||||
type='number'
|
||||
min={0}
|
||||
max={500}
|
||||
step={1}
|
||||
placeholder='200'
|
||||
{...register('overlapSize', { valueAsNumber: true })}
|
||||
error={Boolean(errors.overlapSize)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Chunking Strategy'
|
||||
hint='Auto detects the best strategy based on file content type.'
|
||||
>
|
||||
<ChipCombobox
|
||||
options={STRATEGY_COMBOBOX_OPTIONS}
|
||||
value={strategyValue}
|
||||
onChange={(value) => setValue('strategy', value as FormValues['strategy'])}
|
||||
dropdownWidth='trigger'
|
||||
align='start'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
{strategyValue === 'regex' && (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Regex Pattern'
|
||||
error={errors.regexPattern?.message}
|
||||
hint='Text will be split at each match of this regex pattern.'
|
||||
>
|
||||
<ChipInput
|
||||
placeholder='e.g. \\n\\n or (?<=\\})\\s*(?=\\{)'
|
||||
{...register('regexPattern')}
|
||||
error={Boolean(errors.regexPattern)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Chunk Boundaries'
|
||||
hint='Preserve boundaries exactly. Recommended when each match is a discrete record (e.g. one QA pair per chunk).'
|
||||
>
|
||||
<label
|
||||
htmlFor='regexStrictBoundaries'
|
||||
className='flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
<Checkbox
|
||||
id='regexStrictBoundaries'
|
||||
checked={regexStrictBoundariesValue}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('regexStrictBoundaries', checked === true)
|
||||
}
|
||||
/>
|
||||
<span className='text-[var(--text-primary)] text-sm'>
|
||||
Each match is its own chunk (don't merge)
|
||||
</span>
|
||||
</label>
|
||||
</ChipModalField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{strategyValue === 'recursive' && (
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Custom Separators (optional)'
|
||||
hint='Comma-separated list of delimiters in priority order. Leave empty for default separators.'
|
||||
>
|
||||
<ChipInput
|
||||
placeholder='e.g. \n\n, \n, . , '
|
||||
{...register('customSeparators')}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
<ChipModalField
|
||||
type='file'
|
||||
title='Upload Documents'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
multiple
|
||||
onChange={processFiles}
|
||||
description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)'
|
||||
error={fileError}
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<ChipModalField type='custom' title='Selected Files'>
|
||||
<div className='space-y-2'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
const isProcessing = fileStatus?.status === 'uploading'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${file.name}-${file.size}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-sm border p-2',
|
||||
isFailed && 'border-[var(--text-error)]'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-caption',
|
||||
isFailed && 'text-[var(--text-error)]'
|
||||
)}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 text-[var(--text-muted)] text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
{isProcessing ? (
|
||||
<Loader className='size-4 text-[var(--text-muted)]' animate />
|
||||
) : (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='size-4 p-0'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
<ChipModalError>{uploadError?.message || submitStatus?.message}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleClose(false)}
|
||||
cancelDisabled={isSubmitting}
|
||||
primaryAction={{
|
||||
label: isSubmitting
|
||||
? isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||
: uploadProgress.stage === 'processing'
|
||||
? 'Processing...'
|
||||
: 'Creating...'
|
||||
: 'Creating...'
|
||||
: 'Create',
|
||||
onClick: handleSubmit(onSubmit, onInvalid),
|
||||
disabled: isSubmitting || !nameValue?.trim(),
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</ChipModal>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateBaseModal } from './create-base-modal'
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { ChipConfirmModal } from '@sim/emcn'
|
||||
|
||||
interface DeleteKnowledgeBaseModalProps {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Callback when modal should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when delete is confirmed
|
||||
*/
|
||||
onConfirm: () => void
|
||||
/**
|
||||
* Whether the delete operation is in progress
|
||||
*/
|
||||
isDeleting: boolean
|
||||
/**
|
||||
* Name of the knowledge base being deleted
|
||||
*/
|
||||
knowledgeBaseName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation modal for knowledge base items.
|
||||
* Displays a warning message and confirmation buttons.
|
||||
*/
|
||||
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
knowledgeBaseName,
|
||||
}: DeleteKnowledgeBaseModalProps) {
|
||||
return (
|
||||
<ChipConfirmModal
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
srTitle='Delete Knowledge Base'
|
||||
title='Delete Knowledge Base'
|
||||
text={
|
||||
knowledgeBaseName
|
||||
? [
|
||||
'Are you sure you want to delete ',
|
||||
{ text: knowledgeBaseName, bold: true },
|
||||
'? ',
|
||||
{
|
||||
text: 'All associated documents, chunks, and embeddings will be removed.',
|
||||
error: true,
|
||||
},
|
||||
' You can restore it from Recently Deleted in Settings.',
|
||||
]
|
||||
: [
|
||||
'Are you sure you want to delete this knowledge base? ',
|
||||
{
|
||||
text: 'All associated documents, chunks, and embeddings will be removed.',
|
||||
error: true,
|
||||
},
|
||||
' You can restore it from Recently Deleted in Settings.',
|
||||
]
|
||||
}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: onConfirm,
|
||||
pending: isDeleting,
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal'
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
|
||||
import type { ChunkingConfig } from '@/lib/knowledge/types'
|
||||
|
||||
const logger = createLogger('EditKnowledgeBaseModal')
|
||||
|
||||
interface EditKnowledgeBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
initialName: string
|
||||
initialDescription: string
|
||||
chunkingConfig?: ChunkingConfig
|
||||
onSave: (id: string, name: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for editing knowledge base name and description
|
||||
*/
|
||||
export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
initialName,
|
||||
initialDescription,
|
||||
chunkingConfig,
|
||||
onSave,
|
||||
}: EditKnowledgeBaseModalProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [description, setDescription] = useState(initialDescription)
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
const [descriptionError, setDescriptionError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Seed the fields only on the closed → open transition (render-phase reset),
|
||||
* so a prop change while the modal is open never clobbers in-progress edits.
|
||||
*/
|
||||
const prevOpenRef = useRef(open)
|
||||
if (prevOpenRef.current !== open) {
|
||||
prevOpenRef.current = open
|
||||
if (open) {
|
||||
setName(initialName)
|
||||
setDescription(initialDescription)
|
||||
setNameError(null)
|
||||
setDescriptionError(null)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const validate = (): string | null => {
|
||||
let firstError: string | null = null
|
||||
|
||||
if (!name.trim()) {
|
||||
setNameError('Name is required')
|
||||
firstError ??= 'Name is required'
|
||||
} else if (name.trim().length > 100) {
|
||||
setNameError('Name must be less than 100 characters')
|
||||
firstError ??= 'Name must be less than 100 characters'
|
||||
} else {
|
||||
setNameError(null)
|
||||
}
|
||||
|
||||
if (description.length > KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH) {
|
||||
const message = `Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less`
|
||||
setDescriptionError(message)
|
||||
firstError ??= message
|
||||
} else {
|
||||
setDescriptionError(null)
|
||||
}
|
||||
|
||||
return firstError
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validationError = validate()
|
||||
if (validationError) {
|
||||
toast.error(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave(knowledgeBaseId, name.trim(), description.trim())
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Error updating knowledge base:', err)
|
||||
setError(getErrorMessage(err, 'Failed to update knowledge base'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0
|
||||
const isDirty = name !== initialName || description !== initialDescription
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Edit Knowledge Base'>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>Edit Knowledge Base</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder='Enter knowledge base name'
|
||||
required
|
||||
error={nameError ?? undefined}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<ChipModalField
|
||||
type='textarea'
|
||||
title='Description'
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder='Describe this knowledge base (optional)'
|
||||
rows={4}
|
||||
error={descriptionError ?? undefined}
|
||||
/>
|
||||
{chunkingConfig && (
|
||||
<ChipModalField type='custom' title='Chunking Configuration'>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>Max Size</p>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{chunkingConfig.maxSize.toLocaleString()}
|
||||
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
|
||||
tokens
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>Min Size</p>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{chunkingConfig.minSize.toLocaleString()}
|
||||
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
|
||||
chars
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>Overlap</p>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{chunkingConfig.overlap.toLocaleString()}
|
||||
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
|
||||
tokens
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
)}
|
||||
<ChipModalError>{error}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
cancelDisabled={isSubmitting}
|
||||
primaryAction={{
|
||||
label: isSubmitting ? 'Saving...' : 'Save',
|
||||
onClick: handleSubmit,
|
||||
disabled: !isValid || !isDirty || isSubmitting,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal'
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { SVGProps } from 'react'
|
||||
import {
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
SUPPORTED_VIDEO_EXTENSIONS,
|
||||
} from '@/lib/uploads/utils/validation'
|
||||
|
||||
export function PdfIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='4' y='2' width='16' height='20' rx='2' stroke='currentColor' strokeWidth='1.5' />
|
||||
<text
|
||||
x='12'
|
||||
y='12'
|
||||
textAnchor='middle'
|
||||
dominantBaseline='central'
|
||||
fontSize='5.5'
|
||||
fontWeight='bold'
|
||||
fontFamily='Arial, sans-serif'
|
||||
letterSpacing='0.5'
|
||||
fill='currentColor'
|
||||
>
|
||||
PDF
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M16 9H8' />
|
||||
<path d='M16 13H8' />
|
||||
<path d='M16 17H8' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function XlsxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='3' y='3' width='18' height='18' rx='2' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='3' y1='9' x2='21' y2='9' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='3' y1='15' x2='21' y2='15' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='9' y1='3' x2='9' y2='21' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='15' y1='3' x2='15' y2='21' stroke='currentColor' strokeWidth='1.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CsvIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='3' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='13' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='3' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='13' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='3' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='13' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TxtIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M16 13H8' />
|
||||
<path d='M12 17H8' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PptxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<rect x='2' y='4' width='20' height='16' rx='2' />
|
||||
<line x1='6' y1='9' x2='18' y2='9' />
|
||||
<line x1='8' y1='14' x2='16' y2='14' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AudioIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<line x1='4' y1='14' x2='4' y2='10' />
|
||||
<line x1='8' y1='17' x2='8' y2='7' />
|
||||
<line x1='12' y1='15' x2='12' y2='9' />
|
||||
<line x1='16' y1='18' x2='16' y2='6' />
|
||||
<line x1='20' y1='14' x2='20' y2='10' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VideoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='2' y='4' width='20' height='16' rx='2' stroke='currentColor' strokeWidth='1.5' />
|
||||
<path d='M10 9l5 3-5 3V9Z' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HtmlIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M8 8l-4 4 4 4' />
|
||||
<path d='M16 8l4 4-4 4' />
|
||||
<line x1='14' y1='4' x2='10' y2='20' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function JsonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M8 3H7a2 2 0 0 0-2 2v4c0 1.1-.9 2-2 2 1.1 0 2 .9 2 2v4a2 2 0 0 0 2 2h1' />
|
||||
<path d='M16 3h1a2 2 0 0 1 2 2v4c0 1.1.9 2 2 2-1.1 0-2 .9-2 2v4a2 2 0 0 1-2 2h-1' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='2' y='4' width='20' height='16' rx='3' stroke='currentColor' strokeWidth='1.5' />
|
||||
<path
|
||||
d='M6 15V9l3 3.5L12 9v6'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M17 9v6m-2-2l2 2 2-2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DefaultFileIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function getDocumentIcon(
|
||||
mimeType: string,
|
||||
filename: string
|
||||
): (props: SVGProps<SVGSVGElement>) => React.JSX.Element {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (
|
||||
mimeType.startsWith('audio/') ||
|
||||
(extension &&
|
||||
SUPPORTED_AUDIO_EXTENSIONS.includes(extension as (typeof SUPPORTED_AUDIO_EXTENSIONS)[number]))
|
||||
) {
|
||||
return AudioIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType.startsWith('video/') ||
|
||||
(extension &&
|
||||
SUPPORTED_VIDEO_EXTENSIONS.includes(extension as (typeof SUPPORTED_VIDEO_EXTENSIONS)[number]))
|
||||
) {
|
||||
return VideoIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf' || extension === 'pdf') {
|
||||
return PdfIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
mimeType === 'application/msword' ||
|
||||
extension === 'docx' ||
|
||||
extension === 'doc'
|
||||
) {
|
||||
return DocxIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
mimeType === 'application/vnd.ms-excel' ||
|
||||
extension === 'xlsx' ||
|
||||
extension === 'xls'
|
||||
) {
|
||||
return XlsxIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/csv' || extension === 'csv') {
|
||||
return CsvIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/plain' || extension === 'txt') {
|
||||
return TxtIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
mimeType === 'application/vnd.ms-powerpoint' ||
|
||||
extension === 'pptx' ||
|
||||
extension === 'ppt'
|
||||
) {
|
||||
return PptxIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/html' || extension === 'html' || extension === 'htm') {
|
||||
return HtmlIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'application/json' || extension === 'json') {
|
||||
return JsonIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/markdown' || extension === 'md' || extension === 'mdx') {
|
||||
return MarkdownIcon
|
||||
}
|
||||
|
||||
return DefaultFileIcon
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
AudioIcon,
|
||||
CsvIcon,
|
||||
DefaultFileIcon,
|
||||
DocxIcon,
|
||||
getDocumentIcon,
|
||||
HtmlIcon,
|
||||
JsonIcon,
|
||||
MarkdownIcon,
|
||||
PdfIcon,
|
||||
PptxIcon,
|
||||
TxtIcon,
|
||||
VideoIcon,
|
||||
XlsxIcon,
|
||||
} from './document-icons'
|
||||
@@ -0,0 +1,7 @@
|
||||
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card'
|
||||
export { CreateBaseModal } from './create-base-modal'
|
||||
export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal'
|
||||
export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal'
|
||||
export { getDocumentIcon } from './icons'
|
||||
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu'
|
||||
export { KnowledgeListContextMenu } from './knowledge-list-context-menu'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu'
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Duplicate, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
|
||||
|
||||
interface KnowledgeBaseContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onOpenInNewTab?: () => void
|
||||
onViewTags?: () => void
|
||||
onCopyId?: () => void
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
showOpenInNewTab?: boolean
|
||||
showViewTags?: boolean
|
||||
showEdit?: boolean
|
||||
showDelete?: boolean
|
||||
disableEdit?: boolean
|
||||
disableDelete?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for knowledge base cards.
|
||||
* Displays open in new tab, view tags, edit, and delete options.
|
||||
*/
|
||||
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onViewTags,
|
||||
onCopyId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showOpenInNewTab = true,
|
||||
showViewTags = true,
|
||||
showEdit = true,
|
||||
showDelete = true,
|
||||
disableEdit = false,
|
||||
disableDelete = false,
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
|
||||
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId
|
||||
const hasEditSection = showEdit && !!onEdit
|
||||
const hasDestructiveSection = showDelete && !!onDelete
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{hasNavigationSection && (
|
||||
<DropdownMenuItem onSelect={onOpenInNewTab!}>
|
||||
<SquareArrowUpRight />
|
||||
Open in new tab
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasNavigationSection && (hasInfoSection || hasEditSection || hasDestructiveSection) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
|
||||
{showViewTags && onViewTags && (
|
||||
<DropdownMenuItem onSelect={onViewTags}>
|
||||
<TagIcon />
|
||||
View tags
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCopyId && (
|
||||
<DropdownMenuItem onSelect={onCopyId}>
|
||||
<Duplicate />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasInfoSection && (hasEditSection || hasDestructiveSection) && <DropdownMenuSeparator />}
|
||||
|
||||
{showEdit && onEdit && (
|
||||
<DropdownMenuItem disabled={disableEdit} onSelect={onEdit}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasEditSection && hasDestructiveSection && <DropdownMenuSeparator />}
|
||||
{showDelete && onDelete && (
|
||||
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { KnowledgeListContextMenu } from './knowledge-list-context-menu'
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
|
||||
import { Plus } from '@sim/emcn/icons'
|
||||
|
||||
interface KnowledgeListContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onAddKnowledgeBase?: () => void
|
||||
disableAdd?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for the knowledge base list page.
|
||||
* Displays "Add knowledge base" option when right-clicking on empty space.
|
||||
*/
|
||||
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onAddKnowledgeBase,
|
||||
disableAdd = false,
|
||||
}: KnowledgeListContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{onAddKnowledgeBase && (
|
||||
<DropdownMenuItem disabled={disableAdd} onSelect={onAddKnowledgeBase}>
|
||||
<Plus />
|
||||
Add knowledge base
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user