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,152 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Duplicate, Eye, Pencil, Plus, SquareArrowUpRight, Trash } from '@sim/emcn/icons'
interface ChunkContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpenInNewTab?: () => void
onEdit?: () => void
onCopyContent?: () => void
onToggleEnabled?: () => void
onDelete?: () => void
onAddChunk?: () => void
isChunkEnabled?: boolean
hasChunk: boolean
disableToggleEnabled?: boolean
disableDelete?: boolean
disableAddChunk?: boolean
disableEdit?: boolean
isConnectorDocument?: boolean
selectedCount?: number
enabledCount?: number
disabledCount?: number
}
/**
* Context menu for chunks table.
* Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space.
* Supports batch operations when multiple chunks are selected.
*/
export function ChunkContextMenu({
isOpen,
position,
onClose,
onOpenInNewTab,
onEdit,
onCopyContent,
onToggleEnabled,
onDelete,
onAddChunk,
isChunkEnabled = true,
hasChunk,
disableToggleEnabled = false,
disableDelete = false,
disableAddChunk = false,
disableEdit = false,
isConnectorDocument = false,
selectedCount = 1,
enabledCount = 0,
disabledCount = 0,
}: ChunkContextMenuProps) {
const isMultiSelect = selectedCount > 1
const getToggleLabel = () => {
if (isMultiSelect) {
if (disabledCount > 0) return 'Enable'
return 'Disable'
}
return isChunkEnabled ? 'Disable' : 'Enable'
}
const hasNavigationSection = !isMultiSelect && !!onOpenInNewTab
const hasEditSection = !isMultiSelect && (!!onEdit || !!onCopyContent)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!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()}
>
{hasChunk ? (
<>
{hasNavigationSection && (
<DropdownMenuItem onSelect={onOpenInNewTab!}>
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}
{hasNavigationSection &&
(hasEditSection || hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}
{!isMultiSelect && onEdit && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEdit}>
<Pencil />
{isConnectorDocument ? 'View' : 'Edit'}
</DropdownMenuItem>
)}
{!isMultiSelect && onCopyContent && (
<DropdownMenuItem onSelect={onCopyContent}>
<Duplicate />
Copy content
</DropdownMenuItem>
)}
{hasEditSection && (hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}
{onToggleEnabled && (
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
<Eye />
{getToggleLabel()}
</DropdownMenuItem>
)}
{hasStateSection && hasDestructiveSection && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
)}
</>
) : (
onAddChunk && (
<DropdownMenuItem disabled={disableAddChunk} onSelect={onAddChunk}>
<Plus />
Create chunk
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { ChunkContextMenu } from './chunk-context-menu'
@@ -0,0 +1,300 @@
'use client'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { handleKeyboardActivation, Label, Switch, toast } from '@sim/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
import { useAutosave } from '@/hooks/use-autosave'
const TOKEN_BG_COLORS = [
'rgba(239, 68, 68, 0.55)',
'rgba(249, 115, 22, 0.55)',
'rgba(234, 179, 8, 0.55)',
'rgba(132, 204, 22, 0.55)',
'rgba(34, 197, 94, 0.55)',
'rgba(20, 184, 166, 0.55)',
'rgba(6, 182, 212, 0.55)',
'rgba(59, 130, 246, 0.55)',
'rgba(139, 92, 246, 0.55)',
'rgba(217, 70, 239, 0.55)',
] as const
interface ChunkEditorProps {
mode?: 'edit' | 'create'
chunk?: ChunkData
document: DocumentData
knowledgeBaseId: string
canEdit: boolean
maxChunkSize?: number
onDirtyChange: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef: React.MutableRefObject<(() => Promise<void>) | null>
onCreated?: (chunkId: string) => void
}
export function ChunkEditor({
mode = 'edit',
chunk,
document: documentData,
knowledgeBaseId,
canEdit,
maxChunkSize,
onDirtyChange,
onSaveStatusChange,
saveRef,
onCreated,
}: ChunkEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const tokenizedScrollRef = useRef<HTMLDivElement>(null)
const preservedScrollTopRef = useRef(0)
const { mutateAsync: updateChunk } = useUpdateChunk()
const { mutateAsync: createChunk } = useCreateChunk()
const isCreateMode = mode === 'create'
const chunkContent = chunk?.content ?? ''
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
const [savedContent, setSavedContent] = useState(chunkContent)
const validationToastIdRef = useRef<string | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const savedContentRef = useRef(chunkContent)
const editedContentRef = useRef(editedContent)
editedContentRef.current = editedContent
useEffect(() => {
if (isCreateMode || !chunk?.id) return
const controller = new AbortController()
const chunkId = chunk.id
const handleVisibility = async () => {
if (document.visibilityState !== 'visible') return
try {
const json = await requestJson(getKnowledgeChunkContract, {
params: { id: knowledgeBaseId, documentId: documentData.id, chunkId },
signal: controller.signal,
})
const serverContent = json.data.content ?? ''
if (serverContent === savedContentRef.current) return
const isClean = editedContentRef.current === savedContentRef.current
savedContentRef.current = serverContent
setSavedContent(serverContent)
if (isClean) {
setEditedContent(serverContent)
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
if (isApiClientError(err)) return
}
}
document.addEventListener('visibilitychange', handleVisibility)
return () => {
document.removeEventListener('visibilitychange', handleVisibility)
controller.abort()
}
}, [isCreateMode, chunk?.id, knowledgeBaseId, documentData.id])
useEffect(() => {
if (isCreateMode && textareaRef.current) {
textareaRef.current.focus()
}
}, [isCreateMode])
const isConnectorDocument = Boolean(documentData.connectorId)
const handleSave = useCallback(async () => {
const content = editedContentRef.current
const trimmed = content.trim()
/** Toast every failed attempt, replacing the previous validation toast so retries refresh instead of stack. */
const failValidation = (message: string): never => {
if (validationToastIdRef.current) toast.dismiss(validationToastIdRef.current)
validationToastIdRef.current = toast.error(message)
throw new Error(message)
}
if (trimmed.length === 0) failValidation('Content cannot be empty')
if (trimmed.length > 10000) failValidation('Content exceeds maximum length (10,000 characters)')
if (validationToastIdRef.current) {
toast.dismiss(validationToastIdRef.current)
validationToastIdRef.current = null
}
if (isCreateMode) {
const created = await createChunk({
knowledgeBaseId,
documentId: documentData.id,
content: trimmed,
enabled: true,
})
onCreated?.(created.id)
} else {
if (!chunk?.id) return
await updateChunk({
knowledgeBaseId,
documentId: documentData.id,
chunkId: chunk.id,
content: trimmed,
})
savedContentRef.current = content
setSavedContent(content)
}
}, [
isCreateMode,
chunk?.id,
knowledgeBaseId,
documentData.id,
updateChunk,
createChunk,
onCreated,
])
const {
saveStatus,
saveImmediately,
isDirty: autosaveDirty,
} = useAutosave({
content: editedContent,
savedContent,
onSave: handleSave,
enabled: canEdit && !isCreateMode && !isConnectorDocument,
})
const isDirty = isCreateMode ? editedContent.trim().length > 0 : autosaveDirty
useEffect(() => {
onDirtyChange(isDirty)
}, [isDirty, onDirtyChange])
useEffect(() => {
onSaveStatusChange?.(saveStatus)
}, [saveStatus, onSaveStatusChange])
const saveFunction = isCreateMode ? handleSave : saveImmediately
if (saveRef) saveRef.current = saveFunction
useEffect(
() => () => {
if (saveRef) saveRef.current = null
},
[saveRef]
)
const hasToggledTokenizerRef = useRef(false)
const handleTokenizerChange = useCallback(
(value: boolean) => {
const source = tokenizerOn ? tokenizedScrollRef.current : textareaRef.current
preservedScrollTopRef.current = source?.scrollTop ?? 0
hasToggledTokenizerRef.current = true
setTokenizerOn(value)
},
[tokenizerOn]
)
useLayoutEffect(() => {
if (!hasToggledTokenizerRef.current) return
const target = tokenizerOn ? tokenizedScrollRef.current : textareaRef.current
if (target) target.scrollTop = preservedScrollTopRef.current
}, [tokenizerOn])
const tokenStrings = useMemo(() => {
if (!tokenizerOn || !editedContent) return []
return getTokenStrings(editedContent)
}, [editedContent, tokenizerOn])
const tokenCount = useMemo(() => {
if (!editedContent) return 0
if (tokenizerOn) return tokenStrings.length
return getAccurateTokenCount(editedContent)
}, [editedContent, tokenizerOn, tokenStrings])
return (
<div className='flex flex-1 flex-col overflow-hidden'>
<div
role='group'
aria-label='Chunk content editor'
className='flex min-h-0 flex-1 cursor-text flex-col overflow-hidden'
onClick={(e) => {
if (e.target === e.currentTarget) textareaRef.current?.focus()
}}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
handleKeyboardActivation(event, () => textareaRef.current?.focus())
}}
>
{tokenizerOn ? (
<div
ref={tokenizedScrollRef}
className='mx-auto h-full w-full max-w-[48rem] cursor-default overflow-y-auto whitespace-pre-wrap break-words px-8 py-6 font-sans text-[var(--text-body)] text-sm'
>
{tokenStrings.map((token, index) => (
<span
key={index}
style={{ backgroundColor: TOKEN_BG_COLORS[index % TOKEN_BG_COLORS.length] }}
onMouseEnter={() => setHoveredTokenIndex(index)}
onMouseLeave={() => setHoveredTokenIndex(null)}
>
{token}
</span>
))}
</div>
) : (
<textarea
ref={textareaRef}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
isCreateMode
? 'Enter the content for this chunk...'
: canEdit
? 'Enter chunk content...'
: isConnectorDocument
? 'This chunk is synced from a connector and cannot be edited'
: 'Read-only view'
}
className='mx-auto min-h-0 w-full max-w-[48rem] flex-1 resize-none border-0 bg-transparent px-8 py-6 font-sans text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-subtle)]'
disabled={!canEdit}
readOnly={!canEdit}
spellCheck={false}
/>
)}
</div>
<div className='flex items-center justify-between border-[var(--border)] border-t px-6 py-2.5'>
<TokenizerToggle
checked={tokenizerOn}
onCheckedChange={handleTokenizerChange}
hoveredTokenIndex={tokenizerOn ? hoveredTokenIndex : null}
/>
<span className='text-[var(--text-secondary)] text-caption'>
{tokenCount.toLocaleString()}
{maxChunkSize !== undefined && `/${maxChunkSize.toLocaleString()}`} tokens
</span>
</div>
</div>
)
}
const TokenizerToggle = React.memo(function TokenizerToggle({
checked,
onCheckedChange,
hoveredTokenIndex,
}: {
checked: boolean
onCheckedChange: (value: boolean) => void
hoveredTokenIndex: number | null
}) {
return (
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-caption'>Tokenizer</Label>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
{checked && hoveredTokenIndex !== null && (
<span className='text-[var(--text-tertiary)] text-caption'>
Token #{hoveredTokenIndex + 1}
</span>
)}
</div>
)
})
@@ -0,0 +1 @@
export { ChunkEditor } from './chunk-editor'
@@ -0,0 +1,47 @@
'use client'
import { ChipConfirmModal } from '@sim/emcn'
import type { ChunkData } from '@/lib/knowledge/types'
import { useDeleteChunk } from '@/hooks/queries/kb/knowledge'
interface DeleteChunkModalProps {
chunk: ChunkData | null
knowledgeBaseId: string
documentId: string
isOpen: boolean
onClose: () => void
}
export function DeleteChunkModal({
chunk,
knowledgeBaseId,
documentId,
isOpen,
onClose,
}: DeleteChunkModalProps) {
const { mutate: deleteChunk, isPending: isDeleting } = useDeleteChunk()
const handleDeleteChunk = () => {
if (!chunk || isDeleting) return
deleteChunk({ knowledgeBaseId, documentId, chunkId: chunk.id }, { onSuccess: onClose })
}
if (!chunk) return null
return (
<ChipConfirmModal
open={isOpen}
onOpenChange={onClose}
srTitle='Delete Chunk'
title='Delete Chunk'
text='Are you sure you want to delete this chunk? This action cannot be undone.'
confirm={{
label: 'Delete',
onClick: handleDeleteChunk,
pending: isDeleting,
pendingLabel: 'Deleting...',
}}
/>
)
}
@@ -0,0 +1 @@
export { DeleteChunkModal } from './delete-chunk-modal'
@@ -0,0 +1,748 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Button,
ChipCombobox,
ChipDatePicker,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
handleKeyboardActivation,
Label,
Trash,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import type { DocumentTag } from '@/lib/knowledge/tags/types'
import type { DocumentData } from '@/lib/knowledge/types'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
import { useNextAvailableSlotMutation, useUpdateDocumentTags } from '@/hooks/queries/kb/knowledge'
const logger = createLogger('DocumentTagsModal')
/** Field type display labels */
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Boolean',
}
/**
* Gets the appropriate value when changing field types.
* Clears value when type changes to allow placeholder to show.
*/
function getValueForFieldType(
newFieldType: string,
currentFieldType: string,
currentValue: string
): string {
return newFieldType === currentFieldType ? currentValue : ''
}
/** Format value for display based on field type */
function formatValueForDisplay(value: string, fieldType: string): string {
if (!value) return ''
switch (fieldType) {
case 'boolean':
return value === 'true' ? 'True' : 'False'
case 'date':
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
return formatDate(new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
return formatDate(date)
} catch {
return value
}
default:
return value
}
}
interface DocumentTagsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
documentId: string
documentData: DocumentData | null
onDocumentUpdate?: (updates: Record<string, string>) => void
}
export function DocumentTagsModal({
open,
onOpenChange,
knowledgeBaseId,
documentId,
documentData,
onDocumentUpdate,
}: DocumentTagsModalProps) {
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { mutateAsync: getServerNextSlot } = useNextAvailableSlotMutation()
const { mutateAsync: updateDocumentTags } = useUpdateDocumentTags()
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [isSavingTag, setIsSavingTag] = useState(false)
const [editTagForm, setEditTagForm] = useState({
displayName: '',
fieldType: 'text',
value: '',
})
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
const tags: DocumentTag[] = []
ALL_TAG_SLOTS.forEach((slot) => {
const rawValue = docData[slot]
const definition = definitions.find((def) => def.tagSlot === slot)
if (rawValue !== null && rawValue !== undefined && definition) {
const stringValue = String(rawValue).trim()
if (stringValue) {
tags.push({
slot,
displayName: definition.displayName,
fieldType: definition.fieldType,
value: stringValue,
})
}
}
})
return tags
}, [])
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
setDocumentTags(newTags)
}, [])
const handleSaveDocumentTags = useCallback(
async (tagsToSave: DocumentTag[]) => {
if (!documentData) return
const tagData: Record<string, string> = {}
ALL_TAG_SLOTS.forEach((slot) => {
const tag = tagsToSave.find((t) => t.slot === slot)
if (tag?.value.trim()) {
tagData[slot] = tag.value.trim()
} else {
tagData[slot] = ''
}
})
await updateDocumentTags({
knowledgeBaseId,
documentId,
tags: tagData,
})
onDocumentUpdate?.(tagData)
await fetchTagDefinitions()
},
[
documentData,
knowledgeBaseId,
documentId,
updateDocumentTags,
fetchTagDefinitions,
onDocumentUpdate,
]
)
const handleRemoveTag = async (index: number) => {
const updatedTags = documentTags.filter((_, i) => i !== index)
handleTagsChange(updatedTags)
try {
await handleSaveDocumentTags(updatedTags)
} catch (error) {
logger.error('Error removing tag:', error)
}
}
const startEditingTag = (index: number) => {
const tag = documentTags[index]
setEditingTagIndex(index)
setEditTagForm({
displayName: tag.displayName,
fieldType: tag.fieldType,
value: tag.value,
})
setIsCreatingTag(false)
}
const openTagCreator = () => {
setEditingTagIndex(null)
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
setIsCreatingTag(true)
}
const cancelEditingTag = () => {
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
setEditingTagIndex(null)
setIsCreatingTag(false)
}
const hasTagNameConflict = (name: string) => {
if (!name.trim()) return false
return documentTags.some((tag, index) => {
if (editingTagIndex !== null && index === editingTagIndex) {
return false
}
return tag.displayName.toLowerCase() === name.trim().toLowerCase()
})
}
const availableDefinitions = kbTagDefinitions.filter((def) => {
return !documentTags.some(
(tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase()
)
})
const tagNameOptions = availableDefinitions.map((def) => ({
label: def.displayName,
value: def.displayName,
}))
const saveDocumentTag = async () => {
if (!editTagForm.displayName.trim() || !editTagForm.value.trim()) return
const formData = { ...editTagForm }
const currentEditingIndex = editingTagIndex
const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null
setEditingTagIndex(null)
setIsCreatingTag(false)
setIsSavingTag(true)
try {
let targetSlot: string
if (currentEditingIndex !== null && originalTag) {
targetSlot = originalTag.slot
} else {
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
)
if (existingDefinition) {
targetSlot = existingDefinition.tagSlot
} else {
targetSlot = await getServerNextSlot({
knowledgeBaseId,
fieldType: formData.fieldType,
})
}
}
let updatedTags: DocumentTag[]
if (currentEditingIndex !== null) {
updatedTags = [...documentTags]
updatedTags[currentEditingIndex] = {
...updatedTags[currentEditingIndex],
displayName: formData.displayName,
fieldType: formData.fieldType,
value: formData.value,
}
} else {
const newTag: DocumentTag = {
slot: targetSlot,
displayName: formData.displayName,
fieldType: formData.fieldType,
value: formData.value,
}
updatedTags = [...documentTags, newTag]
}
handleTagsChange(updatedTags)
if (currentEditingIndex !== null && originalTag) {
const currentDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase()
)
if (currentDefinition) {
const updatedDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: currentDefinition.fieldType,
tagSlot: currentDefinition.tagSlot,
_originalDisplayName: originalTag.displayName,
}
if (saveTagDefinitions) {
await saveTagDefinitions([updatedDefinition])
}
await refreshTagDefinitions()
}
} else {
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
)
if (!existingDefinition) {
const newDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: formData.fieldType,
tagSlot: targetSlot as AllTagSlot,
}
if (saveTagDefinitions) {
await saveTagDefinitions([newDefinition])
}
await refreshTagDefinitions()
}
}
await handleSaveDocumentTags(updatedTags)
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
} catch (error) {
logger.error('Error saving tag:', error)
} finally {
setIsSavingTag(false)
}
}
const isTagEditing = editingTagIndex !== null || isCreatingTag
const tagNameConflict = hasTagNameConflict(editTagForm.displayName)
const hasTagChanges = () => {
if (editingTagIndex === null) return true
const originalTag = documentTags[editingTagIndex]
if (!originalTag) return true
return (
originalTag.displayName !== editTagForm.displayName ||
originalTag.value !== editTagForm.value ||
originalTag.fieldType !== editTagForm.fieldType
)
}
const canSaveTag =
editTagForm.displayName.trim() &&
editTagForm.value.trim() &&
!tagNameConflict &&
hasTagChanges()
const canAddNewTag = kbTagDefinitions.length < MAX_TAG_SLOTS || availableDefinitions.length > 0
useEffect(() => {
if (documentData && tagDefinitions && !isSavingTag) {
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions)
setDocumentTags(rebuiltTags)
}
}, [documentData, tagDefinitions, buildDocumentTags, isSavingTag])
const handleClose = (openState: boolean) => {
if (!openState) {
setIsCreatingTag(false)
setEditingTagIndex(null)
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
}
onOpenChange(openState)
}
return (
<ChipModal open={open} onOpenChange={handleClose} srTitle='Document Tags' size='sm'>
<ChipModalHeader onClose={() => handleClose(false)}>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
</div>
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title='Tags'>
<div className='space-y-2'>
{documentTags.map((tag, index) => (
<div key={tag.displayName} className='space-y-2'>
<div
role='button'
tabIndex={0}
className='flex cursor-pointer items-center gap-2 rounded-sm border p-2 hover-hover:bg-[var(--surface-2)]'
onClick={() => startEditingTag(index)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
handleKeyboardActivation(event, () => startEditingTag(index))
}}
>
<span className='min-w-0 truncate text-[var(--text-primary)] text-caption'>
{tag.displayName}
</span>
<span className='rounded-[3px] bg-[var(--surface-3)] px-1.5 py-0.5 text-[var(--text-muted)] text-micro'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[var(--border-1)]' />
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'>
{formatValueForDisplay(tag.value, tag.fieldType)}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleRemoveTag(index)
}}
className='size-4 p-0 text-[var(--text-muted)] hover-hover:text-[var(--text-error)]'
>
<Trash className='size-3' />
</Button>
</div>
</div>
{editingTagIndex === index && (
<div className='space-y-2 rounded-md border p-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor={`tagName-${index}`}>Tag Name</Label>
{availableDefinitions.length > 0 ? (
<ChipCombobox
id={`tagName-${index}`}
options={tagNameOptions}
value={editTagForm.displayName}
selectedValue={editTagForm.displayName}
onChange={(value) => {
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
const newFieldType = def?.fieldType || 'text'
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: newFieldType,
value: getValueForFieldType(
newFieldType,
editTagForm.fieldType,
editTagForm.value
),
})
}}
placeholder='Enter or select tag name'
editable={true}
/>
) : (
<ChipInput
id={`tagName-${index}`}
value={editTagForm.displayName}
onChange={(e) =>
setEditTagForm({ ...editTagForm, displayName: e.target.value })
}
placeholder='Enter tag name'
error={tagNameConflict}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
{tagNameConflict && (
<span className='text-[var(--text-error)] text-caption'>
A tag with this name already exists
</span>
)}
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor={`tagValue-${index}`}>Value</Label>
{editTagForm.fieldType === 'boolean' ? (
<ChipCombobox
id={`tagValue-${index}`}
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
value={editTagForm.value}
selectedValue={editTagForm.value}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select value'
/>
) : editTagForm.fieldType === 'number' ? (
<ChipInput
id={`tagValue-${index}`}
value={editTagForm.value}
onChange={(e) => {
const val = e.target.value
// Allow empty, digits, decimal point, and negative sign
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
setEditTagForm({ ...editTagForm, value: val })
}
}}
placeholder='Enter number'
inputMode='decimal'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
) : editTagForm.fieldType === 'date' ? (
<ChipDatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
fullWidth
/>
) : (
<ChipInput
id={`tagValue-${index}`}
value={editTagForm.value}
onChange={(e) =>
setEditTagForm({ ...editTagForm, value: e.target.value })
}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
</div>
<div className='flex gap-2'>
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
<Button
variant='primary'
onClick={saveDocumentTag}
className='flex-1'
disabled={!canSaveTag}
>
Save Changes
</Button>
</div>
</div>
)}
</div>
))}
{documentTags.length > 0 && !isTagEditing && (
<Button
variant='default'
onClick={openTagCreator}
disabled={!canAddNewTag}
className='w-full'
>
Add Tag
</Button>
)}
{(isCreatingTag || documentTags.length === 0) && editingTagIndex === null && (
<div className='space-y-2 rounded-md border p-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor='newTagName'>Tag Name</Label>
{tagNameOptions.length > 0 ? (
<ChipCombobox
id='newTagName'
options={tagNameOptions}
value={editTagForm.displayName}
selectedValue={editTagForm.displayName}
onChange={(value) => {
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
const newFieldType = def?.fieldType || 'text'
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: newFieldType,
value: getValueForFieldType(
newFieldType,
editTagForm.fieldType,
editTagForm.value
),
})
}}
placeholder='Enter or select tag name'
editable={true}
/>
) : (
<ChipInput
id='newTagName'
value={editTagForm.displayName}
onChange={(e) =>
setEditTagForm({ ...editTagForm, displayName: e.target.value })
}
placeholder='Enter tag name'
error={tagNameConflict}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
{tagNameConflict && (
<span className='text-[var(--text-error)] text-caption'>
A tag with this name already exists
</span>
)}
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='newTagValue'>Value</Label>
{editTagForm.fieldType === 'boolean' ? (
<ChipCombobox
id='newTagValue'
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
value={editTagForm.value}
selectedValue={editTagForm.value}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select value'
/>
) : editTagForm.fieldType === 'number' ? (
<ChipInput
id='newTagValue'
value={editTagForm.value}
onChange={(e) => {
const val = e.target.value
// Allow empty, digits, decimal point, and negative sign
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
setEditTagForm({ ...editTagForm, value: val })
}
}}
placeholder='Enter number'
inputMode='decimal'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
) : editTagForm.fieldType === 'date' ? (
<ChipDatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
fullWidth
/>
) : (
<ChipInput
id='newTagValue'
value={editTagForm.value}
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
</div>
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
!kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase()
) && (
<Badge variant='amber' size='lg' dot className='max-w-full'>
Maximum tag definitions reached. You can still use existing tag definitions,
but cannot create new ones.
</Badge>
)}
<div className='flex gap-2'>
{documentTags.length > 0 && (
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
)}
<Button
variant='primary'
onClick={saveDocumentTag}
className='flex-1'
disabled={
!canSaveTag ||
isSavingTag ||
(kbTagDefinitions.length >= MAX_TAG_SLOTS &&
!kbTagDefinitions.find(
(def) =>
def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase()
))
}
>
{isSavingTag ? 'Creating...' : 'Create Tag'}
</Button>
</div>
</div>
)}
</div>
</ChipModalField>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleClose(false)}
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { DocumentTagsModal } from './document-tags-modal'
@@ -0,0 +1,4 @@
export { ChunkContextMenu } from './chunk-context-menu'
export { ChunkEditor } from './chunk-editor'
export { DeleteChunkModal } from './delete-chunk-modal'
export { DocumentTagsModal } from './document-tags-modal'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
'use client'
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import { FileText } from 'lucide-react'
import {
type BreadcrumbItem,
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const COLUMNS = [
{ id: 'content', header: 'Content' },
{ id: 'index', header: 'Index', widthMultiplier: 0.6 },
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
{ id: 'status', header: 'Status', widthMultiplier: 0.75 },
]
const ACTIONS: ChromeActionSpec[] = [{ text: 'New chunk', icon: Plus, variant: 'primary' }]
const BREADCRUMBS: BreadcrumbItem[] = [
{ label: 'Knowledge Base', icon: Database, onClick: noop },
{ label: '…', icon: Database },
{ label: '…', terminal: true },
]
export default function DocumentLoading() {
return (
<ResourceChromeFallback
icon={FileText}
breadcrumbs={BREADCRUMBS}
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search chunks...'
hasSort
hasFilter
/>
)
}
@@ -0,0 +1,36 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
interface DocumentPageProps {
params: Promise<{
id: string
documentId: string
}>
searchParams: Promise<{
kbName?: string
docName?: string
}>
}
export async function generateMetadata({ searchParams }: DocumentPageProps): Promise<Metadata> {
const { docName, kbName } = await searchParams
const title = docName || 'Document'
const parentName = kbName || 'Knowledge Base'
return { title: `${title}${parentName}` }
}
export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) {
const [{ id, documentId }, { kbName, docName }] = await Promise.all([params, searchParams])
return (
<Suspense fallback={null}>
<Document
knowledgeBaseId={id}
documentId={documentId}
knowledgeBaseName={kbName || 'Knowledge Base'}
documentName={docName || 'Document'}
/>
</Suspense>
)
}
@@ -0,0 +1,26 @@
import { parseAsInteger, parseAsString } from 'nuqs/server'
/**
* Co-located, typed URL query-param definitions for the knowledge document
* (chunk list + inline chunk editor) page. The client (`Document`) consumes this
* typed param definition as the single source of truth.
*
* - `page` is the chunk pagination page, shareable and bookmarkable. It defaults
* to 1 and clears from the URL at the default to keep links clean.
* - `chunk` deep-links a specific chunk so it can be focused/opened in the inline
* editor from a shared link.
*/
export const documentParsers = {
page: parseAsInteger.withDefault(1),
chunk: parseAsString,
} as const
/**
* Shared nuqs options for the document page. Pagination is a transient view
* change, so it replaces history rather than churning the back stack; defaults
* clear from the URL.
*/
export const documentUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
import { Button, cn, Tooltip, Trash2 } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Circle, CircleOff } from 'lucide-react'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
interface ActionBarProps {
selectedCount: number
onEnable?: () => void
onDisable?: () => void
onDelete?: () => void
enabledCount?: number
disabledCount?: number
isLoading?: boolean
className?: string
totalCount?: number
isAllPageSelected?: boolean
isAllSelected?: boolean
onSelectAll?: () => void
onClearSelectAll?: () => void
}
export function ActionBar({
selectedCount,
onEnable,
onDisable,
onDelete,
enabledCount = 0,
disabledCount = 0,
isLoading = false,
className,
totalCount = 0,
isAllPageSelected = false,
isAllSelected = false,
onSelectAll,
onClearSelectAll,
}: ActionBarProps) {
const userPermissions = useUserPermissionsContext()
if (selectedCount === 0 && !isAllSelected) return null
const canEdit = userPermissions.canEdit
const showEnableButton = disabledCount > 0 && onEnable && canEdit
const showDisableButton = enabledCount > 0 && onDisable && canEdit
const showSelectAllOption =
isAllPageSelected && !isAllSelected && totalCount > selectedCount && onSelectAll
return (
<LazyMotion features={domAnimation}>
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className={cn('-translate-x-1/2 fixed bottom-6 z-50 transform', className)}
style={{ left: '50%' }}
>
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
<span className='px-1 text-[var(--text-secondary)] text-small'>
{isAllSelected ? totalCount : selectedCount} selected
{showSelectAllOption && (
<>
{' · '}
<button
type='button'
onClick={onSelectAll}
className='text-[var(--brand-primary)] hover-hover:underline'
>
Select all
</button>
</>
)}
{isAllSelected && onClearSelectAll && (
<>
{' · '}
<button
type='button'
onClick={onClearSelectAll}
className='text-[var(--brand-primary)] hover-hover:underline'
>
Clear
</button>
</>
)}
</span>
<div className='flex items-center gap-[5px]'>
{showEnableButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onEnable}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Circle className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Enable</Tooltip.Content>
</Tooltip.Root>
)}
{showDisableButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDisable}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<CircleOff className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Disable</Tooltip.Content>
</Tooltip.Root>
)}
{onDelete && canEdit && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDelete}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Trash2 className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
</m.div>
</LazyMotion>
)
}
@@ -0,0 +1 @@
export { ActionBar } from './action-bar'
@@ -0,0 +1,496 @@
'use client'
import { useMemo, useState } from 'react'
import {
ArrowRight,
Button,
ButtonGroup,
ButtonGroupItem,
Checkbox,
ChipCombobox,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
cn,
handleKeyboardActivation,
Search,
} from '@sim/emcn'
import { ArrowLeft, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorMeta } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_META_REGISTRY)
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onConnectorTypeChange?: (connectorType: string | null) => void
knowledgeBaseId: string
initialConnectorType?: string | null
}
type Step = 'select-type' | 'configure'
export function AddConnectorModal({
open,
onOpenChange,
onConnectorTypeChange,
knowledgeBaseId,
initialConnectorType,
}: AddConnectorModalProps) {
const [step, setStep] = useState<Step>(() => (initialConnectorType ? 'configure' : 'select-type'))
const [selectedType, setSelectedType] = useState<string | null>(initialConnectorType ?? null)
const [syncInterval, setSyncInterval] = useState(1440)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
const [disabledTagIds, setDisabledTagIds] = useState<Set<string>>(() => new Set())
const [error, setError] = useState<string | null>(null)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [apiKeyValue, setApiKeyValue] = useState('')
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const { workspaceId } = useParams<{ workspaceId: string }>()
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
const connectorProviderId = useMemo(
() =>
connectorConfig && connectorConfig.auth.mode === 'oauth'
? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider)
: null,
[connectorConfig]
)
const {
data: credentials = [],
isLoading: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(connectorProviderId ?? undefined, {
enabled: Boolean(connectorConfig) && !isApiKeyMode,
workspaceId,
})
useCredentialRefreshTriggers(refetchCredentials, connectorProviderId ?? '', workspaceId)
const effectiveCredentialId =
selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null)
const {
sourceConfig,
setSourceConfig,
canonicalModes,
setCanonicalModes,
canonicalGroups,
isFieldVisible,
isFieldPopulated,
handleFieldChange,
toggleCanonicalMode,
resolveSourceConfig,
} = useConnectorConfigFields({ connectorConfig })
const handleSelectType = (type: string) => {
setSelectedType(type)
setSourceConfig({})
setSelectedCredentialId(null)
setApiKeyValue('')
setApiKeyFocused(false)
setDisabledTagIds(new Set())
setCanonicalModes({})
setError(null)
setSearchTerm('')
setStep('configure')
onConnectorTypeChange?.(type)
}
const toggleTagDefinition = (tagId: string) => {
setDisabledTagIds((prev) => {
const next = new Set(prev)
if (prev.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
return next
})
}
const canSubmit = useMemo(() => {
if (!connectorConfig) return false
if (isApiKeyMode) {
if (!apiKeyValue.trim()) return false
} else {
if (!effectiveCredentialId) return false
}
for (const field of connectorConfig.configFields) {
if (!field.required) continue
if (!isFieldVisible(field)) continue
if (!isFieldPopulated(field)) return false
}
return true
}, [
connectorConfig,
isApiKeyMode,
apiKeyValue,
effectiveCredentialId,
isFieldVisible,
isFieldPopulated,
])
const handleSubmit = () => {
if (!selectedType || !canSubmit) return
setError(null)
const resolvedConfig: Record<string, unknown> = {}
for (const [key, value] of Object.entries(resolveSourceConfig())) {
if (Array.isArray(value)) {
if (value.length > 0) resolvedConfig[key] = value
} else if (typeof value === 'string') {
if (value) resolvedConfig[key] = value
} else if (value !== undefined && value !== null) {
resolvedConfig[key] = value
}
}
if (disabledTagIds.size > 0) {
resolvedConfig.disabledTagIds = Array.from(disabledTagIds)
}
if (Object.keys(canonicalModes).length > 0) {
resolvedConfig._canonicalModes = canonicalModes
}
const finalSourceConfig = resolvedConfig
createConnector(
{
knowledgeBaseId,
connectorType: selectedType,
...(isApiKeyMode ? { apiKey: apiKeyValue } : { credentialId: effectiveCredentialId! }),
sourceConfig: finalSourceConfig,
syncIntervalMinutes: syncInterval,
},
{
onSuccess: () => {
onOpenChange(false)
},
onError: (err) => {
setError(err.message)
},
}
)
}
const filteredEntries = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return CONNECTOR_ENTRIES
return CONNECTOR_ENTRIES.filter(
([, config]) =>
config.name.toLowerCase().includes(term) || config.description.toLowerCase().includes(term)
)
}, [searchTerm])
return (
<>
<ChipModal
open={open}
onOpenChange={(val) => !isCreating && onOpenChange(val)}
srTitle={step === 'select-type' ? 'Connect Source' : `Configure ${connectorConfig?.name}`}
size='md'
>
<ChipModalHeader onClose={() => onOpenChange(false)}>
{step === 'configure' ? (
<span className='flex items-center gap-2'>
<Button
variant='ghost'
className='size-6 p-0'
onClick={() => {
setStep('select-type')
onConnectorTypeChange?.('')
}}
>
<ArrowLeft className='size-4' />
</Button>
{`Configure ${connectorConfig?.name}`}
</span>
) : (
'Connect Source'
)}
</ChipModalHeader>
<ChipModalBody
className={step === 'select-type' ? 'max-h-[520px] pb-0' : 'h-[80vh] max-h-[560px]'}
>
{step === 'select-type' ? (
<div className='flex min-h-0 flex-col px-2'>
<ChipInput
icon={Search}
placeholder='Search sources...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className='max-h-[390px] min-h-0 overflow-y-auto [scrollbar-gutter:stable]'>
<div className='flex flex-col gap-0.5 pt-2.5 pr-1 pb-4.5'>
{filteredEntries.map(([type, config]) => (
<ConnectorTypeCard
key={type}
type={type}
config={config}
onClick={() => handleSelectType(type)}
/>
))}
{filteredEntries.length === 0 && (
<div className='rounded-lg bg-[var(--surface-3)] px-3 py-8 text-center text-[var(--text-muted)] text-caption'>
{CONNECTOR_ENTRIES.length === 0
? 'No connectors available.'
: `No sources found matching "${searchTerm}"`}
</div>
)}
</div>
</div>
</div>
) : connectorConfig ? (
<>
{isApiKeyMode ? (
<ChipModalField
type='custom'
title={
connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.label
? connectorConfig.auth.label
: 'API Key'
}
>
<ChipInput
type={apiKeyFocused ? 'text' : 'password'}
autoComplete='new-password'
value={apiKeyValue}
onChange={(e) => setApiKeyValue(e.target.value)}
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
placeholder={
connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.placeholder
? connectorConfig.auth.placeholder
: 'Enter API key'
}
/>
</ChipModalField>
) : (
<ChipModalField type='custom' title='Account'>
<ChipCombobox
options={[
...credentials.map(
(cred): ComboboxOption => ({
label: cred.name || cred.provider,
value: cred.id,
icon: connectorConfig.icon,
})
),
{
label:
credentials.length > 0
? `Connect another ${connectorConfig.name} account`
: `Connect ${connectorConfig.name} account`,
value: '__connect_new__',
icon: Plus,
onSelect: () => setShowOAuthModal(true),
},
]}
value={effectiveCredentialId ?? undefined}
onChange={(value) => setSelectedCredentialId(value)}
onOpenChange={(isOpen) => {
if (isOpen) void refetchCredentials()
}}
placeholder={`Select ${connectorConfig.name} account`}
isLoading={credentialsLoading}
/>
</ChipModalField>
)}
<ConnectorConfigFields
connectorConfig={connectorConfig}
sourceConfig={sourceConfig}
credentialId={effectiveCredentialId}
canonicalGroups={canonicalGroups}
canonicalModes={canonicalModes}
isFieldVisible={isFieldVisible}
onFieldChange={handleFieldChange}
onToggleCanonicalMode={toggleCanonicalMode}
disabled={isCreating}
/>
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
<ChipModalField type='custom' title='Metadata Tags'>
<div className='flex flex-col gap-2'>
{connectorConfig.tagDefinitions.map((tagDef) => (
<div
key={tagDef.id}
role='checkbox'
aria-checked={!disabledTagIds.has(tagDef.id)}
tabIndex={0}
className='flex cursor-pointer items-center gap-2 rounded-sm p-0.5 text-small'
onClick={() => toggleTagDefinition(tagDef.id)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
handleKeyboardActivation(event, () => toggleTagDefinition(tagDef.id))
}}
>
<Checkbox
checked={!disabledTagIds.has(tagDef.id)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
setDisabledTagIds((prev) => {
const next = new Set(prev)
if (checked) {
next.delete(tagDef.id)
} else {
next.add(tagDef.id)
}
return next
})
}}
/>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
{tagDef.displayName}
</span>
<span className='flex-shrink-0 text-[var(--text-muted)] text-xs'>
({tagDef.fieldType})
</span>
</div>
))}
</div>
</ChipModalField>
)}
<ChipModalField type='custom' title='Sync Frequency'>
<ButtonGroup
value={String(syncInterval)}
onValueChange={(val) => setSyncInterval(Number(val))}
>
{SYNC_INTERVALS.map((interval) => (
<ButtonGroupItem
key={interval.value}
value={String(interval.value)}
disabled={interval.requiresMax && !hasMaxAccess}
>
{interval.label}
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
</ButtonGroupItem>
))}
</ButtonGroup>
</ChipModalField>
<ChipModalError>{error}</ChipModalError>
</>
) : null}
</ChipModalBody>
{step === 'configure' && (
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isCreating}
primaryAction={{
label: isCreating ? 'Connecting…' : 'Connect & Sync',
onClick: handleSubmit,
disabled: !canSubmit || isCreating,
}}
/>
)}
</ChipModal>
{showOAuthModal &&
connectorConfig &&
connectorConfig.auth.mode === 'oauth' &&
connectorProviderId && (
<ConnectOAuthModal
mode='connect'
origin='kb-connectors'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={connectorProviderId}
serviceId={connectorConfig.auth.provider}
providerId={connectorProviderId}
requiredScopes={getCanonicalScopesForProvider(connectorProviderId)}
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
connectorType={selectedType ?? undefined}
/>
)}
</>
)
}
interface ConnectorTypeCardProps {
type: string
config: ConnectorMeta
onClick: () => void
}
function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
const Icon = config.icon
const brandBg = getBlock(type)?.bgColor ?? null
return (
<button
type='button'
className='flex w-full items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
onClick={onClick}
>
<div className='size-9 flex-shrink-0'>
<div
className={cn(
'flex size-full items-center justify-center rounded-xl border',
brandBg
? 'border-[var(--border-1)]'
: 'border-[var(--border-muted)] bg-[var(--surface-4)]'
)}
style={brandBg ? { background: brandBg } : undefined}
>
<Icon
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
</div>
</div>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>{config.name}</span>
<span className='truncate text-[var(--text-muted)] text-caption'>{config.description}</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
)
}
@@ -0,0 +1 @@
export { AddConnectorModal } from './add-connector-modal'
@@ -0,0 +1,247 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
Button,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
cn,
Loader,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
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'
const logger = createLogger('AddDocumentsModal')
interface AddDocumentsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
chunkingConfig?: {
maxSize: number
minSize: number
overlap: number
}
}
export function AddDocumentsModal({
open,
onOpenChange,
knowledgeBaseId,
chunkingConfig,
}: AddDocumentsModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [files, setFiles] = useState<File[]>([])
const [fileError, setFileError] = useState<string | null>(null)
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(() => new Set())
const { isUploading, uploadProgress, uploadFiles, uploadError, clearError } = useKnowledgeUpload({
workspaceId,
})
useEffect(() => {
if (open) {
setFiles([])
setFileError(null)
setRetryingIndexes(new Set())
clearError()
}
}, [open, clearError])
/** Handles close with upload guard */
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
if (isUploading) return
setFiles([])
setFileError(null)
clearError()
setRetryingIndexes(new Set())
}
onOpenChange(newOpen)
},
[isUploading, clearError, onOpenChange]
)
const handleClose = () => {
handleOpenChange(false)
}
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 handleRetryFile = async (index: number) => {
const fileToRetry = files[index]
if (!fileToRetry) return
setRetryingIndexes((prev) => new Set(prev).add(index))
try {
await uploadFiles([fileToRetry], knowledgeBaseId, {
recipe: 'default',
})
removeFile(index)
} catch (error) {
logger.error('Error retrying file upload:', error)
} finally {
setRetryingIndexes((prev) => {
const newSet = new Set(prev)
newSet.delete(index)
return newSet
})
}
}
const handleUpload = async () => {
if (files.length === 0) return
try {
await uploadFiles(files, knowledgeBaseId, {
recipe: 'default',
})
logger.info(`Successfully uploaded ${files.length} files`)
handleClose()
} catch (error) {
logger.error('Error uploading files:', error)
}
}
return (
<ChipModal open={open} onOpenChange={handleOpenChange} srTitle='New Documents' size='md'>
<ChipModalHeader onClose={() => handleOpenChange(false)}>New Documents</ChipModalHeader>
<ChipModalBody>
<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='flex flex-col gap-2'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isFailed = fileStatus?.status === 'failed'
const isRetrying = retryingIndexes.has(index)
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
return (
<div
key={`${file.name}-${file.size}`}
className={cn(
'flex items-center gap-2 rounded-sm border p-2',
isFailed && !isRetrying && 'border-[var(--text-error)]'
)}
>
<span
className={cn(
'min-w-0 flex-1 truncate text-caption',
isFailed && !isRetrying && '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 />
) : (
<>
{isFailed && (
<Button
type='button'
variant='ghost'
className='size-4 p-0'
onClick={() => handleRetryFile(index)}
disabled={isUploading}
>
<RotateCcw className='size-3' />
</Button>
)}
<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>
)}
{uploadError && <ChipModalError>{uploadError.message}</ChipModalError>}
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
cancelDisabled={isUploading}
primaryAction={{
label: isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Uploading...'
: 'Upload',
onClick: handleUpload,
disabled: files.length === 0 || isUploading,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { AddDocumentsModal } from './add-documents-modal'
@@ -0,0 +1,474 @@
'use client'
import { useMemo, useState } from 'react'
import {
Button,
ChipCombobox,
ChipConfirmModal,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
handleKeyboardActivation,
Trash,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import type { TagUsageData } from '@/lib/api/contracts/knowledge'
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import {
useCreateTagDefinition,
useDeleteTagDefinition,
useTagUsageQuery,
} from '@/hooks/queries/kb/knowledge'
const logger = createLogger('BaseTagsModal')
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Boolean',
}
interface DocumentListProps {
documents: Array<{ id: string; name: string; tagValue: string }>
totalCount: number
}
function DocumentList({ documents, totalCount }: DocumentListProps) {
const displayLimit = 5
const hasMore = totalCount > displayLimit
return (
<div className='rounded-sm border'>
<div className='max-h-[160px] overflow-y-auto'>
{documents.slice(0, displayLimit).map((doc) => {
const DocumentIcon = getDocumentIcon('', doc.name)
return (
<div key={doc.id} className='flex items-center gap-2 border-b p-2 last:border-b-0'>
<DocumentIcon className='size-4 flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 max-w-[120px] truncate text-[var(--text-primary)] text-caption'>
{doc.name}
</span>
{doc.tagValue && (
<>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[var(--border-1)]' />
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'>
{doc.tagValue}
</span>
</>
)}
</div>
)
})}
{hasMore && (
<div className='p-2 text-[var(--text-muted)] text-caption'>
and {totalCount - displayLimit} more documents
</div>
)}
</div>
</div>
)
}
interface BaseTagsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
}
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
const { tagDefinitions: kbTagDefinitions } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const createTagMutation = useCreateTagDefinition()
const deleteTagMutation = useDeleteTagDefinition()
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
})
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(knowledgeBaseId, {
enabled: open,
})
const getTagUsage = (tagSlot: string): TagUsageData => {
return (
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
tagName: '',
tagSlot,
documentCount: 0,
documents: [],
}
)
}
const handleDeleteTagClick = async (tag: TagDefinition) => {
setSelectedTag(tag)
await refetchTagUsage()
setDeleteTagDialogOpen(true)
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
await refetchTagUsage()
setViewDocumentsDialogOpen(true)
}
const openTagCreator = () => {
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
setIsCreatingTag(true)
}
const cancelCreatingTag = () => {
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
setIsCreatingTag(false)
}
const hasTagNameConflict = (name: string) => {
if (!name.trim()) return false
return kbTagDefinitions.some(
(tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase()
)
}
const tagNameConflict =
isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName)
const canSaveTag = () => {
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
}
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
if (!config) return { used: 0, max: 0 }
const used = kbTagDefinitions.filter((def) => def.fieldType === fieldType).length
return { used, max: config.maxSlots }
}
const hasAvailableSlots = (fieldType: string): boolean => {
const { used, max } = getSlotUsageByFieldType(fieldType)
return used < max
}
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
return SUPPORTED_FIELD_TYPES.reduce<ComboboxOption[]>((acc, type) => {
const { used, max } = getSlotUsageByFieldType(type)
if (used < max) {
acc.push({ value: type, label: `${FIELD_TYPE_LABELS[type]} (${used}/${max})` })
}
return acc
}, [])
}, [kbTagDefinitions])
const saveTagDefinition = async () => {
if (!canSaveTag()) return
try {
if (!hasAvailableSlots(createTagForm.fieldType)) {
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
}
await createTagMutation.mutateAsync({
knowledgeBaseId,
displayName: createTagForm.displayName.trim(),
fieldType: createTagForm.fieldType,
})
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
setIsCreatingTag(false)
} catch (error) {
logger.error('Error creating tag definition:', error)
}
}
const closeDeleteTagDialog = () => {
setDeleteTagDialogOpen(false)
setSelectedTag(null)
}
const confirmDeleteTag = async () => {
if (!selectedTag) return
try {
await deleteTagMutation.mutateAsync({
knowledgeBaseId,
tagDefinitionId: selectedTag.id,
})
closeDeleteTagDialog()
} catch (error) {
logger.error('Error deleting tag definition:', error)
}
}
const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.tagSlot) : null
const handleClose = (openState: boolean) => {
if (!openState) {
setIsCreatingTag(false)
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
}
onOpenChange(openState)
}
return (
<>
<ChipModal open={open} onOpenChange={handleClose} srTitle='Tags' size='sm'>
<ChipModalHeader onClose={() => handleClose(false)}>
<div className='flex items-center justify-between'>
<span>Tags</span>
</div>
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='custom'
title={
<>
Tags:{' '}
<span className='pl-1.5 text-[var(--text-tertiary)]'>
{kbTagDefinitions.length} defined
</span>
</>
}
>
<div className='flex flex-col gap-2'>
{kbTagDefinitions.length === 0 && !isCreatingTag && (
<div className='rounded-md border p-4 text-center'>
<p className='text-[var(--text-tertiary)] text-caption'>
No tag definitions yet. Create your first tag to organize documents.
</p>
</div>
)}
{kbTagDefinitions.map((tag) => {
const usage = getTagUsage(tag.tagSlot)
return (
<div
key={tag.id}
role='button'
tabIndex={0}
className='flex cursor-pointer items-center gap-2 rounded-sm border p-2 hover-hover:bg-[var(--surface-2)]'
onClick={() => handleViewDocuments(tag)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
handleKeyboardActivation(event, () => handleViewDocuments(tag))
}}
>
<span className='min-w-0 truncate text-[var(--text-primary)] text-caption'>
{tag.displayName}
</span>
<span className='rounded-[3px] bg-[var(--surface-3)] px-1.5 py-0.5 text-[var(--text-muted)] text-micro'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[var(--border-1)]' />
<span className='min-w-0 flex-1 text-[var(--text-muted)] text-caption'>
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteTagClick(tag)
}}
className='size-4 p-0 text-[var(--text-muted)] hover-hover:text-[var(--text-error)]'
>
<Trash className='size-3' />
</Button>
</div>
</div>
)
})}
{!isCreatingTag && (
<Button
variant='default'
onClick={openTagCreator}
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
className='w-full'
>
Add Tag
</Button>
)}
{isCreatingTag && (
<div className='space-y-2 rounded-md border p-3'>
<ChipModalField
type='custom'
title='Tag Name'
flush
error={tagNameConflict ? 'A tag with this name already exists' : undefined}
>
<ChipInput
value={createTagForm.displayName}
onChange={(e) =>
setCreateTagForm({ ...createTagForm, displayName: e.target.value })
}
placeholder='Enter tag name'
error={Boolean(tagNameConflict)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag()) {
e.preventDefault()
saveTagDefinition()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelCreatingTag()
}
}}
/>
</ChipModalField>
<ChipModalField
type='custom'
title='Type'
flush
error={
!hasAvailableSlots(createTagForm.fieldType)
? 'No available slots for this type. Choose a different type.'
: undefined
}
>
<ChipCombobox
options={fieldTypeOptions}
value={createTagForm.fieldType}
onChange={(value) => setCreateTagForm({ ...createTagForm, fieldType: value })}
placeholder='Select type'
/>
</ChipModalField>
<div className='flex gap-2'>
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
Cancel
</Button>
<Button
variant='primary'
onClick={saveTagDefinition}
className='flex-1'
disabled={
!canSaveTag() ||
createTagMutation.isPending ||
!hasAvailableSlots(createTagForm.fieldType)
}
>
{createTagMutation.isPending ? 'Creating...' : 'Create Tag'}
</Button>
</div>
</div>
)}
</div>
</ChipModalField>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleClose(false)}
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
/>
</ChipModal>
{/* Delete Tag Confirmation Dialog */}
<ChipConfirmModal
open={deleteTagDialogOpen}
onOpenChange={(openState) => {
if (openState) {
setDeleteTagDialogOpen(true)
} else {
closeDeleteTagDialog()
}
}}
srTitle='Delete Tag'
title='Delete Tag'
text={[
'Are you sure you want to delete the ',
{ text: selectedTag?.displayName ?? 'selected', bold: true },
' tag? ',
{
text: `This will remove this tag from ${selectedTagUsage?.documentCount || 0} document${selectedTagUsage?.documentCount !== 1 ? 's' : ''}.`,
error: true,
},
' This action cannot be undone.',
]}
confirm={{
label: 'Delete Tag',
onClick: confirmDeleteTag,
pending: deleteTagMutation.isPending,
pendingLabel: 'Deleting...',
}}
>
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
<ChipModalField type='custom' title='Affected documents'>
<DocumentList
documents={selectedTagUsage.documents}
totalCount={selectedTagUsage.documentCount}
/>
</ChipModalField>
)}
</ChipConfirmModal>
{/* View Documents Dialog */}
<ChipModal
open={viewDocumentsDialogOpen}
onOpenChange={setViewDocumentsDialogOpen}
srTitle={`Documents using "${selectedTag?.displayName}"`}
size='sm'
>
<ChipModalHeader onClose={() => setViewDocumentsDialogOpen(false)}>
Documents using "{selectedTag?.displayName}"
</ChipModalHeader>
<ChipModalBody>
<div className='flex flex-col gap-2 px-2'>
<p className='text-[var(--text-secondary)]'>
{selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
definition.
</p>
{selectedTagUsage?.documentCount === 0 ? (
<div className='rounded-md border p-4 text-center'>
<p className='text-[var(--text-secondary)]'>
This tag definition is not being used by any documents. You can safely delete it
to free up the tag slot.
</p>
</div>
) : (
<DocumentList
documents={selectedTagUsage?.documents || []}
totalCount={selectedTagUsage?.documentCount || 0}
/>
)}
</div>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setViewDocumentsDialogOpen(false)}
primaryAction={{ label: 'Close', onClick: () => setViewDocumentsDialogOpen(false) }}
/>
</ChipModal>
</>
)
}
@@ -0,0 +1 @@
export { BaseTagsModal } from './base-tags-modal'
@@ -0,0 +1,157 @@
'use client'
import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@sim/emcn'
import { ArrowLeftRight, Info } from 'lucide-react'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import type {
ConfigFieldMap,
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
import type { SelectorKey } from '@/hooks/selectors/types'
export interface ConnectorConfigFieldsProps {
/** Registry definition whose `configFields` drive the rendered rows. */
connectorConfig: ConnectorMeta
/** Current values keyed by field ID. */
sourceConfig: ConfigFieldMap
/** OAuth credential backing selector fields, when available. */
credentialId: string | null
/** Canonical-pair groups keyed by `canonicalParamId`. */
canonicalGroups: Map<string, ConnectorConfigField[]>
/** Active mode per canonical pair. */
canonicalModes: Record<string, 'basic' | 'advanced'>
/** Visibility predicate honoring `condition` / canonical mode. */
isFieldVisible: (field: ConnectorConfigField) => boolean
/** Field value change handler. */
onFieldChange: (fieldId: string, value: ConfigFieldValue) => void
/** Swaps a canonical pair between selector and manual input. */
onToggleCanonicalMode: (canonicalId: string) => void
/** Disables selector fields during submission. */
disabled: boolean
}
/**
* Renders the connector's dynamic configuration fields as canonical
* `ChipModalField` rows. Shared by the add- and edit-connector modals so the
* label + info tooltip + canonical-pair toggle + selector/dropdown/input
* switch stays identical in both flows.
*/
export function ConnectorConfigFields({
connectorConfig,
sourceConfig,
credentialId,
canonicalGroups,
canonicalModes,
isFieldVisible,
onFieldChange,
onToggleCanonicalMode,
disabled,
}: ConnectorConfigFieldsProps) {
return (
<>
{connectorConfig.configFields.map((field) => {
if (!isFieldVisible(field)) return null
const canonicalId = field.canonicalParamId
const hasCanonicalPair =
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
return (
<ChipModalField
key={field.id}
type='custom'
title={
/**
* Buttons inside the field's `Label` would become its labeled
* control, so a click on the title text would forward to them.
* Cancelling the click's default action keeps label clicks
* inert without affecting the buttons' own handlers.
*/
<span
className='flex w-full items-center justify-between'
onClick={(event) => event.preventDefault()}
>
<span className='flex items-center gap-1'>
<span>
{field.title}
{field.required && <span className='ml-0.5'>*</span>}
</span>
{field.description && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
aria-label={`About ${field.title}`}
>
<Info className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
</Tooltip.Root>
)}
</span>
{hasCanonicalPair && canonicalId && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
className='flex size-[18px] items-center justify-center rounded-[3px] p-0 text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
onClick={() => onToggleCanonicalMode(canonicalId)}
>
<ArrowLeftRight className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
</Tooltip.Content>
</Tooltip.Root>
)}
</span>
}
>
{field.type === 'selector' && field.selectorKey ? (
<ConnectorSelectorField
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
value={sourceConfig[field.id] ?? (field.multi ? [] : '')}
onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)}
credentialId={credentialId}
sourceConfig={sourceConfig}
configFields={connectorConfig.configFields}
canonicalModes={canonicalModes}
disabled={disabled}
/>
) : field.type === 'dropdown' && field.options ? (
<ChipCombobox
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
}))}
value={
typeof sourceConfig[field.id] === 'string'
? (sourceConfig[field.id] as string) || undefined
: undefined
}
onChange={(value) => onFieldChange(field.id, value)}
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
/>
) : (
<ChipInput
value={
Array.isArray(sourceConfig[field.id])
? (sourceConfig[field.id] as string[]).join(', ')
: (sourceConfig[field.id] as string) || ''
}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder}
/>
)}
</ChipModalField>
)
})}
</>
)
}
@@ -0,0 +1 @@
export { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields'
@@ -0,0 +1,163 @@
'use client'
import { useMemo } from 'react'
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type {
ConfigFieldMap,
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { getDependsOnFields } from '@/blocks/utils'
import type { ConnectorConfigField } from '@/connectors/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
interface ConnectorSelectorFieldProps {
field: ConnectorConfigField & { selectorKey: SelectorKey }
value: ConfigFieldValue
onChange: (value: ConfigFieldValue) => void
credentialId: string | null
sourceConfig: ConfigFieldMap
configFields: ConnectorConfigField[]
canonicalModes: Record<string, 'basic' | 'advanced'>
disabled?: boolean
}
export function ConnectorSelectorField({
field,
value,
onChange,
credentialId,
sourceConfig,
configFields,
canonicalModes,
disabled,
}: ConnectorSelectorFieldProps) {
const isMulti = Boolean(field.multi)
const context = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
if (credentialId) ctx.oauthCredential = credentialId
if (field.mimeType) ctx.mimeType = field.mimeType
const fieldsById = new Map(configFields.map((f) => [f.id, f]))
for (const depFieldId of getDependsOnFields(field.dependsOn)) {
const depField = fieldsById.get(depFieldId)
const canonicalId = depField?.canonicalParamId ?? depFieldId
const depValue = resolveDepValue(depFieldId, configFields, canonicalModes, sourceConfig)
if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) {
ctx[canonicalId as keyof SelectorContext] = depValue
}
}
return ctx
}, [credentialId, field.mimeType, field.dependsOn, sourceConfig, configFields, canonicalModes])
const depsResolved = useMemo(() => {
if (!field.dependsOn) return true
const deps = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? [])
return deps.every((depId) =>
Boolean(resolveDepValue(depId, configFields, canonicalModes, sourceConfig)?.trim())
)
}, [field.dependsOn, sourceConfig, configFields, canonicalModes])
const isEnabled = !disabled && !!credentialId && depsResolved
const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, {
context,
enabled: isEnabled,
})
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((opt) => ({ label: opt.label, value: opt.id })),
[options]
)
if (isLoading && isEnabled) {
return (
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
<Loader className='size-3.5' animate />
Loading
</div>
)
}
if (isMulti) {
const multiValues = Array.isArray(value) ? value : value ? [value] : []
return (
<ChipCombobox
multiSelect
options={comboboxOptions}
multiSelectValues={multiValues}
onMultiSelectChange={(values) => onChange(values)}
searchable
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
placeholder={
!credentialId
? 'Connect an account first'
: !depsResolved
? `Select ${getDependencyLabel(field, configFields)} first`
: field.placeholder || `Select ${field.title.toLowerCase()}`
}
disabled={disabled || !credentialId || !depsResolved}
emptyMessage={`No ${field.title.toLowerCase()} found`}
/>
)
}
const singleValue = Array.isArray(value) ? value[0] : value
return (
<ChipCombobox
options={comboboxOptions}
value={singleValue || undefined}
onChange={(next) => onChange(next)}
searchable
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
placeholder={
!credentialId
? 'Connect an account first'
: !depsResolved
? `Select ${getDependencyLabel(field, configFields)} first`
: field.placeholder || `Select ${field.title.toLowerCase()}`
}
disabled={disabled || !credentialId || !depsResolved}
emptyMessage={`No ${field.title.toLowerCase()} found`}
/>
)
}
function resolveDepValue(
depFieldId: string,
configFields: ConnectorConfigField[],
canonicalModes: Record<string, 'basic' | 'advanced'>,
sourceConfig: ConfigFieldMap
): string {
const depField = configFields.find((f) => f.id === depFieldId)
/**
* For multi-value parent fields, pass all selected values to dependent
* selectors as a comma-joined string so the downstream selector can load
* options across every selected parent (e.g. Linear projects across multiple
* selected teams). Single-value parents pass through unchanged.
*/
const readDep = (raw: ConfigFieldValue | undefined): string => {
if (Array.isArray(raw)) return raw.join(',')
return raw ?? ''
}
if (!depField?.canonicalParamId) return readDep(sourceConfig[depFieldId])
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
if (depField.mode === activeMode) return readDep(sourceConfig[depFieldId])
const activeField = configFields.find(
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
)
return activeField ? readDep(sourceConfig[activeField.id]) : readDep(sourceConfig[depFieldId])
}
function getDependencyLabel(
field: ConnectorConfigField,
configFields: ConnectorConfigField[]
): string {
const deps = getDependsOnFields(field.dependsOn)
const depField = deps.length > 0 ? configFields.find((f) => f.id === deps[0]) : undefined
return depField?.title?.toLowerCase() ?? 'dependency'
}
@@ -0,0 +1 @@
export { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field'
@@ -0,0 +1,716 @@
'use client'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Badge, Button, Checkbox, ChipConfirmModal, cn, Loader, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { format, formatDistanceToNow, isPast } from 'date-fns'
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
ChevronDown,
Pause,
Play,
RefreshCw,
Settings,
Trash,
XCircle,
} from 'lucide-react'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
import {
useConnectorDetail,
useDeleteConnector,
useTriggerSync,
useUpdateConnector,
} from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
const logger = createLogger('ConnectorsSection')
interface ConnectorsSectionProps {
workspaceId: string
knowledgeBaseId: string
connectors: ConnectorData[]
isLoading: boolean
canEdit: boolean
className?: string
}
/** 5-minute cooldown after a manual sync trigger */
const SYNC_COOLDOWN_MS = 5 * 60 * 1000
const STATUS_CONFIG = {
active: { label: 'Active', variant: 'green' as const },
syncing: { label: 'Syncing', variant: 'amber' as const },
error: { label: 'Error', variant: 'red' as const },
paused: { label: 'Paused', variant: 'gray' as const },
disabled: { label: 'Disabled', variant: 'orange' as const },
} as const
const CONNECTOR_ACTION_BUTTON_CLASSES =
'size-7 rounded-lg p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
export function ConnectorsSection({
workspaceId,
knowledgeBaseId,
connectors,
isLoading,
canEdit,
className,
}: ConnectorsSectionProps) {
const { mutate: triggerSync } = useTriggerSync()
const { mutate: updateConnector } = useUpdateConnector()
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
const deleteDocumentsId = useId()
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [deleteDocuments, setDeleteDocuments] = useState(false)
const closeDeleteModal = useCallback(() => {
setDeleteTarget(null)
setDeleteDocuments(false)
}, [])
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
const [error, setError] = useState<string | null>(null)
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
const [updatingIds, setUpdatingIds] = useState<Set<string>>(() => new Set())
const addToSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => new Set(prev).add(id))
}, [])
const removeFromSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const syncTriggeredAt = useRef<Record<string, number>>({})
const cooldownTimersRef = useRef<Set<ReturnType<typeof setTimeout>> | null>(null)
cooldownTimersRef.current ??= new Set()
const [, forceUpdate] = useState(0)
useEffect(() => {
return () => {
for (const timer of cooldownTimersRef.current ?? []) {
clearTimeout(timer)
}
}
}, [])
const isSyncOnCooldown = useCallback((connectorId: string) => {
const triggeredAt = syncTriggeredAt.current[connectorId]
if (!triggeredAt) return false
return Date.now() - triggeredAt < SYNC_COOLDOWN_MS
}, [])
const handleSync = useCallback(
(connectorId: string) => {
if (isSyncOnCooldown(connectorId)) return
syncTriggeredAt.current[connectorId] = Date.now()
addToSet(setSyncingIds, connectorId)
triggerSync(
{ knowledgeBaseId, connectorId },
{
onSuccess: () => {
setError(null)
const timer = setTimeout(() => {
cooldownTimersRef.current?.delete(timer)
forceUpdate((n) => n + 1)
}, SYNC_COOLDOWN_MS)
cooldownTimersRef.current?.add(timer)
},
onError: (err) => {
logger.error('Sync trigger failed', { error: err.message })
setError(err.message)
delete syncTriggeredAt.current[connectorId]
forceUpdate((n) => n + 1)
},
onSettled: () => removeFromSet(setSyncingIds, connectorId),
}
)
},
[knowledgeBaseId, triggerSync, isSyncOnCooldown, addToSet, removeFromSet]
)
const handleTogglePause = useCallback(
(connector: ConnectorData) => {
addToSet(setUpdatingIds, connector.id)
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status:
connector.status === 'paused' || connector.status === 'disabled'
? 'active'
: 'paused',
},
},
{
onSettled: () => removeFromSet(setUpdatingIds, connector.id),
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
},
[knowledgeBaseId, updateConnector, addToSet, removeFromSet]
)
const handleDeleteConnector = () => {
if (!deleteTarget) return
deleteConnector(
{ knowledgeBaseId, connectorId: deleteTarget, deleteDocuments },
{
onSuccess: () => {
setError(null)
closeDeleteModal()
},
onError: (err) => {
logger.error('Delete connector failed', { error: err.message })
setError(err.message)
closeDeleteModal()
},
}
)
}
if (connectors.length === 0 && !canEdit && !isLoading) return null
return (
<div className={cn('mt-4', className)}>
{error && <p className='mt-2 text-[var(--text-error)] text-caption leading-tight'>{error}</p>}
{isLoading ? (
<div className='mt-2' />
) : connectors.length === 0 ? (
<p className='mt-2 text-[var(--text-muted)] text-small'>
No connected sources yet. Connect an external source to automatically sync documents.
</p>
) : (
<div className='mt-2 flex flex-col gap-0.5'>
{connectors.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
canEdit={canEdit}
isSyncPending={syncingIds.has(connector.id)}
isUpdating={updatingIds.has(connector.id)}
syncCooldown={isSyncOnCooldown(connector.id)}
onSync={() => handleSync(connector.id)}
onTogglePause={() => handleTogglePause(connector)}
onEdit={() => setEditingConnector(connector)}
onDelete={() => setDeleteTarget(connector.id)}
/>
))}
</div>
)}
{editingConnector && (
<EditConnectorModal
open={editingConnector !== null}
onOpenChange={(val) => !val && setEditingConnector(null)}
knowledgeBaseId={knowledgeBaseId}
connector={editingConnector}
/>
)}
<ChipConfirmModal
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) closeDeleteModal()
}}
srTitle='Remove Connector'
title='Remove Connector'
text='This will disconnect the source and stop future syncs. Documents already synced will remain in the knowledge base unless you choose to delete them.'
confirm={{
label: 'Remove',
onClick: handleDeleteConnector,
pending: isDeleting,
pendingLabel: 'Removing...',
}}
>
<div className='flex items-center gap-2 px-2'>
<Checkbox
id={deleteDocumentsId}
checked={deleteDocuments}
onCheckedChange={(checked) => setDeleteDocuments(checked === true)}
/>
<label
htmlFor={deleteDocumentsId}
className='cursor-pointer text-[var(--text-secondary)] text-small'
>
Also delete all synced documents
</label>
</div>
</ChipConfirmModal>
</div>
)
}
interface ConnectorCardProps {
connector: ConnectorData
workspaceId: string
knowledgeBaseId: string
canEdit: boolean
isSyncPending: boolean
isUpdating: boolean
syncCooldown: boolean
onSync: () => void
onEdit: () => void
onTogglePause: () => void
onDelete: () => void
}
function ConnectorCard({
connector,
workspaceId,
knowledgeBaseId,
canEdit,
isSyncPending,
isUpdating,
syncCooldown,
onSync,
onEdit,
onTogglePause,
onDelete,
}: ConnectorCardProps) {
const [expanded, setExpanded] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const connectorDef = CONNECTOR_META_REGISTRY[connector.connectorType]
const Icon = connectorDef?.icon
const brandBg = getBlock(connector.connectorType)?.bgColor ?? null
const statusConfig =
STATUS_CONFIG[connector.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.active
const serviceId = connectorDef?.auth.mode === 'oauth' ? connectorDef.auth.provider : undefined
const providerId = serviceId ? getProviderIdFromServiceId(serviceId) : undefined
const requiredScopes = useMemo(
() => (connectorDef?.auth.mode === 'oauth' ? (connectorDef.auth.requiredScopes ?? []) : []),
[connectorDef]
)
const { data: credentials, refetch: refetchCredentials } = useOAuthCredentials(providerId, {
workspaceId,
})
useCredentialRefreshTriggers(refetchCredentials, providerId ?? '', workspaceId)
const missingScopes = useMemo(() => {
if (!credentials || !connector.credentialId) return []
const credential = credentials.find((c) => c.id === connector.credentialId)
if (!credential) return []
return getMissingRequiredScopes(credential, requiredScopes)
}, [credentials, connector.credentialId, requiredScopes])
const { data: detail, isLoading: detailLoading } = useConnectorDetail(
expanded ? knowledgeBaseId : undefined,
expanded ? connector.id : undefined
)
const syncLogs = detail?.syncLogs ?? []
return (
<div
className={cn(
'overflow-hidden rounded-lg border border-transparent transition-colors duration-100',
expanded
? 'border-[var(--border-muted)] bg-[var(--surface-2)]'
: 'hover-hover:bg-[var(--surface-active)]'
)}
>
<div className='flex items-center justify-between gap-2 px-2 py-2'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='relative size-9 flex-shrink-0'>
<div
className={cn(
'flex size-full items-center justify-center rounded-xl border',
brandBg
? 'border-[var(--border-1)]'
: 'border-[var(--border-muted)] bg-[var(--surface-4)]'
)}
style={brandBg ? { background: brandBg } : undefined}
>
{Icon && (
<Icon
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
)}
</div>
{connector.status === 'disabled' && (
<AlertTriangle className='-right-0.5 -top-0.5 absolute size-3 text-[var(--caution)]' />
)}
</div>
<div className='flex min-w-0 flex-col gap-0.5'>
<div className='flex min-w-0 items-center gap-2'>
<span className='flex min-w-0 items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
<span className='truncate'>{connectorDef?.name || connector.connectorType}</span>
{(isSyncPending || connector.status === 'syncing') && (
<Loader className='size-3 text-[var(--text-muted)]' animate />
)}
</span>
<Badge variant={statusConfig.variant} size='sm' dot className='flex-shrink-0'>
{statusConfig.label}
</Badge>
</div>
<div className='flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[var(--text-muted)] text-xs'>
{connector.lastSyncAt && (
<span>Last sync: {format(new Date(connector.lastSyncAt), 'MMM d, h:mm a')}</span>
)}
{connector.lastSyncDocCount !== null && (
<>
<span>·</span>
<span>{connector.lastSyncDocCount} docs</span>
</>
)}
{connector.nextSyncAt && connector.status === 'active' && (
<>
<span>·</span>
<span>
Next sync:{' '}
{isPast(new Date(connector.nextSyncAt))
? 'pending'
: formatDistanceToNow(new Date(connector.nextSyncAt), { addSuffix: true })}
</span>
</>
)}
{connector.lastSyncError && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertCircle className='size-3 text-[var(--text-error)]' />
</Tooltip.Trigger>
<Tooltip.Content>{connector.lastSyncError}</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
</div>
<div className='flex flex-shrink-0 items-center gap-0.5'>
{canEdit && (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={CONNECTOR_ACTION_BUTTON_CLASSES}
onClick={onSync}
disabled={
connector.status === 'syncing' ||
connector.status === 'disabled' ||
isSyncPending ||
syncCooldown
}
>
<RefreshCw
className={cn('size-3.5', connector.status === 'syncing' && 'animate-spin')}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{syncCooldown ? 'Sync recently triggered' : 'Sync now'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={CONNECTOR_ACTION_BUTTON_CLASSES}
onClick={onEdit}
>
<Settings className='size-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Settings</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={CONNECTOR_ACTION_BUTTON_CLASSES}
onClick={onTogglePause}
disabled={isUpdating}
>
{isUpdating ? (
<Loader className='size-3.5' animate />
) : connector.status === 'paused' || connector.status === 'disabled' ? (
<Play className='size-3.5' />
) : (
<Pause className='size-3.5' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{connector.status === 'paused' || connector.status === 'disabled'
? 'Resume'
: 'Pause'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={CONNECTOR_ACTION_BUTTON_CLASSES}
onClick={onDelete}
>
<Trash className='size-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete</Tooltip.Content>
</Tooltip.Root>
</>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={CONNECTOR_ACTION_BUTTON_CLASSES}
onClick={() => setExpanded((prev) => !prev)}
>
<ChevronDown
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{expanded ? 'Hide history' : 'Sync history'}</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{connector.status === 'disabled' && (
<div className='border-[var(--border-muted)] border-t px-2 py-2'>
<div className='flex flex-col gap-2 rounded-md border border-[var(--border-muted)] bg-[var(--surface-3)] px-2.5 py-2'>
<div className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-caption'>
<AlertTriangle className='size-3 flex-shrink-0 text-[var(--caution)]' />
Connector disabled after repeated sync failures
</div>
<p className='text-[var(--text-muted)] text-caption leading-snug'>
Syncing has been paused due to {connector.consecutiveFailures} consecutive failures.
{serviceId
? ' Reconnect your account to resume syncing.'
: ' Use the resume button to re-enable syncing.'}
</p>
{canEdit && serviceId && providerId && (
<Button
variant='primary'
onClick={() => {
if (connector.credentialId) {
writeOAuthReturnContext({
origin: 'kb-connectors',
knowledgeBaseId,
displayName: connectorDef?.name ?? connector.connectorType,
providerId: providerId!,
preCount: credentials?.length ?? 0,
workspaceId,
requestedAt: Date.now(),
})
}
setShowOAuthModal(true)
}}
size='sm'
className='w-full'
>
Reconnect
</Button>
)}
</div>
</div>
)}
{missingScopes.length > 0 && connector.status !== 'disabled' && (
<div className='border-[var(--border-muted)] border-t px-2 py-2'>
<div className='flex flex-col gap-2 rounded-md border border-[var(--border-muted)] bg-[var(--surface-3)] px-2.5 py-2'>
<div className='flex items-center font-medium text-[var(--text-primary)] text-caption'>
<span className='mr-1.5 inline-block size-[6px] rounded-xs bg-[var(--caution)]' />
Additional permissions required
</div>
{canEdit && (
<Button
variant='primary'
onClick={() => {
if (connector.credentialId) {
writeOAuthReturnContext({
origin: 'kb-connectors',
knowledgeBaseId,
displayName: connectorDef?.name ?? connector.connectorType,
providerId: providerId!,
preCount: credentials?.length ?? 0,
workspaceId,
requestedAt: Date.now(),
})
}
setShowOAuthModal(true)
}}
size='sm'
className='w-full'
>
Update access
</Button>
)}
</div>
</div>
)}
{expanded && (
<div className='border-[var(--border-muted)] border-t px-2 py-2'>
<SyncHistory logs={syncLogs} isLoading={detailLoading} />
</div>
)}
{showOAuthModal && serviceId && providerId && !connector.credentialId && (
<ConnectOAuthModal
mode='connect'
origin='kb-connectors'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
serviceId={serviceId}
providerId={providerId}
requiredScopes={getCanonicalScopesForProvider(providerId)}
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
/>
)}
{showOAuthModal && serviceId && providerId && connector.credentialId && (
<ConnectOAuthModal
mode='reauthorize'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
toolName={connectorDef?.name ?? connector.connectorType}
requiredScopes={getCanonicalScopesForProvider(providerId)}
newScopes={missingScopes}
serviceId={serviceId}
providerId={providerId}
/>
)}
</div>
)
}
interface SyncHistoryProps {
logs: SyncLogData[]
isLoading: boolean
}
function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
if (isLoading) {
return (
<div className='flex items-center gap-2 rounded-md bg-[var(--surface-3)] px-2 py-2 text-[var(--text-muted)] text-xs'>
<Loader className='size-3' animate />
Loading sync history
</div>
)
}
if (logs.length === 0) {
return (
<p className='rounded-md bg-[var(--surface-3)] px-2 py-2 text-[var(--text-muted)] text-xs'>
No sync history yet.
</p>
)
}
return (
<div className='flex flex-col gap-0.5'>
{logs.map((log) => {
const isError = log.status === 'error' || log.status === 'failed'
const isRunning = log.status === 'running' || log.status === 'syncing'
const totalChanges =
log.docsAdded + log.docsUpdated + log.docsDeleted + (log.docsFailed ?? 0)
return (
<div key={log.id} className='flex items-start gap-2 rounded-md px-2 py-1.5 text-xs'>
<div className='mt-[1px] flex-shrink-0'>
{isRunning ? (
<Loader className='size-3 text-[var(--text-muted)]' animate />
) : isError ? (
<XCircle className='size-3 text-[var(--text-error)]' />
) : (
<CheckCircle2 className='size-3 text-[var(--success)]' />
)}
</div>
<div className='flex min-w-0 flex-1 flex-col gap-[1px]'>
<div className='flex items-center gap-1.5'>
<span className='text-[var(--text-muted)]'>
{format(new Date(log.startedAt), 'MMM d, h:mm a')}
</span>
{!isRunning && !isError && (
<span className='text-[var(--text-muted)]'>
{totalChanges > 0 ? (
<>
{log.docsAdded > 0 && (
<span className='text-[var(--success)]'>+{log.docsAdded}</span>
)}
{log.docsUpdated > 0 && (
<>
{log.docsAdded > 0 && ' '}
<span className='text-[var(--caution)]'>~{log.docsUpdated}</span>
</>
)}
{log.docsDeleted > 0 && (
<>
{(log.docsAdded > 0 || log.docsUpdated > 0) && ' '}
<span className='text-[var(--text-error)]'>-{log.docsDeleted}</span>
</>
)}
{log.docsFailed > 0 && (
<>
{(log.docsAdded > 0 || log.docsUpdated > 0 || log.docsDeleted > 0) &&
' '}
<span className='text-[var(--text-error)]'>!{log.docsFailed}</span>
</>
)}
</>
) : (
'No changes'
)}
</span>
)}
{isRunning && <span className='text-[var(--text-muted)]'>In progress</span>}
</div>
{isError && log.errorMessage && (
<span className='truncate text-[var(--text-error)]'>{log.errorMessage}</span>
)}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1 @@
export { ConnectorsSection } from './connectors-section'
@@ -0,0 +1,8 @@
export const SYNC_INTERVALS = [
{ label: 'Live', value: 5, requiresMax: true },
{ label: 'Every hour', value: 60, requiresMax: false },
{ label: 'Every 6 hours', value: 360, requiresMax: false },
{ label: 'Daily', value: 1440, requiresMax: false },
{ label: 'Weekly', value: 10080, requiresMax: false },
{ label: 'Manual only', value: 0, requiresMax: false },
] as const
@@ -0,0 +1,158 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
interface DocumentContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpenInNewTab?: () => void
onOpenSource?: () => void
onRename?: () => void
onToggleEnabled?: () => void
onViewTags?: () => void
onDelete?: () => void
onAddDocument?: () => void
isDocumentEnabled?: boolean
hasDocument: boolean
disableRename?: boolean
disableToggleEnabled?: boolean
disableDelete?: boolean
disableAddDocument?: boolean
selectedCount?: number
enabledCount?: number
disabledCount?: number
}
/**
* Context menu for documents table.
* Shows document actions when right-clicking a row, or "Add Document" when right-clicking empty space.
* Supports batch operations when multiple documents are selected.
*/
export function DocumentContextMenu({
isOpen,
position,
onClose,
onOpenInNewTab,
onOpenSource,
onRename,
onToggleEnabled,
onViewTags,
onDelete,
onAddDocument,
isDocumentEnabled = true,
hasDocument,
disableRename = false,
disableToggleEnabled = false,
disableDelete = false,
disableAddDocument = false,
selectedCount = 1,
enabledCount = 0,
disabledCount = 0,
}: DocumentContextMenuProps) {
const isMultiSelect = selectedCount > 1
const getToggleLabel = () => {
if (isMultiSelect) {
if (disabledCount > 0) return 'Enable'
return 'Disable'
}
return isDocumentEnabled ? 'Disable' : 'Enable'
}
const hasNavigationSection = !isMultiSelect && (!!onOpenInNewTab || !!onOpenSource)
const hasEditSection = !isMultiSelect && (!!onRename || !!onViewTags)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!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()}
>
{hasDocument ? (
<>
{!isMultiSelect && onOpenInNewTab && (
<DropdownMenuItem onSelect={onOpenInNewTab}>
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}
{!isMultiSelect && onOpenSource && (
<DropdownMenuItem onSelect={onOpenSource}>
<SquareArrowUpRight />
Open source
</DropdownMenuItem>
)}
{hasNavigationSection &&
(hasEditSection || hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}
{!isMultiSelect && onRename && (
<DropdownMenuItem disabled={disableRename} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{!isMultiSelect && onViewTags && (
<DropdownMenuItem onSelect={onViewTags}>
<TagIcon />
Tags
</DropdownMenuItem>
)}
{hasEditSection && (hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}
{onToggleEnabled && (
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
<Eye />
{getToggleLabel()}
</DropdownMenuItem>
)}
{hasStateSection && hasDestructiveSection && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
)}
</>
) : (
onAddDocument && (
<DropdownMenuItem disabled={disableAddDocument} onSelect={onAddDocument}>
<Plus />
Add document
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { DocumentContextMenu } from './document-context-menu'
@@ -0,0 +1,498 @@
'use client'
import { useMemo, useState } from 'react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipModalTabs,
Skeleton,
Tooltip,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { ExternalLink, RotateCcw } from 'lucide-react'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import type {
ConfigFieldMap,
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
import {
useConnectorDocuments,
useExcludeConnectorDocument,
useRestoreConnectorDocument,
useUpdateConnector,
} from '@/hooks/queries/kb/connectors'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('EditConnectorModal')
/** Keys injected by the sync engine or modal state — not user-editable */
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds', '_canonicalModes'])
const CANONICAL_MODES_KEY = '_canonicalModes'
function readPersistedCanonicalModes(
sourceConfig: Record<string, unknown>
): Record<string, 'basic' | 'advanced'> {
const raw = sourceConfig[CANONICAL_MODES_KEY]
if (!raw || typeof raw !== 'object') return {}
const result: Record<string, 'basic' | 'advanced'> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (value === 'basic' || value === 'advanced') result[key] = value
}
return result
}
/**
* Deep equality for sourceConfig values (string, string[], or undefined/null).
*
* Empty string, empty array, and nullish are treated as equivalent to absence.
* When either side is an array (multi-value field), both sides are normalized
* to string[] via CSV-split-and-trim so a persisted legacy scalar `"ENG"`
* compares equal to an in-memory `["ENG"]` and a persisted CSV `"ENG,PROJ"`
* compares equal to `["ENG","PROJ"]`. Without this, opening edit on a
* pre-multi-select connector would falsely show unsaved changes.
*/
function valuesEqual(a: unknown, b: unknown): boolean {
const isEmpty = (v: unknown): boolean => {
if (v == null) return true
if (Array.isArray(v)) return v.length === 0
if (typeof v === 'string') return v.trim() === ''
return false
}
if (isEmpty(a) && isEmpty(b)) return true
const toArray = (v: unknown): string[] | null => {
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
if (typeof v === 'string') {
return v.split(',').flatMap((s) => {
const t = s.trim()
return t ? [t] : []
})
}
return null
}
if (Array.isArray(a) || Array.isArray(b)) {
const arrA = toArray(a) ?? []
const arrB = toArray(b) ?? []
if (arrA.length !== arrB.length) return false
/**
* Order-insensitive: the multi-select UI does not guarantee insertion order
* matches the server-returned order, so `["PROD","ENG"]` and `["ENG","PROD"]`
* should be treated as equal to avoid a false unsaved-changes state.
*/
const setA = new Set(arrA)
return arrB.every((v) => setA.has(v))
}
return a === b
}
function didCanonicalModesChange(
current: Record<string, 'basic' | 'advanced'>,
persisted: Record<string, 'basic' | 'advanced'>
): boolean {
const keys = new Set([...Object.keys(persisted), ...Object.keys(current)])
for (const key of keys) {
if ((current[key] ?? 'basic') !== (persisted[key] ?? 'basic')) return true
}
return false
}
interface EditConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
connector: ConnectorData
}
export function EditConnectorModal({
open,
onOpenChange,
knowledgeBaseId,
connector,
}: EditConnectorModalProps) {
const connectorConfig = CONNECTOR_META_REGISTRY[connector.connectorType] ?? null
const [activeTab, setActiveTab] = useState('settings')
const [syncInterval, setSyncInterval] = useState(connector.syncIntervalMinutes)
const [error, setError] = useState<string | null>(null)
/**
* Seeds from the stored canonical config. For canonical-pair fields (selector +
* manual input), both field IDs get the same value so toggling preserves it.
* Captured once on mount; editing state is owned by the hook afterward.
*/
const [initialSourceConfig] = useState<ConfigFieldMap>(() => {
const config: ConfigFieldMap = {}
if (!connectorConfig) {
for (const [key, value] of Object.entries(connector.sourceConfig)) {
if (INTERNAL_CONFIG_KEYS.has(key)) continue
if (Array.isArray(value)) {
config[key] = value.filter((v): v is string => typeof v === 'string')
} else {
config[key] = String(value ?? '')
}
}
return config
}
for (const field of connectorConfig.configFields) {
const canonicalId = field.canonicalParamId ?? field.id
if (INTERNAL_CONFIG_KEYS.has(canonicalId)) continue
const rawValue = connector.sourceConfig[canonicalId]
if (rawValue === undefined) continue
if (field.multi) {
if (Array.isArray(rawValue)) {
config[field.id] = rawValue.filter((v): v is string => typeof v === 'string')
} else if (typeof rawValue === 'string') {
config[field.id] = rawValue.split(',').flatMap((s) => {
const t = s.trim()
return t ? [t] : []
})
} else {
config[field.id] = []
}
} else {
config[field.id] = String(rawValue ?? '')
}
}
return config
})
const [initialCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(() =>
readPersistedCanonicalModes(connector.sourceConfig)
)
const {
sourceConfig,
canonicalModes,
canonicalGroups,
isFieldVisible,
handleFieldChange,
toggleCanonicalMode,
resolveSourceConfig,
} = useConnectorConfigFields({
connectorConfig,
initialSourceConfig,
initialCanonicalModes,
})
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
const persistedCanonicalModes = useMemo(
() => readPersistedCanonicalModes(connector.sourceConfig),
[connector.sourceConfig]
)
const hasChanges = useMemo(() => {
if (syncInterval !== connector.syncIntervalMinutes) return true
if (didCanonicalModesChange(canonicalModes, persistedCanonicalModes)) return true
const resolved = resolveSourceConfig()
for (const [key, value] of Object.entries(resolved)) {
if (!valuesEqual(connector.sourceConfig[key], value)) return true
}
return false
}, [
resolveSourceConfig,
syncInterval,
connector.syncIntervalMinutes,
connector.sourceConfig,
canonicalModes,
persistedCanonicalModes,
])
const handleSave = () => {
setError(null)
const updates: { sourceConfig?: Record<string, unknown>; syncIntervalMinutes?: number } = {}
if (syncInterval !== connector.syncIntervalMinutes) {
updates.syncIntervalMinutes = syncInterval
}
const resolved = resolveSourceConfig()
const changedEntries: Record<string, unknown> = {}
for (const [key, value] of Object.entries(resolved)) {
if (!valuesEqual(connector.sourceConfig[key], value)) changedEntries[key] = value
}
const modesChanged = didCanonicalModesChange(canonicalModes, persistedCanonicalModes)
if (Object.keys(changedEntries).length > 0 || modesChanged) {
const next: Record<string, unknown> = { ...connector.sourceConfig, ...changedEntries }
if (Object.keys(canonicalModes).length > 0) {
next[CANONICAL_MODES_KEY] = canonicalModes
} else {
delete next[CANONICAL_MODES_KEY]
}
updates.sourceConfig = next
}
if (Object.keys(updates).length === 0) {
onOpenChange(false)
return
}
updateConnector(
{ knowledgeBaseId, connectorId: connector.id, updates },
{
onSuccess: () => {
onOpenChange(false)
},
onError: (err) => {
logger.error('Failed to update connector', { error: err.message })
setError(err.message)
},
}
)
}
const displayName = connectorConfig?.name ?? connector.connectorType
const Icon = connectorConfig?.icon
return (
<ChipModal
open={open}
onOpenChange={(val) => !isSaving && onOpenChange(val)}
srTitle={`Edit ${displayName}`}
size='md'
>
<ChipModalHeader icon={Icon ?? null} onClose={() => onOpenChange(false)}>
Edit {displayName}
</ChipModalHeader>
<ChipModalBody>
<ChipModalTabs
tabs={[
{ value: 'settings', label: 'Settings' },
{ value: 'documents', label: 'Documents' },
]}
value={activeTab}
onChange={setActiveTab}
className='mx-2'
/>
{activeTab === 'settings' ? (
<SettingsTab
connectorConfig={connectorConfig}
sourceConfig={sourceConfig}
credentialId={connector.credentialId}
canonicalGroups={canonicalGroups}
canonicalModes={canonicalModes}
onToggleCanonicalMode={toggleCanonicalMode}
onFieldChange={handleFieldChange}
isFieldVisible={isFieldVisible}
syncInterval={syncInterval}
setSyncInterval={setSyncInterval}
hasMaxAccess={hasMaxAccess}
isSaving={isSaving}
error={error}
/>
) : (
<DocumentsTab knowledgeBaseId={knowledgeBaseId} connectorId={connector.id} />
)}
</ChipModalBody>
{activeTab === 'settings' && (
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isSaving}
primaryAction={{
label: isSaving ? 'Saving…' : 'Save',
onClick: handleSave,
disabled: !hasChanges || isSaving,
}}
/>
)}
</ChipModal>
)
}
interface SettingsTabProps {
connectorConfig: ConnectorMeta | null
sourceConfig: ConfigFieldMap
credentialId: string | null
canonicalGroups: Map<string, ConnectorConfigField[]>
canonicalModes: Record<string, 'basic' | 'advanced'>
onToggleCanonicalMode: (canonicalId: string) => void
onFieldChange: (fieldId: string, value: ConfigFieldValue) => void
isFieldVisible: (field: ConnectorConfigField) => boolean
syncInterval: number
setSyncInterval: (v: number) => void
hasMaxAccess: boolean
isSaving: boolean
error: string | null
}
function SettingsTab({
connectorConfig,
sourceConfig,
credentialId,
canonicalGroups,
canonicalModes,
onToggleCanonicalMode,
onFieldChange,
isFieldVisible,
syncInterval,
setSyncInterval,
hasMaxAccess,
isSaving,
error,
}: SettingsTabProps) {
return (
<>
{connectorConfig && (
<ConnectorConfigFields
connectorConfig={connectorConfig}
sourceConfig={sourceConfig}
credentialId={credentialId}
canonicalGroups={canonicalGroups}
canonicalModes={canonicalModes}
isFieldVisible={isFieldVisible}
onFieldChange={onFieldChange}
onToggleCanonicalMode={onToggleCanonicalMode}
disabled={isSaving}
/>
)}
<ChipModalField type='custom' title='Sync Frequency'>
<ButtonGroup
value={String(syncInterval)}
onValueChange={(val) => setSyncInterval(Number(val))}
>
{SYNC_INTERVALS.map((interval) => (
<ButtonGroupItem
key={interval.value}
value={String(interval.value)}
disabled={interval.requiresMax && !hasMaxAccess}
>
{interval.label}
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
</ButtonGroupItem>
))}
</ButtonGroup>
</ChipModalField>
<ChipModalError>{error}</ChipModalError>
</>
)
}
interface DocumentsTabProps {
knowledgeBaseId: string
connectorId: string
}
function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
const [filter, setFilter] = useState<'active' | 'excluded'>('active')
const { data, isLoading } = useConnectorDocuments(knowledgeBaseId, connectorId, {
includeExcluded: true,
})
const { mutate: excludeDoc, isPending: isExcluding } = useExcludeConnectorDocument()
const { mutate: restoreDoc, isPending: isRestoring } = useRestoreConnectorDocument()
const documents = useMemo(() => {
if (!data?.documents) return []
return data.documents.filter((d) => (filter === 'excluded' ? d.userExcluded : !d.userExcluded))
}, [data?.documents, filter])
const counts = data?.counts ?? { active: 0, excluded: 0 }
if (isLoading) {
return (
<div className='flex flex-col gap-2 px-2'>
<Skeleton className='h-7 w-[180px] rounded-md' />
<Skeleton className='h-[30px] w-full rounded-lg' />
<Skeleton className='h-[30px] w-full rounded-lg' />
<Skeleton className='h-[30px] w-full rounded-lg' />
</div>
)
}
return (
<div className='flex flex-col gap-3 px-2'>
<ButtonGroup value={filter} onValueChange={(val) => setFilter(val as 'active' | 'excluded')}>
<ButtonGroupItem value='active'>Active ({counts.active})</ButtonGroupItem>
<ButtonGroupItem value='excluded'>Excluded ({counts.excluded})</ButtonGroupItem>
</ButtonGroup>
<div className='max-h-[320px] min-h-0 overflow-y-auto [scrollbar-gutter:stable]'>
{documents.length === 0 ? (
<p className='rounded-lg bg-[var(--surface-3)] px-3 py-8 text-center text-[var(--text-muted)] text-small'>
{filter === 'excluded' ? 'No excluded documents' : 'No documents yet'}
</p>
) : (
<div className='flex flex-col gap-0.5 pr-1'>
{documents.map((doc) => (
<div
key={doc.id}
className='flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover-hover:bg-[var(--surface-active)]'
>
<div className='flex min-w-0 items-center gap-1.5'>
<span className='truncate text-[var(--text-primary)] text-small'>
{doc.filename}
</span>
{doc.sourceUrl && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={doc.sourceUrl}
target='_blank'
rel='noopener noreferrer'
className='flex size-5 flex-shrink-0 items-center justify-center rounded-md text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
>
<ExternalLink className='size-3' />
</a>
</Tooltip.Trigger>
<Tooltip.Content>Open source document</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<Button
variant='ghost-secondary'
size='sm'
className='flex-shrink-0'
disabled={doc.userExcluded ? isRestoring : isExcluding}
onClick={() =>
doc.userExcluded
? restoreDoc({ knowledgeBaseId, connectorId, documentIds: [doc.id] })
: excludeDoc({ knowledgeBaseId, connectorId, documentIds: [doc.id] })
}
>
{doc.userExcluded ? (
<>
<RotateCcw className='mr-1 size-3' />
Restore
</>
) : (
'Exclude'
)}
</Button>
</div>
))}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { EditConnectorModal } from './edit-connector-modal'
@@ -0,0 +1,8 @@
export { ActionBar } from './action-bar'
export { AddConnectorModal } from './add-connector-modal'
export { AddDocumentsModal } from './add-documents-modal'
export { BaseTagsModal } from './base-tags-modal'
export { ConnectorsSection } from './connectors-section'
export { DocumentContextMenu } from './document-context-menu'
export { EditConnectorModal } from './edit-connector-modal'
export { RenameDocumentModal } from './rename-document-modal'
@@ -0,0 +1,7 @@
export function MaxBadge() {
return (
<span className='ml-1 shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
Max
</span>
)
}
@@ -0,0 +1 @@
export { RenameDocumentModal } from './rename-document-modal'
@@ -0,0 +1,115 @@
'use client'
import { useRef, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
const logger = createLogger('RenameDocumentModal')
interface RenameDocumentModalProps {
open: boolean
onOpenChange: (open: boolean) => void
documentId: string
initialName: string
onSave: (documentId: string, newName: string) => Promise<void>
}
/**
* Modal for renaming a document.
* Only changes the display name, not the underlying storage key.
*/
export function RenameDocumentModal({
open,
onOpenChange,
documentId,
initialName,
onSave,
}: RenameDocumentModalProps) {
const [name, setName] = useState(initialName)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form fields when the modal opens (open transitions false → true).
const prevOpenRef = useRef(open)
if (prevOpenRef.current !== open) {
prevOpenRef.current = open
if (open) {
setName(initialName)
setError(null)
}
}
const handleSubmit = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
setError('Name is required')
return
}
if (trimmedName === initialName) {
onOpenChange(false)
return
}
setIsSubmitting(true)
setError(null)
try {
await onSave(documentId, trimmedName)
onOpenChange(false)
} catch (err) {
logger.error('Error renaming document:', err)
setError(getErrorMessage(err, 'Failed to rename document'))
} finally {
setIsSubmitting(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSubmit()
}
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Rename Document'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Rename Document</ChipModalHeader>
<ChipModalBody onKeyDown={handleKeyDown}>
<ChipModalField
type='input'
title='Name'
value={name}
onChange={(value) => {
setName(value)
setError(null)
}}
placeholder='Enter document name'
maxLength={255}
autoComplete='off'
disabled={isSubmitting}
required
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isSubmitting}
primaryAction={{
label: isSubmitting ? 'Renaming...' : 'Rename',
onClick: () => void handleSubmit(),
disabled: isSubmitting || !name?.trim() || name.trim() === initialName,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function KnowledgeBaseError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load knowledge base'
description='Something went wrong while loading this knowledge base. Please try again.'
loggerName='KnowledgeBaseError'
/>
)
}
@@ -0,0 +1,206 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { getDependsOnFields } from '@/blocks/utils'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
export type ConfigFieldValue = string | string[]
export type ConfigFieldMap = Record<string, ConfigFieldValue>
export interface UseConnectorConfigFieldsOptions {
connectorConfig: ConnectorMeta | null
initialSourceConfig?: ConfigFieldMap
initialCanonicalModes?: Record<string, 'basic' | 'advanced'>
}
export interface UseConnectorConfigFieldsResult {
sourceConfig: ConfigFieldMap
setSourceConfig: React.Dispatch<React.SetStateAction<ConfigFieldMap>>
canonicalModes: Record<string, 'basic' | 'advanced'>
setCanonicalModes: React.Dispatch<React.SetStateAction<Record<string, 'basic' | 'advanced'>>>
canonicalGroups: Map<string, ConnectorConfigField[]>
isFieldVisible: (field: ConnectorConfigField) => boolean
isFieldPopulated: (field: ConnectorConfigField) => boolean
handleFieldChange: (fieldId: string, value: ConfigFieldValue) => void
toggleCanonicalMode: (canonicalId: string) => void
resolveSourceConfig: () => Record<string, unknown>
}
function isMultiField(field: ConnectorConfigField | undefined): boolean {
return Boolean(field?.multi)
}
function emptyValue(field: ConnectorConfigField | undefined): ConfigFieldValue {
return isMultiField(field) ? [] : ''
}
/**
* Coerces a stored value to the shape expected by the field (string vs string[]).
* Multi fields accept either a string[] or a CSV string from advanced mode.
*/
function coerceForField(field: ConnectorConfigField, raw: unknown): ConfigFieldValue {
if (isMultiField(field)) {
if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string')
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return []
return trimmed.split(',').flatMap((s) => {
const t = s.trim()
return t ? [t] : []
})
}
return []
}
if (Array.isArray(raw)) {
return raw.filter((v): v is string => typeof v === 'string').join(',')
}
return raw == null ? '' : String(raw)
}
function isValuePopulated(value: ConfigFieldValue): boolean {
if (Array.isArray(value)) return value.length > 0
return value.trim().length > 0
}
/**
* Shared state and helpers for connector configuration fields that support
* canonical pairs (selector + manual input sharing a `canonicalParamId`) and
* multi-value fields (selector or short-input with `multi: true`).
*/
export function useConnectorConfigFields({
connectorConfig,
initialSourceConfig,
initialCanonicalModes,
}: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult {
const [sourceConfig, setSourceConfig] = useState<ConfigFieldMap>(() => initialSourceConfig ?? {})
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(
() => initialCanonicalModes ?? {}
)
const canonicalGroups = useMemo(() => {
const groups = new Map<string, ConnectorConfigField[]>()
if (!connectorConfig) return groups
for (const field of connectorConfig.configFields) {
if (!field.canonicalParamId) continue
const existing = groups.get(field.canonicalParamId)
if (existing) existing.push(field)
else groups.set(field.canonicalParamId, [field])
}
return groups
}, [connectorConfig])
const fieldsById = useMemo(() => {
const map = new Map<string, ConnectorConfigField>()
if (!connectorConfig) return map
for (const field of connectorConfig.configFields) map.set(field.id, field)
return map
}, [connectorConfig])
const dependentFieldIds = useMemo(() => {
const result = new Map<string, string[]>()
if (!connectorConfig) return result
const map = new Map<string, Set<string>>()
for (const field of connectorConfig.configFields) {
const deps = getDependsOnFields(field.dependsOn)
for (const dep of deps) {
const existing = map.get(dep) ?? new Set<string>()
existing.add(field.id)
if (field.canonicalParamId) {
for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) {
existing.add(sibling.id)
}
}
map.set(dep, existing)
}
}
for (const group of canonicalGroups.values()) {
const allDependents = new Set<string>()
for (const field of group) {
for (const dep of map.get(field.id) ?? []) {
allDependents.add(dep)
const depField = fieldsById.get(dep)
if (depField?.canonicalParamId) {
for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) {
allDependents.add(sibling.id)
}
}
}
}
if (allDependents.size > 0) {
for (const field of group) map.set(field.id, new Set(allDependents))
}
}
for (const [key, value] of map) result.set(key, [...value])
return result
}, [connectorConfig, canonicalGroups, fieldsById])
const isFieldVisible = useCallback(
(field: ConnectorConfigField): boolean => {
if (!field.canonicalParamId || !field.mode) return true
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
return field.mode === activeMode
},
[canonicalModes]
)
const isFieldPopulated = useCallback(
(field: ConnectorConfigField): boolean =>
isValuePopulated(sourceConfig[field.id] ?? emptyValue(field)),
[sourceConfig]
)
const handleFieldChange = (fieldId: string, value: ConfigFieldValue) => {
setSourceConfig((prev) => {
const next: ConfigFieldMap = { ...prev, [fieldId]: value }
const toClear = dependentFieldIds.get(fieldId)
if (toClear) {
for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId))
}
return next
})
}
const toggleCanonicalMode = (canonicalId: string) => {
setCanonicalModes((prev) => ({
...prev,
[canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced',
}))
}
const resolveSourceConfig = useCallback((): Record<string, unknown> => {
const resolved: Record<string, unknown> = {}
const processed = new Set<string>()
if (!connectorConfig) return resolved
for (const field of connectorConfig.configFields) {
if (field.canonicalParamId) {
if (processed.has(field.canonicalParamId)) continue
processed.add(field.canonicalParamId)
const group = canonicalGroups.get(field.canonicalParamId)
if (!group) continue
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
const activeField = group.find((f) => f.mode === activeMode) ?? group[0]
const raw = sourceConfig[activeField.id] ?? emptyValue(activeField)
resolved[field.canonicalParamId] = coerceForField(activeField, raw)
} else {
const raw = sourceConfig[field.id] ?? emptyValue(field)
resolved[field.id] = coerceForField(field, raw)
}
}
return resolved
}, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig])
return {
sourceConfig,
setSourceConfig,
canonicalModes,
setCanonicalModes,
canonicalGroups,
isFieldVisible,
isFieldPopulated,
handleFieldChange,
toggleCanonicalMode,
resolveSourceConfig,
}
}
@@ -0,0 +1,44 @@
'use client'
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import {
type BreadcrumbItem,
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const COLUMNS = [
{ id: 'name', header: 'Name', widthMultiplier: 0.8 },
{ id: 'size', header: 'Size', widthMultiplier: 0.75 },
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 },
{ id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 },
{ id: 'uploaded', header: 'Uploaded' },
{ id: 'status', header: 'Status', widthMultiplier: 0.75 },
{ id: 'tags', header: 'Tags' },
]
const ACTIONS: ChromeActionSpec[] = [
{ text: 'New connector', icon: Plus },
{ text: 'New documents', icon: Plus, variant: 'primary' },
]
const BREADCRUMBS: BreadcrumbItem[] = [
{ label: 'Knowledge Base', icon: Database, onClick: noop },
{ label: '…', icon: Database, terminal: true },
]
export default function KnowledgeBaseLoading() {
return (
<ResourceChromeFallback
icon={Database}
breadcrumbs={BREADCRUMBS}
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search documents...'
hasSort
hasFilter
/>
)
}
@@ -0,0 +1,27 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
interface PageProps {
params: Promise<{
id: string
}>
searchParams: Promise<{
kbName?: string
}>
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const { kbName } = await searchParams
return { title: kbName || 'Knowledge Base' }
}
export default async function KnowledgeBasePage({ params, searchParams }: PageProps) {
const [{ id }, { kbName }] = await Promise.all([params, searchParams])
return (
<Suspense fallback={null}>
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
</Suspense>
)
}
@@ -0,0 +1,82 @@
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
/**
* Co-located, typed URL query-param definitions for the knowledge base detail
* page. The client (`KnowledgeBase`) consumes this typed param definition as the
* single source of truth.
*
* `addConnector` is a deep-link that pre-opens the "add connector" modal. Its
* presence (even as an empty string) opens the modal; its value seeds the
* initial connector type. Mirrors the integrations `connect` deep-link pattern.
*/
export const addConnectorParam = {
key: ADD_CONNECTOR_SEARCH_PARAM,
parser: parseAsString,
} as const
/**
* `page` is the 1-based document-list pagination index for this knowledge base.
* Distinct from the single-document subview's `page` (a different route). The
* default page (1) clears from the URL.
*/
export const pageParam = {
key: 'page',
parser: parseAsInteger.withDefault(1),
} as const
/** Pagination view-state: clean URLs, no back-stack churn. */
export const pageUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
/** Document `enabled` filter buckets, matching the status filter dropdown. */
const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const
/** Sortable document columns, matching the `Resource` sort menu / `DocumentSortField`. */
export const KB_SORT_COLUMNS = [
'filename',
'fileSize',
'tokenCount',
'chunkCount',
'uploadedAt',
'enabled',
] as const
export type KbSortColumn = (typeof KB_SORT_COLUMNS)[number]
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort: most-recently-uploaded first (matches the document query default). */
export const DEFAULT_KB_SORT_COLUMN = 'uploadedAt'
export const DEFAULT_KB_SORT_DIRECTION = 'desc'
/**
* Grouped filter/search/sort URL state for the document list.
*
* - `q` is the document name search. The input is controlled directly by the
* instant nuqs value; only its URL write is debounced via `limitUrlUpdates`
* on the setter — never written on every keystroke.
* - `enabled` filters by processing/enabled status (`all` clears from the URL).
* - `sort` / `dir` follow the shared `sort`+`dir` convention. The defaults match
* the document query's default order; "no active sort" is derived in the
* component as `sort === DEFAULT && dir === DEFAULT`.
*
* `tagFilterEntries` is intentionally NOT represented here: it is an array of
* rich filter-rule objects (slot, field type, operator, value, value-to per
* row), too large/structured for the URL per the URL-state doctrine. It stays
* in local `useState`.
*/
export const documentFiltersParsers = {
q: parseAsString.withDefault(''),
enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'),
sort: parseAsStringLiteral(KB_SORT_COLUMNS).withDefault(DEFAULT_KB_SORT_COLUMN),
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KB_SORT_DIRECTION),
} as const
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
export const documentFiltersUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -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>
)
})
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function KnowledgeError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load knowledge'
description='Something went wrong while loading your knowledge bases. Please try again.'
loggerName='KnowledgeError'
/>
)
}
@@ -0,0 +1,472 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { useQueryClient } from '@tanstack/react-query'
import {
calculateUploadTimeoutMs,
DirectUploadError,
isTransientUploadError,
LARGE_FILE_THRESHOLD,
MULTIPART_MAX_RETRIES,
MULTIPART_RETRY_BACKOFF,
MULTIPART_RETRY_DELAY_MS,
normalizePresignedData,
type PresignedUploadInfo,
runUploadStrategy,
runWithConcurrency,
type UploadProgressEvent,
WHOLE_FILE_PARALLEL_UPLOADS,
} from '@/lib/uploads/client/direct-upload'
import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
const logger = createLogger('KnowledgeUpload')
const KB_BATCH_PRESIGNED_ENDPOINT = '/api/files/presigned/batch?type=knowledge-base'
const KB_API_UPLOAD_ENDPOINT = '/api/files/upload'
const BATCH_REQUEST_SIZE = 50
export interface UploadedFile {
filename: string
fileUrl: string
fileSize: number
mimeType: string
tag1?: string
tag2?: string
tag3?: string
tag4?: string
tag5?: string
tag6?: string
tag7?: string
}
export interface FileUploadStatus {
fileName: string
fileSize: number
status: 'pending' | 'uploading' | 'completed' | 'failed'
progress?: number
error?: string
}
export interface UploadProgress {
stage: 'idle' | 'uploading' | 'processing' | 'completing'
filesCompleted: number
totalFiles: number
currentFile?: string
currentFileProgress?: number
fileStatuses?: FileUploadStatus[]
}
export interface UploadError {
message: string
timestamp: number
code?: string
details?: unknown
}
export interface ProcessingOptions {
recipe?: string
}
export interface UseKnowledgeUploadOptions {
onError?: (error: UploadError) => void
workspaceId?: string
}
class KnowledgeUploadError extends Error {
constructor(
message: string,
public code: string,
public details?: unknown
) {
super(message)
this.name = 'KnowledgeUploadError'
}
}
class ProcessingError extends KnowledgeUploadError {
constructor(message: string, details?: unknown) {
super(message, 'PROCESSING_ERROR', details)
}
}
interface BatchPresignedFile {
fileName: string
contentType: string
fileSize: number
}
/**
* Fetch presigned upload data for the small files in `files`. Returns a sparse
* array aligned with the input: entries for files >= LARGE_FILE_THRESHOLD are
* `undefined` because those uploads use multipart and never consume a presigned
* single-PUT URL.
*/
const fetchBatchPresignedData = async (
files: File[],
workspaceId: string
): Promise<(PresignedUploadInfo | undefined)[]> => {
const result: (PresignedUploadInfo | undefined)[] = new Array(files.length).fill(undefined)
const smallFileIndices: number[] = []
for (let i = 0; i < files.length; i++) {
if (files[i].size <= LARGE_FILE_THRESHOLD) smallFileIndices.push(i)
}
if (smallFileIndices.length === 0) return result
const batchEndpoint = `${KB_BATCH_PRESIGNED_ENDPOINT}&workspaceId=${encodeURIComponent(workspaceId)}`
for (let start = 0; start < smallFileIndices.length; start += BATCH_REQUEST_SIZE) {
const batchIndices = smallFileIndices.slice(start, start + BATCH_REQUEST_SIZE)
const batchFiles = batchIndices.map((i) => files[i])
const body: { files: BatchPresignedFile[] } = {
files: batchFiles.map((file) => ({
fileName: file.name,
contentType: getFileContentType(file),
fileSize: file.size,
})),
}
const response = await fetch(batchEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`Batch presigned URL generation failed: ${response.statusText}`)
}
const { files: presignedItems } = (await response.json()) as { files: unknown[] }
batchIndices.forEach((fileIdx, batchPos) => {
result[fileIdx] = normalizePresignedData(presignedItems[batchPos], batchFiles[batchPos].name)
})
}
return result
}
/**
* Server-proxied fallback used when cloud storage isn't configured.
*/
const uploadFileThroughAPI = async (
file: File,
workspaceId: string | undefined
): Promise<{ filePath: string }> => {
const formData = new FormData()
formData.append('file', file)
formData.append('context', 'knowledge-base')
if (workspaceId) formData.append('workspaceId', workspaceId)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), calculateUploadTimeoutMs(file.size))
try {
const response = await fetch(KB_API_UPLOAD_ENDPOINT, {
method: 'POST',
body: formData,
signal: controller.signal,
})
if (!response.ok) {
let errorData: { message?: string; error?: string } | null = null
try {
errorData = (await response.json()) as { message?: string; error?: string }
} catch {}
throw new KnowledgeUploadError(
`Failed to upload ${file.name}: ${errorData?.message || errorData?.error || response.statusText}`,
'API_UPLOAD_ERROR',
errorData
)
}
const result = (await response.json()) as {
fileInfo?: { path?: string }
path?: string
}
const filePath = result.fileInfo?.path ?? result.path
if (!filePath) {
throw new KnowledgeUploadError(
`Invalid upload response for ${file.name}: missing file path`,
'API_UPLOAD_ERROR',
result
)
}
return { filePath }
} finally {
clearTimeout(timeoutId)
}
}
const toAbsoluteUrl = (path: string): string =>
path.startsWith('http') ? path : `${window.location.origin}${path}`
/**
* Build the {@link UploadedFile} payload from a `File`, carrying through any
* `tagN` fields the caller attached to it. Pure — kept at module scope so it
* isn't rebuilt on every render of the hook.
*/
const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => {
const f = file as File & {
tag1?: string
tag2?: string
tag3?: string
tag4?: string
tag5?: string
tag6?: string
tag7?: string
}
return {
filename: file.name,
fileUrl,
fileSize: file.size,
mimeType: getFileContentType(file),
tag1: f.tag1,
tag2: f.tag2,
tag3: f.tag3,
tag4: f.tag4,
tag5: f.tag5,
tag6: f.tag6,
tag7: f.tag7,
}
}
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const queryClient = useQueryClient()
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
stage: 'idle',
filesCompleted: 0,
totalFiles: 0,
})
const [uploadError, setUploadError] = useState<UploadError | null>(null)
const updateFileStatus = (fileIndex: number, patch: Partial<FileUploadStatus>) => {
setUploadProgress((prev) => ({
...prev,
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
idx === fileIndex ? { ...fs, ...patch } : fs
),
}))
}
const uploadOneFile = async (
file: File,
fileIndex: number,
presigned: PresignedUploadInfo | undefined
): Promise<UploadedFile> => {
if (!options.workspaceId) {
throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID')
}
const onProgress = (event: UploadProgressEvent) => {
updateFileStatus(fileIndex, { progress: event.percent, status: 'uploading' })
}
let attempt = 0
while (true) {
try {
const result = await runUploadStrategy({
file,
workspaceId: options.workspaceId,
context: 'knowledge-base',
presignedEndpoint: `/api/files/presigned?type=knowledge-base&workspaceId=${encodeURIComponent(options.workspaceId)}`,
presignedOverride: presigned,
onProgress,
})
return buildUploadedFile(file, toAbsoluteUrl(result.path))
} catch (error) {
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
const { filePath } = await uploadFileThroughAPI(file, options.workspaceId)
return buildUploadedFile(file, toAbsoluteUrl(filePath))
}
const retryable = isNetworkError(error) || isTransientUploadError(error)
if (isAbortError(error) || !retryable || attempt >= MULTIPART_MAX_RETRIES) {
throw error
}
const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt
attempt++
logger.warn(
`Upload retry ${attempt}/${MULTIPART_MAX_RETRIES} for ${file.name} in ${Math.round(delay / 1000)}s`
)
updateFileStatus(fileIndex, { progress: 0, status: 'uploading' })
await sleep(delay)
}
}
}
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
if (!options.workspaceId) {
throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID')
}
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
fileName: file.name,
fileSize: file.size,
status: 'pending',
progress: 0,
}))
setUploadProgress((prev) => ({ ...prev, fileStatuses }))
logger.info(`Starting batch upload of ${files.length} files`)
const presignedData = await fetchBatchPresignedData(files, options.workspaceId)
const settled = await runWithConcurrency(
files,
WHOLE_FILE_PARALLEL_UPLOADS,
async (file, index) => {
updateFileStatus(index, { status: 'uploading' })
try {
const uploaded = await uploadOneFile(file, index, presignedData[index])
setUploadProgress((prev) => ({
...prev,
filesCompleted: prev.filesCompleted + 1,
}))
updateFileStatus(index, { status: 'completed', progress: 100 })
return uploaded
} catch (error) {
updateFileStatus(index, { status: 'failed', error: getErrorMessage(error) })
throw error
}
}
)
const succeeded: UploadedFile[] = []
const failed: Array<{ file: File; error: Error }> = []
settled.forEach((result, idx) => {
if (result?.status === 'fulfilled') {
succeeded.push(result.value)
} else if (result?.status === 'rejected') {
failed.push({
file: files[idx],
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
})
}
})
if (failed.length > 0) {
throw new KnowledgeUploadError(
`Failed to upload ${failed.length} file(s)`,
'PARTIAL_UPLOAD_FAILURE',
{ failedFiles: failed, uploadedFiles: succeeded }
)
}
return succeeded
}
const uploadFiles = async (
files: File[],
knowledgeBaseId: string,
processingOptions: ProcessingOptions = {}
): Promise<UploadedFile[]> => {
if (files.length === 0) {
throw new KnowledgeUploadError('No files provided for upload', 'NO_FILES')
}
if (!knowledgeBaseId?.trim()) {
throw new KnowledgeUploadError('Knowledge base ID is required', 'INVALID_KB_ID')
}
try {
setIsUploading(true)
setUploadError(null)
setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length })
const uploadedFiles = await uploadFilesInBatches(files)
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))
// boundary-raw-fetch: bulk document-processing kickoff with dynamic recipe payload; response is consumed alongside the upload progress lifecycle and not modeled by a single contract
const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documents: uploadedFiles.map((f) => ({ ...f })),
processingOptions: {
recipe: processingOptions.recipe ?? 'default',
lang: 'en',
},
bulk: true,
}),
})
if (!processResponse.ok) {
let errorData: { error?: string; message?: string } | null = null
try {
errorData = (await processResponse.json()) as { error?: string; message?: string }
} catch {}
logger.error('Document processing failed:', {
status: processResponse.status,
error: errorData,
})
throw new ProcessingError(
`Failed to start document processing: ${errorData?.error || errorData?.message || 'Unknown error'}`,
errorData
)
}
const processResult = (await processResponse.json()) as {
success?: boolean
error?: string
data?: { documentsCreated?: unknown }
}
if (!processResult.success) {
throw new ProcessingError(
`Document processing failed: ${processResult.error || 'Unknown error'}`,
processResult
)
}
if (!processResult.data?.documentsCreated) {
throw new ProcessingError(
'Invalid processing response: missing document data',
processResult
)
}
setUploadProgress((prev) => ({ ...prev, stage: 'completing' }))
logger.info(`Successfully started processing ${uploadedFiles.length} documents`)
await queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) })
return uploadedFiles
} catch (err) {
logger.error('Error uploading documents:', err)
const error: UploadError =
err instanceof KnowledgeUploadError
? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() }
: err instanceof DirectUploadError
? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() }
: err instanceof Error
? { message: err.message, timestamp: Date.now() }
: { message: 'Unknown error occurred during upload', timestamp: Date.now() }
setUploadError(error)
options.onError?.(error)
throw err
} finally {
setIsUploading(false)
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
}
}
const clearError = useCallback(() => {
setUploadError(null)
}, [])
return {
isUploading,
uploadProgress,
uploadError,
uploadFiles,
clearError,
}
}
@@ -0,0 +1,636 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChipDropdownOption } from '@sim/emcn'
import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type {
FilterTag,
ResourceAction,
ResourceCell,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
EMPTY_CELL_PLACEHOLDER,
ownerCell,
Resource,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
CreateBaseModal,
DeleteKnowledgeBaseModal,
EditKnowledgeBaseModal,
KnowledgeBaseContextMenu,
KnowledgeListContextMenu,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
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 { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Knowledge')
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
docCount?: number
}
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents', widthMultiplier: 0.6 },
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
{ id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const KNOWLEDGE_BASE_ICON = <Database className='size-[14px]' />
const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [
{ value: 'all', label: 'All' },
{ value: 'connected', label: 'With connectors' },
{ value: 'unconnected', label: 'Without connectors' },
]
const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [
{ value: 'all', label: 'All' },
{ value: 'has-docs', label: 'Has documents' },
{ value: 'empty', label: 'Empty' },
]
const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small'
function connectorCell(connectorTypes?: string[]): ResourceCell {
if (!connectorTypes || connectorTypes.length === 0) {
return { label: EMPTY_CELL_PLACEHOLDER }
}
const entries = connectorTypes
.map((type) => ({ type, def: CONNECTOR_META_REGISTRY[type] }))
.filter(
(e): e is { type: string; def: NonNullable<(typeof CONNECTOR_META_REGISTRY)[string]> } =>
Boolean(e.def?.icon)
)
if (entries.length === 0) return { label: EMPTY_CELL_PLACEHOLDER }
const visibleEntries = entries.slice(0, 3)
const hiddenEntries = entries.slice(3)
return {
content: (
<div className='flex items-center gap-1'>
{visibleEntries.map(({ type, def }) => {
const Icon = def.icon
return (
<Tooltip.Root key={type}>
<Tooltip.Trigger asChild>
<span className='flex size-5 flex-shrink-0 items-center justify-center rounded-md bg-[var(--surface-4)] text-[var(--text-secondary)]'>
<Icon className='size-[13px]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content>{def.name}</Tooltip.Content>
</Tooltip.Root>
)
})}
{hiddenEntries.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='flex size-5 flex-shrink-0 items-center justify-center rounded-md bg-[var(--surface-4)] font-medium text-[var(--text-muted)] text-micro'>
+{hiddenEntries.length}
</span>
</Tooltip.Trigger>
<Tooltip.Content>{hiddenEntries.map(({ def }) => def.name).join(', ')}</Tooltip.Content>
</Tooltip.Root>
)}
</div>
),
}
}
export function Knowledge() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (permissionConfig.hideKnowledgeBaseTab) {
router.replace(`/workspace/${workspaceId}`)
}
}, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId])
const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
if (error) {
logger.error('Failed to load knowledge bases:', error)
}
const userPermissions = useUserPermissionsContext()
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [connectorFilter, setConnectorFilter] = useState<string[]>([])
const [contentFilter, setContentFilter] = useState<string[]>([])
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [searchInputValue, setSearchInputValue] = useState('')
const debouncedSearchQuery = useDebounce(searchInputValue, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
null
)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
isRowContextMenuOpenRef.current = isRowContextMenuOpen
const knowledgeBasesRef = useRef(knowledgeBases)
knowledgeBasesRef.current = knowledgeBases
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
activeKnowledgeBaseRef.current = activeKnowledgeBase
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
) {
return
}
handleListContextMenu(e)
},
[handleListContextMenu]
)
const handleOpenCreateModal = useCallback(() => {
setIsCreateModalOpen(true)
}, [])
const handleUpdateKnowledgeBase = useCallback(
async (id: string, name: string, description: string) => {
await updateKnowledgeBaseMutation({
knowledgeBaseId: id,
updates: { name, description },
})
logger.info(`Knowledge base updated: ${id}`)
},
[updateKnowledgeBaseMutation]
)
const handleDeleteKnowledgeBase = useCallback(
async (id: string) => {
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
logger.info(`Knowledge base deleted: ${id}`)
},
[deleteKnowledgeBaseMutation]
)
const processedKBs = useMemo(() => {
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
if (connectorFilter.length > 0) {
result = result.filter((kb) => {
const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0
if (connectorFilter.includes('connected') && hasConnectors) return true
if (connectorFilter.includes('unconnected') && !hasConnectors) return true
return false
})
}
if (contentFilter.length > 0) {
const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0
result = result.filter((kb) => {
if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true
if (contentFilter.includes('empty') && docCount(kb) === 0) return true
return false
})
}
if (ownerFilter.length > 0) {
result = result.filter((kb) => ownerFilter.includes(kb.userId))
}
const col = activeSort?.column ?? 'updated'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'documents':
cmp =
((a as KnowledgeBaseWithDocCount).docCount || 0) -
((b as KnowledgeBaseWithDocCount).docCount || 0)
break
case 'tokens':
cmp = (a.tokenCount || 0) - (b.tokenCount || 0)
break
case 'created':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updated':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'connectors':
cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0)
break
case 'owner':
cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare(
members?.find((m) => m.userId === b.userId)?.name ?? ''
)
break
}
return dir === 'asc' ? cmp : -cmp
})
}, [
knowledgeBases,
debouncedSearchQuery,
connectorFilter,
contentFilter,
ownerFilter,
activeSort,
members,
])
const rows: ResourceRow[] = useMemo(
() =>
processedKBs.map((kb) => {
const kbWithCount = kb as KnowledgeBaseWithDocCount
return {
id: kb.id,
cells: {
name: {
icon: KNOWLEDGE_BASE_ICON,
label: kb.name,
},
documents: {
label: String(kbWithCount.docCount || 0),
},
tokens: {
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
},
connectors: connectorCell(kb.connectorTypes),
created: timeCell(kb.createdAt),
owner: ownerCell(kb.userId, members),
updated: timeCell(kb.updatedAt),
},
}
}),
[processedKBs, members]
)
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpenRef.current) return
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
},
[router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
| KnowledgeBaseWithDocCount
| undefined
setActiveKnowledgeBase(kb ?? null)
handleRowCtxMenu(e)
},
[handleRowCtxMenu]
)
const handleConfirmDelete = useCallback(async () => {
const kb = activeKnowledgeBaseRef.current
if (!kb) return
setIsDeleting(true)
try {
await handleDeleteKnowledgeBase(kb.id)
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
} finally {
setIsDeleting(false)
}
}, [handleDeleteKnowledgeBase])
const handleCloseDeleteModal = useCallback(() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}, [])
const handleOpenInNewTab = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank')
}, [workspaceId])
const handleViewTags = useCallback(() => {
setIsTagsModalOpen(true)
}, [])
const handleCopyId = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (kb) {
navigator.clipboard.writeText(kb.id)
}
}, [])
const handleEdit = useCallback(() => {
setIsEditModalOpen(true)
}, [])
const handleDelete = useCallback(() => {
setIsDeleteModalOpen(true)
}, [])
const canEdit = userPermissions.canEdit === true
const headerActions: ResourceAction[] = useMemo(
() => [
{
text: 'New base',
icon: Plus,
onSelect: handleOpenCreateModal,
disabled: !canEdit,
variant: 'primary',
},
],
[handleOpenCreateModal, canEdit]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchInputValue,
onChange: setSearchInputValue,
onClearAll: () => setSearchInputValue(''),
placeholder: 'Search knowledge bases...',
}),
[searchInputValue]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'documents', label: 'Documents' },
{ id: 'tokens', label: 'Tokens' },
{ id: 'connectors', label: 'Connectors' },
{ id: 'created', label: 'Created' },
{ id: 'updated', label: 'Last Updated' },
{ id: 'owner', label: 'Owner' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const memberOptions: ChipDropdownOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const filterContent = useMemo(
() => (
<div className='flex w-[260px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-2'>
<div className='flex h-5 items-center justify-between'>
<span className={FILTER_SECTION_LABEL_CLASS}>Connectors</span>
{connectorFilter.length > 0 && (
<Button
variant='ghost'
onClick={() => setConnectorFilter([])}
className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-secondary)]'
>
Clear
</Button>
)}
</div>
<ChipDropdown
options={CONNECTOR_FILTER_OPTIONS}
value={connectorFilter[0] ?? 'all'}
onChange={(value) => setConnectorFilter(value === 'all' ? [] : [value])}
align='start'
fullWidth
flush
/>
</div>
<div className='flex flex-col gap-2'>
<div className='flex h-5 items-center justify-between'>
<span className={FILTER_SECTION_LABEL_CLASS}>Content</span>
{contentFilter.length > 0 && (
<Button
variant='ghost'
onClick={() => setContentFilter([])}
className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-secondary)]'
>
Clear
</Button>
)}
</div>
<ChipDropdown
options={CONTENT_FILTER_OPTIONS}
value={contentFilter[0] ?? 'all'}
onChange={(value) => setContentFilter(value === 'all' ? [] : [value])}
align='start'
fullWidth
flush
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-2'>
<div className='flex h-5 items-center justify-between'>
<span className={FILTER_SECTION_LABEL_CLASS}>Owner</span>
{ownerFilter.length > 0 && (
<Button
variant='ghost'
onClick={() => setOwnerFilter([])}
className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-secondary)]'
>
Clear
</Button>
)}
</div>
<ChipDropdown
multiple
options={memberOptions}
value={ownerFilter}
onChange={setOwnerFilter}
allLabel='All'
searchable
searchPlaceholder='Search members...'
align='start'
fullWidth
flush
/>
</div>
)}
</div>
),
[connectorFilter, contentFilter, ownerFilter, memberOptions]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (connectorFilter.length > 0) {
const label =
connectorFilter.length === 1
? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}`
: `Connectors: ${connectorFilter.length} types`
tags.push({ label, onRemove: () => setConnectorFilter([]) })
}
if (contentFilter.length > 0) {
const label =
contentFilter.length === 1
? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}`
: `Content: ${contentFilter.length} types`
tags.push({ label, onRemove: () => setContentFilter([]) })
}
if (ownerFilter.length > 0) {
const label =
ownerFilter.length === 1
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
: `Owner: ${ownerFilter.length} members`
tags.push({ label, onRemove: () => setOwnerFilter([]) })
}
return tags
}, [connectorFilter, contentFilter, ownerFilter, members])
return (
<>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header icon={Database} title='Knowledge Base' actions={headerActions} />
<Resource.Options
search={searchConfig}
sort={sortConfig}
filterTags={filterTags}
filter={{ content: filterContent }}
/>
<Resource.Table
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
/>
</Resource>
<KnowledgeListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddKnowledgeBase={handleOpenCreateModal}
disableAdd={!canEdit}
/>
{activeKnowledgeBase && (
<KnowledgeBaseContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpenInNewTab={handleOpenInNewTab}
onViewTags={handleViewTags}
onCopyId={handleCopyId}
onEdit={handleEdit}
onDelete={handleDelete}
showOpenInNewTab
showViewTags
showEdit
showDelete
disableEdit={!canEdit}
disableDelete={!canEdit}
/>
)}
{activeKnowledgeBase && (
<EditKnowledgeBaseModal
open={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
knowledgeBaseId={activeKnowledgeBase.id}
initialName={activeKnowledgeBase.name}
initialDescription={activeKnowledgeBase.description || ''}
chunkingConfig={activeKnowledgeBase.chunkingConfig}
onSave={handleUpdateKnowledgeBase}
/>
)}
{activeKnowledgeBase && (
<DeleteKnowledgeBaseModal
isOpen={isDeleteModalOpen}
onClose={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
knowledgeBaseName={activeKnowledgeBase.name}
/>
)}
{activeKnowledgeBase && (
<BaseTagsModal
open={isTagsModalOpen}
onOpenChange={setIsTagsModalOpen}
knowledgeBaseId={activeKnowledgeBase.id}
/>
)}
<CreateBaseModal open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen} />
</>
)
}
@@ -0,0 +1,34 @@
'use client'
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const COLUMNS = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents', widthMultiplier: 0.6 },
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
{ id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const ACTIONS: ChromeActionSpec[] = [{ text: 'New base', icon: Plus, variant: 'primary' }]
export default function KnowledgeLoading() {
return (
<ResourceChromeFallback
icon={Database}
title='Knowledge Base'
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search knowledge bases...'
hasSort
hasFilter
/>
)
}
@@ -0,0 +1,26 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
import { Knowledge } from './knowledge'
export const metadata: Metadata = {
title: 'Knowledge Base',
}
export default async function KnowledgePage({
params,
}: {
params: Promise<{ workspaceId: string }>
}) {
const { workspaceId } = await params
const queryClient = getQueryClient()
await prefetchKnowledgeBases(queryClient, workspaceId)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Knowledge />
</HydrationBoundary>
)
}
@@ -0,0 +1,28 @@
import type { QueryClient } from '@tanstack/react-query'
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/kb/knowledge'
/**
* Prefetches the workspace's knowledge-bases list under the same query key the
* client `useKnowledgeBasesQuery` hook uses (scope `active`), so the list paints
* populated on first render.
*
* The list carries `Date` fields, so it goes through the `/api/knowledge` route
* and caches the serialized wire shape — see {@link prefetchInternalJson}.
*/
export async function prefetchKnowledgeBases(
queryClient: QueryClient,
workspaceId: string
): Promise<void> {
await queryClient.prefetchQuery({
queryKey: knowledgeKeys.list(workspaceId, 'active'),
queryFn: async () => {
const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>(
`/api/knowledge?workspaceId=${workspaceId}&scope=active`
)
return result.data
},
staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME,
})
}
@@ -0,0 +1,55 @@
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type { SortOption, SortOrder } from '../components/constants'
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
docCount?: number
}
/**
* Sort knowledge bases by the specified field and order
*/
export function sortKnowledgeBases(
knowledgeBases: KnowledgeBaseData[],
sortBy: SortOption,
sortOrder: SortOrder
): KnowledgeBaseData[] {
return [...knowledgeBases].sort((a, b) => {
let comparison = 0
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'createdAt':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updatedAt':
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'docCount':
comparison =
((a as KnowledgeBaseWithDocCount).docCount || 0) -
((b as KnowledgeBaseWithDocCount).docCount || 0)
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
/**
* Filter knowledge bases by search query
*/
export function filterKnowledgeBases(
knowledgeBases: KnowledgeBaseData[],
searchQuery: string
): KnowledgeBaseData[] {
if (!searchQuery.trim()) {
return knowledgeBases
}
const query = searchQuery.toLowerCase()
return knowledgeBases.filter(
(kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query)
)
}