diff --git a/.DS_Store b/.DS_Store index d8fb93e..b963966 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/prisma/.env.example b/prisma/.env.example deleted file mode 100644 index 1e8e72a..0000000 --- a/prisma/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# API Keys Configuration -# Copy this file to .env.local and add your actual API keys - -# Primary API Key (used by default) -# For Google Gemini: https://ai.google.dev/ -# For OpenAI: https://platform.openai.com/ -VITE_API_KEY=your_api_key_here - -# Alternative: Use provider-specific keys (optional) -# GEMINI_API_KEY=your_gemini_key_here -# OPENAI_API_KEY=your_openai_key_here diff --git a/prisma/.gitignore b/prisma/.gitignore index 70454b8..a547bf3 100644 --- a/prisma/.gitignore +++ b/prisma/.gitignore @@ -22,5 +22,3 @@ dist-ssr *.njsproj *.sln *.sw? -.env -# .env.example is allowed by default diff --git a/prisma/api.ts b/prisma/api.ts index afeddf7..7a14457 100644 --- a/prisma/api.ts +++ b/prisma/api.ts @@ -32,7 +32,7 @@ let currentCustomApiUrl: string | null = null; const originalFetch = typeof window !== 'undefined' ? window.fetch.bind(window) : null; if (typeof window !== 'undefined' && originalFetch) { - window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const proxyFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { let urlString: string; if (typeof input === 'string') { urlString = input; @@ -56,11 +56,26 @@ if (typeof window !== 'undefined' && originalFetch) { return originalFetch(input, init); }; + + try { + window.fetch = proxyFetch; + } catch (e) { + try { + Object.defineProperty(window, 'fetch', { + value: proxyFetch, + writable: true, + configurable: true, + enumerable: true + }); + } catch (e2) { + console.error('[API] Failed to intercept fetch:', e2); + } + } } export const getAI = (config?: AIProviderConfig) => { const provider = config?.provider || 'google'; - const apiKey = config?.apiKey || (import.meta.env as any).VITE_API_KEY || process.env.API_KEY; + const apiKey = config?.apiKey || import.meta.env?.VITE_API_KEY || process.env.API_KEY; if (provider === 'openai' || provider === 'deepseek' || provider === 'custom' || provider === 'anthropic' || provider === 'xai' || provider === 'mistral') { const options: any = { @@ -135,4 +150,4 @@ export const getAIProvider = (model: string): ApiProvider => { return 'custom'; } return 'google'; -}; +}; \ No newline at end of file diff --git a/prisma/components/ChatMessage.tsx b/prisma/components/ChatMessage.tsx index 61728b6..4bf7073 100644 --- a/prisma/components/ChatMessage.tsx +++ b/prisma/components/ChatMessage.tsx @@ -102,6 +102,21 @@ const ChatMessageItem = ({ message, isLast }: ChatMessageProps) => { )} + {/* Attachments */} + {message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map(att => ( + attachment window.open(att.url || `data:${att.mimeType};base64,${att.data}`, '_blank')} + /> + ))} +
+ )} + {/* Text Content */}
{message.content ? ( diff --git a/prisma/components/InputSection.tsx b/prisma/components/InputSection.tsx index ae0a3b9..ad94666 100644 --- a/prisma/components/InputSection.tsx +++ b/prisma/components/InputSection.tsx @@ -1,12 +1,13 @@ import React, { useRef, useLayoutEffect, useState, useEffect } from 'react'; -import { ArrowUp, Square } from 'lucide-react'; -import { AppState } from '../types'; +import { ArrowUp, Square, Paperclip, X, Image as ImageIcon } from 'lucide-react'; +import { AppState, MessageAttachment } from '../types'; +import { fileToBase64 } from '../utils'; interface InputSectionProps { query: string; setQuery: (q: string) => void; - onRun: () => void; + onRun: (attachments: MessageAttachment[]) => void; onStop: () => void; appState: AppState; focusTrigger?: number; @@ -14,7 +15,9 @@ interface InputSectionProps { const InputSection = ({ query, setQuery, onRun, onStop, appState, focusTrigger }: InputSectionProps) => { const textareaRef = useRef(null); + const fileInputRef = useRef(null); const [isComposing, setIsComposing] = useState(false); + const [attachments, setAttachments] = useState([]); const adjustHeight = () => { if (textareaRef.current) { @@ -36,8 +39,7 @@ const InputSection = ({ query, setQuery, onRun, onStop, appState, focusTrigger } } }; - // Focus input on mount and when app becomes idle (e.g. after "New Chat" or completion) - // or when explicitly triggered by focusTrigger + // Focus input on mount and when app becomes idle useEffect(() => { if (appState === 'idle' && textareaRef.current) { textareaRef.current.focus(); @@ -49,39 +51,125 @@ const InputSection = ({ query, setQuery, onRun, onStop, appState, focusTrigger } adjustHeight(); }, [query]); + const processFile = async (file: File) => { + if (!file.type.startsWith('image/')) return; + + try { + const base64 = await fileToBase64(file); + const newAttachment: MessageAttachment = { + id: Math.random().toString(36).substring(7), + type: 'image', + mimeType: file.type, + data: base64, + url: URL.createObjectURL(file) + }; + setAttachments(prev => [...prev, newAttachment]); + } catch (e) { + console.error("Failed to process file", e); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData.items; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile(); + if (file) { + e.preventDefault(); + processFile(file); + } + } + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + Array.from(e.target.files).forEach(processFile); + } + // Reset input so same file can be selected again + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const removeAttachment = (id: string) => { + setAttachments(prev => prev.filter(a => a.id !== id)); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { - // If user presses Enter without Shift if (e.key === 'Enter' && !e.shiftKey) { - // robust check for IME composition (e.g. Chinese/Japanese inputs) if (isComposing || (e.nativeEvent as any).isComposing) { return; } - e.preventDefault(); - if (query.trim() && appState === 'idle') { - onRun(); + if ((query.trim() || attachments.length > 0) && appState === 'idle') { + handleSubmit(); } } }; + const handleSubmit = () => { + if (!query.trim() && attachments.length === 0) return; + onRun(attachments); + setAttachments([]); + }; + const isRunning = appState !== 'idle'; return (
- {/* Container: Flex items-end ensures button stays at bottom right as text grows */} + {/* Attachments Preview */} + {attachments.length > 0 && ( +
+ {attachments.map(att => ( +
+ attachment + +
+ ))} +
+ )} + + {/* Input Container */}
+ + + +