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

+189
View File
@@ -0,0 +1,189 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
)
// Handler HTTP 入口,路径与 Java AnalysisJobController 对齐。
type Handler struct {
Jobs *service.JobService
}
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/analysis/health", h.health)
mux.HandleFunc("POST /api/v1/analysis/jobs", h.create)
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}", h.get)
mux.HandleFunc("GET /api/v1/analysis/jobs", h.getByTask)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/cancel", h.cancel)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/resume", h.resume)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/plan", h.patchPlan)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/inject", h.inject)
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}/result", h.result)
}
func (h *Handler) caller(r *http.Request) service.Caller {
return service.NewCaller(r.Header.Get("X-Account-Id"), r.Header.Get("X-Analysis-Role"))
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"service": "eightbf-analysis-go",
})
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var body service.CreateJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Create(&body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
Created(w, job)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
job, err := h.Jobs.Get(id, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) getByTask(w http.ResponseWriter, r *http.Request) {
taskID := strings.TrimSpace(r.URL.Query().Get("taskId"))
if taskID == "" {
Fail(w, http.StatusBadRequest, 400, "taskId required")
return
}
latest := r.URL.Query().Get("latest") == "true"
job, err := h.Jobs.GetByTaskID(taskID, latest, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found for taskId")
return
}
OK(w, job)
}
func (h *Handler) cancel(w http.ResponseWriter, r *http.Request) {
job, err := h.Jobs.Cancel(r.PathValue("id"), h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) resume(w http.ResponseWriter, r *http.Request) {
var body service.ResumeJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Resume(r.PathValue("id"), &body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) patchPlan(w http.ResponseWriter, r *http.Request) {
var body service.PatchJobPlanRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.PatchPlan(r.PathValue("id"), &body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) inject(w http.ResponseWriter, r *http.Request) {
var body service.InjectJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Inject(r.PathValue("id"), &body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) result(w http.ResponseWriter, r *http.Request) {
job, err := h.Jobs.Get(r.PathValue("id"), h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, map[string]interface{}{
"id": job.ID,
"status": job.Status,
"result": job.Result,
"error": job.Error,
"dialogues": job.Dialogues,
})
}
func (h *Handler) mapErr(w http.ResponseWriter, err error) {
var be *service.BizError
if errors.As(err, &be) {
switch {
case errors.Is(be.Kind, service.ErrForbidden):
Fail(w, http.StatusForbidden, 403, be.Msg)
case errors.Is(be.Kind, service.ErrBadState), errors.Is(be.Kind, service.ErrBadArg):
Fail(w, http.StatusBadRequest, 400, be.Msg)
default:
Fail(w, http.StatusInternalServerError, 500, be.Msg)
}
return
}
Fail(w, http.StatusInternalServerError, 500, err.Error())
}
// WithCORS 本机联调放开跨域(经反代同源时无影响)。
func WithCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"encoding/json"
"net/http"
)
// ApiResponse 与 Java 一致:{ code, message, data };成功 code=0。
type ApiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func OK(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusOK, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func Created(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusCreated, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func Fail(w http.ResponseWriter, httpStatus, code int, msg string) {
writeJSON(w, httpStatus, ApiResponse{Code: code, Message: msg, Data: nil})
}
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"os"
"strconv"
"strings"
)
// Config 运行配置(环境变量优先,与 Java EBA_* / analysis.* 对齐)。
type Config struct {
Port int
// MySQL:优先 EBA_DB_DSN;否则用 HOST/PORT/NAME/USER/PASSWORD 拼,或解析 JDBC URL。
DBDSN string
AnalysisMode string // live | demo
AuthEnabled bool
DemoEnabled bool
LivePollMS int
DemoTickMS int
DeerFlowBaseURL string
DeerFlowUser string
DeerFlowPassword string
DeerFlowCoordinator string
DeerFlowStreamIdleTimeout int // 毫秒
}
// Load 从环境变量加载;缺省贴近本机联调。
func Load() Config {
c := Config{
Port: envInt("SERVER_PORT", 18097),
AnalysisMode: env("ANALYSIS_MODE", "live"),
AuthEnabled: envBool("ANALYSIS_AUTH_ENABLED", false),
DemoEnabled: envBool("EBA_WORKER_DEMO_ENABLED", false),
LivePollMS: envInt("EBA_WORKER_LIVE_POLL_MS", 1000),
DemoTickMS: envInt("EBA_WORKER_TICK_MS", 400),
DeerFlowBaseURL: strings.TrimRight(env("EBA_DF_BASE_URL", "http://127.0.0.1:8001"), "/"),
DeerFlowUser: env("EBA_DF_USER", "admin"),
DeerFlowPassword: env("EBA_DF_PASSWORD", "123ewq"),
DeerFlowCoordinator: env("EBA_DF_COORDINATOR", "roundtable-coordinator"),
DeerFlowStreamIdleTimeout: envInt("EBA_DF_STREAM_IDLE_TIMEOUT_MS", 180000),
}
c.DBDSN = resolveDSN()
// live 默认关掉 demo;显式 ANALYSIS_MODE=demo 时打开假进度
if c.AnalysisMode == "demo" {
c.DemoEnabled = true
}
if c.AnalysisMode == "live" && !envExplicit("EBA_WORKER_DEMO_ENABLED") {
c.DemoEnabled = false
}
return c
}
func resolveDSN() string {
if d := strings.TrimSpace(os.Getenv("EBA_DB_DSN")); d != "" {
return ensureMultiStatements(d)
}
user := env("EBA_DB_USER", "eba")
pass := env("EBA_DB_PASSWORD", "eba_local_dev")
jdbc := strings.TrimSpace(os.Getenv("EBA_DB_URL"))
if jdbc != "" {
if dsn, ok := jdbcToDSN(jdbc, user, pass); ok {
return dsn
}
}
host := env("EBA_DB_HOST", "127.0.0.1")
port := env("EBA_DB_PORT", "3306")
name := env("EBA_DB_NAME", "eightbf_analysis")
return user + ":" + pass + "@tcp(" + host + ":" + port + ")/" + name +
"?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
}
// jdbcToDSN 把 Java 的 jdbc:mysql://host:port/db?... 转成 Go driver DSN。
func jdbcToDSN(jdbc, user, pass string) (string, bool) {
s := strings.TrimPrefix(jdbc, "jdbc:mysql://")
if s == jdbc {
return "", false
}
hostPath := s
query := ""
if i := strings.Index(s, "?"); i >= 0 {
hostPath = s[:i]
query = s[i+1:]
}
host := hostPath
db := "eightbf_analysis"
if i := strings.Index(hostPath, "/"); i >= 0 {
host = hostPath[:i]
db = hostPath[i+1:]
}
params := "parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
_ = query // Java JDBC 专用参数不照搬
return user + ":" + pass + "@tcp(" + host + ")/" + db + "?" + params, true
}
func ensureMultiStatements(dsn string) string {
if strings.Contains(dsn, "multiStatements=") {
return dsn
}
if strings.Contains(dsn, "?") {
return dsn + "&multiStatements=true"
}
return dsn + "?multiStatements=true"
}
func env(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return def
}
func envExplicit(k string) bool {
_, ok := os.LookupEnv(k)
return ok
}
func envBool(k string, def bool) bool {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}
func envInt(k string, def int) int {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
+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
+56
View File
@@ -0,0 +1,56 @@
package domain
import "time"
// AnalysisJob 一场会商作业(服务端「研讨录像带」)。
// JSON 字段名与前端 mapJob / Java 实体对齐,少做二次映射。
type AnalysisJob struct {
ID string `json:"id"`
RequestID string `json:"requestId,omitempty"`
TaskID string `json:"taskId,omitempty"`
DraftID string `json:"draftId,omitempty"`
DraftKey string `json:"draftKey,omitempty"`
Status string `json:"status"`
Phase string `json:"phase,omitempty"`
Cycle int `json:"cycle"`
ActiveAgentID string `json:"activeAgentId,omitempty"`
ConsensusPercentage int `json:"consensusPercentage"`
PendingClarification string `json:"pendingClarification,omitempty"`
OrchestrationMode string `json:"orchestrationMode,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
OwnerAccountID string `json:"ownerAccountId,omitempty"`
Agents []map[string]interface{} `json:"agents"`
Dialogues []map[string]interface{} `json:"dialogues"`
ThreadIDs map[string]interface{} `json:"threadIds,omitempty"`
Plan map[string]interface{} `json:"plan"`
Access map[string]interface{} `json:"access"`
Progress map[string]interface{} `json:"progress"`
Result map[string]interface{} `json:"result,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// EnsurePlan plan 为空时补可写 Map。
func (j *AnalysisJob) EnsurePlan() map[string]interface{} {
if j.Plan == nil {
j.Plan = map[string]interface{}{}
}
return j.Plan
}
// EnsureProgress progress 为空时补可写 Map。
func (j *AnalysisJob) EnsureProgress() map[string]interface{} {
if j.Progress == nil {
j.Progress = map[string]interface{}{}
}
return j.Progress
}
// EnsureDialogues dialogues 为空时补切片。
func (j *AnalysisJob) EnsureDialogues() []map[string]interface{} {
if j.Dialogues == nil {
j.Dialogues = []map[string]interface{}{}
}
return j.Dialogues
}
+21
View File
@@ -0,0 +1,21 @@
package domain
// 会商作业状态(与 Java JobStatus / 前端约定一致,勿自创别名)。
const (
StatusQueued = "queued"
StatusRunning = "running"
StatusAwaitingInput = "awaiting_input"
StatusDone = "done"
StatusError = "error"
StatusCancelled = "cancelled"
)
// IsActive 是否仍可能被 worker 推进(含等人中)。
func IsActive(status string) bool {
return status == StatusQueued || status == StatusRunning || status == StatusAwaitingInput
}
// IsTerminal 是否已结束,不应再 resume / inject / 改计划。
func IsTerminal(status string) bool {
return status == StatusDone || status == StatusError || status == StatusCancelled
}
+298
View File
@@ -0,0 +1,298 @@
package repo
import (
"database/sql"
"encoding/json"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
// JobRepo 会商作业表读写(eba_analysis_job)。大字段整段 JSON 文本存。
type JobRepo struct {
DB *sql.DB
}
func NewJobRepo(db *sql.DB) *JobRepo {
return &JobRepo{DB: db}
}
// EnsureSchema 执行建表脚本(幂等;重复索引错误由调用方忽略亦可)。
func (r *JobRepo) EnsureSchema(schemaSQL string) error {
_, err := r.DB.Exec(schemaSQL)
return err
}
func (r *JobRepo) Insert(job *domain.AnalysisJob) error {
now := time.Now()
job.CreatedAt = &now
job.UpdatedAt = &now
_, err := r.DB.Exec(`INSERT INTO eba_analysis_job(
id,request_id,task_id,draft_id,draft_key,status,phase,cycle,active_agent_id,
consensus_percentage,pending_clarification,orchestration_mode,model,error_message,
owner_account_id,agents_json,chain_json,plan_json,access_json,thread_ids_json,
progress_json,result_json,cancel_flag,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?)`,
job.ID, nullStr(job.RequestID), nullStr(job.TaskID), nullStr(job.DraftID), nullStr(job.DraftKey),
job.Status, nullStr(job.Phase), job.Cycle, nullStr(job.ActiveAgentID),
job.ConsensusPercentage, nullStr(job.PendingClarification), nullStr(job.OrchestrationMode),
nullStr(job.Model), nullStr(job.Error), nullStr(job.OwnerAccountID),
mustJSON(job.Agents), mustJSON(job.Dialogues), mustJSON(job.Plan), mustJSON(job.Access),
mustJSON(job.ThreadIDs), mustJSON(job.Progress), mustJSON(job.Result),
now, now,
)
return err
}
// UpdateProgress 全量刷新进度相关列;cancelled 时同步 cancel_flag=1。
func (r *JobRepo) UpdateProgress(job *domain.AnalysisJob) error {
now := time.Now()
job.UpdatedAt = &now
cancel := 0
if job.Status == domain.StatusCancelled {
cancel = 1
}
_, err := r.DB.Exec(`UPDATE eba_analysis_job SET status=?, phase=?, cycle=?, active_agent_id=?,
consensus_percentage=?, pending_clarification=?, model=?, error_message=?, owner_account_id=?,
agents_json=?, chain_json=?, plan_json=?, access_json=?, thread_ids_json=?,
progress_json=?, result_json=?, cancel_flag=?, updated_at=? WHERE id=?`,
job.Status, nullStr(job.Phase), job.Cycle, nullStr(job.ActiveAgentID),
job.ConsensusPercentage, nullStr(job.PendingClarification), nullStr(job.Model),
nullStr(job.Error), nullStr(job.OwnerAccountID),
mustJSON(job.Agents), mustJSON(job.Dialogues), mustJSON(job.Plan), mustJSON(job.Access),
mustJSON(job.ThreadIDs), mustJSON(job.Progress), mustJSON(job.Result),
cancel, now, job.ID,
)
return err
}
func (r *JobRepo) FindByID(id string) (*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE id=?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanJob(rows)
}
func (r *JobRepo) FindActiveByTaskID(taskID string) (*domain.AnalysisJob, error) {
if taskID == "" {
return nil, nil
}
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? AND status IN (?,?,?)
ORDER BY created_at DESC LIMIT 1`,
taskID, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanJob(rows)
}
func (r *JobRepo) FindLatestByTaskID(taskID string) (*domain.AnalysisJob, error) {
if taskID == "" {
return nil, nil
}
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? ORDER BY created_at DESC LIMIT 1`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanJob(rows)
}
func (r *JobRepo) FindActiveJobs() ([]*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status IN (?,?,?) ORDER BY created_at ASC`,
domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return nil, err
}
defer rows.Close()
return scanJobs(rows)
}
func (r *JobRepo) FindQueuedJobs() ([]*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status=? ORDER BY created_at ASC`, domain.StatusQueued)
if err != nil {
return nil, err
}
defer rows.Close()
return scanJobs(rows)
}
func (r *JobRepo) MarkCancel(id string) (bool, error) {
res, err := r.DB.Exec(`UPDATE eba_analysis_job SET cancel_flag=1, updated_at=? WHERE id=? AND status IN (?,?,?)`,
time.Now(), id, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (r *JobRepo) IsCancelFlag(id string) (bool, error) {
var v sql.NullInt64
err := r.DB.QueryRow(`SELECT cancel_flag FROM eba_analysis_job WHERE id=?`, id).Scan(&v)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return v.Valid && v.Int64 == 1, nil
}
func scanJobs(rows *sql.Rows) ([]*domain.AnalysisJob, error) {
var out []*domain.AnalysisJob
for rows.Next() {
j, err := scanJob(rows)
if err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
func scanJob(rows *sql.Rows) (*domain.AnalysisJob, error) {
cols, err := rows.Columns()
if err != nil {
return nil, err
}
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
m := map[string]interface{}{}
for i, c := range cols {
m[c] = vals[i]
}
j := &domain.AnalysisJob{}
j.ID = asString(m["id"])
j.RequestID = asString(m["request_id"])
j.TaskID = asString(m["task_id"])
j.DraftID = asString(m["draft_id"])
j.DraftKey = asString(m["draft_key"])
j.Status = asString(m["status"])
j.Phase = asString(m["phase"])
j.Cycle = asInt(m["cycle"])
j.ActiveAgentID = asString(m["active_agent_id"])
j.ConsensusPercentage = asInt(m["consensus_percentage"])
j.PendingClarification = asString(m["pending_clarification"])
j.OrchestrationMode = asString(m["orchestration_mode"])
j.Model = asString(m["model"])
j.Error = asString(m["error_message"])
j.OwnerAccountID = asString(m["owner_account_id"])
j.Agents = readList(asString(m["agents_json"]))
j.Dialogues = readList(asString(m["chain_json"]))
j.Plan = readMap(asString(m["plan_json"]))
j.Access = readMap(asString(m["access_json"]))
j.ThreadIDs = readMap(asString(m["thread_ids_json"]))
j.Progress = readMap(asString(m["progress_json"]))
j.Result = readMap(asString(m["result_json"]))
if t := asTime(m["created_at"]); t != nil {
j.CreatedAt = t
}
if t := asTime(m["updated_at"]); t != nil {
j.UpdatedAt = t
}
return j, nil
}
func mustJSON(v interface{}) interface{} {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return string(b)
}
func readMap(jsonStr string) map[string]interface{} {
if jsonStr == "" {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
return nil
}
return m
}
func readList(jsonStr string) []map[string]interface{} {
if jsonStr == "" {
return []map[string]interface{}{}
}
var list []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
return []map[string]interface{}{}
}
return list
}
func nullStr(s string) interface{} {
if s == "" {
return nil
}
return s
}
func asString(v interface{}) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case []byte:
return string(t)
default:
return ""
}
}
func asInt(v interface{}) int {
if v == nil {
return 0
}
switch t := v.(type) {
case int64:
return int(t)
case int32:
return int(t)
case int:
return t
case []byte:
var n int
_ = json.Unmarshal(t, &n)
return n
default:
return 0
}
}
func asTime(v interface{}) *time.Time {
if v == nil {
return nil
}
if t, ok := v.(time.Time); ok {
tt := t
return &tt
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package repo
import (
"log"
"strings"
)
// ApplySchema 按语句拆开执行;重复索引等可忽略错误不阻断启动。
func (r *JobRepo) ApplySchema(schemaSQL string) {
for _, stmt := range splitSQL(schemaSQL) {
if _, err := r.DB.Exec(stmt); err != nil {
msg := err.Error()
// 1061 Duplicate key name / 已存在表
if strings.Contains(msg, "1061") || strings.Contains(msg, "1050") ||
strings.Contains(msg, "already exists") {
continue
}
log.Printf("schema 语句失败(已跳过): %v | sql=%.80s…", err, stmt)
}
}
}
func splitSQL(s string) []string {
var out []string
var b strings.Builder
lines := strings.Split(s, "\n")
for _, line := range lines {
trim := strings.TrimSpace(line)
if trim == "" || strings.HasPrefix(trim, "--") {
continue
}
b.WriteString(line)
b.WriteByte('\n')
if strings.HasSuffix(trim, ";") {
stmt := strings.TrimSpace(b.String())
stmt = strings.TrimSuffix(stmt, ";")
if stmt != "" {
out = append(out, stmt)
}
b.Reset()
}
}
if rest := strings.TrimSpace(b.String()); rest != "" {
out = append(out, rest)
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package service
import (
"fmt"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
const (
RoleAdmin = "admin"
RoleTaskOwner = "task_owner"
RolePositionOwner = "position_owner"
RoleViewer = "viewer"
)
// Caller 当前请求调用方(来自 X-Account-Id / X-Analysis-Role)。
type Caller struct {
AccountID string
Role string
}
func NewCaller(accountID, role string) Caller {
r := strings.TrimSpace(role)
if r == "" {
r = RoleViewer
}
return Caller{AccountID: strings.TrimSpace(accountID), Role: r}
}
// AccessService 岗位账号限制与对话投影。
type AccessService struct {
AuthEnabled bool
}
func (a *AccessService) CanMutate(job *domain.AnalysisJob, c Caller) bool {
if !a.AuthEnabled {
return true
}
if c.AccountID == "" {
return false
}
if c.Role == RoleAdmin || c.Role == RoleTaskOwner {
return true
}
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) {
return true
}
if a.isListedTaskOwner(job, c.AccountID) {
return true
}
return c.Role == RolePositionOwner && len(a.boundSeats(job, c.AccountID)) > 0
}
func (a *AccessService) CanCancel(job *domain.AnalysisJob, c Caller) bool {
if c.Role == RolePositionOwner {
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) || a.isListedTaskOwner(job, c.AccountID) {
return true
}
return false
}
if !a.AuthEnabled {
return true
}
if c.AccountID == "" {
return false
}
if c.Role == RoleAdmin || c.Role == RoleTaskOwner {
return true
}
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) {
return true
}
return a.isListedTaskOwner(job, c.AccountID)
}
func (a *AccessService) CanRead(job *domain.AnalysisJob, c Caller) bool {
if !a.AuthEnabled {
return true
}
if c.AccountID == "" {
return false
}
if a.CanMutate(job, c) || a.CanCancel(job, c) {
return true
}
return len(a.boundSeats(job, c.AccountID)) > 0
}
// ProjectForCaller 按角色裁剪对话后返回(责任人全量;岗位只见本席+总控/系统)。
func (a *AccessService) ProjectForCaller(job *domain.AnalysisJob, c Caller) *domain.AnalysisJob {
if job == nil {
return nil
}
if c.AccountID == "" {
return job
}
if c.Role == RoleAdmin || c.Role == RoleTaskOwner ||
c.AccountID == strings.TrimSpace(job.OwnerAccountID) ||
a.isListedTaskOwner(job, c.AccountID) {
return job
}
if c.Role != RolePositionOwner && !a.AuthEnabled {
return job
}
seats := a.boundSeats(job, c.AccountID)
copyJob := *job
if len(seats) == 0 {
var filtered []map[string]interface{}
for _, d := range job.Dialogues {
role := asStr(d["role"])
if role == "system" || role == "user" {
filtered = append(filtered, d)
}
}
copyJob.Dialogues = filtered
if job.Result != nil {
r := cloneMap(job.Result)
delete(r, "seatDeliveries")
copyJob.Result = r
}
return &copyJob
}
var filtered []map[string]interface{}
for _, d := range job.Dialogues {
role := asStr(d["role"])
agentID := asStr(d["agentId"])
if role == "system" || role == "user" || role == "leader" {
filtered = append(filtered, d)
} else if seats[agentID] {
filtered = append(filtered, d)
}
}
copyJob.Dialogues = filtered
if job.Result != nil {
r := cloneMap(job.Result)
if raw, ok := r["seatDeliveries"].([]interface{}); ok {
var kept []interface{}
for _, o := range raw {
m, ok := o.(map[string]interface{})
if !ok {
continue
}
if seats[asStr(m["agentId"])] {
kept = append(kept, m)
}
}
r["seatDeliveries"] = kept
}
copyJob.Result = r
}
return &copyJob
}
func (a *AccessService) boundSeats(job *domain.AnalysisJob, accountID string) map[string]bool {
out := map[string]bool{}
if job == nil || accountID == "" || job.Access == nil {
return out
}
raw, ok := job.Access["seatAccounts"]
if !ok {
return out
}
m, ok := raw.(map[string]interface{})
if !ok {
return out
}
for k, v := range m {
if accountInList(v, accountID) {
out[k] = true
}
}
return out
}
func (a *AccessService) isListedTaskOwner(job *domain.AnalysisJob, accountID string) bool {
if job == nil || job.Access == nil {
return false
}
return accountInList(job.Access["taskOwnerAccountIds"], accountID) ||
accountInList(job.Access["adminAccountIds"], accountID)
}
func accountInList(raw interface{}, accountID string) bool {
if raw == nil || accountID == "" {
return false
}
switch t := raw.(type) {
case []interface{}:
for _, o := range t {
if strings.TrimSpace(asStr(o)) == accountID {
return true
}
}
return false
default:
return strings.TrimSpace(asStr(raw)) == accountID
}
}
func cloneMap(m map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func asStr(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return strings.TrimSpace(fmt.Sprint(v))
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"testing"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
func TestPositionOwnerProjection(t *testing.T) {
a := &AccessService{AuthEnabled: false}
job := &domain.AnalysisJob{
OwnerAccountID: "owner1",
Access: map[string]interface{}{
"seatAccounts": map[string]interface{}{
"seat-a": []interface{}{"acc-a"},
},
},
Dialogues: []map[string]interface{}{
{"role": "leader", "agentId": "coord", "content": "派活"},
{"role": "special", "agentId": "seat-a", "content": "本席"},
{"role": "special", "agentId": "seat-b", "content": "它席"},
{"role": "user", "content": "我说"},
},
}
out := a.ProjectForCaller(job, NewCaller("acc-a", RolePositionOwner))
if len(out.Dialogues) != 3 {
t.Fatalf("期望 3 条(leader+本席+user),得到 %d", len(out.Dialogues))
}
for _, d := range out.Dialogues {
if asStr(d["agentId"]) == "seat-b" {
t.Fatal("岗位责任人不应看到它席气泡")
}
}
}
func TestPositionOwnerCannotCancel(t *testing.T) {
a := &AccessService{AuthEnabled: false}
job := &domain.AnalysisJob{
OwnerAccountID: "owner1",
Access: map[string]interface{}{
"seatAccounts": map[string]interface{}{"seat-a": []interface{}{"acc-a"}},
},
}
if a.CanCancel(job, NewCaller("acc-a", RolePositionOwner)) {
t.Fatal("岗位责任人默认不能 cancel")
}
if !a.CanMutate(job, NewCaller("acc-a", RolePositionOwner)) {
t.Fatal("岗位责任人应可 resume/inject")
}
}
+277
View File
@@ -0,0 +1,277 @@
package service
import (
"log"
"strconv"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// DemoWorker 假进度推进(仅 ANALYSIS_MODE=demo)。
type DemoWorker struct {
Repo *repo.JobRepo
Every time.Duration
}
var demoLines = []string{
"【演示】我先说观点:当前应先收窄目标,再谈方案细节。",
"【演示】节奏上建议短周期验证,避免一次铺太开。",
"【演示】依赖和数据口径要尽早对齐,否则后面容易返工。",
"【演示】成本先压到能证明价值的最小投入。",
"【演示】主要风险是范围蔓延和决策偏慢,需要明确拍板人。",
"【演示】我同意形成一版可执行共识,可以进入总结。",
}
const charsPerTick = 8
func (w *DemoWorker) Start() {
if w.Every <= 0 {
w.Every = 400 * time.Millisecond
}
go func() {
t := time.NewTicker(w.Every)
defer t.Stop()
for range t.C {
list, err := w.Repo.FindActiveJobs()
if err != nil {
continue
}
for _, job := range list {
if err := w.advance(job); err != nil {
log.Printf("demo worker advance failed jobId=%s: %v", job.ID, err)
job.Status = domain.StatusError
job.Phase = "error"
job.Error = err.Error()
_ = w.Repo.UpdateProgress(job)
}
}
}
}()
}
func (w *DemoWorker) advance(job *domain.AnalysisJob) error {
if ok, _ := w.Repo.IsCancelFlag(job.ID); ok {
finishCurrentBubble(job, "cancelled")
job.Status = domain.StatusCancelled
job.Phase = "cancelled"
job.Error = "cancelled"
p := job.EnsureProgress()
p["message"] = "cancelled"
return w.Repo.UpdateProgress(job)
}
if job.Status == domain.StatusAwaitingInput {
return nil
}
agents := job.Agents
if agents == nil {
agents = []map[string]interface{}{}
}
agentCount := len(agents)
if agentCount < 1 {
agentCount = 1
}
dialogues := job.EnsureDialogues()
progress := job.EnsureProgress()
if job.Status == domain.StatusQueued {
job.Status = domain.StatusRunning
job.Phase = "analyzing"
job.Cycle = 1
progress["percent"] = 5
progress["message"] = "席位开始发言"
progress["lineIndex"] = 0
progress["charIndex"] = 0
applyAgentStatus(agents, 0, "speaking")
job.ActiveAgentID = agentIDAt(agents, 0)
job.Agents = agents
openDemoBubble(job, dialogues, agents, 0, "席位输出中…")
// openDemoBubble 已写回 job.Dialogues;切勿再用旧 dialogues 切片盖掉新气泡
return w.Repo.UpdateProgress(job)
}
lineIndex := toIntVal(progress["lineIndex"], 0)
charIndex := toIntVal(progress["charIndex"], 0)
if lineIndex < len(demoLines) {
speakerIdx := lineIndex % agentCount
wantAgent := agentIDAt(agents, speakerIdx)
if job.ActiveAgentID != wantAgent || (charIndex == 0 && !hasStreaming(dialogues)) {
if charIndex == 0 {
finishCurrentBubble(job, "")
for i := range agents {
st := "listening"
if i == speakerIdx {
st = "speaking"
}
applyAgentStatus(agents, i, st)
}
job.ActiveAgentID = wantAgent
openDemoBubble(job, dialogues, agents, speakerIdx, "席位输出中…")
dialogues = job.Dialogues
}
}
line := demoLines[lineIndex]
end := charIndex + charsPerTick
if end > len([]rune(line)) {
end = len([]rune(line))
}
rs := []rune(line)
if end > charIndex {
appendStreaming(dialogues, string(rs[charIndex:end]))
charIndex = end
}
if charIndex >= len(rs) {
appendStreaming(dialogues, "\n\n")
finishCurrentBubble(job, "")
lineIndex++
charIndex = 0
}
totalChars := 0
for _, l := range demoLines {
totalChars += len([]rune(l))
}
doneChars := 0
for i := 0; i < lineIndex && i < len(demoLines); i++ {
doneChars += len([]rune(demoLines[i]))
}
doneChars += charIndex
percent := 5 + (doneChars*85)/max(totalChars, 1)
if percent > 90 {
percent = 90
}
progress["lineIndex"] = lineIndex
progress["charIndex"] = charIndex
progress["percent"] = percent
progress["message"] = "发言中 " + itoa(percent) + "%"
job.Cycle = 1 + lineIndex/max(agentCount, 1)
job.ConsensusPercentage = min(95, percent)
job.Agents = agents
job.Dialogues = dialogues
return w.Repo.UpdateProgress(job)
}
finishCurrentBubble(job, "")
summary := "【演示总结】以上均为占位发言,用于验证「关浏览器后任务仍继续 + 页面流式展示」。" +
"接入真实模型后,这里会换成总控根据各席发言生成的真正总结。" +
"(job=" + job.ID + ")"
dialogues = append(dialogues, map[string]interface{}{
"id": "dlg_" + job.ID + "_leader", "role": "leader", "sender": "总控协调",
"agentId": "coordinator", "content": summary, "status": "done", "phaseLabel": "全案盖印",
})
for i := range agents {
applyAgentStatus(agents, i, "done")
}
job.ActiveAgentID = ""
job.Agents = agents
job.Status = domain.StatusDone
job.Phase = "done"
job.ConsensusPercentage = 100
progress["percent"] = 100
progress["message"] = "completed"
job.Dialogues = dialogues
job.Result = map[string]interface{}{
"summary": summary,
"consensusPercentage": 100,
"recommendations": []string{"确认产品边界与里程碑", "补充关键风险与资源假设", "安排下一轮执行评审"},
}
return w.Repo.UpdateProgress(job)
}
func openDemoBubble(job *domain.AnalysisJob, dialogues, agents []map[string]interface{}, idx int, phase string) {
bubble := map[string]interface{}{
"id": "dlg_" + job.ID + "_" + itoa(len(dialogues)+1), "role": "special",
"sender": agentNameAt(agents, idx), "agentId": agentIDAt(agents, idx),
"content": "", "status": "streaming", "phaseLabel": phase,
}
job.Dialogues = append(dialogues, bubble)
}
func finishCurrentBubble(job *domain.AnalysisJob, override string) {
for i := len(job.Dialogues) - 1; i >= 0; i-- {
b := job.Dialogues[i]
if asStr(b["status"]) == "streaming" {
st := "done"
if override != "" {
st = override
}
b["status"] = st
b["phaseLabel"] = ""
return
}
}
}
func appendStreaming(dialogues []map[string]interface{}, chunk string) {
for i := len(dialogues) - 1; i >= 0; i-- {
b := dialogues[i]
if asStr(b["status"]) == "streaming" {
b["content"] = asStr(b["content"]) + chunk
return
}
}
}
func hasStreaming(dialogues []map[string]interface{}) bool {
for i := len(dialogues) - 1; i >= 0; i-- {
if asStr(dialogues[i]["status"]) == "streaming" {
return true
}
}
return false
}
func agentIDAt(agents []map[string]interface{}, idx int) string {
if len(agents) == 0 {
return ""
}
return asStr(agents[idx%len(agents)]["id"])
}
func agentNameAt(agents []map[string]interface{}, idx int) string {
if len(agents) == 0 {
return "席位"
}
a := agents[idx%len(agents)]
if n := asStr(a["name"]); n != "" {
return n
}
return asStr(a["id"])
}
func applyAgentStatus(agents []map[string]interface{}, idx int, status string) {
if idx < 0 || idx >= len(agents) {
return
}
agents[idx]["status"] = status
}
func toIntVal(v interface{}, def int) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
default:
return def
}
}
func itoa(n int) string { return strconv.Itoa(n) }
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+23
View File
@@ -0,0 +1,23 @@
package service
import "errors"
// 业务错误:HTTP 层按类型映射 403/400。
var (
ErrForbidden = errors.New("forbidden")
ErrBadState = errors.New("bad state")
ErrBadArg = errors.New("bad argument")
)
// BizError 带给人看的文案。
type BizError struct {
Kind error
Msg string
}
func (e *BizError) Error() string { return e.Msg }
func (e *BizError) Unwrap() error { return e.Kind }
func Forbidden(msg string) error { return &BizError{Kind: ErrForbidden, Msg: msg} }
func BadState(msg string) error { return &BizError{Kind: ErrBadState, Msg: msg} }
func BadArg(msg string) error { return &BizError{Kind: ErrBadArg, Msg: msg} }
+352
View File
@@ -0,0 +1,352 @@
package service
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// CreateJobRequest 创建会商作业入参。
type CreateJobRequest struct {
RequestID string `json:"requestId"`
TaskID string `json:"taskId"`
DraftID string `json:"draftId"`
DraftKey string `json:"draftKey"`
Model string `json:"model"`
OrchestrationMode string `json:"orchestrationMode"`
Title string `json:"title"`
Description string `json:"description"`
OwnerAccountID string `json:"ownerAccountId"`
ManualIntervention *bool `json:"manualIntervention"`
Agents []map[string]interface{} `json:"agents"`
Payload map[string]interface{} `json:"payload"`
Access map[string]interface{} `json:"access"`
}
type ResumeJobRequest struct {
Answer string `json:"answer"`
Reason string `json:"reason"`
}
type PatchJobPlanRequest struct {
ManualIntervention *bool `json:"manualIntervention"`
Model *string `json:"model"`
}
type InjectJobRequest struct {
Text string `json:"text"`
}
// JobService 会商作业编排(不直接调大模型)。
type JobService struct {
Repo *repo.JobRepo
Access *AccessService
}
func (s *JobService) Create(req *CreateJobRequest, caller Caller) (*domain.AnalysisJob, error) {
if req == nil {
req = &CreateJobRequest{}
}
manual := true
if req.ManualIntervention != nil {
manual = *req.ManualIntervention
}
owner := blankToNull(req.OwnerAccountID)
if owner == "" && caller.AccountID != "" {
owner = caller.AccountID
}
mode := blankToNull(req.OrchestrationMode)
if mode == "" {
mode = "roundtable"
}
job := &domain.AnalysisJob{
ID: "job_" + randHex(8),
RequestID: firstNonEmpty(blankToNull(req.RequestID), "req_"+randHex(6)),
TaskID: blankToNull(req.TaskID),
DraftID: blankToNull(req.DraftID),
DraftKey: blankToNull(req.DraftKey),
Status: domain.StatusQueued,
Phase: "queued",
OrchestrationMode: mode,
Model: strings.TrimSpace(req.Model),
OwnerAccountID: owner,
Agents: normalizeAgents(req.Agents),
Dialogues: []map[string]interface{}{},
Access: orEmptyMap(req.Access),
Plan: map[string]interface{}{
"seatIndex": 0,
"manualIntervention": manual,
"seedMessage": nil,
"pendingInject": nil,
"awaitReason": nil,
},
Progress: map[string]interface{}{
"percent": 0,
"message": "queued",
"title": req.Title,
"description": req.Description,
},
}
if req.Payload != nil {
job.Progress["payload"] = req.Payload
}
if err := s.Repo.Insert(job); err != nil {
return nil, err
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) Get(id string, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanRead(job, caller) {
return nil, Forbidden("无权查看该会商作业")
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) GetByTaskID(taskID string, latestOnly bool, caller Caller) (*domain.AnalysisJob, error) {
var job *domain.AnalysisJob
var err error
if latestOnly {
job, err = s.Repo.FindLatestByTaskID(taskID)
} else {
job, err = s.Repo.FindActiveByTaskID(taskID)
if err == nil && job == nil {
job, err = s.Repo.FindLatestByTaskID(taskID)
}
}
if err != nil || job == nil {
return job, err
}
if !s.Access.CanRead(job, caller) {
return nil, Forbidden("无权查看该会商作业")
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) Cancel(id string, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanCancel(job, caller) {
return nil, Forbidden("无权取消该会商作业(仅任务责任人/创建人/管理员)")
}
if domain.IsTerminal(job.Status) {
return s.Access.ProjectForCaller(job, caller), nil
}
_, _ = s.Repo.MarkCancel(id)
job.Status = domain.StatusCancelled
job.Phase = "cancelled"
job.Error = "cancelled by client"
p := job.EnsureProgress()
p["message"] = "cancelled"
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) Resume(id string, body *ResumeJobRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanMutate(job, caller) {
return nil, Forbidden("无权续跑该会商作业")
}
if job.Status != domain.StatusAwaitingInput {
return nil, BadState("当前状态不是等待输入,无法 resume(status=" + job.Status + ")")
}
answer := ""
if body != nil {
answer = strings.TrimSpace(body.Answer)
}
plan := job.EnsurePlan()
if answer != "" {
appendUserDialogue(job, answer)
prev := asStr(plan["pendingInject"])
merged := answer
if strings.TrimSpace(prev) != "" {
merged = prev + "\n\n" + answer
}
plan["pendingInject"] = "用户对澄清的回复:\n" + merged
}
plan["awaitReason"] = nil
job.PendingClarification = ""
job.Status = domain.StatusQueued
job.Phase = "resume_queued"
p := job.EnsureProgress()
p["message"] = "已收到补充,准备继续"
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
fresh, err := s.Repo.FindByID(id)
if err != nil {
return nil, err
}
return s.Access.ProjectForCaller(fresh, caller), nil
}
func (s *JobService) PatchPlan(id string, body *PatchJobPlanRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanMutate(job, caller) {
return nil, Forbidden("无权修改该会商作业设置")
}
if domain.IsTerminal(job.Status) {
return nil, BadState("作业已结束,无法修改设置")
}
if body == nil {
return s.Access.ProjectForCaller(job, caller), nil
}
touched := false
plan := job.EnsurePlan()
progress := job.EnsureProgress()
if body.Model != nil {
model := strings.TrimSpace(*body.Model)
job.Model = model
if model == "" {
progress["message"] = "已清除模型指定(用默认)"
} else {
progress["message"] = "已切换模型为 " + model
}
touched = true
}
if body.ManualIntervention != nil {
manual := *body.ManualIntervention
plan["manualIntervention"] = manual
if manual {
progress["message"] = "已开启人工干预(下一席结束后将等待确认)"
} else {
progress["message"] = "已关闭人工干预(将自动连跑)"
}
touched = true
atGate := job.Status == domain.StatusAwaitingInput && asStr(plan["awaitReason"]) == "manual_continue"
if !manual && atGate {
plan["awaitReason"] = nil
job.PendingClarification = ""
job.Status = domain.StatusQueued
job.Phase = "resume_queued"
progress["message"] = "已关闭人工干预,自动继续下一席"
}
}
if !touched {
return s.Access.ProjectForCaller(job, caller), nil
}
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
fresh, err := s.Repo.FindByID(id)
if err != nil {
return nil, err
}
return s.Access.ProjectForCaller(fresh, caller), nil
}
func (s *JobService) Inject(id string, body *InjectJobRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanMutate(job, caller) {
return nil, Forbidden("无权向该会商注入消息")
}
text := ""
if body != nil {
text = strings.TrimSpace(body.Text)
}
if text == "" {
return nil, BadArg("注入内容不能为空")
}
if domain.IsTerminal(job.Status) {
return nil, BadState("作业已结束,无法注入")
}
appendUserDialogue(job, text)
plan := job.EnsurePlan()
prev := asStr(plan["pendingInject"])
merged := text
if strings.TrimSpace(prev) != "" {
merged = prev + "\n\n" + text
}
plan["pendingInject"] = "用户向会商插入的补充说明:\n" + merged
p := job.EnsureProgress()
p["message"] = "已收到用户补充,将在下一轮派活时带入"
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
fresh, err := s.Repo.FindByID(id)
if err != nil {
return nil, err
}
return s.Access.ProjectForCaller(fresh, caller), nil
}
func appendUserDialogue(job *domain.AnalysisJob, text string) {
list := job.EnsureDialogues()
bubble := map[string]interface{}{
"id": "dlg_" + job.ID + "_user_" + strconv.Itoa(len(list)+1),
"role": "user",
"sender": "我",
"content": text,
"status": "done",
"phaseLabel": "",
}
job.Dialogues = append(list, bubble)
}
func normalizeAgents(agents []map[string]interface{}) []map[string]interface{} {
if len(agents) > 0 {
out := make([]map[string]interface{}, 0, len(agents))
for _, a := range agents {
copy := cloneMap(a)
if _, ok := copy["id"]; !ok {
if aid, ok := copy["agentId"]; ok {
copy["id"] = aid
}
}
if _, ok := copy["status"]; !ok {
copy["status"] = "idle"
}
out = append(out, copy)
}
return out
}
return []map[string]interface{}{
{"id": "local-flow-stub-intel", "name": "联调桩-情报采集", "status": "idle"},
{"id": "local-flow-stub-plan", "name": "联调桩-筹划规划", "status": "idle"},
}
}
func blankToNull(s string) string {
return strings.TrimSpace(s)
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func orEmptyMap(m map[string]interface{}) map[string]interface{} {
if m == nil {
return map[string]interface{}{}
}
return m
}
func randHex(nBytes int) string {
b := make([]byte, nBytes)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+623
View File
@@ -0,0 +1,623 @@
package worker
import (
"fmt"
"log"
"strings"
"sync"
"time"
"unicode/utf8"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// LiveChainRunner 真模型按链条顺序执行(被 LiveWorker 异步调用)。
type LiveChainRunner struct {
DF *deerflow.Client
Repo *repo.JobRepo
Coord string
mu sync.Mutex
lastFlush time.Time
}
func (r *LiveChainRunner) Run(job *domain.AnalysisJob) {
jobID := job.ID
defer func() {
if rec := recover(); rec != nil {
log.Printf("真模型会商 panic jobId=%s: %v", jobID, rec)
r.fail(job, fmt.Sprint(rec))
}
}()
agents := job.Agents
if len(agents) < 2 {
r.fail(job, "有效席位不足 2 个,无法启动真模型会商")
return
}
var agentNames []string
for _, a := range agents {
id := asStr(a["id"])
if id != "" {
agentNames = append(agentNames, id)
}
}
if len(agentNames) < 2 {
r.fail(job, "席位 agentId 无效(需要 DeerFlow 的 hyphen-case id)")
return
}
plan := ensurePlan(job)
r.pullModelFromDB(job)
if err := r.DF.AssertModelAvailable(job.Model); err != nil {
r.fail(job, err.Error())
return
}
job.Status = domain.StatusRunning
job.Phase = "running"
r.flush(job)
if r.cancelled(jobID) {
r.markCancelled(job)
return
}
threadIDs := loadThreadIDs(job)
if len(threadIDs) == 0 {
putProgress(job, 2, "正在初始化 DeerFlow 席位线程…")
r.flush(job)
r.pullModelFromDB(job)
tids, err := r.DF.InitMultiAgent(agentNames, r.Coord, job.Model)
if err != nil {
r.fail(job, err.Error())
return
}
threadIDs = tids
store := map[string]interface{}{}
for k, v := range tids {
store[k] = v
}
job.ThreadIDs = store
putProgress(job, 8, "席位线程已就绪,开始链条研讨")
r.flush(job)
} else {
pct := toInt(job.Progress["percent"], 8)
if pct < 8 {
pct = 8
}
putProgress(job, pct, "从检查点继续会商")
r.flush(job)
}
seed := asStr(plan["seedMessage"])
if seed == "" {
seed = BuildSeed(job.Progress)
plan["seedMessage"] = seed
}
if inj := consumePendingInject(plan); inj != "" {
seed = seed + "\n\n" + inj
plan["seedMessage"] = seed
}
total := len(agentNames)
start := toInt(plan["seatIndex"], 0)
if start < 0 {
start = 0
}
seatDeliveries := loadDeliveries(job)
lastLeader := ""
for i := start; i < total; i++ {
if r.cancelled(jobID) {
r.markCancelled(job)
return
}
r.pullManualFromDB(job, plan)
r.pullInjectFromDB(job, plan)
r.pullModelFromDB(job)
model := job.Model
if mid := consumePendingInject(plan); mid != "" {
seed = seed + "\n\n" + mid
plan["seedMessage"] = seed
}
plan["seatIndex"] = i
job.Plan = plan
seat := agents[i]
seatID := asStr(seat["id"])
seatName := firstNonEmpty(asStr(seat["name"]), seatID)
job.ActiveAgentID = r.Coord
job.Cycle = i + 1
applyAgentSpeaking(agents, seatID, false)
job.Agents = agents
leaderBubbleID := openBubble(job, "leader", "总控协调", r.Coord, "链条派活中")
r.flush(job)
leaderRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调",
BuildLeaderTask(seed, i, total, seatID, seatName, i == 0),
model, false,
r.deltaHandler(job, leaderBubbleID),
func() bool { return r.cancelled(jobID) },
)
if err != nil {
finishBubble(job, leaderBubbleID, err.Error(), "error")
r.fail(job, buildModelHint("总控派活失败", model, err.Error()))
return
}
if r.cancelled(jobID) || leaderRes.Aborted {
finishBubble(job, leaderBubbleID, leaderRes.Content, "stopped")
r.markCancelled(job)
return
}
if leaderRes.Clarification {
finishBubble(job, leaderBubbleID, leaderRes.Content, "clarification")
detail := leaderRes.ClarificationDetail
if detail == "" {
detail = leaderRes.Content
}
r.pauseForInput(job, plan, i, "clarification", detail)
return
}
leaderBad := leaderRes.InvalidDelivery || strings.TrimSpace(leaderRes.Content) == "" || looksLikeModelFailure(leaderRes.Content)
if leaderBad {
finishBubble(job, leaderBubbleID, leaderRes.Content, "error")
r.fail(job, buildModelHint("总控派活失败", model, leaderRes.Content))
return
}
finishBubble(job, leaderBubbleID, leaderRes.Content, "done")
lastLeader = leaderRes.Content
r.flush(job)
task := PickChainTask(leaderRes.Dispatched, leaderRes.Content, seatID, seatName)
job.ActiveAgentID = seatID
applyAgentSpeaking(agents, seatID, true)
job.Agents = agents
seatBubbleID := openBubble(job, "special", seatName, seatID, "席位输出中…")
r.flush(job)
seatRes, err := r.DF.RunStream("special", threadIDs, seatID, seatName, task, model, false,
r.deltaHandler(job, seatBubbleID),
func() bool { return r.cancelled(jobID) },
)
if err != nil {
finishBubble(job, seatBubbleID, err.Error(), "error")
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, err.Error()))
return
}
if r.cancelled(jobID) || seatRes.Aborted {
finishBubble(job, seatBubbleID, seatRes.Content, "stopped")
r.markCancelled(job)
return
}
bad := seatRes.InvalidDelivery || strings.TrimSpace(seatRes.Content) == "" || looksLikeModelFailure(seatRes.Content)
status := "done"
if bad {
status = "error"
}
finishBubble(job, seatBubbleID, seatRes.Content, status)
if bad {
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, seatRes.Content))
return
}
seatDeliveries = append(seatDeliveries, map[string]interface{}{
"sender": seatName,
"agentId": seatID,
"content": seatRes.Content,
})
saveDeliveries(job, seatDeliveries)
pct := (i + 1) * 85 / total
if pct > 90 {
pct = 90
}
job.ConsensusPercentage = pct
putProgress(job, pct, fmt.Sprintf("已完成 %d/%d 席", i+1, total))
plan["seatIndex"] = i + 1
job.Plan = plan
r.flush(job)
r.pullManualFromDB(job, plan)
if readManual(plan) && i+1 < total {
r.pauseForInput(job, plan, i+1, "manual_continue",
fmt.Sprintf("人工干预:第 %d/%d 席已完成,请确认后继续下一席。", i+1, total))
return
}
}
if r.cancelled(jobID) {
r.markCancelled(job)
return
}
job.ActiveAgentID = r.Coord
r.pullModelFromDB(job)
closeModel := job.Model
closeID := openBubble(job, "leader", "总控协调", r.Coord, "全案盖印")
r.flush(job)
closeRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调", Closeout, closeModel, true,
r.deltaHandler(job, closeID),
func() bool { return r.cancelled(jobID) },
)
if err != nil {
finishBubble(job, closeID, err.Error(), "error")
r.fail(job, buildModelHint("全案收口失败", closeModel, err.Error()))
return
}
if r.cancelled(jobID) || closeRes.Aborted {
finishBubble(job, closeID, closeRes.Content, "stopped")
r.markCancelled(job)
return
}
if closeRes.InvalidDelivery || looksLikeModelFailure(closeRes.Content) ||
(strings.TrimSpace(closeRes.Content) == "" && strings.TrimSpace(lastLeader) == "") {
finishBubble(job, closeID, closeRes.Content, "error")
r.fail(job, buildModelHint("全案收口失败", closeModel, closeRes.Content))
return
}
if len(seatDeliveries) < total {
finishBubble(job, closeID, closeRes.Content, "error")
r.fail(job, fmt.Sprintf("会商未完整交付(成功 %d/%d 席),请检查模型后重新开始。", len(seatDeliveries), total))
return
}
if dialoguesHaveError(job) {
finishBubble(job, closeID, closeRes.Content, "error")
r.fail(job, "会商过程中已有失败发言,不能进入收口/下一阶段。请换可用模型后重新开始。")
return
}
summary := closeRes.Content
if strings.TrimSpace(summary) == "" {
summary = lastLeader
}
finishBubble(job, closeID, summary, "done")
for _, a := range agents {
a["status"] = "done"
}
job.Agents = agents
job.ActiveAgentID = ""
job.Status = domain.StatusDone
job.Phase = "done"
job.ConsensusPercentage = 100
putProgress(job, 100, "completed")
job.Result = map[string]interface{}{
"summary": summary,
"consensusPercentage": 100,
"seatDeliveries": seatDeliveries,
}
plan["awaitReason"] = nil
job.Plan = plan
r.flush(job)
log.Printf("真模型会商完成 jobId=%s", jobID)
}
func (r *LiveChainRunner) pauseForInput(job *domain.AnalysisJob, plan map[string]interface{}, next int, reason, detail string) {
plan["seatIndex"] = next
plan["awaitReason"] = reason
job.Plan = plan
job.Status = domain.StatusAwaitingInput
job.Phase = reason
job.PendingClarification = detail
msg := "等待用户澄清"
if reason == "manual_continue" {
msg = "等待人工确认继续"
}
putProgress(job, job.ConsensusPercentage, msg)
r.flush(job)
log.Printf("作业暂停等待输入 jobId=%s reason=%s", job.ID, reason)
}
func (r *LiveChainRunner) deltaHandler(job *domain.AnalysisJob, bubbleID string) deerflow.TextDeltaHandler {
return func(delta string) error {
appendBubble(job, bubbleID, delta)
r.throttleFlush(job)
return nil
}
}
func (r *LiveChainRunner) throttleFlush(job *domain.AnalysisJob) {
r.mu.Lock()
defer r.mu.Unlock()
if time.Since(r.lastFlush) >= 150*time.Millisecond {
r.flushLocked(job)
}
}
func (r *LiveChainRunner) flush(job *domain.AnalysisJob) {
r.mu.Lock()
defer r.mu.Unlock()
r.flushLocked(job)
}
func (r *LiveChainRunner) flushLocked(job *domain.AnalysisJob) {
plan := ensurePlan(job)
r.pullManualFromDB(job, plan)
r.pullModelFromDB(job)
injectConsumed := plan["_injectConsumed"] == true
if !injectConsumed {
if strings.TrimSpace(asStr(plan["pendingInject"])) == "" {
r.pullInjectFromDB(job, plan)
}
}
delete(plan, "_injectConsumed")
job.Plan = plan
_ = r.Repo.UpdateProgress(job)
r.lastFlush = time.Now()
}
func (r *LiveChainRunner) cancelled(jobID string) bool {
ok, err := r.Repo.IsCancelFlag(jobID)
return err == nil && ok
}
func (r *LiveChainRunner) markCancelled(job *domain.AnalysisJob) {
job.Status = domain.StatusCancelled
job.Phase = "cancelled"
job.Error = "cancelled"
putProgress(job, job.ConsensusPercentage, "cancelled")
r.flush(job)
}
func (r *LiveChainRunner) fail(job *domain.AnalysisJob, message string) {
job.Status = domain.StatusError
job.Phase = "error"
job.Error = message
job.ConsensusPercentage = 0
if job.Result != nil {
delete(job.Result, "summary")
job.Result["consensusPercentage"] = 0
job.Result["failed"] = true
}
putProgress(job, 0, "error")
r.flush(job)
}
func (r *LiveChainRunner) pullModelFromDB(job *domain.AnalysisJob) {
fresh, err := r.Repo.FindByID(job.ID)
if err != nil || fresh == nil {
return
}
job.Model = fresh.Model
}
func (r *LiveChainRunner) pullManualFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
fresh, err := r.Repo.FindByID(job.ID)
if err != nil || fresh == nil || fresh.Plan == nil {
return
}
if v, ok := fresh.Plan["manualIntervention"]; ok {
plan["manualIntervention"] = v
}
}
func (r *LiveChainRunner) pullInjectFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
fresh, err := r.Repo.FindByID(job.ID)
if err != nil || fresh == nil || fresh.Plan == nil {
return
}
dbText := strings.TrimSpace(asStr(fresh.Plan["pendingInject"]))
if dbText == "" {
return
}
local := strings.TrimSpace(asStr(plan["pendingInject"]))
if local == "" {
plan["pendingInject"] = dbText
} else if !strings.Contains(local, dbText) {
plan["pendingInject"] = local + "\n\n" + dbText
}
}
func consumePendingInject(plan map[string]interface{}) string {
s := strings.TrimSpace(asStr(plan["pendingInject"]))
plan["pendingInject"] = nil
plan["_injectConsumed"] = true
return s
}
func ensurePlan(job *domain.AnalysisJob) map[string]interface{} {
p := job.EnsurePlan()
if _, ok := p["seatIndex"]; !ok {
p["seatIndex"] = 0
}
if _, ok := p["manualIntervention"]; !ok {
p["manualIntervention"] = true
}
return p
}
func putProgress(job *domain.AnalysisJob, percent int, message string) {
p := job.EnsureProgress()
p["percent"] = percent
p["message"] = message
}
func loadThreadIDs(job *domain.AnalysisJob) map[string]string {
out := map[string]string{}
for k, v := range job.ThreadIDs {
if v != nil {
out[k] = fmt.Sprint(v)
}
}
return out
}
func loadDeliveries(job *domain.AnalysisJob) []map[string]interface{} {
if job.Result == nil {
return nil
}
raw, ok := job.Result["seatDeliveries"]
if !ok {
return nil
}
switch t := raw.(type) {
case []map[string]interface{}:
return t
case []interface{}:
var out []map[string]interface{}
for _, o := range t {
if m, ok := o.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
default:
return nil
}
}
func saveDeliveries(job *domain.AnalysisJob, list []map[string]interface{}) {
if job.Result == nil {
job.Result = map[string]interface{}{}
}
job.Result["seatDeliveries"] = list
}
func readManual(plan map[string]interface{}) bool {
if plan == nil {
return true
}
v, ok := plan["manualIntervention"]
if !ok || v == nil {
return true
}
switch t := v.(type) {
case bool:
return t
default:
return !strings.EqualFold(strings.TrimSpace(fmt.Sprint(t)), "false")
}
}
func openBubble(job *domain.AnalysisJob, role, sender, agentID, phase string) string {
list := job.EnsureDialogues()
id := fmt.Sprintf("dlg_%s_%d", job.ID, len(list)+1)
job.Dialogues = append(list, map[string]interface{}{
"id": id, "role": role, "sender": sender, "agentId": agentID,
"content": "", "status": "streaming", "phaseLabel": phase,
})
return id
}
func appendBubble(job *domain.AnalysisJob, bubbleID, delta string) {
if delta == "" {
return
}
for _, b := range job.Dialogues {
if asStr(b["id"]) == bubbleID {
b["content"] = asStr(b["content"]) + delta
return
}
}
}
func finishBubble(job *domain.AnalysisJob, bubbleID, finalContent, status string) {
for _, b := range job.Dialogues {
if asStr(b["id"]) != bubbleID {
continue
}
if strings.TrimSpace(finalContent) != "" {
prev := asStr(b["content"])
b["content"] = mergeKeepThinking(prev, finalContent)
}
if status == "" {
status = "done"
}
b["status"] = status
b["phaseLabel"] = ""
return
}
}
func mergeKeepThinking(streamed, finalContent string) string {
if strings.Contains(finalContent, "<think>") || strings.Contains(finalContent, "</think>") {
return finalContent
}
open := strings.Index(streamed, "<think>")
if open < 0 {
return finalContent
}
close := strings.LastIndex(streamed, "</think>")
if close >= open {
return streamed[:close+len("</think>")] + "\n" + finalContent
}
return streamed + "</think>\n" + finalContent
}
func applyAgentSpeaking(agents []map[string]interface{}, speakingID string, seatTurn bool) {
for _, a := range agents {
id := asStr(a["id"])
if seatTurn && id == speakingID {
a["status"] = "speaking"
} else {
a["status"] = "listening"
}
}
}
func dialoguesHaveError(job *domain.AnalysisJob) bool {
for _, b := range job.Dialogues {
if strings.EqualFold(asStr(b["status"]), "error") {
return true
}
}
return false
}
func looksLikeModelFailure(content string) bool {
t := strings.TrimSpace(content)
if t == "" {
return false
}
lower := strings.ToLower(t)
needles := []string{"model_not_found", "invalid model", "does not exist", "no such model",
"litellm", "aperror", "apierror", "authenticationerror", "rate_limit", "context_length"}
for _, n := range needles {
if strings.Contains(lower, n) {
return true
}
}
cn := []string{"大模型本次调用未能成功完成", "请稍后再试或更换模型", "大模型因鉴权失败",
"大模型服务暂时繁忙", "大模型响应暂时中断", "大模型当前响应异常", "已被熔断保护",
"已拒绝自动切换", "模型不存在", "无可用模型", "模型不可用", "鉴权失败"}
for _, n := range cn {
if strings.Contains(t, n) {
return true
}
}
if utf8.RuneCountInString(t) < 400 && len(t) >= 5 && t[0] == '[' && t[4] == ']' {
return true
}
return false
}
func buildModelHint(head, model, detail string) string {
sb := head
if strings.TrimSpace(model) != "" {
sb += "(当前模型:" + strings.TrimSpace(model) + ")"
}
sb += "。请换可用模型后重新开始会商。"
d := strings.TrimSpace(detail)
if d != "" {
if utf8.RuneCountInString(d) > 180 {
rs := []rune(d)
d = string(rs[:180]) + "…"
}
sb += " 详情:" + d
}
return sb
}
func toInt(v interface{}, def int) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
var n int
_, err := fmt.Sscanf(t, "%d", &n)
if err == nil {
return n
}
}
return def
}
+64
View File
@@ -0,0 +1,64 @@
package worker
import (
"log"
"sync/atomic"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// LiveWorker 定时扫 queued,最多并发 2 场交给 LiveChainRunner。
type LiveWorker struct {
Repo *repo.JobRepo
Runner *LiveChainRunner
PollEvery time.Duration
inflight int32
}
func (w *LiveWorker) Start() {
if w.PollEvery <= 0 {
w.PollEvery = time.Second
}
go func() {
t := time.NewTicker(w.PollEvery)
defer t.Stop()
for range t.C {
w.poll()
}
}()
}
func (w *LiveWorker) poll() {
if atomic.LoadInt32(&w.inflight) >= 2 {
return
}
queued, err := w.Repo.FindQueuedJobs()
if err != nil {
log.Printf("LiveWorker 扫队列失败: %v", err)
return
}
for _, job := range queued {
if atomic.LoadInt32(&w.inflight) >= 2 {
break
}
job.Status = domain.StatusRunning
job.Phase = "claimed"
if err := w.Repo.UpdateProgress(job); err != nil {
log.Printf("LiveWorker 占坑失败 jobId=%s: %v", job.ID, err)
continue
}
atomic.AddInt32(&w.inflight, 1)
jobID := job.ID
go func() {
defer atomic.AddInt32(&w.inflight, -1)
fresh, err := w.Repo.FindByID(jobID)
if err != nil || fresh == nil {
return
}
log.Printf("开始真模型会商 jobId=%s", jobID)
w.Runner.Run(fresh)
}()
}
}
+119
View File
@@ -0,0 +1,119 @@
package worker
import (
"fmt"
"strings"
)
// 链条提示词(与 Java ChainPrompts / 前端历史行为对齐)。
const Lang = "【语言要求】你的全部可见输出(派活说明、任务描述、分析、结论)必须使用简体中文;" +
"禁止用英文句子作答。agent_name / 技能名等标识符可保持原文,正文一律中文。"
const Closeout = "各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。\n" + Lang
func BuildSeed(progress map[string]interface{}) string {
if progress == nil {
return "请基于用户任务意图,调度子智能体完成研讨。"
}
var sb strings.Builder
title := asStr(progress["title"])
desc := asStr(progress["description"])
if payload, ok := progress["payload"].(map[string]interface{}); ok {
if intent, ok := payload["intent"].(map[string]interface{}); ok {
obj := firstNonEmpty(asStr(intent["objective"]), asStr(intent["summary"]), title)
if obj != "" {
sb.WriteString(obj)
}
if list, ok := intent["constraints"].([]interface{}); ok && len(list) > 0 {
sb.WriteString("\n\n约束条件:\n")
for _, c := range list {
sb.WriteString("- ")
sb.WriteString(fmt.Sprint(c))
sb.WriteByte('\n')
}
}
if list, ok := intent["assumptions"].([]interface{}); ok && len(list) > 0 {
sb.WriteString("\n关键假设:\n")
for _, a := range list {
sb.WriteString("- ")
sb.WriteString(fmt.Sprint(a))
sb.WriteByte('\n')
}
}
}
}
if sb.Len() == 0 {
sb.WriteString(title)
if desc != "" {
if sb.Len() > 0 {
sb.WriteByte('\n')
}
sb.WriteString(desc)
}
}
if sb.Len() == 0 {
return "请基于用户此前澄清的任务意图,调度子智能体完成研讨。"
}
sb.WriteString("\n\n用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。")
return sb.String()
}
func BuildLeaderTask(seed string, position, total int, seatAgentID, seatName string, isFirst bool) string {
ordinal := fmt.Sprintf("%d/%d", position+1, total)
if isFirst {
return "这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:\n" +
seed + "\n\n" +
"本次研讨严格按预设链条顺序进行,共 " + fmt.Sprint(total) + " 个席位,现在是第 " + ordinal + " 个席位。\n" +
"请你作为总控,通过 agent_orchestration 技能**仅**向智能体「" + seatName +
"」(agent_name: " + seatAgentID + ")派活,结合任务背景为它分配聚焦其职责的本轮任务。\n" +
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。\n" +
"派活说明与交给该席位的任务正文必须是简体中文(可写成:本轮派活给「" +
seatName + "」……),不要输出英文派活句。\n" + Lang
}
return "链条研讨继续,现在轮到第 " + ordinal + " 个席位。\n" +
"请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「" +
seatName + "」(agent_name: " + seatAgentID +
")派活,为它分配承接前序结论、聚焦其职责的本轮任务。\n" +
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。\n" +
"派活说明与任务正文必须是简体中文。\n" + Lang
}
func PickChainTask(dispatched [][2]string, leaderContent, seatAgentID, seatName string) string {
wanted := strings.ToLower(seatAgentID)
for _, row := range dispatched {
if strings.ToLower(row[0]) == wanted && strings.TrimSpace(row[1]) != "" {
return withLang(row[1])
}
}
for _, row := range dispatched {
if strings.TrimSpace(row[1]) != "" {
return withLang(row[1])
}
}
if strings.TrimSpace(leaderContent) != "" {
return withLang("请你作为「" + seatName + "」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n" + strings.TrimSpace(leaderContent))
}
return withLang("请你作为「" + seatName + "」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。")
}
func withLang(body string) string { return Lang + "\n" + body }
func asStr(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return strings.TrimSpace(fmt.Sprint(v))
}
func firstNonEmpty(parts ...string) string {
for _, p := range parts {
if strings.TrimSpace(p) != "" {
return strings.TrimSpace(p)
}
}
return ""
}
+25
View File
@@ -0,0 +1,25 @@
package worker
import (
"strings"
"testing"
)
func TestBuildSeedAndPickTask(t *testing.T) {
seed := BuildSeed(map[string]interface{}{
"title": "测试任务",
"payload": map[string]interface{}{
"intent": map[string]interface{}{
"objective": "完成联调",
"constraints": []interface{}{"必须中文"},
},
},
})
if !strings.Contains(seed, "完成联调") {
t.Fatalf("seed 异常: %s", seed)
}
task := PickChainTask([][2]string{{"agent-x", "请分析风险"}}, "总控全文", "agent-x", "风险岗")
if !strings.Contains(task, "请分析风险") || !strings.Contains(task, "简体中文") {
t.Fatalf("pick 任务异常: %s", task)
}
}