sintbot 1.0.15 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.en.json +118 -0
  2. package/CHANGELOG.json +118 -0
  3. package/README.md +20 -156
  4. package/README.zh-CN.md +20 -164
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +19 -0
  8. package/docs/install.md +93 -0
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
  10. package/docs/zh-CN/install.md +93 -0
  11. package/install.sh +84 -0
  12. package/package.json +74 -34
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4711
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/clilang.js +0 -31
  93. package/src/core/cron.js +0 -182
  94. package/src/core/cronfire.js +0 -120
  95. package/src/core/cronrun.js +0 -116
  96. package/src/core/engine.js +0 -2762
  97. package/src/core/interfaces.js +0 -114
  98. package/src/core/issues.js +0 -185
  99. package/src/core/ledger.js +0 -30
  100. package/src/core/opsalert.js +0 -214
  101. package/src/core/pending.js +0 -123
  102. package/src/core/proxy.js +0 -424
  103. package/src/core/quotaalert.js +0 -79
  104. package/src/core/quotaerr.js +0 -55
  105. package/src/core/scheduler.js +0 -59
  106. package/src/design.js +0 -5161
  107. package/src/gitrepo.js +0 -150
  108. package/src/health.js +0 -353
  109. package/src/installdeps.js +0 -72
  110. package/src/leftover.js +0 -174
  111. package/src/platform/boardnotice.js +0 -77
  112. package/src/platform/boardops.js +0 -36
  113. package/src/platform/boardwatch.js +0 -447
  114. package/src/platform/cli.js +0 -45
  115. package/src/platform/slack.js +0 -1289
  116. package/src/platform/takeovercard.js +0 -37
  117. package/src/platform/wecom.js +0 -938
  118. package/src/platform/wecomcli.js +0 -128
  119. package/src/platform/wecomgate.js +0 -108
  120. package/src/platform/wecomname.js +0 -43
  121. package/src/platform/wecompoll.js +0 -241
  122. package/src/preview.js +0 -581
  123. package/src/projectmemory.js +0 -175
  124. package/src/service.js +0 -317
  125. package/src/ui/auth.js +0 -31
  126. package/src/ui/browser.html +0 -392
  127. package/src/ui/browserroutes.js +0 -234
  128. package/src/ui/console.html +0 -5494
  129. package/src/ui/dashboard.js +0 -168
  130. package/src/ui/feishulogin.js +0 -136
  131. package/src/ui/server.js +0 -2314
  132. package/src/ui/sessions.js +0 -78
  133. package/src/ui/takeover.js +0 -108
  134. package/src/updater.js +0 -243
  135. package/src/upgrade.js +0 -35
  136. package/src/usage.js +0 -144
