package deerflow import ( "bufio" "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "git.gozeros.com/wsk/eightbf-analysis-go/internal/config" ) // Client 调 DeerFlow:模型校验 / init / run/stream。 type Client struct { cfg config.Config auth *Auth } func NewClient(cfg config.Config, auth *Auth) *Client { return &Client{cfg: cfg, auth: auth} } // AssertModelAvailable 未知模型拒绝开跑(禁止依赖网关静默换模)。 func (c *Client) AssertModelAvailable(model string) error { want := strings.TrimSpace(model) if want == "" || strings.EqualFold(want, "demo") { return nil } token, err := c.auth.BearerToken() if err != nil { return err } req, err := http.NewRequest(http.MethodGet, c.cfg.DeerFlowBaseURL+"/api/models", nil) if err != nil { return err } req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+token) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return fmt.Errorf("无法校验模型列表 HTTP %d: %s", resp.StatusCode, string(raw)) } var root map[string]interface{} _ = json.Unmarshal(raw, &root) var arr []interface{} if m, ok := root["models"].([]interface{}); ok { arr = m } else { var list []interface{} if err := json.Unmarshal(raw, &list); err == nil { arr = list } } if arr == nil { return fmt.Errorf("DeerFlow /api/models 响应无 models 列表,拒绝使用未知模型「%s」", want) } for _, item := range arr { m, ok := item.(map[string]interface{}) if !ok { continue } name := fmt.Sprint(m["name"]) id := fmt.Sprint(m["model"]) display := fmt.Sprint(m["display_name"]) if want == name || want == id || want == display { return nil } } return fmt.Errorf("所选模型「%s」不在可用列表中(或不可用)。请换可用模型后重新开始会商,不会自动改用其它模型", want) } // InitMultiAgent 建多席线程,返回 agentName → threadId。 func (c *Client) InitMultiAgent(agentNames []string, mainAgent, model string) (map[string]string, error) { body := map[string]interface{}{ "agent_names": agentNames, "main_agent_name": mainAgent, } if m := strings.TrimSpace(model); m != "" && !strings.EqualFold(m, "demo") { body["model"] = m } threadIDs := map[string]string{} var errMsg string err := c.readSSE("/api/multi-agent/init", body, nil, func(event string, parsed map[string]interface{}) (bool, error) { ev := event if ev == "" { ev = fmt.Sprint(parsed["event"]) } switch ev { case "seat_ready", "coordinator_ready": agent := strField(parsed, "agent_name", "main_agent_name") tid := strField(parsed, "thread_id") if agent != "" && tid != "" { threadIDs[agent] = tid } case "init_done": if tids, ok := parsed["thread_ids"].(map[string]interface{}); ok { for k, v := range tids { threadIDs[k] = fmt.Sprint(v) } } return true, nil case "error": errMsg = strField(parsed, "detail") if errMsg == "" { errMsg = "init error" } return true, nil } return false, nil }) if err != nil { return nil, err } if errMsg != "" { return nil, fmt.Errorf("多智能体初始化失败:%s", errMsg) } if len(threadIDs) == 0 { return nil, fmt.Errorf("多智能体初始化未返回 thread_ids") } log.Printf("DeerFlow init 完成 seats=%d", len(threadIDs)) return threadIDs, nil } // RunStream 跑一轮流式会商。 func (c *Client) RunStream( agentType string, threadIDs map[string]string, agentName, displayName, newMessage, model string, synthesisMode bool, onText TextDeltaHandler, cancel CancelCheck, ) (*StreamResult, error) { tidNode := map[string]string{} for k, v := range threadIDs { tidNode[k] = v } body := map[string]interface{}{ "agent_type": agentType, "d_agent_thread_id": tidNode, "agent_name": agentName, "new_message": newMessage, "thinking_enabled": true, "reasoning_effort": "medium", "subagent_enabled": false, "seat_skill_directive": false, "synthesis_mode": synthesisMode, "suppress_pre_broadcast": true, "parallel_no_file": false, "peer_deliveries": []interface{}{}, } if displayName != "" { body["display_name"] = displayName } if m := strings.TrimSpace(model); m != "" && !strings.EqualFold(m, "demo") { body["model"] = m } result := &StreamResult{} var contentAcc strings.Builder lastEvent := "" terminal := false display := &streamDisplayAssembler{} err := c.readSSE("/api/multi-agent/run/stream", body, cancel, func(event string, parsed map[string]interface{}) (bool, error) { if event != "" { lastEvent = event } if cancel != nil && cancel() { result.Aborted = true return true, nil } if lastEvent == "error" { result.InvalidDelivery = true detail := strField(parsed, "detail", "message", "content") if detail != "" { result.Content = detail } else if contentAcc.Len() > 0 { result.Content = contentAcc.String() } else { result.Content = "模型运行报错" } terminal = true return true, nil } if status, _ := parsed["status"].(string); status == "model_switch" { result.InvalidDelivery = true failed := strField(parsed, "failed_model") next := strField(parsed, "next_model") reason := strField(parsed, "reason_label", "reason") if reason == "" { reason = "调用失败" } msg := "所选模型" if failed != "" { msg += "「" + failed + "」" } msg += "异常(" + reason + ")" if next != "" { msg += ",已拒绝自动切换到「" + next + "」" } msg += "。请换可用模型后重新开始会商。" result.Content = msg terminal = true return true, nil } if isFinalStatusFrame(parsed) { if closeThink := display.closeThinkIfOpen(); closeThink != "" { contentAcc.WriteString(closeThink) if onText != nil { _ = onText(closeThink) } } if c, ok := parsed["content"].(string); ok { result.Content = c } if n, ok := parsed["agent_name"].(string); ok { result.AgentName = n } switch st := parsed["status"].(type) { case string: if st == "clarification" { result.Clarification = true result.ClarificationDetail = strField(parsed, "content") } else if st == "invalid_delivery" || st == "error" { result.InvalidDelivery = true } case []interface{}: for _, row := range st { arr, ok := row.([]interface{}) if !ok || len(arr) < 2 { continue } result.Dispatched = append(result.Dispatched, [2]string{fmt.Sprint(arr[0]), fmt.Sprint(arr[1])}) } } if result.Content == "" { result.Content = contentAcc.String() } terminal = true return true, nil } if lastEvent == "messages" || lastEvent == "" { delta := display.push(parsed) if delta != "" { contentAcc.WriteString(delta) if onText != nil { if err := onText(delta); err != nil { return true, err } } } } return false, nil }) if err != nil { return result, err } if result.Content == "" { result.Content = contentAcc.String() } if !result.Aborted && !terminal && !result.Clarification { result.InvalidDelivery = true if strings.TrimSpace(result.Content) == "" { result.Content = "流式输出异常结束(无终态帧)" } } return result, nil } func (c *Client) readSSE( path string, body map[string]interface{}, cancel CancelCheck, onData func(event string, parsed map[string]interface{}) (stop bool, err error), ) error { token, err := c.auth.BearerToken() if err != nil { return err } raw, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPost, c.cfg.DeerFlowBaseURL+path, bytes.NewReader(raw)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+token) client := &http.Client{Timeout: time.Duration(c.cfg.DeerFlowStreamIdleTimeout) * time.Millisecond} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("DeerFlow %s HTTP %d: %s", path, resp.StatusCode, string(b)) } scanner := bufio.NewScanner(resp.Body) // 单行可能很长 buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 4*1024*1024) curEvent := "" for scanner.Scan() { if cancel != nil && cancel() { return nil } line := scanner.Text() if strings.HasPrefix(line, "event:") { curEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) continue } if !strings.HasPrefix(line, "data:") { continue } payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if payload == "" { continue } var parsed interface{} if err := json.Unmarshal([]byte(payload), &parsed); err != nil { continue } switch t := parsed.(type) { case map[string]interface{}: stop, err := onData(curEvent, t) if err != nil || stop { return err } case []interface{}: // messages 帧常为数组,包一层给 assembler stop, err := onData(curEvent, map[string]interface{}{"_arr": t}) if err != nil || stop { return err } // 同时把数组直接交给 push 路径:用特殊 key 不够;改为在 onData 外再处理 _ = t } } return scanner.Err() } func isFinalStatusFrame(v map[string]interface{}) bool { if v == nil { return false } if _, ok := v["status"]; !ok { return false } if _, ok := v["content"]; !ok { return false } switch st := v["status"].(type) { case string: return true case []interface{}: for _, item := range st { arr, ok := item.([]interface{}) if !ok || len(arr) < 2 { return false } } return true default: return false } } func strField(m map[string]interface{}, keys ...string) string { for _, k := range keys { if v, ok := m[k]; ok && v != nil { s := strings.TrimSpace(fmt.Sprint(v)) if s != "" && s != "" { return s } } } return "" } // —— 流式展示折算(reasoning → )—— type streamDisplayAssembler struct { thinkOpen bool reasoningSeen string } func (d *streamDisplayAssembler) push(parsed map[string]interface{}) string { var arr []interface{} if a, ok := parsed["_arr"].([]interface{}); ok { arr = a } else { // 兼容:有些路径直接把数组当根;此处若不是 AI chunk 则空 return "" } if len(arr) == 0 { return "" } obj, ok := arr[0].(map[string]interface{}) if !ok { return "" } typ := strings.ToLower(fmt.Sprint(obj["type"])) if typ != "ai" && typ != "aimessagechunk" && typ != "aimessage" { return "" } var reasoning, answer strings.Builder switch c := obj["content"].(type) { case string: answer.WriteString(c) case []interface{}: for _, item := range c { if s, ok := item.(string); ok { answer.WriteString(s) continue } m, ok := item.(map[string]interface{}) if !ok { continue } bt := strings.ToLower(fmt.Sprint(m["type"])) val := firstText(m, "thinking", "text", "content") if val == "" { continue } if strings.Contains(bt, "think") || strings.Contains(bt, "reason") { reasoning.WriteString(val) } else { answer.WriteString(val) } } } if fromKw := extractReasoning(obj); fromKw != "" { if strings.HasPrefix(fromKw, d.reasoningSeen) { more := fromKw[len(d.reasoningSeen):] if more != "" { reasoning.WriteString(more) } d.reasoningSeen = fromKw } else if !strings.Contains(d.reasoningSeen, fromKw) { reasoning.WriteString(fromKw) d.reasoningSeen += fromKw } } var out strings.Builder if reasoning.Len() > 0 { if !d.thinkOpen { out.WriteString("") d.thinkOpen = true } out.WriteString(reasoning.String()) } if answer.Len() > 0 { if d.thinkOpen { out.WriteString("") d.thinkOpen = false } out.WriteString(answer.String()) } return out.String() } func (d *streamDisplayAssembler) closeThinkIfOpen() string { if !d.thinkOpen { return "" } d.thinkOpen = false return "" } func extractReasoning(obj map[string]interface{}) string { for _, k := range []string{"reasoning_content", "reasoning"} { if s, ok := obj[k].(string); ok && s != "" { return s } } if kw, ok := obj["additional_kwargs"].(map[string]interface{}); ok { for _, k := range []string{"reasoning_content", "reasoning"} { if s, ok := kw[k].(string); ok && s != "" { return s } } } return "" } func firstText(m map[string]interface{}, keys ...string) string { for _, k := range keys { if s, ok := m[k].(string); ok && s != "" { return s } } return "" }