mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
security: upgrade vulnerable deps + refactor AgentChatPage
Dependencies upgraded: - go-ethereum v1.16.7 → v1.16.8 (fixes GO-2026-4508 DoS + GO-2026-4314/4315 p2p vulns) - golang-jwt/jwt v5.2.0 → v5.2.2 (fixes GO-2025-3553 header parsing memory alloc) - quic-go v0.54.0 → v0.57.0 (fixes GO-2025-4233 HTTP/3 QPACK DoS) Frontend refactor: - Extract WelcomeScreen component (welcome state + suggestion cards) - Extract ChatMessages component (message list with avatars + streaming) - Extract ChatInput component (textarea + send button + Cmd+K shortcut) - AgentChatPage: 825 → 480 lines Note: 3 stdlib vulns (GO-2026-4599/4600/4601) require Go 1.26.1 upgrade
This commit is contained in:
12
go.mod
12
go.mod
@@ -5,10 +5,10 @@ go 1.25.3
|
||||
require (
|
||||
github.com/adshao/go-binance/v2 v2.8.9
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0
|
||||
github.com/ethereum/go-ethereum v1.16.7
|
||||
github.com/ethereum/go-ethereum v1.16.8
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@@ -87,8 +87,8 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
@@ -105,7 +105,7 @@ require (
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/mock v0.5.2 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
@@ -117,7 +117,7 @@ require (
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -71,6 +71,8 @@ github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qv
|
||||
github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok=
|
||||
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law=
|
||||
github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
@@ -112,6 +114,8 @@ github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
@@ -225,8 +229,12 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
|
||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -309,6 +317,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
@@ -376,6 +386,8 @@ golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
|
||||
154
web/src/components/agent/ChatInput.tsx
Normal file
154
web/src/components/agent/ChatInput.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
|
||||
export interface ChatInputHandle {
|
||||
focus: () => void
|
||||
clear: () => void
|
||||
getValue: () => string
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
language: string
|
||||
loading: boolean
|
||||
onSend: (text: string) => void
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
function ChatInput({ language, loading, onSend }, ref) {
|
||||
const [input, setInput] = useState('')
|
||||
const [composing, setComposing] = useState(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
setInput('')
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto'
|
||||
},
|
||||
getValue: () => input,
|
||||
}))
|
||||
|
||||
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, 150) + 'px'
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSend = () => {
|
||||
const msg = input.trim()
|
||||
if (!msg || loading) return
|
||||
setInput('')
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto'
|
||||
onSend(msg)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Cmd+K to focus
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px 20px',
|
||||
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: '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: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !composing) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
language === 'zh'
|
||||
? '跟 NOFXi 聊点什么... ⌘K'
|
||||
: 'Ask NOFXi anything... ⌘K'
|
||||
}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#eaeaf0',
|
||||
fontSize: 13.5,
|
||||
outline: 'none',
|
||||
padding: '10px 0',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 150,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background:
|
||||
loading || !input.trim()
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
|
||||
color: loading || !input.trim() ? '#3c3c52' : '#000',
|
||||
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 720,
|
||||
margin: '6px auto 0',
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
color: '#1e1e32',
|
||||
}}
|
||||
>
|
||||
NOFXi may make mistakes. Always verify trading decisions.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
136
web/src/components/agent/ChatMessages.tsx
Normal file
136
web/src/components/agent/ChatMessages.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { renderMessageContent } from './MessageRenderer'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'bot'
|
||||
text: string
|
||||
time: string
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(
|
||||
function ChatMessages({ messages }, ref) {
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
|
||||
{messages.map((m) => (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 14,
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
background:
|
||||
m.role === 'user'
|
||||
? '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,.15)'
|
||||
: 'rgba(240,185,11,.1)'),
|
||||
}}
|
||||
>
|
||||
{m.role === 'user' ? '👤' : '⚡'}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div style={{ maxWidth: '78%', minWidth: 0 }}>
|
||||
{m.role === 'user' ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: 18,
|
||||
borderTopRightRadius: 4,
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{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: 2,
|
||||
height: 15,
|
||||
background: '#F0B90B',
|
||||
marginLeft: 1,
|
||||
borderRadius: 1,
|
||||
animation: 'blink 0.8s infinite',
|
||||
verticalAlign: 'text-bottom',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{m.time && !m.streaming && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<div ref={ref} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
138
web/src/components/agent/WelcomeScreen.tsx
Normal file
138
web/src/components/agent/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Zap,
|
||||
BarChart3,
|
||||
Lightbulb,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SuggestionCard {
|
||||
icon: JSX.Element
|
||||
title: string
|
||||
subtitle: string
|
||||
cmd: string
|
||||
}
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
language: string
|
||||
onSend: (cmd: string) => void
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ language, onSend }: WelcomeScreenProps) {
|
||||
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?' },
|
||||
]
|
||||
|
||||
return (
|
||||
<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={() => onSend(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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
PanelRightClose,
|
||||
@@ -8,18 +8,15 @@ import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Lightbulb,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { MarketTicker } from '../components/agent/MarketTicker'
|
||||
import { PositionsPanel } from '../components/agent/PositionsPanel'
|
||||
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
|
||||
import { renderMessageContent } from '../components/agent/MessageRenderer'
|
||||
import { WelcomeScreen } from '../components/agent/WelcomeScreen'
|
||||
import { ChatMessages } from '../components/agent/ChatMessages'
|
||||
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
@@ -34,24 +31,14 @@ 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 { token } = useAuth()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [composing, setComposing] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const chatInputRef = useRef<ChatInputHandle>(null)
|
||||
|
||||
// Sidebar section collapse state
|
||||
const [sections, setSections] = useState({
|
||||
@@ -78,13 +65,9 @@ export function AgentChatPage() {
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcut: Cmd+K to focus input
|
||||
// Escape to close sidebar on mobile
|
||||
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)
|
||||
}
|
||||
@@ -93,29 +76,13 @@ export function AgentChatPage() {
|
||||
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, 150) + 'px'
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const msg = text || input.trim()
|
||||
if (!msg || loading) return
|
||||
setInput('')
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto'
|
||||
}
|
||||
const send = async (text: string) => {
|
||||
if (!text || loading) return
|
||||
const time = new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
const userMsg: Message = { id: nextId(), role: 'user', text: msg, time }
|
||||
const userMsg: Message = { id: nextId(), role: 'user', text, time }
|
||||
const botId = nextId()
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -137,7 +104,7 @@ export function AgentChatPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ message: msg, lang: language }),
|
||||
body: JSON.stringify({ message: text, lang: language }),
|
||||
})
|
||||
const data = await res.json()
|
||||
const responseText = data.response || data.error || 'No response'
|
||||
@@ -188,24 +155,9 @@ export function AgentChatPage() {
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
inputRef.current?.focus()
|
||||
chatInputRef.current?.focus()
|
||||
}
|
||||
|
||||
// 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: '💼 持仓', cmd: '/positions' },
|
||||
@@ -338,316 +290,19 @@ export function AgentChatPage() {
|
||||
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>
|
||||
<WelcomeScreen language={language} onSend={send} />
|
||||
) : (
|
||||
/* ========== MESSAGES ========== */
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
|
||||
{messages.map((m) => (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 14,
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
background:
|
||||
m.role === 'user'
|
||||
? '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,.15)'
|
||||
: 'rgba(240,185,11,.1)'),
|
||||
}}
|
||||
>
|
||||
{m.role === 'user' ? '👤' : '⚡'}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div style={{ maxWidth: '78%', minWidth: 0 }}>
|
||||
{m.role === 'user' ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: 18,
|
||||
borderTopRightRadius: 4,
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{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: 2,
|
||||
height: 15,
|
||||
background: '#F0B90B',
|
||||
marginLeft: 1,
|
||||
borderRadius: 1,
|
||||
animation: 'blink 0.8s infinite',
|
||||
verticalAlign: 'text-bottom',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{m.time && !m.streaming && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<ChatMessages messages={messages} ref={messagesEndRef} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px 20px',
|
||||
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: '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: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !composing) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
language === 'zh'
|
||||
? '跟 NOFXi 聊点什么... ⌘K'
|
||||
: 'Ask NOFXi anything... ⌘K'
|
||||
}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#eaeaf0',
|
||||
fontSize: 13.5,
|
||||
outline: 'none',
|
||||
padding: '10px 0',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 150,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={loading || !input.trim()}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background:
|
||||
loading || !input.trim()
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
|
||||
color: loading || !input.trim() ? '#3c3c52' : '#000',
|
||||
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 720,
|
||||
margin: '6px auto 0',
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
color: '#1e1e32',
|
||||
}}
|
||||
>
|
||||
NOFXi may make mistakes. Always verify trading decisions.
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
language={language}
|
||||
loading={loading}
|
||||
onSend={send}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ==================== RIGHT SIDEBAR ==================== */}
|
||||
|
||||
Reference in New Issue
Block a user