Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
504 lines
13 KiB
Go
504 lines
13 KiB
Go
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 ""
|
||
}
|