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/(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 ff150a84..a89d79f0 100644 --- a/app/(main)/chat/[chatId]/components/MessageComposer.tsx +++ b/app/(main)/chat/[chatId]/components/MessageComposer.tsx @@ -1,6 +1,7 @@ import getHeaders from "@/app/utils/headers.util"; import { Button } from "@/components/ui/button"; import { isMultimodalEnabled } from "@/lib/utils"; +import { toast } from "sonner"; import { Command, CommandEmpty, @@ -23,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"; @@ -37,6 +35,7 @@ import { Loader2Icon, Crown, Zap, + Paperclip, } from "lucide-react"; import { FC, @@ -51,11 +50,28 @@ import Image from "next/image"; import MinorService from "@/services/minorService"; import { useAuthContext } from "@/contexts/AuthContext"; import { useQuery } from "@tanstack/react-query"; +import { DocumentAttachment, ValidationResponse } from "@/lib/types/attachment"; +import { + isDocumentTypeByFile, + isImageTypeByFile, + 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 { projectId: string; disabled: boolean; conversation_id: string; + setPendingDocumentAttachments?: (docs: DocumentAttachment[] | null) => void; } interface NodeOption { @@ -73,6 +89,7 @@ const MessageComposer = ({ projectId, disabled, conversation_id, + setPendingDocumentAttachments, }: MessageComposerProps) => { const [nodeOptions, setNodeOptions] = useState([]); const [selectedNodes, setSelectedNodes] = useState([]); @@ -80,6 +97,18 @@ const MessageComposer = ({ const [selectedNodeIndex, setSelectedNodeIndex] = useState(-1); const [isSearchingNode, setIsSearchingNode] = useState(false); const [isEnhancing, setIsEnhancing] = 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 [uploadProgress, setUploadProgress] = useState(0); + const [contextRefreshTrigger, setContextRefreshTrigger] = useState(0); + const [uploadAbortController, setUploadAbortController] = + useState(null); + const [uploadLocked, setUploadLocked] = useState(false); // Use thread runtime to check if streaming is in progress const threadRuntime = useThreadRuntime(); @@ -100,6 +129,7 @@ const MessageComposer = ({ const textareaRef = useRef(null); const containerRef = useRef(null); const searchTimeoutRef = useRef(null); + const documentInputRef = useRef(null); const composer = useComposerRuntime(); @@ -130,14 +160,16 @@ const MessageComposer = ({ return unsubscribe; }, [composer]); // Only depend on composer, not message - // Update runtime config when nodes change + // Update runtime config when nodes/documents change useEffect(() => { composer.setRunConfig({ custom: { selectedNodes: selectedNodes, + documentIds: documents.map((doc) => doc.id), + documents: documents, }, }); - }, [selectedNodes, composer]); + }, [selectedNodes, documents, composer]); useEffect(() => { const unsubscribe = @@ -148,6 +180,8 @@ const MessageComposer = ({ composer.setRunConfig({ custom: { selectedNodes: [], + documentIds: [], + documents: [], }, }); // Clear React state @@ -155,6 +189,11 @@ const MessageComposer = ({ setNodeOptions([]); setSelectedNodeIndex(-1); setMessage(""); + setDocuments([]); + setValidationError(null); + setValidating(false); + setUploadingDocument(false); + setUploadProgress(0); // Clear refs messageRef.current = ""; lastSyncedComposerText.current = ""; @@ -174,6 +213,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); @@ -327,25 +370,125 @@ const MessageComposer = ({ }; const handleSend = () => { + // Ensure config is set with current documents before sending + const documentIds = documents.map((doc) => doc.id); + composer.setRunConfig({ + custom: { + selectedNodes: selectedNodes, + documentIds: documentIds, + documents: documents, + }, + }); + + // 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(); }; - const handleEnhancePrompt = async () => { + 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) { + toast.error("File too large", { + description: `Maximum file size is 10MB. Your file is ${(file.size / 1024 / 1024).toFixed(1)}MB.`, + }); + return; + } + + if (!isDocumentTypeByFile(file)) { + toast.error("Unsupported file type", { + description: "Supported formats: PDF, DOCX, CSV, XLSX, code files (.py, .js, .ts, etc.), and markdown.", + }); + return; + } + + // Start upload process - set lock + setUploadLocked(true); + setValidating(true); + try { - setIsEnhancing(true); - setMessage("Enhancing..."); - composer.setText("Enhancing..."); - const enhancedMessage = await ChatService.enhancePrompt( - conversation_id, - message + 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}%` ); - setMessage(enhancedMessage); - composer.setText(enhancedMessage); - } catch (error) { - console.error("Error enhancing prompt:", error); + } + + // Upload the document + setValidating(false); + setUploadingDocument(true); + setUploadProgress(0); + + const controller = new AbortController(); + setUploadAbortController(controller); + + 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]); + + // 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 validation/upload error:", error); + toast.error("Upload failed", { + description: + error.message || "Failed to upload document. Please try again.", + }); } finally { - setIsEnhancing(false); + setValidating(false); + setUploadLocked(false); } }; @@ -430,68 +573,260 @@ const MessageComposer = ({ ); }; + const handleEnhancePrompt = async () => { + try { + setIsEnhancing(true); + setMessage("Enhancing..."); + composer.setText("Enhancing..."); + const enhancedMessage = await ChatService.enhancePrompt( + conversation_id, + message + ); + setMessage(enhancedMessage); + composer.setText(enhancedMessage); + } catch (error) { + console.error("Error enhancing prompt:", error); + } finally { + setIsEnhancing(false); + } + }; + + const handleCloseValidationModal = () => { + 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 ( -
- {(nodeOptions?.length > 0 || isSearchingNode) && } - - {selectedNodes.length > 0 && ( -
- - Context - - {selectedNodes.map((node) => ( - - {node.name} - - - ))} -
+ <> + {/* Validation Error Modal */} + {validationError && ( + { + handleCloseValidationModal(); + if (documents.length > 0) { + containerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }} + onChangeModel={() => { + handleCloseValidationModal(); + 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." + ); + } + }); + }} + /> )} -
- {/* Attachment Previews - using assistant-ui components */} - {isMultimodalEnabled() && } - -
- {/* Attachment Button - using assistant-ui component */} - {isMultimodalEnabled() && ( -
- +
+ {(nodeOptions?.length > 0 || isSearchingNode) && } + + {selectedNodes.length > 0 && ( +
+ + Context + + {selectedNodes.map((node) => ( + + {node.name} + + + ))} +
+ )} + +
+ {/* Attachment Previews */} + {isMultimodalEnabled() && } + + {/* Document Attachments - compact tile, does not span full width */} + {documents.length > 0 && ( +
+
+ Documents ({documents.length}) +
+ {documents.map((doc, index) => ( + handleRemoveDocument(index)} + onDownload={() => { + ChatService.downloadAttachment(doc.id, doc.file_name); + }} + /> + ))}
)} - -
+ {/* Validation/Upload Loading State - compact, same width as documents tile */} + {validating && ( +
+ + Validating document... +
+ )} + {uploadingDocument && ( +
+
+
+ + Uploading... {uploadProgress}% +
+ +
+
+
+
+
+ )} + +
+
+ { + 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.info("Image upload is not available in this chat."); + } + } 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)

+
+
+
+
+ + +
+ +
+ + +
+
-
+ ); }; @@ -584,6 +919,7 @@ const ModelSelection: FC<{ {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(); @@ -166,6 +225,7 @@ export const Thread: FC = ({ projectId={projectId} disabled={writeDisabled} conversation_id={conversation_id} + setPendingDocumentAttachments={setPendingDocumentAttachments} />
@@ -236,22 +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 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 ( = ({ userPhotoURL }) => { >
- {/* Display attachments using assistant-ui component */} - {isMultimodalEnabled() && } + {/* Document Attachments */} + {documentAttachments.length > 0 && ( +
+ {documentAttachments.map((attachment: any, index: number) => { + 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 +
+ )} +
+
+ ); + })} +
+ )} + + null, //UserMessageAttachments already handles images + Image: () => null, }} />
diff --git a/app/(main)/chat/[chatId]/page.tsx b/app/(main)/chat/[chatId]/page.tsx index 4668f3b3..859e7588 100644 --- a/app/(main)/chat/[chatId]/page.tsx +++ b/app/(main)/chat/[chatId]/page.tsx @@ -116,7 +116,22 @@ const ChatV2 = () => { 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"); + setErrorState({ + 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 ChatV2 = () => { dispatch( setChat({ - agentId: info.agent_ids[0], + agentId: agentId, title: info.title, }) ); - setProjectId(info.project_ids[0]); + setProjectId(projectIdFromInfo); setInfoLoadedForChat(currentConversationId); - 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/app/(main)/chat/[chatId]/runtime.ts b/app/(main)/chat/[chatId]/runtime.ts index 24a48fd5..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,15 +92,19 @@ 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 || []; // Extract images from message attachments (the assistant-ui way) const images: File[] = []; @@ -169,6 +173,7 @@ const createChatAdapter = ( textContent.text, selectedNodes, images, + documentIds, (message: string, tool_calls: any[], citations: string[]) => { if (abortSignal?.aborted || aborted) return; @@ -580,8 +585,12 @@ const convertToThreadMessage = (msg: BackendMessage): ThreadMessage => { { type: "text", text: msg.text || "" }, ]; - // Prepare attachments array for Assistant UI + // Prepare attachments array for Assistant UI (images only) const attachments: CompleteAttachment[] = []; + const documentAttachments = + msg.attachments?.filter( + (attachment) => attachment.attachment_type !== "image" + ) || []; // Add image content and attachments if multimodal enabled and attachments exist if ( @@ -645,9 +654,11 @@ const convertToThreadMessage = (msg: BackendMessage): ThreadMessage => { id: msg.id, role: "user", content: content as ThreadUserMessage["content"], - attachments, // Now populated with actual attachments + attachments, // Image attachments only metadata: { - custom: {}, + custom: { + documentAttachments, + }, }, createdAt, }; diff --git a/app/(main)/newchat/components/step1.tsx b/app/(main)/newchat/components/step1.tsx index 5ac1e5ff..7f454597 100644 --- a/app/(main)/newchat/components/step1.tsx +++ b/app/(main)/newchat/components/step1.tsx @@ -210,28 +210,24 @@ const Step1: React.FC = ({ setProjectId, setChatStep }) => { { queryKey: ["user-repository"], queryFn: async () => { - const repos = - await BranchAndRepositoryService.getUserRepositories().then( - (data) => { - if (defaultRepo && data.length > 0) { - const decodedDefaultRepo = - decodeURIComponent(defaultRepo).toLowerCase(); - const matchingRepo = data.find((repo: RepoIdentifier) => { - const repoIdentifier = getRepoIdentifier(repo); - return ( - repoIdentifier && - repoIdentifier.toLowerCase() === decodedDefaultRepo - ); - }); - dispatch( - setRepoName( - matchingRepo ? decodeURIComponent(defaultRepo) : "" - ) - ); - } - return data; - } - ); + const repos = + await BranchAndRepositoryService.getUserRepositories().then((data) => { + if (defaultRepo && data.length > 0) { + const decodedDefaultRepo = + decodeURIComponent(defaultRepo).toLowerCase(); + const matchingRepo = data.find((repo: RepoIdentifier) => { + const repoIdentifier = getRepoIdentifier(repo); + return ( + repoIdentifier && + repoIdentifier.toLowerCase() === decodedDefaultRepo + ); + }); + dispatch( + setRepoName(matchingRepo ? decodeURIComponent(defaultRepo) : "") + ); + } + return data; + }); return repos; }, } @@ -601,7 +597,7 @@ const Step1: React.FC = ({ setProjectId, setChatStep }) => { key={value.id} value={repoIdentifier} onSelect={(value) => { - dispatch(setRepoName(value)); + handleRepoSelect(value); setRepoOpen(false); }} > @@ -611,18 +607,20 @@ const Step1: React.FC = ({ setProjectId, setChatStep }) => { })} - handleSetPublicRepoDialog(true)} - > - - Public Repository - - - - {process.env.NEXT_PUBLIC_BASE_URL?.includes( - "localhost" - ) && ( + {!process.env.NEXT_PUBLIC_BASE_URL?.includes("localhost") && ( + <> + handleSetPublicRepoDialog(true)} + > + + Public Repository + + + + + )} + {process.env.NEXT_PUBLIC_BASE_URL?.includes("localhost") && ( <> {user.name} {user.emailVerified ? ( - + + + ) : ( - + + + )}
{user.email} @@ -147,9 +151,13 @@ export function NavUser({
{user.email} {user.emailVerified ? ( - + + + ) : ( - + + + )}
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..5913f3bd --- /dev/null +++ b/components/chat/ContextUsageIndicator.tsx @@ -0,0 +1,203 @@ +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, Sparkles } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +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); + + 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, refreshTrigger]); + + if (loading) { + return ( +
+
+
+
+ ); + } + + if (!usage) return null; + + 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-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 ( + + + +
+ {/* Icon */} + + + {/* Mini Progress Bar */} +
+
+
+ + {/* Compact Text */} + + {formatTokenCount(usage.current_usage.total)} / {formatTokenCount(usage.context_limit)} + + + {/* Warning icon for critical/approaching */} + {usage.warning_level !== 'none' && ( + + )} +
+ + + +
+ {/* Header */} +
+
+ Context Window + + {percentage.toFixed(1)}% used + +
+ + {/* Full progress bar in tooltip */} +
+
+
+
+ + {/* Breakdown */} +
+
+ Conversation + + {formatTokenCount(usage.current_usage.conversation_history)} + +
+
+ Attachments + + {formatTokenCount(usage.current_usage.text_attachments)} + +
+
+ Code context + + {formatTokenCount(usage.current_usage.code_context)} + +
+ +
+ Total + + {usage.current_usage.total.toLocaleString()} + +
+
+ + {/* Footer */} +
+ {usage.model} • {usage.context_limit.toLocaleString()} token limit +
+
+ + + + ); +}; diff --git a/components/chat/DocumentAttachmentCard.tsx b/components/chat/DocumentAttachmentCard.tsx new file mode 100644 index 00000000..6679bed8 --- /dev/null +++ b/components/chat/DocumentAttachmentCard.tsx @@ -0,0 +1,92 @@ +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..6110d5b8 --- /dev/null +++ b/components/chat/ValidationErrorModal.tsx @@ -0,0 +1,139 @@ +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 ( + { + if (!isOpen) onClose(); + }} + > + + + + + 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.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/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; } diff --git a/lib/utils/fileTypes.ts b/lib/utils/fileTypes.ts new file mode 100644 index 00000000..0f2c3044 --- /dev/null +++ b/lib/utils/fileTypes.ts @@ -0,0 +1,143 @@ +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; +} + +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]; + } + 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 7d5d2950..cce2c7fb 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 { private static extractJsonObjects(input: string): { @@ -288,6 +289,7 @@ export default class ChatService { message: string, selectedNodes: any[], images: File[] = [], + attachmentIds: string[] = [], onMessageUpdate: ( message: string, tool_calls: any[], @@ -323,6 +325,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) => { @@ -927,6 +938,150 @@ export default class ChatService { } } + // 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, + onProgress?: (progress: number) => void, + signal?: AbortSignal + ): 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, + signal, + onUploadProgress: (progressEvent) => { + if (progressEvent.total && onProgress) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(percentCompleted); + } + }, + } + ); + 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' + ); + } + } + static async stopMessage(conversationId: string): Promise { try { const headers = await getHeaders();