import { useState, useRef, useEffect, useCallback } from 'react'
import { useWebSocket } from './useWebSocket'
import { truncate } from './utils'
import Markdown from './Markdown'
import MessageDetailModal from './MessageDetailModal'
import ToolModal from './ToolModal'
import AskModal from './AskModal'
const params = new URLSearchParams(location.search)
const token = params.get('token') || ''
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${location.host}/ws?token=${token}`
function Message({ role, content, streaming, onDetail }) {
const isUser = role === 'user'
const widthCls = isUser
? 'w-auto max-w-[90%] sm:max-w-[85%] md:max-w-[80%] lg:max-w-[75%]'
: 'w-[90%] sm:w-[85%] md:w-[80%] lg:w-[75%]'
const base = `${widthCls} px-4 py-3 rounded-2xl leading-relaxed break-words text-sm`
const cls = isUser
? `${base} self-end bg-bubble-user text-white rounded-br-md whitespace-pre-wrap`
: `${base} self-start bg-bubble-ai rounded-bl-md border border-border md-msg${streaming ? ' streaming' : ''}`
return (
<div className="flex flex-col gap-0.5 cursor-pointer active:opacity-80 transition-opacity" onClick={onDetail}>
<span className={`text-[11px] font-medium ${isUser ? 'self-end text-label-user' : 'self-start text-label-ai'}`}>
{isUser ? '你' : 'Sprite'}
</span>
<div className={cls}>
{isUser ? content : <Markdown content={content || ''} />}
</div>
<span className={`text-[10px] text-fg3 ${isUser ? 'self-end' : 'self-start'} mt-0.5 opacity-0 hover:opacity-100 transition-opacity`}>
点击查看详情
</span>
</div>
)
}
function ToolCallMsg({ name, arguments: args, completed, collapsed: initCollapsed, onDetail }) {
const [expanded, setExpanded] = useState(!initCollapsed)
let parsed = null
try { parsed = JSON.parse(args) } catch {}
const statusIcon = completed
? <span className="text-ok font-bold text-sm">✓</span>
: <span className="w-3 h-3 rounded-full border-2 border-warn animate-spin border-t-transparent inline-block shrink-0" />
const statusText = completed ? '已完成' : '执行中'
const statusCls = completed ? 'text-ok' : 'text-warn'
const handleClick = (e) => {
if (e.target.closest('.expand-btn')) {
setExpanded(e => !e)
} else if (onDetail) {
onDetail()
}
}
return (
<div
className={`self-start w-[90%] sm:w-[85%] md:w-[80%] lg:w-[75%] rounded-xl border overflow-hidden cursor-pointer active:opacity-80 transition-opacity shrink-0 min-h-[44px] ${completed ? 'border-ok/30 bg-ok/5' : 'border-border bg-bg2'}`}
onClick={handleClick}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs">
{statusIcon}
<span className={`font-semibold ${statusCls}`}>{name}</span>
<span className={`text-[11px] ml-auto ${completed ? 'text-ok/70' : 'text-fg3'}`}>{statusText}</span>
<button className="expand-btn text-fg3 hover:text-fg px-1" onClick={e => e.stopPropagation()}>
{expanded ? '收起' : '展开'}
</button>
</div>
{expanded && parsed && (
<div className="px-3 pb-2 text-[11px] text-fg2 border-t border-border pt-2">
{Object.entries(parsed).map(([k, v]) => (
<div key={k} className="mb-1 last:mb-0">
<span className="text-fg3">{k}: </span>
<span className="whitespace-pre-wrap break-all">{typeof v === 'string' ? truncate(v, 500) : JSON.stringify(v)}</span>
</div>
))}
</div>
)}
{expanded && !parsed && args && (
<div className="px-3 pb-2 text-[11px] text-fg2 border-t border-border pt-2 whitespace-pre-wrap break-all">
{truncate(args, 500)}
</div>
)}
</div>
)
}
function ToolResultMsg({ toolName, output, isError, collapsed: initCollapsed, onDetail }) {
const [expanded, setExpanded] = useState(!initCollapsed)
const icon = isError ? '✗' : '✓'
const iconCls = isError ? 'text-err' : 'text-ok'
const hasOutput = output && output.trim()
const preview = hasOutput
? (output.length > 50 ? output.slice(0, 50) + '...' : output)
: ''
const handleClick = (e) => {
if (e.target.closest('.expand-btn')) {
setExpanded(e => !e)
} else if (onDetail) {
onDetail()
}
}
return (
<div
className={`self-start w-[90%] sm:w-[85%] md:w-[80%] lg:w-[75%] rounded-xl border overflow-hidden transition-opacity shrink-0 min-h-[44px] ${hasOutput ? 'cursor-pointer active:opacity-80' : ''} ${isError ? 'border-err/40 bg-err/5' : 'border-border bg-bg2'}`}
onClick={handleClick}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs">
<span className={`font-bold text-sm ${iconCls}`}>{icon}</span>
<span className="font-semibold text-fg2">{toolName}</span>
{hasOutput && (
<>
<span className="text-fg3 text-[11px] ml-auto">{expanded ? '收起' : '展开'}</span>
<button className="expand-btn text-accent text-[11px] hover:underline">详情</button>
</>
)}
</div>
{}
{!expanded && hasOutput && (
<div className="px-3 pb-2 text-[11px] text-fg3 border-t border-border pt-2 truncate">
{preview}
</div>
)}
{}
{expanded && hasOutput && (
<div className="px-3 pb-2 text-[11px] text-fg2 border-t border-border pt-2 whitespace-pre-wrap break-all max-h-[300px] overflow-y-auto">
{output}
</div>
)}
</div>
)
}
function isNearBottom(el) {
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight < 80
}
function formatRelativeTime(ts) {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
const d = new Date(ts * 1000)
return `${d.getMonth() + 1}/${d.getDate()}`
}
function SessionSidebar({ sessions, currentSessionId, onSwitch, onNew, onClose }) {
return (
<div className="sidebar-overlay" onClick={onClose}>
<div className="sidebar-panel" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-bold text-[15px]">会话列表</span>
<button
className="text-fg3 hover:text-fg text-xl leading-none px-1"
onClick={onClose}
>×</button>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto">
{sessions.map(s => {
const isCurrent = s.id === currentSessionId
return (
<div
key={s.id}
className={`session-item px-4 py-3 border-b border-border/50 cursor-pointer transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-l-accent' : 'hover:bg-bg3'}`}
onClick={() => onSwitch(s.id)}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-[13px] font-medium truncate flex-1 mr-2 ${isCurrent ? 'text-accent' : 'text-fg'}`}>
{s.first_message_preview || '新会话'}
</span>
{isCurrent && <span className="text-[10px] text-accent bg-accent/15 px-1.5 py-0.5 rounded-full shrink-0">当前</span>}
</div>
<div className="flex items-center gap-2 text-[11px] text-fg3">
<span>{s.message_count} 条消息</span>
<span>·</span>
<span>{formatRelativeTime(s.updated_at)}</span>
</div>
</div>
)
})}
{sessions.length === 0 && (
<div className="text-center text-fg3 text-sm py-8">暂无会话</div>
)}
</div>
{/* New session button */}
<div className="px-4 pt-3 pb-[max(12px,env(safe-area-inset-bottom))] border-t border-border">
<button
className="w-full py-2.5 rounded-xl bg-accent/15 text-accent text-[13px] font-medium hover:bg-accent/25 transition-colors"
onClick={onNew}
>
+ 新建会话
</button>
</div>
</div>
</div>
)
}
export default function App() {
const [messages, setMessages] = useState([])
const [state, setState] = useState('idle')
const [connected, setConnected] = useState(false)
const [modelName, setModelName] = useState('--')
const [toolConfirm, setToolConfirm] = useState(null)
const [toolConfirmIdx, setToolConfirmIdx] = useState(0)
const [askQuestions, setAskQuestions] = useState(null)
const [toast, setToast] = useState(null)
const [inputText, setInputText] = useState('')
const [detailMessage, setDetailMessage] = useState(null)
const [sessions, setSessions] = useState([])
const [showSidebar, setShowSidebar] = useState(false)
const [currentSessionId, setCurrentSessionId] = useState(null)
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark')
const streamContentRef = useRef('')
const messagesRef = useRef(null)
const textareaRef = useRef(null)
const autoScrollRef = useRef(true)
// 主题切换
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme(t => t === 'dark' ? 'light' : 'dark')
}, [])
const scrollToBottom = useCallback(() => {
if (!autoScrollRef.current) return
requestAnimationFrame(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight
}
})
}, [])
const handleScroll = useCallback(() => {
autoScrollRef.current = isNearBottom(messagesRef.current)
}, [])
const onMessage = useCallback((msg) => {
switch (msg.type) {
case 'stream_chunk':
// Skip empty chunks to avoid empty bubbles
if (!msg.content) break
streamContentRef.current = msg.content
setMessages(prev => {
const last = prev[prev.length - 1]
if (last?.streaming) {
return [...prev.slice(0, -1), { ...last, content: msg.content }]
}
return [...prev, { role: 'assistant', content: msg.content, streaming: true }]
})
setState('loading')
scrollToBottom()
break
case 'message':
if (msg.role === 'assistant') {
setMessages(prev => {
const last = prev[prev.length - 1]
if (last?.streaming) {
return [...prev.slice(0, -1), { role: 'assistant', content: msg.content, streaming: false }]
}
// Skip empty messages
if (!msg.content) return prev
return [...prev, { role: 'assistant', content: msg.content }]
})
} else {
if (msg.content) {
setMessages(prev => [...prev, { role: msg.role, content: msg.content }])
}
}
streamContentRef.current = ''
scrollToBottom()
break
case 'tool_confirm_request':
setState('tool_confirm')
setToolConfirm(msg.tools)
setToolConfirmIdx(0)
break
case 'ask_request':
setState('ask')
setAskQuestions(msg.questions)
break
case 'tool_call':
// Add tool call to history (don't replace)
setMessages(prev => [...prev, {
role: 'tool_call',
name: msg.name,
arguments: msg.arguments,
id: msg.id || Date.now()
}])
scrollToBottom()
break
case 'tool_result': {
setMessages(prev => {
// Mark corresponding tool_call as completed, add result
return prev.map(m => {
if (m.role === 'tool_call' && m.name === msg.name && !m.completed) {
return { ...m, completed: true }
}
return m
}).concat({
role: 'tool_result',
toolName: msg.name || 'tool',
output: msg.output,
isError: msg.is_error,
})
})
scrollToBottom()
break
}
case 'status':
setState(msg.state)
if (msg.state === 'idle') {
setMessages(prev => {
const last = prev[prev.length - 1]
if (last?.streaming) {
return [...prev.slice(0, -1), { ...last, streaming: false }].map(m =>
m.role === 'tool_call' ? { ...m, completed: true } : m
)
}
return prev.map(m => m.role === 'tool_call' ? { ...m, completed: true } : m)
})
streamContentRef.current = ''
}
break
case 'session_sync':
streamContentRef.current = ''
// 转换后端消息为前端格式,正确处理 tool_calls 和 tool_result
// 先建立 tool_call_id -> tool_name 映射
const toolNameMap = {}
for (const m of msg.messages) {
if (m.tool_calls) {
for (const tc of m.tool_calls) {
toolNameMap[tc.id] = tc.name
}
}
}
const syncedMsgs = []
for (const m of msg.messages) {
if (m.tool_calls && m.tool_calls.length > 0) {
// assistant 消息带有 tool_calls:生成 tool_call 消息,默认折叠
for (const tc of m.tool_calls) {
syncedMsgs.push({
role: 'tool_call',
name: tc.name,
arguments: tc.arguments,
id: tc.id,
completed: true, // 断线重连后默认已完成
collapsed: true, // 默认折叠
})
}
} else if (m.role === 'tool' && m.tool_call_id) {
// tool 消息:生成 tool_result 消息,默认折叠
const toolName = toolNameMap[m.tool_call_id] || 'tool'
syncedMsgs.push({
role: 'tool_result',
toolName: toolName,
output: m.content,
isError: false,
collapsed: true, // 默认折叠
})
} else if (m.content) {
// 普通消息
syncedMsgs.push({ role: m.role, content: m.content })
}
}
setMessages(syncedMsgs)
setModelName(msg.model || '--')
setState(msg.status)
autoScrollRef.current = true
scrollToBottom()
break
case 'session_list':
setSessions(msg.sessions || [])
break
case 'session_switched':
setShowSidebar(false)
if (msg.session_id) {
setCurrentSessionId(msg.session_id)
}
break
case 'error':
setToast(msg.message || '发生错误')
setTimeout(() => setToast(null), 4000)
break
}
}, [scrollToBottom])
const onStatusChange = useCallback((isConnected) => {
setConnected(isConnected)
}, [])
const send = useWebSocket(wsUrl, onMessage, onStatusChange)
const sendMessage = useCallback(() => {
const text = inputText.trim()
if (!text || !connected) return
send({ type: 'send_message', content: text })
setMessages(prev => [...prev, { role: 'user', content: text }])
setInputText('')
if (state !== 'loading') setState('loading')
autoScrollRef.current = true
scrollToBottom()
}, [inputText, state, connected, send, scrollToBottom])
const confirmTool = useCallback((action, reason) => {
const payload = { type: 'tool_confirm', action }
if (reason) payload.reason = reason
send(payload)
if (toolConfirm && toolConfirmIdx < toolConfirm.length - 1) {
setToolConfirmIdx(prev => prev + 1)
} else {
setToolConfirm(null)
setToolConfirmIdx(0)
setState('loading')
}
}, [send, toolConfirm, toolConfirmIdx])
const submitAsk = useCallback((answers) => {
send({ type: 'ask_response', answers })
setAskQuestions(null)
setState('loading')
}, [send])
const cancelStream = useCallback(() => {
send({ type: 'cancel' })
}, [send])
const fetchSessions = useCallback(() => {
send({ type: 'list_sessions' })
}, [send])
const switchSession = useCallback((id) => {
send({ type: 'switch_session', session_id: id })
}, [send])
const newSession = useCallback(() => {
send({ type: 'new_session' })
}, [send])
const openSidebar = useCallback(() => {
fetchSessions()
setShowSidebar(true)
}, [fetchSessions])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}, [sendMessage])
const autoResize = useCallback(() => {
const el = textareaRef.current
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
}, [])
useEffect(() => { autoResize() }, [inputText, autoResize])
const isLoading = state === 'loading'
const msgCount = messages.length
const statusText = !connected
? '连接断开,重连中...'
: isLoading
? 'Sprite 思考中...'
: state === 'tool_confirm'
? '等待工具确认...'
: state === 'ask'
? '等待回答问题...'
: '已连接'
return (
<div className="flex flex-col h-[100dvh] w-full max-w-[100vw] lg:max-w-[900px] xl:max-w-[1100px] mx-auto">
{/* Header */}
<div className="bg-bg2/95 backdrop-blur-sm border-b border-border shrink-0">
{/* 状态栏 - 放在最上面 */}
<div className={`px-4 py-1 text-[11px] flex items-center justify-center gap-1.5 border-b border-border/50 ${!connected ? 'text-err' : isLoading ? 'text-warn' : 'text-fg3'}`}>
{isLoading && connected && <span className="w-1.5 h-1.5 rounded-full bg-warn animate-[pulse_1.2s_ease-in-out_infinite]" />}
{statusText}
</div>
{/* 导航栏 */}
<div className="flex items-center gap-3 px-4 pt-2.5 pb-2.5">
<button
className="text-fg3 hover:text-fg text-[18px] leading-none px-0.5 transition-colors"
onClick={openSidebar}
title="会话列表"
>☰</button>
<div className="flex items-center gap-2">
<span className="text-[20px] leading-none">🦞</span>
<span className="font-bold text-[16px] tracking-wide">Sprite</span>
</div>
<span className="w-px h-4 bg-border" />
<span className="text-label-ai text-[12px] font-medium">{modelName}</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={toggleTheme}
className="text-fg3 hover:text-fg text-[14px] leading-none p-1.5 rounded-md hover:bg-bg3 transition-colors cursor-pointer"
title={theme === 'dark' ? '切换到白天模式' : '切换到黑夜模式'}
>
{theme === 'dark' ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
<span className={`w-2 h-2 rounded-full shrink-0 transition-colors duration-300 ${connected ? 'bg-ok shadow-[0_0_6px_var(--color-ok)]' : 'bg-fg3'}`} />
<span className="text-fg3 text-[11px]">{msgCount} 条消息</span>
</div>
</div>
</div>
{/* Messages */}
<div
className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-2.5 [-webkit-overflow-scrolling:touch]"
ref={messagesRef}
onScroll={handleScroll}
>
{messages.length === 0 && (
<div className="text-center text-fg3 mt-[40%] text-sm">发送消息开始对话</div>
)}
{messages.map((m, i) =>
m.role === 'tool_call' ? (
<ToolCallMsg
key={`tc-${i}-${m.id || ''}`}
name={m.name}
arguments={m.arguments}
completed={m.completed}
collapsed={m.collapsed}
onDetail={() => setDetailMessage(m)}
/>
) : m.role === 'tool_result' ? (
<ToolResultMsg
key={`tr-${i}`}
toolName={m.toolName}
output={m.output}
isError={m.isError}
collapsed={m.collapsed}
onDetail={() => setDetailMessage(m)}
/>
) : (
<Message
key={i}
role={m.role}
content={m.content}
streaming={m.streaming}
onDetail={() => setDetailMessage(m)}
/>
)
)}
</div>
{/* Toast */}
{toast && (
<div className="px-5 py-2 text-center text-[13px] text-err bg-err/10 border-t border-err/30 shrink-0">{toast}</div>
)}
{/* Input Area */}
<div className="flex gap-2 items-end px-4 pt-3.5 pb-[max(16px,env(safe-area-inset-bottom))] bg-bg2/95 backdrop-blur-sm border-t border-border shrink-0">
<textarea
ref={textareaRef}
rows={1}
placeholder={isLoading ? '追加消息...' : '输入消息...'}
autoComplete="off"
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
className={`flex-1 bg-bg3 border-2 rounded-2xl px-5 py-3.5 text-fg text-[16px] resize-none outline-none max-h-[140px] font-[inherit] leading-relaxed transition-colors duration-200 placeholder:text-fg3 ${isLoading ? 'border-[#786432]' : 'border-[#3c6450] focus:border-accent'}`}
/>
<button
className="w-[48px] h-[48px] rounded-full border-none text-2xl cursor-pointer flex items-center justify-center shrink-0 transition-all duration-150 bg-label-user text-white disabled:opacity-30 disabled:cursor-default enabled:active:scale-[0.92]"
onClick={sendMessage}
disabled={!inputText.trim()}
title="发送"
>↑</button>
{isLoading && (
<button
className="w-[48px] h-[48px] rounded-full border-none text-lg cursor-pointer flex items-center justify-center shrink-0 transition-all duration-150 bg-err text-white active:scale-[0.92]"
onClick={cancelStream}
title="取消"
>■</button>
)}
</div>
<ToolModal tools={toolConfirm} currentIndex={toolConfirmIdx} onConfirm={confirmTool} />
<AskModal questions={askQuestions} onSubmit={submitAsk} />
<MessageDetailModal message={detailMessage} onClose={() => setDetailMessage(null)} />
{/* Session Sidebar */}
{showSidebar && (
<SessionSidebar
sessions={sessions}
currentSessionId={currentSessionId}
onSwitch={switchSession}
onNew={newSession}
onClose={() => setShowSidebar(false)}
/>
)}
</div>
)
}