'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 (

{t.sandbox.title}

{t.sandbox.subtitle}

{!isLoggedIn && ( {t.sandbox.loginForMore} )}
{isLoggedIn && ( <>
{t.sandbox.history}
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}
))}
{sessionsOpen && (
setSessionsOpen(false)} /> )}
)}
{SCENES.map(s => ( ))}
{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.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) => ( ))}
)} {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 && (
)} {msg.role === 'assistant' && extractCodeBlocks(msg.content).length > 0 && (
{extractCodeBlocks(msg.content).map((code, ci) => ( ))}
)}
); })} {sending && (
Y
)} {!sending && messages.length > 2 && (
)}
{quota && (
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
)} {uploadedImages.length > 0 && (
{uploadedImages.map((img, i) => (
))}
)}
{ 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) }} /> 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" />
{t.sandbox.aiReplyDisclaimer}{!isLoggedIn && ` ${t.sandbox.loginForMoreQuota}`}
); }