From bec670ead0d763a5370224d00b65b5974a60bb16 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 10 Mar 2026 20:33:16 +0700 Subject: [PATCH] fix(pending-messages): run compaction in background, return 202 Accepted LLM summarization (30-120s) was blocking HTTP response causing browser timeouts. Now runs in goroutine and returns immediately. UI polls every 5s until completion. --- internal/http/pending_messages.go | 27 ++++++++++--------- .../src/i18n/locales/en/pending-messages.json | 2 ++ .../src/i18n/locales/vi/pending-messages.json | 2 ++ .../src/i18n/locales/zh/pending-messages.json | 2 ++ .../hooks/use-pending-messages.ts | 20 +++++++++----- .../pending-messages-page.tsx | 15 ++++++++--- 6 files changed, 45 insertions(+), 23 deletions(-) diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go index ca88b12f..d504a383 100644 --- a/internal/http/pending_messages.go +++ b/internal/http/pending_messages.go @@ -151,22 +151,25 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re return } - // Detach from HTTP request context so LLM summarization isn't - // cancelled when the browser closes the connection. - ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 180*time.Second) - defer cancel() - + // Run compaction in background so the HTTP response returns immediately. + // The long-running LLM call (30-120s) was blocking the response, which + // caused browser WebSocket connections to drop (pong timeout). keepRecent := h.keepRecent if keepRecent <= 0 { keepRecent = 15 } - remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent, h.maxTokens) - if err != nil { - slog.Warn("compact.failed", "channel", req.ChannelName, "key", req.HistoryKey, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok", "method": "summarized", "remaining": remaining}) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent, h.maxTokens) + if err != nil { + slog.Warn("compact.failed", "channel", req.ChannelName, "key", req.HistoryKey, "error", err) + } else { + slog.Info("compact.done", "channel", req.ChannelName, "key", req.HistoryKey, "remaining", remaining) + } + }() + + writeJSON(w, http.StatusAccepted, map[string]interface{}{"status": "accepted", "method": "summarizing"}) } // resolveProviderAndModel resolves the LLM provider+model for pending message compaction. diff --git a/ui/web/src/i18n/locales/en/pending-messages.json b/ui/web/src/i18n/locales/en/pending-messages.json index 107c5e7b..02137f76 100644 --- a/ui/web/src/i18n/locales/en/pending-messages.json +++ b/ui/web/src/i18n/locales/en/pending-messages.json @@ -41,6 +41,8 @@ "compacted": "Compaction complete", "compactedSummarized": "Summarized to {{count}} messages", "compactedDeleted": "All messages deleted (no LLM provider available)", + "compacting": "Compacting…", + "compactingDesc": "LLM summarization running in background. List will refresh automatically.", "failedCompact": "Compaction failed", "cleared": "Messages cleared", "failedClear": "Failed to clear messages" diff --git a/ui/web/src/i18n/locales/vi/pending-messages.json b/ui/web/src/i18n/locales/vi/pending-messages.json index 5aeefc11..182f04cd 100644 --- a/ui/web/src/i18n/locales/vi/pending-messages.json +++ b/ui/web/src/i18n/locales/vi/pending-messages.json @@ -41,6 +41,8 @@ "compacted": "Tóm tắt hoàn tất", "compactedSummarized": "Đã tóm tắt còn {{count}} tin nhắn", "compactedDeleted": "Đã xóa tất cả (không có LLM provider)", + "compacting": "Đang tóm tắt…", + "compactingDesc": "LLM đang tóm tắt ở nền. Danh sách sẽ tự động cập nhật.", "failedCompact": "Tóm tắt thất bại", "cleared": "Đã xóa tin nhắn", "failedClear": "Không thể xóa tin nhắn" diff --git a/ui/web/src/i18n/locales/zh/pending-messages.json b/ui/web/src/i18n/locales/zh/pending-messages.json index 89dc514a..5aba5c39 100644 --- a/ui/web/src/i18n/locales/zh/pending-messages.json +++ b/ui/web/src/i18n/locales/zh/pending-messages.json @@ -41,6 +41,8 @@ "compacted": "压缩完成", "compactedSummarized": "已压缩为 {{count}} 条消息", "compactedDeleted": "已删除全部(无可用LLM Provider)", + "compacting": "压缩中…", + "compactingDesc": "LLM正在后台进行摘要。列表将自动刷新。", "failedCompact": "压缩失败", "cleared": "消息已清除", "failedClear": "清除消息失败" diff --git a/ui/web/src/pages/pending-messages/hooks/use-pending-messages.ts b/ui/web/src/pages/pending-messages/hooks/use-pending-messages.ts index ae278423..a92b124b 100644 --- a/ui/web/src/pages/pending-messages/hooks/use-pending-messages.ts +++ b/ui/web/src/pages/pending-messages/hooks/use-pending-messages.ts @@ -48,13 +48,19 @@ export function usePendingMessages() { "/v1/pending-messages/compact", { channel_name: channel, history_key: key }, ); - const method = res?.method ?? "summarized"; - toast.success( - i18next.t("pending-messages:toast.compacted"), - method === "deleted" - ? i18next.t("pending-messages:toast.compactedDeleted") - : i18next.t("pending-messages:toast.compactedSummarized", { count: res?.remaining ?? 0 }), - ); + const method = res?.method ?? "summarizing"; + if (method === "deleted") { + toast.success( + i18next.t("pending-messages:toast.compacted"), + i18next.t("pending-messages:toast.compactedDeleted"), + ); + } else { + // Backend runs LLM in background — show info toast and let caller poll + toast.info( + i18next.t("pending-messages:toast.compacting"), + i18next.t("pending-messages:toast.compactingDesc"), + ); + } return true; } catch (err) { toast.error( diff --git a/ui/web/src/pages/pending-messages/pending-messages-page.tsx b/ui/web/src/pages/pending-messages/pending-messages-page.tsx index 45ba5066..ab71d639 100644 --- a/ui/web/src/pages/pending-messages/pending-messages-page.tsx +++ b/ui/web/src/pages/pending-messages/pending-messages-page.tsx @@ -45,10 +45,17 @@ export function PendingMessagesPage() { const key = `${group.channel_name}/${group.history_key}`; setActionLoading(key); setCompactingKey(key); - await compactGroup(group.channel_name, group.history_key); - setCompactingKey(null); - setActionLoading(null); - loadGroups(); + const ok = await compactGroup(group.channel_name, group.history_key); + if (!ok) { + setCompactingKey(null); + setActionLoading(null); + return; + } + // Backend runs LLM in background — poll until done (has_summary changes). + // Compact button is already disabled when has_summary=true. + const poll = setInterval(() => loadGroups(), 5000); + setTimeout(() => loadGroups(), 2000); // quick first check + setTimeout(() => { clearInterval(poll); setCompactingKey(null); setActionLoading(null); }, 120_000); }; const handleClear = async (group: PendingMessageGroup) => {