mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 22:10:57 +08:00
fix: SSE streaming for no-tool path, batch ticker API, security & cleanup
- Fix thinkAndActStream: emit delta event for non-tool responses (was silent) - Add fallback to streaming on first LLM call failure - Add batch ticker endpoint /api/agent/tickers (3 API calls → 1) - Update MarketTicker to use batch endpoint, remove unused state - Warn loudly when JWT_SECRET uses default value - Fix 华为 mapping (was incorrectly mapped to Tencent) - Replace ioutil.ReadAll with safe.ReadAllLimited in nofxos client - Remove unused variable in hyperliquid trader_account - Wrap CoinAnk WS readers with safe.GoNamed for panic recovery - Use 100dvh for mobile viewport in AgentChatPage - console.log → console.warn for auth events
This commit is contained in:
@@ -335,27 +335,45 @@ func (a *Agent) thinkAndActStream(ctx context.Context, userID int64, lang, text
|
||||
|
||||
tools := agentTools()
|
||||
|
||||
// Tool-calling loop: use non-streaming to detect tool calls.
|
||||
// Once no tool calls remain, stream the final response for real-time UX.
|
||||
// Tool-calling loop with streaming:
|
||||
// 1. Non-streaming call with tools to detect if LLM needs tools
|
||||
// 2. If tools needed: execute them, loop back
|
||||
// 3. When done (no more tools): stream the final response via SSE
|
||||
const maxToolRounds = 5
|
||||
toolsUsed := false
|
||||
|
||||
for round := 0; round < maxToolRounds; round++ {
|
||||
req := &mcp.Request{Messages: messages, Tools: tools, ToolChoice: "auto"}
|
||||
resp, err := a.aiClient.CallWithRequestFull(req)
|
||||
if err != nil {
|
||||
a.logger.Error("LLM call failed (stream)", "error", err, "round", round)
|
||||
if round == 0 {
|
||||
// First round failed — try streaming without tools as fallback
|
||||
streamReq := &mcp.Request{Messages: messages}
|
||||
streamText, streamErr := a.aiClient.CallWithRequestStream(streamReq, func(chunk string) {
|
||||
onEvent(StreamEventDelta, chunk)
|
||||
})
|
||||
if streamErr != nil {
|
||||
return a.noAIFallback(lang, text)
|
||||
}
|
||||
a.history.Add(userID, "assistant", streamText)
|
||||
return streamText, nil
|
||||
}
|
||||
return a.noAIFallback(lang, text)
|
||||
}
|
||||
|
||||
// No tool calls → final text response
|
||||
// No tool calls → done with tool loop
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
if !toolsUsed {
|
||||
// No tools at all — response was fast, just return it
|
||||
// No tools were ever called — the non-streaming probe already has the answer.
|
||||
// Emit as a single delta so frontend renders it immediately.
|
||||
onEvent(StreamEventDelta, resp.Content)
|
||||
a.history.Add(userID, "assistant", resp.Content)
|
||||
return resp.Content, nil
|
||||
}
|
||||
// After tool rounds, we already have the response (non-streamed).
|
||||
// Still return it — the SSE handler sends it as "done".
|
||||
// Tools were used in previous rounds, LLM gave final answer without streaming.
|
||||
// This shouldn't normally happen (we break and stream below), but handle it.
|
||||
onEvent(StreamEventDelta, resp.Content)
|
||||
a.history.Add(userID, "assistant", resp.Content)
|
||||
return resp.Content, nil
|
||||
}
|
||||
@@ -375,9 +393,22 @@ func (a *Agent) thinkAndActStream(ctx context.Context, userID int64, lang, text
|
||||
result := a.handleToolCall(ctx, userID, lang, tc)
|
||||
messages = append(messages, mcp.Message{Role: "tool", Content: result, ToolCallID: tc.ID})
|
||||
}
|
||||
|
||||
// After tool execution, stream the next LLM response for real-time UX.
|
||||
// Omit tools so LLM can't start another tool round — it must produce text.
|
||||
streamReq := &mcp.Request{Messages: messages}
|
||||
streamText, streamErr := a.aiClient.CallWithRequestStream(streamReq, func(chunk string) {
|
||||
onEvent(StreamEventDelta, chunk)
|
||||
})
|
||||
if streamErr != nil {
|
||||
a.logger.Error("stream post-tool response failed", "error", streamErr, "round", round)
|
||||
return a.noAIFallback(lang, text)
|
||||
}
|
||||
a.history.Add(userID, "assistant", streamText)
|
||||
return streamText, nil
|
||||
}
|
||||
|
||||
// Exhausted tool rounds — stream the final synthesis response
|
||||
// Exhausted all tool rounds — stream the final synthesis response
|
||||
finalReq := &mcp.Request{Messages: messages}
|
||||
finalText, err := a.aiClient.CallWithRequestStream(finalReq, func(chunk string) {
|
||||
onEvent(StreamEventDelta, chunk)
|
||||
|
||||
@@ -14,6 +14,17 @@ import (
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// stockHTTPClient is a shared HTTP client for stock API requests.
|
||||
// Reused across calls for connection pooling.
|
||||
var stockHTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// StockQuote holds real-time stock data.
|
||||
type StockQuote struct {
|
||||
Name string
|
||||
@@ -54,7 +65,7 @@ var knownStocks = map[string]string{
|
||||
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
|
||||
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
|
||||
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
|
||||
"华为": "hk00700", // fallback to tencent for now
|
||||
// 华为 is not publicly listed — removed incorrect Tencent fallback
|
||||
// 美股
|
||||
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
|
||||
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
|
||||
@@ -127,13 +138,16 @@ func searchStock(keyword string) ([]SearchResult, error) {
|
||||
req, _ := http.NewRequest("GET", u, nil)
|
||||
req.Header.Set("Referer", "https://finance.sina.com.cn")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := stockHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
|
||||
body, err := safe.ReadAllLimited(reader)
|
||||
if err != nil {
|
||||
@@ -283,11 +297,14 @@ func fetchStockQuote(code string) (*StockQuote, error) {
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Referer", "https://finance.sina.com.cn")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := stockHTTPClient.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
|
||||
body, err := safe.ReadAllLimited(reader)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
52
agent/web.go
52
agent/web.go
@@ -187,6 +187,58 @@ func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
|
||||
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
}
|
||||
|
||||
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
|
||||
// Batch endpoint: fetches multiple tickers in one API call to Binance.
|
||||
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
|
||||
symbolsParam := r.URL.Query().Get("symbols")
|
||||
if symbolsParam == "" {
|
||||
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
|
||||
}
|
||||
|
||||
// Validate and build JSON array of symbols
|
||||
symbols := []string{}
|
||||
for _, s := range splitAndTrim(symbolsParam) {
|
||||
if validSymbolRe.MatchString(s) {
|
||||
symbols = append(symbols, `"`+s+`"`)
|
||||
}
|
||||
}
|
||||
if len(symbols) == 0 {
|
||||
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
|
||||
return
|
||||
}
|
||||
if len(symbols) > 20 {
|
||||
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
|
||||
return
|
||||
}
|
||||
|
||||
// Binance supports batch ticker: /fapi/v1/ticker/24hr with multiple symbols
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbols=[%s]", joinStrings(symbols, ","))
|
||||
proxyBinance(rw, url)
|
||||
}
|
||||
|
||||
// splitAndTrim splits a comma-separated string and trims whitespace.
|
||||
func splitAndTrim(s string) []string {
|
||||
parts := []string{}
|
||||
for _, p := range regexp.MustCompile(`\s*,\s*`).Split(s, -1) {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// joinStrings joins strings with a separator.
|
||||
func joinStrings(ss []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range ss {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func proxyBinance(rw http.ResponseWriter, url string) {
|
||||
resp, err := binanceClient.Get(url)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user