feat: add document file upload support for spec creation and project expansion

Add support for uploading Markdown, Text, Word (.docx), CSV, Excel (.xlsx),
PDF, and PowerPoint (.pptx) files in addition to existing JPEG/PNG image
uploads in the spec creation and project expansion chat interfaces.

Backend changes:
- New server/utils/document_extraction.py: in-memory text extraction for all
  document formats using python-docx, openpyxl, PyPDF2, python-pptx (no disk
  persistence)
- Rename ImageAttachment to FileAttachment across schemas, routers, and
  chat session services
- Add build_attachment_content_blocks() helper in chat_constants.py to route
  images as image content blocks and documents as extracted text blocks
- Separate size limits: 5MB for images, 20MB for documents
- Handle extraction errors (corrupt files, encrypted PDFs) gracefully

Frontend changes:
- Widen accepted MIME types and file extensions in both chat components
- Add resolveMimeType() fallback for browsers that don't set MIME on .md files
- Document attachments display with FileText icon instead of image thumbnail
- ChatMessage renders documents as compact pills with filename and size
- Update help text from "attach images" to "attach files"

Dependencies added: python-docx, openpyxl, PyPDF2, python-pptx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Auto
2026-03-25 12:51:17 +02:00
parent fca1f6a5e2
commit 7210c6f066
15 changed files with 513 additions and 123 deletions

View File

