Files
HomeAgent/internal/plugins/webui/handler_chat.go
JianFeeeee 97111778a9 feat(webui): 数据查询 API + 前端 keyed 对账,去掉「局部重建」的闪烁
jianf:局部重建的闪烁几乎消不掉,应暴露数据查询 api,前端轮询后增量更新视图,
聊天记录也用这套。

后端(数据查询 api):
- ChatMsg 增加 seq(服务端单调递增、随记录落盘);老记录加载时补 1..n,重启不重编号。
- /api/v1/chat/history 增加 after=<seq> 增量通道:只回 seq 更大的消息,返回 last_seq
  作下次游标;一批超 limit 时回**最旧**的一批(回最新会把被挤掉的旧消息永久漏掉)。
  普通响应也带 last_seq,客户端首次全量后据此初始化游标。
- 测试 TestChatHistoryIncrementalAfterCursor 钉住「不重不漏 + 截断停在返回的最后一条」。

前端:
- 新增通用 morph():按「子节点位置 + nodeName」递归对账 DOM,同名节点复用、只同步
  变化的属性与文本。运行态面板的 put() 由 innerHTML 重建改为 morph —— SVG 圆环、
  队列条、数字块这些未变节点不再被替换,CSS 过渡与动画不再从头播。
- 聊天列表改用 keyed commitChatList():按 data-key(服务端 seq / 本地临时 key)对账,
  未变消息节点一个字节都不动,只替换真正变化的那条。
- syncChatFromHistory 改走游标:pollChatIncremental() 用 after 拿增量 + tail=1 探尾部
  原地更新(工具调用/最终文本是原地改的,不产生新 seq);聊天页可见时 3s 轮询。
2026-09-14 15:56:27 +08:00

