feat(ui): final polish — ChatGPT-style welcome screen, enhanced markdown, typing indicator

- Welcome state: centered greeting with 4 suggestion cards (ChatGPT style grid)
- Enhanced markdown: headers, numbered/bullet lists, links, italic, HR support
- Typing indicator: bouncing dots animation while waiting for response
- Refined dark theme: subtle rgba borders, backdrop blur, better contrast hierarchy
- Improved sidebar: slimmer 280px, glass morphism effect, smoother hover states
- MarketTicker: crypto icons (₿/Ξ/◎), skeleton loading, monospace prices
- Input area: ArrowUp send icon, ⌘K shortcut hint, focus ring glow
- Keyboard shortcuts: Cmd+K focus input, Esc close mobile sidebar
- Custom scrollbar styling, hidden scrollbar for quick actions bar
- Removed initial bot message in favor of interactive welcome state
This commit is contained in:
shinchan-zhai
2026-03-23 03:25:43 +08:00
parent afa73790c7
commit 2c020d3dc7
2 changed files with 624 additions and 259 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { TrendingUp, TrendingDown, RefreshCw } from 'lucide-react' // icons reserved for future use
interface TickerData { interface TickerData {
symbol: string symbol: string
@@ -12,9 +12,16 @@ interface TickerData {
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT'] const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
const SYMBOL_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
SOL: '◎',
}
export function MarketTicker() { export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({}) const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [, setRefreshing] = useState(false)
const fetchTickers = async () => { const fetchTickers = async () => {
try { try {
@@ -34,6 +41,7 @@ export function MarketTicker() {
// ignore // ignore
} finally { } finally {
setLoading(false) setLoading(false)
setRefreshing(false)
} }
} }
@@ -60,9 +68,33 @@ export function MarketTicker() {
if (loading) { if (loading) {
return ( return (
<div style={{ padding: '12px 14px', color: '#5c5c72', fontSize: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<RefreshCw size={14} className="animate-spin inline mr-2" /> {SYMBOLS.map((sym) => (
Loading market data... <div
key={sym}
style={{
padding: '12px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
height: 56,
}}
>
<div style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}} />
</div>
))}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div> </div>
) )
} }
@@ -73,9 +105,12 @@ export function MarketTicker() {
const t = tickers[sym] const t = tickers[sym]
if (!t) return null if (!t) return null
const pct = parseFloat(t.priceChangePercent) const pct = parseFloat(t.priceChangePercent)
const isUp = pct >= 0 const isUp = pct > 0
const color = isUp ? '#00e5a0' : '#F6465D' const isDown = pct < 0
const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82'
const bgColor = isUp ? 'rgba(0,229,160,0.06)' : isDown ? 'rgba(246,70,93,0.06)' : 'rgba(108,108,130,0.06)'
const label = sym.replace('USDT', '') const label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
return ( return (
<div <div
@@ -84,10 +119,20 @@ export function MarketTicker() {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '10px 12px', padding: '10px 11px',
background: '#0d0d15', background: 'rgba(255,255,255,0.02)',
borderRadius: 10, borderRadius: 10,
border: '1px solid #1a1a28', border: '1px solid rgba(255,255,255,0.04)',
transition: 'all 0.15s ease',
cursor: 'default',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.04)'
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -95,32 +140,37 @@ export function MarketTicker() {
style={{ style={{
width: 28, width: 28,
height: 28, height: 28,
borderRadius: 7, borderRadius: 8,
background: isUp ? 'rgba(0,229,160,0.08)' : 'rgba(246,70,93,0.08)', background: bgColor,
display: 'grid', display: 'grid',
placeItems: 'center', placeItems: 'center',
fontSize: 13,
fontWeight: 700,
color: color,
fontFamily: 'system-ui',
}} }}
> >
{isUp ? ( {icon}
<TrendingUp size={14} color={color} />
) : (
<TrendingDown size={14} color={color} />
)}
</div> </div>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}> <div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
{label} {label}
</div> </div>
<div style={{ fontSize: 10, color: '#5c5c72' }}> <div style={{ fontSize: 10, color: '#4c4c62' }}>
Vol {formatVolume(t.volume)} Vol {formatVolume(t.volume)}
</div> </div>
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}> <div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', fontFamily: '"IBM Plex Mono", monospace', letterSpacing: '-0.02em' }}>
${formatPrice(t.lastPrice)} ${formatPrice(t.lastPrice)}
</div> </div>
<div style={{ fontSize: 11, fontWeight: 500, color }}> <div style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}>
{isUp ? '+' : ''}{pct.toFixed(2)}% {isUp ? '+' : ''}{pct.toFixed(2)}%
</div> </div>
</div> </div>

View File

@@ -8,8 +8,11 @@ import {
Bot, Bot,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Send, ArrowUp,
Sparkles, Zap,
BarChart3,
Lightbulb,
Search,
} from 'lucide-react' } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext' import { useLanguage } from '../contexts/LanguageContext'
import { MarketTicker } from '../components/agent/MarketTicker' import { MarketTicker } from '../components/agent/MarketTicker'
@@ -24,62 +27,186 @@ interface Message {
streaming?: boolean streaming?: boolean
} }
// Simple markdown-ish renderer: bold, code, newlines // Enhanced markdown renderer: headers, bold, italic, code, lists, links
function renderMessageContent(text: string) { function renderMessageContent(text: string) {
// Split by code blocks first const lines = text.split('\n')
const parts = text.split(/(```[\s\S]*?```|`[^`]+`)/g) const elements: JSX.Element[] = []
return parts.map((part, i) => { let inCodeBlock = false
if (part.startsWith('```') && part.endsWith('```')) { let codeContent = ''
const code = part.slice(3, -3).replace(/^\w+\n/, '') // strip language hint
return (
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Code block toggle
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre <pre
key={i} key={`code-${i}`}
style={{ style={{
background: '#0a0a12', background: '#0a0a12',
border: '1px solid #1f1f2c', border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 8, borderRadius: 10,
padding: '10px 12px', padding: '12px 14px',
fontSize: 12, fontSize: 12,
overflowX: 'auto', overflowX: 'auto',
margin: '6px 0', margin: '8px 0',
fontFamily: 'monospace', fontFamily: '"IBM Plex Mono", monospace',
color: '#c0c0d0', color: '#c0c0d0',
lineHeight: 1.6,
}} }}
> >
{code} {codeContent.trim()}
</pre> </pre>
) )
codeContent = ''
inCodeBlock = false
} else {
inCodeBlock = true
// language hint (reserved for syntax highlighting)
} }
if (part.startsWith('`') && part.endsWith('`')) { continue
return ( }
if (inCodeBlock) {
codeContent += (codeContent ? '\n' : '') + line
continue
}
// Headers
if (line.startsWith('### ')) {
elements.push(
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(4))}
</div>
)
continue
}
if (line.startsWith('## ')) {
elements.push(
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(3))}
</div>
)
continue
}
if (line.startsWith('# ')) {
elements.push(
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
{renderInline(line.slice(2))}
</div>
)
continue
}
// Bullet lists
if (line.match(/^[-•*]\s/)) {
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}></span>
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
</div>
)
continue
}
// Numbered lists
if (line.match(/^\d+\.\s/)) {
const num = line.match(/^(\d+)\./)?.[1]
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
</div>
)
continue
}
// Horizontal rule
if (line.match(/^---+$/)) {
elements.push(
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
)
continue
}
// Empty line → small gap
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 6 }} />)
continue
}
// Regular paragraph
elements.push(
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
{renderInline(line)}
</div>
)
}
return elements
}
// Inline formatting: bold, italic, code, links
function renderInline(text: string): (string | JSX.Element)[] {
const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g)
const result: (string | JSX.Element)[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!part) continue
if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) {
result.push(
<code <code
key={i} key={i}
style={{ style={{
background: '#1a1a28', background: 'rgba(240,185,11,0.08)',
padding: '1px 5px', padding: '2px 6px',
borderRadius: 4, borderRadius: 5,
fontSize: '0.9em', fontSize: '0.88em',
fontFamily: 'monospace', fontFamily: '"IBM Plex Mono", monospace',
color: '#d0d0e0', color: '#F0B90B',
border: '1px solid rgba(240,185,11,0.12)',
}} }}
> >
{part.slice(1, -1)} {part.slice(1, -1)}
</code> </code>
) )
} } else if (part.startsWith('**') && part.endsWith('**')) {
// Handle bold **text** result.push(
const boldParts = part.split(/(\*\*[^*]+\*\*)/g) <strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
return boldParts.map((bp, j) => { {part.slice(2, -2)}
if (bp.startsWith('**') && bp.endsWith('**')) {
return (
<strong key={`${i}-${j}`} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{bp.slice(2, -2)}
</strong> </strong>
) )
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
result.push(
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
{part.slice(1, -1)}
</em>
)
} else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (match) {
result.push(
<a
key={i}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{match[1]}
</a>
)
} }
return <span key={`${i}-${j}`}>{bp}</span> } else {
}) result.push(part)
}) }
}
return result
} }
let msgIdCounter = 0 let msgIdCounter = 0
@@ -87,23 +214,18 @@ function nextId() {
return `msg-${Date.now()}-${++msgIdCounter}` return `msg-${Date.now()}-${++msgIdCounter}`
} }
// Suggestion cards for welcome state
interface SuggestionCard {
icon: JSX.Element
title: string
subtitle: string
cmd: string
}
export function AgentChatPage() { export function AgentChatPage() {
const { language } = useLanguage() const { language } = useLanguage()
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024) const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([])
{
id: nextId(),
role: 'bot',
text:
language === 'zh'
? '👋 你好!我是 NOFXi你的 AI 交易 Agent。\n\n跟我说话就行我能帮你分析市场、管理交易、配置策略。\n\n试试: "分析一下BTC" 或 /help'
: "👋 Hi! I'm NOFXi, your AI trading agent.\n\nJust talk to me. I can analyze markets, manage trades, and configure strategies.\n\nTry: \"Analyze BTC\" or /help",
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
},
])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [composing, setComposing] = useState(false) const [composing, setComposing] = useState(false)
@@ -114,7 +236,7 @@ export function AgentChatPage() {
const [sections, setSections] = useState({ const [sections, setSections] = useState({
market: true, market: true,
positions: true, positions: true,
traders: true, traders: false,
}) })
const toggleSection = (key: keyof typeof sections) => { const toggleSection = (key: keyof typeof sections) => {
@@ -135,13 +257,28 @@ export function AgentChatPage() {
return () => window.removeEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize)
}, []) }, [])
// Keyboard shortcut: Cmd+K to focus input
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
if (e.key === 'Escape' && window.innerWidth <= 768) {
setSidebarOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// Auto-resize textarea // Auto-resize textarea
const handleInputChange = useCallback( const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => { (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value) setInput(e.target.value)
const el = e.target const el = e.target
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px' el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}, },
[] []
) )
@@ -202,7 +339,7 @@ export function AgentChatPage() {
) )
) )
if (i < words.length - 1) { if (i < words.length - 1) {
await new Promise((r) => setTimeout(r, 12)) await new Promise((r) => setTimeout(r, 10))
} }
} }
// Mark streaming done // Mark streaming done
@@ -230,11 +367,23 @@ export function AgentChatPage() {
inputRef.current?.focus() inputRef.current?.focus()
} }
const quickActions = // Welcome suggestions (ChatGPT style)
language === 'zh' const suggestions: SuggestionCard[] = language === 'zh'
? [
{ icon: <BarChart3 size={18} />, title: '分析 BTC 走势', subtitle: '技术分析 + 市场情绪', cmd: '分析一下 BTC 的走势' },
{ icon: <Zap size={18} />, title: '做多 ETH', subtitle: 'Agent 帮你自动下单', cmd: '帮我做多 ETH 0.01 手' },
{ icon: <Search size={18} />, title: '搜索股票', subtitle: '输入名称或代码即可', cmd: '搜索一下中远海控' },
{ icon: <Lightbulb size={18} />, title: '策略建议', subtitle: '根据当前市场给出建议', cmd: '当前市场适合什么策略?' },
]
: [
{ icon: <BarChart3 size={18} />, title: 'Analyze BTC', subtitle: 'Technical analysis + sentiment', cmd: 'Analyze BTC price action' },
{ icon: <Zap size={18} />, title: 'Trade ETH', subtitle: 'Agent executes for you', cmd: 'Open a long position on ETH 0.01' },
{ icon: <Search size={18} />, title: 'Search Stocks', subtitle: 'Enter name or ticker', cmd: 'Search for NVIDIA stock' },
{ icon: <Lightbulb size={18} />, title: 'Strategy Ideas', subtitle: 'Market-based suggestions', cmd: 'What strategy fits the current market?' },
]
const quickActions = language === 'zh'
? [ ? [
{ label: '📊 分析 BTC', cmd: '/analyze BTC' },
{ label: '📊 分析 ETH', cmd: '/analyze ETH' },
{ label: '💼 持仓', cmd: '/positions' }, { label: '💼 持仓', cmd: '/positions' },
{ label: '💰 余额', cmd: '/balance' }, { label: '💰 余额', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' }, { label: '📋 Traders', cmd: '/traders' },
@@ -242,8 +391,6 @@ export function AgentChatPage() {
{ label: '❓ 帮助', cmd: '/help' }, { label: '❓ 帮助', cmd: '/help' },
] ]
: [ : [
{ label: '📊 Analyze BTC', cmd: '/analyze BTC' },
{ label: '📊 Analyze ETH', cmd: '/analyze ETH' },
{ label: '💼 Positions', cmd: '/positions' }, { label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' }, { label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' }, { label: '📋 Traders', cmd: '/traders' },
@@ -267,11 +414,13 @@ export function AgentChatPage() {
{ {
key: 'traders' as const, key: 'traders' as const,
icon: <Bot size={14} />, icon: <Bot size={14} />,
title: language === 'zh' ? 'Traders' : 'Traders', title: 'Traders',
component: <TraderStatusPanel />, component: <TraderStatusPanel />,
}, },
] ]
const isWelcomeState = messages.length === 0
return ( return (
<div <div
style={{ style={{
@@ -291,51 +440,43 @@ export function AgentChatPage() {
position: 'relative', position: 'relative',
}} }}
> >
{/* Quick actions bar */} {/* Top bar with quick actions */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
padding: '8px 16px', padding: '8px 16px',
borderBottom: '1px solid #1a1a28', borderBottom: '1px solid rgba(255,255,255,0.04)',
overflowX: 'auto', overflowX: 'auto',
flexShrink: 0, flexShrink: 0,
backdropFilter: 'blur(12px)',
background: 'rgba(9,9,11,0.8)',
}} }}
className="hide-scrollbar"
> >
<Sparkles size={14} color="#F0B90B" style={{ flexShrink: 0 }} />
{quickActions.map((a, i) => ( {quickActions.map((a, i) => (
<button <button
key={i} key={i}
onClick={() => send(a.cmd)} onClick={() => send(a.cmd)}
className="quick-action-btn"
style={{ style={{
padding: '5px 12px', padding: '5px 12px',
background: '#111118', background: 'rgba(255,255,255,0.03)',
border: '1px solid #1f1f2c', border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 20, borderRadius: 20,
color: '#8a8aa0', color: '#6c6c82',
fontSize: 12, fontSize: 12,
cursor: 'pointer', cursor: 'pointer',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
fontFamily: 'inherit', fontFamily: 'inherit',
transition: 'all 0.15s', transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#F0B90B40'
e.currentTarget.style.color = '#F0B90B'
e.currentTarget.style.background = '#F0B90B08'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#1f1f2c'
e.currentTarget.style.color = '#8a8aa0'
e.currentTarget.style.background = '#111118'
}} }}
> >
{a.label} {a.label}
</button> </button>
))} ))}
{/* Sidebar toggle (desktop) */}
<button <button
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
style={{ style={{
@@ -343,14 +484,17 @@ export function AgentChatPage() {
padding: 6, padding: 6,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: '#5c5c72', color: '#4c4c62',
cursor: 'pointer', cursor: 'pointer',
borderRadius: 6, borderRadius: 8,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flexShrink: 0, flexShrink: 0,
transition: 'color 0.2s',
}} }}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'} title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onMouseEnter={(e) => { e.currentTarget.style.color = '#8a8aa0' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#4c4c62' }}
> >
{sidebarOpen ? ( {sidebarOpen ? (
<PanelRightClose size={18} /> <PanelRightClose size={18} />
@@ -360,104 +504,226 @@ export function AgentChatPage() {
</button> </button>
</div> </div>
{/* Messages area */} {/* Messages area or Welcome state */}
<div <div
style={{ style={{
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
padding: '20px 0', padding: '20px 0',
}} }}
className="custom-scrollbar"
> >
{isWelcomeState ? (
/* ========== WELCOME STATE ========== */
<div style={{
maxWidth: 640,
margin: '0 auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 400,
}}>
{/* Logo / greeting */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ textAlign: 'center', marginBottom: 40 }}
>
<div style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(240,185,11,0.12), rgba(0,229,160,0.06))',
border: '1px solid rgba(240,185,11,0.15)',
display: 'grid',
placeItems: 'center',
margin: '0 auto 16px',
fontSize: 24,
}}>
</div>
<h1 style={{
fontSize: 22,
fontWeight: 700,
color: '#f0f0f8',
margin: '0 0 8px',
letterSpacing: '-0.02em',
}}>
{language === 'zh' ? '跟 NOFXi 聊点什么' : 'What can I help with?'}
</h1>
<p style={{
fontSize: 13.5,
color: '#5c5c72',
margin: 0,
lineHeight: 1.5,
}}>
{language === 'zh'
? '分析行情、执行交易、搜索股票 — 用自然语言就行'
: 'Analyze markets, execute trades, search stocks — just ask'}
</p>
</motion.div>
{/* Suggestion cards grid */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: 'easeOut' }}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 10,
width: '100%',
maxWidth: 520,
}}
>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => send(s.cmd)}
className="suggestion-card"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '16px 14px',
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14,
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
<div style={{ color: '#F0B90B', opacity: 0.7 }}>
{s.icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d0d0e0', marginBottom: 2 }}>
{s.title}
</div>
<div style={{ fontSize: 11.5, color: '#5c5c72' }}>
{s.subtitle}
</div>
</div>
</button>
))}
</motion.div>
</div>
) : (
/* ========== MESSAGES ========== */
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}> <div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
{messages.map((m) => ( {messages.map((m) => (
<motion.div <motion.div
key={m.id} key={m.id}
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
style={{ style={{
display: 'flex', display: 'flex',
gap: 12, gap: 12,
marginBottom: 20, marginBottom: 24,
flexDirection: m.role === 'user' ? 'row-reverse' : 'row', flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
}} }}
> >
{/* Avatar */} {/* Avatar */}
<div <div
style={{ style={{
width: 32, width: 30,
height: 32, height: 30,
borderRadius: 10, borderRadius: 10,
display: 'grid', display: 'grid',
placeItems: 'center', placeItems: 'center',
fontSize: 15, fontSize: 14,
flexShrink: 0, flexShrink: 0,
marginTop: 2,
background: background:
m.role === 'user' m.role === 'user'
? 'linear-gradient(135deg, rgba(139,92,246,.15), rgba(139,92,246,.05))' ? 'linear-gradient(135deg, rgba(139,92,246,.12), rgba(139,92,246,.04))'
: 'linear-gradient(135deg, rgba(240,185,11,.1), rgba(0,229,160,.05))', : 'linear-gradient(135deg, rgba(240,185,11,.08), rgba(0,229,160,.04))',
border: border:
'1px solid ' + '1px solid ' +
(m.role === 'user' (m.role === 'user'
? 'rgba(139,92,246,.2)' ? 'rgba(139,92,246,.15)'
: 'rgba(240,185,11,.15)'), : 'rgba(240,185,11,.1)'),
}} }}
> >
{m.role === 'user' ? '👤' : '⚡'} {m.role === 'user' ? '👤' : '⚡'}
</div> </div>
{/* Message bubble */} {/* Message content */}
<div style={{ maxWidth: '75%', minWidth: 0 }}> <div style={{ maxWidth: '78%', minWidth: 0 }}>
{m.role === 'user' ? (
<div <div
style={{ style={{
padding: '12px 16px', padding: '10px 16px',
borderRadius: 16, borderRadius: 18,
borderTopRightRadius: 4,
fontSize: 13.5, fontSize: 13.5,
lineHeight: 1.7, lineHeight: 1.7,
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
background: background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
m.role === 'user' color: '#fff',
? 'linear-gradient(135deg, #8b5cf6, #6d28d9)'
: '#111118',
color: m.role === 'user' ? '#fff' : '#eaeaf0',
border:
m.role === 'bot' ? '1px solid #1f1f2c' : 'none',
borderTopLeftRadius: m.role === 'bot' ? 4 : 16,
borderTopRightRadius: m.role === 'user' ? 4 : 16,
}} }}
> >
{m.role === 'bot' {m.text}
? renderMessageContent(m.text) </div>
: m.text} ) : (
{m.streaming && ( <div
style={{
padding: '12px 16px',
borderRadius: 18,
borderTopLeftRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
wordBreak: 'break-word',
background: 'rgba(255,255,255,0.03)',
color: '#dcdce8',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
{renderMessageContent(m.text)}
{m.streaming && m.text === '' && (
<div style={{ display: 'flex', gap: 4, padding: '4px 0' }}>
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
</div>
)}
{m.streaming && m.text !== '' && (
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
width: 6, width: 2,
height: 14, height: 15,
background: '#F0B90B', background: '#F0B90B',
marginLeft: 2, marginLeft: 1,
borderRadius: 1, borderRadius: 1,
animation: 'blink 1s infinite', animation: 'blink 0.8s infinite',
verticalAlign: 'text-bottom', verticalAlign: 'text-bottom',
}} }}
/> />
)} )}
</div> </div>
{m.time && ( )}
{m.time && !m.streaming && (
<div <div
style={{ style={{
fontSize: 10, fontSize: 10,
color: '#3c3c52', color: '#2c2c42',
marginTop: 4, marginTop: 4,
textAlign: m.role === 'user' ? 'right' : 'left', textAlign: m.role === 'user' ? 'right' : 'left',
paddingLeft: m.role === 'bot' ? 4 : 0, paddingLeft: m.role === 'bot' ? 4 : 0,
paddingRight: m.role === 'user' ? 4 : 0, paddingRight: m.role === 'user' ? 4 : 0,
}} }}
> >
{m.role === 'bot' && 'NOFXi · '} {m.role === 'bot' && 'NOFXi · '}{m.time}
{m.time}
</div> </div>
)} )}
</div> </div>
@@ -465,37 +731,30 @@ export function AgentChatPage() {
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
)}
</div> </div>
{/* Input area */} {/* Input area */}
<div <div
style={{ style={{
padding: '12px 16px 20px', padding: '12px 16px 20px',
borderTop: '1px solid #1a1a28', borderTop: '1px solid rgba(255,255,255,0.04)',
background: background: 'linear-gradient(to top, #09090b 80%, transparent)',
'linear-gradient(to top, #09090b 60%, transparent)',
}} }}
> >
<div <div
className="chat-input-wrapper"
style={{ style={{
maxWidth: 720, maxWidth: 720,
margin: '0 auto', margin: '0 auto',
display: 'flex', display: 'flex',
gap: 8, gap: 8,
background: '#111118', background: 'rgba(255,255,255,0.03)',
border: '1px solid #1f1f2c', border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 16, borderRadius: 18,
padding: '4px 4px 4px 16px', padding: '4px 4px 4px 16px',
alignItems: 'flex-end', alignItems: 'flex-end',
transition: 'border-color 0.2s', transition: 'all 0.2s ease',
}}
onFocus={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor =
'#F0B90B40'
}}
onBlur={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor =
'#1f1f2c'
}} }}
> >
<textarea <textarea
@@ -505,19 +764,15 @@ export function AgentChatPage() {
onCompositionStart={() => setComposing(true)} onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)} onCompositionEnd={() => setComposing(false)}
onKeyDown={(e) => { onKeyDown={(e) => {
if ( if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.key === 'Enter' &&
!e.shiftKey &&
!composing
) {
e.preventDefault() e.preventDefault()
send() send()
} }
}} }}
placeholder={ placeholder={
language === 'zh' language === 'zh'
? '跟 NOFXi 聊点什么...' ? '跟 NOFXi 聊点什么... ⌘K'
: 'Ask NOFXi anything...' : 'Ask NOFXi anything... ⌘K'
} }
rows={1} rows={1}
style={{ style={{
@@ -531,33 +786,30 @@ export function AgentChatPage() {
fontFamily: 'inherit', fontFamily: 'inherit',
resize: 'none', resize: 'none',
lineHeight: 1.5, lineHeight: 1.5,
maxHeight: 120, maxHeight: 150,
}} }}
/> />
<button <button
onClick={() => send()} onClick={() => send()}
disabled={loading || !input.trim()} disabled={loading || !input.trim()}
style={{ style={{
width: 38, width: 36,
height: 38, height: 36,
borderRadius: 12, borderRadius: 12,
border: 'none', border: 'none',
background: background:
loading || !input.trim() loading || !input.trim()
? '#1a1a28' ? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)', : 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000', color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor: cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
loading || !input.trim()
? 'not-allowed'
: 'pointer',
display: 'grid', display: 'grid',
placeItems: 'center', placeItems: 'center',
flexShrink: 0, flexShrink: 0,
transition: 'all 0.2s', transition: 'all 0.2s ease',
}} }}
> >
<Send size={16} /> <ArrowUp size={16} strokeWidth={2.5} />
</button> </button>
</div> </div>
<div <div
@@ -566,7 +818,7 @@ export function AgentChatPage() {
margin: '6px auto 0', margin: '6px auto 0',
textAlign: 'center', textAlign: 'center',
fontSize: 10, fontSize: 10,
color: '#2c2c42', color: '#1e1e32',
}} }}
> >
NOFXi may make mistakes. Always verify trading decisions. NOFXi may make mistakes. Always verify trading decisions.
@@ -579,35 +831,37 @@ export function AgentChatPage() {
{sidebarOpen && ( {sidebarOpen && (
<motion.div <motion.div
initial={{ width: 0, opacity: 0 }} initial={{ width: 0, opacity: 0 }}
animate={{ width: 300, opacity: 1 }} animate={{ width: 280, opacity: 1 }}
exit={{ width: 0, opacity: 0 }} exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }} transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{ style={{
borderLeft: '1px solid #1a1a28', borderLeft: '1px solid rgba(255,255,255,0.04)',
background: '#0b0b13', background: 'rgba(11,11,19,0.6)',
backdropFilter: 'blur(12px)',
overflowY: 'auto', overflowY: 'auto',
overflowX: 'hidden', overflowX: 'hidden',
flexShrink: 0, flexShrink: 0,
}} }}
className="custom-scrollbar"
> >
<div style={{ padding: '12px 12px 20px', width: 300 }}> <div style={{ padding: '12px 10px 20px', width: 280 }}>
{/* Sidebar header */} {/* Sidebar header */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 14, marginBottom: 12,
padding: '0 4px', padding: '4px 6px',
}} }}
> >
<span <span
style={{ style={{
fontSize: 11, fontSize: 10,
fontWeight: 700, fontWeight: 700,
color: '#5c5c72', color: '#4c4c62',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1.5,
}} }}
> >
{language === 'zh' ? '交易面板' : 'Trading Panel'} {language === 'zh' ? '交易面板' : 'Trading Panel'}
@@ -616,7 +870,7 @@ export function AgentChatPage() {
{/* Sidebar sections */} {/* Sidebar sections */}
{sidebarSections.map((section) => ( {sidebarSections.map((section) => (
<div key={section.key} style={{ marginBottom: 10 }}> <div key={section.key} style={{ marginBottom: 8 }}>
<button <button
onClick={() => toggleSection(section.key)} onClick={() => toggleSection(section.key)}
style={{ style={{
@@ -624,27 +878,29 @@ export function AgentChatPage() {
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
width: '100%', width: '100%',
padding: '8px 8px', padding: '7px 8px',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: '#8a8aa0', color: '#7a7a90',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
borderRadius: 8, borderRadius: 8,
transition: 'background 0.15s', transition: 'all 0.15s ease',
fontFamily: 'inherit', fontFamily: 'inherit',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = '#111118' e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.color = '#a0a0b0'
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent' e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = '#7a7a90'
}} }}
> >
{section.icon} {section.icon}
<span>{section.title}</span> <span>{section.title}</span>
<span style={{ marginLeft: 'auto' }}> <span style={{ marginLeft: 'auto', transition: 'transform 0.2s' }}>
{sections[section.key] ? ( {sections[section.key] ? (
<ChevronDown size={14} /> <ChevronDown size={14} />
) : ( ) : (
@@ -674,12 +930,71 @@ export function AgentChatPage() {
)} )}
</AnimatePresence> </AnimatePresence>
{/* Blinking cursor animation */} {/* Animations */}
<style>{` <style>{`
@keyframes blink { @keyframes blink {
0%, 50% { opacity: 1; } 0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; } 51%, 100% { opacity: 0; }
} }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.3; }
30% { transform: translateY(-4px); opacity: 0.8; }
}
.typing-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #F0B90B;
display: inline-block;
animation: typingBounce 1.2s infinite;
}
.suggestion-card:hover {
background: rgba(240,185,11,0.04) !important;
border-color: rgba(240,185,11,0.15) !important;
transform: translateY(-1px);
}
.quick-action-btn:hover {
border-color: rgba(240,185,11,0.2) !important;
color: #F0B90B !important;
background: rgba(240,185,11,0.04) !important;
}
.chat-input-wrapper:focus-within {
border-color: rgba(240,185,11,0.25) !important;
box-shadow: 0 0 0 1px rgba(240,185,11,0.08);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.06);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.1);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (max-width: 640px) {
.suggestion-card {
padding: 12px !important;
}
}
`}</style> `}</style>
</div> </div>
) )