From 7a613a457d1ec1da8bfc4ada42971eba8b851341 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 10 Jun 2025 20:29:58 -0500 Subject: [PATCH 01/44] Gemma 3 with image processing --- .gitignore | 2 + frontend/src/components/ChatBox.tsx | 87 +++++++++++++++++-- frontend/src/components/ModelSelector.tsx | 29 ++++--- frontend/src/routes/_auth.chat.$chatId.tsx | 97 ++++++++++++++++++---- frontend/src/routes/index.tsx | 3 +- frontend/src/state/LocalStateContext.tsx | 7 ++ frontend/src/state/LocalStateContextDef.ts | 11 ++- frontend/src/utils/file.ts | 8 ++ 8 files changed, 207 insertions(+), 37 deletions(-) create mode 100644 frontend/src/utils/file.ts diff --git a/.gitignore b/.gitignore index 90bbd56a..4c61a2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,5 @@ frontend/*.local *.tsbuildinfo **/.claude/settings.local.json + +.repo_ignore diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index d3387245..ee22dd88 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -1,4 +1,4 @@ -import { CornerRightUp, Bot } from "lucide-react"; +import { CornerRightUp, Bot, ImageIcon, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useEffect, useRef, useState } from "react"; @@ -36,8 +36,23 @@ function TokenWarning({ isCompressing?: boolean; }) { const totalTokens = - messages.reduce((acc, msg) => acc + estimateTokenCount(msg.content), 0) + - (currentInput ? estimateTokenCount(currentInput) : 0); + messages.reduce((acc, msg) => { + if (typeof msg.content === "string") { + return acc + estimateTokenCount(msg.content); + } else { + // For multimodal content, estimate tokens from text parts + return ( + acc + + msg.content.reduce((sum, part) => { + if (part.type === "text") { + return sum + estimateTokenCount(part.text); + } + // Rough estimate for images + return sum + 85; + }, 0) + ); + } + }, 0) + (currentInput ? estimateTokenCount(currentInput) : 0); const navigate = useNavigate(); @@ -117,7 +132,7 @@ export default function Component({ onCompress, isSummarizing = false }: { - onSubmit: (input: string, systemPrompt?: string) => void; + onSubmit: (input: string, systemPrompt?: string, images?: File[]) => void; startTall?: boolean; messages?: ChatMessage[]; isStreaming?: boolean; @@ -129,6 +144,18 @@ export default function Component({ const [isSystemPromptExpanded, setIsSystemPromptExpanded] = useState(false); const { billingStatus, setBillingStatus, draftMessages, setDraftMessage, clearDraftMessage } = useLocalState(); + const { model } = useLocalState(); + + const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; + const [images, setImages] = useState([]); + const fileInputRef = useRef(null); + + const handleAddImages = (e: React.ChangeEvent) => { + if (!e.target.files) return; + setImages((prev) => [...prev, ...Array.from(e.target.files!)]); + }; + + const removeImage = (idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)); const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); const systemPromptRef = useRef(null); @@ -162,6 +189,14 @@ export default function Component({ // Check if system prompt can be edited (only for new chats) const canEditSystemPrompt = canUseSystemPrompt && messages.length === 0; + // Check if user has access to vision features (Pro or Team plan) + const hasVisionAccess = + freshBillingStatus && + (freshBillingStatus.product_name?.toLowerCase().includes("pro") || + freshBillingStatus.product_name?.toLowerCase().includes("team")); + + const canUseVision = isGemma && hasVisionAccess; + const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); if (!inputValue.trim() || isSubmitDisabled) return; @@ -180,8 +215,13 @@ export default function Component({ // Only pass system prompt if this is the first message const isFirstMessage = messages.length === 0; - onSubmit(inputValue.trim(), isFirstMessage ? systemPromptValue.trim() || undefined : undefined); + onSubmit( + inputValue.trim(), + isFirstMessage ? systemPromptValue.trim() || undefined : undefined, + images + ); setInputValue(""); + setImages([]); // Re-focus input after submitting setTimeout(() => { @@ -376,6 +416,22 @@ export default function Component({ } }} > + {images.length > 0 && ( +
+ {images.map((f, i) => ( +
+ + +
+ ))} +
+ )} @@ -409,6 +465,27 @@ export default function Component({ />
+ {canUseVision && ( + <> + + + + )}
))} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index bcd23d48..b33dd865 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -64,10 +64,11 @@ function Index() { const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); - async function handleSubmit(input: string, systemPrompt?: string) { + async function handleSubmit(input: string, systemPrompt?: string, images?: File[]) { if (input.trim() === "") return; localState.setUserPrompt(input.trim()); localState.setSystemPrompt(systemPrompt?.trim() || null); + localState.setUserImages(images || []); const id = await localState.addChat(); navigate({ to: "/chat/$chatId", params: { chatId: id } }); } diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index 8ecf2bea..45dff716 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -26,6 +26,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) const [localState, setLocalState] = useState({ userPrompt: "", systemPrompt: null as string | null, + userImages: [] as File[], model: import.meta.env.VITE_DEV_MODEL_OVERRIDE || DEFAULT_MODEL_ID, availableModels: [llamaModel] as OpenSecretModel[], billingStatus: null as BillingStatus | null, @@ -91,6 +92,10 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setLocalState((prev) => ({ ...prev, systemPrompt: prompt })); } + function setUserImages(images: File[]) { + setLocalState((prev) => ({ ...prev, userImages: images })); + } + function setBillingStatus(status: BillingStatus) { setLocalState((prev) => ({ ...prev, billingStatus: status })); } @@ -260,6 +265,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setAvailableModels, userPrompt: localState.userPrompt, systemPrompt: localState.systemPrompt, + userImages: localState.userImages, billingStatus: localState.billingStatus, searchQuery: localState.searchQuery, setSearchQuery, @@ -268,6 +274,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setBillingStatus, setUserPrompt, setSystemPrompt, + setUserImages, addChat, getChatById, persistChat, diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index 93dc62cc..c4d95b93 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -7,9 +7,14 @@ export interface OpenSecretModel extends Model { tasks?: string[]; } +export type ChatContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } }; + export type ChatMessage = { role: "user" | "assistant" | "system"; - content: string; + /** plain text for normal models, or multimodal array for Gemma */ + content: string | ChatContentPart[]; }; export type Chat = { @@ -33,6 +38,7 @@ export type LocalState = { setAvailableModels: (models: OpenSecretModel[]) => void; userPrompt: string; systemPrompt: string | null; + userImages: File[]; billingStatus: BillingStatus | null; /** Current search query for filtering chat history */ searchQuery: string; @@ -45,6 +51,7 @@ export type LocalState = { setBillingStatus: (status: BillingStatus) => void; setUserPrompt: (prompt: string) => void; setSystemPrompt: (prompt: string | null) => void; + setUserImages: (images: File[]) => void; addChat: (title?: string) => Promise; getChatById: (id: string) => Promise; persistChat: (chat: Chat) => Promise; @@ -67,6 +74,7 @@ export const LocalStateContext = createContext({ setAvailableModels: () => void 0, userPrompt: "", systemPrompt: null, + userImages: [], billingStatus: null, searchQuery: "", setSearchQuery: () => void 0, @@ -75,6 +83,7 @@ export const LocalStateContext = createContext({ setBillingStatus: () => void 0, setUserPrompt: () => void 0, setSystemPrompt: () => void 0, + setUserImages: () => void 0, addChat: async () => "", getChatById: async () => undefined, persistChat: async () => void 0, diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts new file mode 100644 index 00000000..ad4004f9 --- /dev/null +++ b/frontend/src/utils/file.ts @@ -0,0 +1,8 @@ +export function fileToDataURL(file: File): Promise { + return new Promise((res, rej) => { + const reader = new FileReader(); + reader.onload = () => res(reader.result as string); + reader.onerror = () => rej(reader.error); + reader.readAsDataURL(file); + }); +} From 3f032607686e403fc2664d3f6c82927c80c5c2f7 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 11 Jun 2025 10:29:08 -0500 Subject: [PATCH 02/44] Disable non-image models when image --- frontend/src/components/ChatBox.tsx | 2 +- frontend/src/components/ModelSelector.tsx | 46 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index ee22dd88..31b9468d 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -464,7 +464,7 @@ export default function Component({ onChange={(e) => setInputValue(e.target.value)} />
- + {canUseVision && ( <> = { } }; -export function ModelSelector() { +import { ChatMessage } from "@/state/LocalStateContextDef"; + +export function ModelSelector({ + messages = [], + draftImages = [] +}: { + messages?: ChatMessage[]; + draftImages?: File[]; +}) { const { model, setModel, availableModels, setAvailableModels, billingStatus } = useLocalState(); const os = useOpenSecret(); const navigate = useNavigate(); @@ -52,6 +60,14 @@ export function ModelSelector() { const hasFetched = useRef(false); const availableModelsRef = useRef(availableModels); + // Check if chat contains any images or if there are draft images + const chatHasImages = + draftImages.length > 0 || + messages.some( + (msg) => + typeof msg.content !== "string" && msg.content.some((part) => part.type === "image_url") + ); + // Keep ref updated useEffect(() => { availableModelsRef.current = availableModels; @@ -213,11 +229,20 @@ export function ModelSelector() { {availableModels && Array.isArray(availableModels) && - // Sort models: available first, then restricted (pro-only), then disabled + // Sort models: vision-capable first (if images present), then available, then restricted, then disabled [...availableModels] .sort((a, b) => { const aConfig = MODEL_CONFIG[a.id]; const bConfig = MODEL_CONFIG[b.id]; + + // If chat has images, prioritize vision models + if (chatHasImages) { + const aHasVision = aConfig?.supportsVision || false; + const bHasVision = bConfig?.supportsVision || false; + if (aHasVision && !bHasVision) return -1; + if (!aHasVision && bHasVision) return 1; + } + // Unknown models are treated as disabled const aDisabled = aConfig?.disabled || !aConfig; const bDisabled = bConfig?.disabled || !bConfig; @@ -247,11 +272,15 @@ export function ModelSelector() { const hasAccess = hasAccessToModel(availableModel.id); const isRestricted = (requiresPro || requiresStarter) && !hasAccess; + // Disable non-vision models if chat has images + const isDisabledDueToImages = chatHasImages && !config?.supportsVision; + const effectivelyDisabled = isDisabled || isDisabledDueToImages; + return ( { - if (isDisabled) return; + if (effectivelyDisabled) return; if (isRestricted) { // Navigate to pricing page for upgrade navigate({ to: "/pricing" }); @@ -260,17 +289,22 @@ export function ModelSelector() { } }} className={`flex items-center justify-between group ${ - isDisabled ? "opacity-50 cursor-not-allowed" : "" + effectivelyDisabled ? "opacity-50 cursor-not-allowed" : "" } ${isRestricted ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" : ""}`} - disabled={isDisabled} + disabled={effectivelyDisabled} >
{getDisplayName(availableModel.id, true)}
- {isRestricted && ( + {isRestricted && !isDisabledDueToImages && ( Upgrade? )} + {isDisabledDueToImages && ( + + {draftImages.length > 0 ? "Image pending" : "Images in chat"} + + )}
{model === availableModel.id && }
From 54cd11409044a3f239d7a23cf090f8507746da86 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 11 Jun 2025 15:01:50 -0500 Subject: [PATCH 03/44] Refactor chat view --- frontend/src/components/ChatHistoryList.tsx | 56 +- frontend/src/components/RenameChatDialog.tsx | 15 +- frontend/src/components/Sidebar.tsx | 59 ++- frontend/src/hooks/useChatSession.ts | 252 +++++++++ frontend/src/routes/_auth.chat.$chatId.tsx | 509 +++++-------------- frontend/src/state/LocalStateContext.tsx | 14 +- 6 files changed, 467 insertions(+), 438 deletions(-) create mode 100644 frontend/src/hooks/useChatSession.ts diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index b951b757..adc3bda4 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useLocalState } from "@/state/useLocalState"; import { Link } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -42,34 +42,40 @@ export function ChatHistoryList({ currentChatId, searchQuery = "" }: ChatHistory return chats.filter((chat) => chat.title.toLowerCase().includes(normalizedQuery)); }, [chats, searchQuery]); - const handleDeleteChat = async (chatId: string) => { - try { - await deleteChat(chatId); - } catch (error) { - console.error("Error deleting chat:", error); - } - queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); - if (chatId === currentChatId) { - navigate({ to: "/" }); - } - }; + const handleDeleteChat = useCallback( + async (chatId: string) => { + try { + await deleteChat(chatId); + } catch (error) { + console.error("Error deleting chat:", error); + } + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + if (chatId === currentChatId) { + navigate({ to: "/" }); + } + }, + [deleteChat, queryClient, currentChatId, navigate] + ); - const handleOpenRenameDialog = (chat: { id: string; title: string }) => { + const handleOpenRenameDialog = useCallback((chat: { id: string; title: string }) => { setSelectedChat(chat); setIsRenameDialogOpen(true); - }; + }, []); - const handleRenameChat = async (chatId: string, newTitle: string) => { - try { - await renameChat(chatId, newTitle); - // Invalidate both the chat history list and the specific chat - queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); - queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); - } catch (error) { - console.error("Error renaming chat:", error); - throw error; - } - }; + const handleRenameChat = useCallback( + async (chatId: string, newTitle: string) => { + try { + await renameChat(chatId, newTitle); + // Invalidate both the chat history list and the specific chat + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); + } catch (error) { + console.error("Error renaming chat:", error); + throw error; + } + }, + [renameChat, queryClient] + ); if (error) { return
{error.message}
; diff --git a/frontend/src/components/RenameChatDialog.tsx b/frontend/src/components/RenameChatDialog.tsx index 840c8087..ba09ea48 100644 --- a/frontend/src/components/RenameChatDialog.tsx +++ b/frontend/src/components/RenameChatDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -38,17 +38,14 @@ export function RenameChatDialog({ } }, [open, currentTitle]); - const resetForm = useCallback(() => { - setNewTitle(currentTitle); - setError(null); - setIsLoading(false); - }, [currentTitle]); - useEffect(() => { if (!open) { - resetForm(); + // Reset form state when dialog closes + setNewTitle(currentTitle); + setError(null); + setIsLoading(false); } - }, [open, resetForm]); + }, [open, currentTitle]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 65b6c56c..4ef481b7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { Button } from "./ui/button"; import { useLocation, useRouter } from "@tanstack/react-router"; import { ChatHistoryList } from "./ChatHistoryList"; import { AccountMenu } from "./AccountMenu"; -import { useRef, useEffect, KeyboardEvent } from "react"; +import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect } from "react"; import { cn, useClickOutside, useIsMobile } from "@/utils/utils"; import { Input } from "./ui/input"; import { useLocalState } from "@/state/useLocalState"; @@ -66,33 +66,54 @@ export function Sidebar({ const sidebarRef = useRef(null); // Modified click outside handler to ignore clicks in dropdowns and dialogs - useClickOutside(sidebarRef, (event: MouseEvent | TouchEvent) => { - if (isOpen) { - // Check if the click was inside a dropdown or dialog - const target = event.target as HTMLElement; - const isInDropdown = target.closest('[role="menu"]'); - const isInDialog = target.closest('[role="dialog"]'); - const isInAlertDialog = target.closest('[role="alertdialog"]'); - - if (!isInDropdown && !isInDialog && !isInAlertDialog) { - onToggle(); + const handleClickOutside = useCallback( + (event: MouseEvent | TouchEvent) => { + if (isOpen) { + // Check if the click was inside a dropdown or dialog + const target = event.target as HTMLElement; + const isInDropdown = target.closest('[role="menu"]'); + const isInDialog = target.closest('[role="dialog"]'); + const isInAlertDialog = target.closest('[role="alertdialog"]'); + + if (!isInDropdown && !isInDialog && !isInAlertDialog) { + onToggle(); + } } - } - }); + }, + [isOpen, onToggle] + ); + + useClickOutside(sidebarRef, handleClickOutside); // Use the centralized hook for mobile detection const isMobile = useIsMobile(); + // Track if component is mounted to prevent state updates after unmount + const isMountedRef = useRef(true); + useLayoutEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + // This effect closes the sidebar on mobile when navigating, // but preserves search state between navigations useEffect(() => { + // Only subscribe if we're on mobile and sidebar is open + if (!isMobile || !isOpen) return; + const unsubscribe = router.subscribe("onResolved", () => { - // On mobile: close the sidebar when navigating to any page - // On desktop: keep the sidebar open - if (isOpen && isMobile) { - // Always close sidebar on mobile when navigating to preserve screen real estate - onToggle(); - } + // Use a microtask to avoid state updates during render + queueMicrotask(() => { + // Prevent updates if component unmounted + if (!isMountedRef.current) return; + + // Double-check conditions after async boundary + if (isOpen && isMobile) { + onToggle(); + } + }); }); return () => { diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts new file mode 100644 index 00000000..4ad180b8 --- /dev/null +++ b/frontend/src/hooks/useChatSession.ts @@ -0,0 +1,252 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Chat, ChatMessage } from "@/state/LocalStateContext"; +import { ChatContentPart } from "@/state/LocalStateContextDef"; +import { fileToDataURL } from "@/utils/file"; + +type ChatPhase = "idle" | "streaming" | "persisting"; + +interface UseChatSessionOptions { + getChatById: (chatId: string) => Promise; + persistChat: (chat: Chat) => Promise; + openai: ReturnType; + model: string; +} + +export function useChatSession(chatId: string, options: UseChatSessionOptions) { + const { getChatById, persistChat, openai, model } = options; + const queryClient = useQueryClient(); + const [phase, setPhase] = useState("idle"); + const [optimisticChat, setOptimisticChat] = useState(null); + const [currentStreamingMessage, setCurrentStreamingMessage] = useState(); + const processingRef = useRef(false); + const abortControllerRef = useRef(null); + + // Query the chat from backend + const { data: serverChat, isPending } = useQuery({ + queryKey: ["chat", chatId], + queryFn: () => getChatById(chatId), + retry: false + }); + + // Reset optimistic chat when chatId changes + useEffect(() => { + // Abort any ongoing streaming when chatId changes + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + setOptimisticChat(null); + setPhase("idle"); + setCurrentStreamingMessage(undefined); + processingRef.current = false; + }, [chatId]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + // Part A: Apply guard when syncing server data to optimistic state + useEffect(() => { + if (!serverChat || isPending) return; + + setOptimisticChat((prev) => { + if (!prev) return serverChat; // first load + // Never downgrade - if server has fewer messages, keep local state + if (serverChat.messages.length <= prev.messages.length) return prev; + return serverChat; + }); + }, [serverChat, isPending]); + + // Mutation for persisting chat + const persistMutation = useMutation({ + mutationFn: persistChat, + onSuccess: () => { + setPhase("idle"); + queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["billingStatus"] }); + }, + onError: () => { + setPhase("idle"); + } + }); + + // Current chat is optimistic if we have it, otherwise server data + const chat: Chat = useMemo( + () => + optimisticChat || + serverChat || { + id: chatId, + title: "New Chat", + messages: [] + }, + [optimisticChat, serverChat, chatId] + ); + + const streamAssistant = useCallback( + async (messages: ChatMessage[]): Promise => { + // Create new abort controller for this stream + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const stream = openai.beta.chat.completions.stream({ + model, + messages: messages as Parameters< + typeof openai.beta.chat.completions.stream + >[0]["messages"], + stream: true + }); + + let fullResponse = ""; + setCurrentStreamingMessage(""); + + for await (const chunk of stream) { + // Check if we should abort + if (abortController.signal.aborted) { + stream.controller.abort(); + throw new Error("Stream aborted"); + } + + const content = chunk.choices[0]?.delta?.content || ""; + fullResponse += content; + setCurrentStreamingMessage(fullResponse); + } + + await stream.finalChatCompletion(); + setCurrentStreamingMessage(undefined); + return fullResponse; + } catch (error) { + if (abortController.signal.aborted) { + throw new Error("Stream aborted"); + } + throw error; + } finally { + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + } + }, + [openai, model] + ); + + const appendUserMessage = useCallback( + async (content: string, images?: File[]) => { + if (phase !== "idle") { + return; + } + + if (processingRef.current) { + return; + } + + processingRef.current = true; + setPhase("streaming"); + + // Handle images for Gemma model + const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; + let userMessage: ChatMessage; + + if (isGemma && images && images.length > 0) { + const parts: ChatContentPart[] = [{ type: "text", text: content }]; + for (const file of images) { + const url = await fileToDataURL(file); + parts.push({ type: "image_url", image_url: { url } }); + } + userMessage = { role: "user", content: parts }; + } else { + userMessage = { role: "user", content }; + } + + const newMessages = [...chat.messages, userMessage]; + + // Update optimistic state immediately + setOptimisticChat({ + ...chat, + messages: newMessages + }); + + try { + // Stream assistant response + const assistantResponse = await streamAssistant(newMessages); + + // Add assistant message + const finalMessages = [ + ...newMessages, + { role: "assistant", content: assistantResponse } as ChatMessage + ]; + + // Update optimistic state with assistant message + setOptimisticChat((prev) => ({ + ...prev!, + messages: finalMessages + })); + + // Generate title if needed + const title = chat.title === "New Chat" ? await generateTitle(finalMessages) : chat.title; + + // Persist to backend + setPhase("persisting"); + await persistMutation.mutateAsync({ + id: chatId, + title, + model, + messages: finalMessages + }); + + // Update title in optimistic state if changed + if (title !== chat.title) { + setOptimisticChat((prev) => ({ ...prev!, title })); + } + } catch (error) { + setPhase("idle"); + processingRef.current = false; + + // Don't throw if it was an intentional abort + if (error instanceof Error && error.message === "Stream aborted") { + return; + } + + throw error; + } finally { + processingRef.current = false; + } + }, + [chat, model, phase, streamAssistant, persistMutation, chatId, openai, queryClient] + ); + + return { + chat, + phase, + currentStreamingMessage, + appendUserMessage, + streamAssistant + }; +} + +// Helper to generate chat title +async function generateTitle(messages: ChatMessage[]): Promise { + const userMessage = messages.find((m) => m.role === "user"); + if (!userMessage) return "New Chat"; + + const messageText = + typeof userMessage.content === "string" + ? userMessage.content + : (userMessage.content as ChatContentPart[]).find((p) => p.type === "text") + ? ( + (userMessage.content as ChatContentPart[]).find((p) => p.type === "text") as { + text: string; + } + ).text + : "New Chat"; + + // Simple title for now - just truncate + return messageText.slice(0, 50).trim(); +} diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index b90a2a86..4722d140 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -5,16 +5,13 @@ import ChatBox from "@/components/ChatBox"; import { useOpenAI } from "@/ai/useOpenAi"; import { useLocalState } from "@/state/useLocalState"; import { Markdown, stripThinkingTags } from "@/components/markdown"; -import { ChatMessage, Chat, DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; -import { AlertDestructive } from "@/components/AlertDestructive"; +import { ChatMessage, DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; import { Sidebar, SidebarToggle } from "@/components/Sidebar"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; -import { BillingStatus } from "@/billing/billingApi"; import { useNavigate, useLocation } from "@tanstack/react-router"; import { useIsMobile } from "@/utils/utils"; -import { fileToDataURL } from "@/utils/file"; -import { ChatContentPart } from "@/state/LocalStateContextDef"; +import { useChatSession } from "@/hooks/useChatSession"; export const Route = createFileRoute("/_auth/chat/$chatId")({ component: ChatComponent @@ -182,11 +179,79 @@ function ChatComponent() { const location = useLocation(); const isMobile = useIsMobile(); - const [error, setError] = useState(""); const [isSummarizing, setIsSummarizing] = useState(false); const chatContainerRef = useRef(null); + // Use the chat session hook + const { + chat: localChat, + phase, + currentStreamingMessage, + appendUserMessage + } = useChatSession(chatId, { + getChatById, + persistChat, + openai, + model + }); + + // Handle initial user prompt - using a ref to prevent double execution + const initialPromptProcessedRef = useRef(false); + + // Reset the ref when chatId changes + useEffect(() => { + initialPromptProcessedRef.current = false; + }, [chatId]); + + useEffect(() => { + // Check if we have a prompt to process and haven't processed it yet + if ( + userPrompt && + localChat.messages.length === 0 && + phase === "idle" && + !initialPromptProcessedRef.current + ) { + // Mark as processed immediately + initialPromptProcessedRef.current = true; + + // Capture values before clearing + const prompt = userPrompt; + const sysPrompt = systemPrompt; + const images = userImages; + + // Clear state immediately + setUserPrompt(""); + setSystemPrompt(null); + setUserImages([]); + + // Combine prompts + const finalPrompt = sysPrompt ? `[System: ${sysPrompt}]\n\n${prompt}` : prompt; + + // Send message + appendUserMessage(finalPrompt, images).catch((error) => { + // Only reset if it wasn't an abort + if (!(error instanceof Error) || error.message !== "Stream aborted") { + console.error("[ChatComponent] Failed to append message:", error); + setUserPrompt(prompt); + setSystemPrompt(sysPrompt); + setUserImages(images); + initialPromptProcessedRef.current = false; + } + }); + } + }, [ + userPrompt, + systemPrompt, + userImages, + localChat.messages.length, + phase, + appendUserMessage, + setUserPrompt, + setSystemPrompt, + setUserImages + ]); + // Handle mobile new chat (matching sidebar behavior) const handleMobileNewChat = useCallback(async () => { // If we're already on "/", focus the chat box @@ -232,396 +297,81 @@ function ChatComponent() { } }, []); - // Query the chat from the backend, in case it already exists - const { - isPending, - error: queryError, - data: queryChat - } = useQuery({ - queryKey: ["chat", chatId], - queryFn: () => { - return getChatById(chatId); - }, - retry: false - }); - - useEffect(() => { - if (queryError) { - console.error("Error fetching chat:", queryError); - setError("Error fetching chat. Please try again."); - } - }, [queryError]); - - // We need to keep a local state so we can stream in chat responses - const [localChat, setLocalChat] = useState({ - id: chatId, - title: "New Chat", - messages: [] - }); - - const [currentStreamingMessage, setCurrentStreamingMessage] = useState(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); - // Track if we've already set the model for this chat - const modelSetForChatRef = useRef(null); - + // Set model when chat first loads + const hasSetModelRef = useRef(false); useEffect(() => { - if (queryChat && !isPending) { - console.debug("Chat loaded from query:", queryChat); - if (queryChat.id !== chatId) { - console.error("Chat ID mismatch"); - setLocalChat((localChat) => ({ ...localChat, messages: [] })); - return; - } - if (queryChat.messages.length === 0) { - console.warn("Chat has no messages, using user prompt"); - - // Build messages array with system prompt first (if exists), then user prompt - const messages: ChatMessage[] = []; - - // Check for system prompt from LocalState - if (systemPrompt?.trim()) { - messages.push({ role: "system", content: systemPrompt.trim() } as ChatMessage); - } - - // Add user prompt if exists - if (userPrompt) { - messages.push({ role: "user", content: userPrompt } as ChatMessage); - } - - setLocalChat((localChat) => ({ ...localChat, messages })); - return; - } - setLocalChat(queryChat); + if (localChat.model && !hasSetModelRef.current) { + setModel(localChat.model); + hasSetModelRef.current = true; } - // I don't want to re-run this effect if the user prompt or system prompt changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queryChat, chatId, isPending]); + }, [localChat.model, setModel]); + // Reset the ref when chatId changes useEffect(() => { - if (queryChat && !isPending) { - if (modelSetForChatRef.current !== chatId) { - const chatModel = queryChat.model || DEFAULT_MODEL_ID; - /** ① Set global selector ② also store on local chat state */ - setModel(chatModel); - setLocalChat((prev) => ({ ...prev, model: chatModel })); - modelSetForChatRef.current = chatId; - } - } - }, [queryChat, chatId, isPending, setModel]); - - // IMPORTANT that this runs only once (because it uses the user's tokens!) - const userPromptEffectRan = useRef(false); + hasSetModelRef.current = false; + }, [chatId]); + // Update the chat's model when user changes it useEffect(() => { - // Make sure we don't run this more than once per mount - if (userPromptEffectRan.current) return; - userPromptEffectRan.current = true; - - // Check if we have a user prompt to send - if (userPrompt) { - console.log("User prompt found for chatId:", chatId, "sending to chat"); - console.log("USER PROMPT:", userPrompt); - console.log("USER IMAGES:", userImages?.length || 0, "images"); - - // Set a small delay to ensure all state is properly initialized - setTimeout(() => { - sendMessage(userPrompt, systemPrompt || undefined, userImages); - }, 100); + if (hasSetModelRef.current && model !== localChat.model && localChat.id) { + // Update the chat with the new model + const updatedChat = { ...localChat, model }; + persistChat(updatedChat).catch((error) => { + console.error("Failed to update chat model:", error); + }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [model, localChat, persistChat]); - const [isLoading, setIsLoading] = useState(false); + const isLoading = phase === "streaming"; + const isPersisting = phase === "persisting"; - const sendMessage = useCallback( - async (input: string, systemPrompt?: string, images?: File[]) => { - // Helper function to check if the user is on a free plan - function isUserOnFreePlan(): boolean { - try { - const billingStatus = queryClient.getQueryData(["billingStatus"]) as - | BillingStatus - | undefined; - - return ( - !billingStatus || - !billingStatus.product_name || - billingStatus.product_name.toLowerCase().includes("free") - ); - } catch (error) { - console.log("Error checking billing status, defaulting to free plan", error); - return true; // Default to free plan if there's an error - } - } - - async function generateChatTitle(messages: ChatMessage[]): Promise { - // Find the first user message - const userMessage = messages.find((message) => message.role === "user"); - if (!userMessage) return "New Chat"; - - // Simple title generation - truncate first message to 50 chars - const messageText = - typeof userMessage.content === "string" - ? userMessage.content - : userMessage.content.find((p) => p.type === "text")?.text || "New Chat"; - const simpleTitleFromMessage = messageText.slice(0, 50).trim(); - - // For free plan users, just use the simple title - // For paid plans, try to generate AI title - if (isUserOnFreePlan()) { - console.log("Using simple title generation for free plan user"); - return simpleTitleFromMessage; - } + // Auto-scroll when new messages appear (user message or start of streaming) + const prevMessageCountRef = useRef(localChat.messages.length); + const prevStreamingRef = useRef(false); - // For paid plans, use LLM to generate a smart title - try { - console.log("Using AI title generation for paid plan user"); - // Get the user's first message, truncate if too long - const userText = - typeof userMessage.content === "string" - ? userMessage.content - : userMessage.content.find((p) => p.type === "text")?.text || "New Chat"; - const userContent = userText.slice(0, 500); // Reduced to 500 chars to optimize token usage - - // Use the OpenAI API to generate a concise title - use the default model - const stream = openai.beta.chat.completions.stream({ - model: DEFAULT_MODEL_ID, // Use the default model instead of user selected model - messages: [ - { - role: "system", - content: - "You are a helpful assistant that generates concise, meaningful titles (3-5 words) for chat conversations based on the user's first message. Return only the title without quotes or explanations." - }, - { - role: "user", - content: `Generate a concise, contextual title (3-5 words) for a chat that starts with this message: "${userContent}"` - } - ], - temperature: 0.7, - max_tokens: 15, // Keep response very short - stream: true + useEffect(() => { + const messageCount = localChat.messages.length; + const hasNewMessage = messageCount > prevMessageCountRef.current; + const justStartedStreaming = isLoading && !prevStreamingRef.current; + + if (hasNewMessage || justStartedStreaming) { + // Always scroll for new user messages or when streaming starts + const container = chatContainerRef.current; + if (container) { + requestAnimationFrame(() => { + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth" }); - - let generatedTitle = ""; - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content || ""; - generatedTitle += content; - } - - // Get the final completion - await stream.finalChatCompletion(); - - // Remove quotes if present and limit length - const cleanTitle = generatedTitle - .replace(/^["']|["']$/g, "") // Remove surrounding quotes if present - .replace(/\n/g, " ") // Remove new lines - .trim(); - - return cleanTitle || simpleTitleFromMessage; // Fallback to simple title if generation fails - } catch (error) { - console.error("Failed to generate chat title:", error); - // Fallback to simple title method - return simpleTitleFromMessage; - } - } - if (!input.trim() || !localChat) return; - setError(""); - - const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; - let userMsg: ChatMessage; - - if (isGemma && images && images.length) { - const parts: ChatContentPart[] = [{ type: "text", text: input.trim() }]; - for (const f of images) { - const url = await fileToDataURL(f); - parts.push({ type: "image_url", image_url: { url } }); - } - userMsg = { role: "user", content: parts }; - } else { - userMsg = { role: "user", content: input.trim() }; + }); } + } - // Build new messages array with system prompt if this is the first message - let newMessages: ChatMessage[]; + prevMessageCountRef.current = messageCount; + prevStreamingRef.current = isLoading; + }, [localChat.messages.length, isLoading]); - if (localChat.messages.length === 0 && systemPrompt?.trim()) { - // First message: add system prompt, then user message - newMessages = [{ role: "system", content: systemPrompt.trim() } as ChatMessage, userMsg]; - } else { - // Subsequent messages: just add user message - newMessages = [...localChat.messages, userMsg]; - } + const sendMessage = useCallback( + async (input: string, systemPrompt?: string, images?: File[]) => { + // Handle system prompt if provided + const messageContent = systemPrompt ? `[System: ${systemPrompt}]\n\n${input}` : input; - setLocalChat((prev) => ({ - ...prev, - messages: newMessages - })); + // Use the appendUserMessage from the hook + await appendUserMessage(messageContent, images); - // Scroll to bottom when user sends message + // Scroll to bottom after sending requestAnimationFrame(() => { chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: "smooth" }); }); - - setIsLoading(true); - - try { - // Start title generation early for paid users if needed - let titleGenerationPromise; - let title = localChat.title; - - if (title === "New Chat") { - const isFreePlan = isUserOnFreePlan(); - - if (!isFreePlan) { - console.log("Starting async AI title generation for paid user's chat"); - // Start title generation in parallel for paid users - titleGenerationPromise = generateChatTitle(newMessages).then((newTitle) => { - // Clean up the title - const cleanTitle = newTitle.replace(/"/g, "").replace(/\n/g, " "); - - // Update local chat with generated title immediately when available - setLocalChat((prev) => ({ - ...prev, - title: cleanTitle - })); - - return cleanTitle; - }); - } else { - console.log("Using simple title for free user's chat"); - // For free users, set the title synchronously - const newTitle = await generateChatTitle(newMessages); - title = newTitle.replace(/"/g, "").replace(/\n/g, " "); - - setLocalChat((prev) => ({ - ...prev, - title - })); - } - } - - // Stream the chat response (happens in parallel with title generation) - // newMessages already contains system prompt if it was the first message - - const stream = openai.beta.chat.completions.stream({ - model, - messages: newMessages as any, - stream: true - }); - - let fullResponse = ""; - let isFirstChunk = true; - - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content || ""; - fullResponse += content; - setCurrentStreamingMessage(fullResponse); - - // Scroll to bottom on first chunk of the response - if (isFirstChunk && content.trim()) { - requestAnimationFrame(() => { - chatContainerRef.current?.scrollTo({ - top: chatContainerRef.current.scrollHeight, - behavior: "smooth" - }); - }); - isFirstChunk = false; - } - } - - // Save scroll position before state updates - const container = chatContainerRef.current; - const scrollPosition = container?.scrollTop; - - const finalMessages = [ - ...newMessages, - { role: "assistant", content: fullResponse } as ChatMessage - ]; - setLocalChat((prev) => ({ - ...prev, - messages: finalMessages - })); - setCurrentStreamingMessage(undefined); - - // Restore scroll position after state updates - if (container && scrollPosition !== undefined) { - // Use requestAnimationFrame to ensure this runs after the render - requestAnimationFrame(() => { - // Ensure we don't scroll beyond bounds - const maxScroll = container.scrollHeight - container.clientHeight; - const boundedPosition = Math.min(scrollPosition, maxScroll); - container.scrollTop = boundedPosition; - }); - } - - // Wait for title generation to complete if we started it - if (titleGenerationPromise) { - title = await titleGenerationPromise; - } - - const chatCompletion = await stream.finalChatCompletion(); - console.log(chatCompletion); - - // Should be safe to clear these by now - setUserPrompt(""); - setSystemPrompt(null); - setUserImages([]); - - // React sucks and doesn't get the latest state - // Use current title from localChat which may have been updated asynchronously - const currentTitle = localChat.title === "New Chat" ? title : localChat.title; - await persistChat({ ...localChat, model, title: currentTitle, messages: finalMessages }); - - // Invalidate chat history to show the new title in the sidebar - queryClient.invalidateQueries({ - queryKey: ["chatHistory"], - refetchType: "all" - }); - - // Invalidate current chat query to ensure the title update is reflected - queryClient.invalidateQueries({ - queryKey: ["chat", chatId], - refetchType: "all" - }); - - // Only invalidate billing status after everything is complete - queryClient.invalidateQueries({ - queryKey: ["billingStatus"], - refetchType: "all" - }); - } catch (error) { - // If there's an error, we should still refetch the billing status - // to make sure our optimistic update was correct - queryClient.invalidateQueries({ - queryKey: ["billingStatus"], - refetchType: "all" - }); - console.error("Error:", error); - if (error instanceof Error) setError(error.message); - } - - setIsLoading(false); }, - // We intentionally don't include freshBillingStatus in the dependency array - // even though it's used in the closure to avoid re-creating the function - // on every billing status change - [ - localChat, - model, - openai, - persistChat, - queryClient, - setUserPrompt, - setSystemPrompt, - setUserImages, - chatId - ] + [appendUserMessage] ); // Chat compression function @@ -661,7 +411,9 @@ END OF INSTRUCTIONS`; let summary = ""; const stream = openai.beta.chat.completions.stream({ model: DEFAULT_MODEL_ID, // Use the default model instead of user selected model - messages: summarizationMessages as any, + messages: summarizationMessages as Parameters< + typeof openai.beta.chat.completions.stream + >[0]["messages"], temperature: 0.3, max_tokens: 600, stream: true @@ -689,7 +441,7 @@ END OF INSTRUCTIONS`; // 3. Take the direct storage approach instead of relying on React state/effects // Create a fake user message directly in storage that the next page will read - const initialChatData: Chat = { + const initialChatData = { id: id, title: inheritedTitle, messages: [{ role: "user" as const, content: initialMsg }] @@ -710,19 +462,16 @@ END OF INSTRUCTIONS`; refetchType: "all" }); - // 5. Reset the flag for good measure - userPromptEffectRan.current = false; - - // 6. Navigate to the new chat which should now have the initial message + // 5. Navigate to the new chat which should now have the initial message console.log("Navigating to new chat with pre-persisted message:", id); navigate({ to: "/chat/$chatId", params: { chatId: id } }); } catch (e) { console.error("compressChat failed:", e); - setError("Could not compress chat – please try again."); + // Note: We don't have a setError function anymore since errors are handled by the hook } finally { setIsSummarizing(false); } - }, [localChat, model, openai, addChat, navigate, setUserPrompt]); + }, [localChat, openai, addChat, navigate, setUserPrompt, persistChat, queryClient]); return (
@@ -807,11 +556,11 @@ END OF INSTRUCTIONS`; {/* Place the chat box inline (below messages) in normal flow */}
- {error && } + {/* Error handling can be added here if needed */} diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index 45dff716..883bf547 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -119,14 +119,14 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) return newChat.id; } - async function getChatById(id: string) { + async function getChatById(id: string): Promise { try { const chat = await get(`chat_${id}`); - if (!chat) throw new Error("Chat not found"); + if (!chat) return undefined; return JSON.parse(chat) as Chat; } catch (error) { console.error("Error fetching chat:", error); - throw new Error("Error fetching chat."); + return undefined; } } @@ -205,8 +205,12 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) async function renameChat(chatId: string, newTitle: string) { try { - // Get the current chat (getChatById already throws if chat not found) + // Get the current chat const chat = await getChatById(chatId); + if (!chat) { + console.error("Chat not found for renaming:", chatId); + throw new Error("Chat not found"); + } // Update the chat title chat.title = newTitle; @@ -249,7 +253,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) } function setModel(model: string) { - setLocalState((prev) => ({ ...prev, model })); + setLocalState((prev) => (prev.model === model ? prev : { ...prev, model })); } function setAvailableModels(models: OpenSecretModel[]) { From 512194fe5a2717bf099d15f7708b2e9d667fc038 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 21:13:15 +0000 Subject: [PATCH 04/44] fix: add type safety to fileToDataURL function - Replace unsafe type assertion with proper type guard - Add error handling for unexpected FileReader result types - Add onabort handler for aborted operations Co-authored-by: AnthonyRonning --- frontend/src/utils/file.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts index ad4004f9..800a97ab 100644 --- a/frontend/src/utils/file.ts +++ b/frontend/src/utils/file.ts @@ -1,8 +1,15 @@ export function fileToDataURL(file: File): Promise { return new Promise((res, rej) => { const reader = new FileReader(); - reader.onload = () => res(reader.result as string); + reader.onload = () => { + if (typeof reader.result === "string") { + res(reader.result); + } else { + rej(new Error("Unexpected FileReader result type")); + } + }; reader.onerror = () => rej(reader.error); + reader.onabort = () => rej(new Error("FileReader operation was aborted")); reader.readAsDataURL(file); }); } From 51ec0d044d5537f4c07ae1b964847fcbbb9b13bc Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 11 Jun 2025 17:19:29 -0500 Subject: [PATCH 05/44] Restrict image types for upload --- frontend/src/components/ChatBox.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 31b9468d..5a862e09 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -152,7 +152,17 @@ export default function Component({ const handleAddImages = (e: React.ChangeEvent) => { if (!e.target.files) return; - setImages((prev) => [...prev, ...Array.from(e.target.files!)]); + + const supportedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; + const validFiles = Array.from(e.target.files).filter((file) => + supportedTypes.includes(file.type.toLowerCase()) + ); + + if (validFiles.length < e.target.files.length) { + console.warn("Some files were skipped. Only JPEG, PNG, and WebP images are supported."); + } + + setImages((prev) => [...prev, ...validFiles]); }; const removeImage = (idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)); @@ -469,7 +479,7 @@ export default function Component({ <> Date: Thu, 12 Jun 2025 10:34:22 -0500 Subject: [PATCH 06/44] Update frontend/src/state/LocalStateContextDef.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- frontend/src/state/LocalStateContextDef.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index c4d95b93..7ce5ae41 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -13,7 +13,7 @@ export type ChatContentPart = export type ChatMessage = { role: "user" | "assistant" | "system"; - /** plain text for normal models, or multimodal array for Gemma */ + /** plain text for normal models, or multimodal array for multimodal models */ content: string | ChatContentPart[]; }; From fe893e71e119fe5cf6c79cf1eb0194b6427ab9ee Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 12 Jun 2025 13:13:07 -0500 Subject: [PATCH 07/44] fix double mount issue on photo upload --- frontend/src/hooks/useChatSession.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index 4ad180b8..29cbfc3a 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -147,9 +147,6 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { return; } - processingRef.current = true; - setPhase("streaming"); - // Handle images for Gemma model const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; let userMessage: ChatMessage; @@ -165,6 +162,14 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { userMessage = { role: "user", content }; } + // Check again after async operations to prevent double execution + if (processingRef.current) { + return; + } + + processingRef.current = true; + setPhase("streaming"); + const newMessages = [...chat.messages, userMessage]; // Update optimistic state immediately From 3e7aded30e3c150c70ff8030671868b567ce81ab Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 12 Jun 2025 13:43:32 -0500 Subject: [PATCH 08/44] Fix thinking markdown streaming --- frontend/src/components/markdown.tsx | 35 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 99ed5c93..9ed476b9 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -136,7 +136,11 @@ function parseThinkingTags(content: string, isComplete: boolean = false): Parsed } // Pattern to match tags (complete or incomplete) - const thinkPattern = /([\s\S]*?)<\/think>|([\s\S]*?)$/g; + // During streaming (!isComplete), we want to catch as soon as it appears + const thinkPattern = isComplete + ? /([\s\S]*?)<\/think>|([\s\S]*?)$/g + : /([\s\S]*?)(?:<\/think>|$)/g; + let lastIndex = 0; let match; @@ -149,16 +153,24 @@ function parseThinkingTags(content: string, isComplete: boolean = false): Parsed } } - // Extract content from either complete or incomplete tag - const thinkContent = (match[1] || match[2] || "").trim(); + // Extract content from the match + const thinkContent = match[1] || ""; - // Only add thinking block if it has actual content (not just whitespace) - if (thinkContent) { + // During streaming, even empty think tags should be shown to indicate thinking is starting + if (!isComplete && match[0].includes("")) { + parts.push({ + type: "thinking", + content: thinkContent, + duration: undefined, + id: `think-${match.index}` + }); + } else if (thinkContent.trim()) { + // For complete content, only add if there's actual content parts.push({ type: "thinking", content: thinkContent, - duration: undefined, // Let the UI calculate based on word count - id: `think-${match.index}` // Unique ID based on position + duration: undefined, + id: `think-${match.index}` }); } @@ -366,9 +378,14 @@ function MarkdownWithThinking({ <> {parsedContent.map((part, index) => { if (part.type === "thinking") { - // Check if this is the last part and we're still loading (no closing tag) + // Check if this thinking block is still being streamed const isLastPart = index === parsedContent.length - 1; - const isThinking = loading && isLastPart && !content.includes(""); + // During streaming, check if this thinking block doesn't have a closing tag + const thisThinkingPosition = content.lastIndexOf(""); + const closingPosition = content.lastIndexOf(""); + + // It's actively thinking if we're loading and this think tag hasn't been closed yet + const isThinking = loading && isLastPart && closingPosition < thisThinkingPosition; return ( Date: Fri, 13 Jun 2025 12:34:30 -0500 Subject: [PATCH 09/44] Fix title generation --- frontend/src/hooks/useChatSession.ts | 107 ++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 11 deletions(-) diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index 29cbfc3a..b5ebf2f9 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -1,8 +1,9 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Chat, ChatMessage } from "@/state/LocalStateContext"; +import { Chat, ChatMessage, DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; import { ChatContentPart } from "@/state/LocalStateContextDef"; import { fileToDataURL } from "@/utils/file"; +import { BillingStatus } from "@/billing/billingApi"; type ChatPhase = "idle" | "streaming" | "persisting"; @@ -179,6 +180,19 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { }); try { + // Start title generation in background if needed + let titlePromise: Promise | undefined; + if (chat.title === "New Chat") { + titlePromise = generateTitle(newMessages, openai, queryClient); + // Update title in UI as soon as it's ready + titlePromise.then((generatedTitle) => { + setOptimisticChat((prev) => { + if (!prev) return prev; + return { ...prev, title: generatedTitle }; + }); + }); + } + // Stream assistant response const assistantResponse = await streamAssistant(newMessages); @@ -194,8 +208,8 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { messages: finalMessages })); - // Generate title if needed - const title = chat.title === "New Chat" ? await generateTitle(finalMessages) : chat.title; + // Wait for title generation if it was started + const title = titlePromise ? await titlePromise : chat.title; // Persist to backend setPhase("persisting"); @@ -205,11 +219,6 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { model, messages: finalMessages }); - - // Update title in optimistic state if changed - if (title !== chat.title) { - setOptimisticChat((prev) => ({ ...prev!, title })); - } } catch (error) { setPhase("idle"); processingRef.current = false; @@ -237,7 +246,29 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { } // Helper to generate chat title -async function generateTitle(messages: ChatMessage[]): Promise { +async function generateTitle( + messages: ChatMessage[], + openai: ReturnType, + queryClient: ReturnType +): Promise { + // Helper function to check if the user is on a free plan + function isUserOnFreePlan(): boolean { + try { + const billingStatus = queryClient.getQueryData(["billingStatus"]) as + | BillingStatus + | undefined; + + return ( + !billingStatus || + !billingStatus.product_name || + billingStatus.product_name.toLowerCase().includes("free") + ); + } catch (error) { + console.log("Error checking billing status, defaulting to free plan", error); + return true; // Default to free plan if there's an error + } + } + const userMessage = messages.find((m) => m.role === "user"); if (!userMessage) return "New Chat"; @@ -252,6 +283,60 @@ async function generateTitle(messages: ChatMessage[]): Promise { ).text : "New Chat"; - // Simple title for now - just truncate - return messageText.slice(0, 50).trim(); + // Simple title generation - truncate first message to 50 chars + const simpleTitleFromMessage = messageText.slice(0, 50).trim(); + + // For free plan users, just use the simple title + // For paid plans, try to generate AI title + if (isUserOnFreePlan()) { + console.log("Using simple title generation for free plan user"); + return simpleTitleFromMessage; + } + + // For paid plans, use LLM to generate a smart title + try { + console.log("Using AI title generation for paid plan user"); + // Get the user's first message, truncate if too long + const userContent = messageText.slice(0, 500); // Reduced to 500 chars to optimize token usage + + // Use the OpenAI API to generate a concise title - use the default model + const stream = openai.beta.chat.completions.stream({ + model: DEFAULT_MODEL_ID, // Use the default model instead of user selected model + messages: [ + { + role: "system", + content: + "You are a helpful assistant that generates concise, meaningful titles (3-5 words) for chat conversations based on the user's first message. Return only the title without quotes or explanations." + }, + { + role: "user", + content: `Generate a concise, contextual title (3-5 words) for a chat that starts with this message: "${userContent}"` + } + ], + temperature: 0.7, + max_tokens: 15, // Keep response very short + stream: true + }); + + let generatedTitle = ""; + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content || ""; + generatedTitle += content; + } + + // Get the final completion + await stream.finalChatCompletion(); + + // Remove quotes if present and limit length + const cleanTitle = generatedTitle + .replace(/^["']|["']$/g, "") // Remove leading/trailing quotes + .trim() + .slice(0, 50); + + console.log("Generated title:", cleanTitle); + return cleanTitle || simpleTitleFromMessage; // Fallback if generation fails + } catch (error) { + console.error("Error generating AI title, falling back to simple title:", error); + return simpleTitleFromMessage; + } } From 8695d2bc674e9a2d6bdb2260d3c4f571a3bc91e3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:53:41 +0000 Subject: [PATCH 10/44] fix: handle both capture groups in think tag regex pattern The regex alternation for completed vs unterminated tags creates two capture groups, but the code was only reading match[1]. This caused unterminated tags to lose their content since it was in match[2]. Now reads from both match[1] and match[2] to handle both cases correctly. Co-authored-by: Anthony --- frontend/src/components/markdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 9ed476b9..a598e8eb 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -154,7 +154,7 @@ function parseThinkingTags(content: string, isComplete: boolean = false): Parsed } // Extract content from the match - const thinkContent = match[1] || ""; + const thinkContent = match[1] ?? match[2] ?? ""; // During streaming, even empty think tags should be shown to indicate thinking is starting if (!isComplete && match[0].includes("")) { From 992692337fa2993005202693f5b7570a1f357ee9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:07:55 +0000 Subject: [PATCH 11/44] fix: improve error handling and model capability detection in useChatSession - Replace hard-coded model check with capability-based detection using MODEL_CONFIG - Add error handling for fileToDataURL calls to prevent crashes from corrupted images - Continue processing other images if individual conversion fails - Add comments explaining error handling approach Co-authored-by: Anthony --- frontend/src/hooks/useChatSession.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index b5ebf2f9..3c9dbc3f 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -5,6 +5,14 @@ import { ChatContentPart } from "@/state/LocalStateContextDef"; import { fileToDataURL } from "@/utils/file"; import { BillingStatus } from "@/billing/billingApi"; +// Import MODEL_CONFIG to check vision capabilities +// TODO: Consider extracting to shared constants file +const MODEL_CONFIG: Record = { + "leon-se/gemma-3-27b-it-fp8-dynamic": { + supportsVision: true + } +}; + type ChatPhase = "idle" | "streaming" | "persisting"; interface UseChatSessionOptions { @@ -148,16 +156,25 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { return; } - // Handle images for Gemma model - const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; + // Handle images for vision-capable models + const modelSupportsVision = MODEL_CONFIG[model]?.supportsVision || false; let userMessage: ChatMessage; - if (isGemma && images && images.length > 0) { + if (modelSupportsVision && images && images.length > 0) { const parts: ChatContentPart[] = [{ type: "text", text: content }]; for (const file of images) { - const url = await fileToDataURL(file); - parts.push({ type: "image_url", image_url: { url } }); + try { + const url = await fileToDataURL(file); + parts.push({ type: "image_url", image_url: { url } }); + } catch (error) { + console.error("[useChatSession] Failed to convert image to data URL:", error); + // Continue with other images instead of failing the entire operation + // TODO: Consider surfacing this error to the user in the future + continue; + } } + // If we have at least text content (and potentially some images), create multimodal message + // If no images were successfully processed, the message will just have text userMessage = { role: "user", content: parts }; } else { userMessage = { role: "user", content }; From 8a6c8c5808bafb713036820c79f610589ce5e07f Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Jun 2025 15:44:13 -0500 Subject: [PATCH 12/44] feat: Add document upload support with OpenSecret SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrate OpenSecret document upload API for text extraction - Support PDF, Word, Excel, PowerPoint, RTF, and text files - Strip image markdown tags from document content before sending to LLM - Add modern UI with square document preview button - Display documents in markdown with interactive dialog viewer - Handle long filenames with truncation and tooltips - Maintain full JSON structure for frontend parsing - Add proper error handling for file size and auth limits 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/bun.lock | 4 +- frontend/package.json | 2 +- frontend/src/components/ChatBox.tsx | 179 +++++++++++++++++++-- frontend/src/components/markdown.tsx | 164 ++++++++++++++++++- frontend/src/hooks/useChatSession.ts | 22 ++- frontend/src/routes/_auth.chat.$chatId.tsx | 16 +- frontend/src/routes/index.tsx | 23 ++- frontend/src/state/LocalStateContextDef.ts | 5 + 8 files changed, 389 insertions(+), 26 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index ad1399f7..2036ef65 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,7 +4,7 @@ "": { "name": "maple", "dependencies": { - "@opensecret/react": "1.3.6", + "@opensecret/react": "1.3.7", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -214,7 +214,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@1.3.6", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-YGAmUArtSCcqSmSSNlFERwfyBxyb5sMCmkCTXfuAcuQVNeBbw04qgrJk9RCv4hQ+KlxU9MlmFRZY74Fbn/O+Jg=="], + "@opensecret/react": ["@opensecret/react@1.3.7", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-jbQ3saW/sEA2XdxeKstPWh+btmQaeTVn+7+dqGDc3YTgQVIg/qCpgeoGXPpXS3yIBgTyLk1s5dcpkNZ6iLt0Hg=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "@peculiar/asn1-x509-attr": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg=="], diff --git a/frontend/package.json b/frontend/package.json index a28e1d7d..568fbc6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "mdast-util-gfm-autolink-literal": "2.0.0" }, "dependencies": { - "@opensecret/react": "1.3.6", + "@opensecret/react": "1.3.7", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 5a862e09..f8ed9244 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -1,4 +1,4 @@ -import { CornerRightUp, Bot, ImageIcon, X } from "lucide-react"; +import { CornerRightUp, Bot, ImageIcon, X, FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useEffect, useRef, useState } from "react"; @@ -11,6 +11,23 @@ import { Route as ChatRoute } from "@/routes/_auth.chat.$chatId"; import { ChatMessage } from "@/state/LocalStateContext"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { ModelSelector } from "@/components/ModelSelector"; +import { useOpenSecret } from "@opensecret/react"; +import type { DocumentResponse } from "@opensecret/react"; + +interface ParsedDocument { + document: { + filename: string; + md_content: string | null; + json_content: string | null; + html_content: string | null; + text_content: string | null; + doctags_content: string | null; + }; + status: string; + errors: unknown[]; + processing_time: number; + timings: Record; +} // Rough token estimation function function estimateTokenCount(text: string): number { @@ -132,7 +149,13 @@ export default function Component({ onCompress, isSummarizing = false }: { - onSubmit: (input: string, systemPrompt?: string, images?: File[]) => void; + onSubmit: ( + input: string, + systemPrompt?: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) => void; startTall?: boolean; messages?: ChatMessage[]; isStreaming?: boolean; @@ -148,7 +171,16 @@ export default function Component({ const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; const [images, setImages] = useState([]); + const [uploadedDocument, setUploadedDocument] = useState<{ + original: DocumentResponse; + parsed: ParsedDocument; + cleanedText: string; + } | null>(null); + const [isUploadingDocument, setIsUploadingDocument] = useState(false); + const [documentError, setDocumentError] = useState(null); const fileInputRef = useRef(null); + const documentInputRef = useRef(null); + const os = useOpenSecret(); const handleAddImages = (e: React.ChangeEvent) => { if (!e.target.files) return; @@ -166,6 +198,70 @@ export default function Component({ }; const removeImage = (idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)); + + const handleDocumentUpload = async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + + const file = e.target.files[0]; + setIsUploadingDocument(true); + setDocumentError(null); + + try { + const result = await os.uploadDocument(file); + + // Parse the JSON response + const parsed = JSON.parse(result.text) as ParsedDocument; + + // Extract content with fallbacks (currently not used since we pass the full JSON) + // const content = + // parsed.document.md_content || + // parsed.document.json_content || + // parsed.document.html_content || + // parsed.document.text_content || + // parsed.document.doctags_content || + // ""; + + // Create a cleaned version of the parsed document with image tags stripped from md_content + const cleanedParsed = { + ...parsed, + document: { + ...parsed.document, + md_content: parsed.document.md_content + ? parsed.document.md_content.replace(/!\[Image\]\([^)]+\)/g, "") + : parsed.document.md_content + } + }; + + setUploadedDocument({ + original: result, + parsed: parsed, + cleanedText: JSON.stringify(cleanedParsed) // Store the cleaned JSON as a string + }); + } catch (error) { + console.error("Document upload failed:", error); + if (error instanceof Error) { + if (error.message.includes("exceeds maximum limit")) { + setDocumentError("File too large. Maximum size is 10MB."); + } else if (error.message.includes("401")) { + setDocumentError("Authentication required. Please log in to upload documents."); + } else if (error.message.includes("403")) { + setDocumentError("Usage limit exceeded. Please upgrade your plan."); + } else { + setDocumentError("Failed to process document. Please try again."); + } + } else { + setDocumentError("An unexpected error occurred."); + } + } finally { + setIsUploadingDocument(false); + if (e.target) e.target.value = ""; + } + }; + + const removeDocument = () => { + setUploadedDocument(null); + setDocumentError(null); + }; const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); const systemPromptRef = useRef(null); @@ -228,10 +324,25 @@ export default function Component({ onSubmit( inputValue.trim(), isFirstMessage ? systemPromptValue.trim() || undefined : undefined, - images + images, + uploadedDocument?.cleanedText, // Now contains the full JSON with cleaned md_content + uploadedDocument + ? { + filename: uploadedDocument.parsed.document.filename, + fullContent: + uploadedDocument.parsed.document.md_content || + uploadedDocument.parsed.document.json_content || + uploadedDocument.parsed.document.html_content || + uploadedDocument.parsed.document.text_content || + uploadedDocument.parsed.document.doctags_content || + "" + } + : undefined ); setInputValue(""); setImages([]); + setUploadedDocument(null); + setDocumentError(null); // Re-focus input after submitting setTimeout(() => { @@ -426,20 +537,48 @@ export default function Component({ } }} > - {images.length > 0 && ( -
- {images.map((f, i) => ( -
- + {(images.length > 0 || uploadedDocument || documentError) && ( +
+ {images.length > 0 && ( +
+ {images.map((f, i) => ( +
+ + +
+ ))} +
+ )} + {uploadedDocument && ( +
+ + + {uploadedDocument.parsed.document.filename} ( + {Math.round(uploadedDocument.original.size / 1024)}KB) +
- ))} + )} + {documentError && ( +
+ {documentError} +
+ )}
)}
+ + + + + {documentData.document.filename} + +
+ +
+
+
+ + ); +} + +function parseDocumentJson(text: string): DocumentData | null { + // Try to find JSON that looks like a document + const jsonMatch = text.match( + /\{"document":\{"filename":[^}]+.*?\}\}(?:,"status":"success"[^}]*\})?/s + ); + if (jsonMatch) { + try { + return JSON.parse(jsonMatch[0]) as DocumentData; + } catch { + // If partial match fails, try to extract the full JSON + const startIndex = text.indexOf('{"document":'); + if (startIndex !== -1) { + // Find the matching closing brace + let braceCount = 0; + let endIndex = startIndex; + for (let i = startIndex; i < text.length; i++) { + if (text[i] === "{") braceCount++; + if (text[i] === "}") braceCount--; + if (braceCount === 0) { + endIndex = i + 1; + break; + } + } + try { + return JSON.parse(text.substring(startIndex, endIndex)) as DocumentData; + } catch (e2) { + console.error("Failed to parse document JSON:", e2); + } + } + } + } + return null; +} + +function parseContentWithDocuments( + content: string +): Array<{ type: "text" | "document"; content: string | DocumentData }> { + const parts: Array<{ type: "text" | "document"; content: string | DocumentData }> = []; + + // Check if content starts with "Here is a document:" and contains JSON + if (content.startsWith("Here is a document:") && content.includes('{"document":')) { + const jsonStartIndex = content.indexOf('{"document":'); + const beforeJson = content.substring(0, jsonStartIndex).trim(); + + // Add the "Here is a document:" text + if (beforeJson) { + parts.push({ type: "text", content: beforeJson }); + } + + // Try to parse the document JSON + const documentData = parseDocumentJson(content.substring(jsonStartIndex)); + if (documentData) { + parts.push({ type: "document", content: documentData }); + + // Find any text after the JSON + const jsonMatch = content.substring(jsonStartIndex).match(/\{"document":[\s\S]*?\}\s*\}/); + if (jsonMatch) { + const afterJsonIndex = jsonStartIndex + jsonMatch[0].length; + const afterJson = content.substring(afterJsonIndex).trim(); + if (afterJson) { + parts.push({ type: "text", content: afterJson }); + } + } + } else { + // If parsing failed, just show as text + parts.push({ type: "text", content: content }); + } + } else { + // No document detected, treat as regular text + parts.push({ type: "text", content: content }); + } + + return parts; +} + function MarkdownWithThinking({ content, loading = false, @@ -396,7 +534,29 @@ function MarkdownWithThinking({ /> ); } else { - return ; + // Parse content for documents + const contentParts = parseContentWithDocuments(part.content); + return ( + + {contentParts.map((contentPart, partIndex) => { + if (contentPart.type === "document") { + return ( + + ); + } else { + return ( + + ); + } + })} + + ); } })} diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index 3c9dbc3f..05edda93 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -147,7 +147,12 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { ); const appendUserMessage = useCallback( - async (content: string, images?: File[]) => { + async ( + content: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) => { if (phase !== "idle") { return; } @@ -160,8 +165,14 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { const modelSupportsVision = MODEL_CONFIG[model]?.supportsVision || false; let userMessage: ChatMessage; + // If document text is provided, prepend it to the content + let finalContent = content; + if (documentText) { + finalContent = `Here is a document:\n\n${documentText}\n\n${content}`; + } + if (modelSupportsVision && images && images.length > 0) { - const parts: ChatContentPart[] = [{ type: "text", text: content }]; + const parts: ChatContentPart[] = [{ type: "text", text: finalContent }]; for (const file of images) { try { const url = await fileToDataURL(file); @@ -177,7 +188,12 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { // If no images were successfully processed, the message will just have text userMessage = { role: "user", content: parts }; } else { - userMessage = { role: "user", content }; + userMessage = { role: "user", content: finalContent }; + } + + // Add document metadata if provided + if (documentMetadata) { + userMessage.document = documentMetadata; } // Check again after async operations to prevent double execution diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 4722d140..6ebb667c 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -47,14 +47,14 @@ function renderContent(content: ChatMessage["content"], chatId: string) { ); } -function UserMessage({ text, chatId }: { text: ChatMessage["content"]; chatId: string }) { +function UserMessage({ message, chatId }: { message: ChatMessage; chatId: string }) { return (
-
{renderContent(text, chatId)}
+
{renderContent(message.content, chatId)}
); @@ -356,12 +356,18 @@ function ChatComponent() { }, [localChat.messages.length, isLoading]); const sendMessage = useCallback( - async (input: string, systemPrompt?: string, images?: File[]) => { + async ( + input: string, + systemPrompt?: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) => { // Handle system prompt if provided const messageContent = systemPrompt ? `[System: ${systemPrompt}]\n\n${input}` : input; // Use the appendUserMessage from the hook - await appendUserMessage(messageContent, images); + await appendUserMessage(messageContent, images, documentText, documentMetadata); // Scroll to bottom after sending requestAnimationFrame(() => { @@ -520,7 +526,7 @@ END OF INSTRUCTIONS`; } /> )} - {message.role === "user" && } + {message.role === "user" && } {message.role === "assistant" && ( setIsSidebarOpen((prev) => !prev), []); - async function handleSubmit(input: string, systemPrompt?: string, images?: File[]) { + async function handleSubmit( + input: string, + systemPrompt?: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) { if (input.trim() === "") return; - localState.setUserPrompt(input.trim()); + + // If document text is provided, prepend it to the input + const finalInput = documentText + ? `Here is a document:\n\n${documentText}\n\n${input.trim()}` + : input.trim(); + + localState.setUserPrompt(finalInput); localState.setSystemPrompt(systemPrompt?.trim() || null); localState.setUserImages(images || []); + + // Store document metadata if provided (we'll need to add this to LocalState) + if (documentMetadata) { + // For now, we'll include it in the prompt until we add proper document storage + // TODO: Add document metadata to LocalState + } + const id = await localState.addChat(); navigate({ to: "/chat/$chatId", params: { chatId: id } }); } diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index 7ce5ae41..19abef5b 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -15,6 +15,11 @@ export type ChatMessage = { role: "user" | "assistant" | "system"; /** plain text for normal models, or multimodal array for multimodal models */ content: string | ChatContentPart[]; + /** Optional document attachment for user messages */ + document?: { + filename: string; + fullContent: string; + }; }; export type Chat = { From 6b960a27d742ba5f2b9756ac59e680f2c20c34cb Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 18:38:57 +0000 Subject: [PATCH 13/44] security: add rehype-sanitize to prevent XSS in document preview - Add rehype-sanitize dependency to package.json - Import and use RehypeSanitize plugin in MarkdownContent component - Prevents XSS attacks from malicious HTML/JS in user-uploaded documents - Maintains legitimate markdown rendering while blocking dangerous content Co-authored-by: Anthony --- frontend/bun.lock | 5 +++++ frontend/package.json | 1 + frontend/src/components/markdown.tsx | 2 ++ 3 files changed, 8 insertions(+) diff --git a/frontend/bun.lock b/frontend/bun.lock index 2036ef65..12e6208f 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -30,6 +30,7 @@ "react-markdown": "^9.0.1", "rehype-highlight": "^7.0.0", "rehype-katex": "^7.0.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", @@ -730,6 +731,8 @@ "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.2", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg=="], "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], @@ -1032,6 +1035,8 @@ "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], diff --git a/frontend/package.json b/frontend/package.json index 568fbc6d..e732bb81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "react-markdown": "^9.0.1", "rehype-highlight": "^7.0.0", "rehype-katex": "^7.0.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index ea580c40..2de399d4 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -6,6 +6,7 @@ import RemarkBreaks from "remark-breaks"; import RehypeKatex from "rehype-katex"; import RemarkGfm from "remark-gfm"; import RehypeHighlight from "rehype-highlight"; +import RehypeSanitize from "rehype-sanitize"; import { useRef, useState, RefObject, useEffect, useMemo } from "react"; import React from "react"; import { Button } from "./ui/button"; @@ -333,6 +334,7 @@ function MarkDownContentToMemo(props: { content: string }) { remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]} rehypePlugins={[ RehypeKatex, + RehypeSanitize, [ RehypeHighlight, { From a6e278c6121497cdd4ea1def211c14f35a90091c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 17 Jun 2025 14:59:34 -0500 Subject: [PATCH 14/44] Fix document parsing logic and icon --- frontend/src/components/markdown.tsx | 44 ++++++++++------------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 2de399d4..4fcc7657 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -10,7 +10,7 @@ import RehypeSanitize from "rehype-sanitize"; import { useRef, useState, RefObject, useEffect, useMemo } from "react"; import React from "react"; import { Button } from "./ui/button"; -import { Check, Copy, ChevronDown, ChevronRight, Brain, FileTextIcon } from "lucide-react"; +import { Check, Copy, ChevronDown, ChevronRight, Brain, FileText } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; async function copyToClipboard(text: string) { @@ -406,7 +406,7 @@ function DocumentPreview({ documentData }: { documentData: DocumentData }) { onClick={() => setIsOpen(true)} title={documentData.document.filename} > - + {displayFilename}
@@ -426,37 +426,25 @@ function DocumentPreview({ documentData }: { documentData: DocumentData }) { } function parseDocumentJson(text: string): DocumentData | null { - // Try to find JSON that looks like a document - const jsonMatch = text.match( - /\{"document":\{"filename":[^}]+.*?\}\}(?:,"status":"success"[^}]*\})?/s - ); - if (jsonMatch) { - try { - return JSON.parse(jsonMatch[0]) as DocumentData; - } catch { - // If partial match fails, try to extract the full JSON - const startIndex = text.indexOf('{"document":'); - if (startIndex !== -1) { - // Find the matching closing brace - let braceCount = 0; - let endIndex = startIndex; - for (let i = startIndex; i < text.length; i++) { - if (text[i] === "{") braceCount++; - if (text[i] === "}") braceCount--; - if (braceCount === 0) { - endIndex = i + 1; - break; - } - } + const start = text.indexOf('{"document":'); + if (start === -1) return null; + + let depth = 0; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === "{") depth++; + if (ch === "}") { + if (--depth === 0) { try { - return JSON.parse(text.substring(startIndex, endIndex)) as DocumentData; - } catch (e2) { - console.error("Failed to parse document JSON:", e2); + return JSON.parse(text.slice(start, i + 1)) as DocumentData; + } catch (e) { + console.error("Document JSON parse error", e); + return null; } } } } - return null; + return null; // incomplete JSON – wait for more chunks } function parseContentWithDocuments( From 8d072ae4555711574d1855951a4342993c4046d2 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 17 Jun 2025 15:41:24 -0500 Subject: [PATCH 15/44] refactor: use shared MODEL_CONFIG for vision capability checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hard-coded model string comparison with imported MODEL_CONFIG from useChatSession hook to improve maintainability and consistency when adding future vision-capable models. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 3 ++- frontend/src/hooks/useChatSession.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index f8ed9244..d2243aef 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -13,6 +13,7 @@ import { useNavigate, useRouter } from "@tanstack/react-router"; import { ModelSelector } from "@/components/ModelSelector"; import { useOpenSecret } from "@opensecret/react"; import type { DocumentResponse } from "@opensecret/react"; +import { MODEL_CONFIG } from "@/hooks/useChatSession"; interface ParsedDocument { document: { @@ -169,7 +170,7 @@ export default function Component({ useLocalState(); const { model } = useLocalState(); - const isGemma = model === "leon-se/gemma-3-27b-it-fp8-dynamic"; + const isGemma = MODEL_CONFIG[model]?.supportsVision || false; const [images, setImages] = useState([]); const [uploadedDocument, setUploadedDocument] = useState<{ original: DocumentResponse; diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index 05edda93..a40a1e8c 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -7,7 +7,7 @@ import { BillingStatus } from "@/billing/billingApi"; // Import MODEL_CONFIG to check vision capabilities // TODO: Consider extracting to shared constants file -const MODEL_CONFIG: Record = { +export const MODEL_CONFIG: Record = { "leon-se/gemma-3-27b-it-fp8-dynamic": { supportsVision: true } From d2304c7ca186c2dceceeaad9203279239a3c965b Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 10:05:09 -0500 Subject: [PATCH 16/44] fix: add opacity-50 to Camera icon for visual consistency with Lock icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 36854495..5ab6072b 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -189,7 +189,7 @@ export function ModelSelector({ } if (config.supportsVision) { - elements.push(); + elements.push(); } } else { // Unknown models: show model ID with "Coming Soon" badge From 7315108e59ef8577164870b896f563f9279f709c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 10:08:54 -0500 Subject: [PATCH 17/44] fix: remove type assertion and properly handle multimodal content in chat summarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed type assertion that bypassed TypeScript safety - Convert multimodal content to string representation for summarization - Images are represented as "[image]" placeholder in summaries 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/routes/_auth.chat.$chatId.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 6ebb667c..8c123766 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -410,16 +410,25 @@ END OF INSTRUCTIONS`; const summarizationMessages = [ { role: "system" as const, content: summarizerSystem }, - ...localChat.messages + ...localChat.messages.map((msg) => { + // Convert content to string for summarization + const content = + typeof msg.content === "string" + ? msg.content + : msg.content.map((part) => (part.type === "text" ? part.text : "[image]")).join(" "); + + return { + role: msg.role, + content: content + }; + }) ]; // 2. Stream the summary let summary = ""; const stream = openai.beta.chat.completions.stream({ model: DEFAULT_MODEL_ID, // Use the default model instead of user selected model - messages: summarizationMessages as Parameters< - typeof openai.beta.chat.completions.stream - >[0]["messages"], + messages: summarizationMessages, temperature: 0.3, max_tokens: 600, stream: true From afe785c78a5e20650e9c0444f7d96ee688185687 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 10:16:20 -0500 Subject: [PATCH 18/44] fix: prevent memory leaks by properly revoking object URLs for uploaded images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store object URLs in a Map to track them properly - Revoke URLs when images are removed - Revoke all URLs when component unmounts - Clean up URLs after form submission 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 42 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index d2243aef..b32de972 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -172,6 +172,7 @@ export default function Component({ const isGemma = MODEL_CONFIG[model]?.supportsVision || false; const [images, setImages] = useState([]); + const [imageUrls, setImageUrls] = useState>(new Map()); const [uploadedDocument, setUploadedDocument] = useState<{ original: DocumentResponse; parsed: ParsedDocument; @@ -195,10 +196,33 @@ export default function Component({ console.warn("Some files were skipped. Only JPEG, PNG, and WebP images are supported."); } + // Create object URLs for the new images + const newUrlMap = new Map(imageUrls); + validFiles.forEach((file) => { + if (!newUrlMap.has(file)) { + newUrlMap.set(file, URL.createObjectURL(file)); + } + }); + setImageUrls(newUrlMap); setImages((prev) => [...prev, ...validFiles]); }; - const removeImage = (idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)); + const removeImage = (idx: number) => { + setImages((prev) => { + const fileToRemove = prev[idx]; + // Revoke the object URL when removing the image + const url = imageUrls.get(fileToRemove); + if (url) { + URL.revokeObjectURL(url); + setImageUrls((prevUrls) => { + const newUrls = new Map(prevUrls); + newUrls.delete(fileToRemove); + return newUrls; + }); + } + return prev.filter((_, i) => i !== idx); + }); + }; const handleDocumentUpload = async (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; @@ -341,7 +365,12 @@ export default function Component({ : undefined ); setInputValue(""); + + // Clean up image URLs when clearing images + imageUrls.forEach((url) => URL.revokeObjectURL(url)); + setImageUrls(new Map()); setImages([]); + setUploadedDocument(null); setDocumentError(null); @@ -463,6 +492,14 @@ export default function Component({ return () => clearTimeout(timer); }, [chatId, isStreaming, isInputDisabled]); // Re-run when chat ID changes, streaming completes, or input state changes + // Cleanup effect for object URLs + useEffect(() => { + return () => { + // Revoke all object URLs when component unmounts + imageUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [imageUrls]); + // No longer need token calculation or plan type check since we removed the hard limit // Just keeping the TokenWarning component which handles its own calculations const placeholderText = (() => { @@ -545,8 +582,9 @@ export default function Component({ {images.map((f, i) => (
{`Uploaded From 718d75fc0d244f2436fa27ea86764f529dc1a931 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 11:05:30 -0500 Subject: [PATCH 24/44] fix: document preview button not showing for document-only messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parseContentWithDocuments function was checking for "Here is a document:" prefix which was removed in a previous commit. Updated the function to detect document JSON directly without requiring any prefix text, allowing the document preview button to appear for messages containing only document data. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/markdown.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 4fcc7657..63848365 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -452,12 +452,12 @@ function parseContentWithDocuments( ): Array<{ type: "text" | "document"; content: string | DocumentData }> { const parts: Array<{ type: "text" | "document"; content: string | DocumentData }> = []; - // Check if content starts with "Here is a document:" and contains JSON - if (content.startsWith("Here is a document:") && content.includes('{"document":')) { + // Check if content contains document JSON + if (content.includes('{"document":')) { const jsonStartIndex = content.indexOf('{"document":'); const beforeJson = content.substring(0, jsonStartIndex).trim(); - // Add the "Here is a document:" text + // Add any text before the JSON if (beforeJson) { parts.push({ type: "text", content: beforeJson }); } From 8701ed560465d468b2557a7f26537218e8a2f8e9 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 11:25:30 -0500 Subject: [PATCH 25/44] fix: improve JSON parsing security in parseDocumentJson MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced vulnerable manual bracket counting with a proper state machine approach that: - Correctly handles escaped characters in strings - Tracks whether we're inside a string to avoid counting brackets in string values - Validates parsed JSON using a comprehensive type guard - Handles malformed JSON more gracefully The new implementation: 1. First attempts to parse the entire string (for complete JSON) 2. Falls back to state machine parsing to find valid JSON boundaries 3. Validates all parsed objects with isDocumentData type guard 4. Properly handles escape sequences and quoted strings This prevents potential exploits from malformed JSON or specially crafted strings. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/markdown.tsx | 100 ++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 63848365..b503142d 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -378,6 +378,42 @@ interface DocumentData { timings: Record; } +// Type guard to validate DocumentData structure +function isDocumentData(obj: unknown): obj is DocumentData { + if (!obj || typeof obj !== 'object') return false; + + const data = obj as Record; + + // Check top-level properties + if (!('document' in data) || !('status' in data) || !('errors' in data) || !('processing_time' in data)) { + return false; + } + + // Check document object structure + const doc = data.document; + if (!doc || typeof doc !== 'object') return false; + + const docObj = doc as Record; + + // Check required document properties + if (!('filename' in docObj) || typeof docObj.filename !== 'string') return false; + + // Check optional content properties (must be string or null) + const contentFields = ['md_content', 'json_content', 'html_content', 'text_content', 'doctags_content']; + for (const field of contentFields) { + if (field in docObj && docObj[field] !== null && typeof docObj[field] !== 'string') { + return false; + } + } + + // Basic type checks for other fields + if (typeof data.status !== 'string') return false; + if (!Array.isArray(data.errors)) return false; + if (typeof data.processing_time !== 'number') return false; + + return true; +} + function DocumentPreview({ documentData }: { documentData: DocumentData }) { const [isOpen, setIsOpen] = useState(false); @@ -429,21 +465,65 @@ function parseDocumentJson(text: string): DocumentData | null { const start = text.indexOf('{"document":'); if (start === -1) return null; + // Try to find a complete JSON object using a more robust approach + // We'll attempt to parse progressively larger substrings + const jsonStart = text.substring(start); + + // First, try to parse the entire remaining string + try { + const parsed = JSON.parse(jsonStart); + // Validate the structure using our type guard + if (isDocumentData(parsed)) { + return parsed; + } + } catch (e) { + // If full parse fails, we need to find the end of the JSON object + } + + // Use a state machine approach to properly handle strings and escapes + let inString = false; + let escapeNext = false; let depth = 0; - for (let i = start; i < text.length; i++) { - const ch = text[i]; - if (ch === "{") depth++; - if (ch === "}") { - if (--depth === 0) { - try { - return JSON.parse(text.slice(start, i + 1)) as DocumentData; - } catch (e) { - console.error("Document JSON parse error", e); - return null; + + for (let i = 0; i < jsonStart.length; i++) { + const ch = jsonStart[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (ch === '\\') { + escapeNext = true; + continue; + } + + if (ch === '"' && !escapeNext) { + inString = !inString; + continue; + } + + if (!inString) { + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + try { + const candidate = jsonStart.substring(0, i + 1); + const parsed = JSON.parse(candidate); + // Validate the structure using our type guard + if (isDocumentData(parsed)) { + return parsed; + } + } catch (e) { + // Continue searching if this wasn't valid JSON + console.error("Document JSON parse error at position", i, e); + } } } } } + return null; // incomplete JSON – wait for more chunks } From 989cc43a14dacbdc7d0199b11b38ae5c8902d6b4 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 11:37:19 -0500 Subject: [PATCH 26/44] feat: add user notifications for image upload failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show inline error messages when unsupported file types are selected - Display error when image conversion to data URL fails during submission - Errors auto-dismiss after 5 seconds for better UX - Follows existing pattern used for document upload errors - Added imageError state in ChatBox for file type validation - Added onImageConversionError callback in useChatSession hook - Pass conversion errors from chat component to ChatBox via prop Also fixed: removed "Here is a document:" prefix from document text that was accidentally left in useChatSession after previous removal. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 22 +++++++++++++++++++--- frontend/src/hooks/useChatSession.ts | 21 +++++++++++++++------ frontend/src/routes/_auth.chat.$chatId.tsx | 9 ++++++++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 8d814473..f71b7463 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -147,7 +147,8 @@ export default function Component({ messages = [], isStreaming = false, onCompress, - isSummarizing = false + isSummarizing = false, + imageConversionError }: { onSubmit: ( input: string, @@ -161,6 +162,7 @@ export default function Component({ isStreaming?: boolean; onCompress?: () => void; isSummarizing?: boolean; + imageConversionError?: string | null; }) { const [inputValue, setInputValue] = useState(""); const [systemPromptValue, setSystemPromptValue] = useState(""); @@ -179,6 +181,7 @@ export default function Component({ } | null>(null); const [isUploadingDocument, setIsUploadingDocument] = useState(false); const [documentError, setDocumentError] = useState(null); + const [imageError, setImageError] = useState(null); const fileInputRef = useRef(null); const documentInputRef = useRef(null); const os = useOpenSecret(); @@ -192,7 +195,12 @@ export default function Component({ ); if (validFiles.length < e.target.files.length) { - console.warn("Some files were skipped. Only JPEG, PNG, and WebP images are supported."); + const skippedCount = e.target.files.length - validFiles.length; + setImageError(`${skippedCount} file(s) skipped. Only JPEG, PNG, and WebP images are supported.`); + // Clear error after 5 seconds + setTimeout(() => setImageError(null), 5000); + } else { + setImageError(null); } // Create object URLs for the new images @@ -221,6 +229,8 @@ export default function Component({ } return prev.filter((_, i) => i !== idx); }); + // Clear any image errors when removing images + setImageError(null); }; const handleDocumentUpload = async (e: React.ChangeEvent) => { @@ -375,6 +385,7 @@ export default function Component({ setUploadedDocument(null); setDocumentError(null); + setImageError(null); // Re-focus input after submitting setTimeout(() => { @@ -577,7 +588,7 @@ export default function Component({ } }} > - {(images.length > 0 || uploadedDocument || documentError) && ( + {(images.length > 0 || uploadedDocument || documentError || imageError || imageConversionError) && (
{images.length > 0 && (
@@ -599,6 +610,11 @@ export default function Component({ ))}
)} + {(imageError || imageConversionError) && ( +
+ {imageError || imageConversionError} +
+ )} {uploadedDocument && (
diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index 694bbb7b..c534b7f4 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -15,8 +15,10 @@ interface UseChatSessionOptions { model: string; } -export function useChatSession(chatId: string, options: UseChatSessionOptions) { - const { getChatById, persistChat, openai, model } = options; +export function useChatSession(chatId: string, options: UseChatSessionOptions & { + onImageConversionError?: (failedCount: number) => void; +}) { + const { getChatById, persistChat, openai, model, onImageConversionError } = options; const queryClient = useQueryClient(); const [phase, setPhase] = useState("idle"); const [optimisticChat, setOptimisticChat] = useState(null); @@ -158,25 +160,32 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions) { const modelSupportsVision = MODEL_CONFIG[model]?.supportsVision || false; let userMessage: ChatMessage; - // If document text is provided, prepend it to the content + // If document text is provided, combine it with the content let finalContent = content; if (documentText) { - finalContent = `Here is a document:\n\n${documentText}\n\n${content}`; + finalContent = documentText + (content ? `\n\n${content}` : ''); } if (modelSupportsVision && images && images.length > 0) { const parts: ChatContentPart[] = [{ type: "text", text: finalContent }]; + let failedImageCount = 0; + for (const file of images) { try { const url = await fileToDataURL(file); parts.push({ type: "image_url", image_url: { url } }); } catch (error) { console.error("[useChatSession] Failed to convert image to data URL:", error); - // Continue with other images instead of failing the entire operation - // TODO: Consider surfacing this error to the user in the future + failedImageCount++; continue; } } + + // Notify about failed conversions + if (failedImageCount > 0 && onImageConversionError) { + onImageConversionError(failedImageCount); + } + // If we have at least text content (and potentially some images), create multimodal message // If no images were successfully processed, the message will just have text userMessage = { role: "user", content: parts }; diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 8c123766..f23f910a 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -180,6 +180,7 @@ function ChatComponent() { const isMobile = useIsMobile(); const [isSummarizing, setIsSummarizing] = useState(false); + const [imageConversionError, setImageConversionError] = useState(null); const chatContainerRef = useRef(null); @@ -193,7 +194,12 @@ function ChatComponent() { getChatById, persistChat, openai, - model + model, + onImageConversionError: (failedCount) => { + setImageConversionError(`${failedCount} image(s) failed to process. Please try again.`); + // Clear error after 5 seconds + setTimeout(() => setImageConversionError(null), 5000); + } }); // Handle initial user prompt - using a ref to prevent double execution @@ -578,6 +584,7 @@ END OF INSTRUCTIONS`; isStreaming={isLoading || isPersisting || isSummarizing} onCompress={compressChat} isSummarizing={isSummarizing} + imageConversionError={imageConversionError} />
From 0000a327f51402086414184ffe5e8add74ce0e70 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 11:39:55 -0500 Subject: [PATCH 27/44] refactor: use CSS text-overflow instead of manual truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced hardcoded filename truncation logic with CSS text-overflow: - Removed manual substring(0, 9) + "..." logic - Leveraged Tailwind's truncate utility class (applies text-overflow: ellipsis) - Added overflow-hidden to button to ensure proper containment - Added w-full and text-center to span for better text alignment - Added px-1 for small horizontal padding This makes the filename display responsive to button size changes and provides a cleaner, more maintainable solution. The full filename is still available in the title attribute for tooltip display. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/markdown.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index b503142d..a7f66d2e 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -426,24 +426,20 @@ function DocumentPreview({ documentData }: { documentData: DocumentData }) { documentData.document.doctags_content || "No content available"; - // Truncate filename if too long for the square button - const displayFilename = - documentData.document.filename.length > 12 - ? documentData.document.filename.substring(0, 9) + "..." - : documentData.document.filename; - return ( <>
From 2d5e52e848d376dc6ffe7586ca19807fbd3bb4d1 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 19 Jun 2025 11:53:01 -0500 Subject: [PATCH 28/44] feat: add file size validation and improved upload indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File size validation: - Documents limited to 1MB (shows actual file size in error) - Images limited to 5MB per image - Validation happens before upload to save bandwidth - Clear error messages with actual vs allowed sizes Improved loading indicators: - Replaced subtle pulsing FileText icon with spinning Loader2 icon - Added "Processing document..." message in upload area - Loading state shows both in button and upload area for visibility - Added fade-in animation for smooth appearance - Updated button tooltip during processing UI improvements: - Document upload button now hides after successful upload - Enforces one document at a time (multiple images still allowed) - Cleaner interface with less clutter The loading indicator is now obvious without being too flashy, using standard spinning loader pattern that users recognize. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 91 +++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index f71b7463..e0386ecc 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -1,4 +1,4 @@ -import { CornerRightUp, Bot, ImageIcon, X, FileText } from "lucide-react"; +import { CornerRightUp, Bot, ImageIcon, X, FileText, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useEffect, useRef, useState } from "react"; @@ -190,13 +190,34 @@ export default function Component({ if (!e.target.files) return; const supportedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; - const validFiles = Array.from(e.target.files).filter((file) => - supportedTypes.includes(file.type.toLowerCase()) - ); + const maxSizeInBytes = 5 * 1024 * 1024; // 5MB for images + const errors: string[] = []; + + const validFiles = Array.from(e.target.files).filter((file) => { + // Check file type + if (!supportedTypes.includes(file.type.toLowerCase())) { + return false; + } + + // Check file size + if (file.size > maxSizeInBytes) { + const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); + errors.push(`${file.name} is too large (${sizeInMB}MB)`); + return false; + } + + return true; + }); if (validFiles.length < e.target.files.length) { const skippedCount = e.target.files.length - validFiles.length; - setImageError(`${skippedCount} file(s) skipped. Only JPEG, PNG, and WebP images are supported.`); + const typeErrors = e.target.files.length - validFiles.length - errors.length; + + if (errors.length > 0) { + setImageError(`${errors.join(", ")}. Max size is 5MB per image.`); + } else if (typeErrors > 0) { + setImageError(`${skippedCount} file(s) skipped. Only JPEG, PNG, and WebP images are supported.`); + } // Clear error after 5 seconds setTimeout(() => setImageError(null), 5000); } else { @@ -237,6 +258,16 @@ export default function Component({ if (!e.target.files || e.target.files.length === 0) return; const file = e.target.files[0]; + + // Check file size (1MB limit = 1024 * 1024 bytes) + const maxSizeInBytes = 1 * 1024 * 1024; // 1MB + if (file.size > maxSizeInBytes) { + const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); + setDocumentError(`File too large (${sizeInMB}MB). Maximum size is 1MB.`); + e.target.value = ''; // Reset input + return; + } + setIsUploadingDocument(true); setDocumentError(null); @@ -615,6 +646,12 @@ export default function Component({ {imageError || imageConversionError}
)} + {isUploadingDocument && !uploadedDocument && ( +
+ + Processing document... +
+ )} {uploadedDocument && (
@@ -692,24 +729,32 @@ export default function Component({ )} - - + {!uploadedDocument && ( + <> + + + + )} @@ -663,6 +666,7 @@ export default function Component({ type="button" onClick={removeDocument} className="text-muted-foreground hover:text-foreground" + aria-label="Remove document" > @@ -724,6 +728,7 @@ export default function Component({ variant="ghost" className="ml-2" onClick={() => fileInputRef.current?.click()} + aria-label="Upload images" > @@ -746,6 +751,7 @@ export default function Component({ onClick={() => documentInputRef.current?.click()} disabled={isUploadingDocument} title={isUploadingDocument ? "Processing document..." : "Upload document"} + aria-label={isUploadingDocument ? "Processing document..." : "Upload document"} > {isUploadingDocument ? ( @@ -762,6 +768,7 @@ export default function Component({ disabled={ (!inputValue.trim() && images.length === 0 && !uploadedDocument) || isSubmitDisabled } + aria-label="Send message" > diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index a7f66d2e..d5a9f4ca 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -435,6 +435,7 @@ function DocumentPreview({ documentData }: { documentData: DocumentData }) { className="h-20 w-20 p-2 flex flex-col items-center justify-center gap-1 overflow-hidden" onClick={() => setIsOpen(true)} title={documentData.document.filename} + aria-label={`Preview document: ${documentData.document.filename}`} > From 4e315e92650960f439b9f39552199c03e673fb83 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Jun 2025 12:23:21 -0500 Subject: [PATCH 30/44] Refactor document parsing logic --- frontend/src/components/markdown.tsx | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index d5a9f4ca..1f3f2d92 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -458,7 +458,7 @@ function DocumentPreview({ documentData }: { documentData: DocumentData }) { ); } -function parseDocumentJson(text: string): DocumentData | null { +function parseDocumentJson(text: string): { data: DocumentData; endIndex: number } | null { const start = text.indexOf('{"document":'); if (start === -1) return null; @@ -471,7 +471,7 @@ function parseDocumentJson(text: string): DocumentData | null { const parsed = JSON.parse(jsonStart); // Validate the structure using our type guard if (isDocumentData(parsed)) { - return parsed; + return { data: parsed, endIndex: start + jsonStart.length }; } } catch (e) { // If full parse fails, we need to find the end of the JSON object @@ -510,7 +510,7 @@ function parseDocumentJson(text: string): DocumentData | null { const parsed = JSON.parse(candidate); // Validate the structure using our type guard if (isDocumentData(parsed)) { - return parsed; + return { data: parsed, endIndex: start + i + 1 }; } } catch (e) { // Continue searching if this wasn't valid JSON @@ -540,18 +540,14 @@ function parseContentWithDocuments( } // Try to parse the document JSON - const documentData = parseDocumentJson(content.substring(jsonStartIndex)); - if (documentData) { - parts.push({ type: "document", content: documentData }); - - // Find any text after the JSON - const jsonMatch = content.substring(jsonStartIndex).match(/\{"document":[\s\S]*?\}\s*\}/); - if (jsonMatch) { - const afterJsonIndex = jsonStartIndex + jsonMatch[0].length; - const afterJson = content.substring(afterJsonIndex).trim(); - if (afterJson) { - parts.push({ type: "text", content: afterJson }); - } + const parseResult = parseDocumentJson(content); + if (parseResult) { + parts.push({ type: "document", content: parseResult.data }); + + // Find any text after the JSON using the endIndex from parsing + const afterJson = content.substring(parseResult.endIndex).trim(); + if (afterJson) { + parts.push({ type: "text", content: afterJson }); } } else { // If parsing failed, just show as text From ef938f6bb81358eab58f4727cc4ac8597f251032 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Jun 2025 14:32:00 -0500 Subject: [PATCH 31/44] fix: iOS image upload using canvas fallback when FileReader unavailable - Add canvas-based image conversion for iOS WebView where FileReader is undefined - Add iOS photo library and camera usage permissions to Info.plist - Resolves image upload failures on iOS native app --- .../maple.xcodeproj/project.pbxproj.backup | 626 ++++++++++++++++++ .../src-tauri/gen/apple/maple_iOS/Info.plist | 4 + frontend/src/utils/file.ts | 48 +- 3 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup diff --git a/frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup b/frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup new file mode 100644 index 00000000..41a99a89 --- /dev/null +++ b/frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup @@ -0,0 +1,626 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 0F4AA8C5111094B8D7B033A4 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E996CAD20B045EF299247BEF /* WebKit.framework */; }; + 23D760C25DBAAA96DF52F98C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AAFD3E024B29F57EA0D88AB5 /* MetalKit.framework */; }; + 27222ACBBD95CA356BDB87AB /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BAF092EB33F2B4FDF867E2ED /* QuartzCore.framework */; }; + 46728F6CD07C626A781543FF /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 938F41BA03AF7FAB80585A60 /* Security.framework */; }; + 4E14944735CA89389849E430 /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F27A6FC4AB3E06D481B1E137 /* libapp.a */; }; + 621A96F6B965E600E714FB6C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B0EAFEFF59AA7CFEF57FA63C /* LaunchScreen.storyboard */; }; + 8F9EDB5679AB5E12D6F1E071 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DACD0B6B9FB86AFF47366EB2 /* UIKit.framework */; }; + 90DEA2E2EC5F95784C9D8B00 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 898D6E7ABC92C3B9A7970DAE /* CoreGraphics.framework */; }; + 9F3ED7EA97AFC22E0B4A6EAF /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = CE20E8A9C4CF149CA9CA485A /* main.mm */; }; + A120CF155DE2B343CEFFB77C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6BF4C7EB12DBD2E253B2741E /* Assets.xcassets */; }; + B79EC8A2A1A869B9F38906ED /* assets in Resources */ = {isa = PBXBuildFile; fileRef = AF5DA3546C7B12BA141E9508 /* assets */; }; + FB98D58EAB479A32F7CA66CA /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C072EB8E1CCC1094712EA7F7 /* Metal.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1094B8CB671809878F533CEC /* lib.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = lib.rs; sourceTree = ""; }; + 19C98BDFCAE8C0B2FE05F13B /* maple_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = maple_iOS.entitlements; sourceTree = ""; }; + 1DB07799A17353B2E32B65D3 /* Maple.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maple.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 6BF4C7EB12DBD2E253B2741E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7BBAB8D5C4E3D05A4F48B6EA /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + 898D6E7ABC92C3B9A7970DAE /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 938F41BA03AF7FAB80585A60 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + AAFD3E024B29F57EA0D88AB5 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + AF5DA3546C7B12BA141E9508 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + B0EAFEFF59AA7CFEF57FA63C /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + BAF092EB33F2B4FDF867E2ED /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + C072EB8E1CCC1094712EA7F7 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + C0A332F21EB65CD4B83229DF /* main.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = main.rs; sourceTree = ""; }; + CE20E8A9C4CF149CA9CA485A /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + D6F6085362AF0C062EDB5810 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + DACD0B6B9FB86AFF47366EB2 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + E996CAD20B045EF299247BEF /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + F27A6FC4AB3E06D481B1E137 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 04F3BEBB04BD927FDC86B255 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4E14944735CA89389849E430 /* libapp.a in Frameworks */, + 90DEA2E2EC5F95784C9D8B00 /* CoreGraphics.framework in Frameworks */, + FB98D58EAB479A32F7CA66CA /* Metal.framework in Frameworks */, + 23D760C25DBAAA96DF52F98C /* MetalKit.framework in Frameworks */, + 27222ACBBD95CA356BDB87AB /* QuartzCore.framework in Frameworks */, + 46728F6CD07C626A781543FF /* Security.framework in Frameworks */, + 8F9EDB5679AB5E12D6F1E071 /* UIKit.framework in Frameworks */, + 0F4AA8C5111094B8D7B033A4 /* WebKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 31943D18956B6AFD5A82AB02 /* maple_iOS */ = { + isa = PBXGroup; + children = ( + D6F6085362AF0C062EDB5810 /* Info.plist */, + 19C98BDFCAE8C0B2FE05F13B /* maple_iOS.entitlements */, + ); + path = maple_iOS; + sourceTree = ""; + }; + 6AE8986BFC081ADF4EF98889 /* Externals */ = { + isa = PBXGroup; + children = ( + ); + path = Externals; + sourceTree = ""; + }; + A278899D6905367F60E00F01 /* Sources */ = { + isa = PBXGroup; + children = ( + E5992A2C52DE443515252068 /* maple */, + ); + path = Sources; + sourceTree = ""; + }; + B58B2AB62FA5D8F41C3A031C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 898D6E7ABC92C3B9A7970DAE /* CoreGraphics.framework */, + F27A6FC4AB3E06D481B1E137 /* libapp.a */, + C072EB8E1CCC1094712EA7F7 /* Metal.framework */, + AAFD3E024B29F57EA0D88AB5 /* MetalKit.framework */, + BAF092EB33F2B4FDF867E2ED /* QuartzCore.framework */, + 938F41BA03AF7FAB80585A60 /* Security.framework */, + DACD0B6B9FB86AFF47366EB2 /* UIKit.framework */, + E996CAD20B045EF299247BEF /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + C312B5C9A1506139452E194B /* Products */ = { + isa = PBXGroup; + children = ( + 1DB07799A17353B2E32B65D3 /* Maple.app */, + ); + name = Products; + sourceTree = ""; + }; + D3140E092D76240F6A157CCE = { + isa = PBXGroup; + children = ( + AF5DA3546C7B12BA141E9508 /* assets */, + 6BF4C7EB12DBD2E253B2741E /* Assets.xcassets */, + B0EAFEFF59AA7CFEF57FA63C /* LaunchScreen.storyboard */, + 6AE8986BFC081ADF4EF98889 /* Externals */, + 31943D18956B6AFD5A82AB02 /* maple_iOS */, + A278899D6905367F60E00F01 /* Sources */, + FF31B258F4FE6CC5B57B4FB8 /* src */, + B58B2AB62FA5D8F41C3A031C /* Frameworks */, + C312B5C9A1506139452E194B /* Products */, + ); + sourceTree = ""; + }; + E5992A2C52DE443515252068 /* maple */ = { + isa = PBXGroup; + children = ( + CE20E8A9C4CF149CA9CA485A /* main.mm */, + F9E1B0C7AF484C8F6312F2C8 /* bindings */, + ); + path = maple; + sourceTree = ""; + }; + F9E1B0C7AF484C8F6312F2C8 /* bindings */ = { + isa = PBXGroup; + children = ( + 7BBAB8D5C4E3D05A4F48B6EA /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + FF31B258F4FE6CC5B57B4FB8 /* src */ = { + isa = PBXGroup; + children = ( + 1094B8CB671809878F533CEC /* lib.rs */, + C0A332F21EB65CD4B83229DF /* main.rs */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 745812EBA5246F9E4CEE1574 /* maple_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 27939507295816FC37580B40 /* Build configuration list for PBXNativeTarget "maple_iOS" */; + buildPhases = ( + 88FF8D973FB9516A2A98CD39 /* Build Rust Code */, + 6D9F08407F22BE108C77276C /* Sources */, + 25104604B2C2A81B04543E68 /* Resources */, + 04F3BEBB04BD927FDC86B255 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = maple_iOS; + productName = maple_iOS; + productReference = 1DB07799A17353B2E32B65D3 /* Maple.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EAA0B11A781B9CD753D1399F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + }; + buildConfigurationList = 2FA6FD4DEFE659C96AAB5E5D /* Build configuration list for PBXProject "maple" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = D3140E092D76240F6A157CCE; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 745812EBA5246F9E4CEE1574 /* maple_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 25104604B2C2A81B04543E68 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A120CF155DE2B343CEFFB77C /* Assets.xcassets in Resources */, + 621A96F6B965E600E714FB6C /* LaunchScreen.storyboard in Resources */, + B79EC8A2A1A869B9F38906ED /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 88FF8D973FB9516A2A98CD39 /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64-sim/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "bun tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 6D9F08407F22BE108C77276C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F3ED7EA97AFC22E0B4A6EAF /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1DE1BEC252AAEF735A07CA8B /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + "arm64-sim", + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = maple_iOS/maple_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "X773Y823TN"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = maple_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple; + PRODUCT_NAME = Maple; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = "arm64 arm64-sim"; + }; + name = debug; + }; + 1E8FE8E2E3063C3FDCD3B5D1 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + 94ECC2E044AA76E166E0866E /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + B90C9BEF4885680BFA986CD2 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + "arm64-sim", + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = maple_iOS/maple_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "X773Y823TN"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = maple_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple; + PRODUCT_NAME = Maple; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = "arm64 arm64-sim"; + }; + name = release; + }; + EA2669D02DADAA11005A7F4B /* local */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = local; + }; + EA2669D12DADAA11005A7F4B /* local */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + "arm64-sim", + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = maple_iOS/maple_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = "X773Y823TN"; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = X773Y823TN; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = maple_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple; + PRODUCT_NAME = Maple; + PROVISIONING_PROFILE_SPECIFIER = "86059ea7-ae8e-44af-8a58-b2ab7c78d299"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "86059ea7-ae8e-44af-8a58-b2ab7c78d299"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = "arm64 arm64-sim"; + }; + name = local; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 27939507295816FC37580B40 /* Build configuration list for PBXNativeTarget "maple_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1DE1BEC252AAEF735A07CA8B /* debug */, + EA2669D12DADAA11005A7F4B /* local */, + B90C9BEF4885680BFA986CD2 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + 2FA6FD4DEFE659C96AAB5E5D /* Build configuration list for PBXProject "maple" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1E8FE8E2E3063C3FDCD3B5D1 /* debug */, + EA2669D02DADAA11005A7F4B /* local */, + 94ECC2E044AA76E166E0866E /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = EAA0B11A781B9CD753D1399F /* Project object */; +} diff --git a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist index a7112f82..f1b651cf 100644 --- a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist +++ b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist @@ -58,5 +58,9 @@ ITSAppUsesNonExemptEncryption + NSPhotoLibraryUsageDescription + Maple needs access to your photo library to upload images to your AI conversations. + NSCameraUsageDescription + Maple needs access to your camera to take photos for your AI conversations. \ No newline at end of file diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts index 800a97ab..6f8fdd18 100644 --- a/frontend/src/utils/file.ts +++ b/frontend/src/utils/file.ts @@ -1,4 +1,50 @@ export function fileToDataURL(file: File): Promise { + // Check if FileReader exists (it doesn't on iOS WebView) + if (typeof FileReader === 'undefined') { + // Use canvas to convert blob to data URL + return new Promise((resolve, reject) => { + const blobUrl = URL.createObjectURL(file); + const img = new Image(); + + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + // Set canvas size to image size + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + + // Draw image to canvas + ctx.drawImage(img, 0, 0); + + // Convert to data URL + const dataUrl = canvas.toDataURL(file.type || 'image/png'); + + // Clean up + URL.revokeObjectURL(blobUrl); + + resolve(dataUrl); + } catch (error) { + URL.revokeObjectURL(blobUrl); + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + reject(new Error('Failed to load image')); + }; + + img.src = blobUrl; + }); + } + + // Standard FileReader approach return new Promise((res, rej) => { const reader = new FileReader(); reader.onload = () => { @@ -12,4 +58,4 @@ export function fileToDataURL(file: File): Promise { reader.onabort = () => rej(new Error("FileReader operation was aborted")); reader.readAsDataURL(file); }); -} +} \ No newline at end of file From 1164b3eb1c40280dd4b28a611714a0c83205a371 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 20 Jun 2025 14:32:56 -0500 Subject: [PATCH 32/44] docs: add iOS build troubleshooting guide and update gitignore --- .gitignore | 3 ++ README.md | 12 +++++ docs/troubleshooting-ios-build.md | 73 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 docs/troubleshooting-ios-build.md diff --git a/.gitignore b/.gitignore index 4c61a2a8..a76bb37f 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ frontend/*.local **/.claude/settings.local.json .repo_ignore + +# iOS build backups +frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup diff --git a/README.md b/README.md index 71e737d1..eddcbc12 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,18 @@ If there's a new version of the enclave pushed to staging or prod, append the ne ## iOS Development +Run in emulator: + +```bash +dotenv -e .env.local -- bun run tauri ios dev 'iPhone 16 Pro' +``` + +Run on a connected phone: + +```bash +dotenv -e .env.local -- bun run tauri ios build +``` + ### Ignoring Local XCode Project Changes To prevent committing automatic changes to the XCode project file during local development: diff --git a/docs/troubleshooting-ios-build.md b/docs/troubleshooting-ios-build.md new file mode 100644 index 00000000..168c08ce --- /dev/null +++ b/docs/troubleshooting-ios-build.md @@ -0,0 +1,73 @@ +# iOS Build Troubleshooting + +## arm64-sim Architecture Error + +### Problem +When building for iOS simulator, you may encounter this error: +``` +clang: error: version '-sim' in target triple 'arm64-apple-ios13.0-simulator-sim' is invalid +``` + +This happens when the Xcode project file incorrectly lists `arm64-sim` as an architecture, causing a duplicate `-sim` suffix in the target triple. + +### Solution + +1. **Edit the Xcode project file** (`frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj`): + + Find and replace all occurrences of: + ``` + ARCHS = ( + arm64, + "arm64-sim", + ); + ``` + + With: + ``` + ARCHS = ( + arm64, + x86_64, + ); + ``` + +2. **Update VALID_ARCHS**: + + Replace: + ``` + VALID_ARCHS = "arm64 arm64-sim"; + ``` + + With: + ``` + VALID_ARCHS = "arm64 x86_64"; + ``` + +3. **Update EXCLUDED_ARCHS**: + + Replace: + ``` + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + ``` + + With: + ``` + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + ``` + +### Important Notes + +- Keep the `arm64-sim` references in library search paths and output paths - these refer to directory names, not architectures +- This issue can reoccur if the Xcode project is regenerated +- Related to Xcode 16.3+ behavior changes with simulator architectures + +### Prevention + +To prevent this issue from recurring: + +1. Avoid regenerating the iOS project unless necessary +2. If you must regenerate, check the project.pbxproj file for incorrect `arm64-sim` architecture entries +3. Consider adding a post-generation script to automatically fix these entries + +### Reference + +This issue is tracked in [tauri-apps/tauri#12882](https://github.com/tauri-apps/tauri/issues/12882) \ No newline at end of file From 3974b03246150450ba11bb4d3a2d4d200cb03385 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:56:07 +0000 Subject: [PATCH 33/44] feat: consolidate image and document upload into single + button with dropdown - Replace separate image/document upload buttons with unified "+" button - Add dropdown menu revealing both upload options when available - Maintain existing vision model restriction for images (Gemma + Pro/Team only) - Apply Pro/Team gatekeeping to both image and document uploads - Show appropriate status text and loading states in dropdown items - Improve UX with cleaner, more discoverable upload interface Co-authored-by: Anthony --- frontend/src/components/ChatBox.tsx | 150 +++++++++++++++++----------- 1 file changed, 90 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 6410e2f5..7db7b65c 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -1,6 +1,12 @@ -import { CornerRightUp, Bot, ImageIcon, X, FileText, Loader2 } from "lucide-react"; +import { CornerRightUp, Bot, ImageIcon, X, FileText, Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; import { useEffect, useRef, useState } from "react"; import { useLocalState } from "@/state/useLocalState"; import { cn, useIsMobile } from "@/utils/utils"; @@ -192,31 +198,33 @@ export default function Component({ const supportedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; const maxSizeInBytes = 5 * 1024 * 1024; // 5MB for images const errors: string[] = []; - + const validFiles = Array.from(e.target.files).filter((file) => { // Check file type if (!supportedTypes.includes(file.type.toLowerCase())) { return false; } - + // Check file size if (file.size > maxSizeInBytes) { const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); errors.push(`${file.name} is too large (${sizeInMB}MB)`); return false; } - + return true; }); if (validFiles.length < e.target.files.length) { const skippedCount = e.target.files.length - validFiles.length; const typeErrors = e.target.files.length - validFiles.length - errors.length; - + if (errors.length > 0) { setImageError(`${errors.join(", ")}. Max size is 5MB per image.`); } else if (typeErrors > 0) { - setImageError(`${skippedCount} file(s) skipped. Only JPEG, PNG, and WebP images are supported.`); + setImageError( + `${skippedCount} file(s) skipped. Only JPEG, PNG, and WebP images are supported.` + ); } // Clear error after 5 seconds setTimeout(() => setImageError(null), 5000); @@ -258,16 +266,16 @@ export default function Component({ if (!e.target.files || e.target.files.length === 0) return; const file = e.target.files[0]; - + // Check file size (1MB limit = 1024 * 1024 bytes) const maxSizeInBytes = 1 * 1024 * 1024; // 1MB if (file.size > maxSizeInBytes) { const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); setDocumentError(`File too large (${sizeInMB}MB). Maximum size is 1MB.`); - e.target.value = ''; // Reset input + e.target.value = ""; // Reset input return; } - + setIsUploadingDocument(true); setDocumentError(null); @@ -360,13 +368,14 @@ export default function Component({ // Check if system prompt can be edited (only for new chats) const canEditSystemPrompt = canUseSystemPrompt && messages.length === 0; - // Check if user has access to vision features (Pro or Team plan) - const hasVisionAccess = + // Check if user has access to Pro/Team features (Pro or Team plan) + const hasProTeamAccess = freshBillingStatus && (freshBillingStatus.product_name?.toLowerCase().includes("pro") || freshBillingStatus.product_name?.toLowerCase().includes("team")); - const canUseVision = isGemma && hasVisionAccess; + const canUseVision = isGemma && hasProTeamAccess; + const canUseDocuments = hasProTeamAccess; const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); @@ -621,7 +630,11 @@ export default function Component({ } }} > - {(images.length > 0 || uploadedDocument || documentError || imageError || imageConversionError) && ( + {(images.length > 0 || + uploadedDocument || + documentError || + imageError || + imageConversionError) && (
{images.length > 0 && (
@@ -712,55 +725,72 @@ export default function Component({ />
- {canUseVision && ( - <> - - - - )} - {!uploadedDocument && ( - <> - - + + + {canUseVision && ( + fileInputRef.current?.click()} + className="flex items-center gap-2" + > + + Upload Images + + {isGemma ? "Vision model" : "Needs vision model"} + + + )} + {canUseDocuments && !uploadedDocument && ( + documentInputRef.current?.click()} + disabled={isUploadingDocument} + className="flex items-center gap-2" + > + {isUploadingDocument ? ( + + ) : ( + + )} + Upload Document + {isUploadingDocument && ( + Processing... + )} + )} - - + + )} + - + {canUseVision && ( fileInputRef.current?.click()} @@ -765,9 +765,6 @@ export default function Component({ > Upload Images - - {isGemma ? "Vision model" : "Needs vision model"} - )} {canUseDocuments && !uploadedDocument && ( diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 2156366e..2b4e81ef 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -226,7 +226,7 @@ export function ModelSelector({ - + {availableModels && Array.isArray(availableModels) && // Sort models: vision-capable first (if images present), then available, then restricted, then disabled diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 1f3f2d92..abbf04e5 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -380,37 +380,48 @@ interface DocumentData { // Type guard to validate DocumentData structure function isDocumentData(obj: unknown): obj is DocumentData { - if (!obj || typeof obj !== 'object') return false; - + if (!obj || typeof obj !== "object") return false; + const data = obj as Record; - + // Check top-level properties - if (!('document' in data) || !('status' in data) || !('errors' in data) || !('processing_time' in data)) { + if ( + !("document" in data) || + !("status" in data) || + !("errors" in data) || + !("processing_time" in data) + ) { return false; } - + // Check document object structure const doc = data.document; - if (!doc || typeof doc !== 'object') return false; - + if (!doc || typeof doc !== "object") return false; + const docObj = doc as Record; - + // Check required document properties - if (!('filename' in docObj) || typeof docObj.filename !== 'string') return false; - + if (!("filename" in docObj) || typeof docObj.filename !== "string") return false; + // Check optional content properties (must be string or null) - const contentFields = ['md_content', 'json_content', 'html_content', 'text_content', 'doctags_content']; + const contentFields = [ + "md_content", + "json_content", + "html_content", + "text_content", + "doctags_content" + ]; for (const field of contentFields) { - if (field in docObj && docObj[field] !== null && typeof docObj[field] !== 'string') { + if (field in docObj && docObj[field] !== null && typeof docObj[field] !== "string") { return false; } } - + // Basic type checks for other fields - if (typeof data.status !== 'string') return false; + if (typeof data.status !== "string") return false; if (!Array.isArray(data.errors)) return false; - if (typeof data.processing_time !== 'number') return false; - + if (typeof data.processing_time !== "number") return false; + return true; } @@ -465,7 +476,7 @@ function parseDocumentJson(text: string): { data: DocumentData; endIndex: number // Try to find a complete JSON object using a more robust approach // We'll attempt to parse progressively larger substrings const jsonStart = text.substring(start); - + // First, try to parse the entire remaining string try { const parsed = JSON.parse(jsonStart); @@ -473,7 +484,7 @@ function parseDocumentJson(text: string): { data: DocumentData; endIndex: number if (isDocumentData(parsed)) { return { data: parsed, endIndex: start + jsonStart.length }; } - } catch (e) { + } catch { // If full parse fails, we need to find the end of the JSON object } @@ -481,28 +492,28 @@ function parseDocumentJson(text: string): { data: DocumentData; endIndex: number let inString = false; let escapeNext = false; let depth = 0; - + for (let i = 0; i < jsonStart.length; i++) { const ch = jsonStart[i]; - + if (escapeNext) { escapeNext = false; continue; } - - if (ch === '\\') { + + if (ch === "\\") { escapeNext = true; continue; } - + if (ch === '"' && !escapeNext) { inString = !inString; continue; } - + if (!inString) { - if (ch === '{') depth++; - else if (ch === '}') { + if (ch === "{") depth++; + else if (ch === "}") { depth--; if (depth === 0) { try { @@ -520,7 +531,7 @@ function parseDocumentJson(text: string): { data: DocumentData; endIndex: number } } } - + return null; // incomplete JSON – wait for more chunks } From f90b746bc83c1bcea15d786a85d9390db8e12609 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 12:06:02 -0500 Subject: [PATCH 35/44] feat: show upload button for all users with Pro upgrade prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upload button now visible for all users regardless of plan - Added "Pro" badges and "Upgrade?" hover text for non-Pro/Team users - Non-Pro users are redirected to /pricing when clicking upload options - Consistent with model selector upgrade behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 79 +++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 03871449..901893ee 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -191,6 +191,7 @@ export default function Component({ const fileInputRef = useRef(null); const documentInputRef = useRef(null); const os = useOpenSecret(); + const navigate = useNavigate(); const handleAddImages = (e: React.ChangeEvent) => { if (!e.target.files) return; @@ -743,8 +744,8 @@ export default function Component({ className="hidden" /> - {/* Consolidated upload button */} - {(canUseVision || (canUseDocuments && !uploadedDocument)) && ( + {/* Consolidated upload button - show for all users */} + {!uploadedDocument && ( - {isGemma && ( - { - if (!canUseVision) { - navigate({ to: "/pricing" }); - } else { - fileInputRef.current?.click(); + { + if (!hasProTeamAccess) { + navigate({ to: "/pricing" }); + } else { + // If not on a vision model, switch to one first + if (!isGemma) { + const visionModelId = findFirstVisionModel(); + if (visionModelId) { + setModel(visionModelId); + } } - }} - className={cn( - "flex items-center gap-2 group", - !canUseVision && "hover:bg-purple-50 dark:hover:bg-purple-950/20" - )} - > - - Upload Images - {!canUseVision && ( - <> - - Pro - - - Upgrade? - - - )} - - )} + fileInputRef.current?.click(); + } + }} + className={cn( + "flex items-center gap-2 group", + !hasProTeamAccess && "hover:bg-purple-50 dark:hover:bg-purple-950/20" + )} + > + + Upload Images + {!hasProTeamAccess && ( + <> + + Pro + + + Upgrade? + + + )} + { if (!canUseDocuments) { diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 2b4e81ef..cd6906bf 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -300,11 +300,6 @@ export function ModelSelector({ Upgrade? )} - {isDisabledDueToImages && ( - - {draftImages.length > 0 ? "Image pending" : "Images in chat"} - - )}
{model === availableModel.id && } diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index c534b7f4..a520ac60 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -15,9 +15,12 @@ interface UseChatSessionOptions { model: string; } -export function useChatSession(chatId: string, options: UseChatSessionOptions & { - onImageConversionError?: (failedCount: number) => void; -}) { +export function useChatSession( + chatId: string, + options: UseChatSessionOptions & { + onImageConversionError?: (failedCount: number) => void; + } +) { const { getChatById, persistChat, openai, model, onImageConversionError } = options; const queryClient = useQueryClient(); const [phase, setPhase] = useState("idle"); @@ -163,13 +166,13 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions & // If document text is provided, combine it with the content let finalContent = content; if (documentText) { - finalContent = documentText + (content ? `\n\n${content}` : ''); + finalContent = documentText + (content ? `\n\n${content}` : ""); } if (modelSupportsVision && images && images.length > 0) { const parts: ChatContentPart[] = [{ type: "text", text: finalContent }]; let failedImageCount = 0; - + for (const file of images) { try { const url = await fileToDataURL(file); @@ -180,12 +183,12 @@ export function useChatSession(chatId: string, options: UseChatSessionOptions & continue; } } - + // Notify about failed conversions if (failedImageCount > 0 && onImageConversionError) { onImageConversionError(failedImageCount); } - + // If we have at least text content (and potentially some images), create multimodal message // If no images were successfully processed, the message will just have text userMessage = { role: "user", content: parts }; diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts index 6f8fdd18..00e1d1ff 100644 --- a/frontend/src/utils/file.ts +++ b/frontend/src/utils/file.ts @@ -1,49 +1,49 @@ export function fileToDataURL(file: File): Promise { // Check if FileReader exists (it doesn't on iOS WebView) - if (typeof FileReader === 'undefined') { + if (typeof FileReader === "undefined") { // Use canvas to convert blob to data URL return new Promise((resolve, reject) => { const blobUrl = URL.createObjectURL(file); const img = new Image(); - + img.onload = () => { try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { - throw new Error('Failed to get canvas context'); + throw new Error("Failed to get canvas context"); } - + // Set canvas size to image size canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; - + // Draw image to canvas ctx.drawImage(img, 0, 0); - + // Convert to data URL - const dataUrl = canvas.toDataURL(file.type || 'image/png'); - + const dataUrl = canvas.toDataURL(file.type || "image/png"); + // Clean up URL.revokeObjectURL(blobUrl); - + resolve(dataUrl); } catch (error) { URL.revokeObjectURL(blobUrl); reject(error); } }; - + img.onerror = () => { URL.revokeObjectURL(blobUrl); - reject(new Error('Failed to load image')); + reject(new Error("Failed to load image")); }; - + img.src = blobUrl; }); } - + // Standard FileReader approach return new Promise((res, rej) => { const reader = new FileReader(); @@ -58,4 +58,4 @@ export function fileToDataURL(file: File): Promise { reader.onabort = () => rej(new Error("FileReader operation was aborted")); reader.readAsDataURL(file); }); -} \ No newline at end of file +} From 2d42992421d2666a6116ca31c4109f6decab11ec Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 12:33:58 -0500 Subject: [PATCH 37/44] chore: update pre-commit hook to check formatting instead of auto-fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed from 'prettier --write' to 'prettier --check' - Commits now fail if code isn't properly formatted - Prevents unstaged changes from auto-formatting during commits - Developers must run 'bun run format' manually before committing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .githooks/pre-commit | 11 ++++++++++- setup-hooks.sh | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 263ed47b..c807ffb0 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -11,6 +11,15 @@ if ! command -v bun &> /dev/null; then exit 1 fi +# Run prettier check +echo "Checking code formatting with Prettier..." +if ! bun run format:check; then + echo "" + echo "Error: Code formatting issues found! Please run 'bun run format' to fix formatting issues." + echo "Run 'cd frontend && bun run format:check' to see the formatting issues." + exit 1 +fi + # Run the build command echo "Running bun build..." if ! bun run build; then @@ -20,5 +29,5 @@ if ! bun run build; then exit 1 fi -echo "Build successful! Proceeding with commit..." +echo "All checks passed! Proceeding with commit..." exit 0 \ No newline at end of file diff --git a/setup-hooks.sh b/setup-hooks.sh index 6c3ee745..2d02abb0 100755 --- a/setup-hooks.sh +++ b/setup-hooks.sh @@ -14,4 +14,6 @@ fi git config core.hooksPath .githooks echo "✅ Git hooks configured successfully!" -echo "The pre-commit hook will now run 'bun run build' before each commit." \ No newline at end of file +echo "The pre-commit hook will now:" +echo " 1. Check code formatting with 'bun run format:check'" +echo " 2. Run 'bun run build' to ensure the project builds" \ No newline at end of file From 892aa054e14697db816eaecb51eda1f107daaf7a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 12:49:46 -0500 Subject: [PATCH 38/44] fix: restore chat draft functionality and remove auto-save on model change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restored draft message persistence when switching between chats - Added refs (lastDraftRef, previousChatIdRef, currentInputRef) for draft tracking - Added useEffect hooks to handle draft loading/saving on chat switches - Removed problematic auto-persist when model selector changes - Model will now only be saved when messages are actually sent This fixes the issue where chats were being saved to backend unnecessarily when switching models, and restores the ability to keep draft messages when navigating between chats. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 72 +++++++++++----------- frontend/src/routes/_auth.chat.$chatId.tsx | 12 +--- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 52188f37..4baf52ae 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -469,7 +469,40 @@ export default function Component({ }, 0); }; - // Keep currentInputRef in sync with inputValue + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + if (isMobile || e.shiftKey || isStreaming) { + // On mobile, when Shift is pressed, or when streaming, allow newline + return; + } else if (isSubmitDisabled || !inputValue.trim()) { + // Prevent form submission when disabled or empty input + e.preventDefault(); + return; + } else { + // On desktop without Shift and not streaming, submit the form + e.preventDefault(); + handleSubmit(); + } + } + }; + + // Auto-resize effect for main input + useEffect(() => { + if (inputRef.current) { + inputRef.current.style.height = "auto"; + inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; + } + }, [inputValue]); + + // Auto-resize effect for system prompt + useEffect(() => { + if (systemPromptRef.current) { + systemPromptRef.current.style.height = "auto"; + systemPromptRef.current.style.height = `${systemPromptRef.current.scrollHeight}px`; + } + }, [systemPromptValue]); + + // Update current input ref when input value changes useEffect(() => { currentInputRef.current = inputValue; }, [inputValue]); @@ -510,42 +543,9 @@ export default function Component({ } } - // Update previous chat id reference + // 3. Update the previous chat ID previousChatIdRef.current = chatId; - }, [chatId, draftMessages, setDraftMessage, clearDraftMessage, canEditSystemPrompt, messages]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - if (isMobile || e.shiftKey || isStreaming) { - // On mobile, when Shift is pressed, or when streaming, allow newline - return; - } else if (isSubmitDisabled || !inputValue.trim()) { - // Prevent form submission when disabled or empty input - e.preventDefault(); - return; - } else { - // On desktop without Shift and not streaming, submit the form - e.preventDefault(); - handleSubmit(); - } - } - }; - - // Auto-resize effect for main input - useEffect(() => { - if (inputRef.current) { - inputRef.current.style.height = "auto"; - inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; - } - }, [inputValue]); - - // Auto-resize effect for system prompt - useEffect(() => { - if (systemPromptRef.current) { - systemPromptRef.current.style.height = "auto"; - systemPromptRef.current.style.height = `${systemPromptRef.current.scrollHeight}px`; - } - }, [systemPromptValue]); + }, [chatId, draftMessages, setDraftMessage, clearDraftMessage]); // Determine when the submit button should be disabled const isSubmitDisabled = diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index f23f910a..793a0ae5 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -321,16 +321,8 @@ function ChatComponent() { hasSetModelRef.current = false; }, [chatId]); - // Update the chat's model when user changes it - useEffect(() => { - if (hasSetModelRef.current && model !== localChat.model && localChat.id) { - // Update the chat with the new model - const updatedChat = { ...localChat, model }; - persistChat(updatedChat).catch((error) => { - console.error("Failed to update chat model:", error); - }); - } - }, [model, localChat, persistChat]); + // Removed auto-persist on model change to prevent unwanted saves + // The model will be saved with the chat when messages are sent const isLoading = phase === "streaming"; const isPersisting = phase === "persisting"; From 58c444dd262444ffa26c2f355a162e33e0e96d8d Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 12:57:39 -0500 Subject: [PATCH 39/44] Update pricing documentation --- frontend/src/config/pricingConfig.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/config/pricingConfig.tsx b/frontend/src/config/pricingConfig.tsx index e1360b50..41d692c4 100644 --- a/frontend/src/config/pricingConfig.tsx +++ b/frontend/src/config/pricingConfig.tsx @@ -43,7 +43,9 @@ export const PRICING_PLANS: PricingPlan[] = [ }, { text: "Rename Chats", included: true, icon: }, { text: "Gemma 3 27B", included: false, icon: }, - { text: "DeepSeek R1 70B", included: false, icon: } + { text: "DeepSeek R1 70B", included: false, icon: }, + { text: "Image Upload", included: false, icon: }, + { text: "Document Upload", included: false, icon: } ], ctaText: "Start Free" }, @@ -68,7 +70,9 @@ export const PRICING_PLANS: PricingPlan[] = [ icon: }, { text: "Gemma 3 27B", included: true, icon: }, - { text: "DeepSeek R1 70B", included: false, icon: } + { text: "DeepSeek R1 70B", included: false, icon: }, + { text: "Image Upload", included: false, icon: }, + { text: "Document Upload", included: false, icon: } ], ctaText: "Start Chatting" }, @@ -98,8 +102,9 @@ export const PRICING_PLANS: PricingPlan[] = [ included: true, icon: }, + { text: "Image Upload", included: true, icon: }, { - text: "Upcoming Pro-only features", + text: "Document Upload", included: true, icon: } @@ -137,6 +142,12 @@ export const PRICING_PLANS: PricingPlan[] = [ text: "DeepSeek R1 70B", included: true, icon: + }, + { text: "Image Upload", included: true, icon: }, + { + text: "Document Upload", + included: true, + icon: } ], ctaText: "Contact Us" From 9480afa823d1bd100d598442184a3cd5f895f1bd Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 13:15:18 -0500 Subject: [PATCH 40/44] fix: improve upload dropdown UI consistency for free users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ImageIcon with Image icon from lucide-react for better consistency - Add shrink-0 class to prevent icon size reduction - Make dropdown width conditional: w-44 for pro/team, w-56 for free users - Ensures proper spacing for Pro badges and upgrade prompts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 4baf52ae..6c1e5a45 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -1,4 +1,4 @@ -import { CornerRightUp, Bot, ImageIcon, X, FileText, Loader2, Plus } from "lucide-react"; +import { CornerRightUp, Bot, Image, X, FileText, Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { @@ -793,7 +793,10 @@ export default function Component({ - + { if (!hasProTeamAccess) { @@ -814,7 +817,7 @@ export default function Component({ !hasProTeamAccess && "hover:bg-purple-50 dark:hover:bg-purple-950/20" )} > - + Upload Images {!hasProTeamAccess && ( <> @@ -842,9 +845,9 @@ export default function Component({ )} > {isUploadingDocument ? ( - + ) : ( - + )} Upload Document {!canUseDocuments && ( From cf0c90b9bea8c216bcb7b2eda438430f3c22b3ea Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 20:07:54 -0500 Subject: [PATCH 41/44] feat: increase document upload limit from 1MB to 5MB and improve upload UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase maximum document file size from 1MB to 5MB - Fix document upload processing indicator visibility - Remove redundant loading states from dropdown menu - Show processing status in main chatbox area for better visibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/ChatBox.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 6c1e5a45..438c5795 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -304,11 +304,11 @@ export default function Component({ const file = e.target.files[0]; - // Check file size (1MB limit = 1024 * 1024 bytes) - const maxSizeInBytes = 1 * 1024 * 1024; // 1MB + // Check file size (5MB limit = 1024 * 1024 bytes) + const maxSizeInBytes = 5 * 1024 * 1024; // 5MB if (file.size > maxSizeInBytes) { const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); - setDocumentError(`File too large (${sizeInMB}MB). Maximum size is 1MB.`); + setDocumentError(`File too large (${sizeInMB}MB). Maximum size is 5MB.`); e.target.value = ""; // Reset input return; } @@ -351,7 +351,7 @@ export default function Component({ console.error("Document upload failed:", error); if (error instanceof Error) { if (error.message.includes("exceeds maximum limit")) { - setDocumentError("File too large. Maximum size is 10MB."); + setDocumentError("File too large. Maximum size is 5MB."); } else if (error.message.includes("401")) { setDocumentError("Authentication required. Please log in to upload documents."); } else if (error.message.includes("403")) { @@ -668,6 +668,7 @@ export default function Component({ > {(images.length > 0 || uploadedDocument || + isUploadingDocument || documentError || imageError || imageConversionError) && ( @@ -838,17 +839,12 @@ export default function Component({ documentInputRef.current?.click(); } }} - disabled={isUploadingDocument} className={cn( "flex items-center gap-2 group", !canUseDocuments && "hover:bg-purple-50 dark:hover:bg-purple-950/20" )} > - {isUploadingDocument ? ( - - ) : ( - - )} + Upload Document {!canUseDocuments && ( <> @@ -860,9 +856,6 @@ export default function Component({ )} - {isUploadingDocument && canUseDocuments && ( - Processing... - )} From 7d912a3b4bce0475ea0c050bff02651a903de03b Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 30 Jun 2025 21:06:07 -0500 Subject: [PATCH 42/44] feat: migrate document upload to new task-based API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update uploadDocument to uploadDocumentWithPolling for seamless async processing - Remove file size display from uploaded documents for cleaner UI - Enhance processing message to indicate secure processing with time expectation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/bun.lock | 4 ++-- frontend/package.json | 2 +- frontend/src/components/ChatBox.tsx | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index 12e6208f..b864a253 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,7 +4,7 @@ "": { "name": "maple", "dependencies": { - "@opensecret/react": "1.3.7", + "@opensecret/react": "1.3.8", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -215,7 +215,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@1.3.7", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-jbQ3saW/sEA2XdxeKstPWh+btmQaeTVn+7+dqGDc3YTgQVIg/qCpgeoGXPpXS3yIBgTyLk1s5dcpkNZ6iLt0Hg=="], + "@opensecret/react": ["@opensecret/react@1.3.8", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-FIkMvPaIWEKuDjffz2W0EPrwwoiWOTuBMMIXr7zvXBhDJ5HwCZIQBmm9bAMprxWYQrWAqdiy80iBAC7MKIGFiw=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "@peculiar/asn1-x509-attr": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg=="], diff --git a/frontend/package.json b/frontend/package.json index e732bb81..e829fac9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "mdast-util-gfm-autolink-literal": "2.0.0" }, "dependencies": { - "@opensecret/react": "1.3.7", + "@opensecret/react": "1.3.8", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index 438c5795..7d775f2d 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -317,7 +317,7 @@ export default function Component({ setDocumentError(null); try { - const result = await os.uploadDocument(file); + const result = await os.uploadDocumentWithPolling(file); // Parse the JSON response const parsed = JSON.parse(result.text) as ParsedDocument; @@ -702,15 +702,16 @@ export default function Component({ {isUploadingDocument && !uploadedDocument && (
- Processing document... + + Processing document securely... This may take a minute. +
)} {uploadedDocument && (
- {uploadedDocument.parsed.document.filename} ( - {Math.round(uploadedDocument.original.size / 1024)}KB) + {uploadedDocument.parsed.document.filename}