Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/components/AttachmentPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { getFileType } from "@/utils/fileUtils"
import { getFileType } from "shared/fileUtils"
import { getFileIcon } from "@/components/ChatBox"

interface AttachmentPreviewProps {
Expand Down
30 changes: 26 additions & 4 deletions frontend/src/components/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ import {
validateAndDeduplicateFiles,
createImagePreview,
cleanupPreviewUrls,
getFileType,
} from "@/utils/fileUtils"
import { getFileType } from "shared/fileUtils"
import { authFetch } from "@/utils/authFetch"

interface SelectedFile {
Expand Down Expand Up @@ -977,15 +977,37 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
return ext || "file"
}

const removeFile = useCallback((id: string) => {
const removeFile = useCallback(async (id: string) => {
const fileToRemove = selectedFiles.find((f) => f.id === id)

// If the file has metadata with fileId (meaning it's already uploaded), delete it from the server
if (fileToRemove?.metadata?.fileId) {
try {
const response = await api.files.delete.$post({
json: {
attachment: fileToRemove.metadata
}
})

if (!response.ok) {
const errorText = await response.text()
console.error(`Failed to delete attachment: ${errorText}`)
// Still remove from UI even if server deletion fails
}
} catch (error) {
console.error('Error deleting attachment:', error)
// Still remove from UI even if server deletion fails
}
}

// Remove from UI
setSelectedFiles((prev) => {
const fileToRemove = prev.find((f) => f.id === id)
if (fileToRemove?.preview) {
URL.revokeObjectURL(fileToRemove.preview)
}
return prev.filter((f) => f.id !== id)
})
}, [])
}, [selectedFiles])

const { handleFileSelect, handleFileChange } = createFileSelectionHandlers(
fileInputRef,
Expand Down
26 changes: 2 additions & 24 deletions frontend/src/lib/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ import {
DataSourceEntity,
WebSearchEntity,
FileType,
MIME_TYPE_MAPPINGS,
EXTENSION_MAPPINGS,
} from "shared/types"
import { LoadingSpinner } from "@/routes/_authenticated/admin/integrations/google"
import { getFileType } from "shared/fileUtils"

// Define placeholder entities if they don't exist in shared/types
const PdfEntity = { Default: "pdf_default" } as const
Expand Down Expand Up @@ -165,30 +164,9 @@ export const getIcon = (
}
}

// Helper function to determine FileType from a file
export const getFileType = (file: File): FileType => {
// First check by MIME type
for (const [fileType, mimeTypes] of Object.entries(MIME_TYPE_MAPPINGS)) {
if ((mimeTypes as readonly string[]).includes(file.type)) {
return fileType as FileType
}
}

// Fallback to extension checking
const fileName = file.name.toLowerCase()
for (const [fileType, extensions] of Object.entries(EXTENSION_MAPPINGS)) {
if ((extensions as readonly string[]).some(ext => fileName.endsWith(ext))) {
return fileType as FileType
}
}

// Default fallback
return FileType.FILE
}

// Icon mapping from FileType to SVG component
export const getFileIcon = (file: File) => {
const fileType = getFileType(file)
const fileType = getFileType({type: file.type, name: file.name})

switch (fileType) {
case FileType.TEXT:
Expand Down
26 changes: 1 addition & 25 deletions frontend/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,11 @@
import { isValidFile, isImageFile } from "shared/fileUtils"
import { SelectedFile } from "@/components/ClFileUpload"
import { authFetch } from "./authFetch"
import { FileType, MIME_TYPE_MAPPINGS, EXTENSION_MAPPINGS, UploadStatus } from "shared/types"
import { UploadStatus } from "shared/types"

// Generate unique ID for files
export const generateFileId = () => Math.random().toString(36).substring(2, 9)

export const getFileType = ({ type, name }: { type: string, name: string }): FileType => {
const fileName = name.toLowerCase()
const mimeType = type.toLowerCase()
const baseMime = mimeType.split(";")[0]

// Check each file type category using the mappings
for (const [fileType, mimeTypes] of Object.entries(MIME_TYPE_MAPPINGS)) {
// Check MIME type first (more reliable)
if (mimeTypes.some(mime => baseMime === mime)) {
return fileType as FileType
}
}

// Fallback to extension-based detection
for (const [fileType, extensions] of Object.entries(EXTENSION_MAPPINGS)) {
if (extensions.some(ext => fileName.endsWith(ext))) {
return fileType as FileType
}
}

// Default fallback
return FileType.FILE
}

// Create preview URL for image files
export const createImagePreview = (file: File): string | undefined => {
if (isImageFile(file.type)) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"dependencies": {
"zustand": "^5.0.8"
}
}
}
5 changes: 3 additions & 2 deletions server/api/chat/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
VespaChatUserSchema,
type VespaSearchResult,
type VespaSearchResults,
AttachmentEntity,
} from "@xyne/vespa-ts/types"
import { APIError } from "openai"
import { insertChatTrace } from "@/db/chatTrace"
Expand Down Expand Up @@ -121,7 +122,6 @@ import { getModelValueFromLabel } from "@/ai/modelConfig"
import {
buildContext,
buildUserQuery,
expandSheetIds,
getThreadContext,
isContextSelected,
UnderstandMessageAndAnswer,
Expand Down Expand Up @@ -156,6 +156,7 @@ import { getDateForAI } from "@/utils/index"
import { validateVespaIdInAgentIntegrations } from "@/search/utils"
import { getAuth, safeGet } from "../agent"
import { applyFollowUpContext } from "@/utils/parseAttachment"
import { expandSheetIds } from "@/search/utils"
const {
JwtPayloadKey,
defaultBestModel,
Expand Down Expand Up @@ -392,7 +393,7 @@ const checkAndYieldCitationsForAgent = async function* (
}

// we dont want citations for attachments in the chat
if (item.source.entity === KnowledgeBaseEntity.Attachment) {
if (Object.values(AttachmentEntity).includes(item.source.entity as AttachmentEntity)) {
continue
}

Expand Down
140 changes: 12 additions & 128 deletions server/api/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ import {
type VespaSearchResultsSchema,
KnowledgeBaseEntity,
KbItemsSchema,
AttachmentEntity,
fileSchema,
} from "@xyne/vespa-ts/types"
import { APIError } from "openai"
import {
Expand Down Expand Up @@ -213,31 +215,12 @@ import { getDateForAI } from "@/utils/index"
import type { User } from "@microsoft/microsoft-graph-types"
import { getAuth, safeGet } from "../agent"
import { getChunkCountPerDoc } from "./chunk-selection"
import { handleAttachmentDelete } from "../files"
import { expandSheetIds } from "@/search/utils"

const METADATA_NO_DOCUMENTS_FOUND = "METADATA_NO_DOCUMENTS_FOUND_INTERNAL"
const METADATA_FALLBACK_TO_RAG = "METADATA_FALLBACK_TO_RAG_INTERNAL"

export function expandSheetIds(fileId: string): string[] {
// Check if the fileId matches the pattern docId_sheet_number
const sheetMatch = fileId.match(/^(.+)_sheet_(\d+)$/)

if (!sheetMatch) {
// Not a sheet ID, return as is
return [fileId]
}

const [, docId, sheetNumberStr] = sheetMatch
const sheetNumber = parseInt(sheetNumberStr, 10)
// Generate IDs from docId_sheet_0 to docId_sheet_number
const expandedIds: string[] = []
const upper = Number.isFinite(sheetNumber) ? sheetNumber : 1
for (let i = 0; i < upper; i++) {
expandedIds.push(`${docId}_sheet_${i}`)
}

return expandedIds
}

export async function resolveNamesToEmails(
intent: Intent,
email: string,
Expand Down Expand Up @@ -506,7 +489,7 @@ const checkAndYieldCitations = async function* (
const f = (item as any)?.fields
if (
f?.sddocname === dataSourceFileSchema ||
f?.entity === KnowledgeBaseEntity.Attachment
Object.values(AttachmentEntity).includes(f?.entity)
) {
// Skip datasource and attachment files from citations
continue
Expand Down Expand Up @@ -784,123 +767,21 @@ export const ChatDeleteApi = async (c: Context) => {
email = sub || ""
// @ts-ignore
const { chatId } = c.req.valid("json")
const attachmentsToDelete: AttachmentMetadata[] = []
await db.transaction(async (tx) => {
// Get the chat's internal ID first
const chat = await getChatByExternalIdWithAuth(tx, chatId, email)
if (!chat) {
throw new HTTPException(404, { message: "Chat not found" })
}

// Get all messages for the chat to find attachments
// Get all messages for the chat to delete attachments
const messagesToDelete = await getChatMessagesWithAuth(tx, chatId, email)

// Collect all attachment file IDs that need to be deleted
const imageAttachmentFileIds: string[] = []
const nonImageAttachmentFileIds: string[] = []

for (const message of messagesToDelete) {
if (message.attachments && Array.isArray(message.attachments)) {
const attachments =
message.attachments as unknown as AttachmentMetadata[]
for (const attachment of attachments) {
if (attachment && typeof attachment === "object") {
if (attachment.fileId) {
// Check if this is an image attachment using both isImage field and fileType
const isImageAttachment =
attachment.isImage ||
(attachment.fileType && isImageFile(attachment.fileType))

if (isImageAttachment) {
imageAttachmentFileIds.push(attachment.fileId)
} else {
nonImageAttachmentFileIds.push(attachment.fileId)
}
}
}
}
}
}

// Delete image attachments and their thumbnails from disk
if (imageAttachmentFileIds.length > 0) {
loggerWithChild({ email: email }).info(
`Deleting ${imageAttachmentFileIds.length} image attachment files and their thumbnails for chat ${chatId}`,
)

for (const fileId of imageAttachmentFileIds) {
try {
// Validate fileId to prevent path traversal
if (
fileId.includes("..") ||
fileId.includes("/") ||
fileId.includes("\\")
) {
loggerWithChild({ email: email }).error(
`Invalid fileId detected: ${fileId}. Skipping deletion for security.`,
)
continue
}
const imageBaseDir = path.resolve(
process.env.IMAGE_DIR || "downloads/xyne_images_db",
)

const imageDir = path.join(imageBaseDir, fileId)
try {
await fs.access(imageDir)
await fs.rm(imageDir, { recursive: true, force: true })
loggerWithChild({ email: email }).info(
`Deleted image attachment directory: ${imageDir}`,
)
} catch (attachmentError) {
loggerWithChild({ email: email }).warn(
`Image attachment file ${fileId} not found in either directory during chat deletion`,
)
}
} catch (error) {
loggerWithChild({ email: email }).error(
error,
`Failed to delete image attachment file ${fileId} during chat deletion: ${getErrorMessage(error)}`,
)
}
}
}

// Delete non-image attachments from Vespa kb_items schema
if (nonImageAttachmentFileIds.length > 0) {
loggerWithChild({ email: email }).info(
`Deleting ${nonImageAttachmentFileIds.length} non-image attachments from Vespa kb_items schema for chat ${chatId}`,
)

for (const fileId of nonImageAttachmentFileIds) {
try {
// Delete from Vespa kb_items schema using the proper Vespa function
const vespaIds = expandSheetIds(fileId)
for (const id of vespaIds) {
try {
await DeleteDocument(id, KbItemsSchema)
loggerWithChild({ email }).info(
`Successfully deleted non-image attachment ${id} from Vespa kb_items schema`,
)
} catch (error) {
loggerWithChild({ email }).error(
`Failed to delete non-image attachment ${id} from Vespa kb_items schema`,
{ error: getErrorMessage(error) }
)
}
}
} catch (error) {
const errorMessage = getErrorMessage(error)
if (errorMessage.includes("404 Not Found")) {
loggerWithChild({ email: email }).warn(
`Non-image attachment ${fileId} not found in Vespa kb_items schema (may have been already deleted)`,
)
} else {
loggerWithChild({ email: email }).error(
error,
`Failed to delete non-image attachment ${fileId} from Vespa kb_items schema: ${errorMessage}`,
)
}
}
const attachments = message.attachments as AttachmentMetadata[]
attachmentsToDelete.push(...attachments)
}
}

Expand All @@ -913,6 +794,9 @@ export const ChatDeleteApi = async (c: Context) => {
await deleteMessagesByChatId(tx, chatId)
await deleteChatByExternalIdWithAuth(tx, chatId, email)
})
if (attachmentsToDelete.length) {
await handleAttachmentDelete(attachmentsToDelete, email)
}
return c.json({ success: true })
} catch (error) {
const errMsg = getErrorMessage(error)
Expand Down
Loading
Loading