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