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, "") || strings.Contains(finalContent, "") {
return finalContent
}
open := strings.Index(streamed, "")
if open < 0 {
return finalContent
}
close := strings.LastIndex(streamed, "")
if close >= open {
return streamed[:close+len("")] + "\n" + finalContent
}
return streamed + "\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
}