'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 ( }> ); } function SandboxPage() { const searchParams = useSearchParams(); const t = useT(); const [messages, setMessages] = useState([ { 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([]); const [sessionsOpen, setSessionsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [conversationId, setConversationId] = useState(() => crypto.randomUUID()); const [sessionFeedback, setSessionFeedback] = useState>({}); const [currentSessionId, setCurrentSessionId] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [uploadedImages, setUploadedImages] = useState([]); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(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 ( ); const isNewChat = messages.length <= 1 && messages[0]?.role === 'assistant'; return ( setSessionsOpen(!sessionsOpen)} className="lg:hidden p-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent"> {t.sandbox.title} {t.sandbox.subtitle} {!isLoggedIn && ( {t.sandbox.loginForMore} )} {isLoggedIn && ( <> {t.sandbox.history} {t.sandbox.newChat} 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" /> {sessions.length === 0 ? ( {t.sandbox.noHistory} ) : sessions.map(s => ( { 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 ? ( 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" /> ) : ( {s.title} )} {formatTime(s.createdAt)} · {s.model} { e.stopPropagation(); deleteSession(s.id); }} className="opacity-0 group-hover:opacity-100 text-[10px] text-red-500 hover:text-red-700"> {t.common.delete} ))} {sessionsOpen && ( setSessionsOpen(false)} /> )} > )} {SCENES.map(s => ( 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' }`}> {s.icon} {s.name} ))} 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' }`}> {t.sandbox.advancedParams} {showParams && ( {[ { 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 => ( {p.label} {p.fmt(p.val)} p.set(parseFloat(e.target.value))} className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" /> ))} )} {isNewChat && ( {currentScene?.starters?.map((q, i) => ( 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} ))} )} {messages.map((msg, i) => { if (msg.role === 'system') return null; const isLastAssistant = msg.role === 'assistant' && i === messages.length - 1; return ( {msg.role === 'assistant' && ( Y )} {(() => { 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 } if (pi > 0 && parts[pi-1].startsWith('```')) return null return {part} }) })()} {msg.role === 'user' && ( 我 )} {msg.role === 'assistant' && currentSessionId && isLastAssistant && ( 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} 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} )} {msg.role === 'assistant' && extractCodeBlocks(msg.content).length > 0 && ( {extractCodeBlocks(msg.content).map((code, ci) => ( 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} ))} )} ); })} {sending && ( Y )} {!sending && messages.length > 2 && ( {t.sandbox.copyShareLink} { 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} )} {quota && ( {t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))} )} {uploadedImages.length > 0 && ( {uploadedImages.map((img, i) => ( 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">× ))} )} { 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) }} /> 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"> 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" /> {sending ? t.sandbox.sending : t.sandbox.send} {t.sandbox.aiReplyDisclaimer}{!isLoggedIn && ` ${t.sandbox.loginForMoreQuota}`} ); }
{t.sandbox.subtitle}