chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)

Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gozerosandCursor committed 2026-11-03 00:13:46 +08:00
commit 0c45b86d67
29 files changed
+3789

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
package deerflow
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
)
// Auth 向 DeerFlow 换 Bearer,过期前复用。
type Auth struct {
cfg config.Config
mu sync.Mutex
token string
expiresAt time.Time
httpClient *http.Client
}
func NewAuth(cfg config.Config) *Auth {
return &Auth{
cfg: cfg,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
func (a *Auth) BearerToken() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if a.token != "" && a.expiresAt.After(now.Add(2*time.Minute)) {
return a.token, nil
}
body, _ := json.Marshal(map[string]string{
"username": a.cfg.DeerFlowUser,
"password": a.cfg.DeerFlowPassword,
})
req, err := http.NewRequest(http.MethodPost, a.cfg.DeerFlowBaseURL+"/api/v1/auth/login/username", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", fmt.Errorf("DeerFlow 登录失败 HTTP %d: %s", resp.StatusCode, string(raw))
}
var parsed struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", err
}
if parsed.AccessToken == "" {
return "", fmt.Errorf("DeerFlow 登录响应无 access_token")
}
if parsed.ExpiresIn <= 0 {
parsed.ExpiresIn = 3600
}
a.token = parsed.AccessToken
a.expiresAt = now.Add(time.Duration(parsed.ExpiresIn) * time.Second)
return a.token, nil
}
+503
View File
@@ -0,0 +1,503 @@
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 ""
}
+18
View File
@@ -0,0 +1,18 @@
package deerflow
// StreamResult 一次 run/stream 汇总(与 Java StreamResult 对齐)。
type StreamResult struct {
Content string
AgentName string
Dispatched [][2]string // [agentId, task]
Clarification bool
ClarificationDetail string
InvalidDelivery bool
Aborted bool
}
// TextDeltaHandler 流式正文增量(可含 <think>)。
type TextDeltaHandler func(delta string) error
// CancelCheck 读 SSE 前询问是否已取消。
type CancelCheck func() bool