sintbot 1.3.0 → 1.4.1

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 (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
@@ -0,0 +1,175 @@
1
+ // 项目长期记忆与约定文件的探测(跨模型记忆交接)。
2
+ //
3
+ // 由来:一个群换了模型大脑之后,上一任攒下的东西并不跟着走。⭐ 但**缺的不是能力,是可达** ——
4
+ // 群都是完全访问(codex 侧 = danger-full-access),那些 markdown 它本来就读得到;只是没有任何
5
+ // 地方告诉过它「有这东西、在哪、什么规矩」。所以正解落在**自知清单**上,不是搬运数据、
6
+ // 不是第三本账、也不是切换那一刻补一轮握手 —— 交接该是持续发生的。
7
+ //
8
+ // 已验死的事实:
9
+ // · codex 运行时**不读 CLAUDE.md**(二进制里 AGENTS.md 出现 77 次,CLAUDE.md 只出现在
10
+ // 「从 Claude Code 一次性导入」的向导串里)⇒ claude 群里天天更新的那份约定,换成 codex
11
+ // 就等于不存在。反过来 claude 不读 AGENTS.md。⇒ **约定文件那一行**无条件点名存在的每一份,
12
+ // 对本来就会自动加载的那一方是无害冗余,对另一方是救命。
13
+ // · 私有记忆目录 = <配置目录>/projects/<slug>/memory。slug 规则见 memorySlug —— **从实物
14
+ // 反推,不是猜**。
15
+ //
16
+ // ⚠ **路径只能探测,不许推算** —— 给错路径比不给更糟:agent 会照着去读,读不到就学会
17
+ // 「这项目没有记忆」这条错信念(一次失败的交互会在会话里固化成信念,之后它绕路不再试),
18
+ // 而更坏的一头是读到**别的项目**的记忆还照着做。所以:每条路径都 stat 过才敢往外报,
19
+ // workdir 本身必须是真实目录,归属还要再过一道权威源。
20
+ const fs = require('node:fs')
21
+ const path = require('node:path')
22
+ const os = require('node:os')
23
+
24
+ // 项目根的约定文件。⚠ 顺序有语义(清单按它挑「往哪写」的落点),故测试里用字面量断言,
25
+ // 不许拿这个常量自己当期望 —— 那样顺序怎么改都测不出来。
26
+ const CONVENTION_FILES = ['CLAUDE.md', 'AGENTS.md']
27
+
28
+ // 记忆条数只报**档位**,不报精确值。
29
+ // ⚠ 这是清单指纹的硬约束,不是偷懒:engine 靠「清单文本变了没」决定要不要重注入,并在变了时
30
+ // 对 agent 说一句「你的能力/配置已更新」。而真实项目**每天都在长记忆**(实测四天 +1),报精确
31
+ // 值 ⇒ 指纹天天变 ⇒ 每天白重注入一份几 KB 的清单,还对 agent 说了假话(能力根本没更新)。
32
+ // ⚠ 残留(已知、接受):claude 侧框架会**自己**往记忆目录写 ⇒ 跨档那一刻仍会翻一次指纹。
33
+ // 这是清单里第一个 agent 自己就能触发的字段。档位只把频率从「每天」压到「跨档时」,没有消除。
34
+ const BUCKETS = [500, 200, 100, 50, 20, 10]
35
+ function countBucket(n) {
36
+ for (const b of BUCKETS) if (n >= b) return `${b}+ 条`
37
+ return '不到 10 条' // 10 以下不报数:小项目每加一条都会翻指纹,而 3 条与 5 条对读不读毫无区别
38
+ }
39
+
40
+ // slug 规则 = **realpath 之后,把 `/` 和 `_` 都压成 `-`**。本机 20 个记忆目录逐一对账反推得出,
41
+ // 不是猜。两条都有实物,且都是对抗审查逮出来的真事故:
42
+ // ⚠ 漏掉 `_`:`…/Doc-TTS`(压根不存在)会命中 `…/Doc_TTS` 的记忆 ⇒ 报出**别的项目**的记忆,
43
+ // 而真主人(带 `_` 的那个)自己反而永远探不到。两头都坏,且「没有记忆」与「slug 算错了」
44
+ // 在清单里长得一模一样。
45
+ // ⚠ 漏掉 realpath:`/tmp/x` 被记成 `-private-tmp-x`(macOS 上 /tmp、/var 都是软链)⇒ workdir
46
+ // 填软链路径的群,记忆永远探不到,不出任何声。
47
+ // ⚠ Windows 规则**无样本**:这里照同一套算,算错的后果是 stat 不到 ⇒ 返回 null(fail-safe),
48
+ // 不会报出错路径 —— 但也等于该平台上这个功能不生效。
49
+ function memorySlug(realPath) {
50
+ return String(realPath).replace(/\\/g, '/').replace(/[/_]/g, '-')
51
+ }
52
+
53
+ // 归属的权威源:claude.json 的 `projects` 以**真实绝对路径**为键(本机实测 18 键)。
54
+ // ⭐ 用查表替代「猜一条替代路径存不存在」的启发式 —— 一次读文件(实测 98KB / 0.29ms,与同一
55
+ // 函数里已有的读 .git/config、读风格档同量级)就把 `_`、`/`、软链三个维度一次覆盖;而启发式
56
+ // 只覆盖其中一维的一个位置,还会被一个**跟 claude 毫无关系的普通兄弟目录**误判成歧义
57
+ // (旧实现:项目里新建个 demo/ 子目录,整段记忆就静默消失)。
58
+ // ⚠ 它权威但**不完备** —— 2026-09-01 实测:18 个带记忆的目录里有 4 个在 claude.json 里没有键
59
+ // 认领。所以「没人认领」不算歧义(照报);只有「别人认领了」或「两个键撞同一 slug」才闭嘴。
60
+ // ⚠ 读不到权威源时返回 null = 「无归属信息」,按不阻断处理:realpath + `_` 已经消掉了现实中
61
+ // 真正发生过的那类错配,而两个键撞同一 slug 实测为 0 次。
62
+ // ⚠ 候选路径要先归一 —— cfgDir 带个尾斜杠就会让两个候选双双落空,然后**静默** fail-open。
63
+ function claudeJsonPath(cfgDir) {
64
+ const base = path.resolve(String(cfgDir))
65
+ for (const p of [base + '.json', path.join(base, '.claude.json')]) {
66
+ try { if (fs.statSync(p).isFile()) return p } catch { /* 换下一个候选 */ }
67
+ }
68
+ return null
69
+ }
70
+
71
+ function slugOwners(cfgDir, slug) {
72
+ const p = claudeJsonPath(cfgDir)
73
+ if (!p) return null
74
+ try {
75
+ const projects = JSON.parse(fs.readFileSync(p, 'utf8')).projects || {}
76
+ const out = []
77
+ for (const k of Object.keys(projects)) {
78
+ // ⚠ 键**不随目录删除而清理**(实测 18 个键里 6 个是幽灵键)。目录都不在了的键认领不了
79
+ // 任何东西 —— 留着它参与匹配,会让一个早就删掉的老项目**压制住一个活着的项目**的记忆,
80
+ // 而且不出任何声。方向虽是 fail-safe,但这是旧实现没有的新压制面。
81
+ let real
82
+ try { real = fs.realpathSync(k) } catch { continue }
83
+ if (memorySlug(real) === slug) out.push(real)
84
+ }
85
+ return out
86
+ } catch { return null }
87
+ }
88
+
89
+ function probeMemoryDir(workdir, opts = {}) {
90
+ // workdir 必须是**真实存在的目录**,并走 realpath(claude 记的就是 realpath)。
91
+ // 顺带挡掉「控制台把路径填错 / 项目被移走 / 盘没挂上」这一整类 —— 旧实现对一个压根不存在
92
+ // 的 workdir 也能报出一个真实可读的记忆目录。path.resolve 先吃掉尾斜杠、`.`、`..`、重复斜杠。
93
+ let real
94
+ try {
95
+ real = fs.realpathSync(path.resolve(String(workdir)))
96
+ if (!fs.statSync(real).isDirectory()) return null
97
+ } catch { return null }
98
+ const home = opts.home || os.homedir()
99
+ const cfgDir = opts.configDir || (opts.env || process.env).CLAUDE_CONFIG_DIR || path.join(home, '.claude')
100
+ const slug = memorySlug(real)
101
+ const dir = path.join(cfgDir, 'projects', slug, 'memory')
102
+ let names
103
+ try {
104
+ if (!fs.statSync(dir).isDirectory()) return null
105
+ // withFileTypes:名字以 .md 结尾的**子目录**不该被算成一条记忆(真实记忆目录里就有
106
+ // _archive/ 这类子目录)。软链不排除 —— 软链到记忆文件是正当用法。
107
+ names = fs.readdirSync(dir, { withFileTypes: true })
108
+ .filter((e) => e.name.endsWith('.md') && !e.isDirectory()).map((e) => e.name)
109
+ } catch { return null }
110
+ const index = names.includes('MEMORY.md') ? 'MEMORY.md' : null
111
+ const count = names.length - (index ? 1 : 0)
112
+ // 空目录 / 只剩一份索引 ⇒ 没东西可交接。清单每轮都付 token,不为「这里什么都没有」占篇幅。
113
+ if (count <= 0) return null
114
+ const owners = slugOwners(cfgDir, slug)
115
+ if (owners && (owners.length > 1 || (owners.length === 1 && owners[0] !== real))) return null
116
+ return { dir, count, bucket: countBucket(count), index }
117
+ }
118
+
119
+ // 原始层判据:「这个项目有没有东西可交接」。只服务 probeProjectMemory 自己 —— 保证它返回非 null
120
+ // 时一定有东西。⚠ 消费者不该用这一条(它不知道这一轮的权限/runner 够不够得着),消费者用 probeHandover。
121
+ function hasHandover(pm) { return !!(pm && (pm.convention.length || pm.memory)) }
122
+
123
+ /**
124
+ * 探测一个项目目录里「跨会话还留得住的东西」。
125
+ * @returns {{convention: string[], memory: {dir,count,bucket,index}|null}|null}
126
+ * 两样都没有时返回 null —— 探测式:项目里没有这些东西,清单里就一个字都不出现。
127
+ */
128
+ function probeProjectMemory(workdir, opts = {}) {
129
+ const wd = workdir && String(workdir).trim()
130
+ if (!wd) return null
131
+ const convention = []
132
+ for (const f of CONVENTION_FILES) {
133
+ try { if (fs.statSync(path.join(wd, f)).isFile()) convention.push(f) } catch { /* 不存在 ⇒ 不点名 */ }
134
+ }
135
+ const out = { convention, memory: probeMemoryDir(wd, opts) }
136
+ return hasHandover(out) ? out : null
137
+ }
138
+
139
+ // ⭐ 换脑时对群成员说的那一句。**控制台切模型与群内 `/model` 都要说它** —— 各写一份必然漂移
140
+ // (教训 26/35),故收在这里当唯一来源,经 probeHandover 的 `note` 交给两个消费者。
141
+ // ⚠ **只陈述事实(东西还在),不承诺行为**(「它会先读」):读不读是半可信 agent 的判断,编排层
142
+ // 保证不了 —— 清单自己都写着「上下文里没有就自己读一遍」。
143
+ // ⚠ 用户面零工具名:不提约定文件名、更不提机器上的目录。
144
+ const HANDOVER_NOTE = '本项目的长期约定与积累都还在,不必从头交代'
145
+
146
+ /**
147
+ * ⭐ **消费者唯一入口**:这一轮**实际给得出去**的交接信息。自知清单与切模型通知都调它。
148
+ *
149
+ * 为什么必须收成一个函数(教训 26/35 家族):门控之前两个消费者天然一致,加了门控就多出第三种
150
+ * 状态 ——「探到了东西,但这一轮一条都给不出去」。各写各的判据 ⇒ 控制台在群里说「之前交代过的
151
+ * 都还在」,而清单里一个字都没有、那个目录 agent 还够不着。**注释挡不住复发,得收成一个被调用的
152
+ * 东西**;而落点选哪份、够不够得着,都属于「哪个 runner 会自动加载什么、文件工具被围在哪」
153
+ * 这类**具体 agent 的行为知识**,本就不该写进 core。
154
+ *
155
+ * @param {string} workdir
156
+ * @param {{full?: boolean, isCodex?: boolean}} opts full = 该群完全访问;isCodex = 走 codex runner
157
+ * @returns {{convention: string[], memory: object|null, landing: string|null}|null}
158
+ */
159
+ function probeHandover(workdir, opts = {}) {
160
+ const pm = probeProjectMemory(workdir, opts)
161
+ if (!pm) return null
162
+ // 记忆目录在 workdir **之外**,而 claude runner 唯一的 --add-dir 就是 workdir 本身 ⇒ 非完全
163
+ // 访问的 claude 群读不到。指了它读不到的路,只会**亲手教出**「这项目没有记忆」那条错信念
164
+ // (一次失败的交互会在会话里固化成信念,之后它绕开不再试)—— 比从没告诉过它更糟。
165
+ // codex 的 read-only 是只读**文件系统策略**、不是路径牢笼,够得着。
166
+ const memory = pm.memory && (opts.full || opts.isCodex) ? pm.memory : null
167
+ // 落点:两份都在时选它**自己会自动加载**的那一份 —— 让 codex 把交接写进一份它不读的档,等于白写。
168
+ const want = opts.isCodex ? 'AGENTS.md' : 'CLAUDE.md'
169
+ const landing = pm.convention.length ? (pm.convention.find((f) => f === want) || pm.convention[0]) : null
170
+ // 探到了但一条都给不出去 ⇒ 两个消费者一致地闭嘴(清单不留光秃秃的标题,群里也不说那句话)。
171
+ if (!landing && !memory) return null
172
+ return { convention: pm.convention, memory, landing, note: HANDOVER_NOTE }
173
+ }
174
+
175
+ module.exports = { probeHandover, probeProjectMemory, probeMemoryDir, memorySlug, slugOwners, hasHandover, countBucket, CONVENTION_FILES, HANDOVER_NOTE }
package/src/ui/auth.js ADDED
@@ -0,0 +1,31 @@
1
+ // 控制台访问密码:scrypt 哈希 + 常量时间校验(零依赖)。
2
+ //
3
+ // 精简口径(局域网访问加固):控制台默认绑 loopback 免密;一旦对外(--host
4
+ // 非环回)就要求登录。密码只干一件事 —— 换取一个本进程 session token,其余鉴权管线原样
5
+ // 复用(token 放 x-sbot-token 头,天然免 CSRF)。密码永不明文落盘,存成 `scrypt$salt$hash`。
6
+
7
+ const crypto = require('node:crypto')
8
+
9
+ const KEYLEN = 32 // 派生密钥字节数
10
+
11
+ // 明文密码 → `scrypt$<saltHex>$<hashHex>`(salt 随机,每次不同)
12
+ function hashPassword(pw) {
13
+ const salt = crypto.randomBytes(16)
14
+ const hash = crypto.scryptSync(String(pw), salt, KEYLEN)
15
+ return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
16
+ }
17
+
18
+ // 明文密码 vs 存储串;格式不符 / 长度不符 / 派生失败 → false(绝不抛)。timingSafeEqual 防计时侧信道。
19
+ function verifyPassword(pw, stored) {
20
+ if (typeof stored !== 'string') return false
21
+ const parts = stored.split('$')
22
+ if (parts.length !== 3 || parts[0] !== 'scrypt') return false
23
+ let expected
24
+ try { expected = Buffer.from(parts[1] && parts[2] ? parts[2] : '', 'hex') } catch { return false }
25
+ if (expected.length !== KEYLEN) return false
26
+ let hash
27
+ try { hash = crypto.scryptSync(String(pw), Buffer.from(parts[1], 'hex'), KEYLEN) } catch { return false }
28
+ return hash.length === expected.length && crypto.timingSafeEqual(hash, expected)
29
+ }
30
+
31
+ module.exports = { hashPassword, verifyPassword }
@@ -0,0 +1,392 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover">
6
+ <meta name="robots" content="noindex">
7
+ <title>接管浏览器</title>
8
+ <!-- 接管浏览器:把机器人常驻浏览器的画面推到这一页,人在手机 / 电脑上点、拖、输入,登录态落在机器人那边。
9
+ 两种人会到这里:控制台管理员(从控制台进来,带令牌)与拿到一次性链接的群成员(只带 cookie,只能进这一页)。
10
+ 页面按「身份 × 阶段」切模式(body[data-mode]):panel(持票未兑换 / 结束 / 过期 / 去登录:整屏一句话 + 一个动作,别的全藏)·
11
+ live(接管中:画面 + 键盘;群成员只看到自己那一页,没有标签页下拉 / 新开 / 控制台入口)。
12
+ 自包含单文件,配色 token 与 console.html 同一套值(两处各写一份 —— 这页故意不加载整份控制台:群成员不该
13
+ 看到控制台其它页面,也不该为一个画面拉几百 KB 的脚本)。图标 = Lucide sprite(lucide-static v1.31.0,与控制台同版,内联)。 -->
14
+ <style>
15
+ :root{
16
+ --bg:#0f0f13;--panel:rgba(205,214,244,.045);--ink:#cdd6f4;--dim:#a6adc8;--faint:#9399b2;--line:rgba(205,214,244,.16);
17
+ --ember:#cba6f7;--ember-rgb:203,166,247;--on-ember:#11111b;--ok:#a6e3a1;--warn:#f9e2af;--fail:#f38ba8;--fail-rgb:243,139,168;
18
+ --btn-bg:rgba(255,255,255,.055);--input-bg:rgba(0,0,0,.28);--stage:#000;
19
+ --sans:-apple-system,BlinkMacSystemFont,'SF Pro SC','SF Pro Text','PingFang SC','Hiragino Sans GB','Source Han Sans SC','Noto Sans SC','Microsoft YaHei UI','Microsoft YaHei',sans-serif;
20
+ --mono:'JetBrains Mono','Monaco',ui-monospace,SFMono-Regular,Menlo,'PingFang SC',monospace;
21
+ }
22
+ :root[data-theme="light"]{
23
+ --bg:#f7f8fc;--panel:#ffffff;--ink:#172033;--dim:#3b4557;--faint:#5b667a;--line:#e2e8f0;
24
+ --ember:#6d28d9;--ember-rgb:109,40,217;--on-ember:#fff;--ok:#6ea81a;--warn:#df8e1d;--fail:#e55934;--fail-rgb:229,89,52;
25
+ --btn-bg:rgba(23,32,51,.06);--input-bg:rgba(23,32,51,.07);--stage:#e9ecf3;
26
+ }
27
+ *{box-sizing:border-box}
28
+ /* ⚠ hidden 属性只有浏览器默认样式在撑,任何一条给同一元素写 display 的规则都会把它覆盖掉 —— 真机截图逮到:群成员看到了
29
+ 本该只给管理员的「控制台」链接(a.back 写了 display:inline-flex)。全局兜底一次,以后新加的元素不必逐个记得写 [hidden] */
30
+ [hidden]{display:none!important}
31
+ html,body{height:100%}
32
+ body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.5 var(--sans);display:flex;flex-direction:column;overflow:hidden}
33
+ /* 模式切换:只有 live 显示工具栏 / 画面 / 键盘;其余模式只显示整屏面板 */
34
+ body:not([data-mode="live"]) .top,body:not([data-mode="live"]) #stage,body:not([data-mode="live"]) .kb{display:none}
35
+ body[data-mode="live"] #panel{display:none}
36
+ .top,.kb{flex:none;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-wrap:wrap}
37
+ .kb{border-bottom:none;border-top:1px solid var(--line);padding-bottom:calc(8px + env(safe-area-inset-bottom))}
38
+ .sp{flex:1}
39
+ select,input,button{font:inherit;color:inherit}
40
+ select{max-width:min(46vw,320px);height:34px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--input-bg)}
41
+ input{flex:1;min-width:140px;height:36px;padding:0 11px;border:1px solid var(--line);border-radius:8px;background:var(--input-bg);outline:none}
42
+ input:focus-visible,button:focus-visible,select:focus-visible{outline:2px solid var(--ember);outline-offset:1px}
43
+ button{height:34px;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:var(--btn-bg);cursor:pointer;white-space:nowrap;transition:color .16s,background-color .16s,border-color .16s}
44
+ button:hover{border-color:rgba(var(--ember-rgb),.5)}
45
+ button.primary{background:var(--ember);color:var(--on-ember);border-color:transparent;font-weight:600}
46
+ button.danger{color:var(--fail);border-color:rgba(var(--fail-rgb),.45)}
47
+ button.on{background:rgba(var(--ember-rgb),.14);border-color:rgba(var(--ember-rgb),.6);color:var(--ember)}
48
+ .iconbtn{width:34px;padding:0;display:inline-flex;align-items:center;justify-content:center}
49
+ .ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;display:block}
50
+ a.back{color:var(--dim);text-decoration:none;font-weight:600;display:inline-flex;align-items:center;gap:6px}
51
+ .ptitle{font-weight:600;max-width:min(52vw,360px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
52
+ .st{font-size:11.5px;color:var(--faint);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
53
+ .st.bad{color:var(--fail)} .st.ok{color:var(--ok)}
54
+ .url{font:11px var(--mono);color:var(--faint);width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
55
+ #stage{flex:1;min-height:0;position:relative;background:var(--stage);overflow:hidden;touch-action:none;-webkit-user-select:none;user-select:none;display:flex;align-items:flex-start;justify-content:center}
56
+ /* 宽度钉成容器宽度(不是 max-width):帧的像素宽会随 maxWidth / 视口变,钉住显示宽度,画面才不会在两帧之间挪位置 —— 手指正按着时挪一下,坐标就漂了 */
57
+ #frame{display:block;width:100%;height:auto;-webkit-user-drag:none;pointer-events:none;transform-origin:0 0;will-change:transform}
58
+ #frame:not([src]){display:none}
59
+ #hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center;color:var(--dim);line-height:1.8;pointer-events:none}
60
+ /* ⚠ 提示层整体 pointer-events:none 是为了让画面上的手势穿过它;层里可点的东西要逐个放开 —— 只放了 a 没放 button,
61
+ 手机上真手指点「开始接管」直接穿透、什么都不发生(2026-09-10 真机;桌面用程序化 click() 绕过了这一层所以没测出来) */
62
+ #hint a,#hint button{color:var(--ember);pointer-events:auto}
63
+ #hint button.primary{color:var(--on-ember)}
64
+ /* 首帧引导:接管中第一次看到画面时飘一句怎么用,手指一碰就收 */
65
+ #tip{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(0,0,0,.72);color:#fff;font-size:12px;padding:8px 14px;border-radius:20px;white-space:nowrap;pointer-events:none;transition:opacity .4s}
66
+ #tip.fade{opacity:0}
67
+ /* 缩放控件:在顶栏、不在画面里(浮在画面右上角时正好压住网页自己右上角的按钮 —— 多数站点的「设置 / 登录」都在那儿,
68
+ 点过去落在控件上而不是网页上)。凡叠在画面上的东西都会抢走它底下那块的点击,所以控件一律放画面区外。没画面时靠 body.streaming 藏。
69
+ 手机上只留「100%」一枚(既是指示也是还原;缩放靠双指捏合,首帧提示已教),− / + 只给桌面 —— 窄屏顶栏挤不下三枚。 */
70
+ #zoom{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--btn-bg)}
71
+ body:not(.streaming) #zoom{display:none}
72
+ #zoom button{height:32px;min-width:32px;padding:0 8px;border:none;border-radius:0;background:transparent;font-size:16px}
73
+ #zoom button+button{border-left:1px solid var(--line)}
74
+ #zoom button#zoomv{font-size:12px;min-width:52px;font-variant-numeric:tabular-nums}
75
+ #zoom.on #zoomv{color:var(--ember);background:rgba(var(--ember-rgb),.14)}
76
+ .keys{display:flex;gap:6px;flex-wrap:wrap;width:100%}
77
+ .keys button{height:32px;padding:0 10px;font-size:12px;display:inline-flex;align-items:center;justify-content:center;flex:1;min-width:38px}
78
+ /* 整屏面板(欢迎 / 结束 / 过期 / 去登录):居中、一句话、一个动作 */
79
+ #panel{flex:1;display:flex;align-items:center;justify-content:center;padding:32px 24px calc(32px + env(safe-area-inset-bottom))}
80
+ #panel .pbox{width:min(420px,100%);text-align:center}
81
+ #panel .pico{width:56px;height:56px;border-radius:16px;margin:0 auto 18px;display:flex;align-items:center;justify-content:center;background:rgba(var(--ember-rgb),.12);color:var(--ember)}
82
+ #panel .pico .ic{width:26px;height:26px}
83
+ #panel .pico.grey{background:rgba(127,127,127,.12);color:var(--faint)}
84
+ #panel h1{font-size:20px;margin:0 0 10px;line-height:1.35}
85
+ #panel p{margin:0 0 22px;color:var(--dim);line-height:1.8;font-size:14px}
86
+ #panel button.primary,#panel a.primary{height:46px;padding:0 28px;font-size:15px;border-radius:12px;width:100%;display:inline-flex;align-items:center;justify-content:center;text-decoration:none;background:var(--ember);color:var(--on-ember);border:none;font-weight:600}
87
+ #panel .note{margin-top:14px;font-size:12px;color:var(--faint)}
88
+ #panel .err{margin-top:12px;font-size:13px;color:var(--fail);min-height:1.2em}
89
+ .mask{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;z-index:9}
90
+ .mask[hidden]{display:none}
91
+ .card{background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:18px;width:min(520px,100%);box-shadow:0 10px 28px rgba(0,0,0,.3)}
92
+ .card h3{margin:0 0 10px;font-size:15px}
93
+ .card .lnk{font:12px var(--mono);word-break:break-all;background:var(--input-bg);padding:10px;border-radius:8px;border:1px solid var(--line);user-select:all}
94
+ .card .row{display:flex;gap:8px;margin-top:12px;justify-content:flex-end;flex-wrap:wrap}
95
+ .hint2{font-size:11.5px;color:var(--faint);margin-top:8px;line-height:1.7}
96
+ @media (max-width:520px){ select{max-width:100%;width:100%} .top{gap:6px} button{padding:0 10px} .ptitle{max-width:100%;width:100%} #zoom [data-z]{display:none} #zoom button+button{border-left:none} }
97
+ </style>
98
+ <script>document.documentElement.dataset.theme=localStorage.getItem('sbot-theme')||'light'</script>
99
+ </head>
100
+ <body data-mode="boot">
101
+ <section id="panel"><div class="pbox" id="pbox"></div></section>
102
+ <header class="top">
103
+ <a class="back" id="back" href="/" hidden>控制台</a>
104
+ <select id="tabs" aria-label="标签页"><option value="">读取标签页…</option></select>
105
+ <span class="ptitle" id="ptitle" hidden></span>
106
+ <button class="iconbtn" id="reload" title="重新读取标签页" aria-label="重新读取标签页"><svg class="ic"><use href="#lu-refresh"/></svg></button>
107
+ <button class="iconbtn" id="newtab" title="新开标签页" aria-label="新开标签页"><svg class="ic"><use href="#lu-plus"/></svg></button>
108
+ <span class="sp"></span>
109
+ <button id="mode" title="点按:手指 / 鼠标动作原样传给页面(能拖滑块)。点一下换成「滚动」">点按</button>
110
+ <div id="zoom" title="画面缩放(手机上双指捏合)"><button data-z="out" aria-label="缩小" title="缩小">−</button><button id="zoomv" title="还原到适应宽度">100%</button><button data-z="in" aria-label="放大" title="放大">+</button></div>
111
+ <button id="mklink" hidden title="生成一条只能打开一次的接管链接,发给群里要登录的人"><svg class="ic" style="display:inline;vertical-align:-3px;margin-right:4px"><use href="#lu-link"/></svg>接管链接</button>
112
+ <button id="end" class="danger" hidden>结束接管</button>
113
+ <button class="iconbtn" id="theme" title="切换亮/暗主题" aria-label="切换主题"><svg class="ic"><use href="#lu-sun"/></svg></button>
114
+ <span class="st" id="st">连接中…</span>
115
+ <span class="url" id="url"></span>
116
+ </header>
117
+ <main id="stage">
118
+ <img id="frame" alt="">
119
+ <div id="hint" hidden></div>
120
+ <div id="tip" hidden>一指点、拖 = 操作页面;两指捏合缩放、两指拖动移动;打字用下面的输入框</div>
121
+ </main>
122
+ <footer class="kb">
123
+ <input id="txt" placeholder="输入文字,回车 = 发到页面并按回车" autocomplete="off" autocapitalize="off">
124
+ <button id="send" class="primary">发送</button>
125
+ <div class="keys">
126
+ <button data-key="Enter" title="回车"><svg class="ic"><use href="#lu-enter"/></svg></button>
127
+ <button data-key="Backspace" title="退格"><svg class="ic"><use href="#lu-backspace"/></svg></button>
128
+ <button data-key="Tab">Tab</button><button data-key="Escape">Esc</button>
129
+ <button data-key="ArrowUp" title="上" aria-label="上"><svg class="ic"><use href="#lu-up"/></svg></button><button data-key="ArrowDown" title="下" aria-label="下"><svg class="ic"><use href="#lu-down"/></svg></button><button data-key="ArrowLeft" title="左" aria-label="左"><svg class="ic"><use href="#lu-left"/></svg></button><button data-key="ArrowRight" title="右" aria-label="右"><svg class="ic"><use href="#lu-right"/></svg></button>
130
+ </div>
131
+ </footer>
132
+ <div class="mask" id="mask" hidden><div class="card" id="card"></div></div>
133
+ <svg style="display:none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
134
+ <symbol id="lu-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
135
+ <symbol id="lu-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
136
+ <symbol id="lu-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
137
+ <symbol id="lu-enter" viewBox="0 0 24 24"><path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/></symbol>
138
+ <symbol id="lu-backspace" viewBox="0 0 24 24"><path d="M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/><path d="m12 9 6 6"/><path d="m18 9-6 6"/></symbol>
139
+ <symbol id="lu-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
140
+ <symbol id="lu-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
141
+ <symbol id="lu-up" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></symbol>
142
+ <symbol id="lu-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></symbol>
143
+ <symbol id="lu-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
144
+ <symbol id="lu-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
145
+ <symbol id="lu-hand" viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></symbol>
146
+ <symbol id="lu-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
147
+ <symbol id="lu-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
148
+ <symbol id="lu-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
149
+ </svg>
150
+ <script>
151
+ /* 令牌来源与 console.html 同一套:本机环回免密时服务端注入;否则读登录时存下的 localStorage。
152
+ 都没有 ⇒ 看服务端注入的接管标记:'1' = 带着有效的接管 cookie 进来的群成员(只能到这一页)。 */
153
+ let TOKEN='__SBOT_TOKEN__';
154
+ const TAKEOVER='__SBOT_TAKEOVER__'==='1';
155
+ /* 票在 URL 片段里(#k=…):服务器收不到它,链接预览 / 安全扫描的裸 GET 也拿不走;人点「开始接管」才拿去兑换 cookie */
156
+ const ticket=(()=>{const m=(location.hash||'').match(/[#&]k=([0-9a-f]{16,128})/i)||(location.search||'').match(/[?&]k=([0-9a-f]{16,128})/i);return m?m[1]:''})();
157
+ TOKEN=TOKEN||localStorage.getItem('sbot-token')||sessionStorage.getItem('sbot-token')||'';
158
+ /* ⚠ 手里有票(或人已经在接管会话里)就一律按接管走,丢掉浏览器里存着的控制台令牌 —— 否则在存过令牌的电脑上点同一条
159
+ 链接会变成管理员布局:既没有「开始接管」也没有「结束接管」;那枚令牌要是还过期了,整页只剩一句 unauthorized、
160
+ 画面都出不来、什么都点不动(2026-09-10 真机复现,手机上没存过令牌所以一直正常)。票是当下最明确的意图,它说了算。 */
161
+ if(ticket||TAKEOVER) TOKEN='';
162
+ const ADMIN=!!TOKEN;
163
+ const $=(s)=>document.querySelector(s);
164
+ const st=$('#st'),img=$('#frame'),stage=$('#stage'),hint=$('#hint'),tip=$('#tip'),sel=$('#tabs'),ptitle=$('#ptitle'),urlEl=$('#url'),txt=$('#txt'),pbox=$('#pbox');
165
+ const H=()=>TOKEN?{'X-Sbot-Token':TOKEN}:{};
166
+ const setSt=(s,cls)=>{st.textContent=s;st.className='st'+(cls?' '+cls:'')};
167
+ const setMode=(m)=>{document.body.dataset.mode=m};
168
+ const esc=(s)=>String(s).replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
169
+ /* 整屏面板:icon + 标题 + 一句话 + (可选)动作 + 小字 */
170
+ const panel=({icon='hand',grey=false,title,text,action='',note=''})=>{ setMode('panel'); pbox.innerHTML=`<div class="pico${grey?' grey':''}"><svg class="ic"><use href="#lu-${icon}"/></svg></div><h1>${title}</h1><p>${text}</p>${action}${note?`<div class="note">${note}</div>`:''}<div class="err" id="perr"></div>` };
171
+ /* 群成员的会话到期(或被结束)⇒ 整屏说清楚,别露服务端那句「unauthorized — 重新打开控制台页面」(群成员没有控制台)。推流 401 与接口 401 两条路都走这里 */
172
+ const expired=()=>{ stopStream(); panel({icon:'clock',grey:true,title:'这次接管已到期',text:'链接只在发出后的几分钟内有效。还需要的话,回群里让机器人重新发一条。'}) };
173
+ async function api(path,method='GET',obj){
174
+ const r=await fetch(path,{method,headers:{...H(),...(obj?{'Content-Type':'application/json'}:{})},body:obj?JSON.stringify(obj):undefined,credentials:'same-origin'});
175
+ const j=await r.json().catch(()=>({}));
176
+ if(r.status===401&&TAKEOVER&&!ADMIN){ expired(); throw new Error('接管已到期'); }
177
+ // 存着的控制台令牌过期了:清掉再请人登录 —— 留着它下次进来还是同一句 unauthorized、画面都出不来
178
+ if(r.status===401&&ADMIN){ try{localStorage.removeItem('sbot-token');sessionStorage.removeItem('sbot-token')}catch{} stopStream(); panel({icon:'lock',grey:true,title:'这一页需要先登录',text:'控制台的登录已经过期。重新登录后再进来,或者用群里机器人发的链接打开。',action:'<a class="primary" href="/">去登录</a>'}); throw new Error('登录已过期'); }
179
+ if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
180
+ return j;
181
+ }
182
+ /* 主题:与控制台共用同一个 localStorage 键,两边切换互相看得见 */
183
+ const themeBtn=$('#theme');
184
+ const paintTheme=()=>{themeBtn.innerHTML=`<svg class="ic"><use href="#${document.documentElement.dataset.theme==='light'?'lu-moon':'lu-sun'}"/></svg>`};
185
+ themeBtn.onclick=()=>{const t=document.documentElement.dataset.theme==='light'?'dark':'light';document.documentElement.dataset.theme=t;localStorage.setItem('sbot-theme',t);paintTheme()};
186
+ paintTheme();
187
+ if(ADMIN){$('#back').hidden=false;$('#mklink').hidden=false}
188
+ if(TAKEOVER&&!ADMIN){$('#end').hidden=false}
189
+
190
+ if(!ADMIN&&!TAKEOVER){
191
+ if(ticket){
192
+ panel({icon:'hand',title:'机器人请你帮它一把',text:'它卡在一个需要真人的步骤 —— 登录、验证码、滑块或扫码。点下面开始,之后就在这一页上直接操作它的浏览器,做完回群里说一声。',action:'<button id="claim" class="primary">开始接管</button>',note:'链接只能用一次,几分钟内有效'});
193
+ $('#claim').onclick=async()=>{
194
+ const b=$('#claim'); b.disabled=true; b.textContent='正在连接…';
195
+ try{ await api('/api/browser/claim','POST',{k:ticket}); history.replaceState(null,'',location.pathname); location.reload(); }
196
+ catch(e){ b.disabled=false; b.textContent='开始接管'; $('#perr').textContent=e.message; }
197
+ };
198
+ } else {
199
+ panel({icon:'lock',grey:true,title:'这一页需要先登录',text:'从控制台登录后再进来,或者用群里机器人发的链接打开。',action:'<a class="primary" href="/">去登录</a>'});
200
+ }
201
+ }
202
+
203
+ /* ── 标签页 ── */
204
+ let curId='',bound='';
205
+ async function loadTargets(){
206
+ let j; try{ j=await api('/api/browser/targets') }catch(e){ setSt(e.message,'bad'); return }
207
+ setMode('live');
208
+ if(!j.ok){ setSt(j.error||'浏览器没连上','bad'); hint.hidden=false; hint.textContent=j.error||'浏览器没连上'; sel.innerHTML='<option value="">(没有标签页)</option>'; return }
209
+ bound=j.bound||'';
210
+ if(j.mode==='takeover'){ $('#newtab').hidden=true; $('#reload').hidden=!!bound; } // 票不是「随便开网页」的许可(服务端同样拒);绑了单页连刷新列表都不需要
211
+ // 绑了单页:不显示下拉,只显示页面标题 —— 群成员没有「选标签页」这件事
212
+ if(bound&&j.targets.length===1){ sel.hidden=true; ptitle.hidden=false; ptitle.textContent=j.targets[0].title||j.targets[0].url||'页面'; }
213
+ else { sel.hidden=false; ptitle.hidden=true; }
214
+ const remembered=sessionStorage.getItem('sbot-takeover-tab')||'';
215
+ sel.innerHTML=j.targets.length?j.targets.map(t=>`<option value="${t.id}">${esc((t.title||t.url||'(空白页)').slice(0,60))}</option>`).join(''):'<option value="">(没有标签页,点 + 新开)</option>';
216
+ const pick=j.targets.find(t=>t.id===remembered)||j.targets.find(t=>!/^about:blank$/.test(t.url))||j.targets[0];
217
+ if(pick){ sel.value=pick.id; if(pick.id!==curId) startStream(pick.id) } else { stopStream(); setSt('浏览器里没有标签页'); }
218
+ }
219
+ sel.onchange=()=>{ if(sel.value) startStream(sel.value) };
220
+ $('#reload').onclick=loadTargets;
221
+ $('#newtab').onclick=async()=>{
222
+ const u=window.prompt('要打开的网址(http 或 https 开头)','https://');
223
+ if(!u)return;
224
+ try{ const r=await api('/api/browser/targets','POST',{url:u}); await loadTargets(); if(r.id){ sel.value=r.id; startStream(r.id) } }catch(e){ setSt(e.message,'bad') }
225
+ };
226
+
227
+ /* ── 画面:服务端按 SSE 形状写,这里用 fetch 流式读(令牌走请求头,不进 URL) ── */
228
+ let ac=null,meta={w:0,h:0},frames=0,tipShown=false;
229
+ let zoom=1,tx=0,ty=0,streamW=0,rezoomTimer=null; // 本地缩放 / 平移(只动 CSS transform)与当前推流宽度
230
+ /* 推流宽度上限 1024(2026-09-15 从 1600 压下来):帧和输入走同一条公网隧道,桌面按容器宽 × dpr 要到 1440~1600 时一帧约 40 KB、拖动中连续出帧,
231
+ 把隧道发送队列塞满后输入的响应排在帧后面,每个事件 1.7~2 秒、滑块拖不动(隧道退化时尤甚);手机本来只要 780 左右所以一直没事。
232
+ 1024 把桌面每帧砍掉约一半给输入让路,1280 宽的页面缩到 80% 仍看得清;放大时也不再超过它 —— 用一点清晰度换拖得动。 */
233
+ const STREAM_W_MAX=1024;
234
+ const W=()=>Math.min(STREAM_W_MAX,Math.max(320,Math.round(stage.clientWidth*Math.min(2,window.devicePixelRatio||1)*zoom))); // 放大了就按放大后的尺寸要帧(CDP 不会放大超过页面本身的宽)
235
+ function stopStream(keepView){ if(ac){ac.abort();ac=null} curId=''; if(!keepView){ img.removeAttribute('src'); document.body.classList.remove('streaming') } }
236
+ async function startStream(id,{keepView=false}={}){
237
+ stopStream(keepView); curId=id; if(!keepView){ zoom=1; tx=0; ty=0; applyView() } sessionStorage.setItem('sbot-takeover-tab',id); frames=0; meta={w:0,h:0};
238
+ hint.hidden=true; setSt('连接中…'); urlEl.textContent='';
239
+ api('/api/browser/targets/activate','POST',{id}).catch(()=>{}); // 机器旁边的人也能看到这一页(失败无所谓)
240
+ const mine=new AbortController(); ac=mine;
241
+ try{
242
+ // q=45:手机上一帧约 12 KB,文字仍可读;想更清晰把它调高,代价是流量翻倍
243
+ streamW=W(); const r=await fetch(`/api/browser/stream?id=${encodeURIComponent(id)}&w=${streamW}&q=45`,{headers:H(),signal:mine.signal,credentials:'same-origin'});
244
+ if(!r.ok){ const j=await r.json().catch(()=>({})); if(r.status===401&&TAKEOVER&&!ADMIN){ expired(); return } setSt(j.error||('连不上:'+r.status),'bad'); hint.hidden=false; hint.textContent=j.error||'连不上浏览器'; return }
245
+ const rd=r.body.getReader(),dec=new TextDecoder(); let buf='';
246
+ while(true){
247
+ const {value,done}=await rd.read(); if(done)break;
248
+ buf+=dec.decode(value,{stream:true});
249
+ let i; while((i=buf.indexOf('\n\n'))>=0){ onEvent(buf.slice(0,i)); buf=buf.slice(i+2) }
250
+ }
251
+ if(ac===mine){ setSt('画面断了,重连中…','bad'); setTimeout(()=>{ if(ac===mine) startStream(id) },1500) }
252
+ }catch(e){
253
+ if(ac===mine&&!mine.signal.aborted){ setSt('画面断了:'+e.message,'bad'); setTimeout(()=>{ if(ac===mine) startStream(id) },2000) }
254
+ }
255
+ }
256
+ function onEvent(chunk){
257
+ let ev='message',data='';
258
+ for(const line of chunk.split('\n')){ if(line.startsWith('event:'))ev=line.slice(6).trim(); else if(line.startsWith('data:'))data+=line.slice(5).trim() }
259
+ let j; try{ j=JSON.parse(data) }catch{ return }
260
+ if(ev==='frame'){
261
+ frames++; if(j.w&&j.h){meta.w=j.w;meta.h=j.h}
262
+ img.src='data:image/jpeg;base64,'+j.b64; document.body.classList.add('streaming'); // 顶栏的缩放控件只在有画面时显示
263
+ // 状态文案给人看:群成员只需要知道「可以动手了」;尺寸与帧数是排障信息,管理员才显示
264
+ setSt(ADMIN?`画面 ${meta.w}×${meta.h} · ${frames} 帧`:'可以操作了','ok');
265
+ document.body.dataset.frames=String(frames); // headless 验证读这个
266
+ if(!tipShown&&!ADMIN){ tipShown=true; tip.hidden=false; setTimeout(()=>tip.classList.add('fade'),4500); }
267
+ } else if(ev==='meta'){ if(j.url) urlEl.textContent=j.url; if(j.title&&bound) ptitle.textContent=j.title; }
268
+ else if(ev==='error'){ setSt(j.error||'出错','bad') }
269
+ }
270
+
271
+ /* ── 输入:坐标从帧图片换算成页面 CSS 像素;move 合并成 33ms 一批,down/up 立刻发且保持顺序 ── */
272
+ /* move 攒全部中间点、整批发出(2026-09-15)。此前 pendingMove 是单个变量、每来一个 move 就覆盖:请求在飞的那段时间里
273
+ (公网隧道一个往返约 160 ms)所有中间点全丢 —— 实测 30 步拖动到服务端只剩 5 个点,轨迹成了跳跃,验证码的轨迹检测不认;
274
+ 局域网往返 10 ms 才显不出来。攒下来整批发、服务端按序回放,轨迹形状就保住了。上限 MOVE_KEEP:超过就隔一个抽一个
275
+ (保形状不保密度,最后一个点必留),免得一次长拖攒出几百条撞上服务端一批 200 条的上限。 */
276
+ const q=[]; let flushing=false,moveTimer=null,pendingMoves=[]; const MOVE_KEEP=60;
277
+ const takeMoves=()=>{ if(pendingMoves.length){ q.push(...pendingMoves); pendingMoves=[] } };
278
+ async function flush(){
279
+ if(flushing||!curId)return; takeMoves();
280
+ if(!q.length)return;
281
+ flushing=true; const batch=q.splice(0,q.length);
282
+ try{ await api('/api/browser/input','POST',{id:curId,events:batch}) }catch(e){ setSt('输入没送到:'+e.message,'bad') }
283
+ finally{ flushing=false; if(q.length||pendingMoves.length) flush() }
284
+ }
285
+ const push=(ev)=>{ q.push(ev); flush() };
286
+ function map(e){ const r=img.getBoundingClientRect(); if(!meta.w||!r.width)return null; return { x:Math.round(Math.min(meta.w,Math.max(0,(e.clientX-r.left)/r.width*meta.w))*10)/10, y:Math.round(Math.min(meta.h,Math.max(0,(e.clientY-r.top)/r.height*meta.h))*10)/10 } }
287
+ /* ── 缩放 / 移动:bot 那边多半是 1280 宽的桌面页,手机上一屏缩到 390 看不清 ⇒ 两指捏合缩放、两指拖动移动(一指仍归页面);
288
+ 「移动」模式下一指拖动也是移动;桌面 ctrl+滚轮(触控板捏合)缩放;右上角 − / + / 百分比(点百分比还原)。
289
+ 只动 CSS transform(transform-origin 左上);map() 读的是 getBoundingClientRect = 变换后的矩形,坐标换算不用改 ── */
290
+ const zoomBox=$('#zoom'),zoomLabel=$('#zoomv');
291
+ function applyView(){
292
+ if(zoom<=1){ zoom=1; tx=0; ty=0 }
293
+ const sw=stage.clientWidth,sh=stage.clientHeight,w=img.clientWidth*zoom,h=img.clientHeight*zoom;
294
+ tx=Math.min(0,Math.max(Math.min(0,sw-w),tx)); ty=Math.min(0,Math.max(Math.min(0,sh-h),ty)); // 夹住:画面不能拖出视口;比视口矮就贴顶
295
+ img.style.transform=zoom===1?'':`translate(${tx}px,${ty}px) scale(${zoom})`;
296
+ zoomLabel.textContent=Math.round(zoom*100)+'%'; zoomBox.classList.toggle('on',zoom>1);
297
+ // 放大到一定程度就按新尺寸重开推流(按手机宽要的 780px 帧放大三倍就糊了);600ms 没再动才重开,画面不清空
298
+ // ⚠ 帧宽到了页面原宽就别再为「放大」重开:上一路要 1600 却只回来 1280(页面本身就 1280 宽)⇒ 再要更大也不会更清楚,只会多一次断流重连
299
+ // (真机:桌面 ctrl+滚轮一步步放大,90 秒重开了 6 路);缩小(要更小的帧省流量)照旧
300
+ clearTimeout(rezoomTimer); rezoomTimer=setTimeout(()=>{ if(!curId||!streamW)return; const need=W(),capped=meta.w>0&&meta.w<streamW-2; if(need>streamW&&capped)return; if(Math.abs(need-streamW)/streamW>0.3) startStream(curId,{keepView:true}) },600);
301
+ }
302
+ function zoomAt(f,cx,cy){ const z=Math.min(5,Math.max(1,zoom*f)),k=z/zoom; tx=cx-(cx-tx)*k; ty=cy-(cy-ty)*k; zoom=z; applyView() } // 以 stage 内 (cx,cy) 为锚:锚点下的画面缩放前后不动
303
+ zoomBox.addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return; const sw=stage.clientWidth/2,sh=stage.clientHeight/2; if(b.dataset.z==='in')zoomAt(1.5,sw,sh); else if(b.dataset.z==='out')zoomAt(1/1.5,sw,sh); else{ zoom=1; applyView() } });
304
+ window.addEventListener('resize',()=>applyView());
305
+ /* 模式:点按(一指 = 页面动作)· 滚动(一指上下 = 页面滚动)· 移动(一指 = 拖画面) */
306
+ let mode='tap'; const MODES={tap:'点按',scroll:'滚动',pan:'移动'};
307
+ const MODE_TITLES={tap:'点按:手指 / 鼠标动作原样传给页面(能拖滑块)。点一下换成「滚动」',scroll:'滚动:上下拖动变成页面滚动。点一下换成「移动」',pan:'移动:一指拖动放大后的画面、滚轮缩放。点一下换回「点按」'};
308
+ $('#mode').onclick=()=>{ mode=mode==='tap'?'scroll':mode==='scroll'?'pan':'tap'; $('#mode').textContent=MODES[mode]; $('#mode').title=MODE_TITLES[mode]; $('#mode').classList.toggle('on',mode!=='tap') };
309
+ /* 指针:ptrs 记着所有按着的手指。一指按下不立刻发 down —— 等 120ms 或手指动了才发:第二根手指在这窗口里落下就是捏合,页面不该收到一次点击 */
310
+ const ptrs=new Map(); let down=false,drag=null,pend=null,gesture=null,pan=null,lastP=null;
311
+ const commitDown=()=>{ if(!pend)return; const p=pend.p; clearTimeout(pend.timer); pend=null; down=true; lastP=p; push({t:'down',...p}) };
312
+ stage.addEventListener('pointerdown',e=>{
313
+ if(e.target.closest('#zoom'))return;
314
+ if(!curId)return; e.preventDefault(); try{stage.setPointerCapture(e.pointerId)}catch{}
315
+ tip.classList.add('fade');
316
+ // 自愈:primary 指针按下 = 浏览器认为此刻没有别的触点(触摸序列的第一指 / 鼠标)⇒ 之前丢了 up / cancel 留下的幽灵指针一律清掉,否则手势态卡死、一指点什么都不发
317
+ if(e.isPrimary){ if(down&&lastP) push({t:'up',...lastP}); down=false; if(pend){clearTimeout(pend.timer);pend=null} drag=null; ptrs.clear(); gesture=null; pan=null } // 上一次的 down 没等到 up ⇒ 先把远端鼠标松开,不然页面那边一直按着
318
+ ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
319
+ if(ptrs.size===2){ // 进入两指手势:没发出去的 down 作废;已发出去的补一个 up(页面会看到一次轻点,少见)
320
+ if(pend){clearTimeout(pend.timer);pend=null}
321
+ if(down){ down=false; if(moveTimer){clearTimeout(moveTimer);moveTimer=null} pendingMoves=[]; if(lastP)push({t:'up',...lastP}) }
322
+ drag=null; pan=null;
323
+ const [a,b]=[...ptrs.values()]; gesture={d:Math.hypot(a.x-b.x,a.y-b.y),cx:(a.x+b.x)/2,cy:(a.y+b.y)/2,zoom,tx,ty}; return;
324
+ }
325
+ if(ptrs.size>2||gesture)return;
326
+ if(mode==='pan'){ pan={x:e.clientX,y:e.clientY,tx,ty}; return }
327
+ const p=map(e); if(!p)return;
328
+ if(mode==='scroll'){ drag={y:e.clientY,p}; return }
329
+ pend={p,x0:e.clientX,y0:e.clientY,timer:setTimeout(commitDown,120)};
330
+ });
331
+ stage.addEventListener('pointermove',e=>{
332
+ if(!curId)return;
333
+ if(ptrs.has(e.pointerId)) ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
334
+ if(gesture){
335
+ if(ptrs.size<2)return; const [a,b]=[...ptrs.values()]; const d=Math.hypot(a.x-b.x,a.y-b.y),cx=(a.x+b.x)/2,cy=(a.y+b.y)/2; const r=stage.getBoundingClientRect();
336
+ const z=Math.min(5,Math.max(1,gesture.zoom*d/gesture.d)),k=z/gesture.zoom; const ax=gesture.cx-r.left,ay=gesture.cy-r.top;
337
+ tx=ax-(ax-gesture.tx)*k+(cx-gesture.cx); ty=ay-(ay-gesture.ty)*k+(cy-gesture.cy); zoom=z; applyView(); return; // 缩放锚在起始中点 + 中点位移 = 移动
338
+ }
339
+ if(pan){ tx=pan.tx+(e.clientX-pan.x); ty=pan.ty+(e.clientY-pan.y); applyView(); return }
340
+ if(drag){ const r=img.getBoundingClientRect(); const dy=(drag.y-e.clientY)/r.height*meta.h; if(Math.abs(dy)>=4){ drag.y=e.clientY; push({t:'wheel',...drag.p,dy:Math.round(dy)}) } return }
341
+ if(pend){ if(Math.hypot(e.clientX-pend.x0,e.clientY-pend.y0)<6)return; commitDown() }
342
+ if(!down)return; const p=map(e); if(!p)return; lastP=p;
343
+ pendingMoves.push({t:'move',...p,down:true});
344
+ if(pendingMoves.length>MOVE_KEEP) pendingMoves=pendingMoves.filter((_,i,a)=>i%2===1||i===a.length-1); // 隔一个抽一个,最后一个点必留
345
+ if(!moveTimer) moveTimer=setTimeout(()=>{moveTimer=null;flush()},33); // 33ms ≈ 30 次/秒:拖滑块够顺,又不至于每次指针抖动都打一个请求
346
+ });
347
+ const endPtr=(e)=>{
348
+ ptrs.delete(e.pointerId);
349
+ if(gesture){ if(ptrs.size===0)gesture=null; return } // 手势里先松一指:等全松开才回到正常,剩下那指不算页面操作
350
+ if(pan){ if(ptrs.size===0)pan=null; return }
351
+ if(!curId)return;
352
+ if(drag){drag=null;return}
353
+ if(pend) commitDown(); // 轻点:down 还没发就松开了 ⇒ 先补 down 再 up
354
+ if(!down)return; down=false; const p=map(e); if(moveTimer){clearTimeout(moveTimer);moveTimer=null} takeMoves(); if(p) push({t:'up',...p}); else flush()
355
+ };
356
+ stage.addEventListener('pointerup',endPtr); stage.addEventListener('pointercancel',endPtr);
357
+ stage.addEventListener('wheel',e=>{ if(!curId)return; e.preventDefault();
358
+ if(e.ctrlKey||mode==='pan'){ const r=stage.getBoundingClientRect(); zoomAt(e.deltaY<0?1.15:1/1.15,e.clientX-r.left,e.clientY-r.top); return } // 触控板捏合到浏览器 = ctrl+wheel
359
+ const p=map(e); if(!p)return; push({t:'wheel',...p,dx:Math.round(e.deltaX),dy:Math.round(e.deltaY)}) },{passive:false});
360
+ /* 键盘 */
361
+ const KEYS=new Set(['Enter','Backspace','Tab','Escape','Delete','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End','PageUp','PageDown']);
362
+ const sendText=()=>{ const s=txt.value; if(!s)return false; push({t:'text',text:s}); txt.value=''; return true };
363
+ $('#send').onclick=()=>{ sendText(); txt.focus() };
364
+ txt.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); sendText(); push({t:'key',key:'Enter'}) } });
365
+ for(const b of document.querySelectorAll('.keys button')) b.onclick=()=>{ push({t:'key',key:b.dataset.key}); };
366
+ document.addEventListener('keydown',e=>{
367
+ if(!curId||e.target===txt||e.target.tagName==='SELECT'||e.target.tagName==='INPUT')return;
368
+ if(KEYS.has(e.key)){ e.preventDefault(); push({t:'key',key:e.key,shift:e.shiftKey,ctrl:e.ctrlKey,alt:e.altKey,meta:e.metaKey}) }
369
+ else if(e.key.length===1&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); push({t:'text',text:e.key}) }
370
+ });
371
+
372
+ /* ── 接管链接(管理员)/ 结束接管(群成员) ── */
373
+ const mask=$('#mask'),card=$('#card');
374
+ const closeCard=()=>{mask.hidden=true};
375
+ mask.addEventListener('click',e=>{ if(e.target===mask) closeCard() });
376
+ $('#mklink').onclick=async()=>{
377
+ try{
378
+ const r=await api('/api/browser/link','POST',{});
379
+ card.innerHTML=`<h3>接管链接</h3><div class="lnk" id="lnk"></div><div class="hint2">发给需要登录的人。${r.minutes} 分钟内有效,<b>只能打开一次</b>;打开后在那边的页面上点、拖、输入,登录态会留在机器人的浏览器里。${r.reach==='public'?'':'<br>⚠ 这是局域网地址,出了局域网打不开(要给外面的人用,先在控制台设置里填公网地址)。'}</div><div class="row"><button id="cp" class="primary">复制</button><button id="cl">关闭</button></div>`;
380
+ $('#lnk').textContent=r.url; mask.hidden=false;
381
+ $('#cp').onclick=async()=>{ try{ await navigator.clipboard.writeText(r.url); $('#cp').textContent='已复制' }catch{ $('#cp').textContent='长按上面的地址复制' } };
382
+ $('#cl').onclick=closeCard;
383
+ }catch(e){ setSt(e.message,'bad') }
384
+ };
385
+ $('#end').onclick=async()=>{
386
+ try{ await api('/api/browser/end','POST',{}) }catch{}
387
+ stopStream(); panel({icon:'check',title:'已结束接管',text:'这条链接不能再用了。要再来一次,回群里让机器人重新发一条。'});
388
+ };
389
+ if(ADMIN||TAKEOVER) loadTargets();
390
+ </script>
391
+ </body>
392
+ </html>