feat: webui 消息重放双重防护(client_msg_id 去重 + agent 内容级去重)

回应群聊 08-19 消息轰炸诊断(GUI SSE 重连导致消息重放):

1. webui 层 client_msg_id 单飞去重(与 GUI c29abe9 配套):
   - /api/v1/chat 解析 client_msg_id, 同 ID 重放等待首次结果直接复用
   - 响应带 deduplicated=true 标记; 无 ID 旧客户端完全兼容
   - FIFO 缓存上限 256 条防泄漏

2. agent 核心层内容级短窗口去重(兜底无 ID 客户端):
   - isDuplicateInput: source+content 为 key, 10s 窗口内重复丢弃
   - 持续轰炸时刷新时间戳保持拦截; 过期项自动清理

测试: webui 去重三场景 + agent 核心去重行为验证, 全项目 go test 通过
This commit is contained in:
JianFeeeee
2026-08-21 10:23:18 +08:00
parent 257ff0ad5d
commit 8d368913a9
4 changed files with 261 additions and 4 deletions

View File

@ -111,6 +111,11 @@ type Agent struct {
noMergeMarkers map[string]int
noMergeMu sync.Mutex
// 输入去重:防 webui/GUI 断线重连导致的消息重放
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
lastInput map[string]time.Time
lastInputMu sync.Mutex
// 词嵌入模型,用于实体语义相似度计算
embedder *memory.StaticEmbedder
}
@ -221,6 +226,7 @@ func New(cfg AgentConfig) *Agent {
inputCfg: cfg.InputProcessing,
embedder: embedder,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
}
}
@ -240,6 +246,34 @@ func (a *Agent) Stop() {
func (a *Agent) ID() types.AgentID { return a.id }
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
// key=source+"|"+content窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
const duplicateInputWindow = 10 * time.Second
func (a *Agent) isDuplicateInput(source, content string) bool {
a.lastInputMu.Lock()
defer a.lastInputMu.Unlock()
now := time.Now()
key := source + "|" + content
if last, ok := a.lastInput[key]; ok && now.Sub(last) < duplicateInputWindow {
a.lastInput[key] = now
return true
}
a.lastInput[key] = now
// 顺带清理过期项,防止 map 无限增长
for k, t := range a.lastInput {
if now.Sub(t) > duplicateInputWindow {
delete(a.lastInput, k)
}
}
return false
}
// IsDuplicateInput 导出包装,供测试验证去重行为。
func (a *Agent) IsDuplicateInput(source, content string) bool {
return a.isDuplicateInput(source, content)
}
// SelfInputChan 返回自循环输入通道(只读,供内部测试验证)
func (a *Agent) SelfInputChan() <-chan string {
return a.selfInputCh