mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
750 lines
23 KiB
TypeScript
750 lines
23 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import {
|
|
PanelRightClose,
|
|
PanelRightOpen,
|
|
TrendingUp,
|
|
Wallet,
|
|
Bot,
|
|
Bookmark,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
} 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 { WelcomeScreen } from '../components/agent/WelcomeScreen'
|
|
import { ChatMessages } from '../components/agent/ChatMessages'
|
|
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
|
|
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
|
|
import {
|
|
chatStorageKey,
|
|
clearAgentMessages,
|
|
getStoredAuthUserId,
|
|
loadAgentMessages,
|
|
migrateAgentMessages,
|
|
prepareAgentMessagesForPersistence,
|
|
persistAgentMessages,
|
|
} from '../lib/agentChatStorage'
|
|
|
|
interface Message {
|
|
id: string
|
|
role: 'user' | 'bot'
|
|
text: string
|
|
time: string
|
|
streaming?: boolean
|
|
steps?: AgentStep[]
|
|
}
|
|
|
|
interface AgentStep {
|
|
id: string
|
|
label: string
|
|
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
|
|
detail?: string
|
|
}
|
|
|
|
let msgIdCounter = 0
|
|
function nextId() {
|
|
return `msg-${Date.now()}-${++msgIdCounter}`
|
|
}
|
|
|
|
function appendStep(
|
|
existing: AgentStep[] | undefined,
|
|
step: AgentStep
|
|
): AgentStep[] {
|
|
const prev = existing ?? []
|
|
const index = prev.findIndex((item) => item.id === step.id)
|
|
if (index === -1) return [...prev, step]
|
|
return prev.map((item, i) => (i === index ? { ...item, ...step } : item))
|
|
}
|
|
|
|
function parsePlanSteps(data: string): AgentStep[] {
|
|
const text = data.replace(/^🗺️\s*(Plan|计划):\s*/i, '').trim()
|
|
if (!text) return []
|
|
return text.split(/\s*->\s*/).map((part, index) => {
|
|
const cleaned = part.replace(/^\d+\./, '').trim()
|
|
return {
|
|
id: `plan-${index + 1}`,
|
|
label: cleaned || `Step ${index + 1}`,
|
|
status: 'pending',
|
|
}
|
|
})
|
|
}
|
|
|
|
function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
|
|
const match = data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) || data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
|
|
if (match) {
|
|
const id = `plan-${match[1]}`
|
|
return {
|
|
id,
|
|
label: match[3].trim(),
|
|
status: 'running',
|
|
detail: data,
|
|
}
|
|
}
|
|
return {
|
|
id: `step-${fallbackIndex}`,
|
|
label: data,
|
|
status: 'running',
|
|
detail: data,
|
|
}
|
|
}
|
|
|
|
function markLatestRunningCompleted(existing: AgentStep[] | undefined, detail: string): AgentStep[] {
|
|
const prev = existing ?? []
|
|
for (let i = prev.length - 1; i >= 0; i--) {
|
|
if (prev[i].status === 'running') {
|
|
return prev.map((step, index) =>
|
|
index === i ? { ...step, status: 'completed', detail } : step
|
|
)
|
|
}
|
|
}
|
|
return prev
|
|
}
|
|
|
|
export function AgentChatPage() {
|
|
const { language } = useLanguage()
|
|
const { token, user } = useAuth()
|
|
const [storageUserId, setStorageUserId] = useState<string | undefined>(() => getStoredAuthUserId())
|
|
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
|
|
const storageKey = chatStorageKey(user?.id || storageUserId)
|
|
const [messages, setMessages] = useState<Message[]>(
|
|
() => loadAgentMessages<Message>(window.localStorage, user?.id || storageUserId).messages
|
|
)
|
|
const [historyHydrated, setHistoryHydrated] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const chatInputRef = useRef<ChatInputHandle>(null)
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
|
|
// Sidebar section collapse state
|
|
const [sections, setSections] = useState({
|
|
market: true,
|
|
positions: true,
|
|
traders: false,
|
|
preferences: true,
|
|
})
|
|
|
|
const toggleSection = (key: keyof typeof sections) => {
|
|
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
|
}
|
|
|
|
// Auto-scroll
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
setStorageUserId(user?.id || getStoredAuthUserId())
|
|
}, [user?.id])
|
|
|
|
useEffect(() => {
|
|
if (!user?.id) return
|
|
migrateAgentMessages(window.localStorage, user.id)
|
|
}, [user?.id])
|
|
|
|
// Restore chat history for the current user when opening the agent page.
|
|
useEffect(() => {
|
|
setHistoryHydrated(false)
|
|
setMessages(loadAgentMessages<Message>(window.localStorage, user?.id || storageUserId).messages)
|
|
setHistoryHydrated(true)
|
|
}, [storageKey, storageUserId, user?.id])
|
|
|
|
// Persist chat history locally so page navigation does not wipe the conversation.
|
|
useEffect(() => {
|
|
if (!historyHydrated) return
|
|
try {
|
|
const persistable = prepareAgentMessagesForPersistence(messages).slice(-100)
|
|
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
|
|
} catch {
|
|
// Ignore storage failures and keep the chat usable.
|
|
}
|
|
}, [historyHydrated, messages, storageKey, storageUserId, user?.id])
|
|
|
|
// Responsive sidebar
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
if (window.innerWidth <= 768) setSidebarOpen(false)
|
|
}
|
|
window.addEventListener('resize', handleResize)
|
|
return () => window.removeEventListener('resize', handleResize)
|
|
}, [])
|
|
|
|
// Escape to close sidebar on mobile
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && window.innerWidth <= 768) {
|
|
setSidebarOpen(false)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [])
|
|
|
|
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, time }
|
|
const botId = nextId()
|
|
const nextConversation: Message[] = [
|
|
userMsg,
|
|
{
|
|
id: botId,
|
|
role: 'bot',
|
|
text: '',
|
|
time: '',
|
|
streaming: true,
|
|
},
|
|
]
|
|
setMessages((prev) =>
|
|
text.trim() === '/clear'
|
|
? nextConversation
|
|
: [...prev, ...nextConversation]
|
|
)
|
|
setLoading(true)
|
|
|
|
if (text.trim() === '/clear') {
|
|
try {
|
|
clearAgentMessages(window.localStorage, user?.id || storageUserId)
|
|
} catch {
|
|
// Ignore storage cleanup failure.
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Abort any in-flight request
|
|
abortRef.current?.abort()
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
const res = await fetch('/api/agent/chat/stream', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({ message: text, lang: language, user_key: user?.id }),
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}))
|
|
throw new Error(errData.error || `Server error (${res.status})`)
|
|
}
|
|
|
|
// Real SSE streaming
|
|
const reader = res.body?.getReader()
|
|
const decoder = new TextDecoder()
|
|
if (!reader) throw new Error('No response body')
|
|
|
|
let buffer = ''
|
|
let finalText = ''
|
|
let stepCounter = 0
|
|
const now = () =>
|
|
new Date().toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || '' // Keep incomplete line in buffer
|
|
|
|
let eventType = ''
|
|
for (const line of lines) {
|
|
if (line.startsWith('event: ')) {
|
|
eventType = line.slice(7).trim()
|
|
} else if (line.startsWith('data: ') && eventType) {
|
|
const rawData = line.slice(6)
|
|
let data: string
|
|
try {
|
|
data = JSON.parse(rawData)
|
|
} catch {
|
|
// Ignore malformed SSE data lines
|
|
eventType = ''
|
|
continue
|
|
}
|
|
if (eventType === 'delta') {
|
|
// data is the accumulated text so far
|
|
finalText = data
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? { ...m, text: data, time: now() }
|
|
: m
|
|
)
|
|
)
|
|
} else if (eventType === 'plan') {
|
|
const parsedSteps = parsePlanSteps(data)
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? {
|
|
...m,
|
|
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
|
|
text: m.text || data,
|
|
time: now(),
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
} else if (eventType === 'step_start') {
|
|
stepCounter += 1
|
|
const nextStep = parseStepEvent(data, stepCounter)
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? {
|
|
...m,
|
|
steps: appendStep(m.steps, nextStep),
|
|
text: m.text || data,
|
|
time: now(),
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
} else if (eventType === 'step_complete') {
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? {
|
|
...m,
|
|
steps: markLatestRunningCompleted(m.steps, data),
|
|
text: m.text || data,
|
|
time: now(),
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
} else if (eventType === 'replan') {
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? {
|
|
...m,
|
|
steps: appendStep(m.steps, {
|
|
id: `replan-${Date.now()}`,
|
|
label: data,
|
|
status: 'replanned',
|
|
detail: data,
|
|
}),
|
|
text: m.text || data,
|
|
time: now(),
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
} else if (
|
|
eventType === 'tool'
|
|
) {
|
|
// Show tool being called as a status indicator
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? {
|
|
...m,
|
|
text: m.text || `🔧 _Calling ${data}..._`,
|
|
steps: appendStep(m.steps, {
|
|
id: `tool-${Date.now()}`,
|
|
label: `Tool: ${data}`,
|
|
status: 'running',
|
|
detail: data,
|
|
}),
|
|
time: now(),
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
} else if (eventType === 'done') {
|
|
finalText = data
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? { ...m, text: data, time: now(), streaming: false }
|
|
: m
|
|
)
|
|
)
|
|
} else if (eventType === 'error') {
|
|
throw new Error(data)
|
|
}
|
|
eventType = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
// If stream ended without a "done" event, mark as done
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId && m.streaming
|
|
? {
|
|
...m,
|
|
text: finalText || m.text || 'No response',
|
|
streaming: false,
|
|
time: now(),
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
window.dispatchEvent(new CustomEvent('agent-preferences-refresh'))
|
|
window.dispatchEvent(new CustomEvent('agent-config-refresh'))
|
|
} catch (e: any) {
|
|
if (e.name === 'AbortError') {
|
|
// Request was cancelled (e.g. user sent a new message), clean up silently
|
|
setMessages((prev) => prev.filter((m) => m.id !== botId))
|
|
} else {
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === botId
|
|
? {
|
|
...m,
|
|
text: '⚠️ Error: ' + e.message,
|
|
time: new Date().toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}),
|
|
streaming: false,
|
|
}
|
|
: m
|
|
)
|
|
)
|
|
}
|
|
}
|
|
setLoading(false)
|
|
chatInputRef.current?.focus()
|
|
}
|
|
|
|
const quickActions = language === 'zh'
|
|
? [
|
|
{ label: '💼 持仓', cmd: '/positions' },
|
|
{ label: '💰 余额', cmd: '/balance' },
|
|
{ label: '📋 Traders', cmd: '/traders' },
|
|
{ label: '🧹 清除记忆', cmd: '/clear' },
|
|
{ label: '❓ 帮助', cmd: '/help' },
|
|
]
|
|
: [
|
|
{ label: '💼 Positions', cmd: '/positions' },
|
|
{ label: '💰 Balance', cmd: '/balance' },
|
|
{ label: '📋 Traders', cmd: '/traders' },
|
|
{ label: '🧹 Clear', cmd: '/clear' },
|
|
{ label: '❓ Help', cmd: '/help' },
|
|
]
|
|
|
|
const sidebarSections = [
|
|
{
|
|
key: 'market' as const,
|
|
icon: <TrendingUp size={14} />,
|
|
title: language === 'zh' ? '市场行情' : 'Market',
|
|
component: <MarketTicker />,
|
|
},
|
|
{
|
|
key: 'positions' as const,
|
|
icon: <Wallet size={14} />,
|
|
title: language === 'zh' ? '持仓' : 'Positions',
|
|
component: <PositionsPanel />,
|
|
},
|
|
{
|
|
key: 'traders' as const,
|
|
icon: <Bot size={14} />,
|
|
title: 'Traders',
|
|
component: <TraderStatusPanel />,
|
|
},
|
|
{
|
|
key: 'preferences' as const,
|
|
icon: <Bookmark size={14} />,
|
|
title: language === 'zh' ? '用户偏好' : 'Preferences',
|
|
component: <UserPreferencesPanel token={token} language={language} />,
|
|
},
|
|
]
|
|
|
|
const isWelcomeState = messages.length === 0
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
height: 'calc(100dvh - 64px)',
|
|
background: '#09090b',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* ==================== MAIN CHAT AREA ==================== */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
minWidth: 0,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{/* Top bar with quick actions */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
padding: '8px 16px',
|
|
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"
|
|
>
|
|
{quickActions.map((a, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => void send(a.cmd)}
|
|
className="quick-action-btn"
|
|
style={{
|
|
padding: '5px 12px',
|
|
background: 'rgba(255,255,255,0.03)',
|
|
border: '1px solid rgba(255,255,255,0.06)',
|
|
borderRadius: 20,
|
|
color: '#6c6c82',
|
|
fontSize: 12,
|
|
cursor: 'pointer',
|
|
whiteSpace: 'nowrap',
|
|
fontFamily: 'inherit',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
>
|
|
{a.label}
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
style={{
|
|
marginLeft: 'auto',
|
|
padding: 6,
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: '#4c4c62',
|
|
cursor: 'pointer',
|
|
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} />
|
|
) : (
|
|
<PanelRightOpen size={18} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Messages area or Welcome state */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: '20px 0',
|
|
}}
|
|
className="custom-scrollbar"
|
|
>
|
|
{isWelcomeState ? (
|
|
<WelcomeScreen language={language} onSend={send} />
|
|
) : (
|
|
<ChatMessages messages={messages} ref={messagesEndRef} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Input area */}
|
|
<ChatInput
|
|
ref={chatInputRef}
|
|
language={language}
|
|
loading={loading}
|
|
onSend={send}
|
|
/>
|
|
</div>
|
|
|
|
{/* ==================== RIGHT SIDEBAR ==================== */}
|
|
<AnimatePresence>
|
|
{sidebarOpen && (
|
|
<motion.div
|
|
initial={{ width: 0, opacity: 0 }}
|
|
animate={{ width: 280, opacity: 1 }}
|
|
exit={{ width: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
style={{
|
|
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 10px 20px', width: 280 }}>
|
|
{/* Sidebar header */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 12,
|
|
padding: '4px 6px',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
color: '#4c4c62',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1.5,
|
|
}}
|
|
>
|
|
{language === 'zh' ? '交易面板' : 'Trading Panel'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Sidebar sections */}
|
|
{sidebarSections.map((section) => (
|
|
<div key={section.key} style={{ marginBottom: 8 }}>
|
|
<button
|
|
onClick={() => toggleSection(section.key)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
width: '100%',
|
|
padding: '7px 8px',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: '#7a7a90',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
borderRadius: 8,
|
|
transition: 'all 0.15s ease',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
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', transition: 'transform 0.2s' }}>
|
|
{sections[section.key] ? (
|
|
<ChevronDown size={14} />
|
|
) : (
|
|
<ChevronRight size={14} />
|
|
)}
|
|
</span>
|
|
</button>
|
|
<AnimatePresence>
|
|
{sections[section.key] && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
style={{ overflow: 'hidden', padding: '0 4px' }}
|
|
>
|
|
<div style={{ paddingTop: 4 }}>
|
|
{section.component}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|