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
package/client/client.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-tabbit", factory: (require) => {
|
|
2
|
+
var module = { exports: {} }; var exports = module.exports;
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* ============================================================================
|
|
7
|
+
* 文件职责:dsh Web 客户端插件——`@tab` 提及的前端半边 + 观看实例打点
|
|
8
|
+
* ============================================================================
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 本文件跑在【浏览器里的 dsh 网页】上,不是 Node 进程!它是 package.json
|
|
11
|
+
* `dsh.client` 声明的客户端模块,由 dsh 以 /plugins/dsh-tabbit/client.js
|
|
12
|
+
* 静态服务、被网页加载执行。
|
|
13
|
+
*
|
|
14
|
+
* 首尾两行的 window.__ModuleLoader__.load({id, factory}) 是 dsh Web 端的
|
|
15
|
+
* 客户端模块装载协议:手写这个外壳(而不是上 webpack/vite 打包链)是刻意
|
|
16
|
+
* 选择——本文件零依赖、百来行,不值得为它引入一整套构建。factory 里手工
|
|
17
|
+
* 模拟了 CommonJS 的 module/exports,最后 return module.exports 交回给
|
|
18
|
+
* ModuleLoader。(middle 部分随便改,首尾包裹行不要动。)
|
|
19
|
+
*
|
|
20
|
+
* 功能一:页面加载即向 /tabbit/instance-hint 打点(fire-and-forget),
|
|
21
|
+
* 让服务端检测"用户正用哪个 Tabbit 看 dsh"(→ 优先在那个实例执行任务)。
|
|
22
|
+
* 功能二:注册 `@` 输入源 "网页标签"——输入框敲 @ 时列出两类候选(排在
|
|
23
|
+
* dsh 自带的"文件与文件夹/对话"之后):①本会话浏览器任务里打开的页面
|
|
24
|
+
* (kind:'task-page');②用户浏览器里的普通标签页(kind:'user-tab',
|
|
25
|
+
* 服务端直连 Runtime Service 清单)。选中插入 chip;点发送时立即调用
|
|
26
|
+
* /tabbit/mention/extract 现场提取(失败阻断发送,语义不变),但序列化
|
|
27
|
+
* 结果不再是整段正文——服务端把提取到的正文暂存、只回一个短 token,
|
|
28
|
+
* 这里拼出 `@[标题](tabbit-tab:token)` 这样的短标记塞进气泡文本。真正的
|
|
29
|
+
* 正文由服务端的 `agent/pre-step` 钩子(src/mentions/index.ts)在模型
|
|
30
|
+
* 即将看到这条消息前,凭 token 取回并接成一条独立的、默认折叠的上下文
|
|
31
|
+
* 消息——和 dsh 内置 @文件/@对话 的做法一致,聊天气泡里不会直接堆全文。
|
|
32
|
+
* 候选与提取的数据都来自服务端的 /tabbit/mention/* 路由
|
|
33
|
+
* (src/mentions/index.ts),同源 fetch 直连。
|
|
34
|
+
*
|
|
35
|
+
* dsh 客户端插件协议与服务端 Cordis 如出一辙:导出 {name, inject, apply(ctx)};
|
|
36
|
+
* 这里注入的 'inputTriggers' 是 dsh-client-ui-input-trigger 提供的"输入框
|
|
37
|
+
* 触发菜单"服务(package.json dsh.client.inject 里声明了要它一起装);
|
|
38
|
+
* 'uiConversation'/'slots' 是 web 客户端核心恒定提供的服务,不用额外声明
|
|
39
|
+
* ('uiConversation' 是宿主 0.1.2-alpha.1 客户端重构后 'conversationEvents'
|
|
40
|
+
* 的新名字,注册入口相应从 service.register() 移到了 service.events.register())。
|
|
41
|
+
* 共享的触发菜单负责渲染候选列表 UI——@tab 部分只提供数据,不需要 React。
|
|
42
|
+
*
|
|
43
|
+
* 功能三:把服务端 /tabbit-info 命令落下的 tabbit/status 会话事件折成
|
|
44
|
+
* 聊天气泡里的一张"状态卡"(结论常显 + 明细展开)。为什么要绕这一道:
|
|
45
|
+
* dsh web 客户端把命令行节点(command/run+command/done 折出来的那行)
|
|
46
|
+
* 视为控制面内容——空白会话里不渲染、也不会让会话脱离引导页;而插件
|
|
47
|
+
* 自己注册的非 command 节点算"会话内容",能把会话视图激活,结果才能
|
|
48
|
+
* 即时可见、且刷新后照常回放。状态卡用 React 渲染(React 由宿主平台
|
|
49
|
+
* 模块表共享,工厂内直接 require)。
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/* React 来自宿主的平台模块表(seed.ts 共享同一个实例,客户端插件工厂内
|
|
53
|
+
* 直接 require 即可;表里没有会启动即报错——宿主刻意的 fail-loud)。 */
|
|
54
|
+
const { createElement, useState } = require('react');
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* 本输入源的注册名(也是 chip 序列化时的 source 标识)。这个字符串同时是
|
|
58
|
+
* @ 菜单分组标题——宿主用 t(source.name) 做本地化查找,查不到译文(我们
|
|
59
|
+
* 的分组名从不在宿主词典里)就原样显示这个字符串,这是宿主自己的
|
|
60
|
+
* "cordis" 提及源也在用的既有兜底行为,不是 hack。
|
|
61
|
+
*/
|
|
62
|
+
const SOURCE_NAME = '网页标签';
|
|
63
|
+
/*
|
|
64
|
+
* 候选键 → 完整标签页引用(task/url/index/title)的暂存表。
|
|
65
|
+
* 为什么需要它:候选条目(candidate)只有 name/description 两个字符串字段,
|
|
66
|
+
* 塞不下结构化引用;先寄存在这里,用户选中时(onPick)再按键取回。
|
|
67
|
+
*/
|
|
68
|
+
const refByKey = new Map();
|
|
69
|
+
|
|
70
|
+
/* 从 dsh 会话对象里取会话 id(兼容 sessionId/id 两种字段形状)。 */
|
|
71
|
+
function sessionIdOf(session) {
|
|
72
|
+
if (!session) return '';
|
|
73
|
+
return String(session.sessionId || session.id || '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* 候选名直接用页面标题(回退 URL)——菜单把 name 当主文案渲染(dsh 自带的
|
|
78
|
+
* "对话"源就是人话标题),mangle 成 slug 会显得粗糙。name 同时是 refByKey
|
|
79
|
+
* 的键,必须唯一:同标题的多个标签页给后来者追加 " ·2" 序号消歧。
|
|
80
|
+
*/
|
|
81
|
+
function candidateName(tab, seen) {
|
|
82
|
+
const base = String(tab.title || tab.url || 'tab').trim().slice(0, 60) || 'tab';
|
|
83
|
+
const count = (seen.get(base) || 0) + 1;
|
|
84
|
+
seen.set(base, count);
|
|
85
|
+
return count > 1 ? base + ' ·' + count : base;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* @ 菜单一次最多渲染的候选数——菜单本身带滚动条(宿主 MenuView 的
|
|
90
|
+
* viewport 是 overflow-y:auto),这里不是"菜单能放几条"的硬限制,只是
|
|
91
|
+
* 防止真机 170+ 标签页时一次性渲染过多 DOM;对齐宿主"对话"提及源自己用
|
|
92
|
+
* 的候选上限(50)。想找更靠后的标签页,继续敲字过滤即可(query 是对
|
|
93
|
+
* 服务端返回的全量清单做的,不受这个数字影响)。
|
|
94
|
+
*/
|
|
95
|
+
const MAX_CANDIDATES = 50;
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* 给标记文本里的标题转义:serialize() 把它嵌进 `@[标题](tabbit-tab:token)`
|
|
99
|
+
* 这样的短标记里,方括号会打乱服务端 agent/pre-step 钩子的解析——把它们
|
|
100
|
+
* 换成空格,不影响可读性。
|
|
101
|
+
*/
|
|
102
|
+
function sanitizeMarkerTitle(text) {
|
|
103
|
+
return String(text || '').replace(/[[\]()]/gu, ' ').trim() || 'tab';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* 拉取本会话的标签页清单(GET /tabbit/mention/tabs)。任何异常形状都归一为空数组。 */
|
|
107
|
+
async function fetchRoster(sessionId, signal) {
|
|
108
|
+
const response = await fetch('/tabbit/mention/tabs?session=' + encodeURIComponent(sessionId), { signal });
|
|
109
|
+
if (!response.ok) return [];
|
|
110
|
+
const data = await response.json().catch(() => ({}));
|
|
111
|
+
return Array.isArray(data.tabs) ? data.tabs : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* 输入源定义——inputTriggers 服务约定的接口形状:
|
|
116
|
+
* trigger:触发字符(@);order:多个 @ 源并存时的排序;
|
|
117
|
+
* candidates():给出候选;onPick():用户选中一条时怎么插入;
|
|
118
|
+
* codec:chip 的序列化协议(clipboardText 复制文案 + serialize 发送时展开)。
|
|
119
|
+
*/
|
|
120
|
+
const source = {
|
|
121
|
+
trigger: '@',
|
|
122
|
+
name: SOURCE_NAME,
|
|
123
|
+
// 正数:排在宿主"文件与文件夹/对话"(reference 源,order 默认 0)后面。
|
|
124
|
+
order: 10,
|
|
125
|
+
/*
|
|
126
|
+
* 产出候选列表。request.query 是 @ 后面已敲的过滤词,按标题/URL 包含匹配
|
|
127
|
+
* (不区分大小写);最多 MAX_CANDIDATES 条(菜单本身会滚动,这不是硬
|
|
128
|
+
* 上限,见常量注释)。roster 拉取失败静默返回空——@ 菜单永远不能因为
|
|
129
|
+
* 我们炸掉。每条候选把完整引用寄存进 refByKey,键 = 序号+标题slug。
|
|
130
|
+
*/
|
|
131
|
+
async candidates(session, request) {
|
|
132
|
+
const sessionId = sessionIdOf(session);
|
|
133
|
+
if (!sessionId) return [];
|
|
134
|
+
let tabs;
|
|
135
|
+
try {
|
|
136
|
+
tabs = await fetchRoster(sessionId, request && request.signal);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const query = String((request && request.query) || '').toLowerCase();
|
|
141
|
+
const matched = tabs.filter((tab) => {
|
|
142
|
+
if (!query) return true;
|
|
143
|
+
return (
|
|
144
|
+
String(tab.title || '').toLowerCase().includes(query)
|
|
145
|
+
|| String(tab.url || '').toLowerCase().includes(query)
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
const seen = new Map();
|
|
149
|
+
return matched.slice(0, MAX_CANDIDATES).map((tab) => {
|
|
150
|
+
const label = tab.title || tab.url;
|
|
151
|
+
const key = candidateName(tab, seen);
|
|
152
|
+
// 两类候选的引用形状不同:任务页记 task/index(就地提取要用),
|
|
153
|
+
// 用户标签页记 userTab 标志(提取走服务端重取分支)。
|
|
154
|
+
refByKey.set(
|
|
155
|
+
key,
|
|
156
|
+
tab.kind === 'user-tab'
|
|
157
|
+
? { userTab: true, url: tab.url, title: label, tabId: tab.tabId }
|
|
158
|
+
: { task: tab.task, url: tab.url, index: tab.index, title: label },
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
name: key, // 候选主文案,同时是唯一键(onPick 靠它回查 refByKey)
|
|
162
|
+
description: tab.url, // 菜单里的副标题
|
|
163
|
+
// 前缀图标:宿主的候选图标是封闭枚举(只有 file/folder/session
|
|
164
|
+
// 三种,见 dsh-client-ui-input-trigger),没有 favicon/自定义图标的
|
|
165
|
+
// 位置——统一用 file(宿主里渲染成一个"浏览"风格的图标)。
|
|
166
|
+
icon: 'file',
|
|
167
|
+
// 来源徽标:任务页标出"是代理任务里的页",普通标签页只标 Tabbit。
|
|
168
|
+
hint: tab.kind === 'user-tab' ? 'Tabbit' : 'Tabbit task',
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
/*
|
|
173
|
+
* 用户选中候选 → 返回插入指令:往输入框插一个真实 chip。
|
|
174
|
+
* ref:chip 携带的引用数据(JSON 字符串,发送时交给 codec.serialize);
|
|
175
|
+
* label:chip 显示文本;clipboardText:chip 被复制时的纯文本形态。
|
|
176
|
+
*/
|
|
177
|
+
onPick(pick) {
|
|
178
|
+
const ref = refByKey.get(pick.candidate.name);
|
|
179
|
+
if (!ref) return undefined;
|
|
180
|
+
return {
|
|
181
|
+
insert: {
|
|
182
|
+
source: SOURCE_NAME,
|
|
183
|
+
ref: JSON.stringify(ref),
|
|
184
|
+
label: ref.title,
|
|
185
|
+
clipboardText: '@' + ref.title,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
codec: {
|
|
190
|
+
/* 含 chip 的内容被复制成纯文本时,chip 显示为 "@标题"。 */
|
|
191
|
+
clipboardText(ref) {
|
|
192
|
+
try {
|
|
193
|
+
return '@' + (JSON.parse(ref).title || 'tab');
|
|
194
|
+
} catch {
|
|
195
|
+
return '@tab';
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
/*
|
|
199
|
+
* 【发送时】的序列化:dsh 在用户点发送时对每个 chip 调用它,返回值
|
|
200
|
+
* 替换 chip 进入实际发给模型的提示词。
|
|
201
|
+
* 这里调 POST /tabbit/mention/extract 现场提取页面标题+正文——提取失败
|
|
202
|
+
* 就【抛异常阻断发送】(输入框保留草稿和 chip),语义与之前完全一样:
|
|
203
|
+
* 用户以为引用了页面内容、实际上没引上的"静默降级",比一次显式失败
|
|
204
|
+
* 糟糕得多。
|
|
205
|
+
*
|
|
206
|
+
* 变化的是【提取成功之后】:正文不再直接拼进这里的返回值(那会让整段
|
|
207
|
+
* 网页正文直接堆进用户能看到的聊天气泡)。服务端把提取到的正文暂存,
|
|
208
|
+
* 只把一个一次性 token 回给我们;这里只拼一个短标记
|
|
209
|
+
* @[标题](tabbit-tab:token)
|
|
210
|
+
* 塞进发出去的文本。真正的正文由服务端的 agent/pre-step 钩子
|
|
211
|
+
* (src/mentions/index.ts)在这条消息即将进入模型这一步时,凭 token
|
|
212
|
+
* 取回暂存内容、接成一条独立的、默认折叠的"Context injection"消息
|
|
213
|
+
* ——聊天气泡里最终只看得到 "@标题",和 dsh 内置 @文件/@对话 的观感
|
|
214
|
+
* 一致,模型仍然完整拿到正文。
|
|
215
|
+
*/
|
|
216
|
+
async serialize(ref, signal) {
|
|
217
|
+
const parsed = JSON.parse(ref);
|
|
218
|
+
// 两类 chip 的提取请求体不同(服务端按 userTab 分流):
|
|
219
|
+
// 任务页 → {task,url,index} 就地读取;用户标签页 → {url,userTab:true}
|
|
220
|
+
// 服务端在共享 fetch 任务里重取该 URL(不触碰用户原标签页)。
|
|
221
|
+
const body = parsed.userTab === true
|
|
222
|
+
? { url: parsed.url, userTab: true }
|
|
223
|
+
: { task: parsed.task, url: parsed.url, index: parsed.index };
|
|
224
|
+
const response = await fetch('/tabbit/mention/extract', {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'content-type': 'application/json' },
|
|
227
|
+
body: JSON.stringify(body),
|
|
228
|
+
signal,
|
|
229
|
+
});
|
|
230
|
+
const data = await response.json().catch(() => ({}));
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
throw new Error('tab mention failed: ' + (data.error || response.status));
|
|
233
|
+
}
|
|
234
|
+
const title = sanitizeMarkerTitle(data.title || parsed.title);
|
|
235
|
+
return '@[' + title + '](tabbit-tab:' + data.token + ')';
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/*
|
|
241
|
+
* ──────────────────────────────────────────────────────────────────────
|
|
242
|
+
* 功能三:/tabbit-info 状态卡(tabbit/status 事件 → 聊天节点)
|
|
243
|
+
* ──────────────────────────────────────────────────────────────────────
|
|
244
|
+
*/
|
|
245
|
+
|
|
246
|
+
/*
|
|
247
|
+
* 状态卡样式:对齐宿主聊天行里 GenericCommandCard 的观感——细边框圆角
|
|
248
|
+
* 卡片、结论一行常显、明细等宽字体展开可滚动。纯内联样式:本文件手写
|
|
249
|
+
* 无构建链,宿主的 CSS Modules 也不跨包共享(变量部分用宿主主题变量
|
|
250
|
+
* 兑底,老宿主兑回硬编码色)。
|
|
251
|
+
*/
|
|
252
|
+
const STATUS_CARD_STYLE = {
|
|
253
|
+
border: '1px solid var(--dsh-border, #d0d7de)',
|
|
254
|
+
borderRadius: '8px',
|
|
255
|
+
padding: '8px 12px',
|
|
256
|
+
margin: '2px 0',
|
|
257
|
+
fontSize: '13px',
|
|
258
|
+
lineHeight: 1.5,
|
|
259
|
+
};
|
|
260
|
+
const STATUS_WARN_BORDER = '#d4a72c';
|
|
261
|
+
const STATUS_REPORT_STYLE = {
|
|
262
|
+
margin: '6px 0 0',
|
|
263
|
+
padding: '8px 10px',
|
|
264
|
+
background: 'var(--dsh-subtle, rgba(129, 139, 152, 0.08))',
|
|
265
|
+
borderRadius: '6px',
|
|
266
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
267
|
+
fontSize: '12px',
|
|
268
|
+
whiteSpace: 'pre-wrap',
|
|
269
|
+
wordBreak: 'break-word',
|
|
270
|
+
maxHeight: '320px',
|
|
271
|
+
overflowY: 'auto',
|
|
272
|
+
};
|
|
273
|
+
const STATUS_TOGGLE_STYLE = {
|
|
274
|
+
background: 'none',
|
|
275
|
+
border: 'none',
|
|
276
|
+
padding: 0,
|
|
277
|
+
marginTop: '4px',
|
|
278
|
+
fontSize: '12px',
|
|
279
|
+
color: 'var(--dsh-accent, #0969da)',
|
|
280
|
+
cursor: 'pointer',
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/*
|
|
284
|
+
* /tabbit-info 的结果卡片:结论行常显(服务端已按用户语言生成,✅/⚠️
|
|
285
|
+
* 前缀自带),明细(英文技术格式,可直接贴 issue)默认收起、点开才
|
|
286
|
+
* 渲染。结论以 ⚠ 开头时整卡描边转警示色,扫一眼就能分清"要动手"和
|
|
287
|
+
* "一切正常"。展开按钮文案跟随结论语言(结论里有 CJK 判为中文)。
|
|
288
|
+
*/
|
|
289
|
+
function TabbitStatusNodeView({ node }) {
|
|
290
|
+
const [expanded, setExpanded] = useState(false);
|
|
291
|
+
const data = node.data || {};
|
|
292
|
+
const conclusion = String(data.conclusion || '');
|
|
293
|
+
const report = String(data.report || '');
|
|
294
|
+
const warn = conclusion.charAt(0) === '\u26a0';
|
|
295
|
+
const zh = /[\u4e00-\u9fff]/.test(conclusion);
|
|
296
|
+
return createElement(
|
|
297
|
+
'div',
|
|
298
|
+
{ style: warn ? { ...STATUS_CARD_STYLE, borderColor: STATUS_WARN_BORDER } : STATUS_CARD_STYLE },
|
|
299
|
+
createElement('div', null, conclusion),
|
|
300
|
+
report !== '' && createElement(
|
|
301
|
+
'button',
|
|
302
|
+
{
|
|
303
|
+
type: 'button',
|
|
304
|
+
style: STATUS_TOGGLE_STYLE,
|
|
305
|
+
onClick: () => { setExpanded(!expanded); },
|
|
306
|
+
},
|
|
307
|
+
(zh ? '明细' : 'details') + (expanded ? ' \u25b4' : ' \u25be'),
|
|
308
|
+
),
|
|
309
|
+
expanded && report !== '' && createElement('pre', { style: STATUS_REPORT_STYLE }, report),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/*
|
|
314
|
+
* tabbit/status 事件 → 聊天节点的 Definition(uiConversation 服务的契约
|
|
315
|
+
* 形状,同 dsh 文档 adding-a-conversation-node 的单事件业务写法)。
|
|
316
|
+
* 每次执行落一条整值事件,以 event.seq 为节点身份各自独立成行——重复
|
|
317
|
+
* 执行 /tabbit-info 天然多行并存、不互相覆盖;anchorSeq 用事件自身的
|
|
318
|
+
* seq,位置正好落在命令行节点(command/run)之后。start/update 都是纯
|
|
319
|
+
* 函数、state 是纯 JSON,满足宿主的回放/分页契约。
|
|
320
|
+
*/
|
|
321
|
+
const tabbitStatusNode = {
|
|
322
|
+
kind: 'tabbit-status',
|
|
323
|
+
target: 'chat',
|
|
324
|
+
match(event) {
|
|
325
|
+
return event.type === 'tabbit/status' ? { id: String(event.seq), role: 'start' } : null;
|
|
326
|
+
},
|
|
327
|
+
start(_context, match) {
|
|
328
|
+
return match.event.data;
|
|
329
|
+
},
|
|
330
|
+
update(context) {
|
|
331
|
+
return context.state;
|
|
332
|
+
},
|
|
333
|
+
buildViewNode(context) {
|
|
334
|
+
return {
|
|
335
|
+
key: context.key,
|
|
336
|
+
kind: 'tabbit-status',
|
|
337
|
+
id: context.id,
|
|
338
|
+
target: 'chat',
|
|
339
|
+
anchorSeq: context.start.event.seq,
|
|
340
|
+
location: context.start.location,
|
|
341
|
+
visibility: 'visible',
|
|
342
|
+
data: context.state,
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
name: 'dsh-tabbit-client',
|
|
349
|
+
inject: ['inputTriggers', 'uiConversation', 'slots'],
|
|
350
|
+
apply(ctx) {
|
|
351
|
+
// 观看实例打点:告诉服务端"是哪个浏览器在看这个页面"。fire-and-forget
|
|
352
|
+
// (不 await、双层吞错)——纯属锦上添花的提示,任何失败都不能影响页面。
|
|
353
|
+
// 服务端由这条连接的 socket 对端溯源出 Tabbit 实例(详见 src/runtime/peer.ts),
|
|
354
|
+
// 之后浏览器任务会优先在那个实例上执行。
|
|
355
|
+
try {
|
|
356
|
+
fetch('/tabbit/instance-hint', { method: 'POST' }).catch(() => {});
|
|
357
|
+
} catch {}
|
|
358
|
+
|
|
359
|
+
// 功能二:"网页标签" 输入源。
|
|
360
|
+
const inputTriggers = ctx.get('inputTriggers');
|
|
361
|
+
// 防御:服务缺席或形状不对(宿主版本过旧)就静默不注册——不 return,
|
|
362
|
+
// 后面的状态卡注册与此功能各自独立。
|
|
363
|
+
if (inputTriggers && typeof inputTriggers.registerSource === 'function') {
|
|
364
|
+
const register = () => inputTriggers.registerSource(source);
|
|
365
|
+
// 优先经 ctx.effect 注册(宿主支持时可在插件卸载时正确回收);
|
|
366
|
+
// 老宿主没有 effect 就直接注册。
|
|
367
|
+
if (typeof ctx.effect === 'function') {
|
|
368
|
+
ctx.effect(register, 'dsh-tabbit: @tab source');
|
|
369
|
+
} else {
|
|
370
|
+
register();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 功能三:/tabbit-info 状态卡。uiConversation/slots 已在 inject 里
|
|
375
|
+
// 声明(apply 只会在它们就绪后运行),这里仍按本文件惯例做形状防御。
|
|
376
|
+
// 两者内部都是 ctx.effect 式注册(随插件 fiber 卸载自动回收),直接调。
|
|
377
|
+
const uiConversation = ctx.get('uiConversation');
|
|
378
|
+
const slots = ctx.get('slots');
|
|
379
|
+
if (uiConversation && typeof uiConversation.events?.register === 'function'
|
|
380
|
+
&& slots && typeof slots.inject === 'function') {
|
|
381
|
+
uiConversation.events.register(tabbitStatusNode);
|
|
382
|
+
slots.inject('conversation.chat.node', () => slots.register(
|
|
383
|
+
{ name: 'conversation.chat.node', key: 'tabbit-status' },
|
|
384
|
+
TabbitStatusNodeView,
|
|
385
|
+
));
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
return module.exports; } });
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,78 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# dsh-tabbit 的 bundle 补丁层(组合层)
|
|
3
|
+
# ============================================================================
|
|
4
|
+
#
|
|
5
|
+
# 这份文件是 dsh「bundle 机制」的核心:package.json 里 `dsh.bundle.patch`
|
|
6
|
+
# 指向它;dsh 装载 profile 时把它叠加进插件组合表(composition),然后按
|
|
7
|
+
# 每一行的 name(模块说明符)去 import 对应模块并作为 Cordis 插件挂载。
|
|
8
|
+
#
|
|
9
|
+
# 补丁语法(dsh 的约定)只有两种操作:
|
|
10
|
+
# - 顶层 `- insert:` 向组合表【追加】若干行(每行 id + name + 可选 config);
|
|
11
|
+
# - 顶层 `- id: xxx` 按 id 找到已存在的行,【整体替换】它的 config
|
|
12
|
+
# ——注意是全量替换、不做深合并(deep merge),所以
|
|
13
|
+
# 原 config 里想保留的键必须在这里重新写一遍。
|
|
14
|
+
#
|
|
15
|
+
# 本补丁做三件事:
|
|
16
|
+
# 1. 追加 Tabbit 集成的 6 个插件行(对应 src/ 下 6 个模块);
|
|
17
|
+
# 2. 打开 dsh 内置 web_fetch 工具的开关(headless 等无 preset 的形态);
|
|
18
|
+
# 3. 把 web 服务钉死的 fetchProvider 从基座内置的直连 http 抓取器改钉为
|
|
19
|
+
# 本包的浏览器代理 provider(见文末 web 行的注释——不改这行,我们的
|
|
20
|
+
# provider 注册了也永远不会被选中)。
|
|
5
21
|
- insert:
|
|
6
|
-
|
|
22
|
+
# ⚠️ 这一行的 name 必须是【裸包名】dsh-tabbit,不能写 dsh-tabbit/core:
|
|
23
|
+
# dsh-web 的"客户端模块扫描"只解析裸包名行——靠这行找到本包的
|
|
24
|
+
# package.json、读到 `dsh.client` 字段,才会把 client/client.js(@tab
|
|
25
|
+
# 提及前端)服务给网页。写成子路径行,服务端功能不受影响,但前端插件
|
|
26
|
+
# 会静默消失。
|
|
27
|
+
- id: tabbit-browser
|
|
7
28
|
name: dsh-tabbit
|
|
29
|
+
# 其余 5 行用包的子路径导出(见 package.json 的 exports 表),
|
|
30
|
+
# 行名 → lib/ 下对应目录的编译产物。
|
|
31
|
+
- id: tabbit-permissions
|
|
32
|
+
name: dsh-tabbit/permissions
|
|
33
|
+
- id: tabbit-tool-browser
|
|
34
|
+
name: dsh-tabbit/tool-browser
|
|
35
|
+
- id: tabbit-web-fetch
|
|
36
|
+
name: dsh-tabbit/web-fetch
|
|
37
|
+
- id: tabbit-mentions
|
|
38
|
+
name: dsh-tabbit/mentions
|
|
39
|
+
- id: tabbit-installer
|
|
40
|
+
name: dsh-tabbit/installer
|
|
41
|
+
|
|
42
|
+
# 全量替换基座里 tool-web 行的 config(补丁不做深合并!):
|
|
43
|
+
# 除了把 fetch 翻成 true,还必须把 @deepseek-ai/dsh-base 原 config 里的
|
|
44
|
+
# searchTimeoutMs 原样重写一遍——不写的话它会随全量替换而丢失、退回内建默认。
|
|
45
|
+
#
|
|
46
|
+
# 另注:这条替换只对 headless 等"无 web-app"的 profile 生效;web 形态下
|
|
47
|
+
# 工具组合由 agent preset 每会话挂载、补丁层够不到 preset 文件——好在
|
|
48
|
+
# dsh 0.1.2-alpha.1 起标准 preset 已自带 fetch:true,web 形态无需本包动作
|
|
49
|
+
# (历史上为此带过的「Tabbit 模式」preset 已移除,core 会做迁移清理)。
|
|
50
|
+
- id: tool-web
|
|
51
|
+
config:
|
|
52
|
+
fetch: true
|
|
53
|
+
searchTimeoutMs: 60000
|
|
54
|
+
|
|
55
|
+
# 接管 web_fetch 的 provider 选择(全量替换基座 web 服务行的 config)。
|
|
56
|
+
#
|
|
57
|
+
# 背景:dsh 0.1.2-alpha.1 起座位不再空缺——基座注册了内置 provider
|
|
58
|
+
# `@deepseek-ai/dsh-web-fetch-http`(id `http`,匿名直连抓取、available()
|
|
59
|
+
# 恒 true),并在本行 config 里钉死 `fetchProvider: http`。dsh 的选择语义
|
|
60
|
+
# 没有优先级链:钉了 id 就只用它,且 config 优先于环境变量
|
|
61
|
+
# DSH_WEB_FETCH_PROVIDER——所以必须替换本行,才能让我们在 src/web-fetch
|
|
62
|
+
# 注册的 `tabbit-browser` provider 被选中。只禁用 web-fetch-http 行是不够
|
|
63
|
+
# 的:钉子还在,会指向未注册的 id(WEB_PROVIDER_CONFIGURED_MISSING)。
|
|
64
|
+
#
|
|
65
|
+
# 为什么值得接管:内置直连抓取器无 JS 渲染、无登录态、不走系统代理(它把
|
|
66
|
+
# 域名解析成 IP、要求全部公网单播后钉 IP 直连——在 fake-ip 模式代理的机器
|
|
67
|
+
# 上一切域名都解析进 198.18.0.0/15 保留段,一律报 WEB_BLOCKED_URL)。浏览
|
|
68
|
+
# 器代理没有这些短板,且配有 pageAccess/intranetFetch 双权限闸。
|
|
69
|
+
#
|
|
70
|
+
# 权衡(有意为之):钉死后 Tabbit 浏览器不可用时,web_fetch 调用报
|
|
71
|
+
# WEB_PROVIDER_CONFIGURED_UNAVAILABLE、不回落 http——工具仍可见,调用时才
|
|
72
|
+
# 报错(tool-web 的既定语义)。
|
|
73
|
+
#
|
|
74
|
+
# ⚠️ searchProvider 是基座原值,必须原样重写(全量替换不深合并,漏写即丢)。
|
|
75
|
+
- id: web
|
|
76
|
+
config:
|
|
77
|
+
searchProvider: deepseek-official
|
|
78
|
+
fetchProvider: tabbit-browser
|