mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +08:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user