Github开源生信云平台 DEMO
internal/agent/event.go
AgentService.publish
Append
internal/agent/repository/event.go
internal/router/router.go:466
internal/handler/agent.go:789
repository/event.go:39
Searched for regex events|GetTaskEvents|agentEventsQuery (**/gobrave/internal/handler/**), 15 results
events|GetTaskEvents|agentEventsQuery
**/gobrave/internal/handler/**
Searched for regex /events|after= (**/gobrave/**), 50 results
/events|after=
**/gobrave/**
Read , lines 770 to 830
Searched for regex agent/task/events|after (**/brave-ui/src/**), 35 results
agent/task/events|after
**/brave-ui/src/**
Searched for regex getAgentTaskEventsApi (**/brave-ui/src/**), 9 results
getAgentTaskEventsApi
Searched for regex attachTaskStream\(|GetAgentTask\(|GetTask\b|parseAgentID (**/gobrave/internal/handler/agent.go), 20 results
attachTaskStream\(|GetAgentTask\(|GetTask\b|parseAgentID
**/gobrave/internal/handler/agent.go
Read , lines 240 to 360
Read , lines 1 to 120
Read , lines 120 to 240
Searched for regex interface AgentEventItem (**/brave-ui/src/api/agent/index.ts), 1 result
interface AgentEventItem
**/brave-ui/src/api/agent/index.ts
Read , lines 120 to 150
Read , lines 210 to 240
「原生增量回放」不是靠前端缓存或时间戳,而是事件表自带一个 per-task 单调递增的 sequence,读取端只传「我已经收到的最大 sequence」,服务端返回 sequence > after 的行。
sequence
sequence > after
type AgentEvent struct { ID int64 // 雪花 ID,永远唯一 TaskID int64 Sequence int64 // per-task 单调递增,增量游标 Type AgentEventType Payload any `gorm:"serializer:json"` CreatedAt time.Time }
GORM tag 上有关键的复合索引:
TaskID ... `gorm:"index:idx_agent_events_task_seq,priority:1"` Sequence ... `gorm:"index:idx_agent_events_task_seq,priority:2"`
即 (task_id, sequence),正好服务 WHERE task_id=? AND sequence>? ORDER BY sequence 这个查询。
(task_id, sequence)
WHERE task_id=? AND sequence>? ORDER BY sequence
e := &AgentEvent{ ID: utils.GenerateID(), // 广播事件(不落库的 delta)也必须分配 TaskID: taskID, Type: typ, CreatedAt: time.Now(), } if persist && s.events != nil { _ = s.events.Append(ctx, e) } s.bus.Publish(ctx, *e)
注意 ID 在广播前就分配了:delta 事件不落库、Sequence 恒为 0,如果 ID 也为 0,前端按 id:sequence 去重会把同一任务的全部 delta 判成重复而丢弃(代码注释里记录了这个坑)。
ID
Sequence
id:sequence
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var maxSeq int64 tx.Model(&agent.AgentEvent{}). Where("task_id = ?", event.TaskID). Select("COALESCE(MAX(sequence), 0)"). Scan(&maxSeq) event.Sequence = maxSeq + 1 return tx.Create(event).Error })
因此 sequence 是稠密、无洞、从 1 开始的。只有「需要进入时间线的事件」才走这条路(isBlockEvent 为真的块事件);delta 事件不写库,也就没有 sequence。
isBlockEvent
r.GET("/agent/task/events", handler.GetTaskEvents)
type agentEventsQuery struct { TaskID string `form:"task_id" binding:"required"` // 雪花 ID 以字符串传输 After int64 `form:"after"` // 上次收到的最大 sequence,缺省 0 } func (h *AgentHandler) GetTaskEvents(c *gin.Context) { if _, ok := getCurrentUserID(c); !ok { return } // 仅鉴权登录 c.ShouldBindQuery(&q) taskID, _ := parseAgentID(q.TaskID) // 字符串 → int64,避免 JS 精度丢失 events, err := h.svc.GetEvents(c.Request.Context(), taskID, q.After) c.JSON(http.StatusOK, events) }
r.db.WithContext(ctx). Where("task_id = ? AND sequence > ?", taskID, after). Order("sequence ASC"). Find(&events)
只有两件事:range scan on 复合索引 + 服务端排序。 没有游标编码、没有 offset 漂移、没有时间戳歧义——这就是「原生」的含义。
客户端只要保存 max(lastEvent.sequence),重连时传 after 即可,天然幂等:重复请求同一 after 返回同一集合,插入新事件也不会改变历史行的 sequence(append-only),不像 offset 分页会被并发写入挤歪。
max(lastEvent.sequence)
after
关键在「先订阅、后回放、再合并」的水位线协议:
sseClient.onMessage
hydratedRef.current === false
liveBufferRef
getAgentTaskEventsApi(taskId, 0)
put()
`${id}:${sequence}`
created_at
applyEvent
closeOpen("assistant_final")
hydratedRef = true
seenRef
这个「缓冲 + 水位线 + 统一 reducer」是漂移最少的重连写法:既不丢 WS 早期事件(已缓冲),也不重复(去重键),还不依赖事件到达顺序(排序)。
优点
坑(值得注意)
text
reasoning_delta
message
reasoning
MAX(sequence)+1
UPDATE ... RETURNING
GetTaskEvents
limit
after=0
getCurrentUserID(c)
GetEvents
GetTask
agent_events