From 772e4cbc7fd7bba25b530791a191df95ee07eaa7 Mon Sep 17 00:00:00 2001 From: dhirenmathur Date: Fri, 7 Nov 2025 16:37:40 +0530 Subject: [PATCH 1/4] Documents support in chat --- app/(auth)/utils/github-oauth.ts | 76 +++++++ .../[chatId]/components/MessageComposer.tsx | 200 ++++++++++++++++-- .../chat/[chatId]/components/Thread.tsx | 51 ++++- app/(main)/chat/[chatId]/runtime.ts | 15 +- app/(main)/newchat/components/step1.tsx | 32 +-- components/agent-creation/ChatPanel.tsx | 1 + components/chat/ContextUsageIndicator.tsx | 135 ++++++++++++ components/chat/DocumentAttachmentCard.tsx | 90 ++++++++ components/chat/ValidationErrorModal.tsx | 134 ++++++++++++ lib/types/attachment.ts | 83 ++++++++ lib/utils/fileTypes.ts | 82 +++++++ services/ChatService.ts | 144 +++++++++++++ 12 files changed, 997 insertions(+), 46 deletions(-) create mode 100644 app/(auth)/utils/github-oauth.ts create mode 100644 components/chat/ContextUsageIndicator.tsx create mode 100644 components/chat/DocumentAttachmentCard.tsx create mode 100644 components/chat/ValidationErrorModal.tsx create mode 100644 lib/types/attachment.ts create mode 100644 lib/utils/fileTypes.ts diff --git a/app/(auth)/utils/github-oauth.ts b/app/(auth)/utils/github-oauth.ts new file mode 100644 index 00000000..b4935b79 --- /dev/null +++ b/app/(auth)/utils/github-oauth.ts @@ -0,0 +1,76 @@ +"use client"; + +import { + GithubAuthProvider, + linkWithPopup, + reauthenticateWithPopup, + User, + UserCredential, +} from "firebase/auth"; + +type GithubOAuthResult = + | { + status: "linked"; + credential: UserCredential; + accessToken: string | null; + providerUsername?: string; + } + | { + status: "already_linked"; + }; + +const GITHUB_PROVIDER_ID = "github.com"; + +/** + * Triggers GitHub OAuth for the given Firebase user ensuring the required scopes + * are granted. If the account is already linked, it skips the popup. + */ +export const ensureGithubOAuth = async ( + user: User | null, + options: { forceReauth?: boolean } = {} +): Promise => { + if (!user) { + throw new Error("GitHub OAuth requires an authenticated user."); + } + + const hasGithubLinked = user.providerData?.some( + (provider) => provider.providerId === GITHUB_PROVIDER_ID + ); + + const provider = new GithubAuthProvider(); + provider.addScope("read:user"); + provider.addScope("read:org"); + provider.addScope("user:email"); + + // When the account is already linked we optionally re-authenticate to refresh tokens. + if (hasGithubLinked && !options.forceReauth) { + return { status: "already_linked" }; + } + + try { + const result = hasGithubLinked + ? await reauthenticateWithPopup(user, provider) + : await linkWithPopup(user, provider); + + const credential = GithubAuthProvider.credentialFromResult(result); + const accessToken = + credential?.accessToken ?? + (result as any)?._tokenResponse?.oauthAccessToken ?? + null; + + const providerUsername = (result as any)?._tokenResponse?.screenName; + + return { + status: "linked", + credential: result, + accessToken, + providerUsername, + }; + } catch (error: any) { + // If the provider is already linked but reauth is not requested, surface as already linked. + if (error?.code === "auth/provider-already-linked") { + return { status: "already_linked" }; + } + throw error; + } +}; diff --git a/app/(main)/chat/[chatId]/components/MessageComposer.tsx b/app/(main)/chat/[chatId]/components/MessageComposer.tsx index 3959c676..e4570d9a 100644 --- a/app/(main)/chat/[chatId]/components/MessageComposer.tsx +++ b/app/(main)/chat/[chatId]/components/MessageComposer.tsx @@ -45,6 +45,16 @@ import { useAuthContext } from "@/contexts/AuthContext"; import { useSelector } from "react-redux"; import { RootState } from "@/lib/state/store"; import { useQuery } from "@tanstack/react-query"; +import { DocumentAttachment, ValidationResponse } from "@/lib/types/attachment"; +import { + isDocumentType, + isImageType, + MAX_FILE_SIZE, + getSupportedFileExtensions +} from "@/lib/utils/fileTypes"; +import { DocumentAttachmentCard } from "@/components/chat/DocumentAttachmentCard"; +import { ValidationErrorModal } from "@/components/chat/ValidationErrorModal"; +import { ContextUsageIndicator } from "@/components/chat/ContextUsageIndicator"; interface MessageComposerProps extends React.HTMLAttributes { projectId: string; @@ -96,6 +106,13 @@ const MessageComposer = ({ const [images, setImages] = useState([]); const [imagePreviews, setImagePreviews] = useState([]); const [isDragOver, setIsDragOver] = useState(false); + const [documents, setDocuments] = useState([]); + const [validating, setValidating] = useState(false); + const [validationError, setValidationError] = useState<{ + validation: ValidationResponse; + file: File; + } | null>(null); + const [uploadingDocument, setUploadingDocument] = useState(false); const messageRef = useRef(null); const fileInputRef = useRef(null); @@ -131,15 +148,17 @@ const MessageComposer = ({ } }); - // Update config when images or nodes change + // Update config when images, documents, or nodes change useEffect(() => { composer.setRunConfig({ custom: { selectedNodes: selectedNodes, - images: images + images: images, + documentIds: documents.map(doc => doc.id), + documents: documents // Pass full document objects for display } }); - }, [selectedNodes, images, composer]); + }, [selectedNodes, images, documents, composer]); // Use a flag to prevent double processing const processingPaste = useRef(false); @@ -276,27 +295,39 @@ const MessageComposer = ({ }; const handleSend = () => { - // For now, we'll handle images through the runtime custom config - // The actual image handling will be done in the runtime when onNew is called - + // Ensure config is set with current documents before sending + const documentIds = documents.map(doc => doc.id); + console.log('[MessageComposer] handleSend - documentIds:', documentIds); + console.log('[MessageComposer] handleSend - documents:', documents); + + composer.setRunConfig({ + custom: { + selectedNodes: selectedNodes, + images: images, + documentIds: documentIds, + documents: documents // Pass full document objects for display + } + }); + composer.send(); setMessage(""); setSelectedNodes([]); setImages([]); setImagePreviews([]); + setDocuments([]); }; const handleImageSelect = (files: FileList | null) => { if (!files) return; - - const newImages = Array.from(files).filter(file => + + const newImages = Array.from(files).filter(file => file.type.startsWith('image/') ); - + if (newImages.length === 0) return; - + setImages(prev => [...prev, ...newImages]); - + // Generate previews newImages.forEach(file => { const reader = new FileReader(); @@ -309,6 +340,63 @@ const MessageComposer = ({ }); }; + const handleDocumentSelect = async (files: FileList | null) => { + if (!files || files.length === 0) return; + + const file = files[0]; // Single document at a time for now + + // Client-side validation + if (file.size > MAX_FILE_SIZE) { + alert(`File size exceeds 10MB limit. ${file.name} is ${(file.size / 1024 / 1024).toFixed(1)} MB`); + return; + } + + if (!isDocumentType(file.type)) { + alert(`Unsupported file type: ${file.type}\n\nSupported types: PDF, DOCX, CSV, XLSX, code files, markdown`); + return; + } + + // Validate against context window + setValidating(true); + try { + const validation = await ChatService.validateDocument(conversation_id, file); + + if (!validation.can_upload) { + // Show error modal + setValidationError({ validation, file }); + return; + } + + // Show warning if usage will be high + if (validation.usage_after_upload && validation.usage_after_upload > 80) { + console.warn(`High context usage after upload: ${validation.usage_after_upload}%`); + } + + // Upload the document + setUploadingDocument(true); + const uploadResult = await ChatService.uploadAttachment(file); + + // Get full metadata including token count + const attachmentInfo = await ChatService.getAttachmentInfo(uploadResult.id); + + // Add to documents state + const documentAttachment: DocumentAttachment = { + ...uploadResult, + file, + token_count: attachmentInfo.file_metadata.token_count, + metadata: attachmentInfo.file_metadata, + }; + + setDocuments(prev => [...prev, documentAttachment]); + } catch (error: any) { + console.error('Document upload error:', error); + alert(`Failed to upload document: ${error.message}`); + } finally { + setValidating(false); + setUploadingDocument(false); + } + }; + const handleImagePaste = (e: React.ClipboardEvent) => { if (!isMultimodalEnabled()) return; @@ -505,9 +593,39 @@ const MessageComposer = ({ } }; + const handleCloseValidationModal = () => { + setValidationError(null); + }; + return ( -
- {(nodeOptions?.length > 0 || isSearchingNode) && } + <> + {/* Validation Error Modal */} + {validationError && ( + { + // Scroll to attachments section + handleCloseValidationModal(); + }} + onChangeModel={() => { + // Open model selector + handleCloseValidationModal(); + // Trigger model dialog (you may need to add this functionality) + }} + /> + )} + +
+ {/* Context Usage Indicator */} +
+ +
+ + {(nodeOptions?.length > 0 || isSearchingNode) && }
{/* display selected nodes */} {selectedNodes.map((node) => ( @@ -549,6 +667,38 @@ const MessageComposer = ({
)} + {/* Document Attachments */} + {documents.length > 0 && ( +
+
+ Documents ({documents.length}) +
+ {documents.map((doc, index) => ( + { + setDocuments(prev => prev.filter((_, i) => i !== index)); + }} + onDownload={() => { + ChatService.downloadAttachment(doc.id, doc.file_name); + }} + /> + ))} +
+ )} + + {/* Validation/Upload Loading State */} + {(validating || uploadingDocument) && ( +
+ + + {validating && 'Validating document...'} + {uploadingDocument && 'Uploading document...'} + +
+ )} + {/* Image Previews */} {isMultimodalEnabled() && imagePreviews.length > 0 && !isDragOver && (
@@ -577,14 +727,29 @@ const MessageComposer = ({ handleImageSelect(e.target.files)} - accept="image/*" - multiple + onChange={(e) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + if (isImageType(file.type)) { + handleImageSelect(files); + } else if (isDocumentType(file.type)) { + handleDocumentSelect(files); + } else { + alert('Unsupported file type'); + } + + // Reset input + e.target.value = ''; + }} + accept={getSupportedFileExtensions()} + multiple={false} className="hidden" />
+ ); }; diff --git a/app/(main)/chat/[chatId]/components/Thread.tsx b/app/(main)/chat/[chatId]/components/Thread.tsx index f36a21cd..7d12427f 100644 --- a/app/(main)/chat/[chatId]/components/Thread.tsx +++ b/app/(main)/chat/[chatId]/components/Thread.tsx @@ -341,11 +341,12 @@ const UserMessageWithURL = (userPhotoURL: string) => { const UserMessage: FC<{ userPhotoURL: string }> = ({ userPhotoURL }) => { const message = useMessage(); - - // Separate text and image content + + // Separate text, image content, and attachments const textContent = message.content.find(c => c.type === "text"); const imageContent = message.content.filter(c => c.type === "image"); - + const attachments = (message as any).attachments || []; + return ( = ({ userPhotoURL }) => { >
+ {/* Document Attachments */} + {attachments.length > 0 && ( +
+ {attachments.map((attachment: any, index: number) => { + // Handle both backend format (file_metadata) and frontend format (metadata, token_count) + const tokenCount = attachment.token_count || attachment.file_metadata?.token_count; + const metadata = attachment.metadata || attachment.file_metadata; + + return ( +
+ + {attachment.attachment_type === 'pdf' && '📄'} + {attachment.attachment_type === 'spreadsheet' && '📊'} + {attachment.attachment_type === 'code' && '💻'} + {attachment.attachment_type === 'document' && '📝'} + +
+
{attachment.file_name}
+ {tokenCount && ( +
+ {tokenCount.toLocaleString()} tokens +
+ )} + {metadata?.page_count && ( +
+ {metadata.page_count} pages +
+ )} +
+
+ ); + })} +
+ )} + {/* Only render image previews if multimodal enabled and images exist */} {isMultimodalEnabled() && imageContent.length > 0 && (
@@ -369,14 +408,14 @@ const UserMessage: FC<{ userPhotoURL: string }> = ({ userPhotoURL }) => { ))}
)} - + {/* Render text content */} {textContent && (
{(textContent as any).text}
)} - + {/* Fallback: if no custom content, use original */} - {imageContent.length === 0 && !textContent && ( + {imageContent.length === 0 && !textContent && attachments.length === 0 && ( )}
diff --git a/app/(main)/chat/[chatId]/runtime.ts b/app/(main)/chat/[chatId]/runtime.ts index c936c05c..32e997df 100644 --- a/app/(main)/chat/[chatId]/runtime.ts +++ b/app/(main)/chat/[chatId]/runtime.ts @@ -231,6 +231,7 @@ export function PotpieRuntime(chatId: string) { message, [], // @ts-ignore [], // No images for this function + [], // No attachment IDs for this function (message: string, tool_calls: any[]) => { setIsRunning(false); setExtras({ loading: false, streaming: true, error: false }); @@ -265,6 +266,9 @@ export function PotpieRuntime(chatId: string) { ? (message.runConfig?.custom?.images as File[]) || [] : []; + // Extract document attachments from config + const documentAttachments = (message.runConfig?.custom?.documents as any[]) || []; + // Create image content for display (only if enabled) const imageContent = isMultimodalEnabled() ? images.map(image => ({ @@ -273,14 +277,16 @@ export function PotpieRuntime(chatId: string) { })) : []; - // Create user message with both text and images + // Create user message with text, images, and document metadata const userMessage: ThreadMessageLike = { role: "user", content: [ { type: "text", text: textContent.text }, ...imageContent ], - }; + // Attach document metadata for display + ...(documentAttachments.length > 0 ? { attachments: documentAttachments } : {}) + } as any; setIsRunning(true); setMessages((currentMessages) => [...currentMessages, userMessage]); @@ -288,11 +294,16 @@ export function PotpieRuntime(chatId: string) { return [...currentMessages, getMessageFromText(undefined, "")]; }); try { + const documentIds = (message.runConfig?.custom?.documentIds as string[]) || []; + console.log('[Runtime] onNew - runConfig.custom:', message.runConfig?.custom); + console.log('[Runtime] onNew - documentIds:', documentIds); + await ChatService.streamMessage( chatId, textContent.text, (message.runConfig?.custom?.selectedNodes as any[]) || [], images, // Pass images to the service + documentIds, // Pass document attachment IDs to the service (message: string, tool_calls: any[]) => { setIsRunning(false); setExtras({ loading: false, streaming: true, error: false }); diff --git a/app/(main)/newchat/components/step1.tsx b/app/(main)/newchat/components/step1.tsx index 2ae101af..0266280a 100644 --- a/app/(main)/newchat/components/step1.tsx +++ b/app/(main)/newchat/components/step1.tsx @@ -213,14 +213,6 @@ const Step1: React.FC = ({ return repoIdentifier && repoIdentifier.toLowerCase() === decodedDefaultRepo; }); dispatch(setRepoName(matchingRepo ? decodeURIComponent(defaultRepo) : "")); - } - const decodedDefaultRepo = decodeURIComponent(defaultRepo).toLowerCase(); - const matchingRepo = data.find((repo: { full_name?: string | null; owner?: string | null; name?: string | null }) => { - const repoIdentifier = getRepoIdentifier(repo); - return repoIdentifier && repoIdentifier.toLowerCase() === decodedDefaultRepo; - }); - dispatch(setRepoName(matchingRepo ? decodeURIComponent(defaultRepo) : "")); - } } return data; }); @@ -495,19 +487,17 @@ const Step1: React.FC = ({ )} - {!process.env.NEXT_PUBLIC_BASE_URL?.includes('localhost') && ( - - { - e.preventDefault(); - openPopup(); - }} - > - Link new repository - - - )} + + { + e.preventDefault(); + openPopup(); + }} + > + Link new repository + + diff --git a/components/agent-creation/ChatPanel.tsx b/components/agent-creation/ChatPanel.tsx index fdbbbc05..77c59256 100644 --- a/components/agent-creation/ChatPanel.tsx +++ b/components/agent-creation/ChatPanel.tsx @@ -189,6 +189,7 @@ export const ChatPanel: React.FC = ({ messageToSend, [], // No nodes for now [], // No images for now + [], // No attachment IDs for now (messageText, _toolCalls, citations) => { // Update the temporary message with streaming content setMessages((prevMessages) => { diff --git a/components/chat/ContextUsageIndicator.tsx b/components/chat/ContextUsageIndicator.tsx new file mode 100644 index 00000000..10a297b1 --- /dev/null +++ b/components/chat/ContextUsageIndicator.tsx @@ -0,0 +1,135 @@ +import React, { FC, useEffect, useState } from 'react'; +import { ContextUsageResponse } from '@/lib/types/attachment'; +import ChatService from '@/services/ChatService'; +import { cn } from '@/lib/utils'; +import { AlertTriangle } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface ContextUsageIndicatorProps { + conversationId: string; + className?: string; + refreshInterval?: number; // ms +} + +export const ContextUsageIndicator: FC = ({ + conversationId, + className, + refreshInterval = 30000, // 30 seconds default +}) => { + const [usage, setUsage] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUsage = async () => { + try { + const data = await ChatService.getContextUsage(conversationId); + setUsage(data); + } catch (error) { + console.error('Error fetching context usage:', error); + } finally { + setLoading(false); + } + }; + + fetchUsage(); + const interval = setInterval(fetchUsage, refreshInterval); + return () => clearInterval(interval); + }, [conversationId, refreshInterval]); + + if (loading || !usage) return null; + + const getBarColor = () => { + if (usage.warning_level === 'critical') return 'bg-red-500'; + if (usage.warning_level === 'approaching') return 'bg-orange-500'; + return 'bg-green-500'; + }; + + const getTextColor = () => { + if (usage.warning_level === 'critical') return 'text-red-700'; + if (usage.warning_level === 'approaching') return 'text-orange-700'; + return 'text-gray-700'; + }; + + return ( + + + +
+ {/* Progress Bar */} +
+
+
+ + {/* Text Info */} +
+ + {usage.current_usage.total.toLocaleString()} /{' '} + {usage.context_limit.toLocaleString()} tokens + + + {usage.usage_percentage.toFixed(1)}% + +
+ + {/* Warning Message */} + {usage.warning_level !== 'none' && ( +
+ + + {usage.warning_level === 'critical' + ? 'Context nearly full' + : 'Approaching context limit'} + +
+ )} +
+ + + +
+
+ Context Usage Breakdown +
+
+
+ Conversation history: + + {usage.current_usage.conversation_history.toLocaleString()} + +
+
+ Text attachments: + + {usage.current_usage.text_attachments.toLocaleString()} + +
+
+ Code context: + + {usage.current_usage.code_context.toLocaleString()} + +
+
+ Total: + + {usage.current_usage.total.toLocaleString()} + +
+
+
+ Model: {usage.model} +
+
+
+ + + ); +}; diff --git a/components/chat/DocumentAttachmentCard.tsx b/components/chat/DocumentAttachmentCard.tsx new file mode 100644 index 00000000..4a13fc23 --- /dev/null +++ b/components/chat/DocumentAttachmentCard.tsx @@ -0,0 +1,90 @@ +import React, { FC } from 'react'; +import { X, Download, FileText } from 'lucide-react'; +import { DocumentAttachment } from '@/lib/types/attachment'; +import { formatFileSize, getFileTypeInfo } from '@/lib/utils/fileTypes'; +import { cn } from '@/lib/utils'; + +interface DocumentAttachmentCardProps { + attachment: DocumentAttachment; + onRemove: () => void; + onDownload?: () => void; + showTokenCount?: boolean; + className?: string; +} + +export const DocumentAttachmentCard: FC = ({ + attachment, + onRemove, + onDownload, + showTokenCount = true, + className, +}) => { + const fileTypeInfo = getFileTypeInfo(attachment.mime_type); + const icon = fileTypeInfo?.icon || '📎'; + + return ( +
+
+
{icon}
+ +
+
+ {attachment.file_name} +
+ +
+ {formatFileSize(attachment.file_size)} + + {showTokenCount && attachment.token_count && ( + <> + + {attachment.token_count.toLocaleString()} tokens + + )} + + {attachment.metadata?.page_count && ( + <> + + {attachment.metadata.page_count} pages + + )} + + {attachment.metadata?.row_count && ( + <> + + {attachment.metadata.row_count} rows + + )} +
+
+
+ +
+ {onDownload && ( + + )} + + +
+
+ ); +}; diff --git a/components/chat/ValidationErrorModal.tsx b/components/chat/ValidationErrorModal.tsx new file mode 100644 index 00000000..d9a782d9 --- /dev/null +++ b/components/chat/ValidationErrorModal.tsx @@ -0,0 +1,134 @@ +import React, { FC } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle } from 'lucide-react'; +import { ValidationResponse } from '@/lib/types/attachment'; +import { formatFileSize } from '@/lib/utils/fileTypes'; + +interface ValidationErrorModalProps { + open: boolean; + onClose: () => void; + validation: ValidationResponse | null; + fileName: string; + fileSize: number; + onViewAttachments?: () => void; + onChangeModel?: () => void; +} + +export const ValidationErrorModal: FC = ({ + open, + onClose, + validation, + fileName, + fileSize, + onViewAttachments, + onChangeModel, +}) => { + if (!validation || validation.can_upload) return null; + + return ( + + + + + + Cannot Upload Document + + + This file would exceed your model's context window. + + + +
+ {/* File Info */} +
+
+ File: + + {fileName} + +
+
+ Size: + {formatFileSize(fileSize)} +
+
+ + {/* Token Usage */} +
+
+ Estimated tokens: + + {validation.estimated_tokens.toLocaleString()} + +
+
+ Current usage: + + {validation.current_context_usage.toLocaleString()} + +
+
+ Model limit: + + {validation.model_context_limit.toLocaleString()} + +
+
+
+ Exceeds by: + + {validation.excess_tokens?.toLocaleString()} tokens ( + {validation.excess_percentage?.toFixed(1)}%) + +
+
+
+ + {/* Suggestions */} +
+

Suggestions:

+
    +
  • + + Remove some existing attachments +
  • +
  • + + + Switch to a model with larger context (e.g., Gemini 2.5 Pro - 2M + tokens) + +
  • +
  • + + Upload a smaller document +
  • +
+
+
+ + {/* Actions */} +
+ + {onViewAttachments && ( + + )} + {onChangeModel && ( + + )} +
+
+
+ ); +}; diff --git a/lib/types/attachment.ts b/lib/types/attachment.ts new file mode 100644 index 00000000..b765789d --- /dev/null +++ b/lib/types/attachment.ts @@ -0,0 +1,83 @@ +// Attachment types +export type AttachmentType = 'pdf' | 'document' | 'spreadsheet' | 'code' | 'image'; + +export interface ValidationResponse { + can_upload: boolean; + estimated_tokens: number; + current_context_usage: number; + model: string; + model_context_limit: number; + remaining_tokens: number; + projected_total: number; + // Present if can_upload = false + exceeds_limit?: boolean; + excess_tokens?: number; + excess_percentage?: number; + // Present if can_upload = true + usage_after_upload?: number; +} + +export interface AttachmentUploadResponse { + id: string; + attachment_type: AttachmentType; + file_name: string; + mime_type: string; + file_size: number; +} + +export interface AttachmentMetadata { + // Documents + original_size?: number; + extracted_text_length?: number; + token_count?: number; + extraction_method?: string; + text_storage?: string; + // PDF specific + page_count?: number; + // DOCX specific + paragraph_count?: number; + table_count?: number; + // CSV/XLSX specific + row_count?: number; + column_count?: number; + columns?: string[]; + sheet_count?: number; + sheet_names?: string[]; + // Images + width?: number; + height?: number; +} + +export interface AttachmentInfo { + id: string; + attachment_type: AttachmentType; + file_name: string; + mime_type: string; + file_size: number; + storage_provider: string; + created_at: string; + file_metadata: AttachmentMetadata; +} + +export interface ContextUsageResponse { + conversation_id: string; + model: string; + context_limit: number; + current_usage: { + conversation_history: number; + text_attachments: number; + image_attachments: number; + code_context: number; + total: number; + }; + remaining: number; + usage_percentage: number; + warning_level: 'none' | 'approaching' | 'critical'; +} + +// Client-side document attachment state +export interface DocumentAttachment extends AttachmentUploadResponse { + file: File; // Keep reference to original file + token_count?: number; + metadata?: AttachmentMetadata; +} diff --git a/lib/utils/fileTypes.ts b/lib/utils/fileTypes.ts new file mode 100644 index 00000000..efb33273 --- /dev/null +++ b/lib/utils/fileTypes.ts @@ -0,0 +1,82 @@ +export const SUPPORTED_DOCUMENT_TYPES = { + // Documents + 'application/pdf': { ext: '.pdf', type: 'pdf', icon: '📄', label: 'PDF' }, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { + ext: '.docx', type: 'document', icon: '📝', label: 'Word Document' + }, + + // Spreadsheets + 'text/csv': { ext: '.csv', type: 'spreadsheet', icon: '📊', label: 'CSV' }, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + ext: '.xlsx', type: 'spreadsheet', icon: '📊', label: 'Excel Spreadsheet' + }, + + // Code files + 'text/x-python': { ext: '.py', type: 'code', icon: '💻', label: 'Python' }, + 'text/javascript': { ext: '.js', type: 'code', icon: '💻', label: 'JavaScript' }, + 'text/typescript': { ext: '.ts', type: 'code', icon: '💻', label: 'TypeScript' }, + 'text/x-java': { ext: '.java', type: 'code', icon: '💻', label: 'Java' }, + 'text/x-go': { ext: '.go', type: 'code', icon: '💻', label: 'Go' }, + 'text/x-rust': { ext: '.rs', type: 'code', icon: '💻', label: 'Rust' }, + 'application/json': { ext: '.json', type: 'code', icon: '💻', label: 'JSON' }, + 'text/yaml': { ext: '.yaml', type: 'code', icon: '💻', label: 'YAML' }, + + // Text files + 'text/markdown': { ext: '.md', type: 'document', icon: '📝', label: 'Markdown' }, + 'text/plain': { ext: '.txt', type: 'document', icon: '📝', label: 'Text File' }, +} as const; + +export const SUPPORTED_IMAGE_TYPES = { + 'image/jpeg': { ext: '.jpg', type: 'image', icon: '🖼️', label: 'JPEG' }, + 'image/png': { ext: '.png', type: 'image', icon: '🖼️', label: 'PNG' }, + 'image/webp': { ext: '.webp', type: 'image', icon: '🖼️', label: 'WebP' }, + 'image/gif': { ext: '.gif', type: 'image', icon: '🖼️', label: 'GIF' }, +} as const; + +export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +export function isDocumentType(mimeType: string): boolean { + return mimeType in SUPPORTED_DOCUMENT_TYPES; +} + +export function isImageType(mimeType: string): boolean { + return mimeType in SUPPORTED_IMAGE_TYPES; +} + +export function getFileTypeInfo(mimeType: string) { + if (mimeType in SUPPORTED_DOCUMENT_TYPES) { + return SUPPORTED_DOCUMENT_TYPES[mimeType as keyof typeof SUPPORTED_DOCUMENT_TYPES]; + } + if (mimeType in SUPPORTED_IMAGE_TYPES) { + return SUPPORTED_IMAGE_TYPES[mimeType as keyof typeof SUPPORTED_IMAGE_TYPES]; + } + return null; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function estimateTokens(file: File): number { + const mimeType = file.type; + const sizeBytes = file.size; + + // Server-side heuristics + if (mimeType.includes('pdf')) { + return Math.floor(sizeBytes / 5); + } else if (mimeType.includes('word') || mimeType.includes('document')) { + return Math.floor(sizeBytes / 5); + } else if (mimeType.includes('csv') || mimeType.includes('spreadsheet')) { + return Math.floor(sizeBytes / 6); + } else { + return Math.floor(sizeBytes / 4); // Text/code + } +} + +export function getSupportedFileExtensions(): string { + const docExts = Object.values(SUPPORTED_DOCUMENT_TYPES).map(t => t.ext); + const imgExts = Object.values(SUPPORTED_IMAGE_TYPES).map(t => t.ext); + return [...docExts, ...imgExts].join(','); +} diff --git a/services/ChatService.ts b/services/ChatService.ts index c5c11b46..331359d4 100644 --- a/services/ChatService.ts +++ b/services/ChatService.ts @@ -3,6 +3,7 @@ import getHeaders from "@/app/utils/headers.util"; import { Visibility } from "@/lib/Constants"; import { SessionInfo, TaskStatus } from "@/lib/types/session"; import { isMultimodalEnabled } from "@/lib/utils"; +import { ValidationResponse, AttachmentUploadResponse, AttachmentInfo, ContextUsageResponse } from "@/lib/types/attachment"; export default class ChatService { // Method for creating a chat with a shared agent @@ -165,6 +166,7 @@ export default class ChatService { message: string, selectedNodes: any[], images: File[] = [], + attachmentIds: string[] = [], onMessageUpdate: ( message: string, tool_calls: any[], @@ -193,6 +195,15 @@ export default class ChatService { formData.append('node_ids', JSON.stringify(selectedNodes)); formData.append('session_id', currentSessionId); + // Add attachment IDs if present + console.log('[ChatService] streamMessage - attachmentIds:', attachmentIds); + if (attachmentIds.length > 0) { + console.log('[ChatService] streamMessage - Adding attachment_ids to FormData:', JSON.stringify(attachmentIds)); + formData.append('attachment_ids', JSON.stringify(attachmentIds)); + } else { + console.warn('[ChatService] streamMessage - No attachment IDs to send'); + } + // Only process images if multimodal is enabled const enabledImages = isMultimodalEnabled() ? images : []; enabledImages.forEach((image, index) => { @@ -686,4 +697,137 @@ export default class ChatService { throw error; } } + + // Document validation before upload + static async validateDocument( + conversationId: string, + file: File + ): Promise { + const headers = await getHeaders(); + const formData = new FormData(); + + formData.append('conversation_id', conversationId); + formData.append('file_size', file.size.toString()); + formData.append('file_name', file.name); + formData.append('mime_type', file.type); + + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_CONVERSATION_BASE_URL}/api/v1/media/validate-document`, + formData, + { headers } + ); + return response.data; + } catch (error: any) { + console.error('Document validation error:', error); + throw new Error( + error.response?.data?.detail || 'Failed to validate document' + ); + } + } + + // Upload document or image + static async uploadAttachment( + file: File, + messageId?: string + ): Promise { + const headers = await getHeaders(); + const formData = new FormData(); + + formData.append('file', file); + if (messageId) { + formData.append('message_id', messageId); + } + + try { + // Create headers without Content-Type so axios can set multipart/form-data with boundary + const uploadHeaders = { ...headers }; + delete (uploadHeaders as any)['Content-Type']; + + const response = await axios.post( + `${process.env.NEXT_PUBLIC_CONVERSATION_BASE_URL}/api/v1/media/upload`, + formData, + { headers: uploadHeaders } + ); + return response.data; + } catch (error: any) { + console.error('File upload error:', error); + throw new Error( + error.response?.data?.detail || 'Failed to upload file' + ); + } + } + + // Get attachment metadata including token count + static async getAttachmentInfo( + attachmentId: string + ): Promise { + const headers = await getHeaders(); + + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_CONVERSATION_BASE_URL}/api/v1/media/${attachmentId}/info`, + { headers } + ); + return response.data; + } catch (error: any) { + console.error('Get attachment info error:', error); + throw new Error( + error.response?.data?.detail || 'Failed to get attachment info' + ); + } + } + + // Get context usage for conversation + static async getContextUsage( + conversationId: string + ): Promise { + const headers = await getHeaders(); + + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_CONVERSATION_BASE_URL}/api/v1/conversations/${conversationId}/context-usage`, + { headers } + ); + return response.data; + } catch (error: any) { + console.error('Get context usage error:', error); + throw new Error( + error.response?.data?.detail || 'Failed to get context usage' + ); + } + } + + // Download attachment + static async downloadAttachment( + attachmentId: string, + fileName: string + ): Promise { + const headers = await getHeaders(); + + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_CONVERSATION_BASE_URL}/api/v1/media/${attachmentId}/download`, + { + headers, + responseType: 'blob' + } + ); + + // Create download link + const url = window.URL.createObjectURL(response.data); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error: any) { + console.error('Download attachment error:', error); + throw new Error( + error.response?.data?.detail || 'Failed to download attachment' + ); + } + } } From 8e7279c5b0d41bd38fded70bfef601ac7f2d9e40 Mon Sep 17 00:00:00 2001 From: dhirenmathur Date: Tue, 9 Dec 2025 12:04:07 +0530 Subject: [PATCH 2/4] Update UX for doc support feature --- .../[chatId]/components/MessageComposer.tsx | 254 ++++++++++++++---- app/(main)/chat/[chatId]/page.tsx | 25 +- components/chat/ContextUsageIndicator.tsx | 182 +++++++++---- components/chat/DocumentAttachmentCard.tsx | 2 + lib/utils.ts | 17 ++ lib/utils/fileTypes.ts | 61 +++++ services/ChatService.ts | 15 +- 7 files changed, 442 insertions(+), 114 deletions(-) diff --git a/app/(main)/chat/[chatId]/components/MessageComposer.tsx b/app/(main)/chat/[chatId]/components/MessageComposer.tsx index e4570d9a..c6a37440 100644 --- a/app/(main)/chat/[chatId]/components/MessageComposer.tsx +++ b/app/(main)/chat/[chatId]/components/MessageComposer.tsx @@ -2,6 +2,7 @@ import getHeaders from "@/app/utils/headers.util"; import { AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { isMultimodalEnabled } from "@/lib/utils"; +import { toast } from "sonner"; import { Command, CommandEmpty, @@ -49,12 +50,21 @@ import { DocumentAttachment, ValidationResponse } from "@/lib/types/attachment"; import { isDocumentType, isImageType, + isDocumentTypeByFile, + isImageTypeByFile, + detectFileTypeByExtension, MAX_FILE_SIZE, getSupportedFileExtensions } from "@/lib/utils/fileTypes"; import { DocumentAttachmentCard } from "@/components/chat/DocumentAttachmentCard"; import { ValidationErrorModal } from "@/components/chat/ValidationErrorModal"; import { ContextUsageIndicator } from "@/components/chat/ContextUsageIndicator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface MessageComposerProps extends React.HTMLAttributes { projectId: string; @@ -113,6 +123,10 @@ const MessageComposer = ({ file: File; } | null>(null); const [uploadingDocument, setUploadingDocument] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [contextRefreshTrigger, setContextRefreshTrigger] = useState(0); + const [uploadAbortController, setUploadAbortController] = useState(null); + const [uploadLocked, setUploadLocked] = useState(false); const messageRef = useRef(null); const fileInputRef = useRef(null); @@ -160,6 +174,19 @@ const MessageComposer = ({ }); }, [selectedNodes, images, documents, composer]); + // Keyboard shortcut for attach (Cmd/Ctrl + U) + useEffect(() => { + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'u' && isMultimodalEnabled()) { + e.preventDefault(); + fileInputRef.current?.click(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + // Use a flag to prevent double processing const processingPaste = useRef(false); @@ -343,21 +370,35 @@ const MessageComposer = ({ const handleDocumentSelect = async (files: FileList | null) => { if (!files || files.length === 0) return; + // Prevent multiple simultaneous uploads + if (uploadLocked || validating || uploadingDocument) { + toast.info("Please wait", { + description: "An upload is already in progress.", + }); + return; + } + const file = files[0]; // Single document at a time for now // Client-side validation if (file.size > MAX_FILE_SIZE) { - alert(`File size exceeds 10MB limit. ${file.name} is ${(file.size / 1024 / 1024).toFixed(1)} MB`); + toast.error("File too large", { + description: `Maximum file size is 10MB. Your file is ${(file.size / 1024 / 1024).toFixed(1)}MB.`, + }); return; } - if (!isDocumentType(file.type)) { - alert(`Unsupported file type: ${file.type}\n\nSupported types: PDF, DOCX, CSV, XLSX, code files, markdown`); + if (!isDocumentTypeByFile(file)) { + toast.error("Unsupported file type", { + description: "Supported formats: PDF, DOCX, CSV, XLSX, code files (.py, .js, .ts, etc.), and markdown.", + }); return; } - // Validate against context window + // Start upload process - set lock + setUploadLocked(true); setValidating(true); + try { const validation = await ChatService.validateDocument(conversation_id, file); @@ -373,27 +414,57 @@ const MessageComposer = ({ } // Upload the document + setValidating(false); setUploadingDocument(true); - const uploadResult = await ChatService.uploadAttachment(file); + setUploadProgress(0); - // Get full metadata including token count - const attachmentInfo = await ChatService.getAttachmentInfo(uploadResult.id); + const controller = new AbortController(); + setUploadAbortController(controller); - // Add to documents state - const documentAttachment: DocumentAttachment = { - ...uploadResult, - file, - token_count: attachmentInfo.file_metadata.token_count, - metadata: attachmentInfo.file_metadata, - }; + try { + const uploadResult = await ChatService.uploadAttachment( + file, + undefined, + (progress) => setUploadProgress(progress), + controller.signal + ); + + // Get full metadata including token count + const attachmentInfo = await ChatService.getAttachmentInfo(uploadResult.id); + + // Add to documents state + const documentAttachment: DocumentAttachment = { + ...uploadResult, + file, + token_count: attachmentInfo.file_metadata.token_count, + metadata: attachmentInfo.file_metadata, + }; + + setDocuments(prev => [...prev, documentAttachment]); - setDocuments(prev => [...prev, documentAttachment]); + // Trigger context refresh + setContextRefreshTrigger(prev => prev + 1); + } catch (error: any) { + if (error.name === 'AbortError' || error.name === 'CanceledError') { + // User cancelled, already handled + return; + } + console.error('Document upload error:', error); + toast.error("Upload failed", { + description: error.message || "Failed to upload document. Please try again.", + }); + } finally { + setUploadAbortController(null); + setUploadingDocument(false); + } } catch (error: any) { - console.error('Document upload error:', error); - alert(`Failed to upload document: ${error.message}`); + console.error('Document validation/upload error:', error); + toast.error("Upload failed", { + description: error.message || "Failed to upload document. Please try again.", + }); } finally { setValidating(false); - setUploadingDocument(false); + setUploadLocked(false); } }; @@ -490,8 +561,18 @@ const MessageComposer = ({ setIsDragOver(false); const files = e.dataTransfer.files; - if (files) { + if (!files || files.length === 0) return; + + const file = files[0]; + + if (isImageTypeByFile(file)) { handleImageSelect(files); + } else if (isDocumentTypeByFile(file)) { + handleDocumentSelect(files); + } else { + toast.error("Unsupported file type", { + description: "Drop an image or document (PDF, DOCX, CSV, code files, etc.)", + }); } }; @@ -597,6 +678,41 @@ const MessageComposer = ({ setValidationError(null); }; + const handleRemoveDocument = (index: number) => { + const removedDoc = documents[index]; + setDocuments(prev => prev.filter((_, i) => i !== index)); + + // Trigger context refresh + setContextRefreshTrigger(prev => prev + 1); + + toast("Document removed", { + description: removedDoc.file_name, + action: { + label: "Undo", + onClick: () => { + setDocuments(prev => [ + ...prev.slice(0, index), + removedDoc, + ...prev.slice(index) + ]); + setContextRefreshTrigger(prev => prev + 1); + }, + }, + duration: 5000, + }); + }; + + const handleCancelUpload = () => { + if (uploadAbortController) { + uploadAbortController.abort(); + setUploadAbortController(null); + } + setUploadingDocument(false); + setValidating(false); + setUploadProgress(0); + toast.info("Upload cancelled"); + }; + return ( <> {/* Validation Error Modal */} @@ -608,23 +724,24 @@ const MessageComposer = ({ fileName={validationError.file.name} fileSize={validationError.file.size} onViewAttachments={() => { - // Scroll to attachments section handleCloseValidationModal(); + // Scroll to documents section if any exist + if (documents.length > 0) { + containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } }} onChangeModel={() => { - // Open model selector handleCloseValidationModal(); - // Trigger model dialog (you may need to add this functionality) + // Find and click the model selector trigger + const modelTrigger = document.querySelector('[class*="DialogTrigger"]') as HTMLButtonElement; + if (modelTrigger) { + setTimeout(() => modelTrigger.click(), 100); + } }} /> )}
- {/* Context Usage Indicator */} -
- -
- {(nodeOptions?.length > 0 || isSearchingNode) && }
{/* display selected nodes */} @@ -661,8 +778,9 @@ const MessageComposer = ({ {isMultimodalEnabled() && isDragOver && (
- -

Drop images here to upload

+ +

Drop files here to upload

+

Images, PDFs, documents, or code files

)} @@ -677,9 +795,7 @@ const MessageComposer = ({ { - setDocuments(prev => prev.filter((_, i) => i !== index)); - }} + onRemove={() => handleRemoveDocument(index)} onDownload={() => { ChatService.downloadAttachment(doc.id, doc.file_name); }} @@ -689,13 +805,33 @@ const MessageComposer = ({ )} {/* Validation/Upload Loading State */} - {(validating || uploadingDocument) && ( + {validating && (
- - {validating && 'Validating document...'} - {uploadingDocument && 'Uploading document...'} - + Validating document... +
+ )} + {uploadingDocument && ( +
+
+
+ + Uploading... {uploadProgress}% +
+ +
+
+
+
)} @@ -732,12 +868,14 @@ const MessageComposer = ({ if (!files || files.length === 0) return; const file = files[0]; - if (isImageType(file.type)) { + if (isImageTypeByFile(file)) { handleImageSelect(files); - } else if (isDocumentType(file.type)) { + } else if (isDocumentTypeByFile(file)) { handleDocumentSelect(files); } else { - alert('Unsupported file type'); + toast.error("Unsupported file type", { + description: "Please select an image or document file.", + }); } // Reset input @@ -747,14 +885,23 @@ const MessageComposer = ({ multiple={false} className="hidden" /> - + + + + + + +

Attach files

+

⌘U / Ctrl+U

+
+
+
)} @@ -770,8 +917,15 @@ const MessageComposer = ({ className="w-full placeholder:text-gray-400 max-h-80 flex-grow resize-none border-none bg-transparent px-4 py-4 text-sm outline-none focus:ring-0 disabled:cursor-not-allowed" />
- - + + {/* Bottom action bar with context indicator */} +
+ + +
diff --git a/app/(main)/chat/[chatId]/page.tsx b/app/(main)/chat/[chatId]/page.tsx index 404239e3..83dbd25d 100644 --- a/app/(main)/chat/[chatId]/page.tsx +++ b/app/(main)/chat/[chatId]/page.tsx @@ -112,7 +112,22 @@ const Chat = () => { setIsCreator(info.is_creator); - if (!list_system_agents.includes(info.agent_ids[0])) { + // Defensive checks for required arrays + const agentId = info.agent_ids?.[0]; + const projectIdFromInfo = info.project_ids?.[0]; + + if (!agentId || !projectIdFromInfo) { + console.error("Missing agent_ids or project_ids in conversation info:", info); + toast.error("Failed to load conversation: missing required data"); + setError({ + isError: true, + message: "Invalid conversation data", + description: "The conversation is missing required information. Please try again or contact support.", + }); + return; + } + + if (!list_system_agents.includes(agentId)) { setChatAccess(info.access_type); } else { setChatAccess(info.is_creator ? "write" : info.access_type); @@ -124,22 +139,22 @@ const Chat = () => { dispatch( setChat({ - agentId: info.agent_ids[0], + agentId: agentId, title: info.title, }) ); - setProjectId(info.project_ids[0]); + setProjectId(projectIdFromInfo); setInfoLoaded(true); - if (!info.is_creator) { + if (!info.is_creator && info.creator_id) { fetchProfilePicture(info.creator_id).then((profilePicture) => { setProfilePicUrl(profilePicture as string); }); } const parsingStatus = await BranchAndRepositoryService.getParsingStatus( - info.project_ids[0] + projectIdFromInfo ); setParsingStatus(parsingStatus); } catch (error) { diff --git a/components/chat/ContextUsageIndicator.tsx b/components/chat/ContextUsageIndicator.tsx index 10a297b1..5913f3bd 100644 --- a/components/chat/ContextUsageIndicator.tsx +++ b/components/chat/ContextUsageIndicator.tsx @@ -2,7 +2,7 @@ import React, { FC, useEffect, useState } from 'react'; import { ContextUsageResponse } from '@/lib/types/attachment'; import ChatService from '@/services/ChatService'; import { cn } from '@/lib/utils'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, Sparkles } from 'lucide-react'; import { Tooltip, TooltipContent, @@ -14,12 +14,25 @@ interface ContextUsageIndicatorProps { conversationId: string; className?: string; refreshInterval?: number; // ms + refreshTrigger?: number; // Increment to trigger immediate refresh } +// Format large numbers compactly (e.g., 31552 -> "31.6k") +const formatTokenCount = (count: number): string => { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k`; + } + return count.toString(); +}; + export const ContextUsageIndicator: FC = ({ conversationId, className, refreshInterval = 30000, // 30 seconds default + refreshTrigger = 0, }) => { const [usage, setUsage] = useState(null); const [loading, setLoading] = useState(true); @@ -39,93 +52,148 @@ export const ContextUsageIndicator: FC = ({ fetchUsage(); const interval = setInterval(fetchUsage, refreshInterval); return () => clearInterval(interval); - }, [conversationId, refreshInterval]); + }, [conversationId, refreshInterval, refreshTrigger]); + + if (loading) { + return ( +
+
+
+
+ ); + } - if (loading || !usage) return null; + if (!usage) return null; - const getBarColor = () => { - if (usage.warning_level === 'critical') return 'bg-red-500'; - if (usage.warning_level === 'approaching') return 'bg-orange-500'; - return 'bg-green-500'; + const percentage = usage.usage_percentage; + + const getBarGradient = () => { + if (usage.warning_level === 'critical') { + return 'bg-gradient-to-r from-red-400 to-red-500'; + } + if (usage.warning_level === 'approaching') { + return 'bg-gradient-to-r from-amber-400 to-orange-500'; + } + if (percentage > 50) { + return 'bg-gradient-to-r from-blue-400 to-blue-500'; + } + return 'bg-gradient-to-r from-emerald-400 to-emerald-500'; }; const getTextColor = () => { - if (usage.warning_level === 'critical') return 'text-red-700'; - if (usage.warning_level === 'approaching') return 'text-orange-700'; - return 'text-gray-700'; + if (usage.warning_level === 'critical') return 'text-red-600'; + if (usage.warning_level === 'approaching') return 'text-amber-600'; + return 'text-gray-500'; + }; + + const getBackgroundColor = () => { + if (usage.warning_level === 'critical') return 'bg-red-50'; + if (usage.warning_level === 'approaching') return 'bg-amber-50'; + return 'bg-gray-50'; }; return ( - + -
- {/* Progress Bar */} -
+
+ {/* Icon */} + + + {/* Mini Progress Bar */} +
- {/* Text Info */} -
- - {usage.current_usage.total.toLocaleString()} /{' '} - {usage.context_limit.toLocaleString()} tokens - - - {usage.usage_percentage.toFixed(1)}% - -
+ {/* Compact Text */} + + {formatTokenCount(usage.current_usage.total)} / {formatTokenCount(usage.context_limit)} + - {/* Warning Message */} + {/* Warning icon for critical/approaching */} {usage.warning_level !== 'none' && ( -
- - - {usage.warning_level === 'critical' - ? 'Context nearly full' - : 'Approaching context limit'} - -
+ )}
- -
-
- Context Usage Breakdown + +
+ {/* Header */} +
+
+ Context Window + + {percentage.toFixed(1)}% used + +
+ + {/* Full progress bar in tooltip */} +
+
+
-
-
- Conversation history: - - {usage.current_usage.conversation_history.toLocaleString()} + + {/* Breakdown */} +
+
+ Conversation + + {formatTokenCount(usage.current_usage.conversation_history)}
-
- Text attachments: - - {usage.current_usage.text_attachments.toLocaleString()} +
+ Attachments + + {formatTokenCount(usage.current_usage.text_attachments)}
-
- Code context: - - {usage.current_usage.code_context.toLocaleString()} +
+ Code context + + {formatTokenCount(usage.current_usage.code_context)}
-
- Total: - + +
+ Total + {usage.current_usage.total.toLocaleString()}
-
- Model: {usage.model} + + {/* Footer */} +
+ {usage.model} • {usage.context_limit.toLocaleString()} token limit
diff --git a/components/chat/DocumentAttachmentCard.tsx b/components/chat/DocumentAttachmentCard.tsx index 4a13fc23..aa1bab6a 100644 --- a/components/chat/DocumentAttachmentCard.tsx +++ b/components/chat/DocumentAttachmentCard.tsx @@ -71,6 +71,7 @@ export const DocumentAttachmentCard: FC = ({ onClick={onDownload} className="p-1.5 hover:bg-gray-200 rounded transition-colors" title="Download" + aria-label={`Download ${attachment.file_name}`} > @@ -81,6 +82,7 @@ export const DocumentAttachmentCard: FC = ({ onClick={onRemove} className="p-1.5 hover:bg-red-100 text-red-600 rounded transition-colors" title="Remove" + aria-label={`Remove ${attachment.file_name}`} > diff --git a/lib/utils.ts b/lib/utils.ts index d213ef96..77fe0f1a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -92,6 +92,9 @@ export const generateMockUser = () => { displayName: "Local Dev User", emailVerified: true, isAnonymous: false, + phoneNumber: null, + photoURL: null, + providerId: "password", metadata: { creationTime: "2023-01-01T00:00:00Z", lastSignInTime: "2023-01-01T00:00:00Z", @@ -130,6 +133,20 @@ export const generateMockUser = () => { displayName: "Local Dev User", emailVerified: true, }), + // Firebase internal methods required for auth state management + _stopProactiveRefresh: () => {}, + _startProactiveRefresh: () => {}, + stsTokenManager: { + refreshToken: "mock-refresh-token", + accessToken: "mock-access-token", + expirationTime: Date.now() + 3600000, + }, + proactiveRefresh: { + user: null, + isRunning: false, + timerId: null, + errorBackoff: 30000, + }, }; }; diff --git a/lib/utils/fileTypes.ts b/lib/utils/fileTypes.ts index efb33273..0f2c3044 100644 --- a/lib/utils/fileTypes.ts +++ b/lib/utils/fileTypes.ts @@ -43,6 +43,67 @@ export function isImageType(mimeType: string): boolean { return mimeType in SUPPORTED_IMAGE_TYPES; } +const EXTENSION_TO_MIME: Record = { + '.pdf': 'application/pdf', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.csv': 'text/csv', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.py': 'text/x-python', + '.js': 'text/javascript', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.jsx': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.txt': 'text/plain', + '.java': 'text/x-java', + '.go': 'text/x-go', + '.rs': 'text/x-rust', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.rb': 'text/x-ruby', + '.php': 'text/x-php', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.cs': 'text/x-csharp', + '.swift': 'text/x-swift', + '.kt': 'text/x-kotlin', + '.scala': 'text/x-scala', + '.sh': 'text/x-shellscript', + '.sql': 'text/x-sql', + '.html': 'text/html', + '.css': 'text/css', + '.scss': 'text/x-scss', + '.less': 'text/x-less', + '.vue': 'text/x-vue', + '.svelte': 'text/x-svelte', +}; + +export function detectFileTypeByExtension(fileName: string): string | null { + const ext = '.' + fileName.split('.').pop()?.toLowerCase(); + return EXTENSION_TO_MIME[ext] || null; +} + +export function getEffectiveMimeType(file: File): string { + // Trust browser MIME type if it's specific (not generic) + if (file.type && file.type !== 'application/octet-stream' && file.type !== '') { + return file.type; + } + // Fall back to extension-based detection + return detectFileTypeByExtension(file.name) || file.type || 'application/octet-stream'; +} + +export function isDocumentTypeByFile(file: File): boolean { + const mimeType = getEffectiveMimeType(file); + return isDocumentType(mimeType); +} + +export function isImageTypeByFile(file: File): boolean { + const mimeType = getEffectiveMimeType(file); + return isImageType(mimeType); +} + export function getFileTypeInfo(mimeType: string) { if (mimeType in SUPPORTED_DOCUMENT_TYPES) { return SUPPORTED_DOCUMENT_TYPES[mimeType as keyof typeof SUPPORTED_DOCUMENT_TYPES]; diff --git a/services/ChatService.ts b/services/ChatService.ts index 331359d4..fd1dff1d 100644 --- a/services/ChatService.ts +++ b/services/ChatService.ts @@ -729,7 +729,9 @@ export default class ChatService { // Upload document or image static async uploadAttachment( file: File, - messageId?: string + messageId?: string, + onProgress?: (progress: number) => void, + signal?: AbortSignal ): Promise { const headers = await getHeaders(); const formData = new FormData(); @@ -747,7 +749,16 @@ export default class ChatService { const response = await axios.post( `${process.env.NEXT_PUBLIC_CONVERSATION_BASE_URL}/api/v1/media/upload`, formData, - { headers: uploadHeaders } + { + headers: uploadHeaders, + signal, + onUploadProgress: (progressEvent) => { + if (progressEvent.total && onProgress) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(percentCompleted); + } + }, + } ); return response.data; } catch (error: any) { From 96fc36800e91f7aa62270b4be469b71e686af436 Mon Sep 17 00:00:00 2001 From: dhirenmathur Date: Fri, 30 Jan 2026 01:08:32 +0530 Subject: [PATCH 3/4] merge master --- app/(main)/chat/[chatId]/components/MessageComposer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/(main)/chat/[chatId]/components/MessageComposer.tsx b/app/(main)/chat/[chatId]/components/MessageComposer.tsx index 579e6467..ec1e62a5 100644 --- a/app/(main)/chat/[chatId]/components/MessageComposer.tsx +++ b/app/(main)/chat/[chatId]/components/MessageComposer.tsx @@ -214,6 +214,10 @@ const MessageComposer = ({ // Node fetching and handling const fetchNodes = async (query: string) => { + if (!projectId) { + setNodeOptions([]); + return; + } const headers = await getHeaders(); const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; setIsSearchingNode(true); From 48a605389e11ae0c945abc1012c822bd5ab2c3e9 Mon Sep 17 00:00:00 2001 From: dhirenmathur Date: Fri, 30 Jan 2026 03:13:32 +0530 Subject: [PATCH 4/4] doc attachment fixes --- app/(auth)/link-github/page.tsx | 4 +- app/(auth)/onboarding/page.tsx | 2 +- .../[chatId]/components/MessageComposer.tsx | 133 ++++++++++-------- .../chat/[chatId]/components/Thread.tsx | 132 +++++++++++++++-- app/(main)/chat/[chatId]/page.tsx | 2 +- app/(main)/chat/[chatId]/runtime.ts | 10 +- components/Layouts/minors/nav-user.tsx | 16 ++- components/chat/DocumentAttachmentCard.tsx | 20 +-- components/chat/ValidationErrorModal.tsx | 19 ++- lib/utils/emailValidation.ts | 8 +- 10 files changed, 246 insertions(+), 100 deletions(-) diff --git a/app/(auth)/link-github/page.tsx b/app/(auth)/link-github/page.tsx index a00f5e8c..ac8e41db 100644 --- a/app/(auth)/link-github/page.tsx +++ b/app/(auth)/link-github/page.tsx @@ -50,13 +50,13 @@ const LinkGithub = () => { const originalUser = auth.currentUser; const linkToUserId = originalUser?.uid; - if (!linkToUserId) { + if (!originalUser || !linkToUserId) { toast.error("Please sign in with your work email first, then link GitHub."); return; } // Link GitHub to the currently signed-in work/SSO user - const result = await linkWithPopup(auth.currentUser, provider); + const result = await linkWithPopup(originalUser, provider); // Extract GitHub provider UID from providerData (stable across sessions) const githubProviderUid = diff --git a/app/(auth)/onboarding/page.tsx b/app/(auth)/onboarding/page.tsx index bf73a009..fd30d2fb 100644 --- a/app/(auth)/onboarding/page.tsx +++ b/app/(auth)/onboarding/page.tsx @@ -372,7 +372,7 @@ const Onboarding = () => { provider.addScope("repo"); // Link GitHub to the CURRENT authenticated user (work/SSO) without switching accounts - const result = await linkWithPopup(auth.currentUser, provider); + const result = await linkWithPopup(currentUser, provider); const credential = GithubAuthProvider.credentialFromResult(result); if (!credential) { diff --git a/app/(main)/chat/[chatId]/components/MessageComposer.tsx b/app/(main)/chat/[chatId]/components/MessageComposer.tsx index ec1e62a5..a89d79f0 100644 --- a/app/(main)/chat/[chatId]/components/MessageComposer.tsx +++ b/app/(main)/chat/[chatId]/components/MessageComposer.tsx @@ -24,10 +24,7 @@ import { useComposerRuntime, useThreadRuntime, } from "@assistant-ui/react"; -import { - ComposerAddAttachment, - ComposerAttachments, -} from "@/components/assistant-ui/attachment"; +import { ComposerAttachments } from "@/components/assistant-ui/attachment"; import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; import { Dialog } from "@radix-ui/react-dialog"; import axios from "axios"; @@ -74,6 +71,7 @@ interface MessageComposerProps { projectId: string; disabled: boolean; conversation_id: string; + setPendingDocumentAttachments?: (docs: DocumentAttachment[] | null) => void; } interface NodeOption { @@ -91,6 +89,7 @@ const MessageComposer = ({ projectId, disabled, conversation_id, + setPendingDocumentAttachments, }: MessageComposerProps) => { const [nodeOptions, setNodeOptions] = useState([]); const [selectedNodes, setSelectedNodes] = useState([]); @@ -381,6 +380,9 @@ const MessageComposer = ({ }, }); + // So the just-sent user message can show document attachments in the thread + setPendingDocumentAttachments?.(documents.length > 0 ? [...documents] : null); + // Only send the message - let the "send" event handler manage cleanup composer.send(); }; @@ -490,9 +492,6 @@ const MessageComposer = ({ } }; - } - }; - // Model selection const [currPlan, setCurrPlan] = useState(undefined); const { user } = useAuthContext(); @@ -652,12 +651,18 @@ const MessageComposer = ({ }} onChangeModel={() => { handleCloseValidationModal(); - const modelTrigger = document.querySelector( - '[class*="DialogTrigger"]' - ) as HTMLButtonElement; - if (modelTrigger) { - setTimeout(() => modelTrigger.click(), 100); - } + requestAnimationFrame(() => { + const modelTrigger = document.querySelector( + '[data-model-selector-trigger]' + ) as HTMLButtonElement | null; + if (modelTrigger && !modelTrigger.hasAttribute("disabled")) { + modelTrigger.click(); + } else { + toast.info( + "Open the model selector (model name next to the send button) to switch to a model with a larger context window." + ); + } + }); }} /> )} @@ -698,9 +703,9 @@ const MessageComposer = ({ {/* Attachment Previews */} {isMultimodalEnabled() && } - {/* Document Attachments */} + {/* Document Attachments - compact tile, does not span full width */} {documents.length > 0 && ( -
+
Documents ({documents.length})
@@ -717,15 +722,15 @@ const MessageComposer = ({
)} - {/* Validation/Upload Loading State */} + {/* Validation/Upload Loading State - compact, same width as documents tile */} {validating && ( -
+
Validating document...
)} {uploadingDocument && ( -
+
@@ -739,7 +744,7 @@ const MessageComposer = ({ Cancel
-
+
- {isMultimodalEnabled() && ( -
- - { - const files = e.target.files; - if (!files || files.length === 0) return; - - const file = files[0]; - if (isImageTypeByFile(file)) { - toast.info("Use the image button for images."); - } else if (isDocumentTypeByFile(file)) { - void handleDocumentSelect(files); +
+ { + const files = e.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + if (isImageTypeByFile(file)) { + if (isMultimodalEnabled()) { + await composer.addAttachment(file); } else { - toast.error("Unsupported file type", { - description: "Please select a supported document file.", - }); + toast.info("Image upload is not available in this chat."); } - - e.target.value = ""; - }} - accept={getSupportedFileExtensions()} - multiple={false} - className="hidden" - /> - - - - - - -

Attach document

-
-
-
-
- )} + } else if (isDocumentTypeByFile(file)) { + void handleDocumentSelect(files); + } else { + toast.error("Unsupported file type", { + description: + "Supported: images (JPEG, PNG, WebP, GIF), documents (PDF, DOCX, CSV, code files, etc.).", + }); + } + + e.target.value = ""; + }} + accept={getSupportedFileExtensions()} + multiple={false} + className="hidden" + /> + + + + + + +

Attach file (image or document)

+
+
+
+
{currentModel ? ( = ({ isBackgroundTaskActive, }) => { const [isInitialLoading, setIsInitialLoading] = useState(true); + const [pendingDocumentAttachments, setPendingDocumentAttachments] = useState< + DocumentAttachment[] | null + >(null); + // Once we show pending docs on a message, store by message id so they stay visible after clearing pending + const [committedDocumentAttachmentsByMessageId, setCommittedDocumentAttachmentsByMessageId] = + useState>({}); const runtime = useThreadRuntime(); + const [messagesSnapshot, setMessagesSnapshot] = useState( + () => runtime.getState().messages ?? [] + ); + const prevLastUserMessageIdRef = useRef(null); + + // Subscribe to thread so we see new messages (e.g. just-sent user message) + useEffect(() => { + setMessagesSnapshot(runtime.getState().messages ?? []); + const unsub = runtime.subscribe(() => { + setMessagesSnapshot(runtime.getState().messages ?? []); + }); + return unsub; + }, [runtime]); + + // Commit pending only when a *new* user message appears (last user message id changed) + // so we don't wrongly attach pending docs to the previous message + const lastUserMessageId = useMemo(() => { + const messages = messagesSnapshot; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id; + } + return null; + }, [messagesSnapshot]); + + useEffect(() => { + if ( + lastUserMessageId != null && + lastUserMessageId !== prevLastUserMessageIdRef.current && + pendingDocumentAttachments != null && + pendingDocumentAttachments.length > 0 + ) { + setCommittedDocumentAttachmentsByMessageId((prev) => ({ + ...prev, + [lastUserMessageId]: pendingDocumentAttachments, + })); + setPendingDocumentAttachments(null); + } + prevLastUserMessageIdRef.current = lastUserMessageId; + }, [lastUserMessageId, pendingDocumentAttachments]); // Move useMemo before any early returns to satisfy React Hooks rules const userMessage = useMemo(() => { const UserMessageComponent = () => ( - + ); UserMessageComponent.displayName = "UserMessageComponent"; return UserMessageComponent; - }, [userImageURL]); + }, [ + userImageURL, + pendingDocumentAttachments, + committedDocumentAttachmentsByMessageId, + ]); useEffect(() => { const state = runtime.getState(); @@ -165,6 +225,7 @@ export const Thread: FC = ({ projectId={projectId} disabled={writeDisabled} conversation_id={conversation_id} + setPendingDocumentAttachments={setPendingDocumentAttachments} />
@@ -235,26 +296,81 @@ const Composer: FC<{ projectId: string; disabled: boolean; conversation_id: string; -}> = ({ projectId, disabled, conversation_id }) => { - // REMOVED: Manual runtime subscriptions and state - // REMOVED: isStreaming check - assistant-ui handles this - + setPendingDocumentAttachments: (docs: DocumentAttachment[] | null) => void; +}> = ({ projectId, disabled, conversation_id, setPendingDocumentAttachments }) => { return ( ); }; -const UserMessage: FC<{ userPhotoURL: string }> = ({ userPhotoURL }) => { +const UserMessage: FC<{ + userPhotoURL: string; + pendingDocumentAttachments: DocumentAttachment[] | null; + setPendingDocumentAttachments: (docs: DocumentAttachment[] | null) => void; + committedDocumentAttachmentsByMessageId: Record< + string, + DocumentAttachment[] + >; + setCommittedDocumentAttachmentsByMessageId: React.Dispatch< + React.SetStateAction> + >; +}> = ({ + userPhotoURL, + pendingDocumentAttachments, + setPendingDocumentAttachments, + committedDocumentAttachmentsByMessageId, + setCommittedDocumentAttachmentsByMessageId, +}) => { const message = useMessage(); - const documentAttachments = + const runtime = useThreadRuntime(); + const [messagesSnapshot, setMessagesSnapshot] = useState( + () => runtime.getState().messages ?? [] + ); + + useEffect(() => { + setMessagesSnapshot(runtime.getState().messages ?? []); + const unsub = runtime.subscribe(() => { + setMessagesSnapshot(runtime.getState().messages ?? []); + }); + return unsub; + }, [runtime]); + + const metadataDocumentAttachments = (message.metadata as any)?.custom?.documentAttachments ?? []; + // For just-sent messages, metadata may not be populated yet; use pending or committed docs + const lastUserMessage = useMemo(() => { + const messages = messagesSnapshot; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i]; + } + return null; + }, [messagesSnapshot]); + + const isLastUserMessage = lastUserMessage?.id === message.id; + const committedForThisMessage = committedDocumentAttachmentsByMessageId[message.id]; + const showPending = + metadataDocumentAttachments.length === 0 && + !(committedForThisMessage?.length) && + isLastUserMessage && + (pendingDocumentAttachments?.length ?? 0) > 0; + + const documentAttachments = + metadataDocumentAttachments.length > 0 + ? metadataDocumentAttachments + : committedForThisMessage?.length + ? committedForThisMessage + : showPending + ? pendingDocumentAttachments! + : []; + return ( { if (!agentId || !projectIdFromInfo) { console.error("Missing agent_ids or project_ids in conversation info:", info); toast.error("Failed to load conversation: missing required data"); - setError({ + setErrorState({ isError: true, message: "Invalid conversation data", description: "The conversation is missing required information. Please try again or contact support.", diff --git a/app/(main)/chat/[chatId]/runtime.ts b/app/(main)/chat/[chatId]/runtime.ts index 87f96222..4d4308f8 100644 --- a/app/(main)/chat/[chatId]/runtime.ts +++ b/app/(main)/chat/[chatId]/runtime.ts @@ -73,7 +73,7 @@ const createChatAdapter = ( isBackgroundTaskActiveRef: React.MutableRefObject ): ChatModelAdapter => { return { - async *run({ messages, abortSignal, context }: ChatModelRunOptions) { + async *run({ messages, abortSignal, runConfig: optionsRunConfig }: ChatModelRunOptions) { // Prevent new messages during background tasks if (isBackgroundTaskActiveRef.current) { console.warn("Cannot send message while background task is active"); @@ -92,14 +92,16 @@ const createChatAdapter = ( throw new Error("Message must contain text"); } - // Extract custom config (selectedNodes) - interface RunConfig { + // Extract custom config (selectedNodes, documentIds) from runConfig + // runConfig is set by MessageComposer via composer.setRunConfig() and must include + // documentIds so we can send attachment_ids to the message API + interface RunConfigCustom { custom?: { selectedNodes?: unknown[]; documentIds?: string[]; }; } - const runConfig = (context as { runConfig?: RunConfig }).runConfig; + const runConfig = (optionsRunConfig ?? {}) as RunConfigCustom; const selectedNodes = (runConfig?.custom?.selectedNodes as unknown[]) || []; const documentIds = runConfig?.custom?.documentIds || []; diff --git a/components/Layouts/minors/nav-user.tsx b/components/Layouts/minors/nav-user.tsx index 4a577900..a3ea353a 100644 --- a/components/Layouts/minors/nav-user.tsx +++ b/components/Layouts/minors/nav-user.tsx @@ -120,9 +120,13 @@ export function NavUser({
{user.name} {user.emailVerified ? ( - + + + ) : ( - + + + )}
{user.email} @@ -147,9 +151,13 @@ export function NavUser({
{user.email} {user.emailVerified ? ( - + + + ) : ( - + + + )}
diff --git a/components/chat/DocumentAttachmentCard.tsx b/components/chat/DocumentAttachmentCard.tsx index aa1bab6a..6679bed8 100644 --- a/components/chat/DocumentAttachmentCard.tsx +++ b/components/chat/DocumentAttachmentCard.tsx @@ -25,19 +25,19 @@ export const DocumentAttachmentCard: FC = ({ return (
-
-
{icon}
+
+
{icon}
-
+
{attachment.file_name}
-
+
{formatFileSize(attachment.file_size)} {showTokenCount && attachment.token_count && ( @@ -64,27 +64,27 @@ export const DocumentAttachmentCard: FC = ({
-
+
{onDownload && ( )}
diff --git a/components/chat/ValidationErrorModal.tsx b/components/chat/ValidationErrorModal.tsx index d9a782d9..6110d5b8 100644 --- a/components/chat/ValidationErrorModal.tsx +++ b/components/chat/ValidationErrorModal.tsx @@ -33,8 +33,13 @@ export const ValidationErrorModal: FC = ({ if (!validation || validation.can_upload) return null; return ( - - + { + if (!isOpen) onClose(); + }} + > + @@ -45,12 +50,12 @@ export const ValidationErrorModal: FC = ({ -
+
{/* File Info */} -
-
- File: - +
+
+ File: + {fileName}
diff --git a/lib/utils/emailValidation.ts b/lib/utils/emailValidation.ts index fa5aabe9..5c5b2e7d 100644 --- a/lib/utils/emailValidation.ts +++ b/lib/utils/emailValidation.ts @@ -5,6 +5,11 @@ import type { UserCredential } from 'firebase/auth'; +// Firebase modular UserCredential type omits additionalUserInfo; it exists at runtime from signInWithPopup +type UserCredentialWithAdditionalInfo = UserCredential & { + additionalUserInfo?: { isNewUser?: boolean } | null; +}; + // Set of blocked email domains (generic/personal email providers) // Using Set for O(1) lookup performance export const BLOCKED_DOMAINS = new Set([ @@ -190,10 +195,11 @@ export function validateWorkEmail(email: string): { isValid: boolean; errorMessa * } */ export function isNewUser(result: UserCredential): boolean { + const cred = result as UserCredentialWithAdditionalInfo; // Method 1: additionalUserInfo.isNewUser // Note: This only returns true if the current SDK call created the account. // It will be false for accounts created via Admin SDK, console, or backend. - if (result.additionalUserInfo?.isNewUser === true) { + if (cred.additionalUserInfo?.isNewUser === true) { return true; }