@@ -6,10 +6,11 @@
*/
import { memo } from 'react'
import { Bot, User, Info } from 'lucide-react'
import { Bot, User, Info, FileText } from 'lucide-react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage as ChatMessageType } from '../lib/types'
import { isImageAttachment } from '../lib/types'
import { Card } from '@/components/ui/card'
interface ChatMessageProps {
@@ -104,21 +105,35 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
</div>
)}
{/* Display image attachments */}
{/* Display file attachments */}
{attachments && attachments.length > 0 && (
<div className={`flex flex-wrap gap-2 ${content ? 'mt-3' : ''}`}>
{attachments.map((attachment) => (
<div key={attachment.id} className="border border-border rounded p-1 bg-card">
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="max-w-48 max-h-48 object-contain cursor-pointer hover:opacity-90 transition-opacity rounded"
onClick={() => window.open(attachment.previewUrl, '_blank')}
title={`${attachment.filename} (click to enlarge)`}
/>
<span className="text-xs text-muted-foreground block mt-1 text-center">
{attachment.filename}
</span>
{isImageAttachment(attachment) ? (
<>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="max-w-48 max-h-48 object-contain cursor-pointer hover:opacity-90 transition-opacity rounded"
onClick={() => window.open(attachment.previewUrl, '_blank')}
title={`${attachment.filename} (click to enlarge)`}
/>
<span className="text-xs text-muted-foreground block mt-1 text-center">
{attachment.filename}
</span>
</>
) : (
<div className="flex items-center gap-2 px-2 py-1">
<FileText size={16} className="text-muted-foreground flex-shrink-0" />
<span className="text-xs text-muted-foreground">
{attachment.filename}
</span>
<span className="text-xs text-muted-foreground/60">
({(attachment.size / 1024).toFixed(0)} KB)
</span>
</div>
)}
</div>
))}
</div>

View File

@@ -6,20 +6,22 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus } from 'lucide-react'
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus, FileText } from 'lucide-react'
import { useExpandChat } from '../hooks/useExpandChat'
import { ChatMessage } from './ChatMessage'
import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types'
import type { FileAttachment } from '../lib/types'
import { ALL_ALLOWED_MIME_TYPES, IMAGE_MIME_TYPES, isImageAttachment, resolveMimeType } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
// Image upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png']
// File upload validation constants
const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB for images
const MAX_DOCUMENT_SIZE = 20 * 1024 * 1024 // 20 MB for documents
const ALLOWED_EXTENSIONS = ['md', 'txt', 'csv', 'docx', 'xlsx', 'pdf', 'pptx', 'jpg', 'jpeg', 'png']
interface ExpandProjectChatProps {
projectName: string
@@ -34,7 +36,7 @@ export function ExpandProjectChat({
}: ExpandProjectChatProps) {
const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null)
const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
const [pendingAttachments, setPendingAttachments] = useState<FileAttachment[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -95,20 +97,33 @@ export function ExpandProjectChat({
}
}
// File handling for image attachments
// File handling for attachments (images and documents)
const handleFileSelect = useCallback((files: FileList | null) => {
if (!files) return
Array.from(files).forEach((file) => {
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`)
return
// Resolve MIME type (browsers may not set it for .md files)
let mimeType = file.type
if (!mimeType || !ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
mimeType = resolveMimeType(file.name)
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
setError(`File too large: ${file.name}. Maximum size is 5 MB.`)
// Validate file type
if (!ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) {
setError(`Unsupported file type: ${file.name}. Supported: images (JPEG, PNG) and documents (MD, TXT, CSV, DOCX, XLSX, PDF, PPTX).`)
return
}
mimeType = resolveMimeType(file.name)
}
// Validate size based on type
const isImage = (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_DOCUMENT_SIZE
const maxLabel = isImage ? '5 MB' : '20 MB'
if (file.size > maxSize) {
setError(`File too large: ${file.name}. Maximum size is ${maxLabel}.`)
return
}
@@ -118,12 +133,12 @@ export function ExpandProjectChat({
const dataUrl = e.target?.result as string
const base64Data = dataUrl.split(',')[1]
const attachment: ImageAttachment = {
const attachment: FileAttachment = {
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
filename: file.name,
mimeType: file.type as 'image/jpeg' | 'image/png',
mimeType: mimeType as FileAttachment['mimeType'],
base64Data,
previewUrl: dataUrl,
previewUrl: isImage ? dataUrl : '',
size: file.size,
}
@@ -291,11 +306,17 @@ export function ExpandProjectChat({
key={attachment.id}
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="w-16 h-16 object-cover rounded"
/>
{isImageAttachment(attachment) ? (
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 flex items-center justify-center bg-muted rounded">
<FileText size={24} className="text-muted-foreground" />
</div>
)}
<button
onClick={() => handleRemoveAttachment(attachment.id)}
className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
@@ -318,7 +339,7 @@ export function ExpandProjectChat({
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png"
accept="image/jpeg,image/png,.md,.txt,.csv,.docx,.xlsx,.pdf,.pptx"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
@@ -330,7 +351,7 @@ export function ExpandProjectChat({
disabled={connectionStatus !== 'connected'}
variant="ghost"
size="icon"
title="Attach image (JPEG, PNG - max 5MB)"
title="Attach files (images: JPEG/PNG up to 5MB, documents: MD, TXT, CSV, DOCX, XLSX, PDF, PPTX up to 20MB)"
>
<Paperclip size={18} />
</Button>
@@ -364,7 +385,7 @@ export function ExpandProjectChat({
{/* Help text */}
<p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images.
Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach files.
</p>
</div>
)}

View File

@@ -11,16 +11,18 @@ import { useSpecChat } from '../hooks/useSpecChat'
import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions'
import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types'
import type { FileAttachment } from '../lib/types'
import { ALL_ALLOWED_MIME_TYPES, IMAGE_MIME_TYPES, isImageAttachment, resolveMimeType } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
// Image upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png']
// File upload validation constants
const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB for images
const MAX_DOCUMENT_SIZE = 20 * 1024 * 1024 // 20 MB for documents
const ALLOWED_EXTENSIONS = ['md', 'txt', 'csv', 'docx', 'xlsx', 'pdf', 'pptx', 'jpg', 'jpeg', 'png']
// Sample prompt for quick testing
const SAMPLE_PROMPT = `Let's call it Simple Todo. This is a really simple web app that I can use to track my to-do items using a Kanban board. I should be able to add to-dos and then drag and drop them through the Kanban board. The different columns in the Kanban board are:
@@ -64,7 +66,7 @@ export function SpecCreationChat({
const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null)
const [yoloEnabled, setYoloEnabled] = useState(false)
const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
const [pendingAttachments, setPendingAttachments] = useState<FileAttachment[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -138,20 +140,33 @@ export function SpecCreationChat({
sendAnswer(answers)
}
// File handling for image attachments
// File handling for attachments (images and documents)
const handleFileSelect = useCallback((files: FileList | null) => {
if (!files) return
Array.from(files).forEach((file) => {
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`)
return
// Resolve MIME type (browsers may not set it for .md files)
let mimeType = file.type
if (!mimeType || !ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
mimeType = resolveMimeType(file.name)
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
setError(`File too large: ${file.name}. Maximum size is 5 MB.`)
// Validate file type
if (!ALL_ALLOWED_MIME_TYPES.includes(mimeType)) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) {
setError(`Unsupported file type: ${file.name}. Supported: images (JPEG, PNG) and documents (MD, TXT, CSV, DOCX, XLSX, PDF, PPTX).`)
return
}
mimeType = resolveMimeType(file.name)
}
// Validate size based on type
const isImage = (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_DOCUMENT_SIZE
const maxLabel = isImage ? '5 MB' : '20 MB'
if (file.size > maxSize) {
setError(`File too large: ${file.name}. Maximum size is ${maxLabel}.`)
return
}
@@ -159,15 +174,14 @@ export function SpecCreationChat({
const reader = new FileReader()
reader.onload = (e) => {
const dataUrl = e.target?.result as string
// dataUrl is "data:image/png;base64,XXXXXX"
const base64Data = dataUrl.split(',')[1]
const attachment: ImageAttachment = {
const attachment: FileAttachment = {
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
filename: file.name,
mimeType: file.type as 'image/jpeg' | 'image/png',
mimeType: mimeType as FileAttachment['mimeType'],
base64Data,
previewUrl: dataUrl,
previewUrl: isImage ? dataUrl : '',
size: file.size,
}
@@ -364,11 +378,17 @@ export function SpecCreationChat({
key={attachment.id}
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="w-16 h-16 object-cover rounded"
/>
{isImageAttachment(attachment) ? (
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 flex items-center justify-center bg-muted rounded">
<FileText size={24} className="text-muted-foreground" />
</div>
)}
<button
onClick={() => handleRemoveAttachment(attachment.id)}
className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
@@ -391,7 +411,7 @@ export function SpecCreationChat({
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png"
accept="image/jpeg,image/png,.md,.txt,.csv,.docx,.xlsx,.pdf,.pptx"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
@@ -403,7 +423,7 @@ export function SpecCreationChat({
disabled={connectionStatus !== 'connected'}
variant="ghost"
size="icon"
title="Attach image (JPEG, PNG - max 5MB)"
title="Attach files (images: JPEG/PNG up to 5MB, documents: MD, TXT, CSV, DOCX, XLSX, PDF, PPTX up to 20MB)"
>
<Paperclip size={18} />
</Button>
@@ -444,7 +464,7 @@ export function SpecCreationChat({
{/* Help text */}
<p className="text-xs text-muted-foreground mt-2">
Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).
Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach files.
</p>
</div>
)}

View File

@@ -3,7 +3,7 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, ExpandChatServerMessage } from '../lib/types'
import type { ChatMessage, FileAttachment, ExpandChatServerMessage } from '../lib/types'
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -27,7 +27,7 @@ interface UseExpandChatReturn {
featuresCreated: number
recentFeatures: CreatedFeature[]
start: () => void
sendMessage: (content: string, attachments?: ImageAttachment[]) => void
sendMessage: (content: string, attachments?: FileAttachment[]) => void
disconnect: () => void
}
@@ -278,7 +278,7 @@ export function useExpandChat({
setTimeout(checkAndSend, 100)
}, [connect, onError])
const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => {
const sendMessage = useCallback((content: string, attachments?: FileAttachment[]) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected')
return

View File

@@ -3,7 +3,7 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
import type { ChatMessage, FileAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
import { getSpecStatus } from '../lib/api'
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -22,7 +22,7 @@ interface UseSpecChatReturn {
currentQuestions: SpecQuestion[] | null
currentToolId: string | null
start: () => void
sendMessage: (content: string, attachments?: ImageAttachment[]) => void
sendMessage: (content: string, attachments?: FileAttachment[]) => void
sendAnswer: (answers: Record<string, string | string[]>) => void
disconnect: () => void
}
@@ -367,7 +367,7 @@ export function useSpecChat({
setTimeout(checkAndSend, 100)
}, [connect])
const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => {
const sendMessage = useCallback((content: string, attachments?: FileAttachment[]) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected')
return

View File

@@ -417,22 +417,67 @@ export type SpecChatServerMessage =
| SpecChatPongMessage
| SpecChatResponseDoneMessage
// Image attachment for chat messages
export interface ImageAttachment {
// File attachment for chat messages (images and documents)
export interface FileAttachment {
id: string
filename: string
mimeType: 'image/jpeg' | 'image/png'
mimeType:
| 'image/jpeg'
| 'image/png'
| 'text/plain'
| 'text/markdown'
| 'text/csv'
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
| 'application/pdf'
| 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
base64Data: string // Raw base64 (without data: prefix)
previewUrl: string // data: URL for display
previewUrl: string // data: URL for images, empty string for documents
size: number // File size in bytes
}
/** @deprecated Use FileAttachment instead */
export type ImageAttachment = FileAttachment
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png'] as const
export const DOCUMENT_MIME_TYPES = [
'text/plain',
'text/markdown',
'text/csv',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/pdf',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
] as const
export const ALL_ALLOWED_MIME_TYPES: string[] = [...IMAGE_MIME_TYPES, ...DOCUMENT_MIME_TYPES]
export function isImageAttachment(att: FileAttachment): boolean {
return (IMAGE_MIME_TYPES as readonly string[]).includes(att.mimeType)
}
export function resolveMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
const map: Record<string, string> = {
md: 'text/markdown',
txt: 'text/plain',
csv: 'text/csv',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pdf: 'application/pdf',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
}
return map[ext || ''] || 'application/octet-stream'
}
// UI chat message for display
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
attachments?: ImageAttachment[]
attachments?: FileAttachment[]
timestamp: Date
questions?: SpecQuestion[]
isStreaming?: boolean