@@ -1,447 +0,0 @@
1
- // 画布自动感知(B3;docs/BOARD.md):画板变化 → 与基线 diff 出「新增 / 改文字 / 删除」→ 群里播报摘要。
2
- // 全程纯 API + 纯函数,**不 spawn agent** —— 读取和播报是确定性操作零模型成本,
3
- // 「要不要执行修改」这个烧额度的决定留给群里的人。
4
- //
5
- // 检测范围(v0.7.1 拍板扩到「改/删」):新增节点 + 改了已有节点的文字内容 + 删除节点都播报;
6
- // 纯挪动/缩放/改样式视作整理,静音吸收(防噪)。为此基线从「id 集(Set)」升级成「id→文字指纹
7
- // (Map)」—— 指纹 = nodeText(node)(注入,与 digest 同一抽取);sig=undefined 表示「文字未知」
8
- // (来自 bot ops 的 add/rewrite,只带 id 不带文字)⇒ 静默采纳当前文字,不算编辑,是 bot 自建内容
9
- // 不被误播的关键。
10
- //
11
- // 两路触发共享同一基线(天然幂等 —— 谁先播完,另一路 diff 为空自动 no-op):
12
- // ① 事件路径(onEvent):board.whiteboard.updated_v1 → 60s 去抖 → flush。真机事实
13
- // (2026-07-21):该事件不投递到 WS 长连接,此路保留仅作「飞书哪天修好投递」的加速通道。
14
- // ② 轮询路径(startPoll,主触发):每 pollMs 对所有有画布的群 listNodes 一次。
15
- // 静默期门槛(2026-07-22 拍板):画板无 presence API,「用户已停止编辑」只能靠变更静默
16
- // 推断 —— 发现变化不立即播报,押后到下一个 poll 周期指纹不再变化才播累计变化;连续编辑
17
- // 持续押后(播报打断创作无意义),holdRounds 上限兜底防永不播报。指纹 = 全量节点 JSON
18
- // (拖动/改样式也算「仍在编辑」,只影响押后节奏,不进播报范围)。
19
- //
20
- // 自编辑抑制两套(按触发路径):事件路径靠 operator_ids(去抖窗内全 bot 则静默刷新基线);
21
- // 轮询路径靠 ops journal(boardops.js)—— CLI 写动作留痕服务端返回的持久 id,poll/flush
22
- // 先重放到基线再 diff:add 并入基线,rewrite(del-node 的 overwrite,id 全变)整体重置基线。
23
- // 已知边界:留痕写失败(概率极低)时 bot 的编辑会被当用户新增多播一次,可接受。
24
- //
25
- // 纯工厂:全部依赖注入(listNodes/digest/lookupGroup/notify/readOps/listBoards/timer/log),
26
- // 可单测;与 wswatchdog.js 同款纪律,不 import 任何飞书/引擎模块。
27
- function createBoardWatch({
28
- debounceMs = 60_000,
29
- pollMs = 0, // 0 = 不轮询(纯事件模式);daemon 默认也是 0,feishu.board.pollSec 填正数才开
30
- holdRounds = 5, // 连续编辑最多押后 N 个 poll 周期,之后强制播报(防饿死)
31
- listNodes, // async (whiteboardId) => nodes[]
32
- listComments = null, // async (docToken) => comments[](评论第二通道;缺省=只感知节点)
33
- digest, // (nodes) => { lines: string[] }
34
- nodeText = () => undefined, // (node) => string —— 节点文字指纹(board.nodeText);缺省 undefined = 只感知新增/不追踪改删(安全降级,bin 注入真实抽取启用改/删)
35
- lookupGroup, // (whiteboardId) => { chatId, docToken } | null(每次现查 ⇒ 自动跟随配置热重载)
36
- listBoards = null, // () => [whiteboardId…](轮询清单;每 tick 现查 ⇒ 跟随热重载)
37
- readOps = null, // (whiteboardId) => [{ts,op,ids}…](bot 侧留痕;boardops.readBoardOps)
38
- notify, // (chatId, text) => Promise<msgId> —— 文本播报(fallback);调用方负责 redact
39
- notifyCard = null, // (chatId, cardJson) => Promise<{msgId,cardId}> —— 播报卡(button 确认,2026-07-22);缺则退回 notify 文本
40
- previewImage = null, // async (whiteboardId) => image_key|null —— 画板整板预览图进卡(卡里直接看画板);失败/缺省降级无图卡
41
- redact = (s) => s, // 卡片内标注文本逐条脱敏(卡是 JSON 无法整体 redact ⇒ 构卡时逐字段过)
42
- // 静默期信号(样式/位置变化也算在编辑)。⚠ 必须顺序无关:飞书 GET nodes 返回顺序不稳定
43
- // (2026-07-22 真机实锤,同批节点两次请求乱序)—— 直接 stringify 指纹恒变 ⇒ 静默期永不
44
- // 满足,每批新增都押满 holdRounds 才被防饿死兜底播出(2-4 分钟劣化成 10+ 分钟)。
45
- fingerprint = (nodes) => JSON.stringify([...(nodes || [])].sort((a, b) => String(a && a.id).localeCompare(String(b && b.id)))),
46
- log = () => {},
47
- botOpenId = null,
48
- now = () => Date.now(), // 注入时钟:建线时推进 ops 游标用(单测可控)
49
- setTimer = setTimeout,
50
- clearTimer = clearTimeout,
51
- // prime 失败后的退避重试间隔。开机时网络常常还没就绪(DNS 解析不到),只试一次就会让基线一直空着:
52
- // 不轮询时(默认)要等第一次画布事件才静默补线,用户重启后画的第一批标注就被吞掉。
53
- primeRetryMs = [15_000, 30_000, 60_000, 120_000, 300_000],
54
- }) {
55
- const boards = new Map() // whiteboardId → { timer, baseline:Map<id,sig>|null, commentBaseline:Set|null, sawUser, opsCursor, lastFp, pendingRounds }
56
- const st = (id) => {
57
- let s = boards.get(id)
58
- if (!s) { s = { timer: null, baseline: null, commentBaseline: null, sawUser: false, opsCursor: 0, lastFp: undefined, pendingRounds: 0 }; boards.set(id, s) }
59
- return s
60
- }
61
-
62
- // 全量节点 → 基线 Map(id→文字指纹)。id 一律 String 归一(与 diff/ops 键一致)。
63
- function baselineMap(nodes) {
64
- const m = new Map()
65
- for (const n of nodes || []) { if (n && n.id != null) m.set(String(n.id), nodeText(n)) }
66
- return m
67
- }
68
-
69
- // 共享 diff 助手(poll/flush 共用,消除重复):prevMap(id→sig)对当前节点求「新增/改文字/删除」。
70
- // added = 当前 id 不在 prevMap
71
- // edited = id 两边都有、prevMap 旧值非 undefined(未知文字不算编辑)、且 sig 变了
72
- // deleted = prevMap 里 sig 非 undefined 的 id 不在当前(未知文字的删除不播,边角可接受)
73
- // undefined 旧值(bot ops add/rewrite 只带 id)⇒ 既不算新增也不算编辑,静默采纳当前文字。
74
- // editedOld = Map(id→旧文字),供工单摘要标「原文字」;curMap = 当前全量 id→sig(播报后作新基线)。
75
- function diffBaseline(prevMap, curNodes) {
76
- const nodes = Array.isArray(curNodes) ? curNodes : []
77
- const curMap = new Map()
78
- for (const n of nodes) { if (n && n.id != null) curMap.set(String(n.id), nodeText(n)) }
79
- const added = []
80
- const edited = []
81
- const editedOld = new Map()
82
- for (const n of nodes) {
83
- if (!n || n.id == null) continue
84
- const id = String(n.id)
85
- if (!prevMap || !prevMap.has(id)) { added.push(n); continue }
86
- const oldSig = prevMap.get(id)
87
- if (oldSig === undefined) continue // 文字未知 ⇒ 静默采纳,不算编辑
88
- if (oldSig !== curMap.get(id)) { edited.push(n); editedOld.set(id, oldSig) }
89
- }
90
- const deleted = []
91
- if (prevMap) for (const [id, sig] of prevMap) { if (sig !== undefined && !curMap.has(id)) deleted.push({ id, oldText: sig }) }
92
- return { added, edited, deleted, editedOld, curMap }
93
- }
94
-
95
- // 工单摘要行(喂 agent,takeNotice 返回):新增=digest 行;改文字=digest 行 +(原文字:"旧");
96
- // 删除=[已删除] id "旧文字"。合并为一个字符串数组。
97
- function changeDigestLines(changes) {
98
- const added = (changes && changes.added) || []
99
- const edited = (changes && changes.edited) || []
100
- const deleted = (changes && changes.deleted) || []
101
- const editedOld = (changes && changes.editedOld) || new Map()
102
- const lines = []
103
- for (const ln of digest(added).lines) lines.push(ln)
104
- const eLines = digest(edited).lines
105
- // 以已截断的行数为准(edited 超 digest 上限时静默丢尾,与 added 分支一致 —— 按原数组遍历会
106
- // 越界拼出 "undefined",审查证实的真 bug)
107
- eLines.forEach((ln, i) => {
108
- const n = edited[i]
109
- const old = editedOld.get(n && n.id != null ? String(n.id) : '')
110
- lines.push(ln + (old ? ` (原文字:"${old}")` : ' (文字有改动)'))
111
- })
112
- for (const d of deleted) lines.push(`[已删除] ${d.id}${d.oldText ? ` "${d.oldText}"` : ''}`)
113
- return lines
114
- }
115
-
116
- // 未解决评论的 id 集(与 digestComments 同款 !is_solved 过滤;字段 comment_id,兜底 id)。
117
- function unresolvedCommentIds(comments) {
118
- const out = new Set()
119
- for (const c of comments || []) {
120
- if (!c || typeof c !== 'object' || c.is_solved) continue
121
- const id = c.comment_id || c.id
122
- if (id) out.add(String(id))
123
- }
124
- return out
125
- }
126
- // 拉未解决评论 id + 相对基线的新评论(listComments 缺省 / 无 docToken / 失败 ⇒ 空,退化为只感知节点)。
127
- async function fetchComments(s, group) {
128
- if (!listComments || !group || !group.docToken) return { all: null, fresh: [] }
129
- let comments
130
- try { comments = await listComments(group.docToken) } catch (e) { log('boardwatch comments: ' + (e && e.message)); return { all: null, fresh: [] } }
131
- const all = unresolvedCommentIds(comments)
132
- const fresh = s.commentBaseline ? [...all].filter((id) => !s.commentBaseline.has(id)) : []
133
- return { all, fresh }
134
- }
135
-
136
- // bot 侧留痕重放到基线(diff 前必调):add 并入,rewrite 重置。游标防重复消费。
137
- function syncOps(s, whiteboardId) {
138
- if (!readOps) return
139
- let ops
140
- try { ops = readOps(whiteboardId) } catch { return }
141
- for (const o of ops || []) {
142
- if (!o || !(Number(o.ts) > s.opsCursor)) continue
143
- s.opsCursor = Number(o.ts)
144
- // rewrite(del-node overwrite,id 全变):基线整体重置为新 id 集,文字未知(undefined)⇒ 下一轮
145
- // 读到真实文字静默采纳,不误报编辑/删除。add:并入新 id(文字未知),bot 放图不自问自答。
146
- if (o.op === 'rewrite') s.baseline = new Map((o.ids || []).map((id) => [String(id), undefined]))
147
- else if (o.op === 'add' && s.baseline) for (const id of o.ids || []) { if (!s.baseline.has(String(id))) s.baseline.set(String(id), undefined) }
148
- }
149
- }
150
-
151
- // 建基线(daemon 启动时对已有画布调用)。失败按 primeRetryMs 退避重试;重试前基线已被 flush/poll
152
- // 补上、或画板已不归任何群,就不再试。重试都用完仍失败时,首次 flush/poll 会静默补基线 —— 代价是
153
- // 错过基线缺失窗口内的第一批标注,好过重启后把整块画布当「新增」重放一遍。
154
- async function prime(whiteboardId, attempt = 0) {
155
- const s0 = st(whiteboardId)
156
- if (s0.primeTimer) { clearTimer(s0.primeTimer); s0.primeTimer = null } // 手动再调一次 = 取代挂着的重试
157
- try {
158
- const nodes = await listNodes(whiteboardId)
159
- const s = st(whiteboardId)
160
- s.baseline = baselineMap(nodes) // id→文字指纹(不再是纯 id 集)
161
- s.lastFp = fingerprint(nodes)
162
- // ⚠ 基线 = 此刻真实全量读取 ⇒ 此前的历史 ops 已尽在其中,游标必须推进到当下。
163
- // 不推的话 daemon 重启后 cursor 归零重放历史 rewrite,会把新鲜基线拽回旧快照,
164
- // 用户早已播报过的节点变回「新增」重复播报(2026-07-22 真机踩过:o20:13 重播)。
165
- s.opsCursor = now()
166
- const { all } = await fetchComments(s, lookupGroup(whiteboardId)) // 评论基线:现有未解决评论不算新
167
- if (all) s.commentBaseline = all
168
- } catch (e) {
169
- const delay = primeRetryMs[attempt]
170
- log(`boardwatch prime(${whiteboardId}): ` + (e && e.message) + (delay ? ` · ${Math.round(delay / 1000)}s 后重试` : ''))
171
- if (!delay) return
172
- const s = st(whiteboardId)
173
- s.primeTimer = setTimer(() => {
174
- s.primeTimer = null
175
- if (s.baseline || !lookupGroup(whiteboardId)) return
176
- return prime(whiteboardId, attempt + 1)
177
- }, delay)
178
- if (s.primeTimer && s.primeTimer.unref) s.primeTimer.unref() // 不为重试拖住进程退出
179
- }
180
- }
181
-
182
- // 事件入口(feishu dispatcher → hooks.onBoardUpdated → 这里)。同一画板连续编辑重置
183
- // 去抖计时器 —— 等用户画完一批再一次性播报,而不是每一笔都响。
184
- function onEvent({ whiteboardId, operatorIds = [] } = {}) {
185
- if (!whiteboardId) return
186
- const group = lookupGroup(whiteboardId)
187
- if (!group) return // 不认识的画板(已换新 / 群已解绑)—— 静默忽略
188
- const s = st(whiteboardId)
189
- // 空 operator 保守当用户(宁多播一次,不吞用户反馈);出现任何非 bot 操作者即标记
190
- if (!operatorIds.length || operatorIds.some((id) => id && id !== botOpenId)) s.sawUser = true
191
- if (s.timer) clearTimer(s.timer)
192
- s.timer = setTimer(() => {
193
- s.timer = null
194
- flush(whiteboardId).catch((e) => log('boardwatch flush: ' + (e && e.message)))
195
- }, debounceMs)
196
- }
197
-
198
- // 有啥说啥的数量串(人话):「N 处标注(2 新增·1 修改) · M 条评论」。纯新增沿用「标注」,含改/删
199
- // 用中性「反馈」;单一变更类型不重复括号明细。changes = {added,edited,deleted}。
200
- function feedbackCount(changes, freshComments) {
201
- const added = (changes && changes.added) || []
202
- const edited = (changes && changes.edited) || []
203
- const deleted = (changes && changes.deleted) || []
204
- const nodeTotal = added.length + edited.length + deleted.length
205
- const parts = []
206
- if (nodeTotal) {
207
- const bd = []
208
- if (added.length) bd.push(`${added.length} 新增`)
209
- if (edited.length) bd.push(`${edited.length} 修改`)
210
- if (deleted.length) bd.push(`${deleted.length} 删除`)
211
- // 纯新增=标注;单一改/删=直说类型(「2 处修改」比「2 处反馈」一眼知性质);混合=反馈+括号明细
212
- if (bd.length > 1) parts.push(`${nodeTotal} 处反馈(${bd.join('·')})`)
213
- else if (edited.length) parts.push(`${nodeTotal} 处修改`)
214
- else if (deleted.length) parts.push(`${nodeTotal} 处删除`)
215
- else parts.push(`${nodeTotal} 处标注`)
216
- }
217
- if ((freshComments || []).length) parts.push(`${freshComments.length} 条评论`)
218
- return parts.join(' · ')
219
- }
220
-
221
- function buildMsg(changes, freshComments = [], boardUrl = null, kind = 'apply') {
222
- const lines = changeDigestLines(changes)
223
- const cnt = feedbackCount(changes, freshComments) || `${lines.length} 项`
224
- const sync = kind === 'sync'
225
- // sync = 用户直接改/删了画布内容(画布已是终态,只需源码收敛);apply = 有新标注/评论(要 agent 产出新版)
226
- const head = sync ? `✏️ 画布内容已修改(${cnt}):` : `📋 画布有新反馈(${cnt}):`
227
- const tail = sync ? '\n同步回源码的话给这条消息点 👍' : '\n处理的话给这条消息点 👍,或群里直接说需求'
228
- return head + '\n' + lines.join('\n') +
229
- (boardUrl ? `\n画布:${boardUrl}` : '') +
230
- tail
231
- }
232
-
233
- // 画布播报卡(Card JSON 2.0;2026-07-22 产品决定:纯文本+点👍 → 卡+点按钮)。callback 按钮点击
234
- // → card.action.trigger 走 WS → onCardAction → takeNotice(卡片 msgId)命中 → 合成消息喂 engine
235
- // (与 onReaction 同款工单)。人看的卡只给数量 + 人话引导,**不怼机器摘要**(digest 的
236
- // `[type] id at(x,y)` 是给 agent 读的工单,人看只觉丑 —— v0.6.8 踩过);技术清单经 noticeDigest 流给
237
- // agent,评论 agent 现拉。标注+评论合一张卡(避免刷屏)。⚠ 2.0 button:样式=type、文字=text 对象。
238
- function buildNoticeCard(changes, freshComments = [], { imgKey = null, boardUrl = null, kind = 'apply' } = {}) {
239
- const nn = (((changes && changes.added) || []).length) + (((changes && changes.edited) || []).length) + (((changes && changes.deleted) || []).length)
240
- const nc = (freshComments || []).length
241
- const nAdded = ((changes && changes.added) || []).length
242
- // sync(v0.7.4)= 用户直接改/删了画布内容(画板已是终态,只需把改动同步回源码,不新增画板版本);
243
- // 其余为 apply(有新标注/评论 = 要 agent 产出新版)。sync 卡语义完全不同,标题/正文/按钮各走一套。
244
- const sync = kind === 'sync'
245
- // 纯新增才叫「新标注」;含改/删(nAdded < nn)用中性「新反馈」,标题不撒谎
246
- const title = sync ? '✏️ 画布内容已修改' : (nn && !nc ? (nAdded === nn ? '🖌 画布有新标注' : '🖌 画布有新反馈') : (!nn && nc ? '💬 画布有新评论' : '🖌 画布有新反馈'))
247
- const btnText = sync ? '🔄 同步到源码' : (nc && !nn ? '✅ 按评论处理' : '✅ 按标注改图')
248
- const bodyText = sync ? '你直接改了画布内容 —— 点下方按钮我把改动同步回源码(不会在画板上新增版本)。' : '点下方按钮确认,我按标注和评论逐条处理,新图放在原图右侧方便对比。'
249
- const confirmBtn = { tag: 'button', type: 'primary', width: 'fill', text: { tag: 'plain_text', content: btnText }, behaviors: [{ type: 'callback', value: { a: 'boardgo' } }] }
250
- const elements = []
251
- // 画板整板预览图:群里不点开也能看到新标注长什么样;点击放大细看
252
- if (imgKey) elements.push({ tag: 'img', img_key: imgKey, scale_type: 'fit_horizontal', corner_radius: '8px', preview: true, alt: { tag: 'plain_text', content: '画板当前内容' } })
253
- elements.push({ tag: 'markdown', content: bodyText })
254
- // 有画板链接 ⇒ 双按钮并排(主=确认开工,次=去画布细看);无 ⇒ 单确认按钮
255
- if (boardUrl) {
256
- elements.push({ tag: 'column_set', horizontal_spacing: 'medium', columns: [
257
- { tag: 'column', width: 'weighted', weight: 1, elements: [confirmBtn] },
258
- { tag: 'column', width: 'weighted', weight: 1, elements: [{ tag: 'button', type: 'default', width: 'fill', text: { tag: 'plain_text', content: '👀 去画布查看' }, behaviors: [{ type: 'open_url', default_url: boardUrl }] }] },
259
- ] })
260
- } else {
261
- elements.push(confirmBtn)
262
- }
263
- elements.push({ tag: 'markdown', text_size: 'notation', content: "<font color='grey'>不急的话可以继续标注/评论,或直接在群里说想法。</font>" })
264
- // 「忽略」低调次要按钮(v0.7.1):功能上「不点」已等于忽略(基线已推进不重播、标注保留),此按钮的价值
265
- // = 消歧(卡不再像活跃待办)+ 防误触(点它消费锚点,防日后误点老卡的 👍 意外触发整轮 agent)。
266
- elements.push({ tag: 'button', type: 'default', width: 'default', text: { tag: 'plain_text', content: '🙈 忽略' }, behaviors: [{ type: 'callback', value: { a: 'boardskip' } }] })
267
- return {
268
- schema: '2.0', config: { width_mode: 'default' },
269
- header: {
270
- title: { tag: 'plain_text', content: title },
271
- subtitle: { tag: 'plain_text', content: sync ? `你在画布上直接改了 ${feedbackCount(changes, freshComments)}` : `你在设计画布上留了 ${feedbackCount(changes, freshComments)} 的反馈` },
272
- template: 'blue',
273
- },
274
- body: { padding: '16px', vertical_spacing: '12px', elements },
275
- }
276
- }
277
-
278
- // 「已忽略」静默灰卡(v0.7.1):点「🙈 忽略」后翻卡定格,无按钮 —— 明确这批反馈已消歧、不再是待办。
279
- function buildSkippedCard() {
280
- return {
281
- schema: '2.0', config: { width_mode: 'default' },
282
- header: { title: { tag: 'plain_text', content: '🙈 已忽略画布反馈' }, template: 'grey' },
283
- body: { padding: '16px', vertical_spacing: '8px', elements: [
284
- { tag: 'markdown', content: '已跳过这批反馈,标注仍保留在画布上。想处理随时在群里说一声;在画布上新加标注或改动文字会再次提醒。' },
285
- ] },
286
- }
287
- }
288
-
289
- // 「处理中」态卡 —— 按钮点击后经 WS 回传的 3s 响应更新播报卡(去掉按钮防重复点)。
290
- // digestLines 只含标注(节点)—— 纯评论轮为空,副标题退成通用「画布反馈」,不谎报「标注」。
291
- function buildAckCard(digestLines, kind = 'apply') {
292
- const n = (digestLines || []).length
293
- const sync = kind === 'sync'
294
- return {
295
- schema: '2.0', config: { width_mode: 'default' },
296
- header: {
297
- title: { tag: 'plain_text', content: sync ? '🔄 同步源码中…' : '🖌 处理画布反馈中…' },
298
- subtitle: { tag: 'plain_text', content: sync ? (n ? `正在同步 ${n} 处画布改动` : '正在同步画布改动') : (n ? `正在处理 ${n} 处画布反馈(评论一并处理)` : '正在处理画布反馈') },
299
- template: 'blue',
300
- },
301
- body: { padding: '16px', vertical_spacing: '8px', elements: [
302
- { tag: 'markdown', content: sync ? '正在把你在画布上的改动同步回源码 —— 不会新增画板版本,完成后群里复述。' : '已开工,处理完会把结果更新到这张卡,并在群里逐条复述改动。' },
303
- ] },
304
- }
305
- }
306
-
307
- // 播报送达后记消息 id + 本轮标注摘要 ⇒ 按钮/表情确认的锚点与工单内容(触发消息自带标注清单,
308
- // agent 明确知道这轮处理哪几条 —— 不靠删除标注区分新旧,2026-07-22 产品决定「不删标注」后的
309
- // 机制)。新播报覆盖旧锚点(只认最新一条)。发卡优先(notifyCard),失败/未注入退回文本。
310
- // 通知形态分流(v0.7.4):纯「改文字/删除」且无新增、无新评论 ⇒ 'sync'(用户直接改了画布,画板已是
311
- // 终态,确认后只把改动同步回源码、不重渲不新增版本);其余 ⇒ 'apply'(有新标注/评论 = 要 agent 产出新版)。
312
- // 内容节点类型:图表框/图片 —— 用户直接改这些 = 改「成品内容」,该 sync(收敛回源码不出新版)。
313
- // 标注类型(sticky_note/connector/自由 text_shape/paint)不在内:改它们是在**细化意见**,该走 apply
314
- // 产出新版(审查 M1:纯结构判据会把「改一条便签」误当 sync 丢掉意图)。text_shape 有歧义(可能是
315
- // 图表标签也可能是自由批注)⇒ 保守归 apply —— applyText 已教 agent 分辨图表内容 vs 便签措辞,不损。
316
- const CONTENT_TYPES = new Set(['composite_shape', 'mind_map', 'image'])
317
- function noticeKindOf(changes, freshComments) {
318
- const edited = (changes && changes.edited) || []
319
- const deleted = (changes && changes.deleted) || []
320
- const added = (changes && changes.added) || []
321
- if (added.length || (freshComments || []).length) return 'apply' // 有新增/新评论 = 要产出新版
322
- if (!edited.length && !deleted.length) return 'apply' // 无变化(不应走到发卡)
323
- // 纯删除(edited 空)= 用户删定了内容,同步语义;有编辑则要求**全部**是内容节点才 sync
324
- return edited.every((n) => n && CONTENT_TYPES.has(n.type)) ? 'sync' : 'apply'
325
- }
326
-
327
- async function notifyAndTrack(s, whiteboardId, group, changes, freshComments = []) {
328
- const chatId = group.chatId
329
- const kind = noticeKindOf(changes, freshComments)
330
- // 预览聚焦用「有几何」的变更(新增+编辑);删除无几何跳过。
331
- const geoNodes = ((changes && changes.added) || []).concat((changes && changes.edited) || [])
332
- // notifyCard 返回 { msgId, cardId }(cardId=实体卡回填锚点);旧式/文本 notify 返回裸 msgId 字符串。
333
- let mid = null
334
- let cardId = null
335
- if (notifyCard) {
336
- // 画板预览图尽力而为(导出+裁剪+上传约 1-3s,轮询后台无感;失败/无注入 ⇒ 无图卡照发,绝不
337
- // 阻断播报)。geoNodes 传下去 ⇒ 预览聚焦到新标注附近而非整板。
338
- let imgKey = null
339
- if (previewImage) imgKey = await Promise.resolve(previewImage(whiteboardId, geoNodes)).catch((e) => { log('boardwatch preview: ' + (e && e.message)); return null })
340
- const r = await Promise.resolve(notifyCard(chatId, buildNoticeCard(changes, freshComments, { imgKey, boardUrl: group.url || null, kind }))).catch((e) => { log('boardwatch notifyCard: ' + (e && e.message)); return null })
341
- if (r) { mid = (typeof r === 'object') ? r.msgId : r; cardId = (typeof r === 'object' && r.cardId) || null }
342
- }
343
- if (!mid) mid = await Promise.resolve(notify(chatId, buildMsg(changes, freshComments, group.url || null, kind))).catch((e) => { log('boardwatch notify: ' + (e && e.message)); return null })
344
- if (mid) { s.noticeMsgId = mid; s.noticeCardId = cardId; s.noticeDigest = changeDigestLines(changes); s.noticeKind = kind }
345
- }
346
-
347
- // 确认入口(bin 的 onReaction/onCardAction → 这里):msgId 命中某画布最新播报 → 消费并返回归属
348
- // + 该轮标注摘要 + 播报卡实体 cardId(单卡回填锚点)。消费即清 ⇒ 多人点/重复点只触发一次;
349
- // 未命中返回 null(不相干消息)。opts.peek=true 只查不消费 —— 暂停群检查等「先看后拒」场景用
350
- // (拒绝时不消费,恢复后用户再点仍有效)。
351
- function takeNotice(msgId, opts = {}) {
352
- if (!msgId) return null
353
- for (const [wb, s] of boards) {
354
- if (s.noticeMsgId && s.noticeMsgId === msgId) {
355
- const digestLines = s.noticeDigest || []
356
- const cardId = s.noticeCardId || null
357
- const kind = s.noticeKind || 'apply' // 'sync'(直接改画布,只同步源码)/ 'apply'(有新标注/评论,出新版)
358
- if (!opts.peek) { s.noticeMsgId = null; s.noticeDigest = null; s.noticeCardId = null; s.noticeKind = null }
359
- const g = lookupGroup(wb)
360
- return g ? { whiteboardId: wb, chatId: g.chatId, digestLines, cardId, kind } : null
361
- }
362
- }
363
- return null
364
- }
365
-
366
- async function flush(whiteboardId) {
367
- const group = lookupGroup(whiteboardId)
368
- const s = st(whiteboardId)
369
- const sawUser = s.sawUser
370
- s.sawUser = false
371
- syncOps(s, whiteboardId)
372
- let nodes
373
- try {
374
- nodes = await listNodes(whiteboardId)
375
- } catch (e) {
376
- log(`boardwatch read(${whiteboardId}): ` + (e && e.message))
377
- return // 基线保持不动 —— 下次事件重试,不丢用户这批改动的可播报性
378
- }
379
- const prev = s.baseline
380
- const changes = diffBaseline(prev, nodes)
381
- s.baseline = changes.curMap
382
- s.lastFp = fingerprint(nodes)
383
- s.pendingRounds = 0 // 事件路径播完即结算,轮询侧不再押这批
384
- if (!group || !sawUser || !prev) { if (!prev) s.opsCursor = now(); return } // 无归属 / 纯 bot 编辑 / 无基线(静默建线防重放;游标同步推进)
385
- if (!changes.added.length && !changes.edited.length && !changes.deleted.length) return // 纯移动/改样式 —— 不进播报范围(事件路径不拉评论)
386
- await notifyAndTrack(s, whiteboardId, group, changes)
387
- }
388
-
389
- // 轮询主入口(每 pollMs 对单画板跑一次)。
390
- async function poll(whiteboardId) {
391
- const group = lookupGroup(whiteboardId)
392
- if (!group) return
393
- const s = st(whiteboardId)
394
- syncOps(s, whiteboardId)
395
- let nodes
396
- try {
397
- nodes = await listNodes(whiteboardId)
398
- } catch (e) {
399
- log(`boardwatch poll(${whiteboardId}): ` + (e && e.message))
400
- return // 基线/pending 不动,下轮重试
401
- }
402
- const fp = fingerprint(nodes)
403
- const { all: allComments, fresh: freshComments } = await fetchComments(s, group) // 评论第二通道(缺省=空)
404
- if (!s.baseline) { s.baseline = baselineMap(nodes); s.lastFp = fp; s.opsCursor = now(); if (allComments) s.commentBaseline = allComments; return } // 静默建线(冷启动不重放整板;游标推进防历史 ops 重放,同 prime)
405
- const changed = s.lastFp !== fp
406
- s.lastFp = fp
407
- const changes = diffBaseline(s.baseline, nodes)
408
- const meaningful = changes.added.length || changes.edited.length || changes.deleted.length
409
- if (!meaningful && !freshComments.length) { s.baseline = changes.curMap; if (allComments) s.commentBaseline = allComments; s.pendingRounds = 0; return } // 纯移/样式 + 无新评论 → 静默吸收
410
- // 仍在编辑节点 → 押后一轮(评论一起等,避免节点半途 + 评论各发一张卡);评论是离散帖,无节点变化时
411
- // 指纹不变 changed=false ⇒ 不押、立即播(只加评论也能被感知,不受节点静默期拖累)。
412
- if (changed && s.pendingRounds < holdRounds) { s.pendingRounds++; return }
413
- // 安静满一周期(或押满 holdRounds)→ 播累计变化(新增/改文字/删除 + 评论),推进两条基线
414
- s.baseline = changes.curMap
415
- if (allComments) s.commentBaseline = allComments
416
- s.pendingRounds = 0
417
- await notifyAndTrack(s, whiteboardId, group, changes, freshComments)
418
- }
419
-
420
- async function pollAll() {
421
- for (const wb of (listBoards && listBoards()) || []) {
422
- try { await poll(wb) } catch (e) { log(`boardwatch poll(${wb}): ` + (e && e.message)) }
423
- }
424
- }
425
-
426
- let pollTimer = null
427
- let polling = false
428
- function startPoll() {
429
- if (!pollMs || !listBoards || polling) return
430
- polling = true
431
- const loop = () => {
432
- if (!polling) return
433
- // 回调返回 promise 链:真 timer 不在乎,注入的测试 timer 可 await 到整轮完成
434
- pollTimer = setTimer(() => pollAll().catch(() => {}).then(loop), pollMs)
435
- if (pollTimer && pollTimer.unref) pollTimer.unref() // 不为轮询拖住进程退出
436
- }
437
- loop()
438
- }
439
- function stopPoll() {
440
- polling = false
441
- if (pollTimer) { clearTimer(pollTimer); pollTimer = null }
442
- }
443
-
444
- return { prime, onEvent, startPoll, stopPoll, takeNotice, buildNoticeCard, buildAckCard, buildSkippedCard, _flush: flush, _poll: poll, _pollAll: pollAll, _boards: boards } // _ 前缀仅供单测
445
- }
446
-
447
- module.exports = { createBoardWatch }
@@ -1,45 +0,0 @@
1
- // CLI platform — a stub that satisfies the Platform contract (interfaces.js)
2
- // so the engine can be driven from the terminal with no Feishu/credentials.
3
- // Each stdin line becomes one InboundMessage; replies print to stdout.
4
- //
5
- // Lets us validate the whole engine loop (routing, per-group session/lock/queue,
6
- // dedup/watermark, slash commands, resume) before the real WS adapter exists.
7
-
8
- const readline = require('node:readline')
9
-
10
- function createCliPlatform({ chatId = 'cli', userId = 'local', senderName = 'you', prompt = 'sbot> ' } = {}) {
11
- let seq = 0
12
- let rl = null
13
- let onMessage = null
14
-
15
- return {
16
- async start(cb) {
17
- onMessage = cb
18
- rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt })
19
- rl.prompt()
20
- rl.on('line', async (line) => {
21
- const text = line.trim()
22
- if (!text) return rl.prompt()
23
- if (text === '/exit' || text === '/quit') return rl.close()
24
- const msg = {
25
- chatId,
26
- userId,
27
- senderName,
28
- text,
29
- msgId: `cli-${++seq}`,
30
- createTimeMs: Date.now(),
31
- replyCtx: { chatId },
32
- }
33
- try { await onMessage(msg) } catch (e) { process.stderr.write('err: ' + (e && e.message) + '\n') }
34
- rl.prompt()
35
- })
36
- rl.on('close', () => process.exit(0))
37
- },
38
-
39
- async reply(_ctx, content) { process.stdout.write('\n' + content + '\n') },
40
- async send(_chatId, content) { process.stdout.write('\n' + content + '\n') },
41
- async stop() { if (rl) rl.close() },
42
- }
43
- }
44
-
45
- module.exports = { createCliPlatform }