Compare commits

..

3 Commits

Author SHA1 Message Date
Vikhyath Mondreti
526b7a64f6 update templates routes to use helper 2026-01-19 16:20:26 -08:00
Siddharth Ganesan
9da689bc8e Fix 2026-01-19 15:58:07 -08:00
Siddharth Ganesan
e1bea05de0 Superuser debug 2026-01-19 15:42:55 -08:00
21 changed files with 1100 additions and 1005 deletions

View File

@@ -1,10 +1,11 @@
import { db } from '@sim/db'
import { templateCreators, user } from '@sim/db/schema'
import { templateCreators } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('CreatorVerificationAPI')
@@ -23,9 +24,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
}
@@ -76,9 +76,8 @@ export async function DELETE(
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
}

View File

@@ -0,0 +1,193 @@
import { db } from '@sim/db'
import { copilotChats, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
const logger = createLogger('SuperUserImportWorkflow')
interface ImportWorkflowRequest {
workflowId: string
targetWorkspaceId: string
}
/**
* POST /api/superuser/import-workflow
*
* Superuser endpoint to import a workflow by ID along with its copilot chats.
* This creates a copy of the workflow in the target workspace with new IDs.
* Only the workflow structure and copilot chats are copied - no deployments,
* webhooks, triggers, or other sensitive data.
*
* Requires both isSuperUser flag AND superUserModeEnabled setting.
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { effectiveSuperUser, isSuperUser, superUserModeEnabled } =
await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn('Non-effective-superuser attempted to access import-workflow endpoint', {
userId: session.user.id,
isSuperUser,
superUserModeEnabled,
})
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
}
const body: ImportWorkflowRequest = await request.json()
const { workflowId, targetWorkspaceId } = body
if (!workflowId) {
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
}
if (!targetWorkspaceId) {
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
}
// Verify target workspace exists
const [targetWorkspace] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, targetWorkspaceId))
.limit(1)
if (!targetWorkspace) {
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
}
// Get the source workflow
const [sourceWorkflow] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!sourceWorkflow) {
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
}
// Load the workflow state from normalized tables
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return NextResponse.json(
{ error: 'Workflow has no normalized data - cannot import' },
{ status: 400 }
)
}
// Use existing export logic to create export format
const workflowState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: sourceWorkflow.name,
description: sourceWorkflow.description ?? undefined,
color: sourceWorkflow.color,
},
}
const exportData = sanitizeForExport(workflowState)
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
if (!importedData || errors.length > 0) {
return NextResponse.json(
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
{ status: 400 }
)
}
// Create new workflow record
const newWorkflowId = crypto.randomUUID()
const now = new Date()
await db.insert(workflow).values({
id: newWorkflowId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
folderId: null, // Don't copy folder association
name: `[Debug Import] ${sourceWorkflow.name}`,
description: sourceWorkflow.description,
color: sourceWorkflow.color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false, // Never copy deployment status
runCount: 0,
variables: sourceWorkflow.variables || {},
})
// Save using existing persistence logic
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
if (!saveResult.success) {
// Clean up the workflow record if save failed
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
return NextResponse.json(
{ error: `Failed to save workflow state: ${saveResult.error}` },
{ status: 500 }
)
}
// Copy copilot chats associated with the source workflow
const sourceCopilotChats = await db
.select()
.from(copilotChats)
.where(eq(copilotChats.workflowId, workflowId))
let copilotChatsImported = 0
for (const chat of sourceCopilotChats) {
await db.insert(copilotChats).values({
userId: session.user.id,
workflowId: newWorkflowId,
title: chat.title ? `[Import] ${chat.title}` : null,
messages: chat.messages,
model: chat.model,
conversationId: null, // Don't copy conversation ID
previewYaml: chat.previewYaml,
planArtifact: chat.planArtifact,
config: chat.config,
createdAt: new Date(),
updatedAt: new Date(),
})
copilotChatsImported++
}
logger.info('Superuser imported workflow', {
userId: session.user.id,
sourceWorkflowId: workflowId,
newWorkflowId,
targetWorkspaceId,
copilotChatsImported,
})
return NextResponse.json({
success: true,
newWorkflowId,
copilotChatsImported,
})
} catch (error) {
logger.error('Error importing workflow', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifySuperUser } from '@/lib/templates/permissions'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateApprovalAPI')
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
}
@@ -71,8 +71,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifySuperUser } from '@/lib/templates/permissions'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateRejectionAPI')
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}

View File

@@ -3,7 +3,6 @@ import {
templateCreators,
templateStars,
templates,
user,
workflow,
workflowDeploymentVersion,
} from '@sim/db/schema'
@@ -14,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import {
extractRequiredCredentials,
sanitizeCredentials,
@@ -70,8 +70,8 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Fetching templates with params:`, params)
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
const isSuperUser = effectiveSuperUser
// Build query conditions
const conditions = []

View File

@@ -2,6 +2,7 @@
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import {
Button,
Label,
@@ -13,7 +14,7 @@ import {
Textarea,
} from '@/components/emcn'
import type { DocumentData } from '@/lib/knowledge/types'
import { useCreateChunk } from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('CreateChunkModal')
@@ -30,15 +31,16 @@ export function CreateChunkModal({
document,
knowledgeBaseId,
}: CreateChunkModalProps) {
const { mutate: createChunk, isPending: isCreating, error: mutationError } = useCreateChunk()
const queryClient = useQueryClient()
const [content, setContent] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const isProcessingRef = useRef(false)
const error = mutationError?.message ?? null
const hasUnsavedChanges = content.trim().length > 0
const handleCreateChunk = () => {
const handleCreateChunk = async () => {
if (!document || content.trim().length === 0 || isProcessingRef.current) {
if (isProcessingRef.current) {
logger.warn('Chunk creation already in progress, ignoring duplicate request')
@@ -46,30 +48,56 @@ export function CreateChunkModal({
return
}
isProcessingRef.current = true
try {
isProcessingRef.current = true
setIsCreating(true)
setError(null)
createChunk(
{
knowledgeBaseId,
documentId: document.id,
content: content.trim(),
enabled: true,
},
{
onSuccess: () => {
isProcessingRef.current = false
onClose()
},
onError: () => {
isProcessingRef.current = false
},
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content.trim(),
enabled: true,
}),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create chunk')
}
)
const result = await response.json()
if (result.success && result.data) {
logger.info('Chunk created successfully:', result.data.id)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
onClose()
} else {
throw new Error(result.error || 'Failed to create chunk')
}
} catch (err) {
logger.error('Error creating chunk:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
isProcessingRef.current = false
setIsCreating(false)
}
}
const onClose = () => {
onOpenChange(false)
setContent('')
setError(null)
setShowUnsavedChangesAlert(false)
}

View File

@@ -1,8 +1,13 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import type { ChunkData } from '@/lib/knowledge/types'
import { useDeleteChunk } from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('DeleteChunkModal')
interface DeleteChunkModalProps {
chunk: ChunkData | null
@@ -19,12 +24,44 @@ export function DeleteChunkModal({
isOpen,
onClose,
}: DeleteChunkModalProps) {
const { mutate: deleteChunk, isPending: isDeleting } = useDeleteChunk()
const queryClient = useQueryClient()
const [isDeleting, setIsDeleting] = useState(false)
const handleDeleteChunk = () => {
const handleDeleteChunk = async () => {
if (!chunk || isDeleting) return
deleteChunk({ knowledgeBaseId, documentId, chunkId: chunk.id }, { onSuccess: onClose })
try {
setIsDeleting(true)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunk.id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
throw new Error('Failed to delete chunk')
}
const result = await response.json()
if (result.success) {
logger.info('Chunk deleted successfully:', chunk.id)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
onClose()
} else {
throw new Error(result.error || 'Failed to delete chunk')
}
} catch (err) {
logger.error('Error deleting chunk:', err)
} finally {
setIsDeleting(false)
}
}
if (!chunk) return null

View File

@@ -25,7 +25,6 @@ import {
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
import { useUpdateDocumentTags } from '@/hooks/queries/knowledge'
const logger = createLogger('DocumentTagsModal')
@@ -59,6 +58,8 @@ function formatValueForDisplay(value: string, fieldType: string): string {
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
// For UTC dates, display the UTC date to prevent timezone shifts
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
return new Date(
date.getUTCFullYear(),
@@ -95,7 +96,6 @@ export function DocumentTagsModal({
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
const { mutateAsync: updateDocumentTags } = useUpdateDocumentTags()
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
@@ -118,6 +118,7 @@ export function DocumentTagsModal({
const definition = definitions.find((def) => def.tagSlot === slot)
if (rawValue !== null && rawValue !== undefined && definition) {
// Convert value to string for storage
const stringValue = String(rawValue).trim()
if (stringValue) {
tags.push({
@@ -141,34 +142,41 @@ export function DocumentTagsModal({
async (tagsToSave: DocumentTag[]) => {
if (!documentData) return
const tagData: Record<string, string> = {}
try {
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] = ''
// Only include tags that have values (omit empty ones)
// Use empty string for slots that should be cleared
ALL_TAG_SLOTS.forEach((slot) => {
const tag = tagsToSave.find((t) => t.slot === slot)
if (tag?.value.trim()) {
tagData[slot] = tag.value.trim()
} else {
// Use empty string to clear a tag (API schema expects string, not null)
tagData[slot] = ''
}
})
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(tagData),
})
if (!response.ok) {
throw new Error('Failed to update document tags')
}
})
await updateDocumentTags({
knowledgeBaseId,
documentId,
tags: tagData,
})
onDocumentUpdate?.(tagData)
await fetchTagDefinitions()
onDocumentUpdate?.(tagData as Record<string, string>)
await fetchTagDefinitions()
} catch (error) {
logger.error('Error updating document tags:', error)
throw error
}
},
[
documentData,
knowledgeBaseId,
documentId,
updateDocumentTags,
fetchTagDefinitions,
onDocumentUpdate,
]
[documentData, knowledgeBaseId, documentId, fetchTagDefinitions, onDocumentUpdate]
)
const handleRemoveTag = async (index: number) => {

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { ChevronDown, ChevronUp } from 'lucide-react'
import {
Button,
@@ -18,7 +19,7 @@ import {
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useUpdateChunk } from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('EditChunkModal')
@@ -49,17 +50,17 @@ export function EditChunkModal({
onNavigateToPage,
maxChunkSize,
}: EditChunkModalProps) {
const queryClient = useQueryClient()
const userPermissions = useUserPermissionsContext()
const { mutate: updateChunk, isPending: isSaving, error: mutationError } = useUpdateChunk()
const [editedContent, setEditedContent] = useState(chunk?.content || '')
const [isSaving, setIsSaving] = useState(false)
const [isNavigating, setIsNavigating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const error = mutationError?.message ?? null
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
const tokenStrings = useMemo(() => {
@@ -101,15 +102,44 @@ export function EditChunkModal({
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
const handleSaveContent = () => {
const handleSaveContent = async () => {
if (!chunk || !document) return
updateChunk({
knowledgeBaseId,
documentId: document.id,
chunkId: chunk.id,
content: editedContent,
})
try {
setIsSaving(true)
setError(null)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks/${chunk.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: editedContent,
}),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update chunk')
}
const result = await response.json()
if (result.success) {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
}
} catch (err) {
logger.error('Error updating chunk:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setIsSaving(false)
}
}
const navigateToChunk = async (direction: 'prev' | 'next') => {
@@ -135,6 +165,7 @@ export function EditChunkModal({
}
} catch (err) {
logger.error(`Error navigating ${direction}:`, err)
setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`)
} finally {
setIsNavigating(false)
}

View File

@@ -48,13 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import {
knowledgeKeys,
useBulkChunkOperation,
useDeleteDocument,
useDocumentChunkSearchQuery,
useUpdateChunk,
} from '@/hooks/queries/knowledge'
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
const logger = createLogger('Document')
@@ -409,13 +403,11 @@ export function Document({
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isBulkOperating, setIsBulkOperating] = useState(false)
const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false)
const [isDeletingDocument, setIsDeletingDocument] = useState(false)
const [contextMenuChunk, setContextMenuChunk] = useState<ChunkData | null>(null)
const { mutate: updateChunkMutation } = useUpdateChunk()
const { mutate: deleteDocumentMutation, isPending: isDeletingDocument } = useDeleteDocument()
const { mutate: bulkChunkMutation, isPending: isBulkOperating } = useBulkChunkOperation()
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
@@ -448,23 +440,36 @@ export function Document({
setSelectedChunk(null)
}
const handleToggleEnabled = (chunkId: string) => {
const handleToggleEnabled = async (chunkId: string) => {
const chunk = displayChunks.find((c) => c.id === chunkId)
if (!chunk) return
updateChunkMutation(
{
knowledgeBaseId,
documentId,
chunkId,
enabled: !chunk.enabled,
},
{
onSuccess: () => {
updateChunk(chunkId, { enabled: !chunk.enabled })
},
try {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: !chunk.enabled,
}),
}
)
if (!response.ok) {
throw new Error('Failed to update chunk')
}
)
const result = await response.json()
if (result.success) {
updateChunk(chunkId, { enabled: !chunk.enabled })
}
} catch (err) {
logger.error('Error updating chunk:', err)
}
}
const handleDeleteChunk = (chunkId: string) => {
@@ -510,69 +515,107 @@ export function Document({
/**
* Handles deleting the document
*/
const handleDeleteDocument = () => {
const handleDeleteDocument = async () => {
if (!documentData) return
deleteDocumentMutation(
{ knowledgeBaseId, documentId },
{
onSuccess: () => {
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
},
try {
setIsDeletingDocument(true)
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete document')
}
)
const result = await response.json()
if (result.success) {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
} else {
throw new Error(result.error || 'Failed to delete document')
}
} catch (err) {
logger.error('Error deleting document:', err)
setIsDeletingDocument(false)
}
}
const performBulkChunkOperation = (
const performBulkChunkOperation = async (
operation: 'enable' | 'disable' | 'delete',
chunks: ChunkData[]
) => {
if (chunks.length === 0) return
bulkChunkMutation(
{
knowledgeBaseId,
documentId,
operation,
chunkIds: chunks.map((chunk) => chunk.id),
},
{
onSuccess: (result) => {
if (operation === 'delete') {
refreshChunks()
} else {
result.results.forEach((opResult) => {
if (opResult.operation === operation) {
opResult.chunkIds.forEach((chunkId: string) => {
updateChunk(chunkId, { enabled: operation === 'enable' })
})
}
})
}
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
setSelectedChunks(new Set())
},
try {
setIsBulkOperating(true)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation,
chunkIds: chunks.map((chunk) => chunk.id),
}),
}
)
if (!response.ok) {
throw new Error(`Failed to ${operation} chunks`)
}
)
const result = await response.json()
if (result.success) {
if (operation === 'delete') {
await refreshChunks()
} else {
result.data.results.forEach((opResult: any) => {
if (opResult.operation === operation) {
opResult.chunkIds.forEach((chunkId: string) => {
updateChunk(chunkId, { enabled: operation === 'enable' })
})
}
})
}
logger.info(`Successfully ${operation}d ${result.data.successCount} chunks`)
}
setSelectedChunks(new Set())
} catch (err) {
logger.error(`Error ${operation}ing chunks:`, err)
} finally {
setIsBulkOperating(false)
}
}
const handleBulkEnable = () => {
const handleBulkEnable = async () => {
const chunksToEnable = displayChunks.filter(
(chunk) => selectedChunks.has(chunk.id) && !chunk.enabled
)
performBulkChunkOperation('enable', chunksToEnable)
await performBulkChunkOperation('enable', chunksToEnable)
}
const handleBulkDisable = () => {
const handleBulkDisable = async () => {
const chunksToDisable = displayChunks.filter(
(chunk) => selectedChunks.has(chunk.id) && chunk.enabled
)
performBulkChunkOperation('disable', chunksToDisable)
await performBulkChunkOperation('disable', chunksToDisable)
}
const handleBulkDelete = () => {
const handleBulkDelete = async () => {
const chunksToDelete = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
performBulkChunkOperation('delete', chunksToDelete)
await performBulkChunkOperation('delete', chunksToDelete)
}
const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import {
AlertCircle,
@@ -61,12 +62,7 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import {
useBulkDocumentOperation,
useDeleteDocument,
useDeleteKnowledgeBase,
useUpdateDocument,
} from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeBase')
@@ -411,17 +407,12 @@ export function KnowledgeBase({
id,
knowledgeBaseName: passedKnowledgeBaseName,
}: KnowledgeBaseProps) {
const queryClient = useQueryClient()
const params = useParams()
const workspaceId = params.workspaceId as string
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
const userPermissions = useUserPermissionsContext()
const { mutate: updateDocumentMutation } = useUpdateDocument()
const { mutate: deleteDocumentMutation } = useDeleteDocument()
const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } =
useDeleteKnowledgeBase(workspaceId)
const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation()
const [searchQuery, setSearchQuery] = useState('')
const [showTagsModal, setShowTagsModal] = useState(false)
@@ -436,6 +427,8 @@ export function KnowledgeBase({
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isBulkOperating, setIsBulkOperating] = useState(false)
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null)
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
@@ -557,7 +550,7 @@ export function KnowledgeBase({
/**
* Checks for documents with stale processing states and marks them as failed
*/
const checkForDeadProcesses = () => {
const checkForDeadProcesses = async () => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
@@ -574,79 +567,116 @@ export function KnowledgeBase({
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
},
{
onSuccess: () => {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
const markFailedPromises = staleDocuments.map(async (doc) => {
try {
const response = await fetch(`/api/knowledge/${id}/documents/${doc.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
markFailedDueToTimeout: true,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
logger.error(`Failed to mark document ${doc.id} as failed: ${errorData.error}`)
return
}
)
const result = await response.json()
if (result.success) {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
}
} catch (error) {
logger.error(`Error marking document ${doc.id} as failed:`, error)
}
})
await Promise.allSettled(markFailedPromises)
}
const handleToggleEnabled = (docId: string) => {
const handleToggleEnabled = async (docId: string) => {
const document = documents.find((doc) => doc.id === docId)
if (!document) return
const newEnabled = !document.enabled
// Optimistic update
updateDocument(docId, { enabled: newEnabled })
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: docId,
updates: { enabled: newEnabled },
},
{
onError: () => {
// Rollback on error
updateDocument(docId, { enabled: !newEnabled })
try {
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: newEnabled,
}),
})
if (!response.ok) {
throw new Error('Failed to update document')
}
)
const result = await response.json()
if (!result.success) {
updateDocument(docId, { enabled: !newEnabled })
}
} catch (err) {
updateDocument(docId, { enabled: !newEnabled })
logger.error('Error updating document:', err)
}
}
/**
* Handles retrying a failed document processing
*/
const handleRetryDocument = (docId: string) => {
// Optimistic update
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
processingStartedAt: null,
processingCompletedAt: null,
})
const handleRetryDocument = async (docId: string) => {
try {
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
processingStartedAt: null,
processingCompletedAt: null,
})
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: docId,
updates: { retryProcessing: true },
},
{
onSuccess: () => {
refreshDocuments()
logger.info(`Document retry initiated successfully for: ${docId}`)
},
onError: (err) => {
logger.error('Error retrying document:', err)
updateDocument(docId, {
processingStatus: 'failed',
processingError:
err instanceof Error ? err.message : 'Failed to retry document processing',
})
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
retryProcessing: true,
}),
})
if (!response.ok) {
throw new Error('Failed to retry document processing')
}
)
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to retry document processing')
}
await refreshDocuments()
logger.info(`Document retry initiated successfully for: ${docId}`)
} catch (err) {
logger.error('Error retrying document:', err)
const currentDoc = documents.find((doc) => doc.id === docId)
if (currentDoc) {
updateDocument(docId, {
processingStatus: 'failed',
processingError:
err instanceof Error ? err.message : 'Failed to retry document processing',
})
}
}
}
/**
@@ -664,32 +694,43 @@ export function KnowledgeBase({
const currentDoc = documents.find((doc) => doc.id === documentId)
const previousName = currentDoc?.filename
// Optimistic update
updateDocument(documentId, { filename: newName })
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
previous ? { ...previous, filename: newName } : previous
)
return new Promise<void>((resolve, reject) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId,
updates: { filename: newName },
try {
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
{
onSuccess: () => {
logger.info(`Document renamed: ${documentId}`)
resolve()
},
onError: (err) => {
// Rollback on error
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
logger.error('Error renaming document:', err)
reject(err)
},
}
)
})
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
}
}
/**
@@ -703,26 +744,35 @@ export function KnowledgeBase({
/**
* Confirms and executes the deletion of a single document
*/
const confirmDeleteDocument = () => {
const confirmDeleteDocument = async () => {
if (!documentToDelete) return
deleteDocumentMutation(
{ knowledgeBaseId: id, documentId: documentToDelete },
{
onSuccess: () => {
refreshDocuments()
setSelectedDocuments((prev) => {
const newSet = new Set(prev)
newSet.delete(documentToDelete)
return newSet
})
},
onSettled: () => {
setShowDeleteDocumentModal(false)
setDocumentToDelete(null)
},
try {
const response = await fetch(`/api/knowledge/${id}/documents/${documentToDelete}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete document')
}
)
const result = await response.json()
if (result.success) {
refreshDocuments()
setSelectedDocuments((prev) => {
const newSet = new Set(prev)
newSet.delete(documentToDelete)
return newSet
})
}
} catch (err) {
logger.error('Error deleting document:', err)
} finally {
setShowDeleteDocumentModal(false)
setDocumentToDelete(null)
}
}
/**
@@ -768,18 +818,32 @@ export function KnowledgeBase({
/**
* Handles deleting the entire knowledge base
*/
const handleDeleteKnowledgeBase = () => {
const handleDeleteKnowledgeBase = async () => {
if (!knowledgeBase) return
deleteKnowledgeBaseMutation(
{ knowledgeBaseId: id },
{
onSuccess: () => {
removeKnowledgeBase(id)
router.push(`/workspace/${workspaceId}/knowledge`)
},
try {
setIsDeleting(true)
const response = await fetch(`/api/knowledge/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete knowledge base')
}
)
const result = await response.json()
if (result.success) {
removeKnowledgeBase(id)
router.push(`/workspace/${workspaceId}/knowledge`)
} else {
throw new Error(result.error || 'Failed to delete knowledge base')
}
} catch (err) {
logger.error('Error deleting knowledge base:', err)
setIsDeleting(false)
}
}
/**
@@ -792,57 +856,93 @@ export function KnowledgeBase({
/**
* Handles bulk enabling of selected documents
*/
const handleBulkEnable = () => {
const handleBulkEnable = async () => {
const documentsToEnable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
)
if (documentsToEnable.length === 0) return
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'enable',
documentIds: documentsToEnable.map((doc) => doc.id),
},
{
onSuccess: (result) => {
result.updatedDocuments?.forEach((updatedDoc) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully enabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
try {
setIsBulkOperating(true)
const response = await fetch(`/api/knowledge/${id}/documents`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation: 'enable',
documentIds: documentsToEnable.map((doc) => doc.id),
}),
})
if (!response.ok) {
throw new Error('Failed to enable documents')
}
)
const result = await response.json()
if (result.success) {
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully enabled ${result.data.successCount} documents`)
}
setSelectedDocuments(new Set())
} catch (err) {
logger.error('Error enabling documents:', err)
} finally {
setIsBulkOperating(false)
}
}
/**
* Handles bulk disabling of selected documents
*/
const handleBulkDisable = () => {
const handleBulkDisable = async () => {
const documentsToDisable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && doc.enabled
)
if (documentsToDisable.length === 0) return
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'disable',
documentIds: documentsToDisable.map((doc) => doc.id),
},
{
onSuccess: (result) => {
result.updatedDocuments?.forEach((updatedDoc) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully disabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
try {
setIsBulkOperating(true)
const response = await fetch(`/api/knowledge/${id}/documents`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation: 'disable',
documentIds: documentsToDisable.map((doc) => doc.id),
}),
})
if (!response.ok) {
throw new Error('Failed to disable documents')
}
)
const result = await response.json()
if (result.success) {
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
})
logger.info(`Successfully disabled ${result.data.successCount} documents`)
}
setSelectedDocuments(new Set())
} catch (err) {
logger.error('Error disabling documents:', err)
} finally {
setIsBulkOperating(false)
}
}
/**
@@ -856,28 +956,44 @@ export function KnowledgeBase({
/**
* Confirms and executes the bulk deletion of selected documents
*/
const confirmBulkDelete = () => {
const confirmBulkDelete = async () => {
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
if (documentsToDelete.length === 0) return
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'delete',
documentIds: documentsToDelete.map((doc) => doc.id),
},
{
onSuccess: (result) => {
logger.info(`Successfully deleted ${result.successCount} documents`)
refreshDocuments()
setSelectedDocuments(new Set())
},
onSettled: () => {
setShowBulkDeleteModal(false)
try {
setIsBulkOperating(true)
const response = await fetch(`/api/knowledge/${id}/documents`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation: 'delete',
documentIds: documentsToDelete.map((doc) => doc.id),
}),
})
if (!response.ok) {
throw new Error('Failed to delete documents')
}
)
const result = await response.json()
if (result.success) {
logger.info(`Successfully deleted ${result.data.successCount} documents`)
}
await refreshDocuments()
setSelectedDocuments(new Set())
} catch (err) {
logger.error('Error deleting documents:', err)
} finally {
setIsBulkOperating(false)
setShowBulkDeleteModal(false)
}
}
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))

View File

@@ -22,10 +22,10 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
const logger = createLogger('BaseTagsModal')
/** Field type display labels */
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
@@ -45,6 +45,7 @@ interface DocumentListProps {
totalCount: number
}
/** Displays a list of documents affected by tag operations */
function DocumentList({ documents, totalCount }: DocumentListProps) {
const displayLimit = 5
const hasMore = totalCount > displayLimit
@@ -94,14 +95,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
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 [isDeletingTag, setIsDeletingTag] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [isSavingTag, setIsSavingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
@@ -177,12 +177,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}
const tagNameConflict =
isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName)
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
const canSaveTag = () => {
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
}
/** Get slot usage counts per field type */
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 }
@@ -190,11 +191,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return { used, max: config.maxSlots }
}
/** Check if a field type has available slots */
const hasAvailableSlots = (fieldType: string): boolean => {
const { used, max } = getSlotUsageByFieldType(fieldType)
return used < max
}
/** Field type options for Combobox */
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
const { used, max } = getSlotUsageByFieldType(type)
@@ -208,17 +211,43 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const saveTagDefinition = async () => {
if (!canSaveTag()) return
setIsSavingTag(true)
try {
// Check if selected field type has available slots
if (!hasAvailableSlots(createTagForm.fieldType)) {
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
}
await createTagMutation.mutateAsync({
knowledgeBaseId,
// Get the next available slot from the API
const slotResponse = await fetch(
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
)
if (!slotResponse.ok) {
throw new Error('Failed to get available slot')
}
const slotResult = await slotResponse.json()
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
throw new Error('No available tag slots for this field type')
}
const newTagDefinition = {
tagSlot: slotResult.data.nextAvailableSlot,
displayName: createTagForm.displayName.trim(),
fieldType: createTagForm.fieldType,
}
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTagDefinition),
})
if (!response.ok) {
throw new Error('Failed to create tag definition')
}
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setCreateTagForm({
@@ -228,17 +257,27 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
setIsCreatingTag(false)
} catch (error) {
logger.error('Error creating tag definition:', error)
} finally {
setIsSavingTag(false)
}
}
const confirmDeleteTag = async () => {
if (!selectedTag) return
setIsDeletingTag(true)
try {
await deleteTagMutation.mutateAsync({
knowledgeBaseId,
tagDefinitionId: selectedTag.id,
})
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
}
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
@@ -246,6 +285,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
setSelectedTag(null)
} catch (error) {
logger.error('Error deleting tag definition:', error)
} finally {
setIsDeletingTag(false)
}
}
@@ -392,11 +433,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
className='flex-1'
disabled={
!canSaveTag() ||
createTagMutation.isPending ||
isSavingTag ||
!hasAvailableSlots(createTagForm.fieldType)
}
>
{createTagMutation.isPending ? 'Creating...' : 'Create Tag'}
{isSavingTag ? 'Creating...' : 'Create Tag'}
</Button>
</div>
</div>
@@ -440,17 +481,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalFooter>
<Button
variant='default'
disabled={deleteTagMutation.isPending}
disabled={isDeletingTag}
onClick={() => setDeleteTagDialogOpen(false)}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={confirmDeleteTag}
disabled={deleteTagMutation.isPending}
>
{deleteTagMutation.isPending ? 'Deleting...' : 'Delete Tag'}
<Button variant='destructive' onClick={confirmDeleteTag} disabled={isDeletingTag}>
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
@@ -22,7 +23,7 @@ import { cn } from '@/lib/core/utils/cn'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('CreateBaseModal')
@@ -81,11 +82,10 @@ interface SubmitStatus {
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [fileError, setFileError] = useState<string | null>(null)
@@ -245,14 +245,12 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
})
}
const isSubmitting =
createKnowledgeBaseMutation.isPending || deleteKnowledgeBaseMutation.isPending || isUploading
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
try {
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
const knowledgeBasePayload = {
name: data.name,
description: data.description || undefined,
workspaceId: workspaceId,
@@ -261,8 +259,29 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
minSize: data.minChunkSize,
overlap: data.overlapSize,
},
}
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(knowledgeBasePayload),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create knowledge base')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to create knowledge base')
}
const newKnowledgeBase = result.data
if (files.length > 0) {
try {
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
@@ -274,11 +293,15 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
} catch (uploadError) {
logger.error('File upload failed, deleting knowledge base:', uploadError)
try {
await deleteKnowledgeBaseMutation.mutateAsync({
knowledgeBaseId: newKnowledgeBase.id,
await fetch(`/api/knowledge/${newKnowledgeBase.id}`, {
method: 'DELETE',
})
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
} catch (deleteError) {
@@ -286,6 +309,10 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
}
throw uploadError
}
} else {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
}
files.forEach((file) => URL.revokeObjectURL(file.preview))
@@ -298,6 +325,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
type: 'error',
message: error instanceof Error ? error.message : 'An unknown error occurred',
})
} finally {
setIsSubmitting(false)
}
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import {
@@ -14,7 +15,7 @@ import {
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeHeader')
@@ -53,13 +54,14 @@ interface Workspace {
}
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const queryClient = useQueryClient()
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
const updateKnowledgeBase = useUpdateKnowledgeBase()
// Fetch available workspaces
useEffect(() => {
if (!options?.knowledgeBaseId) return
@@ -74,6 +76,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
@@ -94,27 +97,47 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
}, [options?.knowledgeBaseId])
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
setIsWorkspacePopoverOpen(false)
try {
setIsUpdatingWorkspace(true)
setIsWorkspacePopoverOpen(false)
updateKnowledgeBase.mutate(
{
knowledgeBaseId: options.knowledgeBaseId,
updates: { workspaceId },
},
{
onSuccess: () => {
logger.info(
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
)
options.onWorkspaceChange?.(workspaceId)
},
onError: (err) => {
logger.error('Error updating workspace:', err)
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId,
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update workspace')
}
)
const result = await response.json()
if (result.success) {
logger.info(
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
)
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(options.knowledgeBaseId),
})
await options.onWorkspaceChange?.(workspaceId)
} else {
throw new Error(result.error || 'Failed to update workspace')
}
} catch (err) {
logger.error('Error updating workspace:', err)
} finally {
setIsUpdatingWorkspace(false)
}
}
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
@@ -124,6 +147,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
<div className={HEADER_STYLES.container}>
<div className={HEADER_STYLES.breadcrumbs}>
{breadcrumbs.map((breadcrumb, index) => {
// Use unique identifier when available, fallback to content-based key
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
return (
@@ -165,13 +189,13 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isLoadingWorkspaces || updateKnowledgeBase.isPending}
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
className={filterButtonClass}
>
<span className='truncate'>
{isLoadingWorkspaces
? 'Loading...'
: updateKnowledgeBase.isPending
: isUpdatingWorkspace
? 'Updating...'
: currentWorkspace?.name || 'No workspace'}
</span>

View File

@@ -32,7 +32,6 @@ import {
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -52,12 +51,10 @@ export function Knowledge() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
const { knowledgeBases, isLoading, error, removeKnowledgeBase, updateKnowledgeBase } =
useKnowledgeBasesList(workspaceId)
const userPermissions = useUserPermissionsContext()
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
@@ -115,13 +112,29 @@ export function Knowledge() {
*/
const handleUpdateKnowledgeBase = useCallback(
async (id: string, name: string, description: string) => {
await updateKnowledgeBaseMutation({
knowledgeBaseId: id,
updates: { name, description },
const response = await fetch(`/api/knowledge/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, description }),
})
logger.info(`Knowledge base updated: ${id}`)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update knowledge base')
}
const result = await response.json()
if (result.success) {
logger.info(`Knowledge base updated: ${id}`)
updateKnowledgeBase(id, { name, description })
} else {
throw new Error(result.error || 'Failed to update knowledge base')
}
},
[updateKnowledgeBaseMutation]
[updateKnowledgeBase]
)
/**
@@ -129,10 +142,25 @@ export function Knowledge() {
*/
const handleDeleteKnowledgeBase = useCallback(
async (id: string) => {
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
logger.info(`Knowledge base deleted: ${id}`)
const response = await fetch(`/api/knowledge/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete knowledge base')
}
const result = await response.json()
if (result.success) {
logger.info(`Knowledge base deleted: ${id}`)
removeKnowledgeBase(id)
} else {
throw new Error(result.error || 'Failed to delete knowledge base')
}
},
[deleteKnowledgeBaseMutation]
[removeKnowledgeBase]
)
/**

View File

@@ -1477,7 +1477,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_responsd' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
)

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput } from '@/components/emcn'
import { workflowKeys } from '@/hooks/queries/workflows'
const logger = createLogger('DebugSettings')
/**
* Debug settings component for superusers.
* Allows importing workflows by ID for debugging purposes.
*/
export function Debug() {
const params = useParams()
const queryClient = useQueryClient()
const workspaceId = params?.workspaceId as string
const [workflowId, setWorkflowId] = useState('')
const [isImporting, setIsImporting] = useState(false)
const handleImport = async () => {
if (!workflowId.trim()) return
setIsImporting(true)
try {
const response = await fetch('/api/superuser/import-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
}),
})
const data = await response.json()
if (response.ok) {
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
setWorkflowId('')
logger.info('Workflow imported successfully', {
originalWorkflowId: workflowId.trim(),
newWorkflowId: data.newWorkflowId,
copilotChatsImported: data.copilotChatsImported,
})
}
} catch (error) {
logger.error('Failed to import workflow', error)
} finally {
setIsImporting(false)
}
}
return (
<div className='flex h-full flex-col gap-[16px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
placeholder='Enter workflow ID'
disabled={isImporting}
/>
<Button
variant='tertiary'
onClick={handleImport}
disabled={isImporting || !workflowId.trim()}
>
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets'
export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'

View File

@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
import {
Bug,
Files,
KeySquare,
LogIn,
@@ -46,6 +47,7 @@ import {
Copilot,
CredentialSets,
CustomTools,
Debug,
EnvironmentVariables,
FileUploads,
General,
@@ -91,8 +93,15 @@ type SettingsSection =
| 'mcp'
| 'custom-tools'
| 'workflow-mcp-servers'
| 'debug'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
type NavigationSection =
| 'account'
| 'subscription'
| 'tools'
| 'system'
| 'enterprise'
| 'superuser'
type NavigationItem = {
id: SettingsSection
@@ -104,6 +113,7 @@ type NavigationItem = {
requiresEnterprise?: boolean
requiresHosted?: boolean
selfHostedOverride?: boolean
requiresSuperUser?: boolean
}
const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -112,6 +122,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' },
{ key: 'enterprise', title: 'Enterprise' },
{ key: 'superuser', title: 'Superuser' },
]
const allNavigationItems: NavigationItem[] = [
@@ -180,15 +191,24 @@ const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
},
{
id: 'debug',
label: 'Debug',
icon: Bug,
section: 'superuser',
requiresSuperUser: true,
},
]
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession()
const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
@@ -209,6 +229,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
// Fetch superuser status
useEffect(() => {
const fetchSuperUserStatus = async () => {
if (!userId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch {
setIsSuperUser(false)
}
}
fetchSuperUserStatus()
}, [userId])
// Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
@@ -268,6 +305,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
// requiresSuperUser: only show if user is a superuser AND has superuser mode enabled
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
return false
}
return true
})
}, [
@@ -280,6 +324,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
isOwner,
isAdmin,
permissionConfig,
isSuperUser,
generalSettings?.superUserModeEnabled,
])
// Memoized callbacks to prevent infinite loops in child components
@@ -308,9 +354,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
[activeSection]
)
// React Query hook automatically loads and syncs settings
useGeneralSettings()
// Apply initial section from store when modal opens
useEffect(() => {
if (open && initialSection) {
@@ -523,6 +566,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{activeSection === 'debug' && <Debug />}
</SModalMainBody>
</SModalMain>
</SModalContent>

View File

@@ -1,4 +1,4 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import type {
ChunkData,
ChunksPagination,
@@ -332,629 +332,3 @@ export function useDocumentChunkSearchQuery(
placeholderData: keepPreviousData,
})
}
export interface UpdateChunkParams {
knowledgeBaseId: string
documentId: string
chunkId: string
content?: string
enabled?: boolean
}
export async function updateChunk({
knowledgeBaseId,
documentId,
chunkId,
content,
enabled,
}: UpdateChunkParams): Promise<ChunkData> {
const body: Record<string, unknown> = {}
if (content !== undefined) body.content = content
if (enabled !== undefined) body.enabled = enabled
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update chunk')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update chunk')
}
return result.data
}
export function useUpdateChunk() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateChunk,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface DeleteChunkParams {
knowledgeBaseId: string
documentId: string
chunkId: string
}
export async function deleteChunk({
knowledgeBaseId,
documentId,
chunkId,
}: DeleteChunkParams): Promise<void> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete chunk')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete chunk')
}
}
export function useDeleteChunk() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteChunk,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface CreateChunkParams {
knowledgeBaseId: string
documentId: string
content: string
enabled?: boolean
}
export async function createChunk({
knowledgeBaseId,
documentId,
content,
enabled = true,
}: CreateChunkParams): Promise<ChunkData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, enabled }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create chunk')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create chunk')
}
return result.data
}
export function useCreateChunk() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createChunk,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface UpdateDocumentParams {
knowledgeBaseId: string
documentId: string
updates: {
enabled?: boolean
filename?: string
retryProcessing?: boolean
markFailedDueToTimeout?: boolean
}
}
export async function updateDocument({
knowledgeBaseId,
documentId,
updates,
}: UpdateDocumentParams): Promise<DocumentData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update document')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update document')
}
return result.data
}
export function useUpdateDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateDocument,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface DeleteDocumentParams {
knowledgeBaseId: string
documentId: string
}
export async function deleteDocument({
knowledgeBaseId,
documentId,
}: DeleteDocumentParams): Promise<void> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete document')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete document')
}
}
export function useDeleteDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteDocument,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface BulkDocumentOperationParams {
knowledgeBaseId: string
operation: 'enable' | 'disable' | 'delete'
documentIds: string[]
}
export interface BulkDocumentOperationResult {
successCount: number
failedCount: number
updatedDocuments?: Array<{ id: string; enabled: boolean }>
}
export async function bulkDocumentOperation({
knowledgeBaseId,
operation,
documentIds,
}: BulkDocumentOperationParams): Promise<BulkDocumentOperationResult> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, documentIds }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || `Failed to ${operation} documents`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || `Failed to ${operation} documents`)
}
return result.data
}
export function useBulkDocumentOperation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bulkDocumentOperation,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface CreateKnowledgeBaseParams {
name: string
description?: string
workspaceId: string
chunkingConfig: {
maxSize: number
minSize: number
overlap: number
}
}
export async function createKnowledgeBase(
params: CreateKnowledgeBaseParams
): Promise<KnowledgeBaseData> {
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create knowledge base')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create knowledge base')
}
return result.data
}
export function useCreateKnowledgeBase(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createKnowledgeBase,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
},
})
}
export interface UpdateKnowledgeBaseParams {
knowledgeBaseId: string
updates: {
name?: string
description?: string
workspaceId?: string | null
}
}
export async function updateKnowledgeBase({
knowledgeBaseId,
updates,
}: UpdateKnowledgeBaseParams): Promise<KnowledgeBaseData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update knowledge base')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update knowledge base')
}
return result.data
}
export function useUpdateKnowledgeBase(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateKnowledgeBase,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
},
})
}
export interface DeleteKnowledgeBaseParams {
knowledgeBaseId: string
}
export async function deleteKnowledgeBase({
knowledgeBaseId,
}: DeleteKnowledgeBaseParams): Promise<void> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete knowledge base')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete knowledge base')
}
}
export function useDeleteKnowledgeBase(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteKnowledgeBase,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
},
})
}
export interface BulkChunkOperationParams {
knowledgeBaseId: string
documentId: string
operation: 'enable' | 'disable' | 'delete'
chunkIds: string[]
}
export interface BulkChunkOperationResult {
successCount: number
failedCount: number
results: Array<{
operation: string
chunkIds: string[]
}>
}
export async function bulkChunkOperation({
knowledgeBaseId,
documentId,
operation,
chunkIds,
}: BulkChunkOperationParams): Promise<BulkChunkOperationResult> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, chunkIds }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || `Failed to ${operation} chunks`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || `Failed to ${operation} chunks`)
}
return result.data
}
export function useBulkChunkOperation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bulkChunkOperation,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface UpdateDocumentTagsParams {
knowledgeBaseId: string
documentId: string
tags: Record<string, string>
}
export async function updateDocumentTags({
knowledgeBaseId,
documentId,
tags,
}: UpdateDocumentTagsParams): Promise<DocumentData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tags),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update document tags')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update document tags')
}
return result.data
}
export function useUpdateDocumentTags() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateDocumentTags,
onSuccess: (_, { knowledgeBaseId, documentId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
})
},
})
}
export interface TagDefinitionData {
id: string
tagSlot: string
displayName: string
fieldType: string
createdAt: string
updatedAt: string
}
export interface CreateTagDefinitionParams {
knowledgeBaseId: string
displayName: string
fieldType: string
}
async function fetchNextAvailableSlot(knowledgeBaseId: string, fieldType: string): Promise<string> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${fieldType}`
)
if (!response.ok) {
throw new Error('Failed to get available slot')
}
const result = await response.json()
if (!result.success || !result.data?.nextAvailableSlot) {
throw new Error('No available tag slots for this field type')
}
return result.data.nextAvailableSlot
}
export async function createTagDefinition({
knowledgeBaseId,
displayName,
fieldType,
}: CreateTagDefinitionParams): Promise<TagDefinitionData> {
const tagSlot = await fetchNextAvailableSlot(knowledgeBaseId, fieldType)
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagSlot, displayName, fieldType }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create tag definition')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create tag definition')
}
return result.data
}
export function useCreateTagDefinition() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createTagDefinition,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface DeleteTagDefinitionParams {
knowledgeBaseId: string
tagDefinitionId: string
}
export async function deleteTagDefinition({
knowledgeBaseId,
tagDefinitionId,
}: DeleteTagDefinitionParams): Promise<void> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${tagDefinitionId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete tag definition')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete tag definition')
}
}
export function useDeleteTagDefinition() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteTagDefinition,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}

View File

@@ -1,18 +1,41 @@
import { db } from '@sim/db'
import { member, templateCreators, templates, user } from '@sim/db/schema'
import { member, settings, templateCreators, templates, user } from '@sim/db/schema'
import { and, eq, or } from 'drizzle-orm'
export type CreatorPermissionLevel = 'member' | 'admin'
/**
* Verifies if a user is a super user.
* Verifies if a user is an effective super user (database flag AND settings toggle).
* This should be used for features that can be disabled by the user's settings toggle.
*
* @param userId - The ID of the user to check
* @returns Object with isSuperUser boolean
* @returns Object with effectiveSuperUser boolean and component values
*/
export async function verifySuperUser(userId: string): Promise<{ isSuperUser: boolean }> {
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
return { isSuperUser: currentUser?.isSuperUser || false }
export async function verifyEffectiveSuperUser(userId: string): Promise<{
effectiveSuperUser: boolean
isSuperUser: boolean
superUserModeEnabled: boolean
}> {
const [currentUser] = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, userId))
.limit(1)
const [userSettings] = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
const isSuperUser = currentUser?.isSuperUser || false
const superUserModeEnabled = userSettings?.superUserModeEnabled ?? false
return {
effectiveSuperUser: isSuperUser && superUserModeEnabled,
isSuperUser,
superUserModeEnabled,
}
}
/**