Skip to content

Commit 1f5dd51

Browse files
nieaoclaude
andcommitted
fix(bot+canvas): 多 pending inbox 串行处理 + bot 反馈队列 1:1 配对
Loop 4 (canvas): 浏览器后开 + 多条 pending → 旧 scan 一次性 fire 全部 → BottomAIBar submitting 守护吃掉后续 → handleItem 已把 yjs 标 processing → 永远卡死. 改成 scanInboxNext() 只 fire 最老一条, BottomAIBar 跑完后 finally 回调推进下一条. Loop 5 (bot): pendingFeedbackByRoom 单值多 cast 互相覆盖 → conclusion 配错 prompt. 改 FIFO 队列, 每次 conclusion 弹出最早 ctx, 与前端串行处 理形成 1:1 对应. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9243603 commit 1f5dd51

3 files changed

Lines changed: 45 additions & 17 deletions

File tree

server/feishu-bot.mjs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -509,14 +509,17 @@ async function handleMessage(evt) {
509509
// 关联到最近一次该 room 的 cast (chatId), 拼卡片 (含 5 个分支 callback button) 发飞书.
510510

511511
const reverseChannels = new Map() // room → { doc, provider, yNodes, observer, baseline:Set, sentConclusions:Set }
512-
const pendingFeedbackByRoom = new Map() // room → { chatId, prompt, sentAt, fed: false }
512+
const pendingFeedbackByRoom = new Map() // room → Array<{ chatId, prompt, sentAt }> (FIFO 队列)
513513
let _cardSchemaDumped = false
514514

515515
function registerPendingFeedback(room, ctx) {
516-
// 重置 fed=false 让新一轮 conclusion 能再次触发反馈卡
517-
// (老 ctx 已发过的 conclusion key 仍在 sentConclusions 防重复, 不影响新轮 conclusion)
518-
pendingFeedbackByRoom.set(room, { ...ctx, fed: false })
519-
log(`[reverse] register pending feedback room=${room} prompt="${(ctx.prompt || '').slice(0, 30)}"`)
516+
// FIFO 队列 — 多 cast 之间不会互相覆盖. 每个 conclusion 弹出最早一条 ctx.
517+
// 配 BottomAIBar 的串行处理 (aletheiaInbox.scanInboxNext): 前端按 ts 升序 fire,
518+
// bot 按 cast 顺序入队, conclusion 顺序也保持一致 → 1:1 对应不会错配.
519+
const q = pendingFeedbackByRoom.get(room) || []
520+
q.push({ ...ctx })
521+
pendingFeedbackByRoom.set(room, q)
522+
log(`[reverse] register pending feedback room=${room} queue=${q.length} prompt="${(ctx.prompt || '').slice(0, 30)}"`)
520523
}
521524

522525
// 调用 source-proxy cast + 同时注册反馈跟踪 (callback 路径必走这个 helper, 避免漏注册)
@@ -556,12 +559,14 @@ function ensureReverseChannel(room) {
556559
// 关键判断: ontologyNode + isConclusion + 含 conclusion 文本
557560
if (n.type !== 'ontologyNode' || !n.data?.isConclusion) return
558561
if (sentConclusions.has(key)) return // 防同节点 update 重复触发
559-
// 时间窗校验: 必须在 pendingFeedback.sentAt 之后产生
560-
const ctx = pendingFeedbackByRoom.get(room)
561-
if (!ctx || ctx.fed) return
562+
// 弹出 FIFO 队列最早一条 ctx — 1:1 对应每次 cast
563+
const q = pendingFeedbackByRoom.get(room)
564+
if (!q || q.length === 0) return
565+
const ctx = q[0]
562566
const cAt = Number(n.data?.created_at || 0)
563567
if (cAt && cAt < ctx.sentAt - 5000) return // 早于本次 cast 5s+ 的肯定不是
564568
sentConclusions.add(key)
569+
q.shift() // 消费 ctx
565570
// 收集本次 cast 之后产生的所有新节点 (拼卡片用)
566571
const newNodes = []
567572
yNodes.forEach((nn, k) => {
@@ -570,15 +575,14 @@ function ensureReverseChannel(room) {
570575
if (at && at < ctx.sentAt - 5000) return
571576
newNodes.push({ id: k, ...nn })
572577
})
573-
log(`[reverse] room=${room} conclusion ready key=${key} 新节点=${newNodes.length}`)
578+
log(`[reverse] room=${room} conclusion ready key=${key} 新节点=${newNodes.length} 剩余队列=${q.length}`)
574579
// 调试: 第一次时 dump 所有新节点 data 字段, 找到伪 ontology 的 marker
575580
if (process.env.LARK_DEBUG === '1') {
576581
for (const nn of newNodes.filter(x => x.type === 'ontologyNode' && !x.data?.isConclusion).slice(0, 3)) {
577582
log(`[reverse-dump] ontology id=${nn.id} data=${JSON.stringify(nn.data || {}).slice(0, 400)}`)
578583
}
579584
}
580585
sendFeedbackCard(room, ctx, n, newNodes).catch((e) => logErr('反馈卡发送失败:', e.message))
581-
ctx.fed = true
582586
})
583587
}
584588
yNodes.observe(observer)

