Files
HomeAgent/internal/plugins/webui/handler_chat.go
HomeAgent Agent 642e1c39b1 refactor(webui): handler.go 2993 行按资源拆成 11 个同包文件
拆法:按「资源面」搬家,每个顶层声明(func/type/var/const)整体搬到目标文件,
声明体一字未改,各文件按实际用到的包重新生成 import。文件头加一行说明本文件负责哪一面。

  handler.go              骨架:嵌入前端资源、Handler/构造、路由表、鉴权会话日志中间件、静态页
  handler_chat.go         对话面:消息模型与内存历史、SSE 事件订阅、对话/历史接口
  handler_upload.go       上传面:handleChatFile / handleUploads / 中断对话
  handler_memory.go       记忆面:图/文档/文本记忆、知识库、LLM 源、变更追踪
  handler_agents.go       内核与代理面:状态、kernel、人格、代理/快照/回滚
  handler_settings.go     设置与插件面:配置读写、插件列表详情(含 pluginmgr 反代)
  handler_terminal.go     终端面:终端会话、终端接口、命令历史
  handler_sse.go          SSE 环形缓冲(断线重连补发)
  handler_openai.go       OpenAI 兼容面:/v1/chat/completions
  handler_device.go       设备网关反代(HTTP + WS 升级透传)
  handler_files.go        /files/ 与 /uploads/ 下载

零漂移校验:拿重构前的 handler.go 与新 11 个文件逐行比对(忽略空行、package/import 头),
**丢失行 0**;新增行恰好是 11 个文件头注释(14 行)。

顺带修掉 import 里两处假使用:handler_openai 的 sdk 只作为 Handler 字段名出现(h.sdk.),
handler_settings 的 fmt 只出现在注释里 —— 都从 import 里去掉。

验证:go build ./... / go vet / webui+config+sdk 测试全绿;
起真实实例(沿用已有 data 目录)后 /status /settings /chat/history /plugins /terminals
/kernel /memory /config /login 全部 200,设置在注入 5MB 历史的情况下仍是 33,921 字节。

最大文件从 2993 → 706 行(handler_chat.go)。
2026-09-14 07:03:47 +08:00

