From e4e7b4c3775903e6f5fbd44f8e0b6372c46dbce8 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 25 Jun 2026 14:36:59 +0800 Subject: [PATCH] fix(chat): suppress 403 console spam from polling unpersisted conversations (ISSUE #408) --- mateclaw-ui/src/stores/useGoalStore.ts | 10 +++++++ mateclaw-ui/src/views/ChatConsole.vue | 37 +++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts index 63f0f27d..408ddeeb 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -82,6 +82,16 @@ export const useGoalStore = defineStore('goal', () => { activeGoalByConv.value[conversationId] = goal return goal } catch (e) { + // A conversation that isn't persisted/owned yet — a brand-new empty chat + // before the first message lands, or a thread owned by another user — + // answers 403 (not the owner) / 404 (not found). That just means "no + // active goal yet": record the no-goal state silently. Only genuinely + // unexpected failures (network / 5xx) are worth a console error. + const status = (e as any)?.response?.status ?? (e as any)?.response?.data?.code + if (status === 403 || status === 404) { + activeGoalByConv.value[conversationId] = null + return null + } console.error('[goal] loadActiveForConversation failed', e) return null } finally { diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 97a4e042..eac2c6b3 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -1001,6 +1001,25 @@ const activeCronRuns = ref([]) function isCronConversation(cid: string | null | undefined): boolean { return !!cid && (cid.startsWith('tasks_') || cid.startsWith('cron_')) } +// True when a conversation id was minted client-side for a brand-new chat +// the user hasn't sent anything in yet, so the backend has never persisted +// (and therefore can't "own") it. Every per-conversation endpoint called with +// such an id is rejected with 403 "无权访问该会话" — the status/messages poller +// and the goal loader both skip the request entirely for these ids instead of +// spamming the console with ownership errors. +// +// Conservative by construction: an id counts as ephemeral only when it is +// absent from BOTH the loaded conversation list AND the local message buffer, +// so a freshly-persisted conversation is never mistaken for one. At worst a +// single stray 403 slips through during the persist race, which the leaf +// callers swallow. +function isEphemeralConversation(cid: string | null | undefined): boolean { + if (!cid) return true + // Server-managed virtual conversations (cron / scheduled tasks) always exist. + if (cid.startsWith('tasks_') || cid.startsWith('cron_')) return false + if (conversations.value.some((c) => c.conversationId === cid)) return false + return !messages.value.some((m: any) => m.conversationId === cid) +} async function refreshActiveCronRuns(cid: string) { if (!isCronConversation(cid)) { activeCronRuns.value = [] @@ -1065,8 +1084,13 @@ async function pollActivity() { } catch { // 静默失败,下一轮再试 } - // 自己没在生成时才刷新当前选中会话的消息 + 探测是否该接入流 - if (currentConversationId.value && !isGenerating.value && streamPhase.value !== 'awaiting_approval') { + // 自己没在生成时才刷新当前选中会话的消息 + 探测是否该接入流。 + // 跳过尚未落库的临时会话(新建空会话、还没发消息):后端查不到会话行, + // getStatus / listMessages 一律 403,4s 轮询会把 console 刷爆(见 issue #408)。 + if (currentConversationId.value + && !isGenerating.value + && streamPhase.value !== 'awaiting_approval' + && !isEphemeralConversation(currentConversationId.value)) { const cid = currentConversationId.value try { const statusRes: any = await conversationApi.getStatus(cid) @@ -1157,7 +1181,10 @@ const goalStore = useGoalStore() const workspaceStoreForGoal = useWorkspaceStore() const currentWorkspaceId = computed(() => workspaceStoreForGoal.currentWorkspaceId ?? '1') watch(currentConversationId, async (cid) => { - if (cid) { + // Skip un-persisted conversations: a brand-new empty chat has no goal yet + // and the lookup would only 403 (Not the owner). The ring is hydrated by the + // goal_created SSE event once the first turn lands. + if (cid && !isEphemeralConversation(cid)) { await goalStore.loadActiveForConversation(cid) } }, { immediate: true }) @@ -1170,7 +1197,9 @@ watch(currentConversationId, async (cid) => { // this transition-to-idle refresh is what keeps the goal ring honest after // every turn against the persisted truth. watch(isGenerating, async (generating, wasGenerating) => { - if (wasGenerating && !generating && currentConversationId.value) { + if (wasGenerating && !generating + && currentConversationId.value + && !isEphemeralConversation(currentConversationId.value)) { await goalStore.loadActiveForConversation(currentConversationId.value) } })