src/collab/aletheiaInbox.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,30 @@ function handleItem(id, item) {
7171
}
7272
}
7373

74+
/** 找最老的 pending (按 ts 升序), 没有则返回 null */
75+
function findOldestPending() {
76+
const map = getInboxMap()
77+
let oldest = null
78+
map.forEach((item, key) => {
79+
if (item?.status !== 'pending') return
80+
const ts = Number(item.ts || 0)
81+
if (!oldest || ts < oldest.ts) oldest = { key, item, ts }
82+
})
83+
return oldest
84+
}
85+
86+
/**
87+
* 扫一次, 只 fire 最老的一条 pending — 让 BottomAIBar 串行处理.
88+
* 当前一条 LLM 跑完后, BottomAIBar 应主动调本函数取下一条.
89+
* 不能一次性 fire 全部: 因为 handleItem 会立刻把 yjs 状态标 processing,
90+
* 而 BottomAIBar 自己有 submitting 守护会把后续 fire 全部丢弃, 导致 inbox 永远卡死 processing.
91+
*/
92+
export function scanInboxNext() {
93+
if (!_attached) return
94+
const oldest = findOldestPending()
95+
if (oldest) handleItem(oldest.key, oldest.item)
96+
}
97+
7498
/** mount 时挂上, unmount 时清掉 */
7599
export function attachAletheiaInbox() {
76100
if (_attached) return
@@ -82,7 +106,7 @@ export function attachAletheiaInbox() {
82106
event.changes.keys.forEach((change, key) => {
83107
if (change.action === 'add') {
84108
const item = map.get(key)
85-
// 新增的 pending 立即处理
109+
// 新增的 pending 立即处理 (handleItem 自带 submitting / 选举守护)
86110
handleItem(key, item)
87111
} else if (change.action === 'update') {
88112
// update 不做 fire (避免循环)
@@ -91,12 +115,8 @@ export function attachAletheiaInbox() {
91115
}
92116
map.observe(_observer)
93117

94-
// 初次挂上时, 扫一遍未处理的 pending (如果别的 cc 还没处理过)
95-
setTimeout(() => {
96-
map.forEach((item, key) => {
97-
if (item && item.status === 'pending') handleItem(key, item)
98-
})
99-
}, 1500) // 等 awareness 稳定再扫
118+
// 初次挂上时, 只 fire 最老的一条 pending — 后续靠 BottomAIBar 跑完后回调 scanInboxNext()
119+
setTimeout(() => { scanInboxNext() }, 1500) // 等 awareness 稳定再扫
100120
}
101121

102122
export function detachAletheiaInbox() {

src/pages/panels/BottomAIBar.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useState, useRef, useEffect } from 'react'
1515
import useCanvasStore from '../../stores/useCanvasStore'
1616
import { parseFile } from '../../utils/fileParser'
1717
import { PROVIDER_PRESETS, getAiConfig } from '../../services/aiConfig'
18+
import { scanInboxNext } from '../../collab/aletheiaInbox'
1819

1920
const MODES = [
2021
{
@@ -86,6 +87,9 @@ function BottomAIBar({ showLeftPanel = true, showRightPanel = true, rightPanelWi
8687
// 失败时保留 input 让用户手动重试
8788
} finally {
8889
setSubmitting(false)
90+
// 排队的下一条 inbox 由我们主动触发 — observer 只对 add 事件 fire,
91+
// 多条 pending 排队时必须靠这里串行推进
92+
setTimeout(() => scanInboxNext(), 200)
8993
}
9094
}
9195
window.addEventListener('aletheia-inbox-fire', handler)

0 commit comments

Comments
 (0)