Files
eightbf-analysis-go/internal/deerflow/client.go
T
gozerosandCursor 0c45b86d67 chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)
Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 00:13:46 +08:00

504 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 != "<nil>" {
return s
}
}
}
return ""
}
// —— 流式展示折算(reasoning → <think>)——
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("<think>")
d.thinkOpen = true
}
out.WriteString(reasoning.String())
}
if answer.Len() > 0 {
if d.thinkOpen {
out.WriteString("</think>")
d.thinkOpen = false
}
out.WriteString(answer.String())
}
return out.String()
}
func (d *streamDisplayAssembler) closeThinkIfOpen() string {
if !d.thinkOpen {
return ""
}
d.thinkOpen = false
return "</think>"
}
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 ""
}