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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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
@@ -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&apos;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'
@@ -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...',
}}
/>
)
})
@@ -0,0 +1 @@
export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal'
@@ -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>
)
})
@@ -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'
@@ -0,0 +1 @@
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu'
@@ -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>
)
})
@@ -0,0 +1 @@
export { KnowledgeListContextMenu } from './knowledge-list-context-menu'
@@ -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>
)
})