chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)

Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gozerosandCursor committed 2026-11-03 00:13:46 +08:00
commit 0c45b86d67
29 files changed
+3789

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
/bin/
*.exe
*.test
*.out
/data/
*.log
.idea/
.vscode/
*.swp
.DS_Store
+74
View File
@@ -0,0 +1,74 @@
# eightbf-analysis-go
8BF「多岗位会商」后台服务的 **Go 实现**,行为对齐 Java 仓 `eightbf-analysis` 与
[docs/服务端开发设计要求.md](./docs/服务端开发设计要求.md)。
- 同一套 HTTP:`/api/v1/analysis/...`
- 同一张表:`eba_analysis_job`(`schema.sql`)
- 同一套环境变量:`EBA_DB_*` / `EBA_DF_*`(Go 另支持 `EBA_DB_DSN`)
- 前端可经 Nginx `/analysis-api` 指向本进程(默认仍 `:18097`,勿与 Java 实例同时占端口)
## 与 Java 的关系
| | Java | Go(本仓) |
|--|------|------------|
| 定位 | 现网/本机主用 | 同构移植,便于无 JVM 环境部署 |
| API | 权威契约 | **兼容**同一契约 |
| 表结构 | `schema.sql` | 共用同一 schema |
两进程不要同时写同一库抢同一 `queued`(尚无领取互斥)。切换时停掉一侧再起另一侧。
## 运行
```bash
# 需要 Go 1.21+、MySQL(库 eightbf_analysis)
export EBA_DB_USER=eba
export EBA_DB_PASSWORD=eba_local_dev
# 或直接:
# export EBA_DB_DSN='eba:eba_local_dev@tcp(127.0.0.1:3306)/eightbf_analysis?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4'
export EBA_DF_BASE_URL=http://127.0.0.1:8001
export ANALYSIS_MODE=live # demo 则假进度
# export ANALYSIS_AUTH_ENABLED=true
cd /project/eightbf-analysis-go
go run ./cmd/server
# 默认 :18097
```
构建:
```bash
go build -o bin/eightbf-analysis-go ./cmd/server
./bin/eightbf-analysis-go
```
## 接口(与 Java 一致)
- `GET /api/v1/analysis/health`
- `POST /api/v1/analysis/jobs`
- `GET /api/v1/analysis/jobs/{id}`
- `GET /api/v1/analysis/jobs?taskId=&latest=`
- `POST /api/v1/analysis/jobs/{id}/cancel|resume|plan|inject`
- `GET /api/v1/analysis/jobs/{id}/result`
身份头:`X-Account-Id`、`X-Analysis-Role`。
## 目录
```text
cmd/server 入口
internal/api HTTP
internal/service 编排 / 鉴权投影 / DemoWorker
internal/worker LiveWorker / LiveChainRunner / 提示词
internal/deerflow 登录、SSE、模型校验
internal/repo MySQL
internal/domain 作业实体与状态常量
internal/config 环境变量
docs/ 设计要求(自 Java 同步)+ env.sample
```
## 日志
遵循设计要求 §11:业务日志带 `jobId=`;禁止打印 token/密码/完整 dialogues。
当前写 stdout,正式环境用 systemd/Docker 收集或挂滚动文件。
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"database/sql"
"log"
"net/http"
"os"
"strconv"
"time"
_ "github.com/go-sql-driver/mysql"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/api"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/worker"
)
func main() {
cfg := config.Load()
log.Printf("eightbf-analysis-go starting port=%d mode=%s auth=%v demo=%v",
cfg.Port, cfg.AnalysisMode, cfg.AuthEnabled, cfg.DemoEnabled)
db, err := sql.Open("mysql", cfg.DBDSN)
if err != nil {
log.Fatalf("open db: %v", err)
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
log.Fatalf("ping db: %v(请检查 EBA_DB_DSN / EBA_DB_*)", err)
}
jobRepo := repo.NewJobRepo(db)
if schema, err := os.ReadFile("schema.sql"); err != nil {
log.Printf("未找到 schema.sql,跳过自动建表: %v", err)
} else {
jobRepo.ApplySchema(string(schema))
}
access := &service.AccessService{AuthEnabled: cfg.AuthEnabled}
jobs := &service.JobService{Repo: jobRepo, Access: access}
h := &api.Handler{Jobs: jobs}
mux := http.NewServeMux()
h.Routes(mux)
if cfg.AnalysisMode == "live" && !cfg.DemoEnabled {
auth := deerflow.NewAuth(cfg)
df := deerflow.NewClient(cfg, auth)
runner := &worker.LiveChainRunner{DF: df, Repo: jobRepo, Coord: cfg.DeerFlowCoordinator}
lw := &worker.LiveWorker{
Repo: jobRepo,
Runner: runner,
PollEvery: time.Duration(cfg.LivePollMS) * time.Millisecond,
}
lw.Start()
log.Printf("LiveWorker 已启动 poll=%dms", cfg.LivePollMS)
} else if cfg.DemoEnabled || cfg.AnalysisMode == "demo" {
dw := &service.DemoWorker{
Repo: jobRepo,
Every: time.Duration(cfg.DemoTickMS) * time.Millisecond,
}
dw.Start()
log.Printf("DemoWorker 已启动 tick=%dms", cfg.DemoTickMS)
} else {
log.Printf("警告:未启动任何 Worker(mode=%s demo=%v)", cfg.AnalysisMode, cfg.DemoEnabled)
}
addr := ":" + strconv.Itoa(cfg.Port)
log.Printf("监听 %s (健康检查 GET /api/v1/analysis/health)", addr)
if err := http.ListenAndServe(addr, api.WithCORS(mux)); err != nil {
log.Fatal(err)
}
}
+24
View File
@@ -0,0 +1,24 @@
# eightbf-analysis / eightbf-analysis-go 内网环境变量样例
# 勿把真实密码提交进 Git。
# MySQL(正式必填)
# Java 可用 JDBC;Go 优先 EBA_DB_DSN,也能解析 EBA_DB_URL(jdbc)
EBA_DB_URL=jdbc:mysql://<mysql-host>:3306/eightbf_analysis?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
EBA_DB_USER=eba
EBA_DB_PASSWORD=
# Go 推荐:
# EBA_DB_DSN=eba:xxx@tcp(mysql-host:3306)/eightbf_analysis?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true
# DeerFlow Gateway(ANALYSIS_MODE=live 时必填)
EBA_DF_BASE_URL=http://<deerflow-gateway>:8001
EBA_DF_USER=admin
EBA_DF_PASSWORD=
# 可选:改端口
# SERVER_PORT=18097
# Go 运行模式
# ANALYSIS_MODE=live
# ANALYSIS_AUTH_ENABLED=false
# EBA_WORKER_DEMO_ENABLED=false
# 假进度:ANALYSIS_MODE=demo
+28
View File
@@ -0,0 +1,28 @@
# Go 版与 Java 版对齐说明
设计权威:[服务端开发设计要求.md](./服务端开发设计要求.md)(与 Java 仓同步)。
## 已对齐
- API 路径、状态码、`ApiResponse`、身份头
- 作业状态机、一开一场、cancel_flag、plan 检查点
- 人工干预 / resume / inject / patch plan(关干预自动放行)
- 岗位投影(position_owner 裁气泡)
- Live:模型校验 → init → 总控派活 → 席位 → 可选卡点 → 收口;失败不当成功
- Demo 假进度 Worker
- 环境变量 `EBA_*`;Go 额外 `EBA_DB_DSN`、`ANALYSIS_MODE`、`ANALYSIS_AUTH_ENABLED`
## 已知差异 / 注意
1. **进程互斥**:勿与 Java 实例同时抢同一 MySQL 的 `queued`(尚未做多实例领取锁)。
2. **schema 启动**:Go 启动读工作目录 `schema.sql`;部署时保证 cwd 或改绝对路径。
3. **SSE 细节**:DeerFlow 帧格式若有变,优先对照 Java `DeerFlowClient` 再改本仓 `internal/deerflow`。
4. **日志**:先 stdout;滚动文件按设计要求 §11 后续加 lumberjack 即可。
## 切换建议
1. 停 Java 进程
2. 确认同一 `EBA_DB_*` / DeerFlow
3. 起 Go:`go run ./cmd/server` 或 systemd
4. Nginx `/analysis-api` 仍指 `:18097`
5. 健康检查 + 开一场研讨验收
+379
View File
@@ -0,0 +1,379 @@
# eightbf-analysis 服务端开发设计要求
> 适用范围:本仓库(8BF「多岗位会商」后台作业服务,Spring Boot 2.0.9 + JDK8)。
> 目标:关浏览器后会商仍能跑完;状态以服务端为准;产品前端只经 `/analysis-api` 访问本服务。
> 配套:根 [README.md](../README.md)、[内网迁移.md](./内网迁移.md)、源码包注释(`package-info` / 各层类注释)。
本文是**开发约束与设计验收标准**,不是空泛愿景。改功能前先对照;评审以本文为准。
---
## 1. 定位与边界
### 1.1 本服务负责
| 职责 | 说明 |
|------|------|
| 会商作业生命周期 | 创建 / 查询 / 取消 / 续跑 / 热更新 / 注入 / 结果 |
| 服务端执行 | Demo 假进度或 Live 真模型链条;关页不断 |
| 状态与流式内容落库 | `status`、`plan`、`dialogues`、`result` 等以 MySQL 为准 |
| 对接 DeerFlow | 登录、建线程、消费 SSE;失败策略由本服务决定 |
| 岗位可见性投影 | 按账号裁剪对话后再返回前端 |
### 1.2 本服务不负责
- 产品登录 / SSO / 账号体系本体(只认请求头里的账号与角色)
- 岗位会商「DeerFlow joinStream」那条链路(岗位侧保持原样,不经本服务挂起)
- 浏览器直连 DeerFlow `roundtable-jobs` 跑 8BF(**禁止**作为正式路径)
- Fusion 其它 API(`/zl_consumer`、`/deerflow-api` 等)
### 1.3 硬原则(不可破)
1. **状态权威在服务端**:前端轮询展示;刷新必须带草稿里的 `jobId`,禁止用「task 下最新一场」冒充重连。
2. **一开一场**:每次「开始研讨」新建作业;同一 `taskId` 可并存多场;不自动取消旧场。
3. **浏览器可走,作业不能丢**:取消只靠 `cancel_flag` + Worker 协作停;不要假设 SSE 连着用户标签页。
4. **失败不当成功**:模型不可用、席位空输出、流无终帧、自动换模 → 整场 `error`;禁止刷完链条再标 `done` 解锁前端下一阶段。
5. **热更新以库为准**:人工干预 / 模型 / inject 写在库里;Runner 每席与 flush 前从库合并,禁止内存态盖掉用户 PATCH。
---
## 2. 总体架构
```text
浏览器 → Nginx /analysis-api → eightbf-analysis (:18097)
├─ web(HTTP + DTO)
├─ service(编排 / 鉴权投影 / 落库 / DemoWorker)
├─ worker(LiveWorker 调度 + LiveChainRunner 执行)
├─ deerflow(Auth / Client / SSE)
└─ MySQL eba_analysis_job
↑
DeerFlow Gateway(仅服务端访问)
```
### 2.1 分层要求
| 包 | 允许 | 禁止 |
|----|------|------|
| `web` | 参数校验、HTTP 状态码、组 Caller、调 service | 直接 JDBC;直接调 DeerFlow;写业务状态机细节 |
| `service` | 作业编排、权限、仓储、Demo 推进 | 拼 DeerFlow SSE;复制一套状态机与 Runner 打架 |
| `worker` | 真模型调度与链条;读写作业检查点 | 暴露新 HTTP;绕过 Repository 改库 |
| `deerflow` | HTTP/SSE、token、模型校验、流式折算 | 知道「作业业务规则」以外的前端约定;改作业 status |
| `domain` | 实体、状态常量 | Spring 注解、IO |
依赖方向只能向内:`web → service → domain`;`worker → service/domain + deerflow`。禁止 `deerflow` 依赖 `web`。
### 2.2 运行模式
| 配置 | 行为 |
|------|------|
| `analysis.mode=live` + `eba.worker.demo-enabled=false` | **正式/联调默认**:LiveWorker 领 `queued`,LiveChainRunner 调 DeerFlow |
| `analysis.mode=demo` + `eba.worker.demo-enabled=true` | 仅演示假发言;不得当内网验收真模型 |
两模式互斥清晰:禁止「一边假进度一边又去打 DeerFlow」。
---
## 3. 领域与状态机
### 3.1 作业(AnalysisJob)
一场作业 = 服务端上的一场研讨录像带。关键袋子:
| 字段 | 用途 | 开发约束 |
|------|------|----------|
| `id` | 主键;草稿必须存 | 新建生成;禁止按 task 覆盖 |
| `status` / `phase` | 生命周期 / 细阶段 | 状态字符串只用 `JobStatus` 常量 |
| `plan` | 检查点与开关 | 至少:`seatIndex`、`manualIntervention`、`pendingInject`、`awaitReason`、`seedMessage` |
| `dialogues` | 气泡流式展示 | 增量追加;终帧勿无脑整段覆盖丢掉 `<think>` |
| `threadIds` | DeerFlow 线程 | 有则续跑跳过 init |
| `access` | 岗位账号限制 | 结构与前端约定一致 |
| `progress` / `result` | 进度文案 / 终态汇总 | `error` 时清掉误导性 summary / 共识 |
### 3.2 状态流转
```text
queued → running → awaiting_input ⇄ queued(resume)
→ done
→ error
→ cancelled
```
| 状态 | 含义 | 允许的写操作 |
|------|------|----------------|
| `queued` | 等 Worker 领取 | cancel;inject;patch plan |
| `running` | 执行中 | cancel;inject;patch plan |
| `awaiting_input` | 澄清或人工卡点 | resume;cancel;inject;patch plan(关干预可自动放行) |
| `done` / `error` / `cancelled` | 终态 | **禁止** resume / inject / 改计划 |
实现要求:
- 取消:先 `cancel_flag=1`,Runner/Demo 每拍检查;再落 `cancelled`。
- 续跑:仅 `awaiting_input → queued`;检查点用 `plan.seatIndex`,勿重新从 0 开场除非产品明确要求。
- 人工干预默认安全:`manualIntervention` 缺省视为 **true**(宁可多卡一次)。
### 3.3 真模型链条语义
顺序固定:
1. 校验席位 ≥ 2、模型在 DeerFlow 列表中(未知模型 **拒绝**,禁止静默换模)。
2. 无 `threadIds` 则 init;有则续跑。
3. 对每个席位:总控派活 → 该席输出 →(若开人工干预且非末席)进入 `awaiting_input`。
4. 全席成功后总控收口;交付不全或对话已有 error 气泡 → 不得 `done`。
一席失败立刻 `error` 停场,禁止「继续跑完再汇总错误」。
---
## 4. API 设计要求
### 4.1 约定
- 前缀:`/api/v1/analysis`
- 包体:`ApiResponse<T>` → `{ code, message, data }`;成功 `code=0`
- HTTP:`201` 创建;`404` 找不到;`403` 无权限;`400` 状态/参数不允许
- 身份头:`X-Account-Id`、`X-Analysis-Role`(`admin` / `task_owner` / `position_owner` / `viewer`)
- 探活:`GET /health` **不鉴权**
### 4.2 接口清单(保持稳定)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/jobs` | 新建;返回投影后快照 |
| GET | `/jobs/{id}` | 轮询 / 重连主路径 |
| GET | `/jobs?taskId=&latest=` | 兼容查询;**正式重连勿依赖此接口替代 jobId** |
| POST | `/jobs/{id}/cancel` | 仅责任人/创建人/管理员 |
| POST | `/jobs/{id}/resume` | 澄清回答或人工「继续」 |
| POST | `/jobs/{id}/plan` | 热更新人工干预 / 模型 |
| POST | `/jobs/{id}/inject` | 运行中补充;空文本 400 |
| GET | `/jobs/{id}/result` | 裁剪字段的结果视图 |
| GET | `/health` | 探活 |
### 4.3 兼容与演进
- 字段名尽量与前端 `mapJob` 一致,少做二次映射。
- 新增字段:只增不改语义;废弃字段先双写/文档标明,再删。
- 破坏性变更(状态字符串、必填头、错误码语义)必须同步改 code-zk 客户端与本文,并在 PR 说明迁移步骤。
---
## 5. 鉴权与投影
配置:`analysis.auth.enabled`(本机可 false;内网建议 true)。
| 角色 | 读 | resume/inject/改 plan | cancel |
|------|----|------------------------|--------|
| admin / task_owner / 创建人 / access 任务责任人 | 全量 | 是 | 是 |
| position_owner(绑定了席位) | 本席 + system/user/leader | 是 | **否**(除非兼责任人) |
| 无账号且强制鉴权 | 否 | 否 | 否 |
投影规则(即使本机关强制鉴权,声明了 `position_owner` 也要裁剪,便于验收):
- 岗位账号:只见 `system` / `user` / `leader` + 自己 `agentId` 的 special 气泡。
- `result.seatDeliveries` 同步裁剪。
新接口若返回作业快照,必须走 `JobAccessService.projectForCaller`,禁止裸库对象出站。
---
## 6. 持久化要求
- 表:`eba_analysis_job`(见 `schema.sql`);大字段 JSON 文本列。
- 正式库:**MySQL utf8mb4**;H2 仅应急联调。
- 写库路径统一经 `JobRepository`;流式 flush 可节流(约 ≥150ms),但取消旗、状态跃迁必须及时。
- 索引:`task_id` / `status` / `owner_account_id` 保持;新增查询条件先补索引再上线。
- 启动执行 `schema.sql` 允许 `continue-on-error`(重复索引);**禁止**靠静默失败掩盖真实 DDL 错误而不验收。
---
## 7. DeerFlow 集成要求
1. **仅服务端**持有 Gateway 账号;口令走环境变量,不写死进正式配置提交物。
2. Token 复用并提前刷新;并发登录要有同步,避免打爆登录接口。
3. `assertModelAvailable`:指定模型不在列表 → 开跑前失败;禁止依赖 Gateway「自动换模」。
4. SSE:识别 `event:error`、`model_switch`、无终帧;一律映射为 `invalidDelivery` / 业务失败。
5. 流式展示:`reasoning_content` 与正文一并折成 `<think>…</think>` + 正文,保证前端两边都能流。
6. 超时:`stream-idle-timeout-ms` 可配;取消检查嵌在读循环里。
7. 本服务不得把 DeerFlow 内部错误原文无差别甩给用户;要带「当前模型」提示,便于换模重开。
---
## 8. Worker 与并发
- LiveWorker:FIFO 领 `queued`;先占坑改 `running` 再异步执行;默认并发上限 **2**(可配置化时须有文档)。
- 单机进程内线程池即可;多实例部署前必须先设计「领取互斥」(乐观锁 / `FOR UPDATE` / 外部队列),否则禁止双活抢同一作业。
- DemoWorker 与 LiveWorker 开关清晰;生产关闭 demo。
---
## 9. 配置与环境
| 项 | 要求 |
|----|------|
| 端口 | 默认 `18097`;经 Nginx `/analysis-api` 反代 |
| 数据源 | `EBA_DB_*` 覆盖;正式禁用默认弱口令 |
| DeerFlow | `EBA_DF_*` 覆盖 |
| 鉴权 | 内网打开 `analysis.auth.enabled=true` |
| 密钥 | 不进 Git;参考 `docs/env.sample` |
变更配置项必须更新:`application.yml` 注释、`env.sample`、内网迁移文档。
---
## 10. 编码与注释规范
- 语言:JDK **8**;禁止使用仅高版本语法(除非整体升级并改构建)。
- 注释:新代码须**详细中文注释**(为什么 / 约定 / 边界),禁止只复述变量名;有用旧注释不得顺手删。
- 异常:业务用明确文案;`SecurityException` → 403;`IllegalStateException` / `IllegalArgumentException` → 400。
- 日志:见 **§11 日志与收集**;关键作业必须带 `jobId=`;禁止打 token、密码、完整用户隐私对话到 INFO(必要时 DEBUG 且脱敏)。
- 测试:至少覆盖状态不允许的操作、权限拒绝、模型校验失败、取消中断;无单测的改动须在 PR 写清手工验收步骤。
---
## 11. 日志与收集
本节是运维可落地的约束:先保证「能查、能关联、不泄密」,再谈集中平台。
### 11.1 目标
| 目标 | 说明 |
|------|------|
| 排障 | 任意一场会商能用 `jobId`(必要时再加 `requestId` / `taskId`)串起创建→领取→DeerFlow→卡点→结束 |
| 审计 | 谁 cancel / resume / inject / 改 plan 可追溯(带账号头,不带口令) |
| 采集 | 日志形态稳定,便于本机落盘或内网 Filebeat/Vector/rsyslog 采集 |
| 安全 | 密钥与大段会商正文默认不上日志;完整内容以库表为准 |
### 11.2 输出与落盘(应用侧)
1. **默认打 stdout/stderr**(systemd / Docker / 进程托管都能直接收)。
2. 正式环境建议同时挂 **滚动文件**(便于采集 agent 扫目录),例如:
- 路径:`/var/log/eightbf-analysis/app.log`(可用环境变量覆盖)
- 滚动:按天或按大小(如 100MB × 14 份);禁止无限单文件撑爆磁盘
3. 使用标准 Logback(Spring Boot 默认即可);自定义 `logback-spring.xml` 时须文档化路径与滚动策略。
4. 日志级别:
- 生产默认:`com.eightbf.analysis=INFO`,框架 `WARN`
- 排障临时:单 logger 升 `DEBUG`,用完改回;禁止长期全局 DEBUG
### 11.3 格式(方便采集与检索)
每条业务日志尽量同构,文本键值即可(先不强制 JSON,内网解析成本更低):
```text
2026-11-02 23:40:01.123 INFO [...] c.e.a.worker.LiveWorker - 开始真模型会商 jobId=job_xxx requestId=req_yyy
```
**推荐固定键(有则必带):**
| 键 | 何时 |
|----|------|
| `jobId=` | 一切与作业相关的日志 |
| `requestId=` | 创建、与前端对账 |
| `taskId=` | 仅当本次操作明确带了 task(勿刷屏) |
| `accountId=` | cancel / resume / inject / patchPlan(来自请求头) |
| `phase=` / `status=` | 状态跃迁、暂停等待 |
| `model=` | 模型校验失败、开跑、换模拒绝 |
禁止:无键的散文导致无法 grep;同一事件重复刷屏(流式 delta **不要**每字打日志)。
### 11.4 必打事件(最低集合)
| 级别 | 事件 |
|------|------|
| INFO | 作业创建;LiveWorker 领取并开始;进入 `awaiting_input`(澄清/人工);resume 重新入队;会商 `done`;`cancelled` |
| INFO | DeerFlow 登录成功(只打过期秒数,**不打 token**);init 完成(席位数) |
| WARN | Worker/Runner 未捕获异常;DeerFlow HTTP≥400;模型不可用;流无终帧 / `model_switch` 拒绝;取消后仍异常退出 |
| ERROR | 进程级故障(数据源不可用、反复登录失败等) |
HTTP 访问明细优先靠 **Nginx access log**(`/analysis-api`);应用内不必再镜像每一条轮询 GET(会淹没真正故障)。
### 11.5 脱敏与禁止项
**禁止写入日志:**
- DeerFlow / DB 密码、Bearer token、Cookie
- 完整 `dialogues` / 模型长回复 / inject 全文(库里已有;日志最多截断摘要 ≤200 字且仅 WARN/ERROR)
- 身份证等敏感 PII(本服务一般无;若 payload 带入勿原样打印)
**允许:** 错误摘要、模型名、agentId、席位序号、HTTP 状态码、耗时毫秒。
### 11.6 采集与留存(运维侧)
按内网条件选一种,**应用不绑定某一家商业 APM**:
```text
eightbf-analysis
→ stdout 和/或 /var/log/eightbf-analysis/*.log
→ Filebeat / Vector / rsyslog
→ 内网 Loki / ELK / 现有日志平台
Nginx /analysis-api access/error
→ 同上(务必带上游 upstream 耗时,便于区分网关与本服务)
```
要求:
1. 采集配置与本服务**同主机交付说明**写进内网迁移或运维手册(路径、用户、权限)。
2. 应用日志与 Nginx 日志**时间对齐**(统一时区 `Asia/Shanghai`)。
3. 留存:建议热数据 ≥14 天,冷存按等保/单位规定;磁盘告警纳入主机巡检。
4. 检索演练:上线后用一场真实 `jobId` 能在平台搜到「创建→开始→结束/失败」完整链,算采集验收通过。
### 11.7 与业务库的分工
| 数据 | 放哪 |
|------|------|
| 会商正文、气泡、检查点 | **MySQL**(权威) |
| 何时开始/停/失败、对接 DeerFlow 是否成功 | **日志**(排障) |
| 进度百分比给用户看 | `progress` 字段,不是日志 |
不要把日志当第二份 dialogues 备份。
### 11.8 可观测(日志之外,可排期)
最低仍须保留:`GET /api/v1/analysis/health`。
建议后续:作业耗时、DeerFlow 错误码计数、queued 堆积告警(指标/告警通道另定,不替代日志)。
内网功能验收仍见 [内网迁移.md](./内网迁移.md)。
---
## 12. 变更流程(本仓)
1. **先方案**:非琐碎改动先说清影响面(状态机 / API / 前端约定 / DeerFlow),用户批准后再写代码。
2. **范围克制**:只改任务所需;不顺手重构无关层。
3. **联调**:本服务 + code-zk `/analysis-api`;`sim:build` 后走一遍开研讨 / 干预 / 刷新。
4. **提交**:仅用户要求时 commit/push;勿夹带密钥与 `target/`、`data/`。
5. **文档同步**:行为变了必须改本文相关条款 + README/内网迁移(若涉及部署)。
---
## 13. 验收检查表(开发自测)
- [ ] 新建两场同 `taskId`,互不覆盖;刷新只跟各自 `jobId`
- [ ] live 模式关浏览器后作业仍推进或停在明确卡点
- [ ] 取消后 SSE/推进停止,状态 `cancelled`
- [ ] 人工干预开:每席(末席前)进入 `awaiting_input`;关且在卡点时自动续跑
- [ ] inject 后下一席派活带上补充;气泡可见用户原文
- [ ] 未知模型开跑失败,不进入 done
- [ ] `position_owner` 看不到其它席 special 气泡;不能 cancel
- [ ] 失败场无「可解锁下一阶段」的假 summary / 100% 共识
- [ ] 一场作业可用 `jobId` 在日志中串起创建→开始→结束/失败;无 token/密码泄露
- [ ] 正式环境日志有滚动策略;采集路径写进运维说明并能检索
- [ ] 注释与本文冲突时:以代码行为为准并回修文档
---
## 14. 反例(明确禁止)
- 浏览器直连 DeerFlow 跑 8BF 正式会商
- 用 `GET ?taskId=` 代替草稿 `jobId` 做刷新重连主路径
- 信任 DeerFlow 自动换模并当成功
- flush 时用旧内存 `manualIntervention`/`model` 盖掉库里用户热改
- 终态仍允许 inject/resume 而不报错
- 多实例无互斥双活抢作业
- 把演示 DemoWorker 开进生产当真模型
- 把 token / 密码 / 完整 dialogues 打进 INFO;流式 delta 逐字打日志
- 只写控制台、无滚动、无采集说明就当「生产可运维」
---
*文档版本与仓库代码同步维护;重大行为变更请改本节并在提交说明中引用。*
+7
View File
@@ -0,0 +1,7 @@
module git.gozeros.com/wsk/eightbf-analysis-go
go 1.26.5
require github.com/go-sql-driver/mysql v1.8.1
require filippo.io/edwards25519 v1.1.0 // indirect
+4
View File
@@ -0,0 +1,4 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+189
View File
@@ -0,0 +1,189 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
)
// Handler HTTP 入口,路径与 Java AnalysisJobController 对齐。
type Handler struct {
Jobs *service.JobService
}
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/analysis/health", h.health)
mux.HandleFunc("POST /api/v1/analysis/jobs", h.create)
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}", h.get)
mux.HandleFunc("GET /api/v1/analysis/jobs", h.getByTask)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/cancel", h.cancel)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/resume", h.resume)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/plan", h.patchPlan)
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/inject", h.inject)
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}/result", h.result)
}
func (h *Handler) caller(r *http.Request) service.Caller {
return service.NewCaller(r.Header.Get("X-Account-Id"), r.Header.Get("X-Analysis-Role"))
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"service": "eightbf-analysis-go",
})
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var body service.CreateJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Create(&body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
Created(w, job)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
job, err := h.Jobs.Get(id, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) getByTask(w http.ResponseWriter, r *http.Request) {
taskID := strings.TrimSpace(r.URL.Query().Get("taskId"))
if taskID == "" {
Fail(w, http.StatusBadRequest, 400, "taskId required")
return
}
latest := r.URL.Query().Get("latest") == "true"
job, err := h.Jobs.GetByTaskID(taskID, latest, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found for taskId")
return
}
OK(w, job)
}
func (h *Handler) cancel(w http.ResponseWriter, r *http.Request) {
job, err := h.Jobs.Cancel(r.PathValue("id"), h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) resume(w http.ResponseWriter, r *http.Request) {
var body service.ResumeJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Resume(r.PathValue("id"), &body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) patchPlan(w http.ResponseWriter, r *http.Request) {
var body service.PatchJobPlanRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.PatchPlan(r.PathValue("id"), &body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) inject(w http.ResponseWriter, r *http.Request) {
var body service.InjectJobRequest
_ = json.NewDecoder(r.Body).Decode(&body)
job, err := h.Jobs.Inject(r.PathValue("id"), &body, h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, job)
}
func (h *Handler) result(w http.ResponseWriter, r *http.Request) {
job, err := h.Jobs.Get(r.PathValue("id"), h.caller(r))
if err != nil {
h.mapErr(w, err)
return
}
if job == nil {
Fail(w, http.StatusNotFound, 404, "job not found")
return
}
OK(w, map[string]interface{}{
"id": job.ID,
"status": job.Status,
"result": job.Result,
"error": job.Error,
"dialogues": job.Dialogues,
})
}
func (h *Handler) mapErr(w http.ResponseWriter, err error) {
var be *service.BizError
if errors.As(err, &be) {
switch {
case errors.Is(be.Kind, service.ErrForbidden):
Fail(w, http.StatusForbidden, 403, be.Msg)
case errors.Is(be.Kind, service.ErrBadState), errors.Is(be.Kind, service.ErrBadArg):
Fail(w, http.StatusBadRequest, 400, be.Msg)
default:
Fail(w, http.StatusInternalServerError, 500, be.Msg)
}
return
}
Fail(w, http.StatusInternalServerError, 500, err.Error())
}
// WithCORS 本机联调放开跨域(经反代同源时无影响)。
func WithCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"encoding/json"
"net/http"
)
// ApiResponse 与 Java 一致:{ code, message, data };成功 code=0。
type ApiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func OK(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusOK, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func Created(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusCreated, ApiResponse{Code: 0, Message: "ok", Data: data})
}
func Fail(w http.ResponseWriter, httpStatus, code int, msg string) {
writeJSON(w, httpStatus, ApiResponse{Code: code, Message: msg, Data: nil})
}
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"os"
"strconv"
"strings"
)
// Config 运行配置(环境变量优先,与 Java EBA_* / analysis.* 对齐)。
type Config struct {
Port int
// MySQL:优先 EBA_DB_DSN;否则用 HOST/PORT/NAME/USER/PASSWORD 拼,或解析 JDBC URL。
DBDSN string
AnalysisMode string // live | demo
AuthEnabled bool
DemoEnabled bool
LivePollMS int
DemoTickMS int
DeerFlowBaseURL string
DeerFlowUser string
DeerFlowPassword string
DeerFlowCoordinator string
DeerFlowStreamIdleTimeout int // 毫秒
}
// Load 从环境变量加载;缺省贴近本机联调。
func Load() Config {
c := Config{
Port: envInt("SERVER_PORT", 18097),
AnalysisMode: env("ANALYSIS_MODE", "live"),
AuthEnabled: envBool("ANALYSIS_AUTH_ENABLED", false),
DemoEnabled: envBool("EBA_WORKER_DEMO_ENABLED", false),
LivePollMS: envInt("EBA_WORKER_LIVE_POLL_MS", 1000),
DemoTickMS: envInt("EBA_WORKER_TICK_MS", 400),
DeerFlowBaseURL: strings.TrimRight(env("EBA_DF_BASE_URL", "http://127.0.0.1:8001"), "/"),
DeerFlowUser: env("EBA_DF_USER", "admin"),
DeerFlowPassword: env("EBA_DF_PASSWORD", "123ewq"),
DeerFlowCoordinator: env("EBA_DF_COORDINATOR", "roundtable-coordinator"),
DeerFlowStreamIdleTimeout: envInt("EBA_DF_STREAM_IDLE_TIMEOUT_MS", 180000),
}
c.DBDSN = resolveDSN()
// live 默认关掉 demo;显式 ANALYSIS_MODE=demo 时打开假进度
if c.AnalysisMode == "demo" {
c.DemoEnabled = true
}
if c.AnalysisMode == "live" && !envExplicit("EBA_WORKER_DEMO_ENABLED") {
c.DemoEnabled = false
}
return c
}
func resolveDSN() string {
if d := strings.TrimSpace(os.Getenv("EBA_DB_DSN")); d != "" {
return ensureMultiStatements(d)
}
user := env("EBA_DB_USER", "eba")
pass := env("EBA_DB_PASSWORD", "eba_local_dev")
jdbc := strings.TrimSpace(os.Getenv("EBA_DB_URL"))
if jdbc != "" {
if dsn, ok := jdbcToDSN(jdbc, user, pass); ok {
return dsn
}
}
host := env("EBA_DB_HOST", "127.0.0.1")
port := env("EBA_DB_PORT", "3306")
name := env("EBA_DB_NAME", "eightbf_analysis")
return user + ":" + pass + "@tcp(" + host + ":" + port + ")/" + name +
"?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
}
// jdbcToDSN 把 Java 的 jdbc:mysql://host:port/db?... 转成 Go driver DSN。
func jdbcToDSN(jdbc, user, pass string) (string, bool) {
s := strings.TrimPrefix(jdbc, "jdbc:mysql://")
if s == jdbc {
return "", false
}
hostPath := s
query := ""
if i := strings.Index(s, "?"); i >= 0 {
hostPath = s[:i]
query = s[i+1:]
}
host := hostPath
db := "eightbf_analysis"
if i := strings.Index(hostPath, "/"); i >= 0 {
host = hostPath[:i]
db = hostPath[i+1:]
}
params := "parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
_ = query // Java JDBC 专用参数不照搬
return user + ":" + pass + "@tcp(" + host + ")/" + db + "?" + params, true
}
func ensureMultiStatements(dsn string) string {
if strings.Contains(dsn, "multiStatements=") {
return dsn
}
if strings.Contains(dsn, "?") {
return dsn + "&multiStatements=true"
}
return dsn + "?multiStatements=true"
}
func env(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return def
}
func envExplicit(k string) bool {
_, ok := os.LookupEnv(k)
return ok
}
func envBool(k string, def bool) bool {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}
func envInt(k string, def int) int {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
+72
View File
@@ -0,0 +1,72 @@
package deerflow
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
)
// Auth 向 DeerFlow 换 Bearer,过期前复用。
type Auth struct {
cfg config.Config
mu sync.Mutex
token string
expiresAt time.Time
httpClient *http.Client
}
func NewAuth(cfg config.Config) *Auth {
return &Auth{
cfg: cfg,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
func (a *Auth) BearerToken() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if a.token != "" && a.expiresAt.After(now.Add(2*time.Minute)) {
return a.token, nil
}
body, _ := json.Marshal(map[string]string{
"username": a.cfg.DeerFlowUser,
"password": a.cfg.DeerFlowPassword,
})
req, err := http.NewRequest(http.MethodPost, a.cfg.DeerFlowBaseURL+"/api/v1/auth/login/username", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", fmt.Errorf("DeerFlow 登录失败 HTTP %d: %s", resp.StatusCode, string(raw))
}
var parsed struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", err
}
if parsed.AccessToken == "" {
return "", fmt.Errorf("DeerFlow 登录响应无 access_token")
}
if parsed.ExpiresIn <= 0 {
parsed.ExpiresIn = 3600
}
a.token = parsed.AccessToken
a.expiresAt = now.Add(time.Duration(parsed.ExpiresIn) * time.Second)
return a.token, nil
}
+503
View File
@@ -0,0 +1,503 @@
package deerflow
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
)
// Client 调 DeerFlow:模型校验 / init / run/stream。
type Client struct {
cfg config.Config
auth *Auth
}
func NewClient(cfg config.Config, auth *Auth) *Client {
return &Client{cfg: cfg, auth: auth}
}
// AssertModelAvailable 未知模型拒绝开跑(禁止依赖网关静默换模)。
func (c *Client) AssertModelAvailable(model string) error {
want := strings.TrimSpace(model)
if want == "" || strings.EqualFold(want, "demo") {
return nil
}
token, err := c.auth.BearerToken()
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, c.cfg.DeerFlowBaseURL+"/api/models", nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("无法校验模型列表 HTTP %d: %s", resp.StatusCode, string(raw))
}
var root map[string]interface{}
_ = json.Unmarshal(raw, &root)
var arr []interface{}
if m, ok := root["models"].([]interface{}); ok {
arr = m
} else {
var list []interface{}
if err := json.Unmarshal(raw, &list); err == nil {
arr = list
}
}
if arr == nil {
return fmt.Errorf("DeerFlow /api/models 响应无 models 列表,拒绝使用未知模型「%s」", want)
}
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
name := fmt.Sprint(m["name"])
id := fmt.Sprint(m["model"])
display := fmt.Sprint(m["display_name"])
if want == name || want == id || want == display {
return nil
}
}
return fmt.Errorf("所选模型「%s」不在可用列表中(或不可用)。请换可用模型后重新开始会商,不会自动改用其它模型", want)
}
// InitMultiAgent 建多席线程,返回 agentName → threadId。
func (c *Client) InitMultiAgent(agentNames []string, mainAgent, model string) (map[string]string, error) {
body := map[string]interface{}{
"agent_names": agentNames,
"main_agent_name": mainAgent,
}
if m := strings.TrimSpace(model); m != "" && !strings.EqualFold(m, "demo") {
body["model"] = m
}
threadIDs := map[string]string{}
var errMsg string
err := c.readSSE("/api/multi-agent/init", body, nil, func(event string, parsed map[string]interface{}) (bool, error) {
ev := event
if ev == "" {
ev = fmt.Sprint(parsed["event"])
}
switch ev {
case "seat_ready", "coordinator_ready":
agent := strField(parsed, "agent_name", "main_agent_name")
tid := strField(parsed, "thread_id")
if agent != "" && tid != "" {
threadIDs[agent] = tid
}
case "init_done":
if tids, ok := parsed["thread_ids"].(map[string]interface{}); ok {
for k, v := range tids {
threadIDs[k] = fmt.Sprint(v)
}
}
return true, nil
case "error":
errMsg = strField(parsed, "detail")
if errMsg == "" {
errMsg = "init error"
}
return true, nil
}
return false, nil
})
if err != nil {
return nil, err
}
if errMsg != "" {
return nil, fmt.Errorf("多智能体初始化失败:%s", errMsg)
}
if len(threadIDs) == 0 {
return nil, fmt.Errorf("多智能体初始化未返回 thread_ids")
}
log.Printf("DeerFlow init 完成 seats=%d", len(threadIDs))
return threadIDs, nil
}
// RunStream 跑一轮流式会商。
func (c *Client) RunStream(
agentType string,
threadIDs map[string]string,
agentName, displayName, newMessage, model string,
synthesisMode bool,
onText TextDeltaHandler,
cancel CancelCheck,
) (*StreamResult, error) {
tidNode := map[string]string{}
for k, v := range threadIDs {
tidNode[k] = v
}
body := map[string]interface{}{
"agent_type": agentType,
"d_agent_thread_id": tidNode,
"agent_name": agentName,
"new_message": newMessage,
"thinking_enabled": true,
"reasoning_effort": "medium",
"subagent_enabled": false,
"seat_skill_directive": false,
"synthesis_mode": synthesisMode,
"suppress_pre_broadcast": true,
"parallel_no_file": false,
"peer_deliveries": []interface{}{},
}
if displayName != "" {
body["display_name"] = displayName
}
if m := strings.TrimSpace(model); m != "" && !strings.EqualFold(m, "demo") {
body["model"] = m
}
result := &StreamResult{}
var contentAcc strings.Builder
lastEvent := ""
terminal := false
display := &streamDisplayAssembler{}
err := c.readSSE("/api/multi-agent/run/stream", body, cancel, func(event string, parsed map[string]interface{}) (bool, error) {
if event != "" {
lastEvent = event
}
if cancel != nil && cancel() {
result.Aborted = true
return true, nil
}
if lastEvent == "error" {
result.InvalidDelivery = true
detail := strField(parsed, "detail", "message", "content")
if detail != "" {
result.Content = detail
} else if contentAcc.Len() > 0 {
result.Content = contentAcc.String()
} else {
result.Content = "模型运行报错"
}
terminal = true
return true, nil
}
if status, _ := parsed["status"].(string); status == "model_switch" {
result.InvalidDelivery = true
failed := strField(parsed, "failed_model")
next := strField(parsed, "next_model")
reason := strField(parsed, "reason_label", "reason")
if reason == "" {
reason = "调用失败"
}
msg := "所选模型"
if failed != "" {
msg += "「" + failed + "」"
}
msg += "异常(" + reason + ")"
if next != "" {
msg += ",已拒绝自动切换到「" + next + "」"
}
msg += "。请换可用模型后重新开始会商。"
result.Content = msg
terminal = true
return true, nil
}
if isFinalStatusFrame(parsed) {
if closeThink := display.closeThinkIfOpen(); closeThink != "" {
contentAcc.WriteString(closeThink)
if onText != nil {
_ = onText(closeThink)
}
}
if c, ok := parsed["content"].(string); ok {
result.Content = c
}
if n, ok := parsed["agent_name"].(string); ok {
result.AgentName = n
}
switch st := parsed["status"].(type) {
case string:
if st == "clarification" {
result.Clarification = true
result.ClarificationDetail = strField(parsed, "content")
} else if st == "invalid_delivery" || st == "error" {
result.InvalidDelivery = true
}
case []interface{}:
for _, row := range st {
arr, ok := row.([]interface{})
if !ok || len(arr) < 2 {
continue
}
result.Dispatched = append(result.Dispatched, [2]string{fmt.Sprint(arr[0]), fmt.Sprint(arr[1])})
}
}
if result.Content == "" {
result.Content = contentAcc.String()
}
terminal = true
return true, nil
}
if lastEvent == "messages" || lastEvent == "" {
delta := display.push(parsed)
if delta != "" {
contentAcc.WriteString(delta)
if onText != nil {
if err := onText(delta); err != nil {
return true, err
}
}
}
}
return false, nil
})
if err != nil {
return result, err
}
if result.Content == "" {
result.Content = contentAcc.String()
}
if !result.Aborted && !terminal && !result.Clarification {
result.InvalidDelivery = true
if strings.TrimSpace(result.Content) == "" {
result.Content = "流式输出异常结束(无终态帧)"
}
}
return result, nil
}
func (c *Client) readSSE(
path string,
body map[string]interface{},
cancel CancelCheck,
onData func(event string, parsed map[string]interface{}) (stop bool, err error),
) error {
token, err := c.auth.BearerToken()
if err != nil {
return err
}
raw, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, c.cfg.DeerFlowBaseURL+path, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: time.Duration(c.cfg.DeerFlowStreamIdleTimeout) * time.Millisecond}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("DeerFlow %s HTTP %d: %s", path, resp.StatusCode, string(b))
}
scanner := bufio.NewScanner(resp.Body)
// 单行可能很长
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 4*1024*1024)
curEvent := ""
for scanner.Scan() {
if cancel != nil && cancel() {
return nil
}
line := scanner.Text()
if strings.HasPrefix(line, "event:") {
curEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" {
continue
}
var parsed interface{}
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
continue
}
switch t := parsed.(type) {
case map[string]interface{}:
stop, err := onData(curEvent, t)
if err != nil || stop {
return err
}
case []interface{}:
// messages 帧常为数组,包一层给 assembler
stop, err := onData(curEvent, map[string]interface{}{"_arr": t})
if err != nil || stop {
return err
}
// 同时把数组直接交给 push 路径:用特殊 key 不够;改为在 onData 外再处理
_ = t
}
}
return scanner.Err()
}
func isFinalStatusFrame(v map[string]interface{}) bool {
if v == nil {
return false
}
if _, ok := v["status"]; !ok {
return false
}
if _, ok := v["content"]; !ok {
return false
}
switch st := v["status"].(type) {
case string:
return true
case []interface{}:
for _, item := range st {
arr, ok := item.([]interface{})
if !ok || len(arr) < 2 {
return false
}
}
return true
default:
return false
}
}
func strField(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok && v != nil {
s := strings.TrimSpace(fmt.Sprint(v))
if s != "" && s != "<nil>" {
return s
}
}
}
return ""
}
// —— 流式展示折算(reasoning → <think>)——
type streamDisplayAssembler struct {
thinkOpen bool
reasoningSeen string
}
func (d *streamDisplayAssembler) push(parsed map[string]interface{}) string {
var arr []interface{}
if a, ok := parsed["_arr"].([]interface{}); ok {
arr = a
} else {
// 兼容:有些路径直接把数组当根;此处若不是 AI chunk 则空
return ""
}
if len(arr) == 0 {
return ""
}
obj, ok := arr[0].(map[string]interface{})
if !ok {
return ""
}
typ := strings.ToLower(fmt.Sprint(obj["type"]))
if typ != "ai" && typ != "aimessagechunk" && typ != "aimessage" {
return ""
}
var reasoning, answer strings.Builder
switch c := obj["content"].(type) {
case string:
answer.WriteString(c)
case []interface{}:
for _, item := range c {
if s, ok := item.(string); ok {
answer.WriteString(s)
continue
}
m, ok := item.(map[string]interface{})
if !ok {
continue
}
bt := strings.ToLower(fmt.Sprint(m["type"]))
val := firstText(m, "thinking", "text", "content")
if val == "" {
continue
}
if strings.Contains(bt, "think") || strings.Contains(bt, "reason") {
reasoning.WriteString(val)
} else {
answer.WriteString(val)
}
}
}
if fromKw := extractReasoning(obj); fromKw != "" {
if strings.HasPrefix(fromKw, d.reasoningSeen) {
more := fromKw[len(d.reasoningSeen):]
if more != "" {
reasoning.WriteString(more)
}
d.reasoningSeen = fromKw
} else if !strings.Contains(d.reasoningSeen, fromKw) {
reasoning.WriteString(fromKw)
d.reasoningSeen += fromKw
}
}
var out strings.Builder
if reasoning.Len() > 0 {
if !d.thinkOpen {
out.WriteString("<think>")
d.thinkOpen = true
}
out.WriteString(reasoning.String())
}
if answer.Len() > 0 {
if d.thinkOpen {
out.WriteString("</think>")
d.thinkOpen = false
}
out.WriteString(answer.String())
}
return out.String()
}
func (d *streamDisplayAssembler) closeThinkIfOpen() string {
if !d.thinkOpen {
return ""
}
d.thinkOpen = false
return "</think>"
}
func extractReasoning(obj map[string]interface{}) string {
for _, k := range []string{"reasoning_content", "reasoning"} {
if s, ok := obj[k].(string); ok && s != "" {
return s
}
}
if kw, ok := obj["additional_kwargs"].(map[string]interface{}); ok {
for _, k := range []string{"reasoning_content", "reasoning"} {
if s, ok := kw[k].(string); ok && s != "" {
return s
}
}
}
return ""
}
func firstText(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
return s
}
}
return ""
}
+18
View File
@@ -0,0 +1,18 @@
package deerflow
// StreamResult 一次 run/stream 汇总(与 Java StreamResult 对齐)。
type StreamResult struct {
Content string
AgentName string
Dispatched [][2]string // [agentId, task]
Clarification bool
ClarificationDetail string
InvalidDelivery bool
Aborted bool
}
// TextDeltaHandler 流式正文增量(可含 <think>)。
type TextDeltaHandler func(delta string) error
// CancelCheck 读 SSE 前询问是否已取消。
type CancelCheck func() bool
+56
View File
@@ -0,0 +1,56 @@
package domain
import "time"
// AnalysisJob 一场会商作业(服务端「研讨录像带」)。
// JSON 字段名与前端 mapJob / Java 实体对齐,少做二次映射。
type AnalysisJob struct {
ID string `json:"id"`
RequestID string `json:"requestId,omitempty"`
TaskID string `json:"taskId,omitempty"`
DraftID string `json:"draftId,omitempty"`
DraftKey string `json:"draftKey,omitempty"`
Status string `json:"status"`
Phase string `json:"phase,omitempty"`
Cycle int `json:"cycle"`
ActiveAgentID string `json:"activeAgentId,omitempty"`
ConsensusPercentage int `json:"consensusPercentage"`
PendingClarification string `json:"pendingClarification,omitempty"`
OrchestrationMode string `json:"orchestrationMode,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
OwnerAccountID string `json:"ownerAccountId,omitempty"`
Agents []map[string]interface{} `json:"agents"`
Dialogues []map[string]interface{} `json:"dialogues"`
ThreadIDs map[string]interface{} `json:"threadIds,omitempty"`
Plan map[string]interface{} `json:"plan"`
Access map[string]interface{} `json:"access"`
Progress map[string]interface{} `json:"progress"`
Result map[string]interface{} `json:"result,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// EnsurePlan plan 为空时补可写 Map。
func (j *AnalysisJob) EnsurePlan() map[string]interface{} {
if j.Plan == nil {
j.Plan = map[string]interface{}{}
}
return j.Plan
}
// EnsureProgress progress 为空时补可写 Map。
func (j *AnalysisJob) EnsureProgress() map[string]interface{} {
if j.Progress == nil {
j.Progress = map[string]interface{}{}
}
return j.Progress
}
// EnsureDialogues dialogues 为空时补切片。
func (j *AnalysisJob) EnsureDialogues() []map[string]interface{} {
if j.Dialogues == nil {
j.Dialogues = []map[string]interface{}{}
}
return j.Dialogues
}
+21
View File
@@ -0,0 +1,21 @@
package domain
// 会商作业状态(与 Java JobStatus / 前端约定一致,勿自创别名)。
const (
StatusQueued = "queued"
StatusRunning = "running"
StatusAwaitingInput = "awaiting_input"
StatusDone = "done"
StatusError = "error"
StatusCancelled = "cancelled"
)
// IsActive 是否仍可能被 worker 推进(含等人中)。
func IsActive(status string) bool {
return status == StatusQueued || status == StatusRunning || status == StatusAwaitingInput
}
// IsTerminal 是否已结束,不应再 resume / inject / 改计划。
func IsTerminal(status string) bool {
return status == StatusDone || status == StatusError || status == StatusCancelled
}
+298
View File
@@ -0,0 +1,298 @@
package repo
import (
"database/sql"
"encoding/json"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
// JobRepo 会商作业表读写(eba_analysis_job)。大字段整段 JSON 文本存。
type JobRepo struct {
DB *sql.DB
}
func NewJobRepo(db *sql.DB) *JobRepo {
return &JobRepo{DB: db}
}
// EnsureSchema 执行建表脚本(幂等;重复索引错误由调用方忽略亦可)。
func (r *JobRepo) EnsureSchema(schemaSQL string) error {
_, err := r.DB.Exec(schemaSQL)
return err
}
func (r *JobRepo) Insert(job *domain.AnalysisJob) error {
now := time.Now()
job.CreatedAt = &now
job.UpdatedAt = &now
_, err := r.DB.Exec(`INSERT INTO eba_analysis_job(
id,request_id,task_id,draft_id,draft_key,status,phase,cycle,active_agent_id,
consensus_percentage,pending_clarification,orchestration_mode,model,error_message,
owner_account_id,agents_json,chain_json,plan_json,access_json,thread_ids_json,
progress_json,result_json,cancel_flag,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?)`,
job.ID, nullStr(job.RequestID), nullStr(job.TaskID), nullStr(job.DraftID), nullStr(job.DraftKey),
job.Status, nullStr(job.Phase), job.Cycle, nullStr(job.ActiveAgentID),
job.ConsensusPercentage, nullStr(job.PendingClarification), nullStr(job.OrchestrationMode),
nullStr(job.Model), nullStr(job.Error), nullStr(job.OwnerAccountID),
mustJSON(job.Agents), mustJSON(job.Dialogues), mustJSON(job.Plan), mustJSON(job.Access),
mustJSON(job.ThreadIDs), mustJSON(job.Progress), mustJSON(job.Result),
now, now,
)
return err
}
// UpdateProgress 全量刷新进度相关列;cancelled 时同步 cancel_flag=1。
func (r *JobRepo) UpdateProgress(job *domain.AnalysisJob) error {
now := time.Now()
job.UpdatedAt = &now
cancel := 0
if job.Status == domain.StatusCancelled {
cancel = 1
}
_, err := r.DB.Exec(`UPDATE eba_analysis_job SET status=?, phase=?, cycle=?, active_agent_id=?,
consensus_percentage=?, pending_clarification=?, model=?, error_message=?, owner_account_id=?,
agents_json=?, chain_json=?, plan_json=?, access_json=?, thread_ids_json=?,
progress_json=?, result_json=?, cancel_flag=?, updated_at=? WHERE id=?`,
job.Status, nullStr(job.Phase), job.Cycle, nullStr(job.ActiveAgentID),
job.ConsensusPercentage, nullStr(job.PendingClarification), nullStr(job.Model),
nullStr(job.Error), nullStr(job.OwnerAccountID),
mustJSON(job.Agents), mustJSON(job.Dialogues), mustJSON(job.Plan), mustJSON(job.Access),
mustJSON(job.ThreadIDs), mustJSON(job.Progress), mustJSON(job.Result),
cancel, now, job.ID,
)
return err
}
func (r *JobRepo) FindByID(id string) (*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE id=?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanJob(rows)
}
func (r *JobRepo) FindActiveByTaskID(taskID string) (*domain.AnalysisJob, error) {
if taskID == "" {
return nil, nil
}
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? AND status IN (?,?,?)
ORDER BY created_at DESC LIMIT 1`,
taskID, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanJob(rows)
}
func (r *JobRepo) FindLatestByTaskID(taskID string) (*domain.AnalysisJob, error) {
if taskID == "" {
return nil, nil
}
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? ORDER BY created_at DESC LIMIT 1`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
return scanJob(rows)
}
func (r *JobRepo) FindActiveJobs() ([]*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status IN (?,?,?) ORDER BY created_at ASC`,
domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return nil, err
}
defer rows.Close()
return scanJobs(rows)
}
func (r *JobRepo) FindQueuedJobs() ([]*domain.AnalysisJob, error) {
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status=? ORDER BY created_at ASC`, domain.StatusQueued)
if err != nil {
return nil, err
}
defer rows.Close()
return scanJobs(rows)
}
func (r *JobRepo) MarkCancel(id string) (bool, error) {
res, err := r.DB.Exec(`UPDATE eba_analysis_job SET cancel_flag=1, updated_at=? WHERE id=? AND status IN (?,?,?)`,
time.Now(), id, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (r *JobRepo) IsCancelFlag(id string) (bool, error) {
var v sql.NullInt64
err := r.DB.QueryRow(`SELECT cancel_flag FROM eba_analysis_job WHERE id=?`, id).Scan(&v)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return v.Valid && v.Int64 == 1, nil
}
func scanJobs(rows *sql.Rows) ([]*domain.AnalysisJob, error) {
var out []*domain.AnalysisJob
for rows.Next() {
j, err := scanJob(rows)
if err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
func scanJob(rows *sql.Rows) (*domain.AnalysisJob, error) {
cols, err := rows.Columns()
if err != nil {
return nil, err
}
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
m := map[string]interface{}{}
for i, c := range cols {
m[c] = vals[i]
}
j := &domain.AnalysisJob{}
j.ID = asString(m["id"])
j.RequestID = asString(m["request_id"])
j.TaskID = asString(m["task_id"])
j.DraftID = asString(m["draft_id"])
j.DraftKey = asString(m["draft_key"])
j.Status = asString(m["status"])
j.Phase = asString(m["phase"])
j.Cycle = asInt(m["cycle"])
j.ActiveAgentID = asString(m["active_agent_id"])
j.ConsensusPercentage = asInt(m["consensus_percentage"])
j.PendingClarification = asString(m["pending_clarification"])
j.OrchestrationMode = asString(m["orchestration_mode"])
j.Model = asString(m["model"])
j.Error = asString(m["error_message"])
j.OwnerAccountID = asString(m["owner_account_id"])
j.Agents = readList(asString(m["agents_json"]))
j.Dialogues = readList(asString(m["chain_json"]))
j.Plan = readMap(asString(m["plan_json"]))
j.Access = readMap(asString(m["access_json"]))
j.ThreadIDs = readMap(asString(m["thread_ids_json"]))
j.Progress = readMap(asString(m["progress_json"]))
j.Result = readMap(asString(m["result_json"]))
if t := asTime(m["created_at"]); t != nil {
j.CreatedAt = t
}
if t := asTime(m["updated_at"]); t != nil {
j.UpdatedAt = t
}
return j, nil
}
func mustJSON(v interface{}) interface{} {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return string(b)
}
func readMap(jsonStr string) map[string]interface{} {
if jsonStr == "" {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
return nil
}
return m
}
func readList(jsonStr string) []map[string]interface{} {
if jsonStr == "" {
return []map[string]interface{}{}
}
var list []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
return []map[string]interface{}{}
}
return list
}
func nullStr(s string) interface{} {
if s == "" {
return nil
}
return s
}
func asString(v interface{}) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case []byte:
return string(t)
default:
return ""
}
}
func asInt(v interface{}) int {
if v == nil {
return 0
}
switch t := v.(type) {
case int64:
return int(t)
case int32:
return int(t)
case int:
return t
case []byte:
var n int
_ = json.Unmarshal(t, &n)
return n
default:
return 0
}
}
func asTime(v interface{}) *time.Time {
if v == nil {
return nil
}
if t, ok := v.(time.Time); ok {
tt := t
return &tt
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package repo
import (
"log"
"strings"
)
// ApplySchema 按语句拆开执行;重复索引等可忽略错误不阻断启动。
func (r *JobRepo) ApplySchema(schemaSQL string) {
for _, stmt := range splitSQL(schemaSQL) {
if _, err := r.DB.Exec(stmt); err != nil {
msg := err.Error()
// 1061 Duplicate key name / 已存在表
if strings.Contains(msg, "1061") || strings.Contains(msg, "1050") ||
strings.Contains(msg, "already exists") {
continue
}
log.Printf("schema 语句失败(已跳过): %v | sql=%.80s…", err, stmt)
}
}
}
func splitSQL(s string) []string {
var out []string
var b strings.Builder
lines := strings.Split(s, "\n")
for _, line := range lines {
trim := strings.TrimSpace(line)
if trim == "" || strings.HasPrefix(trim, "--") {
continue
}
b.WriteString(line)
b.WriteByte('\n')
if strings.HasSuffix(trim, ";") {
stmt := strings.TrimSpace(b.String())
stmt = strings.TrimSuffix(stmt, ";")
if stmt != "" {
out = append(out, stmt)
}
b.Reset()
}
}
if rest := strings.TrimSpace(b.String()); rest != "" {
out = append(out, rest)
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package service
import (
"fmt"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
const (
RoleAdmin = "admin"
RoleTaskOwner = "task_owner"
RolePositionOwner = "position_owner"
RoleViewer = "viewer"
)
// Caller 当前请求调用方(来自 X-Account-Id / X-Analysis-Role)。
type Caller struct {
AccountID string
Role string
}
func NewCaller(accountID, role string) Caller {
r := strings.TrimSpace(role)
if r == "" {
r = RoleViewer
}
return Caller{AccountID: strings.TrimSpace(accountID), Role: r}
}
// AccessService 岗位账号限制与对话投影。
type AccessService struct {
AuthEnabled bool
}
func (a *AccessService) CanMutate(job *domain.AnalysisJob, c Caller) bool {
if !a.AuthEnabled {
return true
}
if c.AccountID == "" {
return false
}
if c.Role == RoleAdmin || c.Role == RoleTaskOwner {
return true
}
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) {
return true
}
if a.isListedTaskOwner(job, c.AccountID) {
return true
}
return c.Role == RolePositionOwner && len(a.boundSeats(job, c.AccountID)) > 0
}
func (a *AccessService) CanCancel(job *domain.AnalysisJob, c Caller) bool {
if c.Role == RolePositionOwner {
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) || a.isListedTaskOwner(job, c.AccountID) {
return true
}
return false
}
if !a.AuthEnabled {
return true
}
if c.AccountID == "" {
return false
}
if c.Role == RoleAdmin || c.Role == RoleTaskOwner {
return true
}
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) {
return true
}
return a.isListedTaskOwner(job, c.AccountID)
}
func (a *AccessService) CanRead(job *domain.AnalysisJob, c Caller) bool {
if !a.AuthEnabled {
return true
}
if c.AccountID == "" {
return false
}
if a.CanMutate(job, c) || a.CanCancel(job, c) {
return true
}
return len(a.boundSeats(job, c.AccountID)) > 0
}
// ProjectForCaller 按角色裁剪对话后返回(责任人全量;岗位只见本席+总控/系统)。
func (a *AccessService) ProjectForCaller(job *domain.AnalysisJob, c Caller) *domain.AnalysisJob {
if job == nil {
return nil
}
if c.AccountID == "" {
return job
}
if c.Role == RoleAdmin || c.Role == RoleTaskOwner ||
c.AccountID == strings.TrimSpace(job.OwnerAccountID) ||
a.isListedTaskOwner(job, c.AccountID) {
return job
}
if c.Role != RolePositionOwner && !a.AuthEnabled {
return job
}
seats := a.boundSeats(job, c.AccountID)
copyJob := *job
if len(seats) == 0 {
var filtered []map[string]interface{}
for _, d := range job.Dialogues {
role := asStr(d["role"])
if role == "system" || role == "user" {
filtered = append(filtered, d)
}
}
copyJob.Dialogues = filtered
if job.Result != nil {
r := cloneMap(job.Result)
delete(r, "seatDeliveries")
copyJob.Result = r
}
return &copyJob
}
var filtered []map[string]interface{}
for _, d := range job.Dialogues {
role := asStr(d["role"])
agentID := asStr(d["agentId"])
if role == "system" || role == "user" || role == "leader" {
filtered = append(filtered, d)
} else if seats[agentID] {
filtered = append(filtered, d)
}
}
copyJob.Dialogues = filtered
if job.Result != nil {
r := cloneMap(job.Result)
if raw, ok := r["seatDeliveries"].([]interface{}); ok {
var kept []interface{}
for _, o := range raw {
m, ok := o.(map[string]interface{})
if !ok {
continue
}
if seats[asStr(m["agentId"])] {
kept = append(kept, m)
}
}
r["seatDeliveries"] = kept
}
copyJob.Result = r
}
return &copyJob
}
func (a *AccessService) boundSeats(job *domain.AnalysisJob, accountID string) map[string]bool {
out := map[string]bool{}
if job == nil || accountID == "" || job.Access == nil {
return out
}
raw, ok := job.Access["seatAccounts"]
if !ok {
return out
}
m, ok := raw.(map[string]interface{})
if !ok {
return out
}
for k, v := range m {
if accountInList(v, accountID) {
out[k] = true
}
}
return out
}
func (a *AccessService) isListedTaskOwner(job *domain.AnalysisJob, accountID string) bool {
if job == nil || job.Access == nil {
return false
}
return accountInList(job.Access["taskOwnerAccountIds"], accountID) ||
accountInList(job.Access["adminAccountIds"], accountID)
}
func accountInList(raw interface{}, accountID string) bool {
if raw == nil || accountID == "" {
return false
}
switch t := raw.(type) {
case []interface{}:
for _, o := range t {
if strings.TrimSpace(asStr(o)) == accountID {
return true
}
}
return false
default:
return strings.TrimSpace(asStr(raw)) == accountID
}
}
func cloneMap(m map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func asStr(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return strings.TrimSpace(fmt.Sprint(v))
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"testing"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
)
func TestPositionOwnerProjection(t *testing.T) {
a := &AccessService{AuthEnabled: false}
job := &domain.AnalysisJob{
OwnerAccountID: "owner1",
Access: map[string]interface{}{
"seatAccounts": map[string]interface{}{
"seat-a": []interface{}{"acc-a"},
},
},
Dialogues: []map[string]interface{}{
{"role": "leader", "agentId": "coord", "content": "派活"},
{"role": "special", "agentId": "seat-a", "content": "本席"},
{"role": "special", "agentId": "seat-b", "content": "它席"},
{"role": "user", "content": "我说"},
},
}
out := a.ProjectForCaller(job, NewCaller("acc-a", RolePositionOwner))
if len(out.Dialogues) != 3 {
t.Fatalf("期望 3 条(leader+本席+user),得到 %d", len(out.Dialogues))
}
for _, d := range out.Dialogues {
if asStr(d["agentId"]) == "seat-b" {
t.Fatal("岗位责任人不应看到它席气泡")
}
}
}
func TestPositionOwnerCannotCancel(t *testing.T) {
a := &AccessService{AuthEnabled: false}
job := &domain.AnalysisJob{
OwnerAccountID: "owner1",
Access: map[string]interface{}{
"seatAccounts": map[string]interface{}{"seat-a": []interface{}{"acc-a"}},
},
}
if a.CanCancel(job, NewCaller("acc-a", RolePositionOwner)) {
t.Fatal("岗位责任人默认不能 cancel")
}
if !a.CanMutate(job, NewCaller("acc-a", RolePositionOwner)) {
t.Fatal("岗位责任人应可 resume/inject")
}
}
+277
View File
@@ -0,0 +1,277 @@
package service
import (
"log"
"strconv"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// DemoWorker 假进度推进(仅 ANALYSIS_MODE=demo)。
type DemoWorker struct {
Repo *repo.JobRepo
Every time.Duration
}
var demoLines = []string{
"【演示】我先说观点:当前应先收窄目标,再谈方案细节。",
"【演示】节奏上建议短周期验证,避免一次铺太开。",
"【演示】依赖和数据口径要尽早对齐,否则后面容易返工。",
"【演示】成本先压到能证明价值的最小投入。",
"【演示】主要风险是范围蔓延和决策偏慢,需要明确拍板人。",
"【演示】我同意形成一版可执行共识,可以进入总结。",
}
const charsPerTick = 8
func (w *DemoWorker) Start() {
if w.Every <= 0 {
w.Every = 400 * time.Millisecond
}
go func() {
t := time.NewTicker(w.Every)
defer t.Stop()
for range t.C {
list, err := w.Repo.FindActiveJobs()
if err != nil {
continue
}
for _, job := range list {
if err := w.advance(job); err != nil {
log.Printf("demo worker advance failed jobId=%s: %v", job.ID, err)
job.Status = domain.StatusError
job.Phase = "error"
job.Error = err.Error()
_ = w.Repo.UpdateProgress(job)
}
}
}
}()
}
func (w *DemoWorker) advance(job *domain.AnalysisJob) error {
if ok, _ := w.Repo.IsCancelFlag(job.ID); ok {
finishCurrentBubble(job, "cancelled")
job.Status = domain.StatusCancelled
job.Phase = "cancelled"
job.Error = "cancelled"
p := job.EnsureProgress()
p["message"] = "cancelled"
return w.Repo.UpdateProgress(job)
}
if job.Status == domain.StatusAwaitingInput {
return nil
}
agents := job.Agents
if agents == nil {
agents = []map[string]interface{}{}
}
agentCount := len(agents)
if agentCount < 1 {
agentCount = 1
}
dialogues := job.EnsureDialogues()
progress := job.EnsureProgress()
if job.Status == domain.StatusQueued {
job.Status = domain.StatusRunning
job.Phase = "analyzing"
job.Cycle = 1
progress["percent"] = 5
progress["message"] = "席位开始发言"
progress["lineIndex"] = 0
progress["charIndex"] = 0
applyAgentStatus(agents, 0, "speaking")
job.ActiveAgentID = agentIDAt(agents, 0)
job.Agents = agents
openDemoBubble(job, dialogues, agents, 0, "席位输出中…")
// openDemoBubble 已写回 job.Dialogues;切勿再用旧 dialogues 切片盖掉新气泡
return w.Repo.UpdateProgress(job)
}
lineIndex := toIntVal(progress["lineIndex"], 0)
charIndex := toIntVal(progress["charIndex"], 0)
if lineIndex < len(demoLines) {
speakerIdx := lineIndex % agentCount
wantAgent := agentIDAt(agents, speakerIdx)
if job.ActiveAgentID != wantAgent || (charIndex == 0 && !hasStreaming(dialogues)) {
if charIndex == 0 {
finishCurrentBubble(job, "")
for i := range agents {
st := "listening"
if i == speakerIdx {
st = "speaking"
}
applyAgentStatus(agents, i, st)
}
job.ActiveAgentID = wantAgent
openDemoBubble(job, dialogues, agents, speakerIdx, "席位输出中…")
dialogues = job.Dialogues
}
}
line := demoLines[lineIndex]
end := charIndex + charsPerTick
if end > len([]rune(line)) {
end = len([]rune(line))
}
rs := []rune(line)
if end > charIndex {
appendStreaming(dialogues, string(rs[charIndex:end]))
charIndex = end
}
if charIndex >= len(rs) {
appendStreaming(dialogues, "\n\n")
finishCurrentBubble(job, "")
lineIndex++
charIndex = 0
}
totalChars := 0
for _, l := range demoLines {
totalChars += len([]rune(l))
}
doneChars := 0
for i := 0; i < lineIndex && i < len(demoLines); i++ {
doneChars += len([]rune(demoLines[i]))
}
doneChars += charIndex
percent := 5 + (doneChars*85)/max(totalChars, 1)
if percent > 90 {
percent = 90
}
progress["lineIndex"] = lineIndex
progress["charIndex"] = charIndex
progress["percent"] = percent
progress["message"] = "发言中 " + itoa(percent) + "%"
job.Cycle = 1 + lineIndex/max(agentCount, 1)
job.ConsensusPercentage = min(95, percent)
job.Agents = agents
job.Dialogues = dialogues
return w.Repo.UpdateProgress(job)
}
finishCurrentBubble(job, "")
summary := "【演示总结】以上均为占位发言,用于验证「关浏览器后任务仍继续 + 页面流式展示」。" +
"接入真实模型后,这里会换成总控根据各席发言生成的真正总结。" +
"(job=" + job.ID + ")"
dialogues = append(dialogues, map[string]interface{}{
"id": "dlg_" + job.ID + "_leader", "role": "leader", "sender": "总控协调",
"agentId": "coordinator", "content": summary, "status": "done", "phaseLabel": "全案盖印",
})
for i := range agents {
applyAgentStatus(agents, i, "done")
}
job.ActiveAgentID = ""
job.Agents = agents
job.Status = domain.StatusDone
job.Phase = "done"
job.ConsensusPercentage = 100
progress["percent"] = 100
progress["message"] = "completed"
job.Dialogues = dialogues
job.Result = map[string]interface{}{
"summary": summary,
"consensusPercentage": 100,
"recommendations": []string{"确认产品边界与里程碑", "补充关键风险与资源假设", "安排下一轮执行评审"},
}
return w.Repo.UpdateProgress(job)
}
func openDemoBubble(job *domain.AnalysisJob, dialogues, agents []map[string]interface{}, idx int, phase string) {
bubble := map[string]interface{}{
"id": "dlg_" + job.ID + "_" + itoa(len(dialogues)+1), "role": "special",
"sender": agentNameAt(agents, idx), "agentId": agentIDAt(agents, idx),
"content": "", "status": "streaming", "phaseLabel": phase,
}
job.Dialogues = append(dialogues, bubble)
}
func finishCurrentBubble(job *domain.AnalysisJob, override string) {
for i := len(job.Dialogues) - 1; i >= 0; i-- {
b := job.Dialogues[i]
if asStr(b["status"]) == "streaming" {
st := "done"
if override != "" {
st = override
}
b["status"] = st
b["phaseLabel"] = ""
return
}
}
}
func appendStreaming(dialogues []map[string]interface{}, chunk string) {
for i := len(dialogues) - 1; i >= 0; i-- {
b := dialogues[i]
if asStr(b["status"]) == "streaming" {
b["content"] = asStr(b["content"]) + chunk
return
}
}
}
func hasStreaming(dialogues []map[string]interface{}) bool {
for i := len(dialogues) - 1; i >= 0; i-- {
if asStr(dialogues[i]["status"]) == "streaming" {
return true
}
}
return false
}
func agentIDAt(agents []map[string]interface{}, idx int) string {
if len(agents) == 0 {
return ""
}
return asStr(agents[idx%len(agents)]["id"])
}
func agentNameAt(agents []map[string]interface{}, idx int) string {
if len(agents) == 0 {
return "席位"
}
a := agents[idx%len(agents)]
if n := asStr(a["name"]); n != "" {
return n
}
return asStr(a["id"])
}
func applyAgentStatus(agents []map[string]interface{}, idx int, status string) {
if idx < 0 || idx >= len(agents) {
return
}
agents[idx]["status"] = status
}
func toIntVal(v interface{}, def int) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
default:
return def
}
}
func itoa(n int) string { return strconv.Itoa(n) }
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+23
View File
@@ -0,0 +1,23 @@
package service
import "errors"
// 业务错误:HTTP 层按类型映射 403/400。
var (
ErrForbidden = errors.New("forbidden")
ErrBadState = errors.New("bad state")
ErrBadArg = errors.New("bad argument")
)
// BizError 带给人看的文案。
type BizError struct {
Kind error
Msg string
}
func (e *BizError) Error() string { return e.Msg }
func (e *BizError) Unwrap() error { return e.Kind }
func Forbidden(msg string) error { return &BizError{Kind: ErrForbidden, Msg: msg} }
func BadState(msg string) error { return &BizError{Kind: ErrBadState, Msg: msg} }
func BadArg(msg string) error { return &BizError{Kind: ErrBadArg, Msg: msg} }
+352
View File
@@ -0,0 +1,352 @@
package service
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// CreateJobRequest 创建会商作业入参。
type CreateJobRequest struct {
RequestID string `json:"requestId"`
TaskID string `json:"taskId"`
DraftID string `json:"draftId"`
DraftKey string `json:"draftKey"`
Model string `json:"model"`
OrchestrationMode string `json:"orchestrationMode"`
Title string `json:"title"`
Description string `json:"description"`
OwnerAccountID string `json:"ownerAccountId"`
ManualIntervention *bool `json:"manualIntervention"`
Agents []map[string]interface{} `json:"agents"`
Payload map[string]interface{} `json:"payload"`
Access map[string]interface{} `json:"access"`
}
type ResumeJobRequest struct {
Answer string `json:"answer"`
Reason string `json:"reason"`
}
type PatchJobPlanRequest struct {
ManualIntervention *bool `json:"manualIntervention"`
Model *string `json:"model"`
}
type InjectJobRequest struct {
Text string `json:"text"`
}
// JobService 会商作业编排(不直接调大模型)。
type JobService struct {
Repo *repo.JobRepo
Access *AccessService
}
func (s *JobService) Create(req *CreateJobRequest, caller Caller) (*domain.AnalysisJob, error) {
if req == nil {
req = &CreateJobRequest{}
}
manual := true
if req.ManualIntervention != nil {
manual = *req.ManualIntervention
}
owner := blankToNull(req.OwnerAccountID)
if owner == "" && caller.AccountID != "" {
owner = caller.AccountID
}
mode := blankToNull(req.OrchestrationMode)
if mode == "" {
mode = "roundtable"
}
job := &domain.AnalysisJob{
ID: "job_" + randHex(8),
RequestID: firstNonEmpty(blankToNull(req.RequestID), "req_"+randHex(6)),
TaskID: blankToNull(req.TaskID),
DraftID: blankToNull(req.DraftID),
DraftKey: blankToNull(req.DraftKey),
Status: domain.StatusQueued,
Phase: "queued",
OrchestrationMode: mode,
Model: strings.TrimSpace(req.Model),
OwnerAccountID: owner,
Agents: normalizeAgents(req.Agents),
Dialogues: []map[string]interface{}{},
Access: orEmptyMap(req.Access),
Plan: map[string]interface{}{
"seatIndex": 0,
"manualIntervention": manual,
"seedMessage": nil,
"pendingInject": nil,
"awaitReason": nil,
},
Progress: map[string]interface{}{
"percent": 0,
"message": "queued",
"title": req.Title,
"description": req.Description,
},
}
if req.Payload != nil {
job.Progress["payload"] = req.Payload
}
if err := s.Repo.Insert(job); err != nil {
return nil, err
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) Get(id string, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanRead(job, caller) {
return nil, Forbidden("无权查看该会商作业")
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) GetByTaskID(taskID string, latestOnly bool, caller Caller) (*domain.AnalysisJob, error) {
var job *domain.AnalysisJob
var err error
if latestOnly {
job, err = s.Repo.FindLatestByTaskID(taskID)
} else {
job, err = s.Repo.FindActiveByTaskID(taskID)
if err == nil && job == nil {
job, err = s.Repo.FindLatestByTaskID(taskID)
}
}
if err != nil || job == nil {
return job, err
}
if !s.Access.CanRead(job, caller) {
return nil, Forbidden("无权查看该会商作业")
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) Cancel(id string, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanCancel(job, caller) {
return nil, Forbidden("无权取消该会商作业(仅任务责任人/创建人/管理员)")
}
if domain.IsTerminal(job.Status) {
return s.Access.ProjectForCaller(job, caller), nil
}
_, _ = s.Repo.MarkCancel(id)
job.Status = domain.StatusCancelled
job.Phase = "cancelled"
job.Error = "cancelled by client"
p := job.EnsureProgress()
p["message"] = "cancelled"
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
return s.Access.ProjectForCaller(job, caller), nil
}
func (s *JobService) Resume(id string, body *ResumeJobRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanMutate(job, caller) {
return nil, Forbidden("无权续跑该会商作业")
}
if job.Status != domain.StatusAwaitingInput {
return nil, BadState("当前状态不是等待输入,无法 resume(status=" + job.Status + ")")
}
answer := ""
if body != nil {
answer = strings.TrimSpace(body.Answer)
}
plan := job.EnsurePlan()
if answer != "" {
appendUserDialogue(job, answer)
prev := asStr(plan["pendingInject"])
merged := answer
if strings.TrimSpace(prev) != "" {
merged = prev + "\n\n" + answer
}
plan["pendingInject"] = "用户对澄清的回复:\n" + merged
}
plan["awaitReason"] = nil
job.PendingClarification = ""
job.Status = domain.StatusQueued
job.Phase = "resume_queued"
p := job.EnsureProgress()
p["message"] = "已收到补充,准备继续"
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
fresh, err := s.Repo.FindByID(id)
if err != nil {
return nil, err
}
return s.Access.ProjectForCaller(fresh, caller), nil
}
func (s *JobService) PatchPlan(id string, body *PatchJobPlanRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanMutate(job, caller) {
return nil, Forbidden("无权修改该会商作业设置")
}
if domain.IsTerminal(job.Status) {
return nil, BadState("作业已结束,无法修改设置")
}
if body == nil {
return s.Access.ProjectForCaller(job, caller), nil
}
touched := false
plan := job.EnsurePlan()
progress := job.EnsureProgress()
if body.Model != nil {
model := strings.TrimSpace(*body.Model)
job.Model = model
if model == "" {
progress["message"] = "已清除模型指定(用默认)"
} else {
progress["message"] = "已切换模型为 " + model
}
touched = true
}
if body.ManualIntervention != nil {
manual := *body.ManualIntervention
plan["manualIntervention"] = manual
if manual {
progress["message"] = "已开启人工干预(下一席结束后将等待确认)"
} else {
progress["message"] = "已关闭人工干预(将自动连跑)"
}
touched = true
atGate := job.Status == domain.StatusAwaitingInput && asStr(plan["awaitReason"]) == "manual_continue"
if !manual && atGate {
plan["awaitReason"] = nil
job.PendingClarification = ""
job.Status = domain.StatusQueued
job.Phase = "resume_queued"
progress["message"] = "已关闭人工干预,自动继续下一席"
}
}
if !touched {
return s.Access.ProjectForCaller(job, caller), nil
}
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
fresh, err := s.Repo.FindByID(id)
if err != nil {
return nil, err
}
return s.Access.ProjectForCaller(fresh, caller), nil
}
func (s *JobService) Inject(id string, body *InjectJobRequest, caller Caller) (*domain.AnalysisJob, error) {
job, err := s.Repo.FindByID(id)
if err != nil || job == nil {
return job, err
}
if !s.Access.CanMutate(job, caller) {
return nil, Forbidden("无权向该会商注入消息")
}
text := ""
if body != nil {
text = strings.TrimSpace(body.Text)
}
if text == "" {
return nil, BadArg("注入内容不能为空")
}
if domain.IsTerminal(job.Status) {
return nil, BadState("作业已结束,无法注入")
}
appendUserDialogue(job, text)
plan := job.EnsurePlan()
prev := asStr(plan["pendingInject"])
merged := text
if strings.TrimSpace(prev) != "" {
merged = prev + "\n\n" + text
}
plan["pendingInject"] = "用户向会商插入的补充说明:\n" + merged
p := job.EnsureProgress()
p["message"] = "已收到用户补充,将在下一轮派活时带入"
if err := s.Repo.UpdateProgress(job); err != nil {
return nil, err
}
fresh, err := s.Repo.FindByID(id)
if err != nil {
return nil, err
}
return s.Access.ProjectForCaller(fresh, caller), nil
}
func appendUserDialogue(job *domain.AnalysisJob, text string) {
list := job.EnsureDialogues()
bubble := map[string]interface{}{
"id": "dlg_" + job.ID + "_user_" + strconv.Itoa(len(list)+1),
"role": "user",
"sender": "我",
"content": text,
"status": "done",
"phaseLabel": "",
}
job.Dialogues = append(list, bubble)
}
func normalizeAgents(agents []map[string]interface{}) []map[string]interface{} {
if len(agents) > 0 {
out := make([]map[string]interface{}, 0, len(agents))
for _, a := range agents {
copy := cloneMap(a)
if _, ok := copy["id"]; !ok {
if aid, ok := copy["agentId"]; ok {
copy["id"] = aid
}
}
if _, ok := copy["status"]; !ok {
copy["status"] = "idle"
}
out = append(out, copy)
}
return out
}
return []map[string]interface{}{
{"id": "local-flow-stub-intel", "name": "联调桩-情报采集", "status": "idle"},
{"id": "local-flow-stub-plan", "name": "联调桩-筹划规划", "status": "idle"},
}
}
func blankToNull(s string) string {
return strings.TrimSpace(s)
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func orEmptyMap(m map[string]interface{}) map[string]interface{} {
if m == nil {
return map[string]interface{}{}
}
return m
}
func randHex(nBytes int) string {
b := make([]byte, nBytes)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+623
View File
@@ -0,0 +1,623 @@
package worker
import (
"fmt"
"log"
"strings"
"sync"
"time"
"unicode/utf8"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// LiveChainRunner 真模型按链条顺序执行(被 LiveWorker 异步调用)。
type LiveChainRunner struct {
DF *deerflow.Client
Repo *repo.JobRepo
Coord string
mu sync.Mutex
lastFlush time.Time
}
func (r *LiveChainRunner) Run(job *domain.AnalysisJob) {
jobID := job.ID
defer func() {
if rec := recover(); rec != nil {
log.Printf("真模型会商 panic jobId=%s: %v", jobID, rec)
r.fail(job, fmt.Sprint(rec))
}
}()
agents := job.Agents
if len(agents) < 2 {
r.fail(job, "有效席位不足 2 个,无法启动真模型会商")
return
}
var agentNames []string
for _, a := range agents {
id := asStr(a["id"])
if id != "" {
agentNames = append(agentNames, id)
}
}
if len(agentNames) < 2 {
r.fail(job, "席位 agentId 无效(需要 DeerFlow 的 hyphen-case id)")
return
}
plan := ensurePlan(job)
r.pullModelFromDB(job)
if err := r.DF.AssertModelAvailable(job.Model); err != nil {
r.fail(job, err.Error())
return
}
job.Status = domain.StatusRunning
job.Phase = "running"
r.flush(job)
if r.cancelled(jobID) {
r.markCancelled(job)
return
}
threadIDs := loadThreadIDs(job)
if len(threadIDs) == 0 {
putProgress(job, 2, "正在初始化 DeerFlow 席位线程…")
r.flush(job)
r.pullModelFromDB(job)
tids, err := r.DF.InitMultiAgent(agentNames, r.Coord, job.Model)
if err != nil {
r.fail(job, err.Error())
return
}
threadIDs = tids
store := map[string]interface{}{}
for k, v := range tids {
store[k] = v
}
job.ThreadIDs = store
putProgress(job, 8, "席位线程已就绪,开始链条研讨")
r.flush(job)
} else {
pct := toInt(job.Progress["percent"], 8)
if pct < 8 {
pct = 8
}
putProgress(job, pct, "从检查点继续会商")
r.flush(job)
}
seed := asStr(plan["seedMessage"])
if seed == "" {
seed = BuildSeed(job.Progress)
plan["seedMessage"] = seed
}
if inj := consumePendingInject(plan); inj != "" {
seed = seed + "\n\n" + inj
plan["seedMessage"] = seed
}
total := len(agentNames)
start := toInt(plan["seatIndex"], 0)
if start < 0 {
start = 0
}
seatDeliveries := loadDeliveries(job)
lastLeader := ""
for i := start; i < total; i++ {
if r.cancelled(jobID) {
r.markCancelled(job)
return
}
r.pullManualFromDB(job, plan)
r.pullInjectFromDB(job, plan)
r.pullModelFromDB(job)
model := job.Model
if mid := consumePendingInject(plan); mid != "" {
seed = seed + "\n\n" + mid
plan["seedMessage"] = seed
}
plan["seatIndex"] = i
job.Plan = plan
seat := agents[i]
seatID := asStr(seat["id"])
seatName := firstNonEmpty(asStr(seat["name"]), seatID)
job.ActiveAgentID = r.Coord
job.Cycle = i + 1
applyAgentSpeaking(agents, seatID, false)
job.Agents = agents
leaderBubbleID := openBubble(job, "leader", "总控协调", r.Coord, "链条派活中")
r.flush(job)
leaderRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调",
BuildLeaderTask(seed, i, total, seatID, seatName, i == 0),
model, false,
r.deltaHandler(job, leaderBubbleID),
func() bool { return r.cancelled(jobID) },
)
if err != nil {
finishBubble(job, leaderBubbleID, err.Error(), "error")
r.fail(job, buildModelHint("总控派活失败", model, err.Error()))
return
}
if r.cancelled(jobID) || leaderRes.Aborted {
finishBubble(job, leaderBubbleID, leaderRes.Content, "stopped")
r.markCancelled(job)
return
}
if leaderRes.Clarification {
finishBubble(job, leaderBubbleID, leaderRes.Content, "clarification")
detail := leaderRes.ClarificationDetail
if detail == "" {
detail = leaderRes.Content
}
r.pauseForInput(job, plan, i, "clarification", detail)
return
}
leaderBad := leaderRes.InvalidDelivery || strings.TrimSpace(leaderRes.Content) == "" || looksLikeModelFailure(leaderRes.Content)
if leaderBad {
finishBubble(job, leaderBubbleID, leaderRes.Content, "error")
r.fail(job, buildModelHint("总控派活失败", model, leaderRes.Content))
return
}
finishBubble(job, leaderBubbleID, leaderRes.Content, "done")
lastLeader = leaderRes.Content
r.flush(job)
task := PickChainTask(leaderRes.Dispatched, leaderRes.Content, seatID, seatName)
job.ActiveAgentID = seatID
applyAgentSpeaking(agents, seatID, true)
job.Agents = agents
seatBubbleID := openBubble(job, "special", seatName, seatID, "席位输出中…")
r.flush(job)
seatRes, err := r.DF.RunStream("special", threadIDs, seatID, seatName, task, model, false,
r.deltaHandler(job, seatBubbleID),
func() bool { return r.cancelled(jobID) },
)
if err != nil {
finishBubble(job, seatBubbleID, err.Error(), "error")
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, err.Error()))
return
}
if r.cancelled(jobID) || seatRes.Aborted {
finishBubble(job, seatBubbleID, seatRes.Content, "stopped")
r.markCancelled(job)
return
}
bad := seatRes.InvalidDelivery || strings.TrimSpace(seatRes.Content) == "" || looksLikeModelFailure(seatRes.Content)
status := "done"
if bad {
status = "error"
}
finishBubble(job, seatBubbleID, seatRes.Content, status)
if bad {
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, seatRes.Content))
return
}
seatDeliveries = append(seatDeliveries, map[string]interface{}{
"sender": seatName,
"agentId": seatID,
"content": seatRes.Content,
})
saveDeliveries(job, seatDeliveries)
pct := (i + 1) * 85 / total
if pct > 90 {
pct = 90
}
job.ConsensusPercentage = pct
putProgress(job, pct, fmt.Sprintf("已完成 %d/%d 席", i+1, total))
plan["seatIndex"] = i + 1
job.Plan = plan
r.flush(job)
r.pullManualFromDB(job, plan)
if readManual(plan) && i+1 < total {
r.pauseForInput(job, plan, i+1, "manual_continue",
fmt.Sprintf("人工干预:第 %d/%d 席已完成,请确认后继续下一席。", i+1, total))
return
}
}
if r.cancelled(jobID) {
r.markCancelled(job)
return
}
job.ActiveAgentID = r.Coord
r.pullModelFromDB(job)
closeModel := job.Model
closeID := openBubble(job, "leader", "总控协调", r.Coord, "全案盖印")
r.flush(job)
closeRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调", Closeout, closeModel, true,
r.deltaHandler(job, closeID),
func() bool { return r.cancelled(jobID) },
)
if err != nil {
finishBubble(job, closeID, err.Error(), "error")
r.fail(job, buildModelHint("全案收口失败", closeModel, err.Error()))
return
}
if r.cancelled(jobID) || closeRes.Aborted {
finishBubble(job, closeID, closeRes.Content, "stopped")
r.markCancelled(job)
return
}
if closeRes.InvalidDelivery || looksLikeModelFailure(closeRes.Content) ||
(strings.TrimSpace(closeRes.Content) == "" && strings.TrimSpace(lastLeader) == "") {
finishBubble(job, closeID, closeRes.Content, "error")
r.fail(job, buildModelHint("全案收口失败", closeModel, closeRes.Content))
return
}
if len(seatDeliveries) < total {
finishBubble(job, closeID, closeRes.Content, "error")
r.fail(job, fmt.Sprintf("会商未完整交付(成功 %d/%d 席),请检查模型后重新开始。", len(seatDeliveries), total))
return
}
if dialoguesHaveError(job) {
finishBubble(job, closeID, closeRes.Content, "error")
r.fail(job, "会商过程中已有失败发言,不能进入收口/下一阶段。请换可用模型后重新开始。")
return
}
summary := closeRes.Content
if strings.TrimSpace(summary) == "" {
summary = lastLeader
}
finishBubble(job, closeID, summary, "done")
for _, a := range agents {
a["status"] = "done"
}
job.Agents = agents
job.ActiveAgentID = ""
job.Status = domain.StatusDone
job.Phase = "done"
job.ConsensusPercentage = 100
putProgress(job, 100, "completed")
job.Result = map[string]interface{}{
"summary": summary,
"consensusPercentage": 100,
"seatDeliveries": seatDeliveries,
}
plan["awaitReason"] = nil
job.Plan = plan
r.flush(job)
log.Printf("真模型会商完成 jobId=%s", jobID)
}
func (r *LiveChainRunner) pauseForInput(job *domain.AnalysisJob, plan map[string]interface{}, next int, reason, detail string) {
plan["seatIndex"] = next
plan["awaitReason"] = reason
job.Plan = plan
job.Status = domain.StatusAwaitingInput
job.Phase = reason
job.PendingClarification = detail
msg := "等待用户澄清"
if reason == "manual_continue" {
msg = "等待人工确认继续"
}
putProgress(job, job.ConsensusPercentage, msg)
r.flush(job)
log.Printf("作业暂停等待输入 jobId=%s reason=%s", job.ID, reason)
}
func (r *LiveChainRunner) deltaHandler(job *domain.AnalysisJob, bubbleID string) deerflow.TextDeltaHandler {
return func(delta string) error {
appendBubble(job, bubbleID, delta)
r.throttleFlush(job)
return nil
}
}
func (r *LiveChainRunner) throttleFlush(job *domain.AnalysisJob) {
r.mu.Lock()
defer r.mu.Unlock()
if time.Since(r.lastFlush) >= 150*time.Millisecond {
r.flushLocked(job)
}
}
func (r *LiveChainRunner) flush(job *domain.AnalysisJob) {
r.mu.Lock()
defer r.mu.Unlock()
r.flushLocked(job)
}
func (r *LiveChainRunner) flushLocked(job *domain.AnalysisJob) {
plan := ensurePlan(job)
r.pullManualFromDB(job, plan)
r.pullModelFromDB(job)
injectConsumed := plan["_injectConsumed"] == true
if !injectConsumed {
if strings.TrimSpace(asStr(plan["pendingInject"])) == "" {
r.pullInjectFromDB(job, plan)
}
}
delete(plan, "_injectConsumed")
job.Plan = plan
_ = r.Repo.UpdateProgress(job)
r.lastFlush = time.Now()
}
func (r *LiveChainRunner) cancelled(jobID string) bool {
ok, err := r.Repo.IsCancelFlag(jobID)
return err == nil && ok
}
func (r *LiveChainRunner) markCancelled(job *domain.AnalysisJob) {
job.Status = domain.StatusCancelled
job.Phase = "cancelled"
job.Error = "cancelled"
putProgress(job, job.ConsensusPercentage, "cancelled")
r.flush(job)
}
func (r *LiveChainRunner) fail(job *domain.AnalysisJob, message string) {
job.Status = domain.StatusError
job.Phase = "error"
job.Error = message
job.ConsensusPercentage = 0
if job.Result != nil {
delete(job.Result, "summary")
job.Result["consensusPercentage"] = 0
job.Result["failed"] = true
}
putProgress(job, 0, "error")
r.flush(job)
}
func (r *LiveChainRunner) pullModelFromDB(job *domain.AnalysisJob) {
fresh, err := r.Repo.FindByID(job.ID)
if err != nil || fresh == nil {
return
}
job.Model = fresh.Model
}
func (r *LiveChainRunner) pullManualFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
fresh, err := r.Repo.FindByID(job.ID)
if err != nil || fresh == nil || fresh.Plan == nil {
return
}
if v, ok := fresh.Plan["manualIntervention"]; ok {
plan["manualIntervention"] = v
}
}
func (r *LiveChainRunner) pullInjectFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
fresh, err := r.Repo.FindByID(job.ID)
if err != nil || fresh == nil || fresh.Plan == nil {
return
}
dbText := strings.TrimSpace(asStr(fresh.Plan["pendingInject"]))
if dbText == "" {
return
}
local := strings.TrimSpace(asStr(plan["pendingInject"]))
if local == "" {
plan["pendingInject"] = dbText
} else if !strings.Contains(local, dbText) {
plan["pendingInject"] = local + "\n\n" + dbText
}
}
func consumePendingInject(plan map[string]interface{}) string {
s := strings.TrimSpace(asStr(plan["pendingInject"]))
plan["pendingInject"] = nil
plan["_injectConsumed"] = true
return s
}
func ensurePlan(job *domain.AnalysisJob) map[string]interface{} {
p := job.EnsurePlan()
if _, ok := p["seatIndex"]; !ok {
p["seatIndex"] = 0
}
if _, ok := p["manualIntervention"]; !ok {
p["manualIntervention"] = true
}
return p
}
func putProgress(job *domain.AnalysisJob, percent int, message string) {
p := job.EnsureProgress()
p["percent"] = percent
p["message"] = message
}
func loadThreadIDs(job *domain.AnalysisJob) map[string]string {
out := map[string]string{}
for k, v := range job.ThreadIDs {
if v != nil {
out[k] = fmt.Sprint(v)
}
}
return out
}
func loadDeliveries(job *domain.AnalysisJob) []map[string]interface{} {
if job.Result == nil {
return nil
}
raw, ok := job.Result["seatDeliveries"]
if !ok {
return nil
}
switch t := raw.(type) {
case []map[string]interface{}:
return t
case []interface{}:
var out []map[string]interface{}
for _, o := range t {
if m, ok := o.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
default:
return nil
}
}
func saveDeliveries(job *domain.AnalysisJob, list []map[string]interface{}) {
if job.Result == nil {
job.Result = map[string]interface{}{}
}
job.Result["seatDeliveries"] = list
}
func readManual(plan map[string]interface{}) bool {
if plan == nil {
return true
}
v, ok := plan["manualIntervention"]
if !ok || v == nil {
return true
}
switch t := v.(type) {
case bool:
return t
default:
return !strings.EqualFold(strings.TrimSpace(fmt.Sprint(t)), "false")
}
}
func openBubble(job *domain.AnalysisJob, role, sender, agentID, phase string) string {
list := job.EnsureDialogues()
id := fmt.Sprintf("dlg_%s_%d", job.ID, len(list)+1)
job.Dialogues = append(list, map[string]interface{}{
"id": id, "role": role, "sender": sender, "agentId": agentID,
"content": "", "status": "streaming", "phaseLabel": phase,
})
return id
}
func appendBubble(job *domain.AnalysisJob, bubbleID, delta string) {
if delta == "" {
return
}
for _, b := range job.Dialogues {
if asStr(b["id"]) == bubbleID {
b["content"] = asStr(b["content"]) + delta
return
}
}
}
func finishBubble(job *domain.AnalysisJob, bubbleID, finalContent, status string) {
for _, b := range job.Dialogues {
if asStr(b["id"]) != bubbleID {
continue
}
if strings.TrimSpace(finalContent) != "" {
prev := asStr(b["content"])
b["content"] = mergeKeepThinking(prev, finalContent)
}
if status == "" {
status = "done"
}
b["status"] = status
b["phaseLabel"] = ""
return
}
}
func mergeKeepThinking(streamed, finalContent string) string {
if strings.Contains(finalContent, "<think>") || strings.Contains(finalContent, "</think>") {
return finalContent
}
open := strings.Index(streamed, "<think>")
if open < 0 {
return finalContent
}
close := strings.LastIndex(streamed, "</think>")
if close >= open {
return streamed[:close+len("</think>")] + "\n" + finalContent
}
return streamed + "</think>\n" + finalContent
}
func applyAgentSpeaking(agents []map[string]interface{}, speakingID string, seatTurn bool) {
for _, a := range agents {
id := asStr(a["id"])
if seatTurn && id == speakingID {
a["status"] = "speaking"
} else {
a["status"] = "listening"
}
}
}
func dialoguesHaveError(job *domain.AnalysisJob) bool {
for _, b := range job.Dialogues {
if strings.EqualFold(asStr(b["status"]), "error") {
return true
}
}
return false
}
func looksLikeModelFailure(content string) bool {
t := strings.TrimSpace(content)
if t == "" {
return false
}
lower := strings.ToLower(t)
needles := []string{"model_not_found", "invalid model", "does not exist", "no such model",
"litellm", "aperror", "apierror", "authenticationerror", "rate_limit", "context_length"}
for _, n := range needles {
if strings.Contains(lower, n) {
return true
}
}
cn := []string{"大模型本次调用未能成功完成", "请稍后再试或更换模型", "大模型因鉴权失败",
"大模型服务暂时繁忙", "大模型响应暂时中断", "大模型当前响应异常", "已被熔断保护",
"已拒绝自动切换", "模型不存在", "无可用模型", "模型不可用", "鉴权失败"}
for _, n := range cn {
if strings.Contains(t, n) {
return true
}
}
if utf8.RuneCountInString(t) < 400 && len(t) >= 5 && t[0] == '[' && t[4] == ']' {
return true
}
return false
}
func buildModelHint(head, model, detail string) string {
sb := head
if strings.TrimSpace(model) != "" {
sb += "(当前模型:" + strings.TrimSpace(model) + ")"
}
sb += "。请换可用模型后重新开始会商。"
d := strings.TrimSpace(detail)
if d != "" {
if utf8.RuneCountInString(d) > 180 {
rs := []rune(d)
d = string(rs[:180]) + "…"
}
sb += " 详情:" + d
}
return sb
}
func toInt(v interface{}, def int) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
var n int
_, err := fmt.Sscanf(t, "%d", &n)
if err == nil {
return n
}
}
return def
}
+64
View File
@@ -0,0 +1,64 @@
package worker
import (
"log"
"sync/atomic"
"time"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
)
// LiveWorker 定时扫 queued,最多并发 2 场交给 LiveChainRunner。
type LiveWorker struct {
Repo *repo.JobRepo
Runner *LiveChainRunner
PollEvery time.Duration
inflight int32
}
func (w *LiveWorker) Start() {
if w.PollEvery <= 0 {
w.PollEvery = time.Second
}
go func() {
t := time.NewTicker(w.PollEvery)
defer t.Stop()
for range t.C {
w.poll()
}
}()
}
func (w *LiveWorker) poll() {
if atomic.LoadInt32(&w.inflight) >= 2 {
return
}
queued, err := w.Repo.FindQueuedJobs()
if err != nil {
log.Printf("LiveWorker 扫队列失败: %v", err)
return
}
for _, job := range queued {
if atomic.LoadInt32(&w.inflight) >= 2 {
break
}
job.Status = domain.StatusRunning
job.Phase = "claimed"
if err := w.Repo.UpdateProgress(job); err != nil {
log.Printf("LiveWorker 占坑失败 jobId=%s: %v", job.ID, err)
continue
}
atomic.AddInt32(&w.inflight, 1)
jobID := job.ID
go func() {
defer atomic.AddInt32(&w.inflight, -1)
fresh, err := w.Repo.FindByID(jobID)
if err != nil || fresh == nil {
return
}
log.Printf("开始真模型会商 jobId=%s", jobID)
w.Runner.Run(fresh)
}()
}
}
+119
View File
@@ -0,0 +1,119 @@
package worker
import (
"fmt"
"strings"
)
// 链条提示词(与 Java ChainPrompts / 前端历史行为对齐)。
const Lang = "【语言要求】你的全部可见输出(派活说明、任务描述、分析、结论)必须使用简体中文;" +
"禁止用英文句子作答。agent_name / 技能名等标识符可保持原文,正文一律中文。"
const Closeout = "各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。\n" + Lang
func BuildSeed(progress map[string]interface{}) string {
if progress == nil {
return "请基于用户任务意图,调度子智能体完成研讨。"
}
var sb strings.Builder
title := asStr(progress["title"])
desc := asStr(progress["description"])
if payload, ok := progress["payload"].(map[string]interface{}); ok {
if intent, ok := payload["intent"].(map[string]interface{}); ok {
obj := firstNonEmpty(asStr(intent["objective"]), asStr(intent["summary"]), title)
if obj != "" {
sb.WriteString(obj)
}
if list, ok := intent["constraints"].([]interface{}); ok && len(list) > 0 {
sb.WriteString("\n\n约束条件:\n")
for _, c := range list {
sb.WriteString("- ")
sb.WriteString(fmt.Sprint(c))
sb.WriteByte('\n')
}
}
if list, ok := intent["assumptions"].([]interface{}); ok && len(list) > 0 {
sb.WriteString("\n关键假设:\n")
for _, a := range list {
sb.WriteString("- ")
sb.WriteString(fmt.Sprint(a))
sb.WriteByte('\n')
}
}
}
}
if sb.Len() == 0 {
sb.WriteString(title)
if desc != "" {
if sb.Len() > 0 {
sb.WriteByte('\n')
}
sb.WriteString(desc)
}
}
if sb.Len() == 0 {
return "请基于用户此前澄清的任务意图,调度子智能体完成研讨。"
}
sb.WriteString("\n\n用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。")
return sb.String()
}
func BuildLeaderTask(seed string, position, total int, seatAgentID, seatName string, isFirst bool) string {
ordinal := fmt.Sprintf("%d/%d", position+1, total)
if isFirst {
return "这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:\n" +
seed + "\n\n" +
"本次研讨严格按预设链条顺序进行,共 " + fmt.Sprint(total) + " 个席位,现在是第 " + ordinal + " 个席位。\n" +
"请你作为总控,通过 agent_orchestration 技能**仅**向智能体「" + seatName +
"」(agent_name: " + seatAgentID + ")派活,结合任务背景为它分配聚焦其职责的本轮任务。\n" +
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。\n" +
"派活说明与交给该席位的任务正文必须是简体中文(可写成:本轮派活给「" +
seatName + "」……),不要输出英文派活句。\n" + Lang
}
return "链条研讨继续,现在轮到第 " + ordinal + " 个席位。\n" +
"请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「" +
seatName + "」(agent_name: " + seatAgentID +
")派活,为它分配承接前序结论、聚焦其职责的本轮任务。\n" +
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。\n" +
"派活说明与任务正文必须是简体中文。\n" + Lang
}
func PickChainTask(dispatched [][2]string, leaderContent, seatAgentID, seatName string) string {
wanted := strings.ToLower(seatAgentID)
for _, row := range dispatched {
if strings.ToLower(row[0]) == wanted && strings.TrimSpace(row[1]) != "" {
return withLang(row[1])
}
}
for _, row := range dispatched {
if strings.TrimSpace(row[1]) != "" {
return withLang(row[1])
}
}
if strings.TrimSpace(leaderContent) != "" {
return withLang("请你作为「" + seatName + "」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n" + strings.TrimSpace(leaderContent))
}
return withLang("请你作为「" + seatName + "」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。")
}
func withLang(body string) string { return Lang + "\n" + body }
func asStr(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return strings.TrimSpace(fmt.Sprint(v))
}
func firstNonEmpty(parts ...string) string {
for _, p := range parts {
if strings.TrimSpace(p) != "" {
return strings.TrimSpace(p)
}
}
return ""
}
+25
View File
@@ -0,0 +1,25 @@
package worker
import (
"strings"
"testing"
)
func TestBuildSeedAndPickTask(t *testing.T) {
seed := BuildSeed(map[string]interface{}{
"title": "测试任务",
"payload": map[string]interface{}{
"intent": map[string]interface{}{
"objective": "完成联调",
"constraints": []interface{}{"必须中文"},
},
},
})
if !strings.Contains(seed, "完成联调") {
t.Fatalf("seed 异常: %s", seed)
}
task := PickChainTask([][2]string{{"agent-x", "请分析风险"}}, "总控全文", "agent-x", "风险岗")
if !strings.Contains(task, "请分析风险") || !strings.Contains(task, "简体中文") {
t.Fatalf("pick 任务异常: %s", task)
}
}
+35
View File
@@ -0,0 +1,35 @@
-- 会商作业主表:一条记录 = 一次后台会商
-- 本机 / 正式默认走 MySQL(utf8mb4);H2 profile 仍可跑同一份(LONGTEXT≈CLOB)
CREATE TABLE IF NOT EXISTS eba_analysis_job (
id VARCHAR(64) PRIMARY KEY,
request_id VARCHAR(64),
task_id VARCHAR(128),
draft_id VARCHAR(64),
draft_key VARCHAR(255),
status VARCHAR(32) NOT NULL,
phase VARCHAR(32),
cycle INT DEFAULT 0,
active_agent_id VARCHAR(128),
consensus_percentage INT DEFAULT 0,
pending_clarification LONGTEXT,
orchestration_mode VARCHAR(32),
model VARCHAR(128),
error_message LONGTEXT,
-- 创建人账号;岗位限制与检查点见 access_json / plan_json
owner_account_id VARCHAR(128),
agents_json LONGTEXT,
chain_json LONGTEXT, -- dialogues 气泡列表
plan_json LONGTEXT, -- 检查点:seatIndex、manualIntervention、pendingInject 等
access_json LONGTEXT, -- 权限:taskOwnerAccountIds、seatAccounts、adminAccountIds
thread_ids_json LONGTEXT,
progress_json LONGTEXT,
result_json LONGTEXT,
cancel_flag TINYINT DEFAULT 0,
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- MySQL 无「IF NOT EXISTS」索引时,重复执行靠 continue-on-error 忽略
CREATE INDEX idx_eba_job_task ON eba_analysis_job(task_id);
CREATE INDEX idx_eba_job_status ON eba_analysis_job(status);
CREATE INDEX idx_eba_job_owner ON eba_analysis_job(owner_account_id);
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# 对本机已启动的 Go 服务做 demo 冒烟(默认 :18097)。
# 注意:勿与 Java 实例同时抢同一库的 queued。
set -euo pipefail
BASE="${BASE:-http://127.0.0.1:18097/api/v1/analysis}"
echo "== health =="
curl -sS "$BASE/health" | tee /tmp/eba-smoke-health.json
echo
CREATE=$(curl -sS -X POST "$BASE/jobs" -H 'Content-Type: application/json' \
-d '{"taskId":"smoke-'"$(date +%s)"'","title":"smoke","manualIntervention":true,"agents":[{"id":"a1","name":"甲"},{"id":"a2","name":"乙"}]}')
JOB=$(echo "$CREATE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')
echo "job=$JOB"
for i in $(seq 1 40); do
J=$(curl -sS "$BASE/jobs/$JOB")
ST=$(echo "$J" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
PCT=$(echo "$J" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"].get("progress",{}).get("percent",0))')
echo "tick=$i status=$ST percent=$PCT"
[ "$ST" = "done" ] && break
sleep 0.5
done
echo OK