chore(frontend): fix corn task (#3)

This commit is contained in:
Haze
2026-02-07 01:28:38 +08:00
committed by GitHub
Unverified
parent fa6c23b82a
commit e6317cafd6
12 changed files with 530 additions and 4399 deletions

View File

@@ -1,21 +1,64 @@
/**
* Chat Input Component
* Textarea with send button. Enter to send, Shift+Enter for new line.
* Textarea with send button and image upload support.
* Enter to send, Shift+Enter for new line.
* Supports: file picker, clipboard paste, drag & drop.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square } from 'lucide-react';
import { Send, Square, ImagePlus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
export interface ChatAttachment {
type: 'image';
mimeType: string;
fileName: string;
content: string; // base64
preview: string; // data URL for display
}
interface ChatInputProps {
onSend: (text: string) => void;
onSend: (text: string, attachments?: ChatAttachment[]) => void;
disabled?: boolean;
sending?: boolean;
}
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
function fileToAttachment(file: File): Promise<ChatAttachment> {
return new Promise((resolve, reject) => {
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
reject(new Error(`Unsupported image type: ${file.type}`));
return;
}
if (file.size > MAX_IMAGE_SIZE) {
reject(new Error('Image too large (max 10MB)'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// Extract base64 content (remove "data:image/png;base64," prefix)
const base64 = dataUrl.split(',')[1];
resolve({
type: 'image',
mimeType: file.type,
fileName: file.name,
content: base64,
preview: dataUrl,
});
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Auto-resize textarea
useEffect(() => {
@@ -25,16 +68,33 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
}
}, [input]);
const addFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files).filter((f) => ACCEPTED_IMAGE_TYPES.includes(f.type));
if (fileArray.length === 0) return;
try {
const newAttachments = await Promise.all(fileArray.map(fileToAttachment));
setAttachments((prev) => [...prev, ...newAttachments]);
} catch (err) {
console.error('Failed to process image:', err);
}
}, []);
const removeAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending;
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || disabled || sending) return;
onSend(trimmed);
if (!canSend) return;
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
setInput('');
// Reset textarea height
setAttachments([]);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [input, disabled, sending, onSend]);
}, [input, attachments, canSend, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -46,37 +106,145 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
[handleSend],
);
// Handle paste (Ctrl/Cmd+V with image)
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
addFiles(imageFiles);
}
},
[addFiles],
);
// Handle drag & drop
const [dragOver, setDragOver] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
if (e.dataTransfer?.files) {
addFiles(e.dataTransfer.files);
}
},
[addFiles],
);
return (
<div className="border-t bg-background p-4">
<div className="flex items-end gap-2 max-w-4xl mx-auto">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
<div
className="bg-background p-4"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="max-w-4xl mx-auto">
{/* Image Previews */}
{attachments.length > 0 && (
<div className="flex gap-2 mb-2 flex-wrap">
{attachments.map((att, idx) => (
<div
key={idx}
className="relative group w-16 h-16 rounded-lg overflow-hidden border border-border"
>
<img
src={att.preview}
alt={att.fileName}
className="w-full h-full object-cover"
/>
<button
onClick={() => removeAttachment(idx)}
className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* Input Row */}
<div className={`flex items-end gap-2 ${dragOver ? 'ring-2 ring-primary rounded-lg' : ''}`}>
{/* Image Upload Button */}
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-[44px] w-[44px] text-muted-foreground hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
className="min-h-[44px] max-h-[200px] resize-none pr-4"
rows={1}
title="Attach image"
>
<ImagePlus className="h-5 w-5" />
</Button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(',')}
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) {
addFiles(e.target.files);
e.target.value = '';
}
}}
/>
{/* Textarea */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
disabled={disabled}
className="min-h-[44px] max-h-[200px] resize-none pr-4"
rows={1}
/>
</div>
{/* Send Button */}
<Button
onClick={handleSend}
disabled={!canSend}
size="icon"
className="shrink-0 h-[44px] w-[44px]"
variant={sending ? 'destructive' : 'default'}
>
{sending ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<Button
onClick={handleSend}
disabled={!input.trim() || disabled}
size="icon"
className="shrink-0 h-10 w-10"
variant={sending ? 'destructive' : 'default'}
>
{sending ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -35,20 +35,17 @@ export function ChatToolbar() {
'focus:outline-none focus:ring-2 focus:ring-ring',
)}
>
{/* Always show current session */}
<option value={currentSessionKey}>
{sessions.find((s) => s.key === currentSessionKey)?.displayName
|| sessions.find((s) => s.key === currentSessionKey)?.label
|| currentSessionKey}
</option>
{/* Other sessions */}
{sessions
.filter((s) => s.key !== currentSessionKey)
.map((s) => (
<option key={s.key} value={s.key}>
{s.displayName || s.label || s.key}
</option>
))}
{/* Render all sessions; if currentSessionKey is not in the list, add it */}
{!sessions.some((s) => s.key === currentSessionKey) && (
<option value={currentSessionKey}>
{currentSessionKey === 'main' ? 'main' : currentSessionKey}
</option>
)}
{sessions.map((s) => (
<option key={s.key} value={s.key}>
{s.displayName || s.label || s.key}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>