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:
commit
0c45b86d67
29 files changed
+3789
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user