remnote-bridge 0.1.7 → 0.1.9
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/README.md +30 -6
- package/dist/cli/commands/connect.js +31 -2
- package/dist/cli/commands/health.js +111 -1
- package/dist/cli/commands/setup.js +112 -0
- package/dist/cli/daemon/daemon.js +101 -20
- package/dist/cli/daemon/headless-browser.js +291 -0
- package/dist/cli/daemon/static-server.js +84 -0
- package/dist/cli/main.js +22 -6
- package/dist/cli/server/ws-server.js +62 -1
- package/dist/mcp/daemon-client.js +4 -1
- package/dist/mcp/instructions.js +54 -5
- package/dist/mcp/tools/infra-tools.js +39 -9
- package/package.json +5 -1
- package/remnote-plugin/dist/bridge-icon.svg +8 -0
- package/remnote-plugin/dist/bridge_widget-sandbox.js +65 -0
- package/remnote-plugin/dist/bridge_widget.js +65 -0
- package/remnote-plugin/dist/index-sandbox.css +591 -0
- package/remnote-plugin/dist/index-sandbox.js +64 -0
- package/remnote-plugin/dist/index.css +591 -0
- package/remnote-plugin/dist/index.html +9 -0
- package/remnote-plugin/dist/index.js +64 -0
- package/remnote-plugin/dist/manifest.json +22 -0
- package/skills/remnote-bridge/SKILL.md +50 -4
- package/skills/remnote-bridge/instructions/connect.md +40 -10
- package/skills/remnote-bridge/instructions/disconnect.md +1 -1
- package/skills/remnote-bridge/instructions/health.md +67 -1
- package/skills/remnote-bridge/instructions/overall.md +1 -1
- package/skills/remnote-bridge/instructions/setup.md +130 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Chrome 浏览器管理器
|
|
3
|
+
*
|
|
4
|
+
* 在 daemon 中启动 headless Chrome 加载 RemNote Plugin 页面,
|
|
5
|
+
* 实现无 GUI 环境下的全自动连接。
|
|
6
|
+
*
|
|
7
|
+
* 属于进程管理层(daemon/),与 static-server.ts、dev-server.ts 平级。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
// ── 工具函数(setup 命令复用) ──
|
|
13
|
+
/**
|
|
14
|
+
* 按平台自动检测 Chrome/Chromium 路径。
|
|
15
|
+
* 返回 null 表示未找到。
|
|
16
|
+
*/
|
|
17
|
+
export function findChromePath() {
|
|
18
|
+
const platform = os.platform();
|
|
19
|
+
const candidates = [];
|
|
20
|
+
if (platform === 'darwin') {
|
|
21
|
+
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary');
|
|
22
|
+
}
|
|
23
|
+
else if (platform === 'win32') {
|
|
24
|
+
const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files';
|
|
25
|
+
const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
|
|
26
|
+
const localAppData = process.env['LOCALAPPDATA'] || '';
|
|
27
|
+
candidates.push(path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
// Linux
|
|
31
|
+
candidates.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium');
|
|
32
|
+
}
|
|
33
|
+
for (const p of candidates) {
|
|
34
|
+
if (fs.existsSync(p))
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 检测是否有桌面环境(GUI)。
|
|
41
|
+
* macOS/Windows 始终返回 true,Linux 检查 DISPLAY/WAYLAND_DISPLAY。
|
|
42
|
+
*/
|
|
43
|
+
export function hasDisplay() {
|
|
44
|
+
const platform = os.platform();
|
|
45
|
+
if (platform === 'darwin' || platform === 'win32')
|
|
46
|
+
return true;
|
|
47
|
+
return !!(process.env['DISPLAY'] || process.env['WAYLAND_DISPLAY']);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 返回 headless Chrome 的默认 profile 目录。
|
|
51
|
+
*/
|
|
52
|
+
export function getDefaultProfileDir() {
|
|
53
|
+
return path.join(os.homedir(), '.remnote-bridge', 'chrome-profile');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 返回 setup 完成标记文件路径。
|
|
57
|
+
*/
|
|
58
|
+
export function getSetupDonePath() {
|
|
59
|
+
return path.join(getDefaultProfileDir(), '.setup-done');
|
|
60
|
+
}
|
|
61
|
+
const MAX_AUTO_RELOAD = 5;
|
|
62
|
+
const AUTO_RELOAD_DELAY_MS = 10_000;
|
|
63
|
+
const CONSOLE_ERROR_BUFFER_SIZE = 20;
|
|
64
|
+
export class HeadlessBrowserManager {
|
|
65
|
+
browser = null;
|
|
66
|
+
page = null;
|
|
67
|
+
status = 'stopped';
|
|
68
|
+
reloadCount = 0;
|
|
69
|
+
lastError = null;
|
|
70
|
+
consoleErrors = [];
|
|
71
|
+
options;
|
|
72
|
+
autoReloadTimer = null;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
log(message, level = 'info') {
|
|
77
|
+
this.options.onLog?.(message, level);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 启动 headless Chrome,打开 RemNote Plugin 页面。
|
|
81
|
+
* 不等待 Plugin WS 连接建立——Plugin 自行连接,health 检测状态。
|
|
82
|
+
*/
|
|
83
|
+
async start() {
|
|
84
|
+
this.status = 'starting';
|
|
85
|
+
this.log('[headless] 正在启动 Chrome...');
|
|
86
|
+
const chromePath = this.options.chromePath ?? findChromePath();
|
|
87
|
+
if (!chromePath) {
|
|
88
|
+
this.status = 'crashed';
|
|
89
|
+
this.lastError = '未找到 Chrome/Chromium,请通过 --chrome-path 指定路径';
|
|
90
|
+
throw new Error(this.lastError);
|
|
91
|
+
}
|
|
92
|
+
const userDataDir = this.options.userDataDir ?? getDefaultProfileDir();
|
|
93
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
94
|
+
try {
|
|
95
|
+
// 动态 import puppeteer-core(避免未安装时报错)
|
|
96
|
+
const puppeteer = await import('puppeteer-core');
|
|
97
|
+
const launchOptions = {
|
|
98
|
+
executablePath: chromePath,
|
|
99
|
+
headless: 'shell',
|
|
100
|
+
userDataDir,
|
|
101
|
+
args: [
|
|
102
|
+
'--no-first-run',
|
|
103
|
+
'--no-default-browser-check',
|
|
104
|
+
'--disable-gpu',
|
|
105
|
+
'--disable-dev-shm-usage',
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
if (this.options.remoteDebuggingPort) {
|
|
109
|
+
launchOptions.args.push(`--remote-debugging-port=${this.options.remoteDebuggingPort}`);
|
|
110
|
+
}
|
|
111
|
+
this.browser = await puppeteer.default.launch(launchOptions);
|
|
112
|
+
this.page = await this.browser.newPage();
|
|
113
|
+
// console 监控:仅收集 error 级别
|
|
114
|
+
this.page.on('console', (msg) => {
|
|
115
|
+
if (msg.type() === 'error') {
|
|
116
|
+
const text = `[${new Date().toISOString()}] ${msg.text()}`;
|
|
117
|
+
this.consoleErrors.push(text);
|
|
118
|
+
if (this.consoleErrors.length > CONSOLE_ERROR_BUFFER_SIZE) {
|
|
119
|
+
this.consoleErrors.shift();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// 页面崩溃/关闭 → 自动恢复
|
|
124
|
+
this.page.on('close', () => {
|
|
125
|
+
if (this.status === 'running') {
|
|
126
|
+
this.log('[headless] 页面意外关闭,将尝试自动恢复', 'warn');
|
|
127
|
+
this.scheduleAutoReload();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
this.browser.on('disconnected', () => {
|
|
131
|
+
if (this.status === 'running' || this.status === 'reloading') {
|
|
132
|
+
this.log('[headless] Chrome 进程断开', 'warn');
|
|
133
|
+
this.status = 'crashed';
|
|
134
|
+
this.lastError = 'Chrome 进程断开';
|
|
135
|
+
this.browser = null;
|
|
136
|
+
this.page = null;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
// 导航到 RemNote Plugin 页面
|
|
140
|
+
await this.page.goto(this.options.remNoteUrl, {
|
|
141
|
+
waitUntil: 'domcontentloaded',
|
|
142
|
+
timeout: 60_000,
|
|
143
|
+
});
|
|
144
|
+
this.status = 'running';
|
|
145
|
+
this.log(`[headless] Chrome 已启动,页面: ${this.options.remNoteUrl}`);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
this.status = 'crashed';
|
|
149
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
150
|
+
this.log(`[headless] 启动失败: ${this.lastError}`, 'error');
|
|
151
|
+
// 尝试清理
|
|
152
|
+
try {
|
|
153
|
+
await this.browser?.close();
|
|
154
|
+
}
|
|
155
|
+
catch { /* ignore */ }
|
|
156
|
+
this.browser = null;
|
|
157
|
+
this.page = null;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 关闭 Chrome 和页面。
|
|
163
|
+
*/
|
|
164
|
+
async stop() {
|
|
165
|
+
if (this.autoReloadTimer) {
|
|
166
|
+
clearTimeout(this.autoReloadTimer);
|
|
167
|
+
this.autoReloadTimer = null;
|
|
168
|
+
}
|
|
169
|
+
this.status = 'stopped';
|
|
170
|
+
this.log('[headless] 正在关闭 Chrome...');
|
|
171
|
+
try {
|
|
172
|
+
if (this.page) {
|
|
173
|
+
await this.page.close().catch(() => { });
|
|
174
|
+
this.page = null;
|
|
175
|
+
}
|
|
176
|
+
if (this.browser) {
|
|
177
|
+
await this.browser.close().catch(() => { });
|
|
178
|
+
this.browser = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
this.log(`[headless] 关闭时出错: ${err}`, 'warn');
|
|
183
|
+
}
|
|
184
|
+
this.log('[headless] Chrome 已关闭');
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 获取诊断信息。
|
|
188
|
+
*/
|
|
189
|
+
getDiagnostics() {
|
|
190
|
+
return {
|
|
191
|
+
status: this.status,
|
|
192
|
+
chromeConnected: this.browser !== null && this.browser.connected,
|
|
193
|
+
pageUrl: this.page?.url() ?? null,
|
|
194
|
+
reloadCount: this.reloadCount,
|
|
195
|
+
lastError: this.lastError,
|
|
196
|
+
recentConsoleErrors: [...this.consoleErrors],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 截图当前页面,返回截图文件路径。
|
|
201
|
+
*/
|
|
202
|
+
async takeScreenshot() {
|
|
203
|
+
if (!this.page)
|
|
204
|
+
return null;
|
|
205
|
+
const dir = path.join(os.homedir(), '.remnote-bridge');
|
|
206
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
207
|
+
const filePath = path.join(dir, `headless-screenshot-${Date.now()}.png`);
|
|
208
|
+
try {
|
|
209
|
+
await this.page.screenshot({ path: filePath, fullPage: true });
|
|
210
|
+
this.log(`[headless] 截图已保存: ${filePath}`);
|
|
211
|
+
return filePath;
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
this.log(`[headless] 截图失败: ${err}`, 'warn');
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 手动重载页面(重置 reloadCount)。
|
|
220
|
+
*/
|
|
221
|
+
async manualReload() {
|
|
222
|
+
this.log('[headless] 手动重载...');
|
|
223
|
+
this.reloadCount = 0;
|
|
224
|
+
await this.doReload();
|
|
225
|
+
}
|
|
226
|
+
scheduleAutoReload() {
|
|
227
|
+
if (this.reloadCount >= MAX_AUTO_RELOAD) {
|
|
228
|
+
this.status = 'crashed';
|
|
229
|
+
this.lastError = `自动恢复次数已达上限 (${MAX_AUTO_RELOAD})`;
|
|
230
|
+
this.log(`[headless] ${this.lastError}`, 'error');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.autoReloadTimer = setTimeout(async () => {
|
|
234
|
+
this.autoReloadTimer = null;
|
|
235
|
+
try {
|
|
236
|
+
await this.doReload();
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
this.log(`[headless] 自动恢复失败: ${err}`, 'error');
|
|
240
|
+
}
|
|
241
|
+
}, AUTO_RELOAD_DELAY_MS);
|
|
242
|
+
}
|
|
243
|
+
async doReload() {
|
|
244
|
+
this.status = 'reloading';
|
|
245
|
+
this.reloadCount++;
|
|
246
|
+
try {
|
|
247
|
+
// 如果浏览器已断开,需要重新启动
|
|
248
|
+
if (!this.browser || !this.browser.connected) {
|
|
249
|
+
this.log('[headless] 浏览器已断开,重新启动...', 'warn');
|
|
250
|
+
this.browser = null;
|
|
251
|
+
this.page = null;
|
|
252
|
+
await this.start();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// 关闭旧页面,创建新页面
|
|
256
|
+
if (this.page) {
|
|
257
|
+
await this.page.close().catch(() => { });
|
|
258
|
+
}
|
|
259
|
+
this.page = await this.browser.newPage();
|
|
260
|
+
// 重新注册 console 监控
|
|
261
|
+
this.page.on('console', (msg) => {
|
|
262
|
+
if (msg.type() === 'error') {
|
|
263
|
+
const text = `[${new Date().toISOString()}] ${msg.text()}`;
|
|
264
|
+
this.consoleErrors.push(text);
|
|
265
|
+
if (this.consoleErrors.length > CONSOLE_ERROR_BUFFER_SIZE) {
|
|
266
|
+
this.consoleErrors.shift();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
this.page.on('close', () => {
|
|
271
|
+
if (this.status === 'running') {
|
|
272
|
+
this.log('[headless] 页面意外关闭,将尝试自动恢复', 'warn');
|
|
273
|
+
this.scheduleAutoReload();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
await this.page.goto(this.options.remNoteUrl, {
|
|
277
|
+
waitUntil: 'domcontentloaded',
|
|
278
|
+
timeout: 60_000,
|
|
279
|
+
});
|
|
280
|
+
this.status = 'running';
|
|
281
|
+
this.lastError = null;
|
|
282
|
+
this.log(`[headless] 重载完成 (第 ${this.reloadCount} 次)`);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
this.status = 'crashed';
|
|
286
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
287
|
+
this.log(`[headless] 重载失败: ${this.lastError}`, 'error');
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量静态文件服务器
|
|
3
|
+
*
|
|
4
|
+
* 用 Node.js 内置 http 模块 serve remnote-plugin/dist/ 目录。
|
|
5
|
+
* 替代 webpack-dev-server,用于非开发模式(预构建 plugin)。
|
|
6
|
+
*/
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
const MIME_TYPES = {
|
|
11
|
+
'.html': 'text/html',
|
|
12
|
+
'.js': 'application/javascript',
|
|
13
|
+
'.css': 'text/css',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.svg': 'image/svg+xml',
|
|
16
|
+
'.png': 'image/png',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
};
|
|
19
|
+
export class StaticServer {
|
|
20
|
+
server = null;
|
|
21
|
+
options;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
const { distDir, port, onLog } = this.options;
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
this.server = http.createServer((req, res) => {
|
|
29
|
+
// CORS headers(与 webpack.config.js 一致)
|
|
30
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
31
|
+
res.setHeader('Access-Control-Allow-Headers', 'baggage, sentry-trace');
|
|
32
|
+
// OPTIONS preflight
|
|
33
|
+
if (req.method === 'OPTIONS') {
|
|
34
|
+
res.writeHead(204);
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const urlPath = req.url?.split('?')[0] || '/';
|
|
39
|
+
const filePath = path.resolve(distDir, urlPath === '/' ? 'index.html' : '.' + urlPath);
|
|
40
|
+
// 防止目录遍历(resolve 规范化后,确保仍在 distDir + sep 下)
|
|
41
|
+
const safePrefix = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
|
|
42
|
+
if (!filePath.startsWith(safePrefix) && filePath !== distDir) {
|
|
43
|
+
res.writeHead(403);
|
|
44
|
+
res.end('Forbidden');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
fs.readFile(filePath, (err, data) => {
|
|
48
|
+
if (err) {
|
|
49
|
+
res.writeHead(404);
|
|
50
|
+
res.end('Not Found');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const ext = path.extname(filePath);
|
|
54
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
55
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
56
|
+
res.end(data);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
this.server.on('error', (err) => {
|
|
60
|
+
onLog?.(`[static-server] 启动失败: ${err.message}`, 'error');
|
|
61
|
+
reject(err);
|
|
62
|
+
});
|
|
63
|
+
this.server.listen(port, '127.0.0.1', () => {
|
|
64
|
+
onLog?.(`[static-server] 已启动 http://127.0.0.1:${port} (serving ${distDir})`, 'info');
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
stop() {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
if (!this.server) {
|
|
72
|
+
resolve();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.server.close(() => {
|
|
76
|
+
this.server = null;
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
isRunning() {
|
|
82
|
+
return this.server !== null && this.server.listening;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/cli/main.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 伞命令:CLI 命令 + mcp 子命令 + install 子命令。
|
|
6
6
|
*/
|
|
7
|
+
import { createRequire } from 'module';
|
|
7
8
|
import { Command } from 'commander';
|
|
8
9
|
import { connectCommand } from './commands/connect.js';
|
|
10
|
+
import { setupCommand } from './commands/setup.js';
|
|
9
11
|
import { healthCommand } from './commands/health.js';
|
|
10
12
|
import { disconnectCommand } from './commands/disconnect.js';
|
|
11
13
|
import { readRemCommand } from './commands/read-rem.js';
|
|
@@ -17,6 +19,8 @@ import { readContextCommand } from './commands/read-context.js';
|
|
|
17
19
|
import { searchCommand } from './commands/search.js';
|
|
18
20
|
import { installSkillCommand, installSkillCopyCommand } from './commands/install-skill.js';
|
|
19
21
|
import { cleanCommand } from './commands/clean.js';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { version } = require('../../package.json');
|
|
20
24
|
const program = new Command();
|
|
21
25
|
/**
|
|
22
26
|
* --json 模式下解析 JSON 输入参数。
|
|
@@ -54,21 +58,33 @@ function parseJsonInput(command, jsonStr, requiredFields = []) {
|
|
|
54
58
|
program
|
|
55
59
|
.name('remnote-bridge')
|
|
56
60
|
.description('RemNote Bridge — CLI + MCP Server + Plugin')
|
|
57
|
-
.version(
|
|
61
|
+
.version(version)
|
|
58
62
|
.option('--json', '以 JSON 格式输出(适用于程序化调用)');
|
|
59
63
|
program
|
|
60
|
-
.command('
|
|
61
|
-
.description('
|
|
64
|
+
.command('setup')
|
|
65
|
+
.description('启动 Chrome 让用户登录 RemNote(headless 模式前置步骤)')
|
|
62
66
|
.action(async () => {
|
|
63
67
|
const { json } = program.opts();
|
|
64
|
-
await
|
|
68
|
+
await setupCommand({ json });
|
|
69
|
+
});
|
|
70
|
+
program
|
|
71
|
+
.command('connect')
|
|
72
|
+
.description('启动守护进程,等待 Plugin 连接')
|
|
73
|
+
.option('--dev', '开发模式:使用 webpack-dev-server(支持 HMR)')
|
|
74
|
+
.option('--headless', '无头模式:自动启动 headless Chrome 加载 Plugin(需先 setup)')
|
|
75
|
+
.option('--remote-debugging-port <port>', 'Chrome 远程调试端口(仅 --headless)', parseInt)
|
|
76
|
+
.action(async (cmdOpts) => {
|
|
77
|
+
const { json } = program.opts();
|
|
78
|
+
await connectCommand({ json, dev: cmdOpts.dev, headless: cmdOpts.headless, remoteDebuggingPort: cmdOpts.remoteDebuggingPort });
|
|
65
79
|
});
|
|
66
80
|
program
|
|
67
81
|
.command('health')
|
|
68
82
|
.description('检查守护进程、Plugin 连接和 SDK 状态')
|
|
69
|
-
.
|
|
83
|
+
.option('--diagnose', '诊断 headless Chrome(截图 + 状态 + console 错误)')
|
|
84
|
+
.option('--reload', '重载 headless Chrome 页面')
|
|
85
|
+
.action(async (cmdOpts) => {
|
|
70
86
|
const { json } = program.opts();
|
|
71
|
-
await healthCommand({ json });
|
|
87
|
+
await healthCommand({ json, diagnose: cmdOpts.diagnose, reload: cmdOpts.reload });
|
|
72
88
|
});
|
|
73
89
|
program
|
|
74
90
|
.command('disconnect')
|
|
@@ -49,6 +49,9 @@ export class BridgeServer {
|
|
|
49
49
|
pongTimeoutMs: config.pongTimeoutMs ?? 10_000,
|
|
50
50
|
onLog: config.onLog,
|
|
51
51
|
getTimeoutRemaining: config.getTimeoutRemaining,
|
|
52
|
+
getHeadlessStatus: config.getHeadlessStatus,
|
|
53
|
+
diagnoseHeadless: config.diagnoseHeadless,
|
|
54
|
+
reloadHeadless: config.reloadHeadless,
|
|
52
55
|
};
|
|
53
56
|
this.defaults = config.defaults ?? DEFAULT_DEFAULTS;
|
|
54
57
|
const defaults = this.defaults;
|
|
@@ -200,6 +203,59 @@ export class BridgeServer {
|
|
|
200
203
|
ws.send(JSON.stringify(response));
|
|
201
204
|
return;
|
|
202
205
|
}
|
|
206
|
+
// diagnose:headless 诊断(不需要 Plugin)
|
|
207
|
+
if (request.action === 'diagnose') {
|
|
208
|
+
if (!this.config.diagnoseHeadless) {
|
|
209
|
+
const response = {
|
|
210
|
+
id: request.id,
|
|
211
|
+
error: '非 headless 模式,不支持 diagnose',
|
|
212
|
+
};
|
|
213
|
+
ws.send(JSON.stringify(response));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const diagResult = await this.config.diagnoseHeadless();
|
|
218
|
+
if (diagResult) {
|
|
219
|
+
// 补充 pluginConnected 和 sdkReady
|
|
220
|
+
diagResult.pluginConnected = this.pluginSocket?.readyState === WebSocket.OPEN;
|
|
221
|
+
diagResult.sdkReady = this.pluginSdkReady;
|
|
222
|
+
}
|
|
223
|
+
const response = { id: request.id, result: diagResult };
|
|
224
|
+
ws.send(JSON.stringify(response));
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
const response = {
|
|
228
|
+
id: request.id,
|
|
229
|
+
error: err instanceof Error ? err.message : String(err),
|
|
230
|
+
};
|
|
231
|
+
ws.send(JSON.stringify(response));
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// headless_reload:重载 headless Chrome 页面(不需要 Plugin)
|
|
236
|
+
if (request.action === 'headless_reload') {
|
|
237
|
+
if (!this.config.reloadHeadless) {
|
|
238
|
+
const response = {
|
|
239
|
+
id: request.id,
|
|
240
|
+
error: '非 headless 模式,不支持 reload',
|
|
241
|
+
};
|
|
242
|
+
ws.send(JSON.stringify(response));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const reloadResult = await this.config.reloadHeadless();
|
|
247
|
+
const response = { id: request.id, result: reloadResult };
|
|
248
|
+
ws.send(JSON.stringify(response));
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const response = {
|
|
252
|
+
id: request.id,
|
|
253
|
+
error: err instanceof Error ? err.message : String(err),
|
|
254
|
+
};
|
|
255
|
+
ws.send(JSON.stringify(response));
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
203
259
|
// 以下 action 都需要 Plugin 连接
|
|
204
260
|
if (!this.pluginSocket || this.pluginSocket.readyState !== WebSocket.OPEN) {
|
|
205
261
|
const response = {
|
|
@@ -325,11 +381,16 @@ export class BridgeServer {
|
|
|
325
381
|
}
|
|
326
382
|
/** 获取当前状态(timeoutRemaining 通过构造时注入的回调获取) */
|
|
327
383
|
getStatus() {
|
|
328
|
-
|
|
384
|
+
const result = {
|
|
329
385
|
pluginConnected: this.pluginSocket?.readyState === WebSocket.OPEN,
|
|
330
386
|
sdkReady: this.pluginSdkReady,
|
|
331
387
|
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
332
388
|
timeoutRemaining: this.config.getTimeoutRemaining?.() ?? 0,
|
|
333
389
|
};
|
|
390
|
+
const headlessStatus = this.config.getHeadlessStatus?.();
|
|
391
|
+
if (headlessStatus) {
|
|
392
|
+
result.headless = headlessStatus;
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
334
395
|
}
|
|
335
396
|
}
|
|
@@ -46,8 +46,11 @@ export class CliError extends Error {
|
|
|
46
46
|
*/
|
|
47
47
|
export async function callCli(command, payload, options) {
|
|
48
48
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
49
|
-
// 构造参数列表:--json <command> [jsonStr]
|
|
49
|
+
// 构造参数列表:--json <command> [flags...] [jsonStr]
|
|
50
50
|
const args = ['--json', command];
|
|
51
|
+
if (options?.flags) {
|
|
52
|
+
args.push(...options.flags);
|
|
53
|
+
}
|
|
51
54
|
if (payload !== undefined) {
|
|
52
55
|
args.push(JSON.stringify(payload));
|
|
53
56
|
}
|
package/dist/mcp/instructions.js
CHANGED
|
@@ -85,6 +85,8 @@ RemNote 的格式设置(标题、高亮、代码等)会注入隐藏的系统
|
|
|
85
85
|
|
|
86
86
|
所有操作都依赖一个活跃的会话。会话 = 守护进程的生命周期。
|
|
87
87
|
|
|
88
|
+
### 标准模式(需要用户配合)
|
|
89
|
+
|
|
88
90
|
\\\`\\\`\\\`
|
|
89
91
|
connect → 启动 daemon(幂等,重复调用安全)
|
|
90
92
|
↓
|
|
@@ -97,6 +99,52 @@ health → 确认三层就绪(daemon / Plugin / SDK)
|
|
|
97
99
|
disconnect → 关闭 daemon,清空所有缓存
|
|
98
100
|
\\\`\\\`\\\`
|
|
99
101
|
|
|
102
|
+
### Headless 模式(自动连接)
|
|
103
|
+
|
|
104
|
+
标准模式每次 connect 后都需要用户手动操作 RemNote。Headless 模式通过 setup(一次性)+ headless Chrome 实现自动连接,后续 connect 无需用户介入。
|
|
105
|
+
|
|
106
|
+
**⚠️ 模式选择建议**:日常使用推荐**标准模式**。Headless 模式下 Chrome 在后台运行,**无法感知用户正在 RemNote 中浏览和操作的界面**(\\\`read_context\\\` 返回的是 headless Chrome 的上下文,而非用户的浏览器)。只有在全自动化场景(CI/CD、定时任务、批量操作等无需与用户界面交互的场景)才建议使用 Headless 模式。
|
|
107
|
+
|
|
108
|
+
#### 首次使用(setup)
|
|
109
|
+
|
|
110
|
+
\\\`setup\\\` 会弹出 Chrome 窗口,用户需要完成两件事:
|
|
111
|
+
1. **登录 RemNote**
|
|
112
|
+
2. **配置 dev plugin**:插件图标 → 开发你的插件 → 填入 \\\`http://localhost:8080\\\`
|
|
113
|
+
|
|
114
|
+
完成后**彻底退出 Chrome**(macOS 必须 Cmd+Q,仅关窗口不够)。
|
|
115
|
+
|
|
116
|
+
**你必须这样与用户交互**:
|
|
117
|
+
1. 调用 \\\`setup\\\`
|
|
118
|
+
2. 立即告知用户:
|
|
119
|
+
"已打开 Chrome 浏览器。请完成以下操作:
|
|
120
|
+
1. 登录 RemNote
|
|
121
|
+
2. 在 RemNote 中配置开发插件:点击左下角插件图标 → 开发你的插件 → 输入 http://localhost:8080
|
|
122
|
+
3. 完成后彻底退出 Chrome(macOS 请按 Cmd+Q)"
|
|
123
|
+
3. 等待 \\\`setup\\\` 返回(阻塞,最长 10 分钟)
|
|
124
|
+
4. 成功 → 进入下一步 \\\`connect(headless=true)\\\`
|
|
125
|
+
|
|
126
|
+
setup 只需执行一次。之后每次连接直接用 \\\`connect(headless=true)\\\`。
|
|
127
|
+
|
|
128
|
+
#### 后续使用
|
|
129
|
+
|
|
130
|
+
\\\`\\\`\\\`
|
|
131
|
+
connect(headless=true) → 启动 daemon + headless Chrome 自动加载 RemNote 和 Plugin
|
|
132
|
+
↓
|
|
133
|
+
health → 等待三层就绪(Plugin 需要 10-30 秒连接,可多次轮询)
|
|
134
|
+
↓
|
|
135
|
+
业务操作(read / search / edit)
|
|
136
|
+
↓
|
|
137
|
+
disconnect → 关闭 daemon + headless Chrome,清空所有缓存
|
|
138
|
+
\\\`\\\`\\\`
|
|
139
|
+
|
|
140
|
+
**无需任何用户操作**——headless Chrome 在后台自动完成登录和 Plugin 加载。
|
|
141
|
+
|
|
142
|
+
#### 排查
|
|
143
|
+
|
|
144
|
+
- \\\`health(diagnose=true)\\\`:截图 + Chrome 状态 + console 错误(确认页面是否正常加载)
|
|
145
|
+
- \\\`health(reload=true)\\\`:重载 headless Chrome 页面(Plugin 未连接时尝试)
|
|
146
|
+
- 如果 Plugin 始终不连接,可能是 RemNote 登录 session 过期,需重新 setup
|
|
147
|
+
|
|
100
148
|
**关键要点**:
|
|
101
149
|
- \\\`connect\\\` 是所有业务操作的前提,未 connect 时任何命令都会报"守护进程未运行"
|
|
102
150
|
- \\\`health\\\` 检查三层状态:daemon 运行 → Plugin 已连接 → SDK 就绪,三者全部通过才能执行业务命令
|
|
@@ -105,19 +153,20 @@ disconnect → 关闭 daemon,清空所有缓存
|
|
|
105
153
|
|
|
106
154
|
### Windows 注意事项
|
|
107
155
|
|
|
108
|
-
-
|
|
109
|
-
-
|
|
156
|
+
- **默认模式秒级启动**:使用预构建 plugin,无需安装依赖
|
|
157
|
+
- **\`--dev\` 模式首次较慢**:会自动安装 remnote-plugin 的依赖(约 600+ 个包),在 Windows 上可能需要 30-60 秒,connect 超时设为 60 秒
|
|
158
|
+
- **\`--dev\` 依赖自动修复**:如果 webpack-dev-server 因依赖损坏而崩溃,daemon 会自动清洁重装依赖(删除 node_modules 后重新安装)并重试,最多重试 2 次
|
|
110
159
|
- **端口残留**:多次 connect 失败后可能出现端口被占用(EADDRINUSE),用 \\\`remnote-bridge disconnect\\\` 或手动终止占用端口的进程后重试
|
|
111
160
|
|
|
112
|
-
### ⚠️ connect 后需要用户配合(重要)
|
|
161
|
+
### ⚠️ 标准模式:connect 后需要用户配合(重要)
|
|
113
162
|
|
|
114
|
-
\\\`connect
|
|
163
|
+
\\\`connect\\\`(不传 headless)成功只意味着 daemon 和 Plugin 服务已启动,**Plugin 并未自动连接**。用户必须在 RemNote 中完成以下操作,Plugin 才能连接到 daemon:
|
|
115
164
|
|
|
116
165
|
**首次使用**(RemNote 从未加载过此插件):
|
|
117
166
|
1. 打开 RemNote 桌面端或网页端
|
|
118
167
|
2. 点击左侧边栏底部的插件图标(拼图形状)
|
|
119
168
|
3. 点击「开发你的插件」(Develop Your Plugin)
|
|
120
|
-
4. 在输入框中填入 \\\`http://localhost:8080\\\`(即 connect 输出的
|
|
169
|
+
4. 在输入框中填入 \\\`http://localhost:8080\\\`(即 connect 输出的 Plugin 服务地址)
|
|
121
170
|
5. 等待插件加载完成
|
|
122
171
|
|
|
123
172
|
**非首次使用**(之前已加载过此插件):
|