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,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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ©Job
|
||||
}
|
||||
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 ©Job
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user