345 lines
15 KiB
TypeScript
345 lines
15 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
|
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff, Highlighter } from 'lucide-react';
|
|
import { useT } from '../hooks/useLocale';
|
|
import { HighlightedTextarea } from './HighlightedTextarea';
|
|
|
|
const ReactMarkdown = lazy(() => import('react-markdown'));
|
|
const remarkGfm = import('remark-gfm').then(m => m.default);
|
|
let _remarkGfm: typeof import('remark-gfm').default | null = null;
|
|
remarkGfm.then(p => { _remarkGfm = p; });
|
|
|
|
interface FileAttachment {
|
|
id: string;
|
|
file: File;
|
|
base64: string; // raw base64 (no data: prefix)
|
|
mimeType: string;
|
|
preview?: string; // data url thumbnail for images
|
|
}
|
|
|
|
interface Props {
|
|
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
|
onAbort: () => void;
|
|
isGenerating: boolean;
|
|
disabled: boolean;
|
|
sessionKey?: string;
|
|
}
|
|
|
|
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
|
const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize
|
|
|
|
function fileToBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
const base64 = dataUrl.split(',')[1] || '';
|
|
resolve(base64);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
let { width, height } = img;
|
|
// Downscale if needed
|
|
if (width > MAX_IMAGE_PIXELS || height > MAX_IMAGE_PIXELS) {
|
|
const ratio = Math.min(MAX_IMAGE_PIXELS / width, MAX_IMAGE_PIXELS / height);
|
|
width = Math.round(width * ratio);
|
|
height = Math.round(height * ratio);
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d')!;
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Try JPEG at decreasing quality until base64 length is under limit
|
|
for (let q = 0.85; q >= 0.2; q -= 0.05) {
|
|
const dataUrl = canvas.toDataURL('image/jpeg', q);
|
|
const b64 = dataUrl.split(',')[1] || '';
|
|
if (b64.length <= maxBase64Chars) {
|
|
return resolve({ base64: b64, mimeType: 'image/jpeg' });
|
|
}
|
|
}
|
|
// Last resort: further downscale
|
|
const scale = 0.5;
|
|
canvas.width = Math.round(width * scale);
|
|
canvas.height = Math.round(height * scale);
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.3);
|
|
resolve({ base64: dataUrl.split(',')[1] || '', mimeType: 'image/jpeg' });
|
|
};
|
|
img.onerror = reject;
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
}
|
|
|
|
export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey }: Props) {
|
|
const t = useT();
|
|
const [text, setText] = useState('');
|
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1');
|
|
const [highlightEnabled, setHighlightEnabled] = useState(() => localStorage.getItem('pinchchat-syntax-hl') !== '0');
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Per-session draft storage
|
|
const draftsRef = useRef<Map<string, string>>(new Map());
|
|
const prevSessionRef = useRef<string | undefined>(sessionKey);
|
|
|
|
// Save draft to previous session and restore draft for new session
|
|
useEffect(() => {
|
|
const prev = prevSessionRef.current;
|
|
// Save current text as draft for the previous session
|
|
if (prev && prev !== sessionKey) {
|
|
const currentText = textareaRef.current?.value ?? text;
|
|
if (currentText.trim()) {
|
|
draftsRef.current.set(prev, currentText);
|
|
} else {
|
|
draftsRef.current.delete(prev);
|
|
}
|
|
}
|
|
// Restore draft for the new session
|
|
const draft = sessionKey ? draftsRef.current.get(sessionKey) ?? '' : '';
|
|
setText(draft); // eslint-disable-line react-hooks/set-state-in-effect -- intentional: restore draft on session switch
|
|
prevSessionRef.current = sessionKey;
|
|
}, [sessionKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px';
|
|
}
|
|
}, [text]);
|
|
|
|
// Auto-focus textarea when session changes or connection becomes active
|
|
useEffect(() => {
|
|
if (!disabled && textareaRef.current) {
|
|
// Small delay to let the DOM settle after session switch
|
|
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [sessionKey, disabled]);
|
|
|
|
const addFiles = useCallback(async (fileList: FileList | File[]) => {
|
|
const newFiles: FileAttachment[] = [];
|
|
for (const file of Array.from(fileList)) {
|
|
if (file.size > 20 * 1024 * 1024) continue; // 20MB max
|
|
const isImage = file.type.startsWith('image/');
|
|
let base64: string;
|
|
let mimeType: string;
|
|
if (isImage) {
|
|
// Compress images to fit WS payload limit
|
|
const compressed = await compressImage(file, MAX_BASE64_CHARS);
|
|
base64 = compressed.base64;
|
|
mimeType = compressed.mimeType;
|
|
} else {
|
|
base64 = await fileToBase64(file);
|
|
mimeType = file.type || 'application/octet-stream';
|
|
}
|
|
newFiles.push({
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
file,
|
|
base64,
|
|
mimeType,
|
|
preview: isImage ? `data:${mimeType};base64,${base64}` : undefined,
|
|
});
|
|
}
|
|
setFiles(prev => [...prev, ...newFiles]);
|
|
}, []);
|
|
|
|
const removeFile = useCallback((id: string) => {
|
|
setFiles(prev => prev.filter(f => f.id !== id));
|
|
}, []);
|
|
|
|
const handleSubmit = () => {
|
|
const trimmed = text.trim();
|
|
if ((!trimmed && files.length === 0) || disabled) return;
|
|
const attachments = files.length > 0 ? files.map(f => ({
|
|
mimeType: f.mimeType,
|
|
fileName: f.file.name,
|
|
content: f.base64,
|
|
})) : undefined;
|
|
onSend(trimmed || ' ', attachments);
|
|
setText('');
|
|
setFiles([]);
|
|
// Clear draft for this session after sending
|
|
if (sessionKey) draftsRef.current.delete(sessionKey);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
};
|
|
|
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
const pastedFiles: File[] = [];
|
|
for (const item of Array.from(items)) {
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile();
|
|
if (file) pastedFiles.push(file);
|
|
}
|
|
}
|
|
if (pastedFiles.length > 0) {
|
|
e.preventDefault();
|
|
addFiles(pastedFiles);
|
|
}
|
|
}, [addFiles]);
|
|
|
|
// Drag & drop handlers on the wrapper
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(true);
|
|
}, []);
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
}, []);
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
if (e.dataTransfer.files.length > 0) {
|
|
addFiles(e.dataTransfer.files);
|
|
}
|
|
}, [addFiles]);
|
|
|
|
return (
|
|
<div
|
|
className="border-t border-pc-border bg-[var(--pc-bg-input)]/60 backdrop-blur-xl p-4 print-hide"
|
|
role="form"
|
|
aria-label={t('chat.inputLabel')}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className={`rounded-3xl border bg-[var(--pc-bg-surface)]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-glow)]' : 'border-pc-border'}`}>
|
|
{/* File previews */}
|
|
{files.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-3 px-1">
|
|
{files.map(f => (
|
|
<div key={f.id} className="group relative flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/50 px-3 py-2 text-xs text-pc-text-secondary">
|
|
{f.preview ? (
|
|
<img src={f.preview} alt="" className="h-8 w-8 rounded-lg object-cover" />
|
|
) : (
|
|
<FileText size={16} className="text-pc-text-muted shrink-0" />
|
|
)}
|
|
<div className="min-w-0 max-w-[120px]">
|
|
<div className="truncate text-pc-text">{f.file.name}</div>
|
|
<div className="text-[10px] text-pc-text-muted">{formatSize(f.file.size)}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => removeFile(f.id)}
|
|
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-pc-elevated border border-pc-border-strong flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80"
|
|
>
|
|
<X size={10} className="text-pc-text" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Markdown preview */}
|
|
{showPreview && text.trim() && (
|
|
<div className="mb-3 px-1 max-h-[200px] overflow-y-auto rounded-2xl border border-pc-border bg-pc-elevated/30 p-3 text-sm text-pc-text prose prose-invert prose-sm max-w-none [&_pre]:bg-pc-elevated [&_pre]:rounded-lg [&_pre]:p-2 [&_code]:text-cyan-300 [&_a]:text-cyan-400">
|
|
<Suspense fallback={<span className="text-pc-text-muted text-xs">Loading…</span>}>
|
|
<ReactMarkdown remarkPlugins={_remarkGfm ? [_remarkGfm] : []}>{text}</ReactMarkdown>
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-3">
|
|
{/* File picker button */}
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={disabled}
|
|
className="shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)] transition-colors disabled:opacity-30"
|
|
title={t('chat.attachFile')}
|
|
aria-label={t('chat.attachFile')}
|
|
>
|
|
<Paperclip size={18} />
|
|
</button>
|
|
{/* Markdown preview toggle — hidden on mobile */}
|
|
<button
|
|
onClick={() => setShowPreview(v => { const next = !v; localStorage.setItem('pinchchat-md-preview', next ? '1' : '0'); return next; })}
|
|
className={`hidden sm:flex shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 items-center justify-center transition-colors ${showPreview ? 'text-pc-accent-light bg-[var(--pc-accent-glow)]' : 'text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)]'}`}
|
|
title={showPreview ? t('chat.hidePreview') : t('chat.showPreview')}
|
|
aria-label={showPreview ? t('chat.hidePreview') : t('chat.showPreview')}
|
|
>
|
|
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
</button>
|
|
{/* Syntax highlight toggle — hidden on mobile */}
|
|
<button
|
|
onClick={() => setHighlightEnabled(v => { const next = !v; localStorage.setItem('pinchchat-syntax-hl', next ? '1' : '0'); return next; })}
|
|
className={`hidden sm:flex shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 items-center justify-center transition-colors ${highlightEnabled ? 'text-pc-accent-light bg-[var(--pc-accent-glow)]' : 'text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)]'}`}
|
|
title={highlightEnabled ? 'Disable syntax highlight' : 'Enable syntax highlight'}
|
|
aria-label={highlightEnabled ? 'Disable syntax highlight' : 'Enable syntax highlight'}
|
|
>
|
|
<Highlighter size={18} />
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
|
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
|
|
/>
|
|
|
|
<HighlightedTextarea
|
|
id="chat-input"
|
|
ref={textareaRef}
|
|
value={text}
|
|
highlightEnabled={highlightEnabled}
|
|
onChange={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder={t('chat.inputPlaceholder')}
|
|
aria-label={t('chat.inputLabel')}
|
|
disabled={disabled}
|
|
rows={1}
|
|
className="flex-1 bg-transparent resize-none rounded-2xl border border-pc-border bg-pc-input/35 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-muted outline-none transition-all max-h-[200px]"
|
|
/>
|
|
{isGenerating ? (
|
|
<button
|
|
onClick={onAbort}
|
|
className="shrink-0 h-11 px-4 rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors flex items-center gap-2"
|
|
>
|
|
<Square size={16} />
|
|
<span className="text-sm hidden sm:inline">{t('chat.stop')}</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={(!text.trim() && files.length === 0) || disabled}
|
|
aria-label={t('chat.send')}
|
|
className="shrink-0 h-11 px-5 rounded-2xl bg-[var(--pc-accent)] text-white font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(var(--pc-accent-rgb),0.15)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
|
|
>
|
|
<Send size={16} />
|
|
<span className="hidden sm:inline">{t('chat.send')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|