707 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"context"
"fmt"
"log"
"strings"
"time"
"encoding/json"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http"
)
// 对话面聊天消息模型与内存历史、SSE 事件订阅、对话与历史接口。
//
// 持久化在 history.go独立文件位置由插件设置 history_file 决定)。
type ChatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
Source string `json:"source,omitempty"`
Time string `json:"time"`
// Attachment 附件输出output_send__webui type=image/file
// image 前端内联展示file 渲染下载卡片。nil 表示纯文本消息。
Attachment *Attachment `json:"attachment,omitempty"`
}
// Attachment 描述一条附件消息(与 SSE agent_output 事件的 output_type/url/size 对应)。
type Attachment struct {
Type string `json:"type"` // "image" | "file"
URL string `json:"url"` // /files/<name> 或远程 http(s) URL
Size int64 `json:"size,omitempty"` // 字节数(远程 URL 为 0
Name string `json:"name,omitempty"` // 展示用文件名
}
type ChatToolCall struct {
Tool string `json:"tool"`
Name string `json:"name,omitempty"`
Args interface{} `json:"args,omitempty"`
Result interface{} `json:"result,omitempty"`
Status string `json:"status,omitempty"`
Plugin string `json:"plugin,omitempty"`
}
const maxChatHistory = 200
// ===== client_msg_id 去重(防 GUI 断线重连/超时重试导致的消息重放)=====
// GUI 端每条发送消息带唯一 client_msg_id服务端按 ID 单飞singleflight
// 首次请求正常注入 agent同 ID 重放等待首次结果并直接复用,不再重复处理。
const maxChatMsgCache = 256
type chatMsgEntry struct {
done chan struct{}
resp *agentIO.OutputEvent
}
func (h *Handler) claimChatMsg(id string) (*chatMsgEntry, bool) {
h.chatMsgMu.Lock()
defer h.chatMsgMu.Unlock()
if e, ok := h.chatMsgCache[id]; ok {
return e, true
}
e := &chatMsgEntry{done: make(chan struct{})}
h.chatMsgCache[id] = e
h.chatMsgOrder = append(h.chatMsgOrder, id)
if len(h.chatMsgOrder) > maxChatMsgCache {
old := h.chatMsgOrder[0]
h.chatMsgOrder = h.chatMsgOrder[1:]
delete(h.chatMsgCache, old)
}
return e, false
}
// completeChatMsg 记录首次处理结果并唤醒所有等待的同 ID 重放请求。
func (h *Handler) completeChatMsg(e *chatMsgEntry, resp *agentIO.OutputEvent) {
e.resp = resp
close(e.done)
}
func (h *Handler) loadChatHistory() {
if h.history == nil {
return
}
msgs := h.history.LoadWithMigration(h.settings)
if len(msgs) == 0 {
return
}
h.chatMu.Lock()
h.chatHistory = msgs
h.chatMu.Unlock()
}
func (h *Handler) trackToolEvents() {
if h.sdk == nil {
return
}
h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
h.handleToolEvent(ev)
})
}
// subscribeChatEvents 捕获所有通道cli/qq/webui 等)的对话轮次,
// 与 handleChat 的注入一起构成完整的全通道对话历史。
func (h *Handler) subscribeChatEvents() {
if h.sdk == nil {
return
}
h.sdk.Subscribe(sdk.EventRawInput, func(ev *sdk.Event) {
content, _ := ev.Payload["content"].(string)
source, _ := ev.Payload["source"].(string)
// 用户上传的附件handleChatFile 注入的 payload 携带 upload_* 字段)
var att *Attachment
if url, _ := ev.Payload["upload_url"].(string); url != "" {
ut, _ := ev.Payload["upload_type"].(string)
var size int64
switch v := ev.Payload["upload_size"].(type) {
case int64:
size = v
case float64:
size = int64(v)
}
name, _ := ev.Payload["upload_name"].(string)
att = &Attachment{Type: ut, URL: url, Size: size, Name: name}
}
if content == "" {
return
}
h.chatMu.Lock()
h.pendingIdx = -1
h.chatMu.Unlock()
h.addChatMsg(ChatMsg{
Role: "user",
Content: content,
Source: source,
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
Attachment: att,
})
})
h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
tool, _ := ev.Payload["tool"].(string)
if tool == "" {
return
}
channel, _ := ev.Payload["channel"].(string)
if channel == "_consolidation_" {
return
}
plugin, _ := ev.Payload["plugin"].(string)
status, _ := ev.Payload["status"].(string)
if status == "" {
status = "ok"
}
tc := ChatToolCall{
Tool: tool,
Name: tool,
Args: ev.Payload["args"],
Result: ev.Payload["result"],
Status: status,
Plugin: plugin,
}
h.chatMu.Lock()
msg := h.pendingAssistantLocked()
if msg == nil {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
h.pendingIdx = len(h.chatHistory) - 1
msg = &h.chatHistory[h.pendingIdx]
}
msg.ToolCalls = append(msg.ToolCalls, tc)
h.persistChatLocked()
h.chatMu.Unlock()
})
h.sdk.Subscribe(sdk.EventReasoning, func(ev *sdk.Event) {
content, _ := ev.Payload["content"].(string)
if content == "" {
return
}
channel, _ := ev.Payload["channel"].(string)
if channel == "_consolidation_" {
return
}
h.chatMu.Lock()
msg := h.pendingAssistantLocked()
if msg == nil {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
h.pendingIdx = len(h.chatHistory) - 1
msg = &h.chatHistory[h.pendingIdx]
}
msg.ReasoningContent += content
h.persistChatLocked()
h.chatMu.Unlock()
})
h.sdk.Subscribe(sdk.EventAgentOutput, func(ev *sdk.Event) {
content, _ := ev.Payload["content"].(string)
channel, _ := ev.Payload["channel"].(string)
kind, _ := ev.Payload["kind"].(string)
h.chatMu.Lock()
// 输出通道主动输出(output_send__{通道})作为独立气泡,不并入最终回复
if kind == "channel_output" {
h.pendingIdx = -1
// 附件输出output_type=image/file存 attachment 字段供前端渲染,
// content 保留原始 payload 作为备选文案(历史兼容旧数据)。
var att *Attachment
if ot, _ := ev.Payload["output_type"].(string); ot == "image" || ot == "file" {
url, _ := ev.Payload["url"].(string)
size, _ := ev.Payload["size"].(int64)
if f, ok := ev.Payload["size"].(float64); ok && size == 0 {
size = int64(f)
}
name := url
if i := strings.LastIndexByte(url, '/'); i >= 0 {
name = url[i+1:]
}
att = &Attachment{Type: ot, URL: url, Size: size, Name: name}
}
if content == "" && att == nil {
h.chatMu.Unlock()
return
}
m := ChatMsg{
Role: "assistant",
Source: channel,
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
Attachment: att,
}
// 附件消息不把本地路径当正文展示(如 "/tmp/homeagent.png"),置空
if att != nil {
m.Content = ""
} else {
m.Content = content
}
// 已持 chatMu直接操作 chatHistory + persist不可调 addChatMsg
//内部会重入加锁导致死锁——output_send__webui 发图 60s 超时的根因)
h.chatHistory = append(h.chatHistory, m)
if len(h.chatHistory) > maxChatHistory {
drop := len(h.chatHistory) - maxChatHistory
h.chatHistory = h.chatHistory[drop:]
if h.pendingIdx >= 0 {
h.pendingIdx -= drop
if h.pendingIdx < 0 {
h.pendingIdx = -1
}
}
}
h.persistChatLocked()
h.chatMu.Unlock()
return
}
if msg := h.pendingAssistantLocked(); msg != nil && content != "" {
msg.Content = content
if channel != "" {
msg.Source = channel
}
h.pendingIdx = -1
h.persistChatLocked()
h.chatMu.Unlock()
return
}
h.pendingIdx = -1
h.chatMu.Unlock()
if content == "" {
return
}
h.addChatMsg(ChatMsg{
Role: "assistant",
Content: content,
Source: channel,
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
})
})
}
// pendingAssistantLocked 返回 chatHistory 中当前进行中的 assistant 消息(已持有 chatMu
// 仅当最后一条是 assistant 且尚未产出最终内容时视为进行中,避免跨轮次误合并。
func (h *Handler) pendingAssistantLocked() *ChatMsg {
if h.pendingIdx < 0 || h.pendingIdx >= len(h.chatHistory) {
return nil
}
msg := &h.chatHistory[h.pendingIdx]
if msg.Role != "assistant" || msg.Content != "" {
return nil
}
return msg
}
func (h *Handler) persistChatLocked() {
if h.history == nil {
return
}
// 写独立文件(原子替换)。失败只告警:聊天记录不该影响对话主流程。
if err := h.history.Save(h.chatHistory); err != nil {
log.Printf("[webui] 写聊天记录 %s 失败: %v", h.history.Path(), err)
}
}
func (h *Handler) handleToolEvent(ev *sdk.Event) {
payload := ev.Payload
tool, _ := payload["tool"].(string)
args, _ := payload["args"].(map[string]interface{})
status, _ := payload["status"].(string)
ts := time.Now()
switch tool {
case "cmd_run":
exec := CmdExec{
Command: getStr(args, "command"),
Status: status,
Time: ts.Format(time.RFC3339),
}
h.cmdMu.Lock()
h.cmdHistory = append(h.cmdHistory, exec)
if len(h.cmdHistory) > maxCmdHistory {
h.cmdHistory = h.cmdHistory[len(h.cmdHistory)-maxCmdHistory:]
}
h.cmdMu.Unlock()
case "terminal_create":
id := getStr(args, "id")
if id == "" {
// agent 调用时不知道生成的 id从工具结果中回填
if res, ok := payload["result"].(map[string]interface{}); ok {
id = getStr(res, "id")
}
}
if id == "" {
break
}
cmd := getStr(args, "command")
if cmd == "" {
if res, ok := payload["result"].(map[string]interface{}); ok {
cmd = getStr(res, "command")
}
}
now := time.Now()
term := &termState{
ID: id,
Command: cmd,
Running: true,
CreatedAt: now.Format(time.RFC3339),
created: now,
}
h.termMu.Lock()
if old, ok := h.termStates[id]; ok {
old.Command = cmd
old.Running = true
old.created = now
} else {
h.termStates[id] = term
}
if len(h.termStates) > maxTerminals {
for k := range h.termStates {
delete(h.termStates, k)
break
}
}
h.termMu.Unlock()
case "terminal_close":
id := getStr(args, "id")
if id != "" {
h.termMu.Lock()
if t, ok := h.termStates[id]; ok {
t.Running = false
}
h.termMu.Unlock()
}
}
}
// chatSaveThrottle 控制写盘频率:最多每 3 秒写一次
const chatSaveThrottle = 3 * time.Second
func (h *Handler) addChatMsg(msg ChatMsg) {
h.chatMu.Lock()
h.chatHistory = append(h.chatHistory, msg)
if len(h.chatHistory) > maxChatHistory {
drop := len(h.chatHistory) - maxChatHistory
h.chatHistory = h.chatHistory[drop:]
if h.pendingIdx >= 0 {
h.pendingIdx -= drop
if h.pendingIdx < 0 {
h.pendingIdx = -1
}
}
}
h.persistChatLocked()
h.chatMu.Unlock()
}
// handleChatHistory 返回对话历史,支持分段懒加载。
//
// 查询参数(全部可选,省略时保持旧行为=返回全量,向后兼容旧客户端):
// - limit: 返回条数上限1..maxChatHistory。带 limit 时默认取「最新的 limit 条」。
// - before: 游标,只返回下标 < before 的消息(配合 limit 向上翻页取更早历史)。
//
// 响应额外返回 total / offset / has_more供前端判断是否继续向上加载。
// 注意:不对 tool_calls / reasoning_content 做任何裁剪——工具调用详情是排查与
// 上下文还原的关键信息,必须完整下发;瘦身只通过分页控制条数。
func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := parseIntDefault(q.Get("limit"), 0)
if limit < 0 {
limit = 0
}
if limit > maxChatHistory {
limit = maxChatHistory
}
h.chatMu.Lock()
total := len(h.chatHistory)
// before 游标:默认取到末尾(最新)
end := parseIntDefault(q.Get("before"), total)
if end < 0 || end > total {
end = total
}
start := 0
if limit > 0 && end-limit > 0 {
start = end - limit
}
result := make([]ChatMsg, end-start)
copy(result, h.chatHistory[start:end])
h.chatMu.Unlock()
writeJSON(w, http.StatusOK, map[string]interface{}{
"messages": result,
"total": total,
"offset": start,
"has_more": start > 0,
})
}
func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Message string `json:"message"`
DeviceID string `json:"device_id"` // 消息来源设备GUI/受控设备),可选
DeviceName string `json:"device_name"` // 设备显示名,可选
ClientMsgID string `json:"client_msg_id"` // 客户端唯一消息 ID防断线重放/超时重试)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if body.Message == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
return
}
if h.sdk == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
// client_msg_id 去重:同 ID 重放等待首次结果直接复用,不重复注入 agent。
// 无 ID 的旧客户端走原路径agent 核心层另有内容级短窗口去重兑底)。
var entry *chatMsgEntry
if body.ClientMsgID != "" {
var replay bool
entry, replay = h.claimChatMsg(body.ClientMsgID)
if replay {
log.Printf("[webui] duplicate chat msg %s: waiting for first request result", body.ClientMsgID)
select {
case <-entry.done:
resp := entry.resp
if resp == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
content, _ := resp.Payload["content"].(string)
reasoning, _ := resp.Payload["reasoning_content"].(string)
result := map[string]interface{}{"response": content, "deduplicated": true}
if reasoning != "" {
result["reasoning_content"] = reasoning
}
writeJSON(w, http.StatusOK, result)
case <-r.Context().Done():
return
}
return
}
}
// 来源编码:带设备身份时用 webui/{device_id}agent 经 injectSourceContext 可见来源);
// 无设备时保持 webui兼容旧调用。device_name 一并注入便于 agent 识别。
source := "webui"
if body.DeviceID != "" {
source = "webui/" + body.DeviceID
}
payload := map[string]interface{}{"content": body.Message}
if body.DeviceID != "" {
payload["device_id"] = body.DeviceID
payload["device_name"] = body.DeviceName
}
if body.ClientMsgID != "" {
payload["client_msg_id"] = body.ClientMsgID
}
// 带超时的上下文,防止 InjectInputSync 长时间阻塞 HTTP 请求。
// 注意ctx 派生自 r.Context(),客户端提前断开(前端 15s ackTimer abort
// 立即取消,不会真等满 300s300s 只约束"连接保持 + agent 排队/长生成"场景
// agent 串行处理后发消息的排队时间也计入60s 曾导致连发第 3 条必超时)。
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
defer cancel()
respCh := make(chan *agentIO.OutputEvent, 1)
go func() {
respCh <- h.sdk.InjectInputSync(source, "webui", "text", payload)
}()
var resp *agentIO.OutputEvent
select {
case resp = <-respCh:
case <-ctx.Done():
if entry != nil {
h.completeChatMsg(entry, nil)
}
writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (60s)"})
return
}
if entry != nil {
h.completeChatMsg(entry, resp)
}
if resp == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
content, _ := resp.Payload["content"].(string)
reasoning, _ := resp.Payload["reasoning_content"].(string)
result := map[string]interface{}{
"response": content,
}
if reasoning != "" {
result["reasoning_content"] = reasoning
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
done := r.Context().Done()
if h.sdk == nil {
fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"event bus unavailable\"}\n\n")
flusher.Flush()
return
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
// writeCh 不 closeSubscribe 回调闭包持有它handler 退出后回调仍可能被
// 总线异步触发close 后再发送会 panicsend on closed channel生产日志中
// 单日数千次。writer goroutine 通过 done 退出;发送侧 select on done 防泄漏。
// 缓冲 2048reasoning/content 增量是高频小包LLM token 级),
// 512 时连续 tool_call + reasoning + delta 密集期会溢出导致前端丢帧。
// 写入侧用短超时(50ms)兜底,比立即丢弃更友好。
writeCh := make(chan string, 2048)
writerDone := make(chan struct{})
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[SSE] writer panic: %v", r)
}
close(writerDone)
}()
// 批量合并窗口16ms 内收集的增量一次性 flush降 flush 次数、
// 避免高频小包拖慢 socket 写导致 writeCh 积压丢 delta。
pending := make([]string, 0, 64)
flushPending := func() {
if len(pending) == 0 {
return
}
for _, line := range pending {
fmt.Fprintf(w, "%s\n", line)
}
flusher.Flush()
pending = pending[:0]
}
flushTicker := time.NewTicker(16 * time.Millisecond)
defer flushTicker.Stop()
for {
select {
case line := <-writeCh:
pending = append(pending, line)
// 大批量一次性 flush阈值从 64 提高,利用批量减少 syscall 开销
if len(pending) >= 128 {
flushPending()
}
case <-flushTicker.C:
flushPending()
case <-done:
flushPending()
return
}
}
}()
log.Printf("[SSE] handler started, subscribing to events")
// 解析 Last-Event-ID断线重连时客户端携带重放期间内遗漏的事件。
// 注意:本 handler 的 Last-Event-ID 重放仅为 GUI (cmd/gui/renderer/app.js) 服务。
// 浏览器原生 EventSource (webui/dashboard.html 使用) 由浏览器自动处理 Last-Event-ID 重连。
if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" {
log.Printf("[SSE] client reported Last-Event-ID: %s", lastEventID)
if h.sseEvents != nil {
replayed := h.sseEvents.After(lastEventID)
if len(replayed) == 0 {
log.Printf("[SSE] replay: nothing after id %s (id not in ring or already at tip)", lastEventID)
// ID 不在 ring说明最后一帧是 deltadelta 不进 ring或已到最新。
// 显式通知前端补拉历史,避免其空等后续聚合事件(表现为消息同步不及时)。
fmt.Fprintf(w, "event: sync_required\ndata: {}\n\n")
flusher.Flush()
} else {
log.Printf("[SSE] replay: sending %d events after id %s", len(replayed), lastEventID)
for _, rec := range replayed {
fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n", rec.id, rec.eventType, string(rec.data))
flusher.Flush()
}
log.Printf("[SSE] replay complete, wrote %d events", len(replayed))
}
}
}
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output"}
// token 级流式增量事件:实时转发给浏览器做逐 token 渲染。
// 不进 sseEventRing —— 断线重连只重放聚合事件(最终真相),
// 避免重放 delta 与聚合内容重复追加。
var unsubs []func()
var seq int64
appendDeltaSub := func(evtType sdk.EventType) {
unsub := h.sdk.Subscribe(evtType, func(evt *sdk.Event) {
data, _ := json.Marshal(evt)
seq++
id := fmt.Sprintf("%d-%d", evt.Timestamp, seq)
sendSSE(writeCh, id, string(evt.Type), string(data))
})
unsubs = append(unsubs, unsub)
}
appendDeltaSub(sdk.EventReasoningDelta)
appendDeltaSub(sdk.EventContentDelta)
for _, t := range subTypes {
t2 := t
unsub := h.sdk.Subscribe(sdk.EventType(t2), func(evt *sdk.Event) {
if evt.Type == sdk.EventToolCall {
toolName, _ := evt.Payload["tool"].(string)
log.Printf("[SSE] received tool_call event: tool=%s", toolName)
}
data, _ := json.Marshal(evt)
seq++
id := fmt.Sprintf("%d-%d", evt.Timestamp, seq)
// 写入环状缓冲区,供断线重连重放
if h.sseEvents != nil {
h.sseEvents.Append(id, string(evt.Type), data)
}
sendSSE(writeCh, id, string(evt.Type), string(data))
if evt.Type == sdk.EventToolCall {
toolName, _ := evt.Payload["tool"].(string)
log.Printf("[SSE] wrote tool_call to writeCh: tool=%s", toolName)
}
})
unsubs = append(unsubs, unsub)
}
defer func() {
for _, unsub := range unsubs {
unsub()
}
<-writerDone // 等 writer 退出,保证 handler 返回后无残余写入
}()
for {
select {
case <-done:
return
case <-ticker.C:
// 心跳直接写 w 并 flush绕过 writeCh事件密集/队列满时也能保活长连接,
// 避免远程 nginx 网关因长时间无字节而 504/半开)。
if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}