feat: SSE Last-Event-ID 断线重放 + GUI 表单防刷新

- webui handler: 新增 sseEventRing 环状缓冲区(200条),断线重连按 Last-Event-ID 重放遗漏事件
- GUI app.js: doRenderAll 检测连接表单打开时改走 refreshDataOnly,修复 15s 定时器擦掉用户输入的 bug
- 附带 dashboard.html/index.html 前端调整 + handler_sse_test.go 单测
This commit is contained in:
JianFeeeee
2026-08-24 16:22:28 +08:00
parent fb8a1b8ce3
commit 5163ce51a7
5 changed files with 365 additions and 24 deletions

View File

@ -17,6 +17,10 @@
src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"
onerror="console.warn('marked CDN failed')"
></script>
<script
src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"
onerror="console.warn('DOMPurify CDN failed')"
></script>
<script>
setTimeout(function () {
if (!window.THREE) window._THREE_FAILED = true;
@ -2508,6 +2512,25 @@ background:
})();
// ===== Utility =====
function renderMd(text) {
if (typeof text !== "string") text = String(text || "");
var html;
if (typeof marked !== "undefined") {
try { html = marked.parse(text); }
catch (e) { html = escHtml(text); }
} else {
html = "<pre>" + escHtml(text) + "</pre>";
}
if (typeof DOMPurify !== "undefined" && typeof DOMPurify.sanitize === "function") {
try { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); }
catch (e) {}
}
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/\son\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\son\w+\s*=\s*'[^']*'/gi, "")
.replace(/javascript:/gi, "");
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
@ -3200,11 +3223,7 @@ background:
var role = m.role || "user";
var c = m.content || "";
if (role === "assistant") {
if (typeof marked !== "undefined") {
c = marked.parse(c);
} else {
c = "<pre>" + escHtml(c) + "</pre>";
}
c = renderMd(c);
} else if (role === "system") {
c = escHtml(c);
} else {
@ -3384,10 +3403,7 @@ background:
}
function renderReasoningCard(text, isStreaming) {
var body =
typeof marked !== "undefined"
? marked.parse(text)
: escHtml(text);
var body = renderMd(text);
var preview =
typeof marked !== "undefined"
? text.replace(/[\s\n]+/g, " ").slice(0, 60)

View File

@ -66,6 +66,51 @@ func init() {
}
}
// sseEventRecord 保存一条 SSE 事件元数据,供断线重连时按 Last-Event-ID 重放遗漏事件。
type sseEventRecord struct {
id string // SSE 事件 id 值(如 "1234567890-5"
eventType string // 事件类型agent_output, reasoning 等)
data json.RawMessage // 序列化后的 payload JSON
}
// sseEventRing 是一个固定大小的环状缓冲区,保持最近 cap 条 SSE 事件。
type sseEventRing struct {
mu sync.Mutex
buf []sseEventRecord
cap int
}
func newSSEEventRing(cap int) *sseEventRing {
return &sseEventRing{cap: cap}
}
// Append 追加一条事件,超过容量时丢弃最旧条目。
func (r *sseEventRing) Append(id, eventType string, data json.RawMessage) {
r.mu.Lock()
defer r.mu.Unlock()
r.buf = append(r.buf, sseEventRecord{id: id, eventType: eventType, data: data})
if len(r.buf) > r.cap {
r.buf = r.buf[len(r.buf)-r.cap:]
}
}
// After 返回所有在指定 id 之后的事件(按写入顺序),若 id 不在缓冲区中则返回全部。
func (r *sseEventRing) After(id string) []sseEventRecord {
r.mu.Lock()
defer r.mu.Unlock()
for i := len(r.buf) - 1; i >= 0; i-- {
if r.buf[i].id == id {
result := make([]sseEventRecord, len(r.buf)-i-1)
copy(result, r.buf[i+1:])
return result
}
}
// ID 不在缓冲区(可能是太旧或从未收到),返回全部
result := make([]sseEventRecord, len(r.buf))
copy(result, r.buf)
return result
}
type Handler struct {
sdk *sdk.PluginSDK
supervisor sdk.SupervisorAPI
@ -85,6 +130,8 @@ type Handler struct {
sessionMu sync.Mutex
sessions map[string]time.Time
sseEvents *sseEventRing // SSE 事件环状缓冲区Last-Event-ID 重放用
chatMu sync.Mutex
chatHistory []ChatMsg
pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无
@ -214,6 +261,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
termStates: make(map[string]*termState),
pendingIdx: -1,
chatMsgCache: make(map[string]*chatMsgEntry),
sseEvents: newSSEEventRing(200),
}
h.loadChatHistory()
if s != nil {
@ -1344,10 +1392,24 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
log.Printf("[SSE] handler started, subscribing to events")
// 解析 Last-Event-ID断线重连时客户端携带
lastEventID := r.Header.Get("Last-Event-ID")
if lastEventID != "" {
// 解析 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)
} 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"}
@ -1362,8 +1424,13 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
}
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)
}
select {
case writeCh <- fmt.Sprintf("id: %d-%d\nevent: %s\ndata: %s\n", evt.Timestamp, seq, evt.Type, string(data)):
case writeCh <- fmt.Sprintf("id: %s\nevent: %s\ndata: %s\n", id, 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)

View File

@ -0,0 +1,96 @@
package webui
import (
"encoding/json"
"reflect"
"testing"
)
func TestSSEEventRing_Append_Capacity(t *testing.T) {
r := newSSEEventRing(3)
data := json.RawMessage(`{"a":1}`)
for i := 0; i < 5; i++ {
r.Append("id-"+string(rune('0'+i)), "agent_output", data)
}
// cap=3只保留 id-2,id-3,id-4
if len(r.buf) != 3 {
t.Fatalf("expected 3, got %d", len(r.buf))
}
if r.buf[0].id != "id-2" {
t.Fatalf("expected id-2, got %s", r.buf[0].id)
}
}
func TestSSEEventRing_After_Mid(t *testing.T) {
r := newSSEEventRing(10)
data := json.RawMessage(`{"x":"y"}`)
r.Append("id-1", "reasoning", data)
r.Append("id-2", "agent_output", data)
r.Append("id-3", "tool_call", data)
after := r.After("id-2")
if len(after) != 1 || after[0].id != "id-3" {
t.Fatalf("expected [id-3], got %v", after)
}
after = r.After("id-3")
if len(after) != 0 {
t.Fatalf("expected 0, got %d", len(after))
}
}
func TestSSEEventRing_After_NotFound_ReturnsAll(t *testing.T) {
r := newSSEEventRing(3)
data := json.RawMessage(`{}`)
r.Append("id-1", "agent_output", data)
r.Append("id-2", "agent_output", data)
after := r.After("id-0")
if len(after) != 2 {
t.Fatalf("expected 2 (fallback to all), got %d", len(after))
}
}
func TestSSEEventRing_After_Last_Tip(t *testing.T) {
r := newSSEEventRing(5)
data := json.RawMessage(`{"a":1}`)
for i := 0; i < 5; i++ {
r.Append("id-"+string(rune('0'+i)), "agent_output", data)
}
after := r.After("id-4")
if len(after) != 0 {
t.Fatalf("expected 0 after tip, got %d", len(after))
}
}
func TestSSEEventRing_Concurrent(t *testing.T) {
r := newSSEEventRing(100)
done := make(chan struct{})
for i := 0; i < 8; i++ {
go func(n int) {
defer func() { done <- struct{}{} }()
data := json.RawMessage(`{}`)
for j := 0; j < 100; j++ {
r.Append("w"+string(rune('0'+n))+"/"+string(rune('0'+j)), "agent_output", data)
}
}(i)
}
for i := 0; i < 8; i++ {
go func() {
defer func() { done <- struct{}{} }()
for j := 0; j < 100; j++ {
_ = r.After("w0/0")
}
}()
}
for i := 0; i < 16; i++ {
<-done
}
}
func TestSSEEventRecord_Type(t *testing.T) {
var got interface{} = sseEventRecord{}
if reflect.TypeOf(got).Kind() != reflect.Struct {
t.Fatal("sseEventRecord should be a struct")
}
}