Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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"
}
}
}
3 changes: 2 additions & 1 deletion 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 @@ -391,7 +392,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
104 changes: 7 additions & 97 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,6 +215,7 @@ 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"

const METADATA_NO_DOCUMENTS_FOUND = "METADATA_NO_DOCUMENTS_FOUND_INTERNAL"
const METADATA_FALLBACK_TO_RAG = "METADATA_FALLBACK_TO_RAG_INTERNAL"
Expand Down Expand Up @@ -485,7 +488,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 @@ -770,106 +773,13 @@ export const ChatDeleteApi = async (c: Context) => {
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
await DeleteDocument(fileId, KbItemsSchema)
loggerWithChild({ email: email }).info(
`Successfully deleted non-image attachment ${fileId} from Vespa kb_items schema`,
)
} 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[]
await handleAttachmentDelete(attachments, email)
}
}

Expand Down
Loading
Loading