dsh-tabbit 0.2.3 → 0.3.2
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.
- package/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +70 -76
- package/client/client.js +390 -0
- package/cordis.patch.yml +76 -5
- package/lib/core/index.js +756 -0
- package/lib/installer/detect.js +374 -0
- package/lib/installer/download.js +247 -0
- package/lib/installer/index.js +254 -0
- package/lib/mentions/index.js +595 -0
- package/lib/permissions/index.js +136 -0
- package/lib/runtime/cli.js +229 -0
- package/lib/runtime/client.js +454 -0
- package/lib/runtime/codec.js +126 -0
- package/lib/runtime/endpoint.js +248 -0
- package/lib/runtime/errors.js +126 -0
- package/lib/runtime/instances.js +287 -0
- package/lib/runtime/net.js +143 -0
- package/lib/runtime/peer.js +132 -0
- package/lib/tool-browser/index.js +476 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +55 -16
- package/skills/tabbit/SKILL.md +66 -0
- package/skills/tabbit/references/interaction-helpers.md +150 -0
- package/skills/tabbit/references/platform-invocation.md +174 -0
- package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
- package/skills/tabbit/references/runtime-recovery.md +104 -0
- package/README.zh-CN.md +0 -114
- package/index.js +0 -352
- package/installer.js +0 -568
- package/skills/tabbit-browser/SKILL.md +0 -274
- package/skills/tabbit-browser/agents/openai.yaml +0 -4
- package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
- package/skills/tabbit-browser/references/platform-invocation.md +0 -45
- package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
- package/update-check.js +0 -177
- /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:`@tab` 提及的服务端半边 + 观看实例打点路由
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* `@tab` 提及是一个"前后端合作"的功能:
|
|
7
|
+
* - 前端半边在 client/client.js(跑在 dsh 网页里):输入框敲 `@` 时列出
|
|
8
|
+
* 候选标签页、选中插 chip、发送时序列化 chip;
|
|
9
|
+
* - 本文件是后端半边:四条挂在 dsh `webServer` 服务上的 loopback HTTP 路由,
|
|
10
|
+
* 给前端提供数据,外加一个不挂路由、直接注册在 agent 事件上的钩子。
|
|
11
|
+
*
|
|
12
|
+
* ─── 背景:dsh 的 webServer 服务 ─────────────────────────────────────────
|
|
13
|
+
* dsh 起的本机 HTTP 服务器(服务 web UI 的那个)开放了路由注册点:
|
|
14
|
+
* `ctx.webServer.register({kind:'exact', path, handler})` 挂精确路径的处理器,
|
|
15
|
+
* handler 拿到原生 Node 的 (IncomingMessage, ServerResponse)。前端页面和这些
|
|
16
|
+
* 路由同源,fetch 直接打过来。
|
|
17
|
+
*
|
|
18
|
+
* 四条路由:
|
|
19
|
+
* - GET /tabbit/mention/tabs?session=<会话id>
|
|
20
|
+
* @ 候选列表的数据源,两段合并:①该会话浏览器任务里打开的页面
|
|
21
|
+
* (kind:'task-page');②用户浏览器里的普通标签页(kind:'user-tab',
|
|
22
|
+
* 直连端点清单,仅 state=available——busy 的是某个自动化任务已认领的,
|
|
23
|
+
* 与任务页重复;且仅 http/https、非新标签页,见 mentionableUserTabUrl)。
|
|
24
|
+
* 两段各自失败互不拖累。
|
|
25
|
+
* - GET /tabbit/tabs[?instance=<16位大写hex>]
|
|
26
|
+
* 【全 profile】标签页清单(含用户自己开的页面)——直连 Runtime Service
|
|
27
|
+
* 端点读取(runtime/endpoint.ts,~1ms、零副作用、不拉起浏览器),给本机
|
|
28
|
+
* 程序化消费方用。与 roster 不同,这条路由的失败如实回 4xx/5xx。
|
|
29
|
+
* - POST /tabbit/mention/extract
|
|
30
|
+
* 提取被提及页面的标题+正文(用户点发送时由 chip 的序列化逻辑调用)。
|
|
31
|
+
* 两种请求体:
|
|
32
|
+
* {"task","url","index"} —— 任务页:在所属任务里就地读取(live 状态);
|
|
33
|
+
* {"url","userTab":true} —— 用户标签页:在共享只读 fetch 任务里【重新
|
|
34
|
+
* 打开该 URL】提取后关页(与 web_fetch 同机制,共享登录态)。不直接读
|
|
35
|
+
* 用户标签页本体是刻意的:就地读取必须先 claim——那会把用户的标签页
|
|
36
|
+
* 可见地挪进代理标签组,对一次提及来说过于侵入。代价是拿到的是该 URL
|
|
37
|
+
* 的新副本而非用户页面的实时状态(表单输入、滚动位置等不包含)。
|
|
38
|
+
* 提取到的正文【不会】原样回给前端:本路由把它暂存进下面的
|
|
39
|
+
* extractionStash,只回一个一次性 token(见 stashExtraction/
|
|
40
|
+
* consumeStash)。前端 codec.serialize() 只拿 token 拼一个短标记塞进
|
|
41
|
+
* 发出去的文本,聊天气泡里不会直接堆出整段网页正文。
|
|
42
|
+
* - POST /tabbit/instance-hint
|
|
43
|
+
* 观看实例打点:网页加载时前端 ping 一下,服务端把这条 TCP 连接溯源到
|
|
44
|
+
* 具体 Tabbit 实例(peer.ts),记为首选执行实例。
|
|
45
|
+
*
|
|
46
|
+
* ─── 正文怎么从"暂存"变成模型看得见的上下文(agent/pre-step 钩子)────────
|
|
47
|
+
*
|
|
48
|
+
* dsh 的 agent 在每一步即将把消息交给模型前会触发 `agent/pre-step`(瀑布式
|
|
49
|
+
* 事件,处理器收到 (payload, next):调 next() 拿到下游已经处理过的决定,
|
|
50
|
+
* 可以在其基础上再改 messages 后原样返回——src/permissions/index.ts 头部
|
|
51
|
+
* 注释里 tools/pre-execute 瀑布是同一种机制,这里是 agent 版)。
|
|
52
|
+
* 本文件在 apply() 里注册这个钩子:扫描本次即将进入模型的每条【用户】消息
|
|
53
|
+
* 文本,找 `@[标题](tabbit-tab:token)` 这样的标记——命中就:
|
|
54
|
+
* 1. 用 token 从 extractionStash 取回(并清掉)之前 /tabbit/mention/extract
|
|
55
|
+
* 暂存的完整正文;
|
|
56
|
+
* 2. 把消息里的标记文本替换成干净的 "@标题"(用户看到的气泡不再有标记
|
|
57
|
+
* 痕迹);
|
|
58
|
+
* 3. 在这条消息后面紧跟着插入一条【独立】的消息,source 标成
|
|
59
|
+
* {kind:'plugin', plugin:'dsh-tabbit', form:'notice'}——dsh 把这种
|
|
60
|
+
* source.kind 不是 'user' 的消息渲染成默认折叠的"Context injection"
|
|
61
|
+
* 行,而不是普通聊天气泡,正文(含 <browser-tab> 包装)就放在这条
|
|
62
|
+
* 消息里。模型两条都会看到,用户默认只看到一行可展开的提示。
|
|
63
|
+
* 这与 dsh 内置的 @文件/@对话 提及是同一套机制(dsh-session-reference 包
|
|
64
|
+
* 的 ctx.on('agent/pre-step', ...) 就是这么把引用的会话正文接进去的)——
|
|
65
|
+
* MessageSourceMap 的 'plugin' 分支就是官方留给三方插件的扩展点,不需要
|
|
66
|
+
* 改宿主代码。
|
|
67
|
+
*
|
|
68
|
+
* 范围说明:@ 候选列表目前仍只覆盖代理自己任务里开的(或被显式 claim 的)
|
|
69
|
+
* 页面——因为 extract 只能对任务内页面执行;用户标签页要先经 claim 接管才能
|
|
70
|
+
* 提取,@ 提及升级为全量候选是后续工作。枚举本身已不受限:新一代 Runtime
|
|
71
|
+
* Service 的 unbound tabs 清单(/tabbit/tabs 路由)就是为此开的口。
|
|
72
|
+
*
|
|
73
|
+
* 隐私边界:/tabbit/tabs 暴露的是用户整个浏览器的标签页元数据(标题+URL),
|
|
74
|
+
* 敏感度高于 roster——与其它路由同样只接受本机回环访问;它不做逐次审批
|
|
75
|
+
* (路由处理器没有开启中的 turn,接不了 ctx.approval),消费方应自律只在
|
|
76
|
+
* 用户明确交互(如 @ 菜单展开、明确的列表命令)时调用。
|
|
77
|
+
*/
|
|
78
|
+
import { randomUUID } from 'node:crypto';
|
|
79
|
+
import { boundContextSummary, createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm';
|
|
80
|
+
import { FETCH_TASK_NAME } from '../core/index.js';
|
|
81
|
+
import { TabbitCliError } from '../runtime/errors.js';
|
|
82
|
+
import { listInstances } from '../runtime/instances.js';
|
|
83
|
+
import { instanceIdForPeerPort } from '../runtime/peer.js';
|
|
84
|
+
/* 各路由里浏览器求值的超时与提取上限。 */
|
|
85
|
+
const ROSTER_TIMEOUT_MS = 15_000;
|
|
86
|
+
const EXTRACT_TIMEOUT_MS = 20_000;
|
|
87
|
+
const EXTRACT_MAX_CHARS = 60_000;
|
|
88
|
+
/*
|
|
89
|
+
* 提取正文的暂存有效期:从 /tabbit/mention/extract 写入到 agent/pre-step
|
|
90
|
+
* 消费之间正常只有几百毫秒(用户点发送 → 该条消息进入下一步),10 分钟是
|
|
91
|
+
* 给"发送后消息迟迟没进入任何一步"(比如用户切走、会话卡住)的宽松兜底,
|
|
92
|
+
* 而不是预期路径。过期或已被消费的 token 在 pre-step 里会被当作"取不回"
|
|
93
|
+
* 处理(见 expandTabMentions),不影响这条消息本身发送成功。
|
|
94
|
+
*/
|
|
95
|
+
const EXTRACT_STASH_TTL_MS = 10 * 60 * 1000;
|
|
96
|
+
/* 前端 codec.serialize() 拼出的短标记:@[标题](tabbit-tab:token)。 */
|
|
97
|
+
const TAB_MENTION_MARKER = /@\[([^\]]*)\]\(tabbit-tab:([a-zA-Z0-9-]+)\)/gu;
|
|
98
|
+
export const name = 'tabbit-mentions';
|
|
99
|
+
export const inject = ['tabbit'];
|
|
100
|
+
export function apply(ctx) {
|
|
101
|
+
// 提取结果的暂存表:/tabbit/mention/extract 写入,agent/pre-step 钩子
|
|
102
|
+
// 消费。挂在 apply() 的闭包里而不是模块顶层——同一进程多次 apply()
|
|
103
|
+
// (测试、插件重载)不会共享状态。
|
|
104
|
+
const stash = new Map();
|
|
105
|
+
// agent/pre-step 钩子不依赖 webServer(发消息不需要网页在场),无条件注册。
|
|
106
|
+
registerContextInjection(ctx, stash);
|
|
107
|
+
// 软依赖 webServer:无 web 服务器的组合(纯 CLI 形态)下本模块静默不装。
|
|
108
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
109
|
+
webCtx.webServer.register({
|
|
110
|
+
kind: 'exact',
|
|
111
|
+
path: '/tabbit/mention/tabs',
|
|
112
|
+
handler: (req, res) => handleRoster(ctx, req, res),
|
|
113
|
+
});
|
|
114
|
+
webCtx.webServer.register({
|
|
115
|
+
kind: 'exact',
|
|
116
|
+
path: '/tabbit/tabs',
|
|
117
|
+
handler: (req, res) => handleAllTabs(ctx, req, res),
|
|
118
|
+
});
|
|
119
|
+
webCtx.webServer.register({
|
|
120
|
+
kind: 'exact',
|
|
121
|
+
path: '/tabbit/mention/extract',
|
|
122
|
+
handler: (req, res) => handleExtract(ctx, stash, req, res),
|
|
123
|
+
});
|
|
124
|
+
webCtx.webServer.register({
|
|
125
|
+
kind: 'exact',
|
|
126
|
+
path: '/tabbit/instance-hint',
|
|
127
|
+
handler: (req, res) => handleInstanceHint(ctx, req, res),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/*
|
|
132
|
+
* 写入一条暂存记录,返回供前端携带的一次性 token。
|
|
133
|
+
* 顺手清扫过期记录(惰性:只在有新写入时扫一遍,不另开定时器)——正常
|
|
134
|
+
* 路径里 token 活不过几百毫秒就被消费掉,这里清的都是异常路径遗留的。
|
|
135
|
+
*/
|
|
136
|
+
function stashExtraction(stash, entry) {
|
|
137
|
+
const cutoff = Date.now() - EXTRACT_STASH_TTL_MS;
|
|
138
|
+
for (const [token, existing] of stash) {
|
|
139
|
+
if (existing.createdAt < cutoff)
|
|
140
|
+
stash.delete(token);
|
|
141
|
+
}
|
|
142
|
+
const token = randomUUID();
|
|
143
|
+
stash.set(token, { ...entry, createdAt: Date.now() });
|
|
144
|
+
return token;
|
|
145
|
+
}
|
|
146
|
+
/* 按 token 取回并【立即删除】——一次性凭据,重放拿不到第二次。 */
|
|
147
|
+
function consumeStash(stash, token) {
|
|
148
|
+
const entry = stash.get(token);
|
|
149
|
+
if (entry === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
stash.delete(token);
|
|
152
|
+
return entry;
|
|
153
|
+
}
|
|
154
|
+
/*
|
|
155
|
+
* agent/pre-step 钩子:见文件头"正文怎么从暂存变成模型看得见的上下文"。
|
|
156
|
+
* 瀑布式事件——先 next() 拿下游已经处理过的决定,'reject' 原样放行;
|
|
157
|
+
* 'enter' 才有 messages 可改,改完原样按同一形状返回。
|
|
158
|
+
*/
|
|
159
|
+
function registerContextInjection(ctx, stash) {
|
|
160
|
+
ctx.on('agent/pre-step', async (_payload, next) => {
|
|
161
|
+
const decision = await next();
|
|
162
|
+
if (decision.kind === 'reject')
|
|
163
|
+
return decision;
|
|
164
|
+
const expanded = await Promise.all(decision.messages.map((message) => expandTabMentions(message, stash)));
|
|
165
|
+
return { ...decision, messages: expanded.flat() };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/*
|
|
169
|
+
* 展开一条消息里的所有 tabbit-tab 标记:
|
|
170
|
+
* - 没有标记的消息(包括非用户消息)原样返回,不多分配;
|
|
171
|
+
* - 有标记:标记替换成干净的 "@标题"(这条消息本身,用户在气泡里看到的
|
|
172
|
+
* 形态,见 chipMention),每个标记额外产出一条 source.kind='plugin' 的
|
|
173
|
+
* 兄弟消息(dsh 渲染成默认折叠的 Context injection 行),紧跟在这条
|
|
174
|
+
* 消息后面——顺序即引用顺序,模型读起来跟用户打字的顺序一致。
|
|
175
|
+
*/
|
|
176
|
+
async function expandTabMentions(message, stash) {
|
|
177
|
+
if (message.source.kind !== 'user')
|
|
178
|
+
return [message];
|
|
179
|
+
const siblings = [];
|
|
180
|
+
let matchedAny = false;
|
|
181
|
+
const content = message.content.map((block) => {
|
|
182
|
+
if (block.type !== 'text')
|
|
183
|
+
return block;
|
|
184
|
+
const text = block.text.replace(TAB_MENTION_MARKER, (_whole, title, token) => {
|
|
185
|
+
matchedAny = true;
|
|
186
|
+
const chip = chipMention(title);
|
|
187
|
+
const entry = consumeStash(stash, token);
|
|
188
|
+
// 取不到(过期/已消费/进程重启丢了内存):只留干净的标题文本,不附
|
|
189
|
+
// 正文——这是异常路径的兜底,不静默伪造内容,也不让整条消息发送失败
|
|
190
|
+
// (token 只是"附件",消息本体已经真实发出去了)。
|
|
191
|
+
if (entry === undefined)
|
|
192
|
+
return chip;
|
|
193
|
+
siblings.push(buildTabContextMessage(entry));
|
|
194
|
+
return chip;
|
|
195
|
+
});
|
|
196
|
+
return { ...block, text };
|
|
197
|
+
});
|
|
198
|
+
if (!matchedAny)
|
|
199
|
+
return [message];
|
|
200
|
+
return [freezeMessage({ ...message, content }), ...siblings];
|
|
201
|
+
}
|
|
202
|
+
/*
|
|
203
|
+
* 把标题包成宿主聊天气泡认得的"一整块"引用文案。
|
|
204
|
+
*
|
|
205
|
+
* 宿主转录装饰器(dsh-client-ui-primitives 的 user-text.tsx)用这条正则
|
|
206
|
+
* 识别"@ 引用":/(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/——裸 @标题 在第一个
|
|
207
|
+
* 空白处就断了(真机验证过的 bug:《Example Domain》只有《Example》被
|
|
208
|
+
* 框进 chip,《 Domain》漏在外面变成普通文字)。带引号的 @"..." 才会被
|
|
209
|
+
* 当成一整块——这也是 dsh 自己的 @文件 提及给带空格路径加引号的同一招。
|
|
210
|
+
* 引号形态的展示逻辑还会把内容按 / 或 \ 分段只取最后一段(抄的是文件路径
|
|
211
|
+
* 展示),所以引号内的斜杠必须先换成形近的全角符号,否则形如
|
|
212
|
+
* "GitHub - foo/bar" 的标题会被裁到只剩 "bar"——这一手只影响这行展示
|
|
213
|
+
* 文案,不影响 <browser-tab> 里模型看到的真实标题。
|
|
214
|
+
* 标题本身干净(无空白/引号/斜杠)时用裸 @标题——更接近人眼里"这是一个
|
|
215
|
+
* 提及"的直觉,没必要为了统一而处处加引号。
|
|
216
|
+
*/
|
|
217
|
+
function chipMention(rawTitle) {
|
|
218
|
+
const title = rawTitle.replace(/[\r\n]+/gu, ' ').trim().slice(0, 80) || 'tab';
|
|
219
|
+
if (!/["\\/\s]/u.test(title))
|
|
220
|
+
return `@${title}`;
|
|
221
|
+
const quoted = title.replace(/"/gu, "'").replace(/\\/gu, '\').replace(/\//gu, '/');
|
|
222
|
+
return `@"${quoted}"`;
|
|
223
|
+
}
|
|
224
|
+
/* 把一条暂存的提取结果包装成独立的上下文消息(<browser-tab> 文本块)。 */
|
|
225
|
+
function buildTabContextMessage(entry) {
|
|
226
|
+
const sourceAttr = entry.refetched === true ? ' source="refetched-copy"' : '';
|
|
227
|
+
const truncatedNote = entry.truncated ? '\n[content truncated]' : '';
|
|
228
|
+
const text = `<browser-tab url="${xmlAttrEscape(entry.url)}" title="${xmlAttrEscape(entry.title)}"${sourceAttr}>\n${entry.text}${truncatedNote}\n</browser-tab>`;
|
|
229
|
+
return createUserMessage({
|
|
230
|
+
source: {
|
|
231
|
+
kind: 'plugin',
|
|
232
|
+
plugin: 'dsh-tabbit',
|
|
233
|
+
form: 'notice',
|
|
234
|
+
summary: boundContextSummary(`Fetched "${entry.title || entry.url}"`),
|
|
235
|
+
},
|
|
236
|
+
content: [{ type: 'text', text }],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/* 文本转义(url/title 嵌进 <browser-tab ...> 标签属性前)。 */
|
|
240
|
+
function xmlAttrEscape(text) {
|
|
241
|
+
return text.replace(/&/gu, '&').replace(/"/gu, '"').replace(/</gu, '<');
|
|
242
|
+
}
|
|
243
|
+
/*
|
|
244
|
+
* 观看实例打点路由。安全设计两条硬规则:
|
|
245
|
+
* 1. 【身份取自 socket,不信请求体】——req.socket.remotePort 是操作系统层
|
|
246
|
+
* 的事实,页面伪造不了;请求体里说什么我们一概不读。
|
|
247
|
+
* 2. 【同源 Origin 校验】——浏览器对跨源 POST 一定带 Origin 头(fetch 规
|
|
248
|
+
* 范如此);不校验的话,用户在【别的】浏览器里开的任意恶意网页都能向
|
|
249
|
+
* http://127.0.0.1:<dsh端口>/tabbit/instance-hint 发 no-cors POST,把
|
|
250
|
+
* 首选执行实例翻转到攻击者期望的实例上。
|
|
251
|
+
* 溯源细节(lsof/ps 父链匹配 browserPid)见 runtime/peer.ts。
|
|
252
|
+
* 溯源失败不报错:返回 detected:null(正常现象——用 Chrome 看 dsh 就必然
|
|
253
|
+
* 溯不到 Tabbit)。
|
|
254
|
+
*/
|
|
255
|
+
async function handleInstanceHint(ctx, req, res) {
|
|
256
|
+
if (!hostAllowed(req))
|
|
257
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
258
|
+
if (req.method !== 'POST')
|
|
259
|
+
return sendJson(res, 405, { error: 'POST only' });
|
|
260
|
+
const origin = req.headers.origin;
|
|
261
|
+
const host = req.headers.host ?? '';
|
|
262
|
+
if (typeof origin !== 'string' || new URL(origin).host !== host) {
|
|
263
|
+
return sendJson(res, 403, { error: 'cross-origin hint rejected' });
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const peerPort = req.socket.remotePort ?? 0;
|
|
267
|
+
const detected = await instanceIdForPeerPort(peerPort, listInstances());
|
|
268
|
+
if (detected !== undefined)
|
|
269
|
+
ctx.tabbit.setViewerInstance(detected);
|
|
270
|
+
sendJson(res, 200, { detected: detected ?? null });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
sendJson(res, 200, { detected: null, note: String(error?.message ?? error) });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/* Host 头校验:只接受本机回环地址的访问(这些路由不该被局域网里其它机器打到)。 */
|
|
277
|
+
function hostAllowed(req) {
|
|
278
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
279
|
+
return host.startsWith('127.0.0.1') || host.startsWith('localhost') || host.startsWith('[::1]');
|
|
280
|
+
}
|
|
281
|
+
/* 统一的 JSON 响应写出(no-store:这些数据都是即时状态,禁止缓存)。 */
|
|
282
|
+
function sendJson(res, status, payload) {
|
|
283
|
+
const body = JSON.stringify(payload);
|
|
284
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
285
|
+
res.end(body);
|
|
286
|
+
}
|
|
287
|
+
/*
|
|
288
|
+
* 圈定"该会话名下【确实还活着】的任务"。三道保守过滤:
|
|
289
|
+
* 1. 只看 ctx.tabbit 登记过的任务(本会话自己创建过的);
|
|
290
|
+
* 2. 浏览器全离线就直接返回空——绝不能因为一个 @ 候选查询把浏览器拉起来;
|
|
291
|
+
* 3. 与服务端 `tasks` 实况求交集——【绝不隐式创建任务】:对不存在的任务
|
|
292
|
+
* 调 evaluate 会创建它并开出一个空白标签页,用户会莫名其妙看到浏览器
|
|
293
|
+
* 里多了个标签组。
|
|
294
|
+
*/
|
|
295
|
+
async function existingSessionTasks(ctx, sessionId) {
|
|
296
|
+
const registered = ctx.tabbit.sessionTasks(sessionId);
|
|
297
|
+
if (registered.length === 0)
|
|
298
|
+
return [];
|
|
299
|
+
if (!ctx.tabbit.instances().some((instance) => instance.online))
|
|
300
|
+
return [];
|
|
301
|
+
const live = await ctx.tabbit.client().listTasks();
|
|
302
|
+
const liveNames = new Set(live.map((task) => task.taskName));
|
|
303
|
+
return registered.filter((task) => liveNames.has(task));
|
|
304
|
+
}
|
|
305
|
+
/*
|
|
306
|
+
* 用户标签页可否作为 @ 候选。两条硬规则:
|
|
307
|
+
* 1. 只收 http/https——提及的兑现方式是"共享 fetch 任务里重取该 URL",而
|
|
308
|
+
* chrome:// chrome-extension:// file:// about: 等 Playwright 一律
|
|
309
|
+
* ERR_BLOCKED_BY_CLIENT 打不开(真机验证):候选必须能兑现内容,列出
|
|
310
|
+
* 注定失败的页面等于给用户埋一个"点发送就报错"的雷。本地文件类内容
|
|
311
|
+
* 走 dsh 自带的文件提及即可覆盖。
|
|
312
|
+
* 2. 滤掉新标签页(pathname 恰为 /newtab 的产品 newtab 页)——空页对提及
|
|
313
|
+
* 没意义。about:blank 被规则 1 顺带覆盖。
|
|
314
|
+
*/
|
|
315
|
+
function mentionableUserTabUrl(url) {
|
|
316
|
+
try {
|
|
317
|
+
const parsed = new URL(url);
|
|
318
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
319
|
+
return false;
|
|
320
|
+
return parsed.pathname !== '/newtab';
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/*
|
|
327
|
+
* 候选清单路由:两段数据合并,各自失败互不拖累(note 记原因)。
|
|
328
|
+
*
|
|
329
|
+
* ① 任务页(kind:'task-page'):对该会话每个活任务跑一段只读求值,列出任务
|
|
330
|
+
* 里所有页面的 {index, url, title}。查询串在下面模板字符串里(用注入全局
|
|
331
|
+
* pages() 遍历,title() 失败单页容错)。
|
|
332
|
+
* ② 用户标签页(kind:'user-tab'):ctx.tabbit.listAllTabs() 直连端点清单,
|
|
333
|
+
* 只收 state=available(busy 的已被某个自动化任务认领——即①里的页面,
|
|
334
|
+
* 收了就重复;owned 在无绑定连接上不会出现),滤掉空白页/新标签页;
|
|
335
|
+
* 排序:当前活跃页最前,其余按窗口、标签条位置。
|
|
336
|
+
*
|
|
337
|
+
* 整体失败也回 200 + 空列表(前端的 @ 菜单绝不应该因为我们报错而炸掉)。
|
|
338
|
+
* 不变量保持:本路由绝不拉起浏览器(①只查已存在任务;②直连端点天然不拉起)。
|
|
339
|
+
*/
|
|
340
|
+
async function handleRoster(ctx, req, res) {
|
|
341
|
+
if (!hostAllowed(req))
|
|
342
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
343
|
+
const sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('session') ?? '';
|
|
344
|
+
if (sessionId === '')
|
|
345
|
+
return sendJson(res, 400, { error: 'missing session' });
|
|
346
|
+
const tabs = [];
|
|
347
|
+
const notes = [];
|
|
348
|
+
try {
|
|
349
|
+
const tasks = await existingSessionTasks(ctx, sessionId);
|
|
350
|
+
for (const task of tasks) {
|
|
351
|
+
const outcome = await ctx.tabbit.client().evaluate({
|
|
352
|
+
task,
|
|
353
|
+
readOnly: true,
|
|
354
|
+
timeoutMs: ROSTER_TIMEOUT_MS,
|
|
355
|
+
// ⚠️ 下面是发往浏览器执行的代码原文,勿在字符串内加注释。
|
|
356
|
+
code: `const out = [];
|
|
357
|
+
const list = pages();
|
|
358
|
+
for (let i = 0; i < list.length; i++) {
|
|
359
|
+
const p = list[i];
|
|
360
|
+
let title = '';
|
|
361
|
+
try { title = await p.title(); } catch {}
|
|
362
|
+
out.push({ index: i, url: p.url(), title });
|
|
363
|
+
}
|
|
364
|
+
return out;`,
|
|
365
|
+
});
|
|
366
|
+
if (outcome.status !== 'succeeded')
|
|
367
|
+
continue;
|
|
368
|
+
const value = outcome.result?.value;
|
|
369
|
+
if (!Array.isArray(value))
|
|
370
|
+
continue;
|
|
371
|
+
// 返回值来自浏览器侧求值,形状逐字段校验后再收录。
|
|
372
|
+
for (const entry of value) {
|
|
373
|
+
if (typeof entry !== 'object' || entry === null)
|
|
374
|
+
continue;
|
|
375
|
+
const { index, url, title } = entry;
|
|
376
|
+
if (typeof url !== 'string' || url === 'about:blank')
|
|
377
|
+
continue;
|
|
378
|
+
tabs.push({
|
|
379
|
+
kind: 'task-page',
|
|
380
|
+
task,
|
|
381
|
+
index: typeof index === 'number' ? index : 0,
|
|
382
|
+
url,
|
|
383
|
+
title: typeof title === 'string' ? title : '',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
notes.push(`task pages unavailable: ${String(error?.message ?? error)}`);
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const inventory = await ctx.tabbit.listAllTabs({ timeoutMs: ROSTER_TIMEOUT_MS });
|
|
393
|
+
const userTabs = inventory.tabs
|
|
394
|
+
.filter((tab) => tab.state === 'available' && mentionableUserTabUrl(tab.url))
|
|
395
|
+
.sort((a, b) => Number(b.active) - Number(a.active) || a.windowId - b.windowId || a.index - b.index)
|
|
396
|
+
.map((tab) => ({
|
|
397
|
+
kind: 'user-tab',
|
|
398
|
+
url: tab.url,
|
|
399
|
+
title: tab.title,
|
|
400
|
+
tabId: tab.tabId,
|
|
401
|
+
windowId: tab.windowId,
|
|
402
|
+
index: tab.index,
|
|
403
|
+
...(tab.active ? { active: true } : {}),
|
|
404
|
+
...(tab.group !== null && tab.group.title !== '' ? { group: tab.group.title } : {}),
|
|
405
|
+
}));
|
|
406
|
+
tabs.push(...userTabs);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
// 典型原因:浏览器离线(直连不拉起)、多实例歧义。@ 菜单退回只有任务页。
|
|
410
|
+
notes.push(`user tabs unavailable: ${String(error?.message ?? error)}`);
|
|
411
|
+
}
|
|
412
|
+
sendJson(res, 200, { tabs, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) });
|
|
413
|
+
}
|
|
414
|
+
/*
|
|
415
|
+
* 全 profile 标签页清单路由。与 roster 的两点不同:
|
|
416
|
+
* 1. 数据来源是直连端点的 unbound tabs 清单(ctx.tabbit.listAllTabs),
|
|
417
|
+
* 覆盖用户自己开的页面,不需要任何会话/任务存在;
|
|
418
|
+
* 2. 面向程序化消费方,失败如实回状态码(离线 503、实例问题 409、其余 500)
|
|
419
|
+
* ——不像 roster 那样为保 @ 菜单永远回 200。
|
|
420
|
+
* ?instance=<16位大写hex> 可显式指定实例;不带时沿用服务的四级实例解析。
|
|
421
|
+
*/
|
|
422
|
+
async function handleAllTabs(ctx, req, res) {
|
|
423
|
+
if (!hostAllowed(req))
|
|
424
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
425
|
+
if (req.method !== 'GET')
|
|
426
|
+
return sendJson(res, 405, { error: 'GET only' });
|
|
427
|
+
const instanceParam = new URL(req.url ?? '/', 'http://localhost').searchParams.get('instance');
|
|
428
|
+
if (instanceParam !== null && !/^[0-9A-F]{16}$/u.test(instanceParam)) {
|
|
429
|
+
return sendJson(res, 400, { error: 'instance must be a 16-hex uppercase id' });
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const inventory = await ctx.tabbit.listAllTabs({
|
|
433
|
+
...(instanceParam !== null ? { instanceId: instanceParam } : {}),
|
|
434
|
+
});
|
|
435
|
+
sendJson(res, 200, inventory);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
const cliError = error instanceof TabbitCliError ? error : undefined;
|
|
439
|
+
const status = cliError?.kind === 'browser-unavailable' ? 503 : cliError?.kind === 'instance-selection' ? 409 : 500;
|
|
440
|
+
sendJson(res, status, {
|
|
441
|
+
error: String(error?.message ?? error),
|
|
442
|
+
...(cliError ? { code: cliError.code } : {}),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/* 读取并解析 JSON 请求体,64KiB 上限(防恶意大包)。for-await 逐块累积。 */
|
|
447
|
+
async function readJsonBody(req) {
|
|
448
|
+
const chunks = [];
|
|
449
|
+
let bytes = 0;
|
|
450
|
+
for await (const chunk of req) {
|
|
451
|
+
bytes += chunk.byteLength;
|
|
452
|
+
if (bytes > 64 * 1024)
|
|
453
|
+
throw new Error('body too large');
|
|
454
|
+
chunks.push(chunk);
|
|
455
|
+
}
|
|
456
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
457
|
+
}
|
|
458
|
+
/*
|
|
459
|
+
* 校验浏览器求值返回的提取结果形状、暂存正文、组装回给前端的响应
|
|
460
|
+
* (token 取代 text——见文件头"暂存"说明)。两个提取分支(任务页/用户
|
|
461
|
+
* 标签页)共用,后者额外传 refetched:true。
|
|
462
|
+
*/
|
|
463
|
+
function stashAndRespond(stash, raw, extra = {}) {
|
|
464
|
+
const url = typeof raw.url === 'string' ? raw.url : '';
|
|
465
|
+
const title = typeof raw.title === 'string' ? raw.title : '';
|
|
466
|
+
const text = typeof raw.text === 'string' ? raw.text : '';
|
|
467
|
+
const truncated = raw.truncated === true;
|
|
468
|
+
const token = stashExtraction(stash, { url, title, text, truncated, ...extra });
|
|
469
|
+
return { url, title, truncated, ...extra, token };
|
|
470
|
+
}
|
|
471
|
+
/*
|
|
472
|
+
* 提取路由:把被提及页面的标题+正文抓出来(chip 序列化时调用)。
|
|
473
|
+
*
|
|
474
|
+
* 流程:校验参数 → 确认任务还活着(404 而不是隐式创建)→ 只读求值提取 →
|
|
475
|
+
* 暂存正文、回一次性 token(见 stashAndRespond)。
|
|
476
|
+
* 提取脚本逻辑(模板字符串内,参数经 JSON.stringify 注入防逃逸):
|
|
477
|
+
* 先按 URL 精确找页(用户选 chip 后页面可能导航走了,URL 是最强身份);
|
|
478
|
+
* 找不到再按当时的 index 兜底;都没有就报"页面已关闭"。
|
|
479
|
+
* 正文取 article/main/[role=main] 或 body 的 innerText,60k 字符截断。
|
|
480
|
+
*
|
|
481
|
+
* 与 roster 的一个区别:这里的失败要如实回 4xx/5xx——前端拿到错误会【阻断
|
|
482
|
+
* 发送】(用户以为引用了页面内容、实际没引上,静默降级是更糟的体验)。
|
|
483
|
+
*/
|
|
484
|
+
async function handleExtract(ctx, stash, req, res) {
|
|
485
|
+
if (!hostAllowed(req))
|
|
486
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
487
|
+
if (req.method !== 'POST')
|
|
488
|
+
return sendJson(res, 405, { error: 'POST only' });
|
|
489
|
+
let body;
|
|
490
|
+
try {
|
|
491
|
+
body = (await readJsonBody(req));
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return sendJson(res, 400, { error: 'invalid JSON body' });
|
|
495
|
+
}
|
|
496
|
+
const task = typeof body.task === 'string' ? body.task : '';
|
|
497
|
+
const url = typeof body.url === 'string' ? body.url : '';
|
|
498
|
+
const index = typeof body.index === 'number' ? body.index : -1;
|
|
499
|
+
if (body.userTab === true) {
|
|
500
|
+
if (url === '')
|
|
501
|
+
return sendJson(res, 400, { error: 'url is required' });
|
|
502
|
+
// 重取只对 http/https 可行(roster 已按此过滤;这里防直接调用方)。
|
|
503
|
+
if (!/^https?:\/\//u.test(url)) {
|
|
504
|
+
return sendJson(res, 400, { error: 'only http(s) tabs can be mentioned; this page type cannot be re-fetched' });
|
|
505
|
+
}
|
|
506
|
+
return extractUserTab(ctx, stash, url, res);
|
|
507
|
+
}
|
|
508
|
+
if (task === '' || url === '')
|
|
509
|
+
return sendJson(res, 400, { error: 'task and url are required' });
|
|
510
|
+
try {
|
|
511
|
+
const live = new Set((await ctx.tabbit.client().listTasks()).map((entry) => entry.taskName));
|
|
512
|
+
if (!live.has(task))
|
|
513
|
+
return sendJson(res, 404, { error: `task "${task}" no longer exists` });
|
|
514
|
+
const outcome = await ctx.tabbit.client().evaluate({
|
|
515
|
+
task,
|
|
516
|
+
readOnly: true,
|
|
517
|
+
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
518
|
+
// ⚠️ 下面是发往浏览器执行的代码原文,勿在字符串内加注释。
|
|
519
|
+
code: `const wantedUrl = ${JSON.stringify(url)};
|
|
520
|
+
const wantedIndex = ${JSON.stringify(index)};
|
|
521
|
+
const list = pages();
|
|
522
|
+
let p = list.find((candidate) => candidate.url() === wantedUrl);
|
|
523
|
+
if (!p && wantedIndex >= 0 && wantedIndex < list.length) p = list[wantedIndex];
|
|
524
|
+
if (!p) throw new Error('mentioned tab is no longer open');
|
|
525
|
+
let title = '';
|
|
526
|
+
try { title = await p.title(); } catch {}
|
|
527
|
+
const data = await p.evaluate(() => {
|
|
528
|
+
const root = document.querySelector('article, main, [role="main"]') || document.body;
|
|
529
|
+
return root ? root.innerText || '' : '';
|
|
530
|
+
});
|
|
531
|
+
let text = data;
|
|
532
|
+
let truncated = false;
|
|
533
|
+
if (text.length > ${EXTRACT_MAX_CHARS}) { text = text.slice(0, ${EXTRACT_MAX_CHARS}); truncated = true; }
|
|
534
|
+
return { url: p.url(), title, text, truncated };`,
|
|
535
|
+
});
|
|
536
|
+
if (outcome.status !== 'succeeded') {
|
|
537
|
+
return sendJson(res, 500, { error: outcome.errorMessage ?? 'extraction failed' });
|
|
538
|
+
}
|
|
539
|
+
sendJson(res, 200, stashAndRespond(stash, (outcome.result?.value ?? {})));
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
sendJson(res, 500, { error: String(error?.message ?? error) });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/*
|
|
546
|
+
* 用户标签页的提取:在共享只读 fetch 任务(FETCH_TASK_NAME,与 web_fetch
|
|
547
|
+
* 同一个)里开新页重取该 URL——共享用户登录态,但【不触碰】用户的原标签页
|
|
548
|
+
* (不 claim、不挪组、不抢焦点)。取完 finally 必关页。
|
|
549
|
+
* 结果带 refetched:true,前端把它标进 <browser-tab> 块,让模型知道内容是
|
|
550
|
+
* 该 URL 的新副本、不是用户页面的实时状态。
|
|
551
|
+
* 失败如实回 5xx——前端拿到错误会阻断发送(同任务页提取的语义)。
|
|
552
|
+
*/
|
|
553
|
+
async function extractUserTab(ctx, stash, url, res) {
|
|
554
|
+
try {
|
|
555
|
+
const client = ctx.tabbit.client();
|
|
556
|
+
const outcome = await client.evaluate({
|
|
557
|
+
task: FETCH_TASK_NAME,
|
|
558
|
+
readOnly: true,
|
|
559
|
+
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
560
|
+
// ⚠️ 下面是发往浏览器执行的代码原文,勿在字符串内加注释。
|
|
561
|
+
code: `const target = ${JSON.stringify(url)};
|
|
562
|
+
const p = await context.newPage();
|
|
563
|
+
try {
|
|
564
|
+
try {
|
|
565
|
+
await p.goto(target, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
566
|
+
} catch (error) {
|
|
567
|
+
throw new Error('navigation failed: ' + String((error && error.message) || error));
|
|
568
|
+
}
|
|
569
|
+
await p.waitForTimeout(400);
|
|
570
|
+
let title = '';
|
|
571
|
+
try { title = await p.title(); } catch {}
|
|
572
|
+
const data = await p.evaluate(() => {
|
|
573
|
+
const root = document.querySelector('article, main, [role="main"]') || document.body;
|
|
574
|
+
return root ? root.innerText || '' : '';
|
|
575
|
+
});
|
|
576
|
+
let text = data;
|
|
577
|
+
let truncated = false;
|
|
578
|
+
if (text.length > ${EXTRACT_MAX_CHARS}) { text = text.slice(0, ${EXTRACT_MAX_CHARS}); truncated = true; }
|
|
579
|
+
return { url: p.url(), title, text, truncated };
|
|
580
|
+
} finally {
|
|
581
|
+
try { await p.close(); } catch {}
|
|
582
|
+
}`,
|
|
583
|
+
});
|
|
584
|
+
// 登记共享任务实际用的实例(插件卸载清理要在对的实例上 finish 它)。
|
|
585
|
+
ctx.tabbit.markFetchTaskUsed(client.resolvedInstanceId());
|
|
586
|
+
if (outcome.status !== 'succeeded') {
|
|
587
|
+
return sendJson(res, 500, { error: outcome.errorMessage ?? 'extraction failed' });
|
|
588
|
+
}
|
|
589
|
+
const value = (outcome.result?.value ?? {});
|
|
590
|
+
sendJson(res, 200, stashAndRespond(stash, value, { refetched: true }));
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
sendJson(res, 500, { error: String(error?.message ?? error) });
|
|
594
|
+
}
|
|
595
|
+
}
|