Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
141 changes: 13 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 Expand Up @@ -2086,6 +1970,7 @@ async function* generateAnswerFromGivenContext(
results = await searchVespaInFiles(builtUserQuery, email, nonCollectionFileIds, {
limit: fileIds?.length,
alpha: userAlpha,
rankProfile: SearchModes.attachmentRank,
})
if (results.root.children) {
combinedSearchResponse.push(...results.root.children)
Expand Down
Loading
Loading