mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
fix(kb): fix styling inconsistencies, add rename capability for documents, added search preview (#2680)
This commit is contained in:
@@ -39,11 +39,24 @@ interface ChunkContextMenuProps {
|
|||||||
* Whether add chunk is disabled
|
* Whether add chunk is disabled
|
||||||
*/
|
*/
|
||||||
disableAddChunk?: boolean
|
disableAddChunk?: boolean
|
||||||
|
/**
|
||||||
|
* Number of selected chunks (for batch operations)
|
||||||
|
*/
|
||||||
|
selectedCount?: number
|
||||||
|
/**
|
||||||
|
* Number of enabled chunks in selection
|
||||||
|
*/
|
||||||
|
enabledCount?: number
|
||||||
|
/**
|
||||||
|
* Number of disabled chunks in selection
|
||||||
|
*/
|
||||||
|
disabledCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context menu for chunks table.
|
* Context menu for chunks table.
|
||||||
* Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space.
|
* 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({
|
export function ChunkContextMenu({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -61,7 +74,20 @@ export function ChunkContextMenu({
|
|||||||
disableToggleEnabled = false,
|
disableToggleEnabled = false,
|
||||||
disableDelete = false,
|
disableDelete = false,
|
||||||
disableAddChunk = false,
|
disableAddChunk = false,
|
||||||
|
selectedCount = 1,
|
||||||
|
enabledCount = 0,
|
||||||
|
disabledCount = 0,
|
||||||
}: ChunkContextMenuProps) {
|
}: ChunkContextMenuProps) {
|
||||||
|
const isMultiSelect = selectedCount > 1
|
||||||
|
|
||||||
|
const getToggleLabel = () => {
|
||||||
|
if (isMultiSelect) {
|
||||||
|
if (disabledCount > 0) return 'Enable'
|
||||||
|
return 'Disable'
|
||||||
|
}
|
||||||
|
return isChunkEnabled ? 'Disable' : 'Enable'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||||
<PopoverAnchor
|
<PopoverAnchor
|
||||||
@@ -76,7 +102,7 @@ export function ChunkContextMenu({
|
|||||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||||
{hasChunk ? (
|
{hasChunk ? (
|
||||||
<>
|
<>
|
||||||
{onOpenInNewTab && (
|
{!isMultiSelect && onOpenInNewTab && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenInNewTab()
|
onOpenInNewTab()
|
||||||
@@ -86,7 +112,7 @@ export function ChunkContextMenu({
|
|||||||
Open in new tab
|
Open in new tab
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{onEdit && (
|
{!isMultiSelect && onEdit && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onEdit()
|
onEdit()
|
||||||
@@ -96,7 +122,7 @@ export function ChunkContextMenu({
|
|||||||
Edit
|
Edit
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{onCopyContent && (
|
{!isMultiSelect && onCopyContent && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCopyContent()
|
onCopyContent()
|
||||||
@@ -114,7 +140,7 @@ export function ChunkContextMenu({
|
|||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isChunkEnabled ? 'Disable' : 'Enable'}
|
{getToggleLabel()}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -107,14 +108,31 @@ interface DocumentProps {
|
|||||||
documentName?: string
|
documentName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusBadgeStyles(enabled: boolean) {
|
function truncateContent(content: string, maxLength = 150, searchQuery = ''): string {
|
||||||
return enabled
|
|
||||||
? 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
||||||
: 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateContent(content: string, maxLength = 150): string {
|
|
||||||
if (content.length <= maxLength) return content
|
if (content.length <= maxLength) return content
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const searchTerms = searchQuery
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((term) => term.length > 0)
|
||||||
|
.map((term) => term.toLowerCase())
|
||||||
|
|
||||||
|
for (const term of searchTerms) {
|
||||||
|
const matchIndex = content.toLowerCase().indexOf(term)
|
||||||
|
if (matchIndex !== -1) {
|
||||||
|
const contextBefore = 30
|
||||||
|
const start = Math.max(0, matchIndex - contextBefore)
|
||||||
|
const end = Math.min(content.length, start + maxLength)
|
||||||
|
|
||||||
|
let result = content.substring(start, end)
|
||||||
|
if (start > 0) result = `...${result}`
|
||||||
|
if (end < content.length) result = `${result}...`
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `${content.substring(0, maxLength)}...`
|
return `${content.substring(0, maxLength)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,13 +673,21 @@ export function Document({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle right-click on a chunk row
|
* Handle right-click on a chunk row
|
||||||
|
* If right-clicking on an unselected chunk, select only that chunk
|
||||||
|
* If right-clicking on a selected chunk with multiple selections, keep all selections
|
||||||
*/
|
*/
|
||||||
const handleChunkContextMenu = useCallback(
|
const handleChunkContextMenu = useCallback(
|
||||||
(e: React.MouseEvent, chunk: ChunkData) => {
|
(e: React.MouseEvent, chunk: ChunkData) => {
|
||||||
|
const isCurrentlySelected = selectedChunks.has(chunk.id)
|
||||||
|
|
||||||
|
if (!isCurrentlySelected) {
|
||||||
|
setSelectedChunks(new Set([chunk.id]))
|
||||||
|
}
|
||||||
|
|
||||||
setContextMenuChunk(chunk)
|
setContextMenuChunk(chunk)
|
||||||
baseHandleContextMenu(e)
|
baseHandleContextMenu(e)
|
||||||
},
|
},
|
||||||
[baseHandleContextMenu]
|
[selectedChunks, baseHandleContextMenu]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -946,106 +972,114 @@ export function Document({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
displayChunks.map((chunk: ChunkData) => (
|
displayChunks.map((chunk: ChunkData) => {
|
||||||
<TableRow
|
const isSelected = selectedChunks.has(chunk.id)
|
||||||
key={chunk.id}
|
|
||||||
className='cursor-pointer hover:bg-[var(--surface-2)]'
|
return (
|
||||||
onClick={() => handleChunkClick(chunk)}
|
<TableRow
|
||||||
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
|
key={chunk.id}
|
||||||
>
|
className={`${
|
||||||
<TableCell
|
isSelected
|
||||||
className='w-[52px] py-[8px]'
|
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||||
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
|
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
|
||||||
|
} cursor-pointer`}
|
||||||
|
onClick={() => handleChunkClick(chunk)}
|
||||||
|
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center'>
|
<TableCell
|
||||||
<Checkbox
|
className='w-[52px] py-[8px]'
|
||||||
size='sm'
|
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
|
||||||
checked={selectedChunks.has(chunk.id)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleSelectChunk(chunk.id, checked as boolean)
|
|
||||||
}
|
|
||||||
disabled={!userPermissions.canEdit}
|
|
||||||
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
|
|
||||||
{chunk.chunkIndex}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='px-[12px] py-[8px]'>
|
|
||||||
<span
|
|
||||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
|
||||||
title={chunk.content}
|
|
||||||
>
|
>
|
||||||
<SearchHighlight
|
<div className='flex items-center'>
|
||||||
text={truncateContent(chunk.content)}
|
<Checkbox
|
||||||
searchQuery={searchQuery}
|
size='sm'
|
||||||
/>
|
checked={selectedChunks.has(chunk.id)}
|
||||||
</span>
|
onCheckedChange={(checked) =>
|
||||||
</TableCell>
|
handleSelectChunk(chunk.id, checked as boolean)
|
||||||
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
}
|
||||||
{chunk.tokenCount > 1000
|
disabled={!userPermissions.canEdit}
|
||||||
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
|
aria-label={`Select chunk ${chunk.chunkIndex}`}
|
||||||
: chunk.tokenCount}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell className='w-[12%] px-[12px] py-[8px]'>
|
</div>
|
||||||
<div className={getStatusBadgeStyles(chunk.enabled)}>
|
</TableCell>
|
||||||
{chunk.enabled ? 'Enabled' : 'Disabled'}
|
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
|
||||||
</div>
|
{chunk.chunkIndex}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
|
<TableCell className='px-[12px] py-[8px]'>
|
||||||
<div className='flex items-center gap-[4px]'>
|
<span
|
||||||
<Tooltip.Root>
|
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||||
<Tooltip.Trigger asChild>
|
title={chunk.content}
|
||||||
<Button
|
>
|
||||||
variant='ghost'
|
<SearchHighlight
|
||||||
onClick={(e) => {
|
text={truncateContent(chunk.content, 150, searchQuery)}
|
||||||
e.stopPropagation()
|
searchQuery={searchQuery}
|
||||||
handleToggleEnabled(chunk.id)
|
/>
|
||||||
}}
|
</span>
|
||||||
disabled={!userPermissions.canEdit}
|
</TableCell>
|
||||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
|
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||||
>
|
{chunk.tokenCount > 1000
|
||||||
{chunk.enabled ? (
|
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
|
||||||
<Circle className='h-[14px] w-[14px]' />
|
: chunk.tokenCount.toLocaleString()}
|
||||||
) : (
|
</TableCell>
|
||||||
<CircleOff className='h-[14px] w-[14px]' />
|
<TableCell className='w-[12%] px-[12px] py-[8px]'>
|
||||||
)}
|
<Badge variant={chunk.enabled ? 'green' : 'gray'} size='sm'>
|
||||||
</Button>
|
{chunk.enabled ? 'Enabled' : 'Disabled'}
|
||||||
</Tooltip.Trigger>
|
</Badge>
|
||||||
<Tooltip.Content side='top'>
|
</TableCell>
|
||||||
{!userPermissions.canEdit
|
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
|
||||||
? 'Write permission required to modify chunks'
|
<div className='flex items-center gap-[4px]'>
|
||||||
: chunk.enabled
|
<Tooltip.Root>
|
||||||
? 'Disable Chunk'
|
<Tooltip.Trigger asChild>
|
||||||
: 'Enable Chunk'}
|
<Button
|
||||||
</Tooltip.Content>
|
variant='ghost'
|
||||||
</Tooltip.Root>
|
onClick={(e) => {
|
||||||
<Tooltip.Root>
|
e.stopPropagation()
|
||||||
<Tooltip.Trigger asChild>
|
handleToggleEnabled(chunk.id)
|
||||||
<Button
|
}}
|
||||||
variant='ghost'
|
disabled={!userPermissions.canEdit}
|
||||||
onClick={(e) => {
|
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
|
||||||
e.stopPropagation()
|
>
|
||||||
handleDeleteChunk(chunk.id)
|
{chunk.enabled ? (
|
||||||
}}
|
<Circle className='h-[14px] w-[14px]' />
|
||||||
disabled={!userPermissions.canEdit}
|
) : (
|
||||||
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
|
<CircleOff className='h-[14px] w-[14px]' />
|
||||||
>
|
)}
|
||||||
<Trash className='h-[14px] w-[14px]' />
|
</Button>
|
||||||
</Button>
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Trigger>
|
<Tooltip.Content side='top'>
|
||||||
<Tooltip.Content side='top'>
|
{!userPermissions.canEdit
|
||||||
{!userPermissions.canEdit
|
? 'Write permission required to modify chunks'
|
||||||
? 'Write permission required to delete chunks'
|
: chunk.enabled
|
||||||
: 'Delete Chunk'}
|
? 'Disable Chunk'
|
||||||
</Tooltip.Content>
|
: 'Enable Chunk'}
|
||||||
</Tooltip.Root>
|
</Tooltip.Content>
|
||||||
</div>
|
</Tooltip.Root>
|
||||||
</TableCell>
|
<Tooltip.Root>
|
||||||
</TableRow>
|
<Tooltip.Trigger asChild>
|
||||||
))
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteChunk(chunk.id)
|
||||||
|
}}
|
||||||
|
disabled={!userPermissions.canEdit}
|
||||||
|
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
|
||||||
|
>
|
||||||
|
<Trash className='h-[14px] w-[14px]' />
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side='top'>
|
||||||
|
{!userPermissions.canEdit
|
||||||
|
? 'Write permission required to delete chunks'
|
||||||
|
: 'Delete Chunk'}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -1206,8 +1240,11 @@ export function Document({
|
|||||||
onClose={handleContextMenuClose}
|
onClose={handleContextMenuClose}
|
||||||
hasChunk={contextMenuChunk !== null}
|
hasChunk={contextMenuChunk !== null}
|
||||||
isChunkEnabled={contextMenuChunk?.enabled ?? true}
|
isChunkEnabled={contextMenuChunk?.enabled ?? true}
|
||||||
|
selectedCount={selectedChunks.size}
|
||||||
|
enabledCount={enabledCount}
|
||||||
|
disabledCount={disabledCount}
|
||||||
onOpenInNewTab={
|
onOpenInNewTab={
|
||||||
contextMenuChunk
|
contextMenuChunk && selectedChunks.size === 1
|
||||||
? () => {
|
? () => {
|
||||||
const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}`
|
const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}`
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
@@ -1215,7 +1252,7 @@ export function Document({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onEdit={
|
onEdit={
|
||||||
contextMenuChunk
|
contextMenuChunk && selectedChunks.size === 1
|
||||||
? () => {
|
? () => {
|
||||||
setSelectedChunk(contextMenuChunk)
|
setSelectedChunk(contextMenuChunk)
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
@@ -1223,7 +1260,7 @@ export function Document({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onCopyContent={
|
onCopyContent={
|
||||||
contextMenuChunk
|
contextMenuChunk && selectedChunks.size === 1
|
||||||
? () => {
|
? () => {
|
||||||
navigator.clipboard.writeText(contextMenuChunk.content)
|
navigator.clipboard.writeText(contextMenuChunk.content)
|
||||||
}
|
}
|
||||||
@@ -1231,12 +1268,22 @@ export function Document({
|
|||||||
}
|
}
|
||||||
onToggleEnabled={
|
onToggleEnabled={
|
||||||
contextMenuChunk && userPermissions.canEdit
|
contextMenuChunk && userPermissions.canEdit
|
||||||
? () => handleToggleEnabled(contextMenuChunk.id)
|
? selectedChunks.size > 1
|
||||||
|
? () => {
|
||||||
|
if (disabledCount > 0) {
|
||||||
|
handleBulkEnable()
|
||||||
|
} else {
|
||||||
|
handleBulkDisable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: () => handleToggleEnabled(contextMenuChunk.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDelete={
|
onDelete={
|
||||||
contextMenuChunk && userPermissions.canEdit
|
contextMenuChunk && userPermissions.canEdit
|
||||||
? () => handleDeleteChunk(contextMenuChunk.id)
|
? selectedChunks.size > 1
|
||||||
|
? handleBulkDelete
|
||||||
|
: () => handleDeleteChunk(contextMenuChunk.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onAddChunk={
|
onAddChunk={
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -47,10 +48,12 @@ import {
|
|||||||
AddDocumentsModal,
|
AddDocumentsModal,
|
||||||
BaseTagsModal,
|
BaseTagsModal,
|
||||||
DocumentContextMenu,
|
DocumentContextMenu,
|
||||||
|
RenameDocumentModal,
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
import {
|
import {
|
||||||
useKnowledgeBase,
|
useKnowledgeBase,
|
||||||
useKnowledgeBaseDocuments,
|
useKnowledgeBaseDocuments,
|
||||||
@@ -404,6 +407,7 @@ export function KnowledgeBase({
|
|||||||
id,
|
id,
|
||||||
knowledgeBaseName: passedKnowledgeBaseName,
|
knowledgeBaseName: passedKnowledgeBaseName,
|
||||||
}: KnowledgeBaseProps) {
|
}: KnowledgeBaseProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaceId = params.workspaceId as string
|
const workspaceId = params.workspaceId as string
|
||||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||||
@@ -432,6 +436,8 @@ export function KnowledgeBase({
|
|||||||
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
|
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||||
|
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||||
|
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen: isContextMenuOpen,
|
isOpen: isContextMenuOpen,
|
||||||
@@ -699,6 +705,60 @@ export function KnowledgeBase({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the rename document modal
|
||||||
|
*/
|
||||||
|
const handleRenameDocument = (doc: DocumentData) => {
|
||||||
|
setDocumentToRename(doc)
|
||||||
|
setShowRenameModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the renamed document
|
||||||
|
*/
|
||||||
|
const handleSaveRename = async (documentId: string, newName: string) => {
|
||||||
|
const currentDoc = documents.find((doc) => doc.id === documentId)
|
||||||
|
const previousName = currentDoc?.filename
|
||||||
|
|
||||||
|
updateDocument(documentId, { filename: newName })
|
||||||
|
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
|
||||||
|
previous ? { ...previous, filename: newName } : previous
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filename: newName }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to rename document')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to rename document')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Document renamed: ${documentId}`)
|
||||||
|
} catch (err) {
|
||||||
|
if (previousName !== undefined) {
|
||||||
|
updateDocument(documentId, { filename: previousName })
|
||||||
|
queryClient.setQueryData<DocumentData>(
|
||||||
|
knowledgeKeys.document(id, documentId),
|
||||||
|
(previous) => (previous ? { ...previous, filename: previousName } : previous)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.error('Error renaming document:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the delete document confirmation modal
|
* Opens the delete document confirmation modal
|
||||||
*/
|
*/
|
||||||
@@ -968,13 +1028,21 @@ export function KnowledgeBase({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle right-click on a document row
|
* Handle right-click on a document row
|
||||||
|
* If right-clicking on an unselected document, select only that document
|
||||||
|
* If right-clicking on a selected document with multiple selections, keep all selections
|
||||||
*/
|
*/
|
||||||
const handleDocumentContextMenu = useCallback(
|
const handleDocumentContextMenu = useCallback(
|
||||||
(e: React.MouseEvent, doc: DocumentData) => {
|
(e: React.MouseEvent, doc: DocumentData) => {
|
||||||
|
const isCurrentlySelected = selectedDocuments.has(doc.id)
|
||||||
|
|
||||||
|
if (!isCurrentlySelected) {
|
||||||
|
setSelectedDocuments(new Set([doc.id]))
|
||||||
|
}
|
||||||
|
|
||||||
setContextMenuDocument(doc)
|
setContextMenuDocument(doc)
|
||||||
baseHandleContextMenu(e)
|
baseHandleContextMenu(e)
|
||||||
},
|
},
|
||||||
[baseHandleContextMenu]
|
[selectedDocuments, baseHandleContextMenu]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1211,7 +1279,9 @@ export function KnowledgeBase({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className={`${
|
className={`${
|
||||||
isSelected ? 'bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
|
isSelected
|
||||||
|
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||||
|
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
|
||||||
} ${doc.processingStatus === 'completed' ? 'cursor-pointer' : 'cursor-default'}`}
|
} ${doc.processingStatus === 'completed' ? 'cursor-pointer' : 'cursor-default'}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (doc.processingStatus === 'completed') {
|
if (doc.processingStatus === 'completed') {
|
||||||
@@ -1558,6 +1628,17 @@ export function KnowledgeBase({
|
|||||||
chunkingConfig={knowledgeBase?.chunkingConfig}
|
chunkingConfig={knowledgeBase?.chunkingConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Rename Document Modal */}
|
||||||
|
{documentToRename && (
|
||||||
|
<RenameDocumentModal
|
||||||
|
open={showRenameModal}
|
||||||
|
onOpenChange={setShowRenameModal}
|
||||||
|
documentId={documentToRename.id}
|
||||||
|
initialName={documentToRename.filename}
|
||||||
|
onSave={handleSaveRename}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ActionBar
|
<ActionBar
|
||||||
selectedCount={selectedDocuments.size}
|
selectedCount={selectedDocuments.size}
|
||||||
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
|
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
|
||||||
@@ -1580,8 +1661,11 @@ export function KnowledgeBase({
|
|||||||
? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0
|
? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0
|
||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
|
selectedCount={selectedDocuments.size}
|
||||||
|
enabledCount={enabledCount}
|
||||||
|
disabledCount={disabledCount}
|
||||||
onOpenInNewTab={
|
onOpenInNewTab={
|
||||||
contextMenuDocument
|
contextMenuDocument && selectedDocuments.size === 1
|
||||||
? () => {
|
? () => {
|
||||||
const urlParams = new URLSearchParams({
|
const urlParams = new URLSearchParams({
|
||||||
kbName: knowledgeBaseName,
|
kbName: knowledgeBaseName,
|
||||||
@@ -1594,13 +1678,26 @@ export function KnowledgeBase({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onRename={
|
||||||
|
contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
|
||||||
|
? () => handleRenameDocument(contextMenuDocument)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onToggleEnabled={
|
onToggleEnabled={
|
||||||
contextMenuDocument && userPermissions.canEdit
|
contextMenuDocument && userPermissions.canEdit
|
||||||
? () => handleToggleEnabled(contextMenuDocument.id)
|
? selectedDocuments.size > 1
|
||||||
|
? () => {
|
||||||
|
if (disabledCount > 0) {
|
||||||
|
handleBulkEnable()
|
||||||
|
} else {
|
||||||
|
handleBulkDisable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: () => handleToggleEnabled(contextMenuDocument.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTags={
|
onViewTags={
|
||||||
contextMenuDocument
|
contextMenuDocument && selectedDocuments.size === 1
|
||||||
? () => {
|
? () => {
|
||||||
const urlParams = new URLSearchParams({
|
const urlParams = new URLSearchParams({
|
||||||
kbName: knowledgeBaseName,
|
kbName: knowledgeBaseName,
|
||||||
@@ -1614,7 +1711,9 @@ export function KnowledgeBase({
|
|||||||
}
|
}
|
||||||
onDelete={
|
onDelete={
|
||||||
contextMenuDocument && userPermissions.canEdit
|
contextMenuDocument && userPermissions.canEdit
|
||||||
? () => handleDeleteDocument(contextMenuDocument.id)
|
? selectedDocuments.size > 1
|
||||||
|
? handleBulkDelete
|
||||||
|
: () => handleDeleteDocument(contextMenuDocument.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined}
|
onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface DocumentContextMenuProps {
|
|||||||
* Document-specific actions (shown when right-clicking on a document)
|
* Document-specific actions (shown when right-clicking on a document)
|
||||||
*/
|
*/
|
||||||
onOpenInNewTab?: () => void
|
onOpenInNewTab?: () => void
|
||||||
|
onRename?: () => void
|
||||||
onToggleEnabled?: () => void
|
onToggleEnabled?: () => void
|
||||||
onViewTags?: () => void
|
onViewTags?: () => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
@@ -42,11 +43,24 @@ interface DocumentContextMenuProps {
|
|||||||
* Whether add document is disabled
|
* Whether add document is disabled
|
||||||
*/
|
*/
|
||||||
disableAddDocument?: boolean
|
disableAddDocument?: boolean
|
||||||
|
/**
|
||||||
|
* Number of selected documents (for batch operations)
|
||||||
|
*/
|
||||||
|
selectedCount?: number
|
||||||
|
/**
|
||||||
|
* Number of enabled documents in selection
|
||||||
|
*/
|
||||||
|
enabledCount?: number
|
||||||
|
/**
|
||||||
|
* Number of disabled documents in selection
|
||||||
|
*/
|
||||||
|
disabledCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context menu for documents table.
|
* Context menu for documents table.
|
||||||
* Shows document actions when right-clicking a row, or "Add Document" when right-clicking empty space.
|
* 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({
|
export function DocumentContextMenu({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -54,6 +68,7 @@ export function DocumentContextMenu({
|
|||||||
menuRef,
|
menuRef,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenInNewTab,
|
onOpenInNewTab,
|
||||||
|
onRename,
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
onViewTags,
|
onViewTags,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -64,7 +79,20 @@ export function DocumentContextMenu({
|
|||||||
disableToggleEnabled = false,
|
disableToggleEnabled = false,
|
||||||
disableDelete = false,
|
disableDelete = false,
|
||||||
disableAddDocument = false,
|
disableAddDocument = false,
|
||||||
|
selectedCount = 1,
|
||||||
|
enabledCount = 0,
|
||||||
|
disabledCount = 0,
|
||||||
}: DocumentContextMenuProps) {
|
}: DocumentContextMenuProps) {
|
||||||
|
const isMultiSelect = selectedCount > 1
|
||||||
|
|
||||||
|
const getToggleLabel = () => {
|
||||||
|
if (isMultiSelect) {
|
||||||
|
if (disabledCount > 0) return 'Enable'
|
||||||
|
return 'Disable'
|
||||||
|
}
|
||||||
|
return isDocumentEnabled ? 'Disable' : 'Enable'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||||
<PopoverAnchor
|
<PopoverAnchor
|
||||||
@@ -79,7 +107,7 @@ export function DocumentContextMenu({
|
|||||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||||
{hasDocument ? (
|
{hasDocument ? (
|
||||||
<>
|
<>
|
||||||
{onOpenInNewTab && (
|
{!isMultiSelect && onOpenInNewTab && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenInNewTab()
|
onOpenInNewTab()
|
||||||
@@ -89,7 +117,17 @@ export function DocumentContextMenu({
|
|||||||
Open in new tab
|
Open in new tab
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{hasTags && onViewTags && (
|
{!isMultiSelect && onRename && (
|
||||||
|
<PopoverItem
|
||||||
|
onClick={() => {
|
||||||
|
onRename()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</PopoverItem>
|
||||||
|
)}
|
||||||
|
{!isMultiSelect && hasTags && onViewTags && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onViewTags()
|
onViewTags()
|
||||||
@@ -107,7 +145,7 @@ export function DocumentContextMenu({
|
|||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDocumentEnabled ? 'Disable' : 'Enable'}
|
{getToggleLabel()}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { ActionBar } from './action-bar/action-bar'
|
|||||||
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
|
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
|
||||||
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
|
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
|
||||||
export { DocumentContextMenu } from './document-context-menu'
|
export { DocumentContextMenu } from './document-context-menu'
|
||||||
|
export { RenameDocumentModal } from './rename-document-modal'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { RenameDocumentModal } from './rename-document-modal'
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(initialName)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, initialName])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
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(err instanceof Error ? err.message : 'Failed to rename document')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Rename Document</ModalHeader>
|
||||||
|
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div className='space-y-[12px]'>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='document-name'>Name</Label>
|
||||||
|
<Input
|
||||||
|
id='document-name'
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
placeholder='Enter document name'
|
||||||
|
className={cn(error && 'border-[var(--text-error)]')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
maxLength={255}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||||
|
{error ? (
|
||||||
|
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<div className='flex flex-shrink-0 gap-[8px]'>
|
||||||
|
<Button
|
||||||
|
variant='default'
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
type='button'
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
|
||||||
|
{isSubmitting ? 'Renaming...' : 'Rename'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
|
|||||||
return <span className={className}>{text}</span>
|
return <span className={className}>{text}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create regex pattern for all search terms
|
|
||||||
const searchTerms = searchQuery
|
const searchTerms = searchQuery
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
@@ -35,7 +34,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
|
|||||||
return isMatch ? (
|
return isMatch ? (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className='bg-yellow-200 text-yellow-900 dark:bg-yellow-900/50 dark:text-yellow-200'
|
className='bg-[#bae6fd] text-[#0369a1] dark:bg-[rgba(51,180,255,0.2)] dark:text-[var(--brand-secondary)]'
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user