sintbot 1.4.10 → 1.4.12

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.
@@ -0,0 +1,276 @@
1
+ /* Injected into the console's existing script; no framework or extra connection. */
2
+ const activityPanel = (() => {
3
+ let root, groupBody, listBody, taskBody, selected = '', count = 5
4
+ try { selected = sessionStorage.getItem('sintbot.activity.group') || '' } catch {}
5
+ let epoch = '', revision = -1, loaded = false, pending = null, frames = [], scheduled = false
6
+ const rows = new Map(), groups = new Map(), detailCache = new Map(), detailPending = new Set(), detailErrors = new Map(), open = new Set()
7
+ const groupNodes = new Map(), rowNodes = new Map(), actionPending = new Set()
8
+ let lang = '', error = false, connected = true, focusBeforeRender = null, groupScroll = 0
9
+ const key = r => r.chatId + ':' + r.id
10
+ const en = () => langOf() === 'en'
11
+ const tr = (zh, english) => en() ? english : zh
12
+ const labels = {
13
+ queued: ['排队', 'Queued'], running: ['运行中', 'Running'], sending: ['回复发送中', 'Sending reply'],
14
+ answered: ['已回复', 'Replied'], silent: ['无需回复', 'No reply needed'], stopped: ['已停止', 'Stopped'],
15
+ recalled: ['已撤回', 'Recalled'], timeout: ['已超时', 'Timed out'], error: ['失败', 'Failed'],
16
+ 'not-started': ['未启动', 'Not started'], unknown: ['结果待核实', 'Unconfirmed'],
17
+ '已结账': ['历史记录已结账', 'Historical receipt settled'], '等待处理': ['等待处理', 'Waiting'],
18
+ }
19
+ const status = s => labels[s] ? tr(...labels[s]) : s
20
+ const active = r => ['queued', 'running', 'sending', '等待处理'].includes(r.status)
21
+ const num = n => n == null ? '—' : n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(n)
22
+ const stamp = ts => ts ? new Date(ts).toLocaleTimeString(en() ? 'en-GB' : 'zh-CN', { hour: '2-digit', minute: '2-digit', timeZone: S?.timezone || 'Asia/Shanghai' }) : '—'
23
+ const node = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n }
24
+ const text = (n, value) => { if (n.textContent !== value) n.textContent = value }
25
+ function schedule() {
26
+ if (scheduled || document.hidden) return
27
+ scheduled = true; requestAnimationFrame(() => { scheduled = false; paint() })
28
+ }
29
+ function setup() {
30
+ if (root) return
31
+ root = node('section', 'la'); root.setAttribute('data-i18n-skip', '')
32
+ const style = node('style'); style.textContent = `
33
+ .la{margin:20px 0}.la [hidden]{display:none!important}.la .la-panel{padding:0;overflow:hidden}
34
+ .la .la-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px;border-bottom:1px solid var(--line)}
35
+ .la h2{font-size:15px;font-weight:600;letter-spacing:0;margin:0}.la .la-capacity{font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums;text-align:right}
36
+ .la .la-select,.la .la-toggle{font:inherit;width:100%;border:0;border-radius:0;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:48px}
37
+ .la .la-group{position:relative;display:flex;align-items:center;border-bottom:1px solid var(--line);transition:background .18s ease}.la .la-select{flex:1;min-width:0;display:grid;grid-template-columns:minmax(160px,1.2fr) minmax(120px,1fr) minmax(160px,1fr);gap:20px;padding:15px 20px 15px 24px}.la .la-select>div{min-width:0}
38
+ .la .la-group:last-child{border-bottom:0}.la .la-group:before{content:'';position:absolute;inset:12px auto 12px 0;width:3px;border-radius:0 3px 3px 0;background:var(--ember);opacity:0;transform:scaleY(.4);transition:opacity .18s,transform .18s}
39
+ .la .la-select strong,.la .la-select small,.la .la-model>span,.la .la-context>span{display:block;white-space:normal;overflow-wrap:anywhere}.la .la-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}.la .la-tags .tag{font-size:10px;padding:2px 6px}.la .la-tags [data-status=running]{color:var(--ember);border-color:color-mix(in srgb,var(--ember) 30%,var(--line))}.la .la-tags [data-status=attention]{color:var(--fail)}.la .la-path{font-family:var(--mono);font-size:10.5px}
40
+ .la .la-group:hover,.la .la-toggle:hover{background:color-mix(in srgb,var(--ember) 4%,transparent)}
41
+ .la .la-group.selected{background:color-mix(in srgb,var(--ember) 7%,transparent)}.la .la-group.selected:before{opacity:1;transform:scaleY(1)}
42
+ .la button:focus-visible{outline:2px solid var(--ember);outline-offset:-3px}.la button:active:not(:disabled){background:color-mix(in srgb,var(--ember) 11%,transparent)}
43
+ .la small{display:block;color:var(--dim);font-size:11px;line-height:1.5;margin-top:4px}.la strong{font-size:13px;font-weight:600}.la .la-model{font-size:12px}.la .la-model small[data-status=running]{color:var(--ember)}.la .la-model small[data-status=attention]{color:var(--fail)}.la .la-context{font-size:12px;font-variant-numeric:tabular-nums}
44
+ .la .la-track{height:3px;margin-top:8px;background:var(--line);overflow:hidden;border-radius:2px}.la .la-fill{height:100%;background:var(--ember);transform-origin:left;transition:transform .3s ease-out;transform:scaleX(0)}
45
+ .la .la-groups{max-height:560px;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
46
+ .la .la-task-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:22px 0 12px}.la .la-task-head strong{font-size:14px;overflow-wrap:anywhere}
47
+ .la .la-actions{display:flex;gap:6px;flex-shrink:0;padding-right:24px}.la .la-actions .iconbtn{padding:0;transition:background .15s,border-color .15s,transform .15s}.la .la-actions .iconbtn:active:not(:disabled){transform:scale(.92)}.la button:disabled{cursor:default;opacity:.45}
48
+ .la .la-list{padding:0;overflow:hidden}.la .la-row{border-bottom:1px solid var(--line)}.la .la-row:last-child{border-bottom:0}
49
+ .la .la-toggle{padding:16px 20px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;transition:background .15s}
50
+ .la .la-title{font-size:13px;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:5px;overflow-wrap:anywhere}
51
+ .la .la-preview{font-size:12px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:5px}
52
+ .la .la-state{font-size:11px;color:var(--dim);white-space:nowrap;padding-top:4px}.la .la-state:after{content:'';display:inline-block;width:5px;height:5px;border-right:1px solid currentColor;border-bottom:1px solid currentColor;transform:rotate(45deg);margin:0 2px 3px 14px;transition:transform .18s}
53
+ .la [aria-expanded=true] .la-state:after{transform:rotate(225deg)}.la .la-state[data-state=running],.la .la-state[data-state=sending]{color:var(--ember)}
54
+ .la .la-state[data-state=error],.la .la-state[data-state=unknown],.la .la-state[data-state=timeout]{color:var(--fail)}
55
+ .la .la-fold{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .2s ease,opacity .2s ease}.la .la-fold.open{grid-template-rows:1fr;opacity:1}
56
+ .la .la-fold-inner{min-height:0;overflow:hidden}.la .la-detail{padding:0 20px 18px;font-size:13px;line-height:1.7;overflow-wrap:anywhere}
57
+ .la .la-detail .aexp{margin:0;max-height:360px;white-space:normal}.la .la-detail .aereply{white-space:pre-wrap}.la .la-detail .aemeta{gap:6px 16px;white-space:normal}.la .la-detail .la-source{margin-top:10px;border-top:1px solid var(--line);padding-top:8px}.la .la-source summary{cursor:pointer;color:var(--dim);font-size:11px}.la .la-source summary:focus-visible{outline:2px solid var(--ember);outline-offset:3px}.la .la-detail .la-error{color:var(--fail)}.la .la-detail p{white-space:pre-wrap;margin:8px 0}.la .la-detail h3{font-size:11px;color:var(--dim);margin:16px 0 5px;font-weight:500}.la .la-notice{font-size:12px;color:var(--dim);margin:12px 0;line-height:1.6}
58
+ .la .la-more{margin:12px auto 0;display:block;min-height:40px;font-size:12px}
59
+ @media(max-width:640px){.la .la-head{padding:16px;gap:10px}.la .la-capacity{font-size:11px}.la .la-group{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.la .la-select{grid-column:1/-1;grid-row:1;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px 16px;padding:14px 16px}.la .la-select>div:first-child{padding-right:94px;grid-column:1/-1}.la .la-actions{grid-column:2;grid-row:1;z-index:1;padding:14px 16px 0 0}.la .la-model{grid-column:1/-1}.la .la-context{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:0 8px}.la .la-context small{margin:0;font-size:10px}.la .la-track{grid-column:1/-1;margin-top:6px}.la .la-task-head{align-items:flex-start;flex-direction:column;gap:10px}.la .la-actions{flex-wrap:wrap}.la .la-toggle{grid-template-columns:minmax(0,1fr);gap:7px;padding:14px 16px}.la .la-state{white-space:normal;padding:0}.la .la-state:after{float:right}.la .la-detail{padding:0 16px 16px}}
60
+ @media(prefers-reduced-motion:reduce){.la *{transition:none!important;animation:none!important}}`
61
+ root.append(style)
62
+ const panel = node('div', 'card la-panel'), head = node('div', 'la-head')
63
+ head.append(node('h2'), node('span', 'la-capacity')); panel.append(head)
64
+ groupBody = node('div', 'la-groups'); panel.append(groupBody); root.append(panel)
65
+ const note = node('div', 'la-notice'); note.dataset.la = 'note'; note.setAttribute('role', 'status'); root.append(note)
66
+ taskBody = node('div', 'la-task-body'); taskBody.hidden = true
67
+ const bar = node('div', 'la-task-head'), title = node('strong'); title.dataset.la = 'activity'; bar.append(title)
68
+ taskBody.append(bar)
69
+ listBody = node('div', 'card la-list'); taskBody.append(listBody)
70
+ const more = node('button', 'btn la-more'); more.dataset.la = 'more'; more.onclick = () => { count += 5; schedule() }; taskBody.append(more)
71
+ root.append(taskBody)
72
+ }
73
+ async function act(action, button, chatId) {
74
+ if (!chatId || button.disabled) return
75
+ if (action === 'say') { openSay(chatId); return }
76
+ const actionKey = chatId + ':' + action
77
+ actionPending.add(actionKey); button.disabled = true
78
+ try {
79
+ const result = await api(action === 'stop' ? '/api/groups/stop' : '/api/groups/interrupt','POST',{chatId})
80
+ toast(action === 'new' ? tr('已开新会话','New session started') : result.ok ? tr('已请求停止,等待结束确认','Stop requested; waiting for confirmation') : tr('当前没有可停止的运行任务','No running task to stop'),'ok')
81
+ await sync()
82
+ } catch (e) { toast(e.message,'err') }
83
+ finally { actionPending.delete(actionKey); schedule() }
84
+ }
85
+
86
+ function groupNode(g) {
87
+ let n = groupNodes.get(g.chatId)
88
+ if (!n) {
89
+ n = node('div', 'la-group'); n.dataset.chatId = g.chatId
90
+ const select = node('button', 'la-select'); select.type = 'button'
91
+ const a = node('div'), b = node('div', 'la-model'), c = node('div', 'la-context')
92
+ const tags = node('div','la-tags'); tags.append(node('span','tag prov'),node('span','tag'))
93
+ a.append(node('strong'), tags, node('small','la-path')); b.append(node('span'), node('small'))
94
+ const track = node('div', 'la-track'); track.append(node('div', 'la-fill'))
95
+ c.append(node('span'), node('small'), track); select.append(a, b, c); n.append(select)
96
+ const actions = node('div','la-actions')
97
+ for (const [action,icon] of [['say','say'],['stop','square'],['new','rotate']]) {
98
+ const button = node('button','iconbtn sm'); button.type = 'button'; button.dataset.action = action
99
+ button.innerHTML = IC[icon]; button.onclick = () => void act(action,button,g.chatId); actions.append(button)
100
+ }
101
+ n.append(actions)
102
+ select.onclick = () => {
103
+ if (selected === g.chatId) return
104
+ selected = g.chatId; count = 5
105
+ try { sessionStorage.setItem('sintbot.activity.group', selected) } catch {}
106
+ schedule(); requestAnimationFrame(() => {
107
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
108
+ taskBody.getAnimations().forEach(animation => animation.cancel())
109
+ taskBody.animate([{opacity:0,transform:'translateY(4px)'},{opacity:1,transform:'translateY(0)'}],{duration:180,easing:'ease-out'})
110
+ }
111
+ })
112
+ }
113
+ groupNodes.set(g.chatId, n)
114
+ }
115
+ const [a,b,c] = n.children[0].children, ctx = g.context
116
+ text(a.children[0], g.name); text(a.children[2], g.workdir ? shortPath(g.workdir) : g.project)
117
+ a.children[2].title = g.workdir || g.project
118
+ text(a.children[1].children[0], g.runner === 'codex' ? 'codex' : 'claude')
119
+ const state = a.children[1].children[1]
120
+ text(state, g.held || g.queueFaulted ? tr('需要处理','Needs attention') : g.paused ? tr('已暂停','Paused') : g.finalizing ? tr('收尾中','Finishing') : g.waitingForCapacity ? tr('等待容量','Waiting for capacity') : g.busy ? 'busy' : 'idle')
121
+ state.dataset.status = g.held || g.queueFaulted ? 'attention' : g.busy ? 'running' : 'idle'
122
+ if (g.busy) text(state, state.textContent + ' ' + Math.floor((g.sinceMs || 0)/1000) + 's')
123
+ if (g.queue) text(state, state.textContent + ' · ' + tr('排队 ','Queued ') + g.queue)
124
+ text(b.children[0], g.model)
125
+ text(b.children[1], tr('思考深度 · ','Reasoning · ') + (g.reasoningEffort || tr('默认','Default')))
126
+ b.children[1].title = tr('当前模型配置;未指定时使用模型默认值','Current model configuration; unspecified uses the model default')
127
+ const blocked = !S?.live?.running || g.paused || g.held || g.queueFaulted
128
+ const busy = g.busy || g.pending > 0 || g.queue > 0 || g.waitingForCapacity || g.finalizing
129
+ for (const [action,zh,eng] of [['say','插话','Message'],['stop','停止任务','Stop task'],['new','新会话','New session']]) {
130
+ const button = n.querySelector('[data-action='+action+']')
131
+ button.disabled = actionPending.has(g.chatId + ':' + action) || blocked || (action === 'stop' ? !busy : !!busy || action === 'say' && !!g.sayBlocked)
132
+ button.setAttribute('aria-label',tr(zh,eng) + ' · ' + g.name)
133
+ button.title = tr(zh,eng) + ' · ' + (button.disabled ? tr('当前状态不可操作','Unavailable in the current state') : action === 'say' ? tr('这句话和回复都会发进该群','Your message and reply will be sent to this group') : action === 'new' ? tr('开始新会话,清除当前会话上下文','Start a new session with fresh context') : tr('停止当前任务,保留会话','Stop the task and keep the session'))
134
+ }
135
+ const valid = ctx?.usedTokens != null, capacity = ctx?.windowTokens > 0
136
+ const percent = valid && capacity ? Math.round(ctx.usedTokens / ctx.windowTokens * 100) : null
137
+ text(c.children[0], valid ? `${num(ctx.usedTokens)}${capacity ? ' / ' + num(ctx.windowTokens) + ' · ' + percent + '%' : ' · ' + tr('容量未知','capacity unknown')}` : ctx?.quality === 'compacted' ? tr('压缩后待上报','Awaiting usage after compaction') : !g.sessionId && !g.busy ? tr('未开始','No session') : tr('待上报','Awaiting usage'))
138
+ const quality = ctx?.quality === 'reported' ? tr('上下文 · 最新上报','Context · Latest report') : ctx?.quality === 'historical' ? tr('上下文 · 历史采样','Context · Historical sample') : tr('上下文 · 上次上报','Context · Last report')
139
+ text(c.children[1], ctx ? quality + ' · ' + stamp(ctx.measuredAt) : tr('上下文','Context'))
140
+ c.children[2].children[0].style.transform = 'scaleX(' + (percent == null ? 0 : Math.max(0, Math.min(100, percent)) / 100) + ')'
141
+ n.title = g.name + ' · ' + g.project + ' · ' + g.model + ' · ' + g.chatId.slice(-6)
142
+ n.classList.toggle('selected',selected === g.chatId); n.children[0].setAttribute('aria-pressed', String(selected === g.chatId)); return n
143
+ }
144
+ async function showDetail(r, n) {
145
+ const k = key(r), body = n.querySelector('.la-detail')
146
+ if (!open.has(k)) return
147
+ if (!r.details) { text(body, tr('历史记录未保存消息和回复详情。','Message and reply details were not recorded.')); return }
148
+ if (detailErrors.get(k) === r.detailRevision) return
149
+ let d = detailCache.get(k)
150
+ if (!d || d.detailRevision !== r.detailRevision) {
151
+ if (detailPending.has(k)) return
152
+ detailPending.add(k)
153
+ text(body, tr('加载详情…','Loading details…'))
154
+ try {
155
+ const value = await api('/api/activity/detail?chatId=' + encodeURIComponent(r.chatId) + '&id=' + encodeURIComponent(r.id))
156
+ if (epoch !== value.epoch) return
157
+ d = { ...value, lang: langOf() }; detailCache.set(k, d)
158
+ while (detailCache.size > 40) detailCache.delete(detailCache.keys().next().value)
159
+ } catch { detailErrors.set(k,r.detailRevision);text(body, tr('详情暂不可用,收起后可重新打开。','Details unavailable. Close and reopen to retry.')); return }
160
+ finally { detailPending.delete(k) }
161
+ }
162
+ if (!open.has(k) || !n.isConnected) return
163
+ const sig = d.detailRevision + ':' + langOf()
164
+ if (body.dataset.rev === sig) return
165
+ body.dataset.rev = sig; body.replaceChildren()
166
+ const box = node('div','aexp'); body.append(box)
167
+ if (d.tools?.length) {
168
+ const tools = node('div','aetools')
169
+ for (const tool of d.tools) {
170
+ const chip = node('span','tchip'); chip.innerHTML = IC.wrench
171
+ chip.append(document.createTextNode(' ' + tool.name + (tool.count > 1 ? ' ×' + tool.count : ''))); tools.append(chip)
172
+ }
173
+ box.append(tools)
174
+ }
175
+ box.append(node('div',d.reply ? 'aereply' : 'faint',d.reply || (active(d) ? tr('任务进行中,等待回复…','Task in progress; awaiting reply…') : tr('(无回复内容)','(No reply content)'))))
176
+ if (d.err) box.append(node('p','la-error',d.err))
177
+ const meta = node('div','aemeta')
178
+ function metric(label,value) { const item = node('span'); item.append(node('b','',label+' '),document.createTextNode(value)); meta.append(item) }
179
+ if (d.sender) metric(tr('发送者','Sender'),d.sender)
180
+ metric(tr('状态','Status'),status(d.status))
181
+ if (d.startedAt) metric(tr('排队','Queued'),(Math.max(0,d.startedAt-d.ts)/1000).toFixed(1)+'s')
182
+ if (d.ms != null) metric(tr('耗时','Duration'),(d.ms/1000).toFixed(1)+'s')
183
+ if (d.tokens) {
184
+ const cr=d.tokens.cr||0,cw=d.tokens.cw||0
185
+ metric('tokens',`↑${num((d.tokens.in||0)+cr+cw)} ↓${num(d.tokens.out||0)}` + (cr||cw ? ' · '+tr('缓存读 ','Cache read ')+num(cr)+tr(' / 写 ',' / write ')+num(cw) : ''))
186
+ }
187
+ box.append(meta)
188
+ if (d.messages?.length || d.milestones?.length) {
189
+ const source = node('details','la-source'); source.append(node('summary','',tr('请求、补充与阶段进度','Request, follow-ups and progress')))
190
+ for (const message of d.messages || []) source.append(node('p','',(message.sender || '—')+' · '+(message.state==='landed'?tr('已读到','Read'):tr('已受理','Accepted'))+'\n'+message.text))
191
+ for (const milestone of d.milestones || []) source.append(node('p','',milestone))
192
+ box.append(source)
193
+ }
194
+ if (d.truncated) box.append(node('p','faint',tr('内容较长,记录已截断。','Long content was truncated in this record.')))
195
+
196
+ }
197
+ function rowNode(r) {
198
+ const k = key(r); let n = rowNodes.get(k)
199
+ if (!n) {
200
+ n = node('article', 'la-row'); const button = node('button','la-toggle'); button.type = 'button'
201
+ const lead = node('div'); lead.append(node('small'),node('div','la-title'),node('div','la-preview'))
202
+ button.append(lead,node('span','la-state')); n.append(button)
203
+ const fold = node('div','la-fold'), inner = node('div','la-fold-inner'), detail = node('div','la-detail')
204
+ fold.id = 'la-detail-' + rowNodes.size + '-' + Math.random().toString(36).slice(2,8)
205
+ inner.append(detail); fold.append(inner); n.append(fold)
206
+ button.setAttribute('aria-controls', fold.id)
207
+ button.onclick = () => { open.has(k) ? open.delete(k) : open.add(k);detailErrors.delete(k);schedule() }
208
+ rowNodes.set(k,n)
209
+ }
210
+ const b = n.children[0], lead = b.children[0], group = groups.get(r.chatId)
211
+ text(lead.children[0], stamp(r.ts) + ' · ' + (r.sender || '—'))
212
+ text(lead.children[1], r.text || tr('内容未记录','Content not recorded'))
213
+ text(lead.children[2], active(r) ? (r.doing || []).join(' · ') : r.preview || '')
214
+ text(b.children[1], status(r.status) + (r.messageCount > 1 ? ' · ' + tr('消息 ','messages ') + r.messageCount : '') + (r.ms ? ' · ' + (r.ms/1000).toFixed(1)+'s' : ''))
215
+ b.children[1].dataset.state = r.status; b.setAttribute('aria-expanded', String(open.has(k)))
216
+ n.children[1].classList.toggle('open', open.has(k)); n.children[1].inert = !open.has(k)
217
+ if (open.has(k)) void showDetail(r,n)
218
+ return n
219
+ }
220
+ function reconcile(container, nodes) {
221
+ const wanted = new Set(nodes)
222
+ for (const child of [...container.children]) if (!wanted.has(child)) child.remove()
223
+ nodes.forEach((n,i) => { if (container.children[i] !== n) container.insertBefore(n,container.children[i] || null) })
224
+ }
225
+ function paint() {
226
+ if (!root?.isConnected) return
227
+ lang = langOf()
228
+ text(root.querySelector('h2'),tr('群 · 项目','Groups · Projects'))
229
+ const concurrency = S?.live?.concurrency, capacity = concurrency?.max ?? S?.agent?.maxConcurrent ?? 0
230
+ text(root.querySelector('.la-capacity'), tr('运行 ','Running ') + (concurrency?.active ?? [...groups.values()].filter(g=>g.busy).length) + (capacity > 0 ? ' / ' + capacity : '') + ' · ' + tr('排队 ','Queued ') + (S?.live?.queue || 0))
231
+ if (loaded && selected && !groups.has(selected)) { selected = ''; try { sessionStorage.removeItem('sintbot.activity.group') } catch {} }
232
+ reconcile(groupBody, groups.size ? [...groups.values()].map(groupNode) : [node('div','empty',loaded ? tr('暂无群项目','No projects') : tr('正在同步…','Syncing…'))])
233
+ const group = groups.get(selected)
234
+ taskBody.hidden = !group
235
+ text(root.querySelector('[data-la=activity]'), (group?.name || '') + ' · ' + tr('任务活动','Task activity'))
236
+ const all = [...rows.values()].filter(r => selected && r.chatId===selected)
237
+ .sort((a,b) => Number(!active(a))-Number(!active(b)) || b.ts-a.ts)
238
+ const shown = all.filter(active).concat(all.filter(r=>!active(r)).slice(0,count))
239
+ reconcile(listBody, shown.length ? shown.map(rowNode) : [node('div','empty',loaded ? tr('该群暂无任务活动','No task activity for this group') : tr('正在同步…','Syncing…'))])
240
+ const more = root.querySelector('[data-la=more]'); more.hidden=shown.length>=all.length;text(more,tr('查看更早的任务','Show earlier tasks'))
241
+ const note = root.querySelector('[data-la=note]')
242
+ text(note, !connected ? tr('连接中断,当前为最后收到的记录','Disconnected — showing last received records') : error ? tr('部分活动记录暂不可用','Some activity records are unavailable') : !selected ? tr('选择一个群项目,查看任务活动','Select a project to view its tasks') : '')
243
+ note.hidden = !note.textContent
244
+ for (const id of groupNodes.keys()) if (!groups.has(id)) groupNodes.delete(id)
245
+ for (const k of rowNodes.keys()) if (!rows.has(k)) { rowNodes.delete(k); open.delete(k); detailCache.delete(k);detailErrors.delete(k) }
246
+ }
247
+ function apply(data, full) {
248
+ if (!data?.epoch) return
249
+ if (loaded && data.epoch===epoch && data.revision<revision) return
250
+ if (full || data.epoch!==epoch) { rows.clear(); groups.clear() }
251
+ if (data.epoch!==epoch) { detailCache.clear(); open.clear(); revision=-1 }
252
+ epoch=data.epoch;revision=data.revision;error=!!data.storageError
253
+ for (const r of data.activity || []) rows.set(key(r),r)
254
+ for (const g of data.groups || []) groups.set(g.chatId,g)
255
+ for (const k of data.removed || []) rows.delete(k)
256
+ loaded=true;schedule()
257
+ }
258
+ function sync() {
259
+ if (pending) return pending
260
+ frames=[]
261
+ pending=api('/api/activity?limit=100').then(data=>{
262
+ apply(data,true); for(const frame of frames)apply(frame,false)
263
+ }).catch(()=>{error=true;schedule()}).finally(()=>{pending=null;frames=[]})
264
+ return pending
265
+ }
266
+ document.addEventListener('visibilitychange',()=>{if(!document.hidden){schedule();void sync()}})
267
+ new MutationObserver(schedule).observe(document.documentElement,{attributes:true,attributeFilter:['data-lang']})
268
+ return {
269
+ preserve(){focusBeforeRender=root?.contains(document.activeElement)?document.activeElement:null;groupScroll=groupBody?.scrollTop||0},
270
+ mount(){setup();const target=document.getElementById('liveActivity');if(target&&root.parentNode!==target)target.append(root);groupBody.scrollTop=groupScroll;if(focusBeforeRender?.isConnected)focusBeforeRender.focus({preventScroll:true});focusBeforeRender=null;schedule();void sync()},
271
+ sync,
272
+ receive(data){if(pending){frames.push(data);if(frames.length>200)frames.shift()}else apply(data,false)},
273
+ live(live){for(const [id,value]of Object.entries(live?.groups||{})){const g=groups.get(id);if(g)Object.assign(g,value)}schedule()},
274
+ connected(value){connected=value;schedule()},
275
+ }
276
+ })()
@@ -0,0 +1,193 @@
1
+ 'use strict'
2
+
3
+ // An observational read model, never an execution/recovery authority. All
4
+ // terminal labels are supplied by the existing acknowledged engine paths.
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const { randomUUID } = require('node:crypto')
8
+ const { redact } = require('../core/redact')
9
+ const LIMIT = 256, TEXT = 16384, FILE_LIMIT = 12 * 1024 * 1024
10
+ const CLOSED = new Set(['answered', 'silent', 'stopped', 'recalled', 'timeout', 'error', 'not-started', 'unknown'])
11
+ const clean = (s, n = TEXT) => redact(String(s || ''), { pii: false }).slice(0, n)
12
+ const finite = n => Number.isFinite(n) && n >= 0
13
+
14
+ function createLiveActivity({ file, now = Date.now, log = () => {}, canWrite = () => true } = {}) {
15
+ const epoch = randomUUID(), rows = new Map(), aliases = new Map(), contexts = new Map(), listeners = new Set()
16
+ let revision = 0, timer, saveTimer, saving = Promise.resolve(), writing = false, dirty = false, storageError = false
17
+ const key = (chatId, id) => chatId + ':' + id
18
+ const locate = (chatId, id) => rows.get(aliases.get(key(chatId, id)) || key(chatId, id))
19
+ function changed(urgent = false) {
20
+ revision++; dirty = true
21
+ if (!timer) timer = setTimeout(() => {
22
+ timer = null
23
+ for (const fn of listeners) { try { fn() } catch {} }
24
+ }, urgent ? 0 : 150).unref()
25
+ if (!saveTimer && file) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
26
+ }
27
+ function touch(row, urgent = false, details = true) {
28
+ row.updatedAt = now(); row.revision = revision + 1
29
+ if (details) row.detailRevision = row.revision
30
+ changed(urgent)
31
+ while (rows.size > LIMIT) {
32
+ const oldest = [...rows.values()].sort((a, b) => Number(!CLOSED.has(a.status)) - Number(!CLOSED.has(b.status)) || a.updatedAt - b.updatedAt)[0]
33
+ rows.delete(key(oldest.chatId, oldest.id))
34
+ for (const id of oldest.receiptIds) aliases.delete(key(oldest.chatId, id))
35
+ }
36
+ }
37
+ function receive({ chatId, receiptId, text, sender, acceptedAt }) {
38
+ if (locate(chatId, receiptId)) return
39
+ const row = { id: receiptId, chatId, receiptIds: [receiptId], ts: acceptedAt || now(),
40
+ status: 'queued', text: clean(text, 160), sender: clean(sender, 120),
41
+ messages: [{ id: receiptId, text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
42
+ truncated: String(text || '').length > TEXT, reply: '', tools: [], milestones: [] }
43
+ rows.set(key(chatId, receiptId), row); touch(row, true)
44
+ }
45
+ function merge(chatId, parentId, ids, state) {
46
+ const row = locate(chatId, parentId)
47
+ if (!row) return
48
+ for (const id of ids) {
49
+ const child = locate(chatId, id)
50
+ if (child && child !== row) {
51
+ row.messages.push(...child.messages)
52
+ row.receiptIds.push(...child.receiptIds)
53
+ rows.delete(key(chatId, child.id))
54
+ }
55
+ if (!row.receiptIds.includes(id)) row.receiptIds.push(id)
56
+ aliases.set(key(chatId, id), key(chatId, row.id))
57
+ const msg = row.messages.find(m => m.id === id)
58
+ if (msg) msg.state = state
59
+ }
60
+ // Bound the entire message body, not only each message, and disclose loss.
61
+ let budget = TEXT
62
+ if (row.messages.length > 64) row.truncated = true
63
+ row.messages = row.messages.slice(0, 64).map(m => {
64
+ const text = m.text.slice(0, budget); budget -= text.length
65
+ if (text !== m.text) row.truncated = true
66
+ return { ...m, text }
67
+ })
68
+ touch(row, true)
69
+ return row
70
+ }
71
+ function start({ chatId, receiptId, receiptIds, generation, sessionId, model, runner, project }) {
72
+ const row = merge(chatId, receiptId, receiptIds || [receiptId], 'landed')
73
+ if (!row) return
74
+ row.status = 'running'; row.generation = generation
75
+ row.startedAt ||= now(); row.sessionId = sessionId || null
76
+ Object.assign(row, { model: clean(model, 120), runner, project: clean(project, 120) })
77
+ const prior = contexts.get(chatId)
78
+ if (!prior || prior.sessionId !== sessionId) contexts.delete(chatId)
79
+ else contexts.set(chatId, { ...prior, generation, quality: 'previous' })
80
+ touch(row, true)
81
+ }
82
+ function observe({ chatId, receiptId, generation, event, doing, milestones }) {
83
+ const row = locate(chatId, receiptId)
84
+ if (!row || row.generation !== generation || CLOSED.has(row.status)) return
85
+ if (event.sessionId) row.sessionId = event.sessionId
86
+ const compact = event.type === 'system' && event.subtype === 'compact' || event.contextCompacted && !event.contextTokens
87
+ const measured = finite(event.contextTokens) && event.contextSampled !== false
88
+ if (compact || measured) {
89
+ const prior = contexts.get(chatId)
90
+ contexts.set(chatId, { sessionId: row.sessionId, generation,
91
+ usedTokens: compact ? null : event.contextTokens,
92
+ windowTokens: finite(event.contextWindowTokens) && event.contextWindowTokens > 0 ? event.contextWindowTokens : prior?.generation === generation ? prior.windowTokens : null,
93
+ measuredAt: now(), quality: compact ? 'compacted' : 'reported', source: row.runner })
94
+ }
95
+ if (event.tools) {
96
+ for (const tool of event.tools) {
97
+ const name = clean(tool.name, 100), found = row.tools.find(t => t.name === name)
98
+ if (found) found.count++
99
+ else if (row.tools.length < 50) row.tools.push({ name, count: 1 })
100
+ }
101
+ }
102
+ if (doing) row.doing = doing.map(x => clean(x, 160)).slice(-3)
103
+ if (milestones) row.milestones = milestones.map(x => clean(x, 200)).slice(-12)
104
+ // No model text deltas: only user-facing milestones and tool descriptions.
105
+ if (compact || measured || event.tools?.length || milestones)
106
+ touch(row, compact, !!event.tools?.length || !!milestones)
107
+ }
108
+ function finish({ chatId, receiptId, status, reply, err, tokens, sessionId }) {
109
+ const row = locate(chatId, receiptId)
110
+ if (!row || CLOSED.has(row.status)) return
111
+ row.status = status; row.endedAt = now(); row.ms = row.startedAt ? row.endedAt - row.startedAt : 0
112
+ row.reply = clean(reply); row.err = clean(err, 1000); row.tokens = tokens || null
113
+ row.truncated ||= String(reply || '').length > TEXT
114
+ if (sessionId) row.sessionId = sessionId
115
+ const ctx = contexts.get(chatId)
116
+ if (ctx && ctx.generation === row.generation) {
117
+ ctx.sessionId = row.sessionId; ctx.quality = ctx.quality === 'compacted' ? 'compacted' : 'previous'
118
+ }
119
+ touch(row, true)
120
+ }
121
+ function sending({ chatId, receiptId }) {
122
+ const row = locate(chatId, receiptId)
123
+ if (row && !CLOSED.has(row.status)) { row.status = 'sending'; touch(row, true) }
124
+ }
125
+ function unknown({ chatId }) {
126
+ for (const row of rows.values()) if (row.chatId === chatId && !CLOSED.has(row.status)) {
127
+ finish({ chatId, receiptId: row.id, status: 'unknown', err: '结果尚未确认,请查看群状态' })
128
+ }
129
+ }
130
+ const summary = row => {
131
+ const { messages, reply, tools, milestones, receiptIds, generation, sessionId, ...rest } = row
132
+ return { ...rest, preview: (row.err || reply || '').slice(0, 160), messageCount: messages.length,
133
+ kind: CLOSED.has(row.status) ? 'done' : 'start', details: true }
134
+ }
135
+ function list({ chatId, status, offset = 0, limit = 30 } = {}) {
136
+ const all = [...rows.values()].filter(r => (!chatId || r.chatId === chatId) && (!status || r.status === status))
137
+ .sort((a, b) => Number(CLOSED.has(a.status)) - Number(CLOSED.has(b.status)) || b.ts - a.ts)
138
+ offset = Math.max(0, Math.min(LIMIT, Number(offset) || 0)); limit = Math.max(1, Math.min(100, Number(limit) || 30))
139
+ return { epoch, revision, storageError, total: all.length, activity: all.slice(offset, offset + limit).map(summary) }
140
+ }
141
+ function detail(chatId, id) {
142
+ const row = locate(chatId, id)
143
+ return row ? { epoch, revision: row.revision, ...structuredClone(row) } : null
144
+ }
145
+ function context(chatId, sessionId, busy = false, generation = null) {
146
+ const c = contexts.get(chatId)
147
+ if (!c || !(busy && generation && c.generation === generation) && (!sessionId || c.sessionId !== sessionId)) return null
148
+ return { ...c, quality: busy ? c.quality : c.quality === 'reported' ? 'previous' : c.quality }
149
+ }
150
+ function flush() {
151
+ clearTimeout(saveTimer); saveTimer = null
152
+ if (writing) return saving
153
+ if (!file || !dirty || !canWrite()) return saving
154
+ dirty = false
155
+ const data = JSON.stringify({ version: 1, rows: [...rows.values()], contexts: [...contexts] })
156
+ writing = true
157
+ saving = saving.then(async () => {
158
+ if (!canWrite()) return
159
+ if (Buffer.byteLength(data) > FILE_LIMIT) throw new Error('activity size limit')
160
+ await fs.promises.mkdir(path.dirname(file), { recursive: true })
161
+ const tmp = file + '.tmp'
162
+ await fs.promises.writeFile(tmp, data, { mode: 0o600 })
163
+ if (canWrite()) await fs.promises.rename(tmp, file)
164
+ storageError = false
165
+ }).catch(() => { storageError = true; log('Activity history could not be saved') })
166
+ .finally(() => {
167
+ writing = false
168
+ if (dirty && !saveTimer) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
169
+ })
170
+ return saving
171
+ }
172
+ if (file) {
173
+ try {
174
+ if (fs.statSync(file).size > FILE_LIMIT) throw new Error('activity size limit')
175
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'))
176
+ if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > LIMIT) throw new Error('activity format')
177
+ for (const row of data.rows) {
178
+ if (!row || typeof row.id !== 'string' || typeof row.chatId !== 'string' || !Array.isArray(row.receiptIds) || !Array.isArray(row.messages)) continue
179
+ if (!CLOSED.has(row.status)) { row.status = 'unknown'; row.err = '服务重启,结果需要核实' }
180
+ rows.set(key(row.chatId, row.id), row)
181
+ revision = Math.max(revision, Number(row.revision) || 0)
182
+ for (const id of row.receiptIds) aliases.set(key(row.chatId, id), key(row.chatId, row.id))
183
+ }
184
+ for (const [id, value] of (data.contexts || []).slice(0, 32)) contexts.set(id, { ...value, quality: value.quality === 'compacted' ? 'compacted' : 'historical' })
185
+ } catch (error) { if (error.code !== 'ENOENT') { storageError = true; log('Activity history could not be loaded') } }
186
+ }
187
+ return { receive, start, observe, finish, sending, unknown, merge, list, detail, context, flush,
188
+ has: (chatId, id) => !!locate(chatId, id),
189
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
190
+ async close() { clearTimeout(timer); listeners.clear(); await flush(); await flush(); clearTimeout(saveTimer) } }
191
+ }
192
+
193
+ module.exports = { createLiveActivity }
@@ -5,6 +5,7 @@
5
5
  // two stay in sync.
6
6
 
7
7
  const http = require('node:http')
8
+ const { followupEnabled } = require('../agent/privatecore')
8
9
  const crypto = require('node:crypto')
9
10
  const fs = require('node:fs')
10
11
  const os = require('node:os')
@@ -48,6 +49,7 @@ const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl
48
49
  const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-sessions.json')
49
50
 
50
51
  const CONSOLE_HTML = path.join(__dirname, 'console.html')
52
+ const LIVE_ACTIVITY_JS = fs.readFileSync(path.join(__dirname, 'liveactivity-client.js'), 'utf8')
51
53
  const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
52
54
  const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
53
55
  const dashboard = require('./dashboard') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
@@ -507,7 +509,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
507
509
  // repo:群项目关联的 git 远端(可空)。repoState 走纯 fs 的 .git/config 读取(quickLinkState)
508
510
  // —— 群卡标签要反映真实关联(origin 被手改/删掉时不能还绿着),但这里每个 API 响应都跑一遍,
509
511
  // per-group spawn git 会把控制台拖垮,所以只读文件、不 spawn。
510
- .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: g.codexSteer !== false, model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
512
+ .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: followupEnabled(g, 'codex'), steer: g.steer ?? null, followupEnabled: followupEnabled(g, cfg.providers[(control && control.providerOf && control.providerOf(chatId)) || g.provider || cfg.defaultProvider]?.runner || 'claude'), model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
511
513
  // 项目级插件/技能(workdir 自带声明,随项目恒加载)—— 群卡摘要行不点开也看得见。
512
514
  // projectPlugins 纯 fs 读且各错各兜(readJson/scanSkills 都 catch),与同行的 projectStyleCard 同量级。
513
515
  // ⚠ workdir 必须先判空:空串会让 path.join 退化成 daemon cwd 相对读(实验证实能读到别的项目的
@@ -839,7 +841,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
839
841
  // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
840
842
  const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
841
843
  const injectTok = (authConfigured() && !localBypass) ? '' : token
842
- return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : ''))))
844
+ return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : '').replace('/*__LIVE_ACTIVITY__*/', () => LIVE_ACTIVITY_JS))))
843
845
  }
