feat: P4 多模态 — 图片上传 + 沙盒 vision 支持

后端:
- ChatMessage 接口支持 text | image_url 多部分内容
- sandbox chat 接受 images[] 参数
- 自动将图片转为 image_url 格式发送到 AI
- 上传模块支持 jpg/png/gif/webp/svg

前端:
- 沙盒输入区 + 图片上传按钮
- 图片缩略图预览 + 删除
- 上传后随消息发送到 API
- uploads/ 目录持久化存储
This commit is contained in:
yuzhiran-dev
2026-05-18 14:15:56 +08:00
parent 0ed017b212
commit 79d7535c1c
6 changed files with 71 additions and 15 deletions
+42 -2
View File
@@ -75,6 +75,9 @@ function SandboxPage() {
const [currentSessionId, setCurrentSessionId] = useState<number | null>(null);
const [renamingId, setRenamingId] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState('');
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { isLoggedIn } = useAuth();
@@ -156,7 +159,7 @@ function SandboxPage() {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens }),
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens, ...(uploadedImages.length > 0 ? { images: uploadedImages } : {}) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
@@ -164,6 +167,7 @@ function SandboxPage() {
if (data.conversationId) setConversationId(data.conversationId);
if (data.sessionId) setCurrentSessionId(data.sessionId);
if (quota) setQuota({ ...quota, used: quota.used + 1, remaining: quota.remaining - 1 });
setUploadedImages([]);
loadSessions(tk);
} else {
await new Promise(r => setTimeout(r, 600));
@@ -515,11 +519,47 @@ function SandboxPage() {
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
</div>
)}
{uploadedImages.length > 0 && (
<div className="flex gap-2 mb-2 flex-wrap">
{uploadedImages.map((img, i) => (
<div key={i} className="relative group">
<img src={img} alt="" className="w-16 h-16 object-cover rounded-lg border border-border" />
<button onClick={() => setUploadedImages(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">×</button>
</div>
))}
</div>
)}
<form onSubmit={handleSend} className="flex gap-2">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden"
onChange={async e => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const formData = new FormData()
formData.append('file', file)
try {
const tk = getToken()
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: tk ? { Authorization: `Bearer ${tk}` } : {},
body: formData,
})
const data = await res.json()
if (data.url) setUploadedImages(prev => [...prev, data.url])
} catch {}
setUploading(false)
}} />
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading || !isLoggedIn}
className="px-2.5 py-2.5 text-muted-foreground hover:text-foreground border border-input rounded-xl hover:bg-accent disabled:opacity-50">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
<input type="text" value={input} onChange={e => setInput(e.target.value)}
placeholder={t.sandbox.placeholder} disabled={sending}
className="flex-1 px-4 py-2.5 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" />
<button type="submit" disabled={sending || !input.trim()}
<button type="submit" disabled={sending || (!input.trim() && uploadedImages.length === 0)}
className="px-5 py-2.5 bg-brand-600 text-white text-sm font-medium rounded-xl hover:bg-brand-700 disabled:opacity-50">
{sending ? t.sandbox.sending : t.sandbox.send}
</button>