2026-05-08 02:29:07 +08:00
|
|
|
import { useState, useRef } from 'react';
|
|
|
|
|
import { Terminal, Image, Play, FileText, ArrowUp, X, Paperclip } from 'lucide-react';
|
2026-05-07 02:41:01 +08:00
|
|
|
|
2026-05-07 04:09:00 +08:00
|
|
|
const SLASH_COMMANDS = [
|
|
|
|
|
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
|
|
|
|
|
{ cmd: '/status', desc: '查看管线进度', icon: Terminal },
|
|
|
|
|
{ cmd: '/images', desc: '生成图片', icon: Image },
|
|
|
|
|
{ cmd: '/list', desc: '列出可用账号', icon: FileText },
|
|
|
|
|
{ cmd: '/help', desc: '显示帮助', icon: Terminal },
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-08 02:29:07 +08:00
|
|
|
export interface ImageAttachment {
|
|
|
|
|
data: string;
|
|
|
|
|
mimeType: string;
|
|
|
|
|
preview: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 03:16:48 +08:00
|
|
|
export function ChatInput({ onSend, disabled, connecting }: { onSend: (content: string, images?: Array<{ type: 'image'; data: string; mimeType: string }>) => void; disabled?: boolean; connecting?: boolean }) {
|
2026-05-07 02:41:01 +08:00
|
|
|
const [input, setInput] = useState('');
|
2026-05-07 04:09:00 +08:00
|
|
|
const [showCmds, setShowCmds] = useState(false);
|
|
|
|
|
const [cmdIdx, setCmdIdx] = useState(0);
|
2026-05-08 02:29:07 +08:00
|
|
|
const [images, setImages] = useState<ImageAttachment[]>([]);
|
|
|
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const textRef = useRef<HTMLTextAreaElement>(null);
|
2026-05-07 02:41:01 +08:00
|
|
|
|
2026-05-07 04:09:00 +08:00
|
|
|
const matchingCmds = input.startsWith('/')
|
|
|
|
|
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
|
|
|
|
|
: [];
|
|
|
|
|
|
2026-05-08 02:29:07 +08:00
|
|
|
const readFiles = (files: File[]) => {
|
|
|
|
|
if (files.length === 0) return;
|
|
|
|
|
let loaded = 0;
|
|
|
|
|
const results: ImageAttachment[] = [];
|
|
|
|
|
files.forEach((file, i) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = () => {
|
|
|
|
|
const base64 = (reader.result as string).split(',')[1];
|
|
|
|
|
results[i] = { data: base64, mimeType: file.type, preview: reader.result as string };
|
|
|
|
|
loaded++;
|
|
|
|
|
if (loaded === files.length) {
|
|
|
|
|
setImages((prev) => [...prev, ...results]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
if (e.target.files) readFiles(Array.from(e.target.files));
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!dragging) setDragging(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setDragging(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setDragging(false);
|
|
|
|
|
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
|
|
|
|
if (imageFiles.length > 0) readFiles(imageFiles);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeImage = (idx: number) => {
|
|
|
|
|
setImages((prev) => prev.filter((_, i) => i !== idx));
|
|
|
|
|
};
|
2026-05-08 00:41:09 +08:00
|
|
|
|
2026-05-07 02:41:01 +08:00
|
|
|
const handleSend = () => {
|
2026-05-08 02:29:07 +08:00
|
|
|
const text = input.trim();
|
|
|
|
|
if ((!text && images.length === 0) || disabled) return;
|
2026-05-08 03:15:21 +08:00
|
|
|
const imgs = images.length > 0 ? images.map(({ data, mimeType }) => ({ type: 'image' as const, data, mimeType })) : undefined;
|
2026-05-08 02:29:07 +08:00
|
|
|
onSend(text || '(图片)', imgs);
|
2026-05-07 02:41:01 +08:00
|
|
|
setInput('');
|
2026-05-08 02:29:07 +08:00
|
|
|
setImages([]);
|
2026-05-07 04:14:16 +08:00
|
|
|
setShowCmds(false);
|
2026-05-08 02:29:07 +08:00
|
|
|
textRef.current?.focus();
|
2026-05-07 02:41:01 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
2026-05-07 04:09:00 +08:00
|
|
|
if (showCmds && matchingCmds.length > 0) {
|
2026-05-07 04:14:16 +08:00
|
|
|
if (e.key === 'Tab') { e.preventDefault(); setInput(matchingCmds[cmdIdx % matchingCmds.length].cmd + ' '); setShowCmds(false); return; }
|
2026-05-07 04:09:00 +08:00
|
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setCmdIdx((i) => (i + 1) % matchingCmds.length); return; }
|
|
|
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); setCmdIdx((i) => (i - 1 + matchingCmds.length) % matchingCmds.length); return; }
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'Escape') { setShowCmds(false); return; }
|
2026-05-07 02:41:01 +08:00
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSend();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 02:29:07 +08:00
|
|
|
const canSend = (input.trim().length > 0 || images.length > 0) && !disabled;
|
2026-05-07 04:14:16 +08:00
|
|
|
|
2026-05-07 02:41:01 +08:00
|
|
|
return (
|
2026-05-07 04:09:00 +08:00
|
|
|
<div className="relative">
|
2026-05-07 04:14:16 +08:00
|
|
|
{/* Slash command menu */}
|
2026-05-07 04:09:00 +08:00
|
|
|
{showCmds && matchingCmds.length > 0 && (
|
2026-05-08 02:29:07 +08:00
|
|
|
<div className="mx-6 mb-1 bg-white border border-zinc-200 rounded-2xl shadow-xl shadow-zinc-200/50 overflow-hidden animate-in slide-in-from-bottom-2">
|
2026-05-07 04:09:00 +08:00
|
|
|
{matchingCmds.map((c, i) => {
|
|
|
|
|
const Icon = c.icon;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={c.cmd}
|
2026-05-08 02:29:07 +08:00
|
|
|
onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); textRef.current?.focus(); }}
|
|
|
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
|
|
|
|
i === (cmdIdx % matchingCmds.length) ? 'bg-zinc-50 text-zinc-900' : 'text-zinc-500 hover:bg-zinc-50'
|
2026-05-07 04:09:00 +08:00
|
|
|
}`}
|
|
|
|
|
>
|
2026-05-07 04:14:16 +08:00
|
|
|
<Icon size={15} className="text-zinc-400" />
|
2026-05-08 02:29:07 +08:00
|
|
|
<span className="font-mono font-medium text-xs">{c.cmd}</span>
|
2026-05-07 04:14:16 +08:00
|
|
|
<span className="text-zinc-400 text-xs">{c.desc}</span>
|
2026-05-07 04:09:00 +08:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-07 04:14:16 +08:00
|
|
|
|
2026-05-08 02:29:07 +08:00
|
|
|
{/* Input area */}
|
|
|
|
|
<div
|
|
|
|
|
className="px-5 pb-5 pt-3"
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
onDragLeave={handleDragLeave}
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
>
|
2026-05-07 04:14:16 +08:00
|
|
|
<div className={`
|
2026-05-08 02:29:07 +08:00
|
|
|
bg-white rounded-3xl border transition-all duration-300 relative
|
|
|
|
|
${dragging
|
|
|
|
|
? 'border-zinc-400 shadow-xl shadow-zinc-200/60'
|
|
|
|
|
: disabled
|
|
|
|
|
? 'border-zinc-100 opacity-50'
|
|
|
|
|
: 'border-zinc-200/80 shadow-lg shadow-zinc-100/50 focus-within:border-zinc-300 focus-within:shadow-xl focus-within:shadow-zinc-200/40'
|
|
|
|
|
}
|
2026-05-07 04:14:16 +08:00
|
|
|
`}>
|
2026-05-08 02:29:07 +08:00
|
|
|
{/* Drop overlay */}
|
|
|
|
|
{dragging && (
|
|
|
|
|
<div className="absolute inset-0 rounded-3xl bg-zinc-50/80 backdrop-blur-sm z-10 flex items-center justify-center pointer-events-none">
|
|
|
|
|
<span className="text-sm text-zinc-400 font-medium">松开以添加图片</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{/* Image preview strip */}
|
|
|
|
|
{images.length > 0 && (
|
|
|
|
|
<div className="flex flex-wrap gap-2.5 px-5 pt-4">
|
|
|
|
|
{images.map((img, i) => (
|
|
|
|
|
<div key={i} className="relative group/att">
|
|
|
|
|
<img src={img.preview} alt="" className="w-20 h-20 object-cover rounded-2xl shadow-sm" />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => removeImage(i)}
|
|
|
|
|
className="absolute -top-2 -right-2 w-5 h-5 bg-zinc-900 text-white rounded-full flex items-center justify-center opacity-0 group-hover/att:opacity-100 transition-all duration-200 scale-75 group-hover/att:scale-100"
|
|
|
|
|
>
|
|
|
|
|
<X size={10} strokeWidth={3} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Input row */}
|
|
|
|
|
<div className="flex items-end gap-1 px-3 py-3">
|
|
|
|
|
{/* Attachment button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => fileRef.current?.click()}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
className="flex-shrink-0 w-10 h-10 rounded-2xl flex items-center justify-center text-zinc-300 hover:text-zinc-500 hover:bg-zinc-50 active:scale-95 transition-all duration-200 disabled:opacity-40"
|
|
|
|
|
>
|
|
|
|
|
<Paperclip size={20} strokeWidth={1.8} />
|
|
|
|
|
</button>
|
|
|
|
|
<input
|
|
|
|
|
ref={fileRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept="image/*"
|
|
|
|
|
multiple
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
className="hidden"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
ref={textRef}
|
|
|
|
|
value={input}
|
|
|
|
|
onChange={(e) => setInput(e.target.value)}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
rows={1}
|
2026-05-08 02:47:23 +08:00
|
|
|
placeholder={disabled ? (connecting ? '连接中...' : '等待回复中...') : '输入消息...'}
|
2026-05-08 02:29:07 +08:00
|
|
|
className="flex-1 bg-transparent text-[15px] resize-none outline-none placeholder:text-zinc-300 text-zinc-800 min-h-[40px] max-h-[140px] py-2 leading-relaxed"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
style={{ height: 'auto' }}
|
|
|
|
|
onInput={(e) => {
|
|
|
|
|
const el = e.currentTarget;
|
|
|
|
|
el.style.height = 'auto';
|
|
|
|
|
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Send button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSend}
|
|
|
|
|
disabled={!canSend}
|
|
|
|
|
className={`
|
|
|
|
|
flex-shrink-0 w-10 h-10 rounded-2xl flex items-center justify-center transition-all duration-200
|
|
|
|
|
${canSend
|
|
|
|
|
? 'bg-zinc-900 text-white hover:bg-zinc-800 active:scale-90'
|
|
|
|
|
: 'bg-zinc-100 text-zinc-300'}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
<ArrowUp size={18} strokeWidth={2.5} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-07 04:09:00 +08:00
|
|
|
</div>
|
2026-05-07 02:41:01 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|