chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
+152
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ChunkContextMenu } from './chunk-context-menu'
|
||||
+300
@@ -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>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ChunkEditor } from './chunk-editor'
|
||||
+47
@@ -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...',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DeleteChunkModal } from './delete-chunk-modal'
|
||||
+748
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -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
+139
@@ -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'
|
||||
+496
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AddConnectorModal } from './add-connector-modal'
|
||||
+247
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AddDocumentsModal } from './add-documents-modal'
|
||||
+474
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { BaseTagsModal } from './base-tags-modal'
|
||||
+157
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields'
|
||||
+163
@@ -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'
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field'
|
||||
+716
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -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
|
||||
+158
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DocumentContextMenu } from './document-context-menu'
|
||||
+498
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { RenameDocumentModal } from './rename-document-modal'
|
||||
+115
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
|
||||
|
||||
export type ConfigFieldValue = string | string[]
|
||||
export type ConfigFieldMap = Record<string, ConfigFieldValue>
|
||||
|
||||
export interface UseConnectorConfigFieldsOptions {
|
||||
connectorConfig: ConnectorMeta | null
|
||||
initialSourceConfig?: ConfigFieldMap
|
||||
initialCanonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||
}
|
||||
|
||||
export interface UseConnectorConfigFieldsResult {
|
||||
sourceConfig: ConfigFieldMap
|
||||
setSourceConfig: React.Dispatch<React.SetStateAction<ConfigFieldMap>>
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>
|
||||
setCanonicalModes: React.Dispatch<React.SetStateAction<Record<string, 'basic' | 'advanced'>>>
|
||||
canonicalGroups: Map<string, ConnectorConfigField[]>
|
||||
isFieldVisible: (field: ConnectorConfigField) => boolean
|
||||
isFieldPopulated: (field: ConnectorConfigField) => boolean
|
||||
handleFieldChange: (fieldId: string, value: ConfigFieldValue) => void
|
||||
toggleCanonicalMode: (canonicalId: string) => void
|
||||
resolveSourceConfig: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
function isMultiField(field: ConnectorConfigField | undefined): boolean {
|
||||
return Boolean(field?.multi)
|
||||
}
|
||||
|
||||
function emptyValue(field: ConnectorConfigField | undefined): ConfigFieldValue {
|
||||
return isMultiField(field) ? [] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a stored value to the shape expected by the field (string vs string[]).
|
||||
* Multi fields accept either a string[] or a CSV string from advanced mode.
|
||||
*/
|
||||
function coerceForField(field: ConnectorConfigField, raw: unknown): ConfigFieldValue {
|
||||
if (isMultiField(field)) {
|
||||
if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string')
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return []
|
||||
return trimmed.split(',').flatMap((s) => {
|
||||
const t = s.trim()
|
||||
return t ? [t] : []
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((v): v is string => typeof v === 'string').join(',')
|
||||
}
|
||||
return raw == null ? '' : String(raw)
|
||||
}
|
||||
|
||||
function isValuePopulated(value: ConfigFieldValue): boolean {
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
return value.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state and helpers for connector configuration fields that support
|
||||
* canonical pairs (selector + manual input sharing a `canonicalParamId`) and
|
||||
* multi-value fields (selector or short-input with `multi: true`).
|
||||
*/
|
||||
export function useConnectorConfigFields({
|
||||
connectorConfig,
|
||||
initialSourceConfig,
|
||||
initialCanonicalModes,
|
||||
}: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult {
|
||||
const [sourceConfig, setSourceConfig] = useState<ConfigFieldMap>(() => initialSourceConfig ?? {})
|
||||
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(
|
||||
() => initialCanonicalModes ?? {}
|
||||
)
|
||||
|
||||
const canonicalGroups = useMemo(() => {
|
||||
const groups = new Map<string, ConnectorConfigField[]>()
|
||||
if (!connectorConfig) return groups
|
||||
for (const field of connectorConfig.configFields) {
|
||||
if (!field.canonicalParamId) continue
|
||||
const existing = groups.get(field.canonicalParamId)
|
||||
if (existing) existing.push(field)
|
||||
else groups.set(field.canonicalParamId, [field])
|
||||
}
|
||||
return groups
|
||||
}, [connectorConfig])
|
||||
|
||||
const fieldsById = useMemo(() => {
|
||||
const map = new Map<string, ConnectorConfigField>()
|
||||
if (!connectorConfig) return map
|
||||
for (const field of connectorConfig.configFields) map.set(field.id, field)
|
||||
return map
|
||||
}, [connectorConfig])
|
||||
|
||||
const dependentFieldIds = useMemo(() => {
|
||||
const result = new Map<string, string[]>()
|
||||
if (!connectorConfig) return result
|
||||
|
||||
const map = new Map<string, Set<string>>()
|
||||
for (const field of connectorConfig.configFields) {
|
||||
const deps = getDependsOnFields(field.dependsOn)
|
||||
for (const dep of deps) {
|
||||
const existing = map.get(dep) ?? new Set<string>()
|
||||
existing.add(field.id)
|
||||
if (field.canonicalParamId) {
|
||||
for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) {
|
||||
existing.add(sibling.id)
|
||||
}
|
||||
}
|
||||
map.set(dep, existing)
|
||||
}
|
||||
}
|
||||
for (const group of canonicalGroups.values()) {
|
||||
const allDependents = new Set<string>()
|
||||
for (const field of group) {
|
||||
for (const dep of map.get(field.id) ?? []) {
|
||||
allDependents.add(dep)
|
||||
const depField = fieldsById.get(dep)
|
||||
if (depField?.canonicalParamId) {
|
||||
for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) {
|
||||
allDependents.add(sibling.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allDependents.size > 0) {
|
||||
for (const field of group) map.set(field.id, new Set(allDependents))
|
||||
}
|
||||
}
|
||||
for (const [key, value] of map) result.set(key, [...value])
|
||||
return result
|
||||
}, [connectorConfig, canonicalGroups, fieldsById])
|
||||
|
||||
const isFieldVisible = useCallback(
|
||||
(field: ConnectorConfigField): boolean => {
|
||||
if (!field.canonicalParamId || !field.mode) return true
|
||||
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
|
||||
return field.mode === activeMode
|
||||
},
|
||||
[canonicalModes]
|
||||
)
|
||||
|
||||
const isFieldPopulated = useCallback(
|
||||
(field: ConnectorConfigField): boolean =>
|
||||
isValuePopulated(sourceConfig[field.id] ?? emptyValue(field)),
|
||||
[sourceConfig]
|
||||
)
|
||||
|
||||
const handleFieldChange = (fieldId: string, value: ConfigFieldValue) => {
|
||||
setSourceConfig((prev) => {
|
||||
const next: ConfigFieldMap = { ...prev, [fieldId]: value }
|
||||
const toClear = dependentFieldIds.get(fieldId)
|
||||
if (toClear) {
|
||||
for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCanonicalMode = (canonicalId: string) => {
|
||||
setCanonicalModes((prev) => ({
|
||||
...prev,
|
||||
[canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced',
|
||||
}))
|
||||
}
|
||||
|
||||
const resolveSourceConfig = useCallback((): Record<string, unknown> => {
|
||||
const resolved: Record<string, unknown> = {}
|
||||
const processed = new Set<string>()
|
||||
if (!connectorConfig) return resolved
|
||||
|
||||
for (const field of connectorConfig.configFields) {
|
||||
if (field.canonicalParamId) {
|
||||
if (processed.has(field.canonicalParamId)) continue
|
||||
processed.add(field.canonicalParamId)
|
||||
const group = canonicalGroups.get(field.canonicalParamId)
|
||||
if (!group) continue
|
||||
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
|
||||
const activeField = group.find((f) => f.mode === activeMode) ?? group[0]
|
||||
const raw = sourceConfig[activeField.id] ?? emptyValue(activeField)
|
||||
resolved[field.canonicalParamId] = coerceForField(activeField, raw)
|
||||
} else {
|
||||
const raw = sourceConfig[field.id] ?? emptyValue(field)
|
||||
resolved[field.id] = coerceForField(field, raw)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig])
|
||||
|
||||
return {
|
||||
sourceConfig,
|
||||
setSourceConfig,
|
||||
canonicalModes,
|
||||
setCanonicalModes,
|
||||
canonicalGroups,
|
||||
isFieldVisible,
|
||||
isFieldPopulated,
|
||||
handleFieldChange,
|
||||
toggleCanonicalMode,
|
||||
resolveSourceConfig,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from '@sim/emcn'
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import { noop } from '@sim/utils/helpers'
|
||||
import {
|
||||
type BreadcrumbItem,
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', header: 'Name', widthMultiplier: 0.8 },
|
||||
{ id: 'size', header: 'Size', widthMultiplier: 0.75 },
|
||||
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 },
|
||||
{ id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 },
|
||||
{ id: 'uploaded', header: 'Uploaded' },
|
||||
{ id: 'status', header: 'Status', widthMultiplier: 0.75 },
|
||||
{ id: 'tags', header: 'Tags' },
|
||||
]
|
||||
|
||||
const ACTIONS: ChromeActionSpec[] = [
|
||||
{ text: 'New connector', icon: Plus },
|
||||
{ text: 'New documents', icon: Plus, variant: 'primary' },
|
||||
]
|
||||
|
||||
const BREADCRUMBS: BreadcrumbItem[] = [
|
||||
{ label: 'Knowledge Base', icon: Database, onClick: noop },
|
||||
{ label: '…', icon: Database, terminal: true },
|
||||
]
|
||||
|
||||
export default function KnowledgeBaseLoading() {
|
||||
return (
|
||||
<ResourceChromeFallback
|
||||
icon={Database}
|
||||
breadcrumbs={BREADCRUMBS}
|
||||
columns={COLUMNS}
|
||||
actions={ACTIONS}
|
||||
searchPlaceholder='Search documents...'
|
||||
hasSort
|
||||
hasFilter
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
kbName?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
|
||||
const { kbName } = await searchParams
|
||||
return { title: kbName || 'Knowledge Base' }
|
||||
}
|
||||
|
||||
export default async function KnowledgeBasePage({ params, searchParams }: PageProps) {
|
||||
const [{ id }, { kbName }] = await Promise.all([params, searchParams])
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the knowledge base detail
|
||||
* page. The client (`KnowledgeBase`) consumes this typed param definition as the
|
||||
* single source of truth.
|
||||
*
|
||||
* `addConnector` is a deep-link that pre-opens the "add connector" modal. Its
|
||||
* presence (even as an empty string) opens the modal; its value seeds the
|
||||
* initial connector type. Mirrors the integrations `connect` deep-link pattern.
|
||||
*/
|
||||
export const addConnectorParam = {
|
||||
key: ADD_CONNECTOR_SEARCH_PARAM,
|
||||
parser: parseAsString,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* `page` is the 1-based document-list pagination index for this knowledge base.
|
||||
* Distinct from the single-document subview's `page` (a different route). The
|
||||
* default page (1) clears from the URL.
|
||||
*/
|
||||
export const pageParam = {
|
||||
key: 'page',
|
||||
parser: parseAsInteger.withDefault(1),
|
||||
} as const
|
||||
|
||||
/** Pagination view-state: clean URLs, no back-stack churn. */
|
||||
export const pageUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/** Document `enabled` filter buckets, matching the status filter dropdown. */
|
||||
const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const
|
||||
|
||||
/** Sortable document columns, matching the `Resource` sort menu / `DocumentSortField`. */
|
||||
export const KB_SORT_COLUMNS = [
|
||||
'filename',
|
||||
'fileSize',
|
||||
'tokenCount',
|
||||
'chunkCount',
|
||||
'uploadedAt',
|
||||
'enabled',
|
||||
] as const
|
||||
|
||||
export type KbSortColumn = (typeof KB_SORT_COLUMNS)[number]
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
/** Default sort: most-recently-uploaded first (matches the document query default). */
|
||||
export const DEFAULT_KB_SORT_COLUMN = 'uploadedAt'
|
||||
export const DEFAULT_KB_SORT_DIRECTION = 'desc'
|
||||
|
||||
/**
|
||||
* Grouped filter/search/sort URL state for the document list.
|
||||
*
|
||||
* - `q` is the document name search. The input is controlled directly by the
|
||||
* instant nuqs value; only its URL write is debounced via `limitUrlUpdates`
|
||||
* on the setter — never written on every keystroke.
|
||||
* - `enabled` filters by processing/enabled status (`all` clears from the URL).
|
||||
* - `sort` / `dir` follow the shared `sort`+`dir` convention. The defaults match
|
||||
* the document query's default order; "no active sort" is derived in the
|
||||
* component as `sort === DEFAULT && dir === DEFAULT`.
|
||||
*
|
||||
* `tagFilterEntries` is intentionally NOT represented here: it is an array of
|
||||
* rich filter-rule objects (slot, field type, operator, value, value-to per
|
||||
* row), too large/structured for the URL per the URL-state doctrine. It stays
|
||||
* in local `useState`.
|
||||
*/
|
||||
export const documentFiltersParsers = {
|
||||
q: parseAsString.withDefault(''),
|
||||
enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'),
|
||||
sort: parseAsStringLiteral(KB_SORT_COLUMNS).withDefault(DEFAULT_KB_SORT_COLUMN),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KB_SORT_DIRECTION),
|
||||
} as const
|
||||
|
||||
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
|
||||
export const documentFiltersUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Badge, DocumentAttachment, Tooltip } from '@sim/emcn'
|
||||
import { formatAbsoluteDate, formatRelativeTime } from '@sim/utils/formatting'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorMeta } from '@/connectors/types'
|
||||
import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal'
|
||||
import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal'
|
||||
import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu'
|
||||
|
||||
interface BaseCardProps {
|
||||
id?: string
|
||||
title: string
|
||||
docCount: number
|
||||
description: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
connectorTypes?: string[]
|
||||
chunkingConfig?: { maxSize: number; minSize: number; overlap: number }
|
||||
onUpdate?: (id: string, name: string, description: string) => Promise<void>
|
||||
onDelete?: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTOR_TYPES: string[] = []
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for a knowledge base card
|
||||
*/
|
||||
export function BaseCardSkeleton() {
|
||||
return (
|
||||
<div className='group flex h-full cursor-pointer flex-col gap-3 rounded-sm bg-[var(--surface-3)] px-2 py-1.5 transition-colors hover-hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='h-[17px] w-[120px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
<div className='h-[22px] w-[90px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='size-[12px] animate-pulse rounded-xs bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
<div className='h-[15px] w-[45px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
<div className='h-[15px] w-[120px] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<div className='flex h-[36px] flex-col gap-1.5'>
|
||||
<div className='h-[15px] w-full animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
<div className='h-[15px] w-[75%] animate-pulse rounded-sm bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders multiple knowledge base card skeletons as a fragment
|
||||
*/
|
||||
export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<BaseCardSkeleton key={i} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base card component displaying overview information
|
||||
*/
|
||||
export function BaseCard({
|
||||
id,
|
||||
title,
|
||||
docCount,
|
||||
description,
|
||||
updatedAt,
|
||||
connectorTypes = EMPTY_CONNECTOR_TYPES,
|
||||
chunkingConfig,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: BaseCardProps) {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
menuRef,
|
||||
handleContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const connectorEntries = useMemo(
|
||||
() =>
|
||||
connectorTypes.reduce<{ type: string; config: ConnectorMeta }[]>((acc, type) => {
|
||||
const config = CONNECTOR_META_REGISTRY[type]
|
||||
if (config?.icon) acc.push({ type, config })
|
||||
return acc
|
||||
}, []),
|
||||
[connectorTypes]
|
||||
)
|
||||
const visibleConnectorEntries = useMemo(() => connectorEntries.slice(0, 3), [connectorEntries])
|
||||
const hiddenConnectorLabels = useMemo(
|
||||
() => connectorEntries.slice(3).map(({ type, config }) => config?.name ?? type),
|
||||
[connectorEntries]
|
||||
)
|
||||
const hiddenConnectorCount = hiddenConnectorLabels.length
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
})
|
||||
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
|
||||
|
||||
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
|
||||
|
||||
const navigateToKnowledgeBase = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isContextMenuOpen) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
router.push(href)
|
||||
},
|
||||
[isContextMenuOpen, router, href]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
router.push(href)
|
||||
}
|
||||
},
|
||||
[router, href]
|
||||
)
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
window.open(href, '_blank')
|
||||
}, [href])
|
||||
|
||||
const handleViewTags = useCallback(() => {
|
||||
setIsTagsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!id || !onDelete) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await onDelete(id)
|
||||
setIsDeleteModalOpen(false)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [id, onDelete])
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (knowledgeBaseId: string, name: string, newDescription: string) => {
|
||||
if (!onUpdate) return
|
||||
await onUpdate(knowledgeBaseId, name, newDescription)
|
||||
},
|
||||
[onUpdate]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='h-full cursor-pointer'
|
||||
onClick={navigateToKnowledgeBase}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-kb-card
|
||||
>
|
||||
<div className='group flex h-full flex-col gap-3 rounded-sm bg-[var(--surface-3)] px-2 py-1.5 transition-colors hover-hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<h3 className='min-w-0 flex-1 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
{title}
|
||||
</h3>
|
||||
{shortId && <Badge className='flex-shrink-0 rounded-sm text-caption'>{shortId}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='flex items-center gap-1.5 text-[var(--text-tertiary)] text-caption'>
|
||||
<DocumentAttachment className='size-[12px]' />
|
||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||
</span>
|
||||
{updatedAt && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='text-[var(--text-tertiary)] text-caption'>
|
||||
last updated: {formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<p className='line-clamp-2 h-[36px] flex-1 text-[var(--text-tertiary)] text-caption leading-[18px]'>
|
||||
{description}
|
||||
</p>
|
||||
{connectorEntries.length > 0 && (
|
||||
<div className='[&>*:not(:first-child)]:-ml-1 flex flex-shrink-0 items-center'>
|
||||
{visibleConnectorEntries.map(({ type, config }) => {
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Tooltip.Root key={type}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='flex size-5 flex-shrink-0 items-center justify-center rounded-md border border-[var(--surface-3)] bg-[var(--surface-5)] transition-[border-color] group-hover:border-[var(--surface-4)] dark:border-[var(--surface-4)] dark:group-hover:border-[var(--surface-5)]'>
|
||||
<Icon className='size-[12px] text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{config.name}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})}
|
||||
{hiddenConnectorCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='flex size-5 flex-shrink-0 items-center justify-center rounded-md border border-[var(--surface-3)] bg-[var(--surface-5)] font-medium text-[var(--text-muted)] text-micro transition-[border-color] group-hover:border-[var(--surface-4)] dark:border-[var(--surface-4)] dark:group-hover:border-[var(--surface-5)]'>
|
||||
+{hiddenConnectorCount}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{hiddenConnectorLabels.join(', ')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KnowledgeBaseContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onCopyId={id ? () => navigator.clipboard.writeText(id) : undefined}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
showOpenInNewTab={true}
|
||||
showViewTags={!!id}
|
||||
showEdit={!!onUpdate}
|
||||
showDelete={!!onDelete}
|
||||
disableEdit={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
/>
|
||||
|
||||
{id && onUpdate && (
|
||||
<EditKnowledgeBaseModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
knowledgeBaseId={id}
|
||||
initialName={title}
|
||||
initialDescription={description === 'No description provided' ? '' : description}
|
||||
chunkingConfig={chunkingConfig}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{id && onDelete && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<BaseTagsModal
|
||||
open={isTagsModalOpen}
|
||||
onOpenChange={setIsTagsModalOpen}
|
||||
knowledgeBaseId={id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card'
|
||||
@@ -0,0 +1,20 @@
|
||||
export const filterButtonClass =
|
||||
'w-full justify-between rounded-[10px] border-[var(--surface-6)] bg-[var(--white)] font-normal text-sm dark:border-[var(--border-muted)] dark:bg-[var(--surface-2)]'
|
||||
|
||||
export const dropdownContentClass =
|
||||
'w-[220px] rounded-lg border-[var(--surface-6)] bg-[var(--white)] p-0 shadow-xs dark:border-[var(--border-muted)] dark:bg-[var(--surface-2)]'
|
||||
|
||||
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
|
||||
|
||||
export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'updatedAt-desc', label: 'Last Updated' },
|
||||
{ value: 'createdAt-desc', label: 'Newest First' },
|
||||
{ value: 'createdAt-asc', label: 'Oldest First' },
|
||||
{ value: 'name-asc', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'docCount-desc', label: 'Most Documents' },
|
||||
{ value: 'docCount-asc', label: 'Least Documents' },
|
||||
] as const
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ChipCombobox,
|
||||
ChipInput,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipTextarea,
|
||||
type ComboboxOption,
|
||||
cn,
|
||||
Loader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { type FieldErrors, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import type { StrategyOptions } from '@/lib/chunkers/types'
|
||||
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
|
||||
const logger = createLogger('CreateBaseModal')
|
||||
|
||||
interface CreateBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto (detect from content)' },
|
||||
{ value: 'text', label: 'Text (word boundary splitting)' },
|
||||
{ value: 'recursive', label: 'Recursive (configurable separators)' },
|
||||
{ value: 'sentence', label: 'Sentence' },
|
||||
{ value: 'token', label: 'Token (fixed-size)' },
|
||||
{ value: 'regex', label: 'Regex (custom pattern)' },
|
||||
] as const
|
||||
|
||||
const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({
|
||||
label: o.label,
|
||||
value: o.value,
|
||||
}))
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
||||
description: z
|
||||
.string()
|
||||
.max(
|
||||
KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH,
|
||||
`Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less`
|
||||
)
|
||||
.optional(),
|
||||
minChunkSize: z
|
||||
.number()
|
||||
.min(1, 'Min chunk size must be at least 1 character')
|
||||
.max(2000, 'Min chunk size must be less than 2000 characters'),
|
||||
maxChunkSize: z
|
||||
.number()
|
||||
.min(100, 'Max chunk size must be at least 100 tokens')
|
||||
.max(4000, 'Max chunk size must be less than 4000 tokens'),
|
||||
overlapSize: z
|
||||
.number()
|
||||
.min(0, 'Overlap must be non-negative')
|
||||
.max(500, 'Overlap must be less than 500 tokens'),
|
||||
strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).default('auto'),
|
||||
regexPattern: z.string().optional(),
|
||||
regexStrictBoundaries: z.boolean().default(false),
|
||||
customSeparators: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const maxChunkSizeInChars = data.maxChunkSize * 4
|
||||
return data.minChunkSize < maxChunkSizeInChars
|
||||
},
|
||||
{
|
||||
message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)',
|
||||
path: ['minChunkSize'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.overlapSize < data.maxChunkSize
|
||||
},
|
||||
{
|
||||
message: 'Overlap must be less than max chunk size',
|
||||
path: ['overlapSize'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.strategy === 'regex' && !data.regexPattern?.trim()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Regex pattern is required when using the regex strategy',
|
||||
path: ['regexPattern'],
|
||||
}
|
||||
)
|
||||
|
||||
type FormInputValues = z.input<typeof FormSchema>
|
||||
type FormValues = z.output<typeof FormSchema>
|
||||
|
||||
interface SubmitStatus {
|
||||
type: 'success' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
|
||||
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
const { uploadFiles, isUploading, uploadProgress, uploadError, clearError } = useKnowledgeUpload({
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
clearError()
|
||||
}
|
||||
onOpenChange(open)
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormInputValues, unknown, FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 100,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
strategy: 'auto',
|
||||
regexPattern: '',
|
||||
regexStrictBoundaries: false,
|
||||
customSeparators: '',
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const nameValue = watch('name')
|
||||
const strategyValue = watch('strategy')
|
||||
const regexStrictBoundariesValue = watch('regexStrictBoundaries')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 100,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
strategy: 'auto',
|
||||
regexPattern: '',
|
||||
regexStrictBoundaries: false,
|
||||
customSeparators: '',
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = (selectedFiles: File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
if (!selectedFiles || selectedFiles.length === 0) return
|
||||
|
||||
try {
|
||||
const newFiles: File[] = []
|
||||
let hasError = false
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
const validationError = validateKnowledgeBaseFile(file)
|
||||
if (validationError) {
|
||||
setFileError(validationError)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
newFiles.push(file)
|
||||
}
|
||||
|
||||
if (!hasError && newFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing files:', error)
|
||||
setFileError('An error occurred while processing files. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
createKnowledgeBaseMutation.isPending || deleteKnowledgeBaseMutation.isPending || isUploading
|
||||
|
||||
const onInvalid = (formErrors: FieldErrors<FormInputValues>) => {
|
||||
const firstMessage = Object.values(formErrors).find(
|
||||
(fieldError) => typeof fieldError?.message === 'string'
|
||||
)?.message
|
||||
toast.error(
|
||||
typeof firstMessage === 'string' ? firstMessage : 'Please fix the highlighted fields'
|
||||
)
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
const strategyOptions: StrategyOptions | undefined =
|
||||
data.strategy === 'regex' && data.regexPattern
|
||||
? {
|
||||
pattern: data.regexPattern,
|
||||
...(data.regexStrictBoundaries && { strictBoundaries: true }),
|
||||
}
|
||||
: data.strategy === 'recursive' && data.customSeparators?.trim()
|
||||
? {
|
||||
separators: data.customSeparators
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
chunkingConfig: {
|
||||
maxSize: data.maxChunkSize,
|
||||
minSize: data.minChunkSize,
|
||||
overlap: data.overlapSize,
|
||||
...(data.strategy !== 'auto' && { strategy: data.strategy }),
|
||||
...(strategyOptions && { strategyOptions }),
|
||||
},
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||
recipe: 'default',
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
} catch (uploadError) {
|
||||
logger.error('File upload failed, deleting knowledge base:', uploadError)
|
||||
try {
|
||||
await deleteKnowledgeBaseMutation.mutateAsync({
|
||||
knowledgeBaseId: newKnowledgeBase.id,
|
||||
})
|
||||
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
|
||||
} catch (deleteError) {
|
||||
logger.error('Failed to delete orphaned knowledge base:', deleteError)
|
||||
}
|
||||
throw uploadError
|
||||
}
|
||||
}
|
||||
|
||||
setFiles([])
|
||||
|
||||
handleClose(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
type: 'error',
|
||||
message: getErrorMessage(error, 'An unknown error occurred'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={handleClose} srTitle='Create Knowledge Base' size='lg'>
|
||||
<ChipModalHeader onClose={() => handleClose(false)}>Create Knowledge Base</ChipModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className='flex min-h-0 flex-1 flex-col'>
|
||||
<button type='submit' hidden disabled={isSubmitting || !nameValue?.trim()} />
|
||||
<ChipModalBody>
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
className='-left-[9999px] pointer-events-none absolute opacity-0'
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
<ChipModalField type='custom' title='Name'>
|
||||
<ChipInput
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
error={Boolean(errors.name)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField type='custom' title='Description' error={errors.description?.message}>
|
||||
<ChipTextarea
|
||||
placeholder='Describe this knowledge base (optional)'
|
||||
rows={4}
|
||||
{...register('description')}
|
||||
error={Boolean(errors.description)}
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<ChipModalField type='custom' title='Min Chunk Size (characters)' className='flex-1'>
|
||||
<ChipInput
|
||||
type='number'
|
||||
min={1}
|
||||
max={2000}
|
||||
step={1}
|
||||
placeholder='100'
|
||||
{...register('minChunkSize', { valueAsNumber: true })}
|
||||
error={Boolean(errors.minChunkSize)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField type='custom' title='Max Chunk Size (tokens)' className='flex-1'>
|
||||
<ChipInput
|
||||
type='number'
|
||||
min={100}
|
||||
max={4000}
|
||||
step={1}
|
||||
placeholder='1024'
|
||||
{...register('maxChunkSize', { valueAsNumber: true })}
|
||||
error={Boolean(errors.maxChunkSize)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</div>
|
||||
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Overlap (tokens)'
|
||||
hint='1 token ≈ 4 characters. Max chunk size and overlap are in tokens.'
|
||||
>
|
||||
<ChipInput
|
||||
type='number'
|
||||
min={0}
|
||||
max={500}
|
||||
step={1}
|
||||
placeholder='200'
|
||||
{...register('overlapSize', { valueAsNumber: true })}
|
||||
error={Boolean(errors.overlapSize)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Chunking Strategy'
|
||||
hint='Auto detects the best strategy based on file content type.'
|
||||
>
|
||||
<ChipCombobox
|
||||
options={STRATEGY_COMBOBOX_OPTIONS}
|
||||
value={strategyValue}
|
||||
onChange={(value) => setValue('strategy', value as FormValues['strategy'])}
|
||||
dropdownWidth='trigger'
|
||||
align='start'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
{strategyValue === 'regex' && (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Regex Pattern'
|
||||
error={errors.regexPattern?.message}
|
||||
hint='Text will be split at each match of this regex pattern.'
|
||||
>
|
||||
<ChipInput
|
||||
placeholder='e.g. \\n\\n or (?<=\\})\\s*(?=\\{)'
|
||||
{...register('regexPattern')}
|
||||
error={Boolean(errors.regexPattern)}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Chunk Boundaries'
|
||||
hint='Preserve boundaries exactly. Recommended when each match is a discrete record (e.g. one QA pair per chunk).'
|
||||
>
|
||||
<label
|
||||
htmlFor='regexStrictBoundaries'
|
||||
className='flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
<Checkbox
|
||||
id='regexStrictBoundaries'
|
||||
checked={regexStrictBoundariesValue}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('regexStrictBoundaries', checked === true)
|
||||
}
|
||||
/>
|
||||
<span className='text-[var(--text-primary)] text-sm'>
|
||||
Each match is its own chunk (don't merge)
|
||||
</span>
|
||||
</label>
|
||||
</ChipModalField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{strategyValue === 'recursive' && (
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Custom Separators (optional)'
|
||||
hint='Comma-separated list of delimiters in priority order. Leave empty for default separators.'
|
||||
>
|
||||
<ChipInput
|
||||
placeholder='e.g. \n\n, \n, . , '
|
||||
{...register('customSeparators')}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
<ChipModalField
|
||||
type='file'
|
||||
title='Upload Documents'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
multiple
|
||||
onChange={processFiles}
|
||||
description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)'
|
||||
error={fileError}
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<ChipModalField type='custom' title='Selected Files'>
|
||||
<div className='space-y-2'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
const isProcessing = fileStatus?.status === 'uploading'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${file.name}-${file.size}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-sm border p-2',
|
||||
isFailed && 'border-[var(--text-error)]'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-caption',
|
||||
isFailed && 'text-[var(--text-error)]'
|
||||
)}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 text-[var(--text-muted)] text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
{isProcessing ? (
|
||||
<Loader className='size-4 text-[var(--text-muted)]' animate />
|
||||
) : (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='size-4 p-0'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
<ChipModalError>{uploadError?.message || submitStatus?.message}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleClose(false)}
|
||||
cancelDisabled={isSubmitting}
|
||||
primaryAction={{
|
||||
label: isSubmitting
|
||||
? isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||
: uploadProgress.stage === 'processing'
|
||||
? 'Processing...'
|
||||
: 'Creating...'
|
||||
: 'Creating...'
|
||||
: 'Create',
|
||||
onClick: handleSubmit(onSubmit, onInvalid),
|
||||
disabled: isSubmitting || !nameValue?.trim(),
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</ChipModal>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateBaseModal } from './create-base-modal'
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { ChipConfirmModal } from '@sim/emcn'
|
||||
|
||||
interface DeleteKnowledgeBaseModalProps {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Callback when modal should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when delete is confirmed
|
||||
*/
|
||||
onConfirm: () => void
|
||||
/**
|
||||
* Whether the delete operation is in progress
|
||||
*/
|
||||
isDeleting: boolean
|
||||
/**
|
||||
* Name of the knowledge base being deleted
|
||||
*/
|
||||
knowledgeBaseName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation modal for knowledge base items.
|
||||
* Displays a warning message and confirmation buttons.
|
||||
*/
|
||||
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
knowledgeBaseName,
|
||||
}: DeleteKnowledgeBaseModalProps) {
|
||||
return (
|
||||
<ChipConfirmModal
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
srTitle='Delete Knowledge Base'
|
||||
title='Delete Knowledge Base'
|
||||
text={
|
||||
knowledgeBaseName
|
||||
? [
|
||||
'Are you sure you want to delete ',
|
||||
{ text: knowledgeBaseName, bold: true },
|
||||
'? ',
|
||||
{
|
||||
text: 'All associated documents, chunks, and embeddings will be removed.',
|
||||
error: true,
|
||||
},
|
||||
' You can restore it from Recently Deleted in Settings.',
|
||||
]
|
||||
: [
|
||||
'Are you sure you want to delete this knowledge base? ',
|
||||
{
|
||||
text: 'All associated documents, chunks, and embeddings will be removed.',
|
||||
error: true,
|
||||
},
|
||||
' You can restore it from Recently Deleted in Settings.',
|
||||
]
|
||||
}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: onConfirm,
|
||||
pending: isDeleting,
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal'
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
|
||||
import type { ChunkingConfig } from '@/lib/knowledge/types'
|
||||
|
||||
const logger = createLogger('EditKnowledgeBaseModal')
|
||||
|
||||
interface EditKnowledgeBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
initialName: string
|
||||
initialDescription: string
|
||||
chunkingConfig?: ChunkingConfig
|
||||
onSave: (id: string, name: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for editing knowledge base name and description
|
||||
*/
|
||||
export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
initialName,
|
||||
initialDescription,
|
||||
chunkingConfig,
|
||||
onSave,
|
||||
}: EditKnowledgeBaseModalProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [description, setDescription] = useState(initialDescription)
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
const [descriptionError, setDescriptionError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Seed the fields only on the closed → open transition (render-phase reset),
|
||||
* so a prop change while the modal is open never clobbers in-progress edits.
|
||||
*/
|
||||
const prevOpenRef = useRef(open)
|
||||
if (prevOpenRef.current !== open) {
|
||||
prevOpenRef.current = open
|
||||
if (open) {
|
||||
setName(initialName)
|
||||
setDescription(initialDescription)
|
||||
setNameError(null)
|
||||
setDescriptionError(null)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const validate = (): string | null => {
|
||||
let firstError: string | null = null
|
||||
|
||||
if (!name.trim()) {
|
||||
setNameError('Name is required')
|
||||
firstError ??= 'Name is required'
|
||||
} else if (name.trim().length > 100) {
|
||||
setNameError('Name must be less than 100 characters')
|
||||
firstError ??= 'Name must be less than 100 characters'
|
||||
} else {
|
||||
setNameError(null)
|
||||
}
|
||||
|
||||
if (description.length > KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH) {
|
||||
const message = `Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less`
|
||||
setDescriptionError(message)
|
||||
firstError ??= message
|
||||
} else {
|
||||
setDescriptionError(null)
|
||||
}
|
||||
|
||||
return firstError
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validationError = validate()
|
||||
if (validationError) {
|
||||
toast.error(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave(knowledgeBaseId, name.trim(), description.trim())
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Error updating knowledge base:', err)
|
||||
setError(getErrorMessage(err, 'Failed to update knowledge base'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0
|
||||
const isDirty = name !== initialName || description !== initialDescription
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Edit Knowledge Base'>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>Edit Knowledge Base</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder='Enter knowledge base name'
|
||||
required
|
||||
error={nameError ?? undefined}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<ChipModalField
|
||||
type='textarea'
|
||||
title='Description'
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder='Describe this knowledge base (optional)'
|
||||
rows={4}
|
||||
error={descriptionError ?? undefined}
|
||||
/>
|
||||
{chunkingConfig && (
|
||||
<ChipModalField type='custom' title='Chunking Configuration'>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>Max Size</p>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{chunkingConfig.maxSize.toLocaleString()}
|
||||
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
|
||||
tokens
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>Min Size</p>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{chunkingConfig.minSize.toLocaleString()}
|
||||
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
|
||||
chars
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>Overlap</p>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{chunkingConfig.overlap.toLocaleString()}
|
||||
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
|
||||
tokens
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
)}
|
||||
<ChipModalError>{error}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
cancelDisabled={isSubmitting}
|
||||
primaryAction={{
|
||||
label: isSubmitting ? 'Saving...' : 'Save',
|
||||
onClick: handleSubmit,
|
||||
disabled: !isValid || !isDirty || isSubmitting,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal'
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { SVGProps } from 'react'
|
||||
import {
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
SUPPORTED_VIDEO_EXTENSIONS,
|
||||
} from '@/lib/uploads/utils/validation'
|
||||
|
||||
export function PdfIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='4' y='2' width='16' height='20' rx='2' stroke='currentColor' strokeWidth='1.5' />
|
||||
<text
|
||||
x='12'
|
||||
y='12'
|
||||
textAnchor='middle'
|
||||
dominantBaseline='central'
|
||||
fontSize='5.5'
|
||||
fontWeight='bold'
|
||||
fontFamily='Arial, sans-serif'
|
||||
letterSpacing='0.5'
|
||||
fill='currentColor'
|
||||
>
|
||||
PDF
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M16 9H8' />
|
||||
<path d='M16 13H8' />
|
||||
<path d='M16 17H8' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function XlsxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='3' y='3' width='18' height='18' rx='2' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='3' y1='9' x2='21' y2='9' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='3' y1='15' x2='21' y2='15' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='9' y1='3' x2='9' y2='21' stroke='currentColor' strokeWidth='1.5' />
|
||||
<line x1='15' y1='3' x2='15' y2='21' stroke='currentColor' strokeWidth='1.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CsvIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='3' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='13' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='3' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='13' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='3' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
<rect x='13' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TxtIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
<path d='M16 13H8' />
|
||||
<path d='M12 17H8' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PptxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<rect x='2' y='4' width='20' height='16' rx='2' />
|
||||
<line x1='6' y1='9' x2='18' y2='9' />
|
||||
<line x1='8' y1='14' x2='16' y2='14' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AudioIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<line x1='4' y1='14' x2='4' y2='10' />
|
||||
<line x1='8' y1='17' x2='8' y2='7' />
|
||||
<line x1='12' y1='15' x2='12' y2='9' />
|
||||
<line x1='16' y1='18' x2='16' y2='6' />
|
||||
<line x1='20' y1='14' x2='20' y2='10' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VideoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='2' y='4' width='20' height='16' rx='2' stroke='currentColor' strokeWidth='1.5' />
|
||||
<path d='M10 9l5 3-5 3V9Z' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HtmlIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M8 8l-4 4 4 4' />
|
||||
<path d='M16 8l4 4-4 4' />
|
||||
<line x1='14' y1='4' x2='10' y2='20' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function JsonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M8 3H7a2 2 0 0 0-2 2v4c0 1.1-.9 2-2 2 1.1 0 2 .9 2 2v4a2 2 0 0 0 2 2h1' />
|
||||
<path d='M16 3h1a2 2 0 0 1 2 2v4c0 1.1.9 2 2 2-1.1 0-2 .9-2 2v4a2 2 0 0 1-2 2h-1' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<rect x='2' y='4' width='20' height='16' rx='3' stroke='currentColor' strokeWidth='1.5' />
|
||||
<path
|
||||
d='M6 15V9l3 3.5L12 9v6'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M17 9v6m-2-2l2 2 2-2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DefaultFileIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
|
||||
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function getDocumentIcon(
|
||||
mimeType: string,
|
||||
filename: string
|
||||
): (props: SVGProps<SVGSVGElement>) => React.JSX.Element {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (
|
||||
mimeType.startsWith('audio/') ||
|
||||
(extension &&
|
||||
SUPPORTED_AUDIO_EXTENSIONS.includes(extension as (typeof SUPPORTED_AUDIO_EXTENSIONS)[number]))
|
||||
) {
|
||||
return AudioIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType.startsWith('video/') ||
|
||||
(extension &&
|
||||
SUPPORTED_VIDEO_EXTENSIONS.includes(extension as (typeof SUPPORTED_VIDEO_EXTENSIONS)[number]))
|
||||
) {
|
||||
return VideoIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf' || extension === 'pdf') {
|
||||
return PdfIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
mimeType === 'application/msword' ||
|
||||
extension === 'docx' ||
|
||||
extension === 'doc'
|
||||
) {
|
||||
return DocxIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
mimeType === 'application/vnd.ms-excel' ||
|
||||
extension === 'xlsx' ||
|
||||
extension === 'xls'
|
||||
) {
|
||||
return XlsxIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/csv' || extension === 'csv') {
|
||||
return CsvIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/plain' || extension === 'txt') {
|
||||
return TxtIcon
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
mimeType === 'application/vnd.ms-powerpoint' ||
|
||||
extension === 'pptx' ||
|
||||
extension === 'ppt'
|
||||
) {
|
||||
return PptxIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/html' || extension === 'html' || extension === 'htm') {
|
||||
return HtmlIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'application/json' || extension === 'json') {
|
||||
return JsonIcon
|
||||
}
|
||||
|
||||
if (mimeType === 'text/markdown' || extension === 'md' || extension === 'mdx') {
|
||||
return MarkdownIcon
|
||||
}
|
||||
|
||||
return DefaultFileIcon
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
AudioIcon,
|
||||
CsvIcon,
|
||||
DefaultFileIcon,
|
||||
DocxIcon,
|
||||
getDocumentIcon,
|
||||
HtmlIcon,
|
||||
JsonIcon,
|
||||
MarkdownIcon,
|
||||
PdfIcon,
|
||||
PptxIcon,
|
||||
TxtIcon,
|
||||
VideoIcon,
|
||||
XlsxIcon,
|
||||
} from './document-icons'
|
||||
@@ -0,0 +1,7 @@
|
||||
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card'
|
||||
export { CreateBaseModal } from './create-base-modal'
|
||||
export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal'
|
||||
export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal'
|
||||
export { getDocumentIcon } from './icons'
|
||||
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu'
|
||||
export { KnowledgeListContextMenu } from './knowledge-list-context-menu'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu'
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Duplicate, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
|
||||
|
||||
interface KnowledgeBaseContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onOpenInNewTab?: () => void
|
||||
onViewTags?: () => void
|
||||
onCopyId?: () => void
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
showOpenInNewTab?: boolean
|
||||
showViewTags?: boolean
|
||||
showEdit?: boolean
|
||||
showDelete?: boolean
|
||||
disableEdit?: boolean
|
||||
disableDelete?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for knowledge base cards.
|
||||
* Displays open in new tab, view tags, edit, and delete options.
|
||||
*/
|
||||
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onViewTags,
|
||||
onCopyId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showOpenInNewTab = true,
|
||||
showViewTags = true,
|
||||
showEdit = true,
|
||||
showDelete = true,
|
||||
disableEdit = false,
|
||||
disableDelete = false,
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
|
||||
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId
|
||||
const hasEditSection = showEdit && !!onEdit
|
||||
const hasDestructiveSection = showDelete && !!onDelete
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{hasNavigationSection && (
|
||||
<DropdownMenuItem onSelect={onOpenInNewTab!}>
|
||||
<SquareArrowUpRight />
|
||||
Open in new tab
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasNavigationSection && (hasInfoSection || hasEditSection || hasDestructiveSection) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
|
||||
{showViewTags && onViewTags && (
|
||||
<DropdownMenuItem onSelect={onViewTags}>
|
||||
<TagIcon />
|
||||
View tags
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCopyId && (
|
||||
<DropdownMenuItem onSelect={onCopyId}>
|
||||
<Duplicate />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasInfoSection && (hasEditSection || hasDestructiveSection) && <DropdownMenuSeparator />}
|
||||
|
||||
{showEdit && onEdit && (
|
||||
<DropdownMenuItem disabled={disableEdit} onSelect={onEdit}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasEditSection && hasDestructiveSection && <DropdownMenuSeparator />}
|
||||
{showDelete && onDelete && (
|
||||
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { KnowledgeListContextMenu } from './knowledge-list-context-menu'
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
|
||||
import { Plus } from '@sim/emcn/icons'
|
||||
|
||||
interface KnowledgeListContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onAddKnowledgeBase?: () => void
|
||||
disableAdd?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for the knowledge base list page.
|
||||
* Displays "Add knowledge base" option when right-clicking on empty space.
|
||||
*/
|
||||
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onAddKnowledgeBase,
|
||||
disableAdd = false,
|
||||
}: KnowledgeListContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{onAddKnowledgeBase && (
|
||||
<DropdownMenuItem disabled={disableAdd} onSelect={onAddKnowledgeBase}>
|
||||
<Plus />
|
||||
Add knowledge base
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
export default function KnowledgeError({ error, reset }: ErrorBoundaryProps) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={error}
|
||||
reset={reset}
|
||||
title='Failed to load knowledge'
|
||||
description='Something went wrong while loading your knowledge bases. Please try again.'
|
||||
loggerName='KnowledgeError'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
calculateUploadTimeoutMs,
|
||||
DirectUploadError,
|
||||
isTransientUploadError,
|
||||
LARGE_FILE_THRESHOLD,
|
||||
MULTIPART_MAX_RETRIES,
|
||||
MULTIPART_RETRY_BACKOFF,
|
||||
MULTIPART_RETRY_DELAY_MS,
|
||||
normalizePresignedData,
|
||||
type PresignedUploadInfo,
|
||||
runUploadStrategy,
|
||||
runWithConcurrency,
|
||||
type UploadProgressEvent,
|
||||
WHOLE_FILE_PARALLEL_UPLOADS,
|
||||
} from '@/lib/uploads/client/direct-upload'
|
||||
import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
|
||||
const logger = createLogger('KnowledgeUpload')
|
||||
|
||||
const KB_BATCH_PRESIGNED_ENDPOINT = '/api/files/presigned/batch?type=knowledge-base'
|
||||
const KB_API_UPLOAD_ENDPOINT = '/api/files/upload'
|
||||
|
||||
const BATCH_REQUEST_SIZE = 50
|
||||
|
||||
export interface UploadedFile {
|
||||
filename: string
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
tag1?: string
|
||||
tag2?: string
|
||||
tag3?: string
|
||||
tag4?: string
|
||||
tag5?: string
|
||||
tag6?: string
|
||||
tag7?: string
|
||||
}
|
||||
|
||||
export interface FileUploadStatus {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
status: 'pending' | 'uploading' | 'completed' | 'failed'
|
||||
progress?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
stage: 'idle' | 'uploading' | 'processing' | 'completing'
|
||||
filesCompleted: number
|
||||
totalFiles: number
|
||||
currentFile?: string
|
||||
currentFileProgress?: number
|
||||
fileStatuses?: FileUploadStatus[]
|
||||
}
|
||||
|
||||
export interface UploadError {
|
||||
message: string
|
||||
timestamp: number
|
||||
code?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export interface ProcessingOptions {
|
||||
recipe?: string
|
||||
}
|
||||
|
||||
export interface UseKnowledgeUploadOptions {
|
||||
onError?: (error: UploadError) => void
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
class KnowledgeUploadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'KnowledgeUploadError'
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessingError extends KnowledgeUploadError {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(message, 'PROCESSING_ERROR', details)
|
||||
}
|
||||
}
|
||||
|
||||
interface BatchPresignedFile {
|
||||
fileName: string
|
||||
contentType: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch presigned upload data for the small files in `files`. Returns a sparse
|
||||
* array aligned with the input: entries for files >= LARGE_FILE_THRESHOLD are
|
||||
* `undefined` because those uploads use multipart and never consume a presigned
|
||||
* single-PUT URL.
|
||||
*/
|
||||
const fetchBatchPresignedData = async (
|
||||
files: File[],
|
||||
workspaceId: string
|
||||
): Promise<(PresignedUploadInfo | undefined)[]> => {
|
||||
const result: (PresignedUploadInfo | undefined)[] = new Array(files.length).fill(undefined)
|
||||
const smallFileIndices: number[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (files[i].size <= LARGE_FILE_THRESHOLD) smallFileIndices.push(i)
|
||||
}
|
||||
if (smallFileIndices.length === 0) return result
|
||||
|
||||
const batchEndpoint = `${KB_BATCH_PRESIGNED_ENDPOINT}&workspaceId=${encodeURIComponent(workspaceId)}`
|
||||
|
||||
for (let start = 0; start < smallFileIndices.length; start += BATCH_REQUEST_SIZE) {
|
||||
const batchIndices = smallFileIndices.slice(start, start + BATCH_REQUEST_SIZE)
|
||||
const batchFiles = batchIndices.map((i) => files[i])
|
||||
const body: { files: BatchPresignedFile[] } = {
|
||||
files: batchFiles.map((file) => ({
|
||||
fileName: file.name,
|
||||
contentType: getFileContentType(file),
|
||||
fileSize: file.size,
|
||||
})),
|
||||
}
|
||||
|
||||
const response = await fetch(batchEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Batch presigned URL generation failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { files: presignedItems } = (await response.json()) as { files: unknown[] }
|
||||
batchIndices.forEach((fileIdx, batchPos) => {
|
||||
result[fileIdx] = normalizePresignedData(presignedItems[batchPos], batchFiles[batchPos].name)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-proxied fallback used when cloud storage isn't configured.
|
||||
*/
|
||||
const uploadFileThroughAPI = async (
|
||||
file: File,
|
||||
workspaceId: string | undefined
|
||||
): Promise<{ filePath: string }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'knowledge-base')
|
||||
if (workspaceId) formData.append('workspaceId', workspaceId)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), calculateUploadTimeoutMs(file.size))
|
||||
|
||||
try {
|
||||
const response = await fetch(KB_API_UPLOAD_ENDPOINT, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: { message?: string; error?: string } | null = null
|
||||
try {
|
||||
errorData = (await response.json()) as { message?: string; error?: string }
|
||||
} catch {}
|
||||
throw new KnowledgeUploadError(
|
||||
`Failed to upload ${file.name}: ${errorData?.message || errorData?.error || response.statusText}`,
|
||||
'API_UPLOAD_ERROR',
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
fileInfo?: { path?: string }
|
||||
path?: string
|
||||
}
|
||||
const filePath = result.fileInfo?.path ?? result.path
|
||||
if (!filePath) {
|
||||
throw new KnowledgeUploadError(
|
||||
`Invalid upload response for ${file.name}: missing file path`,
|
||||
'API_UPLOAD_ERROR',
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
return { filePath }
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
const toAbsoluteUrl = (path: string): string =>
|
||||
path.startsWith('http') ? path : `${window.location.origin}${path}`
|
||||
|
||||
/**
|
||||
* Build the {@link UploadedFile} payload from a `File`, carrying through any
|
||||
* `tagN` fields the caller attached to it. Pure — kept at module scope so it
|
||||
* isn't rebuilt on every render of the hook.
|
||||
*/
|
||||
const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => {
|
||||
const f = file as File & {
|
||||
tag1?: string
|
||||
tag2?: string
|
||||
tag3?: string
|
||||
tag4?: string
|
||||
tag5?: string
|
||||
tag6?: string
|
||||
tag7?: string
|
||||
}
|
||||
return {
|
||||
filename: file.name,
|
||||
fileUrl,
|
||||
fileSize: file.size,
|
||||
mimeType: getFileContentType(file),
|
||||
tag1: f.tag1,
|
||||
tag2: f.tag2,
|
||||
tag3: f.tag3,
|
||||
tag4: f.tag4,
|
||||
tag5: f.tag5,
|
||||
tag6: f.tag6,
|
||||
tag7: f.tag7,
|
||||
}
|
||||
}
|
||||
|
||||
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
|
||||
stage: 'idle',
|
||||
filesCompleted: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
const [uploadError, setUploadError] = useState<UploadError | null>(null)
|
||||
|
||||
const updateFileStatus = (fileIndex: number, patch: Partial<FileUploadStatus>) => {
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
|
||||
idx === fileIndex ? { ...fs, ...patch } : fs
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const uploadOneFile = async (
|
||||
file: File,
|
||||
fileIndex: number,
|
||||
presigned: PresignedUploadInfo | undefined
|
||||
): Promise<UploadedFile> => {
|
||||
if (!options.workspaceId) {
|
||||
throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID')
|
||||
}
|
||||
|
||||
const onProgress = (event: UploadProgressEvent) => {
|
||||
updateFileStatus(fileIndex, { progress: event.percent, status: 'uploading' })
|
||||
}
|
||||
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
const result = await runUploadStrategy({
|
||||
file,
|
||||
workspaceId: options.workspaceId,
|
||||
context: 'knowledge-base',
|
||||
presignedEndpoint: `/api/files/presigned?type=knowledge-base&workspaceId=${encodeURIComponent(options.workspaceId)}`,
|
||||
presignedOverride: presigned,
|
||||
onProgress,
|
||||
})
|
||||
return buildUploadedFile(file, toAbsoluteUrl(result.path))
|
||||
} catch (error) {
|
||||
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
|
||||
const { filePath } = await uploadFileThroughAPI(file, options.workspaceId)
|
||||
return buildUploadedFile(file, toAbsoluteUrl(filePath))
|
||||
}
|
||||
|
||||
const retryable = isNetworkError(error) || isTransientUploadError(error)
|
||||
if (isAbortError(error) || !retryable || attempt >= MULTIPART_MAX_RETRIES) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt
|
||||
attempt++
|
||||
logger.warn(
|
||||
`Upload retry ${attempt}/${MULTIPART_MAX_RETRIES} for ${file.name} in ${Math.round(delay / 1000)}s`
|
||||
)
|
||||
updateFileStatus(fileIndex, { progress: 0, status: 'uploading' })
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
|
||||
if (!options.workspaceId) {
|
||||
throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID')
|
||||
}
|
||||
|
||||
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}))
|
||||
|
||||
setUploadProgress((prev) => ({ ...prev, fileStatuses }))
|
||||
|
||||
logger.info(`Starting batch upload of ${files.length} files`)
|
||||
|
||||
const presignedData = await fetchBatchPresignedData(files, options.workspaceId)
|
||||
|
||||
const settled = await runWithConcurrency(
|
||||
files,
|
||||
WHOLE_FILE_PARALLEL_UPLOADS,
|
||||
async (file, index) => {
|
||||
updateFileStatus(index, { status: 'uploading' })
|
||||
try {
|
||||
const uploaded = await uploadOneFile(file, index, presignedData[index])
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
filesCompleted: prev.filesCompleted + 1,
|
||||
}))
|
||||
updateFileStatus(index, { status: 'completed', progress: 100 })
|
||||
return uploaded
|
||||
} catch (error) {
|
||||
updateFileStatus(index, { status: 'failed', error: getErrorMessage(error) })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const succeeded: UploadedFile[] = []
|
||||
const failed: Array<{ file: File; error: Error }> = []
|
||||
settled.forEach((result, idx) => {
|
||||
if (result?.status === 'fulfilled') {
|
||||
succeeded.push(result.value)
|
||||
} else if (result?.status === 'rejected') {
|
||||
failed.push({
|
||||
file: files[idx],
|
||||
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (failed.length > 0) {
|
||||
throw new KnowledgeUploadError(
|
||||
`Failed to upload ${failed.length} file(s)`,
|
||||
'PARTIAL_UPLOAD_FAILURE',
|
||||
{ failedFiles: failed, uploadedFiles: succeeded }
|
||||
)
|
||||
}
|
||||
|
||||
return succeeded
|
||||
}
|
||||
|
||||
const uploadFiles = async (
|
||||
files: File[],
|
||||
knowledgeBaseId: string,
|
||||
processingOptions: ProcessingOptions = {}
|
||||
): Promise<UploadedFile[]> => {
|
||||
if (files.length === 0) {
|
||||
throw new KnowledgeUploadError('No files provided for upload', 'NO_FILES')
|
||||
}
|
||||
if (!knowledgeBaseId?.trim()) {
|
||||
throw new KnowledgeUploadError('Knowledge base ID is required', 'INVALID_KB_ID')
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true)
|
||||
setUploadError(null)
|
||||
setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length })
|
||||
|
||||
const uploadedFiles = await uploadFilesInBatches(files)
|
||||
|
||||
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))
|
||||
|
||||
// boundary-raw-fetch: bulk document-processing kickoff with dynamic recipe payload; response is consumed alongside the upload progress lifecycle and not modeled by a single contract
|
||||
const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documents: uploadedFiles.map((f) => ({ ...f })),
|
||||
processingOptions: {
|
||||
recipe: processingOptions.recipe ?? 'default',
|
||||
lang: 'en',
|
||||
},
|
||||
bulk: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!processResponse.ok) {
|
||||
let errorData: { error?: string; message?: string } | null = null
|
||||
try {
|
||||
errorData = (await processResponse.json()) as { error?: string; message?: string }
|
||||
} catch {}
|
||||
logger.error('Document processing failed:', {
|
||||
status: processResponse.status,
|
||||
error: errorData,
|
||||
})
|
||||
throw new ProcessingError(
|
||||
`Failed to start document processing: ${errorData?.error || errorData?.message || 'Unknown error'}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
const processResult = (await processResponse.json()) as {
|
||||
success?: boolean
|
||||
error?: string
|
||||
data?: { documentsCreated?: unknown }
|
||||
}
|
||||
|
||||
if (!processResult.success) {
|
||||
throw new ProcessingError(
|
||||
`Document processing failed: ${processResult.error || 'Unknown error'}`,
|
||||
processResult
|
||||
)
|
||||
}
|
||||
|
||||
if (!processResult.data?.documentsCreated) {
|
||||
throw new ProcessingError(
|
||||
'Invalid processing response: missing document data',
|
||||
processResult
|
||||
)
|
||||
}
|
||||
|
||||
setUploadProgress((prev) => ({ ...prev, stage: 'completing' }))
|
||||
logger.info(`Successfully started processing ${uploadedFiles.length} documents`)
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) })
|
||||
|
||||
return uploadedFiles
|
||||
} catch (err) {
|
||||
logger.error('Error uploading documents:', err)
|
||||
|
||||
const error: UploadError =
|
||||
err instanceof KnowledgeUploadError
|
||||
? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() }
|
||||
: err instanceof DirectUploadError
|
||||
? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() }
|
||||
: err instanceof Error
|
||||
? { message: err.message, timestamp: Date.now() }
|
||||
: { message: 'Unknown error occurred during upload', timestamp: Date.now() }
|
||||
|
||||
setUploadError(error)
|
||||
options.onError?.(error)
|
||||
throw err
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setUploadError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
uploadFiles,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChipDropdownOption } from '@sim/emcn'
|
||||
import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import type {
|
||||
FilterTag,
|
||||
ResourceAction,
|
||||
ResourceCell,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
EMPTY_CELL_PLACEHOLDER,
|
||||
ownerCell,
|
||||
Resource,
|
||||
timeCell,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import {
|
||||
CreateBaseModal,
|
||||
DeleteKnowledgeBaseModal,
|
||||
EditKnowledgeBaseModal,
|
||||
KnowledgeBaseContextMenu,
|
||||
KnowledgeListContextMenu,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
docCount?: number
|
||||
}
|
||||
|
||||
const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'documents', header: 'Documents', widthMultiplier: 0.6 },
|
||||
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
|
||||
{ id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
{ id: 'updated', header: 'Last Updated' },
|
||||
]
|
||||
|
||||
const KNOWLEDGE_BASE_ICON = <Database className='size-[14px]' />
|
||||
|
||||
const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'connected', label: 'With connectors' },
|
||||
{ value: 'unconnected', label: 'Without connectors' },
|
||||
]
|
||||
|
||||
const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'has-docs', label: 'Has documents' },
|
||||
{ value: 'empty', label: 'Empty' },
|
||||
]
|
||||
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small'
|
||||
|
||||
function connectorCell(connectorTypes?: string[]): ResourceCell {
|
||||
if (!connectorTypes || connectorTypes.length === 0) {
|
||||
return { label: EMPTY_CELL_PLACEHOLDER }
|
||||
}
|
||||
|
||||
const entries = connectorTypes
|
||||
.map((type) => ({ type, def: CONNECTOR_META_REGISTRY[type] }))
|
||||
.filter(
|
||||
(e): e is { type: string; def: NonNullable<(typeof CONNECTOR_META_REGISTRY)[string]> } =>
|
||||
Boolean(e.def?.icon)
|
||||
)
|
||||
|
||||
if (entries.length === 0) return { label: EMPTY_CELL_PLACEHOLDER }
|
||||
|
||||
const visibleEntries = entries.slice(0, 3)
|
||||
const hiddenEntries = entries.slice(3)
|
||||
|
||||
return {
|
||||
content: (
|
||||
<div className='flex items-center gap-1'>
|
||||
{visibleEntries.map(({ type, def }) => {
|
||||
const Icon = def.icon
|
||||
return (
|
||||
<Tooltip.Root key={type}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='flex size-5 flex-shrink-0 items-center justify-center rounded-md bg-[var(--surface-4)] text-[var(--text-secondary)]'>
|
||||
<Icon className='size-[13px]' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{def.name}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})}
|
||||
{hiddenEntries.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='flex size-5 flex-shrink-0 items-center justify-center rounded-md bg-[var(--surface-4)] font-medium text-[var(--text-muted)] text-micro'>
|
||||
+{hiddenEntries.length}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{hiddenEntries.map(({ def }) => def.name).join(', ')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function Knowledge() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
useEffect(() => {
|
||||
if (permissionConfig.hideKnowledgeBaseTab) {
|
||||
router.replace(`/workspace/${workspaceId}`)
|
||||
}
|
||||
}, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId])
|
||||
|
||||
const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
|
||||
const { data: members } = useWorkspaceMembersQuery(workspaceId)
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to load knowledge bases:', error)
|
||||
}
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const [connectorFilter, setConnectorFilter] = useState<string[]>([])
|
||||
const [contentFilter, setContentFilter] = useState<string[]>([])
|
||||
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
|
||||
|
||||
const [searchInputValue, setSearchInputValue] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchInputValue, 300)
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
|
||||
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
|
||||
null
|
||||
)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
position: listContextMenuPosition,
|
||||
handleContextMenu: handleListContextMenu,
|
||||
closeMenu: closeListContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const {
|
||||
isOpen: isRowContextMenuOpen,
|
||||
position: rowContextMenuPosition,
|
||||
handleContextMenu: handleRowCtxMenu,
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
|
||||
isRowContextMenuOpenRef.current = isRowContextMenuOpen
|
||||
|
||||
const knowledgeBasesRef = useRef(knowledgeBases)
|
||||
knowledgeBasesRef.current = knowledgeBases
|
||||
|
||||
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
|
||||
activeKnowledgeBaseRef.current = activeKnowledgeBase
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (
|
||||
target.closest('[data-resource-row]') ||
|
||||
target.closest('button, input, a, [role="button"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
handleListContextMenu(e)
|
||||
},
|
||||
[handleListContextMenu]
|
||||
)
|
||||
|
||||
const handleOpenCreateModal = useCallback(() => {
|
||||
setIsCreateModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleUpdateKnowledgeBase = useCallback(
|
||||
async (id: string, name: string, description: string) => {
|
||||
await updateKnowledgeBaseMutation({
|
||||
knowledgeBaseId: id,
|
||||
updates: { name, description },
|
||||
})
|
||||
logger.info(`Knowledge base updated: ${id}`)
|
||||
},
|
||||
[updateKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
const handleDeleteKnowledgeBase = useCallback(
|
||||
async (id: string) => {
|
||||
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
|
||||
logger.info(`Knowledge base deleted: ${id}`)
|
||||
},
|
||||
[deleteKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
const processedKBs = useMemo(() => {
|
||||
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
|
||||
|
||||
if (connectorFilter.length > 0) {
|
||||
result = result.filter((kb) => {
|
||||
const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0
|
||||
if (connectorFilter.includes('connected') && hasConnectors) return true
|
||||
if (connectorFilter.includes('unconnected') && !hasConnectors) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (contentFilter.length > 0) {
|
||||
const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0
|
||||
result = result.filter((kb) => {
|
||||
if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true
|
||||
if (contentFilter.includes('empty') && docCount(kb) === 0) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (ownerFilter.length > 0) {
|
||||
result = result.filter((kb) => ownerFilter.includes(kb.userId))
|
||||
}
|
||||
|
||||
const col = activeSort?.column ?? 'updated'
|
||||
const dir = activeSort?.direction ?? 'desc'
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'documents':
|
||||
cmp =
|
||||
((a as KnowledgeBaseWithDocCount).docCount || 0) -
|
||||
((b as KnowledgeBaseWithDocCount).docCount || 0)
|
||||
break
|
||||
case 'tokens':
|
||||
cmp = (a.tokenCount || 0) - (b.tokenCount || 0)
|
||||
break
|
||||
case 'created':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'updated':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
break
|
||||
case 'connectors':
|
||||
cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0)
|
||||
break
|
||||
case 'owner':
|
||||
cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare(
|
||||
members?.find((m) => m.userId === b.userId)?.name ?? ''
|
||||
)
|
||||
break
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [
|
||||
knowledgeBases,
|
||||
debouncedSearchQuery,
|
||||
connectorFilter,
|
||||
contentFilter,
|
||||
ownerFilter,
|
||||
activeSort,
|
||||
members,
|
||||
])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
processedKBs.map((kb) => {
|
||||
const kbWithCount = kb as KnowledgeBaseWithDocCount
|
||||
return {
|
||||
id: kb.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: KNOWLEDGE_BASE_ICON,
|
||||
label: kb.name,
|
||||
},
|
||||
documents: {
|
||||
label: String(kbWithCount.docCount || 0),
|
||||
},
|
||||
tokens: {
|
||||
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
|
||||
},
|
||||
connectors: connectorCell(kb.connectorTypes),
|
||||
created: timeCell(kb.createdAt),
|
||||
owner: ownerCell(kb.userId, members),
|
||||
updated: timeCell(kb.updatedAt),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[processedKBs, members]
|
||||
)
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (isRowContextMenuOpenRef.current) return
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
|
||||
},
|
||||
[router, workspaceId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
|
||||
| KnowledgeBaseWithDocCount
|
||||
| undefined
|
||||
setActiveKnowledgeBase(kb ?? null)
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[handleRowCtxMenu]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await handleDeleteKnowledgeBase(kb.id)
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [handleDeleteKnowledgeBase])
|
||||
|
||||
const handleCloseDeleteModal = useCallback(() => {
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
}, [])
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank')
|
||||
}, [workspaceId])
|
||||
|
||||
const handleViewTags = useCallback(() => {
|
||||
setIsTagsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCopyId = useCallback(() => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (kb) {
|
||||
navigator.clipboard.writeText(kb.id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const headerActions: ResourceAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: 'New base',
|
||||
icon: Plus,
|
||||
onSelect: handleOpenCreateModal,
|
||||
disabled: !canEdit,
|
||||
variant: 'primary',
|
||||
},
|
||||
],
|
||||
[handleOpenCreateModal, canEdit]
|
||||
)
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: searchInputValue,
|
||||
onChange: setSearchInputValue,
|
||||
onClearAll: () => setSearchInputValue(''),
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}),
|
||||
[searchInputValue]
|
||||
)
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'documents', label: 'Documents' },
|
||||
{ id: 'tokens', label: 'Tokens' },
|
||||
{ id: 'connectors', label: 'Connectors' },
|
||||
{ id: 'created', label: 'Created' },
|
||||
{ id: 'updated', label: 'Last Updated' },
|
||||
{ id: 'owner', label: 'Owner' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => setActiveSort({ column, direction }),
|
||||
onClear: () => setActiveSort(null),
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const memberOptions: ChipDropdownOption[] = useMemo(
|
||||
() =>
|
||||
(members ?? []).map((m) => ({
|
||||
value: m.userId,
|
||||
label: m.name,
|
||||
iconElement: m.image ? (
|
||||
<img
|
||||
src={m.image}
|
||||
alt={m.name}
|
||||
referrerPolicy='no-referrer'
|
||||
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
|
||||
/>
|
||||
) : (
|
||||
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[members]
|
||||
)
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[260px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex h-5 items-center justify-between'>
|
||||
<span className={FILTER_SECTION_LABEL_CLASS}>Connectors</span>
|
||||
{connectorFilter.length > 0 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setConnectorFilter([])}
|
||||
className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-secondary)]'
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ChipDropdown
|
||||
options={CONNECTOR_FILTER_OPTIONS}
|
||||
value={connectorFilter[0] ?? 'all'}
|
||||
onChange={(value) => setConnectorFilter(value === 'all' ? [] : [value])}
|
||||
align='start'
|
||||
fullWidth
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex h-5 items-center justify-between'>
|
||||
<span className={FILTER_SECTION_LABEL_CLASS}>Content</span>
|
||||
{contentFilter.length > 0 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setContentFilter([])}
|
||||
className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-secondary)]'
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ChipDropdown
|
||||
options={CONTENT_FILTER_OPTIONS}
|
||||
value={contentFilter[0] ?? 'all'}
|
||||
onChange={(value) => setContentFilter(value === 'all' ? [] : [value])}
|
||||
align='start'
|
||||
fullWidth
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
{memberOptions.length > 0 && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex h-5 items-center justify-between'>
|
||||
<span className={FILTER_SECTION_LABEL_CLASS}>Owner</span>
|
||||
{ownerFilter.length > 0 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setOwnerFilter([])}
|
||||
className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-secondary)]'
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ChipDropdown
|
||||
multiple
|
||||
options={memberOptions}
|
||||
value={ownerFilter}
|
||||
onChange={setOwnerFilter}
|
||||
allLabel='All'
|
||||
searchable
|
||||
searchPlaceholder='Search members...'
|
||||
align='start'
|
||||
fullWidth
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[connectorFilter, contentFilter, ownerFilter, memberOptions]
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(() => {
|
||||
const tags: FilterTag[] = []
|
||||
if (connectorFilter.length > 0) {
|
||||
const label =
|
||||
connectorFilter.length === 1
|
||||
? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}`
|
||||
: `Connectors: ${connectorFilter.length} types`
|
||||
tags.push({ label, onRemove: () => setConnectorFilter([]) })
|
||||
}
|
||||
if (contentFilter.length > 0) {
|
||||
const label =
|
||||
contentFilter.length === 1
|
||||
? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}`
|
||||
: `Content: ${contentFilter.length} types`
|
||||
tags.push({ label, onRemove: () => setContentFilter([]) })
|
||||
}
|
||||
if (ownerFilter.length > 0) {
|
||||
const label =
|
||||
ownerFilter.length === 1
|
||||
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
|
||||
: `Owner: ${ownerFilter.length} members`
|
||||
tags.push({ label, onRemove: () => setOwnerFilter([]) })
|
||||
}
|
||||
return tags
|
||||
}, [connectorFilter, contentFilter, ownerFilter, members])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource onContextMenu={handleContentContextMenu}>
|
||||
<Resource.Header icon={Database} title='Knowledge Base' actions={headerActions} />
|
||||
<Resource.Options
|
||||
search={searchConfig}
|
||||
sort={sortConfig}
|
||||
filterTags={filterTags}
|
||||
filter={{ content: filterContent }}
|
||||
/>
|
||||
<Resource.Table
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
/>
|
||||
</Resource>
|
||||
|
||||
<KnowledgeListContextMenu
|
||||
isOpen={isListContextMenuOpen}
|
||||
position={listContextMenuPosition}
|
||||
onClose={closeListContextMenu}
|
||||
onAddKnowledgeBase={handleOpenCreateModal}
|
||||
disableAdd={!canEdit}
|
||||
/>
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<KnowledgeBaseContextMenu
|
||||
isOpen={isRowContextMenuOpen}
|
||||
position={rowContextMenuPosition}
|
||||
onClose={closeRowContextMenu}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onCopyId={handleCopyId}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
showOpenInNewTab
|
||||
showViewTags
|
||||
showEdit
|
||||
showDelete
|
||||
disableEdit={!canEdit}
|
||||
disableDelete={!canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<EditKnowledgeBaseModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
knowledgeBaseId={activeKnowledgeBase.id}
|
||||
initialName={activeKnowledgeBase.name}
|
||||
initialDescription={activeKnowledgeBase.description || ''}
|
||||
chunkingConfig={activeKnowledgeBase.chunkingConfig}
|
||||
onSave={handleUpdateKnowledgeBase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={activeKnowledgeBase.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<BaseTagsModal
|
||||
open={isTagsModalOpen}
|
||||
onOpenChange={setIsTagsModalOpen}
|
||||
knowledgeBaseId={activeKnowledgeBase.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateBaseModal open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from '@sim/emcn'
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'documents', header: 'Documents', widthMultiplier: 0.6 },
|
||||
{ id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
|
||||
{ id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
{ id: 'updated', header: 'Last Updated' },
|
||||
]
|
||||
|
||||
const ACTIONS: ChromeActionSpec[] = [{ text: 'New base', icon: Plus, variant: 'primary' }]
|
||||
|
||||
export default function KnowledgeLoading() {
|
||||
return (
|
||||
<ResourceChromeFallback
|
||||
icon={Database}
|
||||
title='Knowledge Base'
|
||||
columns={COLUMNS}
|
||||
actions={ACTIONS}
|
||||
searchPlaceholder='Search knowledge bases...'
|
||||
hasSort
|
||||
hasFilter
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import type { Metadata } from 'next'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Knowledge Base',
|
||||
}
|
||||
|
||||
export default async function KnowledgePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceId: string }>
|
||||
}) {
|
||||
const { workspaceId } = await params
|
||||
|
||||
const queryClient = getQueryClient()
|
||||
await prefetchKnowledgeBases(queryClient, workspaceId)
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Knowledge />
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
|
||||
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
|
||||
import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
|
||||
/**
|
||||
* Prefetches the workspace's knowledge-bases list under the same query key the
|
||||
* client `useKnowledgeBasesQuery` hook uses (scope `active`), so the list paints
|
||||
* populated on first render.
|
||||
*
|
||||
* The list carries `Date` fields, so it goes through the `/api/knowledge` route
|
||||
* and caches the serialized wire shape — see {@link prefetchInternalJson}.
|
||||
*/
|
||||
export async function prefetchKnowledgeBases(
|
||||
queryClient: QueryClient,
|
||||
workspaceId: string
|
||||
): Promise<void> {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: knowledgeKeys.list(workspaceId, 'active'),
|
||||
queryFn: async () => {
|
||||
const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>(
|
||||
`/api/knowledge?workspaceId=${workspaceId}&scope=active`
|
||||
)
|
||||
return result.data
|
||||
},
|
||||
staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import type { SortOption, SortOrder } from '../components/constants'
|
||||
|
||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
docCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort knowledge bases by the specified field and order
|
||||
*/
|
||||
export function sortKnowledgeBases(
|
||||
knowledgeBases: KnowledgeBaseData[],
|
||||
sortBy: SortOption,
|
||||
sortOrder: SortOrder
|
||||
): KnowledgeBaseData[] {
|
||||
return [...knowledgeBases].sort((a, b) => {
|
||||
let comparison = 0
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'createdAt':
|
||||
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'updatedAt':
|
||||
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
break
|
||||
case 'docCount':
|
||||
comparison =
|
||||
((a as KnowledgeBaseWithDocCount).docCount || 0) -
|
||||
((b as KnowledgeBaseWithDocCount).docCount || 0)
|
||||
break
|
||||
}
|
||||
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter knowledge bases by search query
|
||||
*/
|
||||
export function filterKnowledgeBases(
|
||||
knowledgeBases: KnowledgeBaseData[],
|
||||
searchQuery: string
|
||||
): KnowledgeBaseData[] {
|
||||
if (!searchQuery.trim()) {
|
||||
return knowledgeBases
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return knowledgeBases.filter(
|
||||
(kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user