Files
socweb/frontend/components/gemini/GeminiInput.tsx
T
gongzhiyongandClaude Sonnet 4.6 fe8c361127 feat(input): replace model selector buttons with Select dropdown
Replace the three pill buttons (Auto/Flash/Pro) in GeminiInput with a
single shadcn/ui Select component. Selection logic and onSelectedModelChange
callback are unchanged; only the UI widget is swapped.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:47:35 +08:00

363 lines
13 KiB
TypeScript

"use client";
import { useRef, useEffect, KeyboardEvent, useState } from "react";
import {
Plus,
Mic,
ArrowUp,
Box,
Search,
Database,
FileText,
Code2,
X,
Loader2,
Paperclip,
RotateCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { uploadAttachment, type AttachmentData } from "@/lib/api";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface Tool {
id: string;
icon: React.ElementType;
label: string;
}
interface UploadingFile {
id: string;
file: File;
status: "uploading" | "done" | "error";
attachment?: AttachmentData;
error?: string;
}
interface GeminiInputProps {
value: string;
onChange: (val: string) => void;
onSubmit: (attachments?: AttachmentData[]) => void;
isLoading?: boolean;
activeTools: Set<string>;
onActiveToolsChange: (tools: Set<string>) => void;
selectedModel: "flash" | "auto" | "pro";
onSelectedModelChange: (model: "flash" | "auto" | "pro") => void;
}
const TOOLS: Tool[] = [
{ id: "search", icon: Search, label: "搜索" },
{ id: "knowledge", icon: Database, label: "内部知识库" },
{ id: "sandbox", icon: Box, label: "沙盒" },
{ id: "document", icon: FileText, label: "文档生成" },
];
export function GeminiInput({
value,
onChange,
onSubmit,
isLoading,
activeTools,
onActiveToolsChange,
selectedModel,
onSelectedModelChange,
}: GeminiInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showTools, setShowTools] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
// Auto-resize textarea
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 200) + "px";
}, [value]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) handleSubmit();
}
};
const toggleTool = (id: string) => {
const next = new Set(activeTools);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onActiveToolsChange(next);
};
const hasUploading = uploadingFiles.some((f) => f.status === "uploading");
const canSend = value.trim().length > 0 && !isLoading && !hasUploading;
const handleSubmit = () => {
const attachments = uploadingFiles
.filter((f) => f.status === "done" && f.attachment)
.map((f) => f.attachment!);
onSubmit(attachments.length > 0 ? attachments : undefined);
setUploadingFiles([]);
};
const doUpload = async (entry: UploadingFile) => {
try {
const attachment = await uploadAttachment(entry.file);
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === entry.id ? { ...f, status: "done", attachment } : f
)
);
} catch (err) {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === entry.id
? { ...f, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
: f
)
);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const newEntries: UploadingFile[] = Array.from(files).map((file) => ({
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file,
status: "uploading" as const,
}));
setUploadingFiles((prev) => [...prev, ...newEntries]);
// Start uploads
newEntries.forEach((entry) => doUpload(entry));
// Reset file input so the same file can be selected again
e.target.value = "";
};
const handleRetryUpload = (entry: UploadingFile) => {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === entry.id ? { ...f, status: "uploading", error: undefined } : f
)
);
doUpload({ ...entry, status: "uploading" });
};
const handleRemoveFile = (id: string) => {
setUploadingFiles((prev) => prev.filter((f) => f.id !== id));
};
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="sticky bottom-0 left-0 right-0 pb-6 pt-4 bg-gradient-to-t from-[var(--gem-bg)] via-[var(--gem-bg)]/90 to-transparent pointer-events-none">
<div className="max-w-3xl mx-auto px-4 pointer-events-auto">
{/* Input container */}
<div className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-3xl shadow-lg focus-within:border-[var(--gem-border-hover)] transition-colors duration-150">
{/* Uploaded files preview */}
{uploadingFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pt-3 pb-1">
{uploadingFiles.map((entry) => (
<div
key={entry.id}
className={cn(
"flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs border max-w-[220px]",
entry.status === "error"
? "bg-red-500/10 border-red-500/30 text-red-400"
: "bg-[var(--gem-surface-2)] border-[var(--gem-border)] text-[var(--gem-text-secondary)]"
)}
>
{entry.status === "uploading" && (
<Loader2 size={14} className="animate-spin flex-shrink-0 text-[var(--gem-blue)]" />
)}
{entry.status === "done" && (
<Paperclip size={14} className="flex-shrink-0 text-[var(--gem-text-muted)]" />
)}
{entry.status === "error" && (
<Paperclip size={14} className="flex-shrink-0" />
)}
<span className="truncate">
{entry.file.name}
{entry.status === "done" && entry.attachment && (
<span className="text-[var(--gem-text-muted)] ml-1">
{formatSize(entry.attachment.size_bytes)}
</span>
)}
</span>
{entry.status === "error" && (
<button
onClick={(e) => { e.stopPropagation(); handleRetryUpload(entry); }}
className="flex-shrink-0 hover:opacity-80 cursor-pointer"
aria-label="重试上传"
title="重试"
>
<RotateCcw size={12} />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleRemoveFile(entry.id); }}
className="flex-shrink-0 hover:opacity-80 cursor-pointer text-[var(--gem-text-muted)]"
aria-label="移除文件"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
{/* Main input row */}
<div className="flex items-end gap-2 px-4 py-3">
{/* Left: File upload button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-1.5 rounded-full text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)] transition-colors duration-150 flex-shrink-0 cursor-pointer mb-0.5"
aria-label="文件上传"
title="上传文件"
>
<Plus size={20} />
</button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
aria-label="文件选择"
/>
{/* Tools toggle */}
<button
onClick={() => setShowTools(!showTools)}
className={cn(
"p-1.5 rounded-full transition-colors duration-150 flex-shrink-0 cursor-pointer mb-0.5",
showTools
? "bg-[var(--gem-blue)]/20 text-[var(--gem-blue)]"
: "text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)]"
)}
aria-label="工具菜单"
title="工具"
aria-expanded={showTools}
>
<Box size={20} />
</button>
{/* Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="阐述你的图片"
rows={1}
className={cn(
"flex-1 bg-transparent text-[var(--gem-text)] text-sm placeholder:text-[var(--gem-text-placeholder)] resize-none outline-none",
"leading-relaxed py-0.5 min-h-[28px] max-h-[200px] overflow-y-auto scrollbar-thin"
)}
aria-label="消息输入"
aria-multiline="true"
/>
{/* Right controls */}
<div className="flex items-center gap-1 flex-shrink-0 mb-0.5">
<button
className="p-1.5 rounded-full text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)] transition-colors duration-150 cursor-pointer"
aria-label="语音输入"
>
<Mic size={20} />
</button>
<button
onClick={() => canSend && handleSubmit()}
disabled={!canSend}
aria-label="发送消息"
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center transition-all duration-150",
canSend
? "bg-[var(--gem-blue)] text-white hover:bg-[var(--gem-blue-hover)] cursor-pointer shadow-md"
: "bg-[var(--gem-surface-2)] text-[var(--gem-text-placeholder)] cursor-not-allowed"
)}
>
<ArrowUp
size={18}
className={cn(canSend ? "translate-y-px" : "")}
/>
</button>
</div>
</div>
{/* Bottom row: Model selector */}
<div className="flex items-center justify-end px-4 pb-3 pt-0">
<Select
value={selectedModel}
onValueChange={(val) =>
onSelectedModelChange(val as "flash" | "auto" | "pro")
}
>
<SelectTrigger className="h-7 w-24 rounded-full border border-[var(--gem-border)] bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] text-xs px-3 py-0 focus:ring-0 focus:ring-offset-0 hover:bg-[var(--gem-surface-3)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer [&>svg]:opacity-60">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--gem-surface)] border-[var(--gem-border)] text-[var(--gem-text)] text-xs rounded-xl min-w-[6rem]">
<SelectItem value="auto" className="text-xs cursor-pointer focus:bg-[var(--gem-surface-2)]">Auto</SelectItem>
<SelectItem value="flash" className="text-xs cursor-pointer focus:bg-[var(--gem-surface-2)]">Flash</SelectItem>
<SelectItem value="pro" className="text-xs cursor-pointer focus:bg-[var(--gem-surface-2)]">Pro</SelectItem>
</SelectContent>
</Select>
</div>
{/* Tools panel (collapsible, below input) */}
{showTools && (
<div className="px-4 pb-3 pt-2 border-t border-[var(--gem-surface-2)]">
<div className="flex items-center gap-2 flex-wrap">
{TOOLS.map((tool) => {
const Icon = tool.icon;
const isActive = activeTools.has(tool.id);
return (
<button
key={tool.id}
onClick={() => toggleTool(tool.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer whitespace-nowrap",
isActive
? "bg-[var(--gem-blue)] text-white border border-[var(--gem-blue)]"
: "bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] border border-transparent hover:bg-[var(--gem-surface-3)] hover:text-[var(--gem-text)]"
)}
aria-pressed={isActive}
>
<Icon size={14} />
<span>{tool.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Disclaimer */}
<p className="text-center text-[11px] text-[var(--gem-text-placeholder)] mt-2.5">
运营大脑可能会出错,请仔细检查其回复。{" "}
<button className="underline hover:text-[var(--gem-text-muted)] transition-colors cursor-pointer">
你的隐私与运营大脑应用
</button>
</p>
</div>
</div>
);
}