Files
ai-learning-platform/frontend/src/app/sandbox/page.tsx
T
yuzhiran-dev 23edb74bce feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善
- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成
- 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作
- 角色管理:表格布局 + Dialog 表单 + 权限勾选
- 配置统一:config.ts 单一数据源
- API 审计:补齐 status toggle / comments 端点
- 暗黑模式硬件编码颜色全部替换为 CSS 变量
2026-05-20 18:29:00 +08:00

587 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useEffect, FormEvent, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { getToken, apiFetch } from '@/lib/auth';
import { DEFAULT_MODEL } from '@/lib/models';
import { ModelSelector } from '@/components/ui/model-selector';
import { useT } from '@/i18n';
import { CodeBlock } from '@/components/ui/code-block';
import { API_BASE } from '@/lib/config';
interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface SessionItem {
id: number;
conversationId: string;
model: string;
title: string;
createdAt: string;
tokens: number;
}
function extractCodeBlocks(content: string): string[] {
const blocks: string[] = [];
const regex = /```(?:\w+)?\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(content)) !== null) {
const code = match[1].trim();
if (code.length > 0) blocks.push(code);
}
return blocks;
}
export default function SandboxPageWrapper() {
return (
<Suspense fallback={
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="h-8 w-48 bg-muted rounded-lg animate-pulse mb-4" />
<div className="h-[65vh] bg-muted rounded-2xl animate-pulse" />
</div>
}>
<SandboxPage />
</Suspense>
);
}
function SandboxPage() {
const searchParams = useSearchParams();
const t = useT();
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '你好!我是宇之然 AI 助手。你可以问我任何问题,我会尽力帮你解答。\n\n试试问我关于 AI、编程、写作、办公效率等方面的问题!' },
]);
const [input, setInput] = useState('');
const [model, setModel] = useState(DEFAULT_MODEL);
const [scene, setScene] = useState('');
const [SCENES, setSCENES] = useState<{ id: string; name: string; icon: string; systemPrompt: string; starters: string[] }[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
const [sending, setSending] = useState(false);
const [showParams, setShowParams] = useState(false);
const [temperature, setTemperature] = useState(0.7);
const [topP, setTopP] = useState(1);
const [maxTokens, setMaxTokens] = useState(2000);
const [quota, setQuota] = useState<{ used: number; remaining: number } | null>(null);
const [sessions, setSessions] = useState<SessionItem[]>([]);
const [sessionsOpen, setSessionsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [conversationId, setConversationId] = useState(() => crypto.randomUUID());
const [sessionFeedback, setSessionFeedback] = useState<Record<number, string | null>>({});
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();
useEffect(() => {
const tk = getToken();
if (tk) {
fetch(`${API_BASE}/sandbox/quota`, {
headers: { Authorization: `Bearer ${tk}` },
}).then(r => r.json()).then(data => {
if (data.remaining !== undefined) setQuota(data);
}).catch(() => {});
loadSessions(tk);
}
}, [isLoggedIn]);
useEffect(() => {
fetch(`${API_BASE}/skills`)
.then(r => r.json())
.then(data => {
const scenes = (data.items || []).map((s: any) => ({ id: s.id, name: s.name, icon: s.icon, systemPrompt: s.systemPrompt, starters: s.starters }));
setSCENES(scenes);
const skillParam = searchParams.get('skill');
const initialScene = scenes.find((s: any) => s.id === skillParam) ? skillParam : (scenes[0]?.id || '');
setScene(initialScene);
})
.finally(() => setSkillsLoading(false));
}, []);
useEffect(() => {
if (messages.some(m => m.role === 'user') && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [messages]);
function loadSessions(tk?: string) {
const token = tk || getToken();
if (!token) return;
const params = searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : '';
fetch(`${API_BASE}/sandbox/sessions${params}`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json()).then(data => {
if (data.items) setSessions(data.items);
}).catch(() => {});
}
function handleSceneChange(sceneId: string) {
setScene(sceneId);
const s = SCENES.find(x => x.id === sceneId);
setMessages([
{ role: 'assistant', content: `欢迎来到 **${s?.name || sceneId}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
]);
}
async function handleSend(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const userMsg: Message = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
setInput('');
setSending(true);
try {
const tk = getToken();
let reply = '';
if (tk) {
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
const systemPrompt = curScene?.systemPrompt || '你是一个智能 AI 助手';
const apiMessages = [
{ role: 'system', content: systemPrompt },
...messages,
userMsg,
].map(m => ({ role: m.role, content: m.content }));
const res = await fetch(`${API_BASE}/sandbox/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
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 || '请求失败');
reply = data.reply;
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, 300));
reply = '📝 注册登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用。';
}
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (e: any) {
if (e.message.includes('今日沙箱使用次数已用完')) {
setMessages(prev => [...prev, { role: 'assistant', content: '今日沙箱使用次数已用完。' + (isLoggedIn ? '' : ' 登录后可获得更多使用次数。') }]);
} else if (e.message.includes('未登录') || e.message.includes('Unauthorized')) {
setMessages(prev => [...prev, { role: 'assistant', content: '登录已过期,请重新登录后再试。' }]);
} else {
setMessages(prev => [...prev, { role: 'assistant', content: `出错啦:${e.message}` }]);
}
} finally {
setSending(false);
}
}
async function loadSession(sessionId: number) {
const tk = getToken();
if (!tk) return;
try {
const res = await fetch(`${API_BASE}/sandbox/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${tk}` },
});
const data = await res.json();
if (data.messages) {
setMessages(data.messages.filter((m: any) => m.role !== 'system'));
setModel(data.model);
setConversationId(data.conversationId);
setCurrentSessionId(data.id);
setSessionFeedback(prev => ({ ...prev, [data.id]: data.feedback || null }));
}
} catch {}
}
async function deleteSession(sessionId: number) {
const tk = getToken();
if (!tk) return;
try {
await fetch(`${API_BASE}/sandbox/sessions/${sessionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${tk}` },
});
setSessions(prev => prev.filter(s => s.id !== sessionId));
} catch {}
}
async function renameSession(sessionId: number, title: string) {
const tk = getToken();
if (!tk || !title.trim()) return;
try {
await fetch(`${API_BASE}/sandbox/sessions/${sessionId}/rename`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}` },
body: JSON.stringify({ title: title.trim() }),
});
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, title: title.trim() } : s));
setRenamingId(null);
} catch {}
}
function newChat() {
setConversationId(crypto.randomUUID());
setCurrentSessionId(null);
const s = SCENES.find(x => x.id === scene);
setMessages([
{ role: 'assistant', content: `欢迎来到 **${s?.name || scene}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
]);
}
async function handleFeedback(sessionId: number, value: 'LIKE' | 'DISLIKE') {
const tk = getToken();
if (!tk) return;
const newVal = sessionFeedback[sessionId] === value ? null : value;
setSessionFeedback(prev => ({ ...prev, [sessionId]: newVal }));
try {
await fetch(`${API_BASE}/sandbox/sessions/${sessionId}/feedback`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}` },
body: JSON.stringify({ feedback: newVal }),
});
} catch {}
}
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return t.sandbox.justNow;
if (diff < 3600000) return t.sandbox.minutesAgo.replace('{n}', String(Math.floor(diff / 60000)));
if (diff < 86400000) return t.sandbox.hoursAgo.replace('{n}', String(Math.floor(diff / 3600000)));
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
async function shareSessionLink() {
if (!getToken() || !currentSessionId) { alert('请先登录'); return; }
try {
const tk = getToken();
const res = await fetch(`${API_BASE}/sandbox/sessions/${currentSessionId}/share`, {
method: 'POST', headers: { Authorization: `Bearer ${tk}` },
});
const data = await res.json();
if (data.shareUrl) {
await navigator.clipboard.writeText(data.shareUrl);
alert('链接已复制');
}
} catch { alert('生成分享链接失败'); }
}
function shareToCommunity(content: string, title?: string) {
if (!getToken()) { alert('请先登录'); return; }
apiFetch('/community/posts', {
method: 'POST',
body: JSON.stringify({
title: title || `AI对话分享 - ${new Date().toLocaleDateString()}`,
content: `【AI沙箱对话分享】\n\n${content}\n\n---\n来自宇之然AI沙箱`,
tags: '沙箱分享,AI对话',
}),
}).then(() => alert('分享成功!')).catch(() => alert('分享失败'));
}
const currentScene = SCENES.find(s => s.id === scene) || SCENES[0] || null;
if (skillsLoading) return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 bg-muted rounded-lg" />
<div className="h-[65vh] bg-muted rounded-2xl" />
</div>
</div>
);
const isNewChat = messages.length <= 1 && messages[0]?.role === 'assistant';
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<button onClick={() => setSessionsOpen(!sessionsOpen)}
className="lg:hidden p-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h1 className="text-2xl font-bold text-foreground">{t.sandbox.title}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{t.sandbox.subtitle}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<ModelSelector value={model} onChange={setModel} className="w-40 sm:w-48" />
{!isLoggedIn && (
<Link href="/auth"
className="px-4 py-1.5 text-sm font-medium text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50">
{t.sandbox.loginForMore}
</Link>
)}
</div>
</div>
<div className="flex gap-4">
{isLoggedIn && (
<>
<div className={`${sessionsOpen ? 'fixed inset-0 z-40 bg-black/50 lg:static lg:bg-transparent' : 'hidden'} lg:block lg:w-72 shrink-0`}>
<div className={`${sessionsOpen ? 'fixed left-0 top-0 bottom-0 w-80 z-50' : ''} lg:static lg:w-72 bg-card border border-border rounded-2xl overflow-hidden flex flex-col`} style={{ maxHeight: '75vh' }}>
<div className="p-3 border-b border-border flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{t.sandbox.history}</span>
<button onClick={newChat}
className="text-xs px-3 py-1 bg-brand-600 text-white rounded-lg hover:bg-brand-700">
{t.sandbox.newChat}
</button>
</div>
<div className="p-2 border-b border-border">
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
placeholder={t.sandbox.searchHistory} onKeyDown={e => { if (e.key === 'Enter') loadSessions(); }}
className="w-full px-3 py-1.5 bg-background border border-border rounded-lg text-xs focus:outline-none focus:border-brand-400" />
</div>
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">{t.sandbox.noHistory}</div>
) : sessions.map(s => (
<div key={s.id} onClick={() => { if (renamingId !== s.id) { loadSession(s.id); setSessionsOpen(false); } }}
className="group px-3 py-2.5 hover:bg-accent cursor-pointer border-b border-border/50"
onDoubleClick={() => { setRenamingId(s.id); setRenameValue(s.title); }}>
{renamingId === s.id ? (
<input type="text" value={renameValue} onChange={e => setRenameValue(e.target.value)}
autoFocus onClick={e => e.stopPropagation()}
onBlur={() => renameSession(s.id, renameValue)}
onKeyDown={e => { if (e.key === 'Enter') { e.stopPropagation(); renameSession(s.id, renameValue); } if (e.key === 'Escape') setRenamingId(null); }}
className="w-full px-1 py-0.5 text-xs font-medium bg-background border border-border rounded focus:outline-none" />
) : (
<div className="text-xs font-medium text-foreground truncate">{s.title}</div>
)}
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-muted-foreground">{formatTime(s.createdAt)} · {s.model}</span>
<button onClick={e => { e.stopPropagation(); deleteSession(s.id); }}
className="opacity-0 group-hover:opacity-100 text-[10px] text-red-500 hover:text-red-700">
{t.common.delete}
</button>
</div>
</div>
))}
</div>
</div>
{sessionsOpen && (
<div className="fixed inset-0 z-40 lg:hidden" onClick={() => setSessionsOpen(false)} />
)}
</div>
</>
)}
<div className="flex-1 min-w-0">
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
{SCENES.map(s => (
<button key={s.id} onClick={() => handleSceneChange(s.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium whitespace-nowrap border transition-colors shrink-0 ${
scene === s.id
? 'bg-brand-600 text-white border-brand-600'
: 'bg-card text-muted-foreground border-border hover:border-brand-400 hover:text-foreground'
}`}>
<span>{s.icon}</span>
<span>{s.name}</span>
</button>
))}
</div>
<div className="flex items-center justify-between mb-2">
<div />
<button onClick={() => setShowParams(!showParams)}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg border transition-colors ${
showParams ? 'bg-accent text-foreground border-border' : 'text-muted-foreground border-border hover:text-foreground hover:bg-accent'
}`}>
<svg className={`w-3.5 h-3.5 transition-transform ${showParams ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
{t.sandbox.advancedParams}
</button>
</div>
{showParams && (
<div className="bg-card border border-border rounded-xl p-4 mb-3 space-y-3">
{[
{ label: t.sandbox.temperature, min: 0, max: 2, step: 0.1, val: temperature, set: setTemperature, fmt: (v: number) => v.toFixed(1) },
{ label: t.sandbox.topP, min: 0, max: 1, step: 0.05, val: topP, set: setTopP, fmt: (v: number) => v.toFixed(2) },
{ label: t.sandbox.maxTokens, min: 100, max: 8192, step: 100, val: maxTokens, set: setMaxTokens, fmt: (v: number) => String(v) },
].map(p => (
<div key={p.label}>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">{p.label}</label>
<span className="text-xs text-muted-foreground tabular-nums">{p.fmt(p.val)}</span>
</div>
<input type="range" min={p.min} max={p.max} step={p.step} value={p.val}
onChange={e => p.set(parseFloat(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
</div>
))}
</div>
)}
<div className="bg-card rounded-2xl border border-border shadow-sm overflow-hidden flex flex-col" style={{ maxHeight: '65vh' }}>
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
{isNewChat && (
<div className="flex flex-wrap gap-2 mb-4">
{currentScene?.starters?.map((q, i) => (
<button key={i} onClick={() => setInput(q)}
className="px-3 py-1.5 text-xs bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground transition-colors">
{q}
</button>
))}
</div>
)}
{messages.map((msg, i) => {
if (msg.role === 'system') return null;
const isLastAssistant = msg.role === 'assistant' && i === messages.length - 1;
return (
<div key={i}>
<div className={`flex items-start gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0">Y</div>
)}
<div className={`max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${msg.role === 'user' ? 'bg-brand-600 text-white rounded-tr-none' : 'bg-muted text-foreground rounded-tl-none'}`}>
{(() => {
const parts = msg.content.split(/(```(?:\w+)?\n[\s\S]*?```)/g)
return parts.map((part, pi) => {
const match = part.match(/^```(\w*)\n([\s\S]*)```$/)
if (match) {
const [, lang, code] = match
return <CodeBlock key={pi} code={code.trim()} language={lang || undefined} />
}
if (pi > 0 && parts[pi-1].startsWith('```')) return null
return <span key={pi} className="whitespace-pre-wrap">{part}</span>
})
})()}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 bg-muted-foreground/20 rounded-xl flex items-center justify-center text-xs font-bold shrink-0"></div>
)}
</div>
{msg.role === 'assistant' && currentSessionId && isLastAssistant && (
<div className="flex items-center gap-2 mt-1 ml-11">
<button onClick={() => handleFeedback(currentSessionId, 'LIKE')}
className={`text-xs px-2 py-1 rounded-full border transition-colors ${sessionFeedback[currentSessionId] === 'LIKE' ? 'bg-green-500/10 text-green-600 border-green-300' : 'text-muted-foreground border-border hover:border-green-300 hover:text-green-600'}`}>
{t.sandbox.helpful}
</button>
<button onClick={() => handleFeedback(currentSessionId, 'DISLIKE')}
className={`text-xs px-2 py-1 rounded-full border transition-colors ${sessionFeedback[currentSessionId] === 'DISLIKE' ? 'bg-red-500/10 text-red-600 border-red-300' : 'text-muted-foreground border-border hover:border-red-300 hover:text-red-600'}`}>
{t.sandbox.notHelpful}
</button>
</div>
)}
{msg.role === 'assistant' && extractCodeBlocks(msg.content).length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 ml-11">
{extractCodeBlocks(msg.content).map((code, ci) => (
<button key={ci} onClick={() => window.open(`/sandbox/code?code=${btoa(code)}`, '_blank')}
className="text-xs px-2.5 py-1 rounded-full border border-border text-brand-600 hover:bg-accent transition-colors">
{t.sandbox.runInCodeSandbox}
</button>
))}
</div>
)}
</div>
);
})}
{sending && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0">Y</div>
<div className="bg-muted rounded-2xl rounded-tl-none px-4 py-2.5">
<span className="inline-flex gap-1">
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" />
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
)}
{!sending && messages.length > 2 && (
<div className="flex justify-end gap-2">
<button onClick={shareSessionLink}
className="text-xs text-brand-600 hover:underline">
{t.sandbox.copyShareLink}
</button>
<button onClick={() => {
const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant');
if (lastAssistantMsg) shareToCommunity(lastAssistantMsg.content);
}} className="text-xs text-brand-600 hover:underline">
{t.sandbox.shareToCommunity}
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-border p-4">
{quota && (
<div className="text-xs text-muted-foreground mb-2">
{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() && 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>
</form>
</div>
</div>
<div className="mt-4 text-center text-xs text-muted-foreground">
{t.sandbox.aiReplyDisclaimer}{!isLoggedIn && ` ${t.sandbox.loginForMoreQuota}`}
</div>
</div>
</div>
</div>
);
}