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