897 lines
28 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"
"strconv"
"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"`
// Seq 是服务端分配的**单调递增**序号,作为增量查询游标
// GET /chat/history?after=<seq>)与前端列表的稳定 key。
//
// 为什么不能用下标chatHistory 有上限maxChatHistory=200超出从头丢
// 下标会整体前移 —— 拿它当游标要么重复要么漏消息。seq 只增不减,且随
// 记录一起落盘,重启后编号不重置。
Seq int64 `json:"seq"`
// 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"`
}
// defaultChatHistoryLimit 是 /chat/history 不带 limit 时默认返回的页大小。
const defaultChatHistoryLimit = 40
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
// 老记录没有 seq本字段是后加的补成 1..n 并把计数器顶到最大。
// 只补缺失的,已有序号原样保留 —— 重启不能重编号,否则客户端的 after 游标
// 会指向另一条消息。
var maxSeq int64
for i := range h.chatHistory {
if h.chatHistory[i].Seq > maxSeq {
maxSeq = h.chatHistory[i].Seq
}
}
for i := range h.chatHistory {
if h.chatHistory[i].Seq == 0 {
maxSeq++
h.chatHistory[i].Seq = maxSeq
}
}
h.chatSeq = maxSeq
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", Seq: h.bumpSeqLocked(), 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", Seq: h.bumpSeqLocked(), 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",
Seq: h.bumpSeqLocked(),
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 且尚未产出最终内容时视为进行中,避免跨轮次误合并。
// chatSaveThrottle 控制写盘频率:变更后延迟这么久落盘,合并连续更新。
const chatSaveThrottle = 3 * time.Second
// chatSaveMaxDelay 是连续写入时的强制落盘上限:聊天再密也不超过这么久。
const chatSaveMaxDelay = 10 * time.Second
// persistChatLocked 标记聊天记录待写盘(调用方已持 chatMu
//
// 真正的写盘在 chatPersistLoop 里做,并带节流:原先这里是**每条消息都整段
// 重写一次记录文件**,而一轮对话会触发多次(用户消息、每个工具事件、收尾消息)——
// 200 条上限下文件可达数 MB于是单轮就放大出几十 MB 写。
func (h *Handler) persistChatLocked() {
if h.history == nil {
return
}
if !h.chatDirty {
h.chatDirtySince = time.Now()
}
h.chatDirty = true
select {
case h.chatSaveWake <- struct{}{}:
default: // 已有待处理信号,合并即可
}
}
// chatPersistLoop 把聊天记录按节流节奏落盘,直到 Close。
func (h *Handler) chatPersistLoop() {
defer close(h.chatLoopDone)
timer := time.NewTimer(chatSaveThrottle)
timer.Stop()
defer timer.Stop()
for {
select {
case <-h.chatStop:
h.flushChat() // 关停前把最后一次变更写下去
return
case <-h.chatSaveWake:
delay := chatSaveThrottle
h.chatMu.Lock()
if !h.chatDirtySince.IsZero() {
if left := chatSaveMaxDelay - time.Since(h.chatDirtySince); left < delay {
if left < 0 {
left = 0
}
delay = left
}
}
h.chatMu.Unlock()
timer.Reset(delay)
case <-timer.C:
h.flushChat()
}
}
}
// flushChat 把当前聊天记录快照写盘。文件 IO 不持 chatMu快照拷出来再写
// 写失败则重新标脏,等下一轮重试。
func (h *Handler) flushChat() {
if h.history == nil {
return
}
h.chatMu.Lock()
if !h.chatDirty {
h.chatMu.Unlock()
return
}
h.chatDirty = false
h.chatDirtySince = time.Time{}
msgs := make([]ChatMsg, len(h.chatHistory))
copy(msgs, h.chatHistory)
h.chatMu.Unlock()
if err := h.history.Save(msgs); err != nil {
log.Printf("[webui] 写聊天记录 %s 失败(稍后重试): %v", h.history.Path(), err)
h.chatMu.Lock()
h.chatDirty = true
if h.chatDirtySince.IsZero() {
h.chatDirtySince = time.Now()
}
h.chatMu.Unlock()
}
}
// Close 停掉写盘协程并把最后一次变更落盘(幂等)。
// 由插件 Stop 调用;不这样做会丢掉最后一轮对话。
func (h *Handler) Close() {
h.chatCloseOnce.Do(func() {
close(h.chatStop)
<-h.chatLoopDone
})
}
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) 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()
}
}
}
// bumpSeqLocked 分配下一个聊天序号(调用方须持 chatMu
// 序号单调递增、随记录落盘,作为 /chat/history?after= 的增量游标。
func (h *Handler) bumpSeqLocked() int64 {
h.chatSeq++
return h.chatSeq
}
func (h *Handler) addChatMsg(msg ChatMsg) {
h.chatMu.Lock()
msg.Seq = h.bumpSeqLocked()
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()
// 默认只给**一页**,不是整段历史。
//
// 原先缺省值 0 = 不限制,于是任何不带 limit 的客户端每次都会拿到完整聊天记录
// (生产实例上 ~5MB本地 126 条实测 635KB。WebUI/GUI 都显式带 limit
// 所以把默认收到一页不会影响它们;想整取的调用方显式传 limit=0。
limit := parseIntDefault(q.Get("limit"), defaultChatHistoryLimit)
if limit < 0 {
limit = defaultChatHistoryLimit
}
if limit > maxChatHistory {
limit = maxChatHistory
}
// after**增量游标**。只返回 seq > after 的消息,按 seq 升序。
//
// 这是给「轮询查询数据再更新视图」用的:客户端存下 last_seq下次带回来
// 只拿新增/变化的部分去 patch 视图,不做整块重建(重建的闪烁消不掉)。
// 与 before向上翻页互斥after 优先。
//
// 返回的是新增里**最旧的一批**(最多 limit 条last_seq 是这批最后一条 ——
// 客户端据此继续追下一批。若改成返回最新一批,被挤掉的旧的那几条就永远
// 追不回来了。
if raw := strings.TrimSpace(q.Get("after")); raw != "" {
after, err := strconv.ParseInt(raw, 10, 64)
if err != nil || after < 0 {
http.Error(w, "invalid after", http.StatusBadRequest)
return
}
h.chatMu.Lock()
total := len(h.chatHistory)
out := make([]ChatMsg, 0, 16)
for i := range h.chatHistory {
if h.chatHistory[i].Seq > after {
out = append(out, h.chatHistory[i])
}
}
h.chatMu.Unlock()
if limit > 0 && len(out) > limit {
out = out[:limit]
}
lastSeq := after
if len(out) > 0 {
lastSeq = out[len(out)-1].Seq
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"messages": out,
"total": total,
"after": after,
"last_seq": lastSeq,
})
return
}
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])
// last_seq 在锁内取:放锁后再读 h.chatHistory 是数据竞争。
var lastSeq int64
if total > 0 {
lastSeq = h.chatHistory[total-1].Seq
}
h.chatMu.Unlock()
// last_seq 一并下发:客户端首次全量加载后据此初始化增量游标。
writeJSON(w, http.StatusOK, map[string]interface{}{
"messages": result,
"total": total,
"offset": start,
"has_more": start > 0,
"last_seq": lastSeq,
})
}
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)
}
// channel_input把「哪条输入通道刚进来一条消息、由哪个 agent 接手」单独推一条
// **轻量**事件,供总览页拓扑画「光点进入 agent」的动画。
//
// 为什么不直接把 raw_input 放进 subTypes那条事件的 payload 带整条输入正文
// (用户消息,可能几 KB而拓扑只需要 `source`(通道名)与发出者的 agent id。
// 全量转发会让每次用户说话都在 SSE 上多背一份正文。
unsubInput := h.sdk.Subscribe(sdk.EventRawInput, func(evt *sdk.Event) {
src, _ := evt.Payload["source"].(string)
if src == "" {
return
}
data, _ := json.Marshal(map[string]interface{}{
"type": "channel_input",
"source": src,
"agent": evt.Source,
"timestamp": evt.Timestamp,
})
seq++
id := fmt.Sprintf("%d-%d", evt.Timestamp, seq)
sendSSE(writeCh, id, "channel_input", string(data))
})
unsubs = append(unsubs, unsubInput)
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()
}
}
}