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 27fab11b68
commit e4a59f36ed
2 changed files with 624 additions and 259 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { TrendingUp, TrendingDown, RefreshCw } from 'lucide-react'
// icons reserved for future use
interface TickerData {
symbol: string
@@ -12,9 +12,16 @@ interface TickerData {
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
const SYMBOL_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
SOL: '◎',
}
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
const [, setRefreshing] = useState(false)
const fetchTickers = async () => {
try {
@@ -34,6 +41,7 @@ export function MarketTicker() {
// ignore
} finally {
setLoading(false)
setRefreshing(false)
}
}
@@ -60,9 +68,33 @@ export function MarketTicker() {
if (loading) {
return (
<div style={{ padding: '12px 14px', color: '#5c5c72', fontSize: 12 }}>
<RefreshCw size={14} className="animate-spin inline mr-2" />
Loading market data...
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => (
<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>
)
}
@@ -73,9 +105,12 @@ export function MarketTicker() {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
const isUp = pct >= 0
const color = isUp ? '#00e5a0' : '#F6465D'
const isUp = pct > 0
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 icon = SYMBOL_ICONS[label] || label[0]
return (
<div
@@ -84,10 +119,20 @@ export function MarketTicker() {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
padding: '10px 11px',
background: 'rgba(255,255,255,0.02)',
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 }}>
@@ -95,32 +140,37 @@ export function MarketTicker() {
style={{
width: 28,
height: 28,
borderRadius: 7,
background: isUp ? 'rgba(0,229,160,0.08)' : 'rgba(246,70,93,0.08)',
borderRadius: 8,
background: bgColor,
display: 'grid',
placeItems: 'center',
fontSize: 13,
fontWeight: 700,
color: color,
fontFamily: 'system-ui',
}}
>
{isUp ? (
<TrendingUp size={14} color={color} />
) : (
<TrendingDown size={14} color={color} />
)}
{icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
{label}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<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)}
</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)}%
</div>
</div>

View File

@@ -8,8 +8,11 @@ import {
Bot,
ChevronDown,
ChevronRight,
Send,
Sparkles,
ArrowUp,
Zap,
BarChart3,
Lightbulb,
Search,
} from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
import { MarketTicker } from '../components/agent/MarketTicker'
@@ -24,62 +27,186 @@ interface Message {
streaming?: boolean
}
// Simple markdown-ish renderer: bold, code, newlines
// Enhanced markdown renderer: headers, bold, italic, code, lists, links
function renderMessageContent(text: string) {
// Split by code blocks first
const parts = text.split(/(```[\s\S]*?```|`[^`]+`)/g)
return parts.map((part, i) => {
if (part.startsWith('```') && part.endsWith('```')) {
const code = part.slice(3, -3).replace(/^\w+\n/, '') // strip language hint
return (
const lines = text.split('\n')
const elements: JSX.Element[] = []
let inCodeBlock = false
let codeContent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Code block toggle
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre
key={i}
key={`code-${i}`}
style={{
background: '#0a0a12',
border: '1px solid #1f1f2c',
borderRadius: 8,
padding: '10px 12px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 10,
padding: '12px 14px',
fontSize: 12,
overflowX: 'auto',
margin: '6px 0',
fontFamily: 'monospace',
margin: '8px 0',
fontFamily: '"IBM Plex Mono", monospace',
color: '#c0c0d0',
lineHeight: 1.6,
}}
>
{code}
{codeContent.trim()}
</pre>
)
codeContent = ''
inCodeBlock = false
} else {
inCodeBlock = true
// language hint (reserved for syntax highlighting)
}
if (part.startsWith('`') && part.endsWith('`')) {
return (
continue
}
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
key={i}
style={{
background: '#1a1a28',
padding: '1px 5px',
borderRadius: 4,
fontSize: '0.9em',
fontFamily: 'monospace',
color: '#d0d0e0',
background: 'rgba(240,185,11,0.08)',
padding: '2px 6px',
borderRadius: 5,
fontSize: '0.88em',
fontFamily: '"IBM Plex Mono", monospace',
color: '#F0B90B',
border: '1px solid rgba(240,185,11,0.12)',
}}
>
{part.slice(1, -1)}
</code>
)
}
// Handle bold **text**
const boldParts = part.split(/(\*\*[^*]+\*\*)/g)
return boldParts.map((bp, j) => {
if (bp.startsWith('**') && bp.endsWith('**')) {
return (
<strong key={`${i}-${j}`} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{bp.slice(2, -2)}
} else if (part.startsWith('**') && part.endsWith('**')) {
result.push(
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{part.slice(2, -2)}
</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
@@ -87,23 +214,18 @@ function nextId() {
return `msg-${Date.now()}-${++msgIdCounter}`
}
// Suggestion cards for welcome state
interface SuggestionCard {
icon: JSX.Element
title: string
subtitle: string
cmd: string
}
export function AgentChatPage() {
const { language } = useLanguage()
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
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 [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [composing, setComposing] = useState(false)
@@ -114,7 +236,7 @@ export function AgentChatPage() {
const [sections, setSections] = useState({
market: true,
positions: true,
traders: true,
traders: false,
})
const toggleSection = (key: keyof typeof sections) => {
@@ -135,13 +257,28 @@ export function AgentChatPage() {
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
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
const el = e.target
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) {
await new Promise((r) => setTimeout(r, 12))
await new Promise((r) => setTimeout(r, 10))
}
}
// Mark streaming done
@@ -230,11 +367,23 @@ export function AgentChatPage() {
inputRef.current?.focus()
}
const quickActions =
language === 'zh'
// Welcome suggestions (ChatGPT style)
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: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
@@ -242,8 +391,6 @@ export function AgentChatPage() {
{ label: '❓ 帮助', cmd: '/help' },
]
: [
{ label: '📊 Analyze BTC', cmd: '/analyze BTC' },
{ label: '📊 Analyze ETH', cmd: '/analyze ETH' },
{ label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
@@ -267,11 +414,13 @@ export function AgentChatPage() {
{
key: 'traders' as const,
icon: <Bot size={14} />,
title: language === 'zh' ? 'Traders' : 'Traders',
title: 'Traders',
component: <TraderStatusPanel />,
},
]
const isWelcomeState = messages.length === 0
return (
<div
style={{
@@ -291,51 +440,43 @@ export function AgentChatPage() {
position: 'relative',
}}
>
{/* Quick actions bar */}
{/* Top bar with quick actions */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 16px',
borderBottom: '1px solid #1a1a28',
borderBottom: '1px solid rgba(255,255,255,0.04)',
overflowX: 'auto',
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) => (
<button
key={i}
onClick={() => send(a.cmd)}
className="quick-action-btn"
style={{
padding: '5px 12px',
background: '#111118',
border: '1px solid #1f1f2c',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 20,
color: '#8a8aa0',
color: '#6c6c82',
fontSize: 12,
cursor: 'pointer',
whiteSpace: 'nowrap',
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
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'
transition: 'all 0.2s ease',
}}
>
{a.label}
</button>
))}
{/* Sidebar toggle (desktop) */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
style={{
@@ -343,14 +484,17 @@ export function AgentChatPage() {
padding: 6,
background: 'transparent',
border: 'none',
color: '#5c5c72',
color: '#4c4c62',
cursor: 'pointer',
borderRadius: 6,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
flexShrink: 0,
transition: 'color 0.2s',
}}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onMouseEnter={(e) => { e.currentTarget.style.color = '#8a8aa0' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#4c4c62' }}
>
{sidebarOpen ? (
<PanelRightClose size={18} />
@@ -360,104 +504,226 @@ export function AgentChatPage() {
</button>
</div>
{/* Messages area */}
{/* Messages area or Welcome state */}
<div
style={{
flex: 1,
overflowY: 'auto',
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' }}>
{messages.map((m) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
style={{
display: 'flex',
gap: 12,
marginBottom: 20,
marginBottom: 24,
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
}}
>
{/* Avatar */}
<div
style={{
width: 32,
height: 32,
width: 30,
height: 30,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
fontSize: 15,
fontSize: 14,
flexShrink: 0,
marginTop: 2,
background:
m.role === 'user'
? 'linear-gradient(135deg, rgba(139,92,246,.15), rgba(139,92,246,.05))'
: 'linear-gradient(135deg, rgba(240,185,11,.1), rgba(0,229,160,.05))',
? 'linear-gradient(135deg, rgba(139,92,246,.12), rgba(139,92,246,.04))'
: 'linear-gradient(135deg, rgba(240,185,11,.08), rgba(0,229,160,.04))',
border:
'1px solid ' +
(m.role === 'user'
? 'rgba(139,92,246,.2)'
: 'rgba(240,185,11,.15)'),
? 'rgba(139,92,246,.15)'
: 'rgba(240,185,11,.1)'),
}}
>
{m.role === 'user' ? '👤' : '⚡'}
</div>
{/* Message bubble */}
<div style={{ maxWidth: '75%', minWidth: 0 }}>
{/* Message content */}
<div style={{ maxWidth: '78%', minWidth: 0 }}>
{m.role === 'user' ? (
<div
style={{
padding: '12px 16px',
borderRadius: 16,
padding: '10px 16px',
borderRadius: 18,
borderTopRightRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background:
m.role === 'user'
? '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,
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff',
}}
>
{m.role === 'bot'
? renderMessageContent(m.text)
: m.text}
{m.streaming && (
{m.text}
</div>
) : (
<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
style={{
display: 'inline-block',
width: 6,
height: 14,
width: 2,
height: 15,
background: '#F0B90B',
marginLeft: 2,
marginLeft: 1,
borderRadius: 1,
animation: 'blink 1s infinite',
animation: 'blink 0.8s infinite',
verticalAlign: 'text-bottom',
}}
/>
)}
</div>
{m.time && (
)}
{m.time && !m.streaming && (
<div
style={{
fontSize: 10,
color: '#3c3c52',
color: '#2c2c42',
marginTop: 4,
textAlign: m.role === 'user' ? 'right' : 'left',
paddingLeft: m.role === 'bot' ? 4 : 0,
paddingRight: m.role === 'user' ? 4 : 0,
}}
>
{m.role === 'bot' && 'NOFXi · '}
{m.time}
{m.role === 'bot' && 'NOFXi · '}{m.time}
</div>
)}
</div>
@@ -465,37 +731,30 @@ export function AgentChatPage() {
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input area */}
<div
style={{
padding: '12px 16px 20px',
borderTop: '1px solid #1a1a28',
background:
'linear-gradient(to top, #09090b 60%, transparent)',
borderTop: '1px solid rgba(255,255,255,0.04)',
background: 'linear-gradient(to top, #09090b 80%, transparent)',
}}
>
<div
className="chat-input-wrapper"
style={{
maxWidth: 720,
margin: '0 auto',
display: 'flex',
gap: 8,
background: '#111118',
border: '1px solid #1f1f2c',
borderRadius: 16,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 18,
padding: '4px 4px 4px 16px',
alignItems: 'flex-end',
transition: 'border-color 0.2s',
}}
onFocus={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor =
'#F0B90B40'
}}
onBlur={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor =
'#1f1f2c'
transition: 'all 0.2s ease',
}}
>
<textarea
@@ -505,19 +764,15 @@ export function AgentChatPage() {
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!composing
) {
if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.preventDefault()
send()
}
}}
placeholder={
language === 'zh'
? '跟 NOFXi 聊点什么...'
: 'Ask NOFXi anything...'
? '跟 NOFXi 聊点什么... ⌘K'
: 'Ask NOFXi anything... ⌘K'
}
rows={1}
style={{
@@ -531,33 +786,30 @@ export function AgentChatPage() {
fontFamily: 'inherit',
resize: 'none',
lineHeight: 1.5,
maxHeight: 120,
maxHeight: 150,
}}
/>
<button
onClick={() => send()}
disabled={loading || !input.trim()}
style={{
width: 38,
height: 38,
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.trim()
? '#1a1a28'
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor:
loading || !input.trim()
? 'not-allowed'
: 'pointer',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s',
transition: 'all 0.2s ease',
}}
>
<Send size={16} />
<ArrowUp size={16} strokeWidth={2.5} />
</button>
</div>
<div
@@ -566,7 +818,7 @@ export function AgentChatPage() {
margin: '6px auto 0',
textAlign: 'center',
fontSize: 10,
color: '#2c2c42',
color: '#1e1e32',
}}
>
NOFXi may make mistakes. Always verify trading decisions.
@@ -579,35 +831,37 @@ export function AgentChatPage() {
{sidebarOpen && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 300, opacity: 1 }}
animate={{ width: 280, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{
borderLeft: '1px solid #1a1a28',
background: '#0b0b13',
borderLeft: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(11,11,19,0.6)',
backdropFilter: 'blur(12px)',
overflowY: 'auto',
overflowX: 'hidden',
flexShrink: 0,
}}
className="custom-scrollbar"
>
<div style={{ padding: '12px 12px 20px', width: 300 }}>
<div style={{ padding: '12px 10px 20px', width: 280 }}>
{/* Sidebar header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14,
padding: '0 4px',
marginBottom: 12,
padding: '4px 6px',
}}
>
<span
style={{
fontSize: 11,
fontSize: 10,
fontWeight: 700,
color: '#5c5c72',
color: '#4c4c62',
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: 1.5,
}}
>
{language === 'zh' ? '交易面板' : 'Trading Panel'}
@@ -616,7 +870,7 @@ export function AgentChatPage() {
{/* Sidebar sections */}
{sidebarSections.map((section) => (
<div key={section.key} style={{ marginBottom: 10 }}>
<div key={section.key} style={{ marginBottom: 8 }}>
<button
onClick={() => toggleSection(section.key)}
style={{
@@ -624,27 +878,29 @@ export function AgentChatPage() {
alignItems: 'center',
gap: 6,
width: '100%',
padding: '8px 8px',
padding: '7px 8px',
background: 'transparent',
border: 'none',
color: '#8a8aa0',
color: '#7a7a90',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
borderRadius: 8,
transition: 'background 0.15s',
transition: 'all 0.15s ease',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#111118'
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.color = '#a0a0b0'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = '#7a7a90'
}}
>
{section.icon}
<span>{section.title}</span>
<span style={{ marginLeft: 'auto' }}>
<span style={{ marginLeft: 'auto', transition: 'transform 0.2s' }}>
{sections[section.key] ? (
<ChevronDown size={14} />
) : (
@@ -674,12 +930,71 @@ export function AgentChatPage() {
)}
</AnimatePresence>
{/* Blinking cursor animation */}
{/* Animations */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
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>
</div>
)