844
846
  // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
845
847
  // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
@@ -866,7 +868,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
866
868
  if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
867
869
  // Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
868
870
  // Lightweight + kept off /api/state so mutations don't re-ship it every write.
869
- if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, { activity: recentActivity() })
871
+ if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, runtimePolicy?.activityPage
872
+ ? runtimePolicy.activityPage(Object.fromEntries(url.searchParams)) : { activity: recentActivity() })
873
+ if (p === '/api/activity/detail' && req.method === 'GET') {
874
+ const detail = runtimePolicy?.activityDetail?.(url.searchParams.get('chatId'), url.searchParams.get('id'))
875
+ return sendJSON(res, detail ? 200 : 404, detail || { error: '活动详情未记录' })
876
+ }
870
877
  // 版本历史(设置页「版本历史」卡):CHANGELOG.json 随包分发 = 单一事实源,发版时更新
871
878
  if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
872
879
  // 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
@@ -1795,15 +1802,15 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1795
1802
  }
1796
1803
  // Codex 忙时跟进默认开启;显式 false 才关闭。当前轮按启动时配置运行。
1797
1804
  // providerOf 覆盖运行时模型切换/额度回退,因此必须以实际有效 runner 校验。
1798
- if (p === '/api/groups/codex-steer' && req.method === 'POST') {
1805
+ if ((p === '/api/groups/steer' || p === '/api/groups/codex-steer') && req.method === 'POST') {
1799
1806
  const { chatId, enabled } = await body(req); const raw = readRaw()
1800
1807
  if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1801
1808
  if (typeof enabled !== 'boolean') return sendJSON(res, 400, { error: 'enabled 必须为布尔值' })
1802
1809
  const name = (control && control.providerOf && control.providerOf(chatId)) || raw.groups[chatId].provider || raw.defaultProvider || loadConfig(configPath).defaultProvider
1803
1810
  const provider = { ...DEFAULTS.providers, ...(raw.providers || {}) }[name]
1804
- if (!provider || provider.runner !== 'codex') return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1805
- if (enabled) delete raw.groups[chatId].codexSteer
1806
- else raw.groups[chatId].codexSteer = false
1811
+ if (p === '/api/groups/codex-steer' && (!provider || provider.runner !== 'codex')) return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1812
+ raw.groups[chatId].steer = enabled
1813
+ delete raw.groups[chatId].codexSteer
1807
1814
  writeRaw(raw)
1808
1815
  return sendJSON(res, 200, await buildState())
1809
1816
  }
@@ -2416,8 +2423,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2416
2423
  const live = getLiveState ? getLiveState() : { running: false }
2417
2424
  const conn = getConnHealth ? getConnHealth() : null // 飞书 WS 健康度随心跳带上(SSE 无重放,轮询兜底)
2418
2425
  const msg = `event: tick\ndata: ${JSON.stringify({ live, conn, ts: Date.now(),
2419
- ...(runtimePolicy ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
2420
- for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2426
+ ...(runtimePolicy && !runtimePolicy.activityPage ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
2427
+ for (const c of sseClients) { try {
2428
+ if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
2429
+ else c.write(msg)
2430
+ } catch { sseClients.delete(c) } }
2421
2431
  }, 3000)
2422
2432
 
2423
2433
  return {
@@ -2433,7 +2443,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2433
2443
  if (type === 'turn' && getLiveState) {
2434
2444
  msg += `event: tick\ndata: ${JSON.stringify({ live: getLiveState(), conn: getConnHealth ? getConnHealth() : null, ts: Date.now() })}\n\n`
2435
2445
  }
2436
- for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2446
+ for (const c of sseClients) { try {
2447
+ if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
2448
+ else c.write(msg)
2449
+ } catch { sseClients.delete(c) } }
2437
2450
  },
2438
2451
  close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
2439
2452
  _browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用