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
|
||||
Reference in New Issue
Block a user