From 9c023e2590f93dd848bf943febc7243c0da34ebc Mon Sep 17 00:00:00 2001 From: Hunter Phillips Date: Sun, 14 Dec 2025 20:03:51 -0600 Subject: [PATCH 1/4] attachments --- client/src/App.tsx | 135 +------ client/src/components/Sidebar.tsx | 7 +- .../src/components/chat/AttachmentPreview.tsx | 67 ++++ client/src/components/chat/ChatListItem.tsx | 6 +- client/src/components/chat/ChatView.tsx | 132 +++++++ client/src/components/chat/FilePicker.tsx | 63 ++++ client/src/components/chat/InputArea.tsx | 179 ++++----- .../src/components/chat/MessageAttachment.tsx | 53 +++ client/src/components/chat/MessageBubble.tsx | 21 +- client/src/components/chat/ToolMenu.tsx | 11 +- .../src/components/chat/ToolPillContainer.tsx | 58 +++ .../src/components/tools/attachments/index.ts | 4 + .../components/tools/instructions/index.ts | 2 +- client/src/components/ui/toast.tsx | 10 +- client/src/hooks/useChatPersistence.ts | 41 ++ client/src/hooks/useChatSession.ts | 144 +++---- client/src/hooks/useFileUpload.ts | 60 +++ client/src/lib/icon-utils.ts | 2 + client/src/lib/types.ts | 19 + client/src/store/config-store.ts | 19 + package-lock.json | 356 +++++++++++++++++- server/package.json | 3 + server/src/db/chats.ts | 14 + server/src/index.ts | 84 ++++- server/src/lib/message-converter.ts | 103 +++++ server/src/lib/workflow-router.ts | 25 ++ server/src/providers.ts | 77 ++++ server/src/routes/uploads.ts | 149 ++++++++ server/src/types.ts | 27 ++ server/src/workflows/consensus.ts | 178 +++++---- 30 files changed, 1628 insertions(+), 421 deletions(-) create mode 100644 client/src/components/chat/AttachmentPreview.tsx create mode 100644 client/src/components/chat/ChatView.tsx create mode 100644 client/src/components/chat/FilePicker.tsx create mode 100644 client/src/components/chat/MessageAttachment.tsx create mode 100644 client/src/components/chat/ToolPillContainer.tsx create mode 100644 client/src/components/tools/attachments/index.ts create mode 100644 client/src/hooks/useChatPersistence.ts create mode 100644 client/src/hooks/useFileUpload.ts create mode 100644 server/src/lib/message-converter.ts create mode 100644 server/src/lib/workflow-router.ts create mode 100644 server/src/routes/uploads.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 01cafb8..91d4c1a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,137 +1,8 @@ -import { useEffect, useState } from 'react'; -import { Routes, Route, useParams, useNavigate } from 'react-router-dom'; -import { ChatContainer } from './components/chat/ChatContainer'; -import { InputArea } from './components/chat/InputArea'; -import { ModelSelector } from './components/chat/ModelSelector'; -import { Sidebar } from './components/Sidebar'; -import { Toast, useToast } from './components/ui/toast'; +import { useEffect } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { ChatView } from './components/chat/ChatView'; import { useConfigStore } from './store/config-store'; import { initializeToolRegistry } from './components/tools'; -import { useChatSession } from './hooks/useChatSession'; - -function ChatView() { - const [sidebarOpen, setSidebarOpen] = useState(true); - const { id } = useParams(); - const navigate = useNavigate(); - const { toast, showToast, hideToast } = useToast(); - - // Get all chat state and handlers from custom hook - const { - messages, - input, - handleInputChange, - handleSubmit, - isLoading, - error, - stop, - handleNewChat, - handleSelectChat, - } = useChatSession(); - - // Load chat when URL changes - useEffect(() => { - const loadChatById = async (chatId: string) => { - try { - await handleSelectChat(chatId); - } catch (error) { - showToast('Chat not found', 'error'); - navigate('/'); - } - }; - - if (id) { - loadChatById(id); - } else { - handleNewChat(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - // Show toast on error - useEffect(() => { - if (error) { - showToast(error.message, 'error'); - } - }, [error, showToast]); - - // Navigation handlers (passed as props to children) - const onSelectChat = (chatId: string) => { - navigate(`/c/${chatId}`); - }; - - const onNewChat = () => { - navigate('/'); - }; - - const onDeleteCurrentChat = () => { - navigate('/'); - }; - - // Wrap handleSubmit to navigate to new chats - const onSubmit = async (e: React.FormEvent) => { - const result = await handleSubmit(e); - if (result?.newChatId) { - navigate(`/c/${result.newChatId}`); - } - }; - - return ( -
- {/* Sidebar */} - setSidebarOpen(!sidebarOpen)} - onNewChat={onNewChat} - onSelectChat={onSelectChat} - onDeleteCurrentChat={onDeleteCurrentChat} - currentChatId={id || null} - /> - - {/* Main Content Area */} -
- - - {messages.length === 0 ? ( - // Empty state: centered input -
-
-

- What can I help you with? -

- -
-
- ) : ( - // Chat view: normal layout - <> - - - - )} - - {toast && ( - - )} -
-
- ); -} function App() { const { loadConfig } = useConfigStore(); diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 2b8863c..50eb584 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { PenSquare, PanelLeft, Pencil, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/store/config-store'; @@ -24,7 +24,7 @@ export function Sidebar({ onDeleteCurrentChat, currentChatId, }: SidebarProps) { - const [isHovering, setIsHovering] = React.useState(false); + const [isHovering, setIsHovering] = useState(false); const [chatToDelete, setChatToDelete] = useState(null); const [dropdownMenu, setDropdownMenu] = useState<{ chatId: string; @@ -148,7 +148,8 @@ export function Sidebar({ {/* Chat List */} {isOpen && ( -
+ // space-y-1 +
{chats.map((chat) => ( void; +} + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +export function AttachmentPreview({ + attachment, + onRemove, +}: AttachmentPreviewProps) { + const isImage = attachment.mimeType.startsWith('image/'); + const isPdf = attachment.mimeType === 'application/pdf'; + const isCode = + attachment.mimeType.includes('javascript') || + attachment.mimeType.includes('typescript') || + attachment.mimeType === 'application/json'; + const isText = attachment.mimeType.startsWith('text/'); + + const fileUrl = `${API_URL}/api/uploads/${attachment.path}`; + + const getIcon = () => { + if (isImage) return Image; + if (isPdf) return File; + if (isCode) return FileCode; + if (isText) return FileText; + return File; + }; + + const Icon = getIcon(); + + return ( +
+ {isImage ? ( + {attachment.name} + ) : ( +
+ +
+ )} + +
+ + {attachment.name} + + + {(attachment.size / 1024).toFixed(1)} KB + +
+ + +
+ ); +} diff --git a/client/src/components/chat/ChatListItem.tsx b/client/src/components/chat/ChatListItem.tsx index 62af8cb..390be73 100644 --- a/client/src/components/chat/ChatListItem.tsx +++ b/client/src/components/chat/ChatListItem.tsx @@ -1,4 +1,4 @@ -import { MessageSquare, MoreHorizontal } from 'lucide-react'; +import { MoreHorizontal } from 'lucide-react'; import { useState, useRef, useEffect } from 'react'; import { cn } from '@/lib/utils'; import type { ChatListItem } from '@/lib/types'; @@ -58,13 +58,11 @@ export function ChatListItem({
- - {isEditing ? (
{ + const loadChatById = async (chatId: string) => { + try { + await handleSelectChat(chatId); + } catch (error) { + showToast('Chat not found', 'error'); + navigate('/'); + } + }; + + if (id) { + loadChatById(id); + } else { + handleNewChat(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + // Show toast on error + useEffect(() => { + if (error) { + showToast(error.message, 'error'); + } + }, [error, showToast]); + + // Navigation handlers (passed as props to children) + const onSelectChat = (chatId: string) => { + navigate(`/c/${chatId}`); + }; + + const onNewChat = () => { + navigate('/'); + }; + + const onDeleteCurrentChat = () => { + navigate('/'); + }; + + // Wrap handleSubmit to navigate to new chats + const onSubmit = async (e: React.FormEvent) => { + const result = await handleSubmit(e); + if (result?.newChatId) { + navigate(`/c/${result.newChatId}`); + } + }; + + return ( +
+ {/* Sidebar */} + setSidebarOpen(!sidebarOpen)} + onNewChat={onNewChat} + onSelectChat={onSelectChat} + onDeleteCurrentChat={onDeleteCurrentChat} + currentChatId={id || null} + /> + + {/* Main Content Area */} +
+ + + {messages.length === 0 ? ( + // Empty state: centered input +
+
+

+ What can I help you with? +

+ +
+
+ ) : ( + // Chat view: normal layout + <> + + + + )} + + {toast && ( + + )} +
+
+ ); +} diff --git a/client/src/components/chat/FilePicker.tsx b/client/src/components/chat/FilePicker.tsx new file mode 100644 index 0000000..0781a3a --- /dev/null +++ b/client/src/components/chat/FilePicker.tsx @@ -0,0 +1,63 @@ +import { useRef } from 'react'; +import { Paperclip } from 'lucide-react'; + +interface FilePickerProps { + onFilesSelected: (files: FileList) => void; + disabled?: boolean; +} + +const ACCEPTED_FILE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + // 'image/svg+xml', + 'application/pdf', + 'text/plain', + 'text/markdown', + 'text/javascript', + 'application/javascript', + 'text/x-javascript', + 'application/x-javascript', + 'text/typescript', + 'application/x-typescript', + 'application/json', +].join(','); + +export function FilePicker({ onFilesSelected, disabled }: FilePickerProps) { + const inputRef = useRef(null); + + const handleClick = () => { + inputRef.current?.click(); + }; + + const handleChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + onFilesSelected(e.target.files); + // Reset input so same file can be selected again + e.target.value = ''; + } + }; + + return ( + <> + + + + ); +} diff --git a/client/src/components/chat/InputArea.tsx b/client/src/components/chat/InputArea.tsx index 6a65039..99bfc11 100644 --- a/client/src/components/chat/InputArea.tsx +++ b/client/src/components/chat/InputArea.tsx @@ -1,16 +1,19 @@ -import React from 'react'; +import { useState, ChangeEvent, FormEvent, KeyboardEvent } from 'react'; import { Button } from '@/components/ui/button'; import { Square } from 'lucide-react'; import { ToolMenu } from './ToolMenu'; -import { ToolPill } from './ToolPill'; +import { AttachmentPreview } from './AttachmentPreview'; +import { FilePicker } from './FilePicker'; +import { ToolPillContainer } from './ToolPillContainer'; import { toolRegistry } from '@/lib/tool-registry'; import { useConfigStore } from '@/store/config-store'; +import { useFileUpload } from '@/hooks/useFileUpload'; import birdIcon from '@/assets/bird.png'; interface InputAreaProps { input: string; - handleInputChange: (e: React.ChangeEvent) => void; - handleSubmit: (e: React.FormEvent) => void; + handleInputChange: (e: ChangeEvent) => void; + handleSubmit: (e: FormEvent) => void; isLoading: boolean; onStop: () => void; } @@ -22,120 +25,104 @@ export function InputArea({ isLoading, onStop, }: InputAreaProps) { - const [activePanelToolId, setActivePanelToolId] = React.useState< - string | null - >(null); - const [activeConfigToolId, setActiveConfigToolId] = React.useState< - string | null - >(null); - const { tools, toggleTool } = useConfigStore(); + const [activePanelToolId, setActivePanelToolId] = useState( + null + ); + const { tools, currentChatId, pendingAttachments, removeAttachment } = + useConfigStore(); + const { uploadFiles } = useFileUpload(currentChatId); - const enabledTools = tools.filter((t) => t.enabled); + const hasAttachments = pendingAttachments.length > 0; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e as any); } }; - const handleRemoveTool = (toolId: string) => { - toggleTool(toolId, false); - }; - - const handleOpenConfig = (toolId: string) => { - setActiveConfigToolId(toolId); + const handleFilesSelected = async (files: FileList) => { + await uploadFiles(files); }; return ( <>
-
- {/* Plus Menu */} - - -