mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
feat: unified NOFXi — one system, one port, one UI
Key changes:
- Agent API routes (/api/agent/*) registered directly on NOFX's :8080 server
- No more separate :8900 port — everything on :8080
- AgentChatPage.tsx added to React frontend
- Agent tab (🤖 Agent) added to navigation bar, set as DEFAULT page
- Branding: NOFX → NOFXi everywhere (title, header, branding.ts, i18n)
- Default landing page is now Agent chat, not competition
This is THE change: there is no more 'NOFX' vs 'NOFXi'.
Users open localhost:8080, they see NOFXi.
Agent chat is the first thing they see.
All other pages (Traders, Dashboard, Strategy, Data) are still there.
One binary. One port. One product. NOFXi.
This commit is contained in:
188
agent/web.go
188
agent/web.go
@@ -4,104 +4,94 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebHandler provides HTTP endpoints for the NOFXi agent.
|
||||
// These are registered on the existing NOFX API server.
|
||||
type WebHandler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebHandler creates a new web handler.
|
||||
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
|
||||
return &WebHandler{agent: agent, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers agent API routes on an existing mux or gin router.
|
||||
// For now we use a standalone http server on a separate port.
|
||||
func (w *WebHandler) StartStandalone(port int) {
|
||||
mux := http.NewServeMux()
|
||||
// HandleHealth handles GET /api/agent/health.
|
||||
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
// Health
|
||||
mux.HandleFunc("/api/agent/health", func(rw http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
|
||||
})
|
||||
// HandleChat handles POST /api/agent/chat.
|
||||
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
writeJSON(rw, 400, map[string]string{"error": "message required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = 1
|
||||
}
|
||||
msg := req.Message
|
||||
if req.Lang != "" {
|
||||
msg = "[lang:" + req.Lang + "] " + msg
|
||||
}
|
||||
|
||||
// Chat endpoint
|
||||
mux.HandleFunc("/api/agent/chat", func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
writeJSON(rw, 400, map[string]string{"error": "message required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
|
||||
defer cancel()
|
||||
|
||||
msg := req.Message
|
||||
if req.Lang != "" {
|
||||
msg = "[lang:" + req.Lang + "] " + msg
|
||||
}
|
||||
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
|
||||
if err != nil {
|
||||
writeJSON(rw, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
|
||||
defer cancel()
|
||||
// HandleKlines proxies kline data from Binance.
|
||||
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" { symbol = "BTCUSDT" }
|
||||
interval := r.URL.Query().Get("interval")
|
||||
if interval == "" { interval = "1h" }
|
||||
|
||||
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
|
||||
if err != nil {
|
||||
writeJSON(rw, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||
})
|
||||
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
|
||||
}
|
||||
|
||||
// Kline data
|
||||
mux.HandleFunc("/api/agent/klines", handleKlines)
|
||||
// HandleTicker proxies ticker data from Binance.
|
||||
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" { symbol = "BTCUSDT" }
|
||||
|
||||
// Ticker
|
||||
mux.HandleFunc("/api/agent/ticker", handleTicker)
|
||||
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
}
|
||||
|
||||
// Serve nofxi.html as the root page
|
||||
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(rw, r)
|
||||
return
|
||||
}
|
||||
// Try web/nofxi.html relative to executable, then working dir
|
||||
paths := []string{"web/nofxi.html", "../web/nofxi.html"}
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
http.ServeFile(rw, r, p)
|
||||
return
|
||||
}
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.Write([]byte("<h1>NOFXi Agent</h1><p>API is running. Web UI not found.</p>"))
|
||||
})
|
||||
|
||||
go func() {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
w.logger.Info("NOFXi agent web starting", "port", port, "url", fmt.Sprintf("http://localhost:%d", port))
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
w.logger.Error("agent web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
func proxyBinance(rw http.ResponseWriter, url string) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
writeJSON(rw, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
io.Copy(rw, resp.Body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
@@ -110,51 +100,3 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func handleKlines(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" { symbol = "BTCUSDT" }
|
||||
interval := r.URL.Query().Get("interval")
|
||||
if interval == "" { interval = "1h" }
|
||||
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(200)
|
||||
buf := make([]byte, 32768)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 { w.Write(buf[:n]) }
|
||||
if err != nil { break }
|
||||
}
|
||||
}
|
||||
|
||||
func handleTicker(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" { symbol = "BTCUSDT" }
|
||||
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(200)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 { w.Write(buf[:n]) }
|
||||
if err != nil { break }
|
||||
}
|
||||
}
|
||||
|
||||
15
api/agent_routes.go
Normal file
15
api/agent_routes.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"nofx/agent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
|
||||
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
|
||||
s.router.POST("/api/agent/chat", gin.WrapF(h.HandleChat))
|
||||
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
|
||||
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
|
||||
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
|
||||
}
|
||||
4
main.go
4
main.go
@@ -158,9 +158,9 @@ func main() {
|
||||
nofxiAgent.Start()
|
||||
defer nofxiAgent.Stop()
|
||||
|
||||
// Start NOFXi Agent Web API (port 8900)
|
||||
// Register NOFXi Agent API on the main server's router
|
||||
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
|
||||
agentWeb.StartStandalone(8900)
|
||||
server.RegisterAgentHandler(agentWeb)
|
||||
|
||||
logger.Info("🧠 NOFXi Agent started (sentinel + brain + scheduler + web:8900)")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
<!-- End Google Tag Manager -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/nofx.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NOFX - AI Auto Trading Dashboard</title>
|
||||
<title>NOFXi - AI Auto Trading Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ResetPasswordPage } from './components/auth/ResetPasswordPage'
|
||||
import { CompetitionPage } from './components/trader/CompetitionPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { AgentChatPage } from './pages/AgentChatPage'
|
||||
import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
} from './types'
|
||||
|
||||
type Page =
|
||||
| 'agent'
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
@@ -63,13 +65,14 @@ function App() {
|
||||
const path = window.location.pathname
|
||||
const hash = window.location.hash.slice(1) // 去掉 #
|
||||
|
||||
if (path === '/agent' || hash === 'agent') return 'agent'
|
||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||
if (path === '/data' || hash === 'data') return 'data'
|
||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
return 'competition' // 默认为竞赛页面
|
||||
return 'agent' // 默认为 Agent 页面
|
||||
}
|
||||
|
||||
// Login required overlay state
|
||||
@@ -84,6 +87,7 @@ function App() {
|
||||
// Unified page navigation handler
|
||||
const navigateToPage = (page: Page) => {
|
||||
const pathMap: Record<Page, string> = {
|
||||
'agent': '/agent',
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'data': '/data',
|
||||
@@ -141,7 +145,9 @@ function App() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const traderParam = params.get('trader')
|
||||
|
||||
if (path === '/traders' || hash === 'traders') {
|
||||
if (path === '/agent' || hash === 'agent') {
|
||||
setCurrentPage('agent')
|
||||
} else if (path === '/traders' || hash === 'traders') {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
@@ -399,6 +405,7 @@ function App() {
|
||||
if (route === '/data') {
|
||||
const dataPageNavigate = (page: Page) => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'agent': '/agent',
|
||||
'data': '/data',
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
@@ -476,6 +483,8 @@ function App() {
|
||||
>
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'agent' ? (
|
||||
<AgentChatPage />
|
||||
) : currentPage === 'data' ? (
|
||||
<DataPage />
|
||||
) : currentPage === 'strategy-market' ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t, type Language } from '../../i18n/translations'
|
||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||
|
||||
type Page =
|
||||
| 'agent'
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
@@ -93,6 +94,7 @@ export default function HeaderBar({
|
||||
{(() => {
|
||||
// Define all navigation tabs
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'agent', path: '/agent', label: language === 'zh' ? '🤖 Agent' : '🤖 Agent', requiresAuth: false },
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
@@ -334,6 +336,7 @@ export default function HeaderBar({
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
{(() => {
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'agent', path: '/agent', label: '🤖 Agent', requiresAuth: false },
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
|
||||
@@ -56,8 +56,8 @@ export const OFFICIAL_LINKS = {
|
||||
|
||||
// Brand watermark component data
|
||||
export const BRAND_INFO = {
|
||||
name: 'NOFX',
|
||||
tagline: 'AI Trading Platform',
|
||||
name: 'NOFXi',
|
||||
tagline: 'AI Trading Agent',
|
||||
version: '1.0.0',
|
||||
// Links embedded in multiple formats for redundancy
|
||||
social: {
|
||||
|
||||
@@ -3,7 +3,7 @@ export type Language = 'en' | 'zh' | 'id'
|
||||
export const translations = {
|
||||
en: {
|
||||
// Header
|
||||
appTitle: 'NOFX',
|
||||
appTitle: 'NOFXi',
|
||||
subtitle: 'Multi-AI Model Trading Platform',
|
||||
aiTraders: 'AI Traders',
|
||||
details: 'Details',
|
||||
@@ -1348,7 +1348,7 @@ export const translations = {
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
appTitle: 'NOFX',
|
||||
appTitle: 'NOFXi',
|
||||
subtitle: '多AI模型交易平台',
|
||||
aiTraders: 'AI交易员',
|
||||
details: '详情',
|
||||
@@ -2633,7 +2633,7 @@ export const translations = {
|
||||
},
|
||||
id: {
|
||||
// Header
|
||||
appTitle: 'NOFX',
|
||||
appTitle: 'NOFXi',
|
||||
subtitle: 'Platform Trading Multi-AI',
|
||||
aiTraders: 'Trader AI',
|
||||
details: 'Detail',
|
||||
|
||||
131
web/src/pages/AgentChatPage.tsx
Normal file
131
web/src/pages/AgentChatPage.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'bot'
|
||||
text: string
|
||||
time: string
|
||||
}
|
||||
|
||||
export function AgentChatPage() {
|
||||
const { language } = useLanguage()
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ 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 [loading, setLoading] = useState(false)
|
||||
const [composing, setComposing] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const msg = text || input.trim()
|
||||
if (!msg || loading) return
|
||||
setInput('')
|
||||
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
setMessages(prev => [...prev, { role: 'user', text: msg, time }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: msg, user_id: 1, lang: language })
|
||||
})
|
||||
const data = await res.json()
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'bot',
|
||||
text: data.response || data.error || 'No response',
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}])
|
||||
} catch (e: any) {
|
||||
setMessages(prev => [...prev, { role: 'bot', text: '⚠️ Error: ' + e.message, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }])
|
||||
}
|
||||
setLoading(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const quickActions = language === 'zh'
|
||||
? [
|
||||
{ label: '📊 分析 BTC', cmd: '/analyze BTC' },
|
||||
{ label: '📊 分析 ETH', cmd: '/analyze ETH' },
|
||||
{ label: '💼 持仓', cmd: '/positions' },
|
||||
{ label: '💰 余额', cmd: '/balance' },
|
||||
{ label: '📋 Traders', cmd: '/traders' },
|
||||
{ label: '❓ 帮助', cmd: '/help' },
|
||||
]
|
||||
: [
|
||||
{ label: '📊 Analyze BTC', cmd: '/analyze BTC' },
|
||||
{ label: '📊 Analyze ETH', cmd: '/analyze ETH' },
|
||||
{ label: '💼 Positions', cmd: '/positions' },
|
||||
{ label: '💰 Balance', cmd: '/balance' },
|
||||
{ label: '📋 Traders', cmd: '/traders' },
|
||||
{ label: '❓ Help', cmd: '/help' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#09090b' }}>
|
||||
{/* Quick actions */}
|
||||
<div style={{ display: 'flex', gap: 6, padding: '10px 16px', borderBottom: '1px solid #1f1f2c', overflowX: 'auto', flexShrink: 0 }}>
|
||||
{quickActions.map((a, i) => (
|
||||
<button key={i} onClick={() => send(a.cmd)}
|
||||
style={{ padding: '5px 12px', background: '#13131b', border: '1px solid #1f1f2c', borderRadius: 8, color: '#a0a0b0', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', fontFamily: 'inherit' }}
|
||||
>{a.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, marginBottom: 14, flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 8, display: 'grid', placeItems: 'center', fontSize: 15, flexShrink: 0, background: m.role === 'user' ? 'rgba(139,92,246,.1)' : 'rgba(0,229,160,.05)', border: '1px solid ' + (m.role === 'user' ? 'rgba(139,92,246,.15)' : '#1f1f2c') }}>
|
||||
{m.role === 'user' ? '👤' : '⚡'}
|
||||
</div>
|
||||
<div style={{ maxWidth: '70%' }}>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 14, fontSize: 13, lineHeight: 1.65, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
background: m.role === 'user' ? 'linear-gradient(135deg, #8b5cf6, #6d28d9)' : '#13131b',
|
||||
color: m.role === 'user' ? '#fff' : '#eaeaf0',
|
||||
border: m.role === 'bot' ? '1px solid #1f1f2c' : 'none',
|
||||
borderTopLeftRadius: m.role === 'bot' ? 3 : 14,
|
||||
borderTopRightRadius: m.role === 'user' ? 3 : 14,
|
||||
}}>{m.text}</div>
|
||||
<div style={{ fontSize: 10, color: '#5c5c72', marginTop: 2, textAlign: m.role === 'user' ? 'right' : 'left' }}>{m.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 8, display: 'grid', placeItems: 'center', fontSize: 15, background: 'rgba(0,229,160,.05)', border: '1px solid #1f1f2c' }}>⚡</div>
|
||||
<div style={{ padding: '10px 14px', background: '#13131b', border: '1px solid #1f1f2c', borderRadius: 14, borderTopLeftRadius: 3, color: '#5c5c72', fontSize: 13 }}>
|
||||
{language === 'zh' ? '思考中...' : 'Thinking...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{ padding: '12px 16px 16px', borderTop: '1px solid #1f1f2c', background: '#0c0c12' }}>
|
||||
<div style={{ display: 'flex', gap: 8, maxWidth: 800, margin: '0 auto', background: '#13131b', border: '1px solid #1f1f2c', borderRadius: 14, padding: '3px 3px 3px 14px' }}>
|
||||
<input ref={inputRef} value={input} onChange={e => setInput(e.target.value)}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !composing) { e.preventDefault(); send() } }}
|
||||
placeholder={language === 'zh' ? '跟 NOFXi 聊点什么...' : 'Ask NOFXi anything...'}
|
||||
style={{ flex: 1, background: 'none', border: 'none', color: '#eaeaf0', fontSize: 13, outline: 'none', padding: '8px 0', fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button onClick={() => send()} disabled={loading}
|
||||
style={{ width: 36, height: 36, borderRadius: 10, border: 'none', background: 'linear-gradient(135deg, #00e5a0, #00c896)', color: '#000', fontSize: 16, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? .35 : 1, display: 'grid', placeItems: 'center', flexShrink: 0 }}
|
||||
>➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user