ai_co_discuss 0.2.71 → 0.2.73
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/.agents/skills/discuss-host/references/manual.md +1 -1
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/.agents/skills/discuss-participant/references/free.md +2 -0
- package/README.md +128 -185
- package/README.zh-CN.md +122 -0
- package/backend/free_room.py +54 -5
- package/backend/remote_launch.py +175 -0
- package/backend/schema.sql +36 -0
- package/backend/server.py +60 -14
- package/docs/architecture.md +280 -280
- package/docs/deployment.md +225 -225
- package/docs/runner.md +467 -455
- package/docs/skills.md +1 -1
- package/docs/workflow.md +157 -155
- package/package.json +2 -2
- package/runner/api_participant.mjs +235 -230
- package/runner/discussion_adapters.mjs +70 -2
- package/runner/discussion_launch.mjs +54 -5
- package/runner/discussion_local.mjs +3 -2
- package/runner/discussion_service.mjs +8 -0
- package/runner/free_runner.mjs +39 -3
- package/runner/remote_launch.mjs +101 -0
- package/web/app.js +3437 -3384
- package/web/icons/typesafe.png +0 -0
- package/web/modern.css +1 -1
- package/web/modern.js +2526 -2058
|
@@ -50,6 +50,8 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
|
|
|
50
50
|
codiscuss launch scheduler-check --file FILE
|
|
51
51
|
codiscuss launch room-member --file FILE
|
|
52
52
|
codiscuss launch pick-directory
|
|
53
|
+
codiscuss launch projects [--file FILE]
|
|
54
|
+
codiscuss launch machine-link --file FILE
|
|
53
55
|
|
|
54
56
|
settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
|
|
55
57
|
start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
|
|
@@ -60,6 +62,8 @@ api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_r
|
|
|
60
62
|
api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
|
|
61
63
|
scheduler-check 用智能调度配置(provider、model、api_key_ref)向决策模型发送一道是非题,核对供应商、模型与密钥是否可用。
|
|
62
64
|
pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
|
|
65
|
+
projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
|
|
66
|
+
machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
|
|
63
67
|
room-member 为运行中的自由房间或讨论邀请、请回一位成员(讨论只接受本机唤醒的成员);须提供 site、room_id、token、action(add/rejoin)、name。
|
|
64
68
|
JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
|
|
65
69
|
或 room_id(已有房间)、participants(本次软件覆盖配置)。
|
|
@@ -71,7 +75,9 @@ conversation 还须 current_session_id,当前会话接手首个主持任务。
|
|
|
71
75
|
// Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
|
|
72
76
|
// shown (off-screen) as a topmost window; the dialog it owns then stays above the browser that asked for it.
|
|
73
77
|
// windowsHide turns the process's first Show into a hide, so the owner is shown a second time to really appear.
|
|
74
|
-
//
|
|
78
|
+
// Under the local service the whole process chain has no console and no foreground rights: Windows then silently ignores
|
|
79
|
+
// SetWindowPos(HWND_TOPMOST) on the dialog (it returns success). So once the dialog exists, a timer in its message loop
|
|
80
|
+
// briefly attaches to the foreground window's input thread (the browser that asked) to borrow its right to raise the dialog.
|
|
75
81
|
// The C# source must not contain single quotes: it is embedded in a single-quoted PowerShell string.
|
|
76
82
|
const PICKER_RAISE_SOURCE = [
|
|
77
83
|
'using System; using System.Runtime.InteropServices;',
|
|
@@ -80,8 +86,21 @@ const PICKER_RAISE_SOURCE = [
|
|
|
80
86
|
' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc proc, IntPtr arg);',
|
|
81
87
|
' [DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr handle, uint command);',
|
|
82
88
|
' [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr handle, IntPtr after, int x, int y, int width, int height, uint flags);',
|
|
83
|
-
'
|
|
84
|
-
'
|
|
89
|
+
' [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();',
|
|
90
|
+
' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr handle, IntPtr process);',
|
|
91
|
+
' [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();',
|
|
92
|
+
' [DllImport("user32.dll")] static extern bool AttachThreadInput(uint attach, uint to, bool on);',
|
|
93
|
+
' [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr handle);',
|
|
94
|
+
' [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr handle);',
|
|
95
|
+
' public static bool OwnedBy(IntPtr owner) {',
|
|
96
|
+
' IntPtr dialog = IntPtr.Zero;',
|
|
97
|
+
' EnumWindows((handle, arg) => { if (GetWindow(handle, 4) == owner) dialog = handle; return dialog == IntPtr.Zero; }, IntPtr.Zero);',
|
|
98
|
+
' if (dialog == IntPtr.Zero) return false;',
|
|
99
|
+
' uint current = GetCurrentThreadId(), foreground = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);',
|
|
100
|
+
' bool attached = foreground != 0 && foreground != current && AttachThreadInput(current, foreground, true);',
|
|
101
|
+
' SetWindowPos(dialog, (IntPtr)(-1), 0, 0, 0, 0, 0x13); BringWindowToTop(dialog); SetForegroundWindow(dialog);',
|
|
102
|
+
' if (attached) AttachThreadInput(current, foreground, false);',
|
|
103
|
+
' return true;',
|
|
85
104
|
' }',
|
|
86
105
|
'}',
|
|
87
106
|
].join(' ');
|
|
@@ -91,7 +110,7 @@ const WINDOWS_PICKER = [
|
|
|
91
110
|
'$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "Manual"; Location = New-Object System.Drawing.Point(-32000, -32000); Size = New-Object System.Drawing.Size(1, 1) }',
|
|
92
111
|
`Add-Type -TypeDefinition '${PICKER_RAISE_SOURCE}'`,
|
|
93
112
|
'$owner.Show(); $owner.Hide(); $owner.Show(); $owner.Activate()',
|
|
94
|
-
'$timer = New-Object System.Windows.Forms.Timer -Property @{ Interval = 150 }; $timer.Add_Tick({ [PickerRaise]::OwnedBy($owner.Handle) }); $timer.Start()',
|
|
113
|
+
'$timer = New-Object System.Windows.Forms.Timer -Property @{ Interval = 150 }; $timer.Add_Tick({ if ([PickerRaise]::OwnedBy($owner.Handle)) { $timer.Stop() } }); $timer.Start()',
|
|
95
114
|
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
|
|
96
115
|
'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
|
|
97
116
|
'$owner.Close()',
|
|
@@ -188,6 +207,34 @@ export function save_launch_settings(home, input) {
|
|
|
188
207
|
return settings;
|
|
189
208
|
}
|
|
190
209
|
|
|
210
|
+
const PROJECT_NAME_LIMIT = 80;
|
|
211
|
+
const PROJECT_ID_PATTERN = /^[0-9a-f]{8,32}$/;
|
|
212
|
+
|
|
213
|
+
/** Read the named project directories saved on this computer, offered as launch cwd choices. */
|
|
214
|
+
export function launch_projects(home) {
|
|
215
|
+
const path = join(home, 'launch-projects.json');
|
|
216
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { projects: [] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Replace the project list; every path must be an existing folder on this computer. */
|
|
220
|
+
// The id outlives renames and moves: other devices choose a project by id and never see its path.
|
|
221
|
+
export function save_launch_projects(home, input) {
|
|
222
|
+
if (!Array.isArray(input.projects)) throw new Error('projects 必须为列表');
|
|
223
|
+
const projects = input.projects.map(item => ({ id: PROJECT_ID_PATTERN.test(item?.id) ? item.id : randomUUID().replaceAll('-', '').slice(0, 16),
|
|
224
|
+
name: String(item?.name || '').trim(), path: String(item?.path || '').trim() }));
|
|
225
|
+
const names = new Set();
|
|
226
|
+
for (const { name, path } of projects) {
|
|
227
|
+
if (!name || name.length > PROJECT_NAME_LIMIT) throw new Error(`项目名称不能为空,且不超过 ${PROJECT_NAME_LIMIT} 字`);
|
|
228
|
+
if (names.has(name)) throw new Error(`项目名称重复:${name}`);
|
|
229
|
+
names.add(name);
|
|
230
|
+
if (!isAbsolute(path)) throw new Error(`${name}:路径必须为完整绝对路径`);
|
|
231
|
+
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${name}:这台电脑上不存在此文件夹:${path}`);
|
|
232
|
+
}
|
|
233
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
234
|
+
atomic_json(join(home, 'launch-projects.json'), { projects });
|
|
235
|
+
return { projects };
|
|
236
|
+
}
|
|
237
|
+
|
|
191
238
|
/** Reject malformed client fields before saving defaults or creating any discussion. */
|
|
192
239
|
function validate_profile(name, profile, binding = false) {
|
|
193
240
|
if (!clients_of(name) || !profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error(`不支持的软件配置:${name}`);
|
|
@@ -775,7 +822,8 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
775
822
|
assert_shared_home(home);
|
|
776
823
|
let result;
|
|
777
824
|
if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
|
|
778
|
-
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home) };
|
|
825
|
+
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home), projects: launch_projects(home).projects };
|
|
826
|
+
else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
|
|
779
827
|
else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
|
|
780
828
|
else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
|
|
781
829
|
else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
|
|
@@ -788,6 +836,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
788
836
|
}
|
|
789
837
|
else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
|
|
790
838
|
else if (positionals[0] === 'pick-directory') result = await pick_directory();
|
|
839
|
+
else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
|
|
791
840
|
else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
|
|
792
841
|
else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
|
|
793
842
|
else throw new Error(HELP);
|
|
@@ -11,6 +11,7 @@ import { atomic_json } from './discussion_adapters.mjs';
|
|
|
11
11
|
import { acquire_lock } from './discussion_runner.mjs';
|
|
12
12
|
import { assert_shared_home, main as service_main, service_home, service_pid, service_ready, start_service } from './discussion_service.mjs';
|
|
13
13
|
import { detect_python, local_settings } from './discussion_setup.mjs';
|
|
14
|
+
import { unlink_machine } from './remote_launch.mjs';
|
|
14
15
|
import { tone } from './discussion_tui.mjs';
|
|
15
16
|
|
|
16
17
|
const SCRIPT = fileURLToPath(import.meta.url);
|
|
@@ -25,7 +26,7 @@ const LOCAL_HELP = `用法:codiscuss local open|start|status|stop [--json]
|
|
|
25
26
|
codiscuss local disconnect SITE
|
|
26
27
|
|
|
27
28
|
connect 允许指定 HTTPS 站点通过此浏览器使用本机软件配置并启动 AI,打开连接链接完成接入。
|
|
28
|
-
disconnect
|
|
29
|
+
disconnect 撤销该站点的新启动权限(含其他设备经网站转来的启动);已运行的讨论须另行暂停。
|
|
29
30
|
数据保留在服务目录中;重新 connect 会替换该站点的旧连接。
|
|
30
31
|
结果摘要写入终端;输出被重定向或加 --json 时 JSON 写入 stdout。
|
|
31
32
|
`;
|
|
@@ -48,7 +49,7 @@ export function save_site_connection(home, site, token = null) {
|
|
|
48
49
|
const path = join(home, 'site-connections.json');
|
|
49
50
|
const settings = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
50
51
|
if (token) settings.sites[origin] = { token_hash: createHash('sha256').update(token).digest('hex') };
|
|
51
|
-
else delete settings.sites[origin];
|
|
52
|
+
else { delete settings.sites[origin]; unlink_machine(home, origin); }
|
|
52
53
|
atomic_json(path, settings);
|
|
53
54
|
} finally { release(); }
|
|
54
55
|
}
|
|
@@ -13,6 +13,7 @@ import { DiscussionRunner, DiscussionStore, acquire_lock, api, load_config, paus
|
|
|
13
13
|
import { FreeRoomRunner } from './free_runner.mjs';
|
|
14
14
|
|
|
15
15
|
const POLL_MS = 2000;
|
|
16
|
+
const REMOTE_SYNC_MS = 3000;
|
|
16
17
|
const START_WAIT_MS = 5000;
|
|
17
18
|
const STOP_WAIT_MS = 20_000;
|
|
18
19
|
const REGISTRY_WAIT_MS = 5000;
|
|
@@ -400,8 +401,15 @@ export async function run_service(home, output = () => {}) {
|
|
|
400
401
|
log_line(`讨论服务已启动 pid=${process.pid}`);
|
|
401
402
|
atomic_json(join(home, 'status.json'), { pid: process.pid, ready: true,
|
|
402
403
|
updated_at: Math.floor(Date.now() / 1000), rooms: [] });
|
|
404
|
+
// Loaded lazily: the relay runs launches, and the launch module itself depends on this one.
|
|
405
|
+
const { sync_remote_launches } = await import('./remote_launch.mjs');
|
|
406
|
+
let synced_at = 0;
|
|
403
407
|
while (!stopped && !existsSync(stop_path)) {
|
|
404
408
|
try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
|
|
409
|
+
if (Date.now() - synced_at >= REMOTE_SYNC_MS) {
|
|
410
|
+
synced_at = Date.now();
|
|
411
|
+
try { await sync_remote_launches(home, { log: log_line }); } catch (error) { log_line(`远程启动检查失败:${error.message}`); }
|
|
412
|
+
}
|
|
405
413
|
await sleep(POLL_MS);
|
|
406
414
|
}
|
|
407
415
|
} finally {
|
package/runner/free_runner.mjs
CHANGED
|
@@ -9,6 +9,12 @@ import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_W
|
|
|
9
9
|
|
|
10
10
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
11
11
|
const RENEW_MS = 30_000;
|
|
12
|
+
/** A changed phase is reported early, but no more often than this, since each report refreshes open pages. */
|
|
13
|
+
const ACTIVITY_REPORT_MS = 10_000;
|
|
14
|
+
/** Progress notes kept per execution, each cut to this length, and how many one renewal carries; the website enforces the same. */
|
|
15
|
+
const MAX_EXECUTION_NOTES = 50;
|
|
16
|
+
const MAX_NOTE_LENGTH = 500;
|
|
17
|
+
const MAX_NOTES_PER_RENEW = 10;
|
|
12
18
|
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
13
19
|
const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running'];
|
|
14
20
|
const MAX_HASHED_BYTES = 5 * 1024 * 1024;
|
|
@@ -130,6 +136,9 @@ export class FreeRoomRunner {
|
|
|
130
136
|
this.members = new Map();
|
|
131
137
|
this.exited = new Set();
|
|
132
138
|
this.snapshots = new Map();
|
|
139
|
+
// Execution id -> { phase, at, sent_phase, sent_at, notes, next_seq }: what the member's software last showed it doing,
|
|
140
|
+
// and the progress notes not yet on the website.
|
|
141
|
+
this.activity = new Map();
|
|
133
142
|
this.context_errors = new Map();
|
|
134
143
|
this.adopt(config);
|
|
135
144
|
this.store.db.exec(`CREATE TABLE IF NOT EXISTS free_deliveries(member TEXT PRIMARY KEY, client_id TEXT NOT NULL,
|
|
@@ -329,7 +338,9 @@ export class FreeRoomRunner {
|
|
|
329
338
|
requests: execution.requests, read_after: execution.read_until },
|
|
330
339
|
on_binding: session => this.store.bind(member.name, session),
|
|
331
340
|
on_process: child => this.track(member.name, child),
|
|
332
|
-
on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
|
|
341
|
+
on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); },
|
|
342
|
+
on_activity: phase => this.touch(execution.id, phase),
|
|
343
|
+
on_note: text => this.note(execution.id, text),
|
|
333
344
|
// A stopped process may report after the next execution started; only its own execution takes the result.
|
|
334
345
|
on_failure: error => { try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ } },
|
|
335
346
|
on_exit: () => this.exited.add(execution.id),
|
|
@@ -357,6 +368,7 @@ export class FreeRoomRunner {
|
|
|
357
368
|
}
|
|
358
369
|
const execution = active.get(row.execution_id);
|
|
359
370
|
if (!execution) {
|
|
371
|
+
this.activity.delete(row.execution_id);
|
|
360
372
|
this.stop_member(member.name);
|
|
361
373
|
return this.save(member.name, { status: 'idle', error: '' });
|
|
362
374
|
}
|
|
@@ -369,11 +381,34 @@ export class FreeRoomRunner {
|
|
|
369
381
|
return this.save(member.name, { status: 'idle' });
|
|
370
382
|
}
|
|
371
383
|
if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes} 分钟;请核对原会话确认是否已有内容`);
|
|
372
|
-
|
|
373
|
-
|
|
384
|
+
const activity = this.activity.get(execution.id);
|
|
385
|
+
const changed = activity && (activity.phase !== activity.sent_phase || activity.at !== activity.sent_at || activity.notes.length);
|
|
386
|
+
const elapsed = Date.now() - row.last_renew;
|
|
387
|
+
if (elapsed >= RENEW_MS || (changed && elapsed >= ACTIVITY_REPORT_MS)) {
|
|
388
|
+
const notes = activity?.notes.slice(0, MAX_NOTES_PER_RENEW) || [];
|
|
389
|
+
await api(this.config, `/executions/${execution.id}`, { action: 'renew',
|
|
390
|
+
...(activity ? { activity: activity.phase, activity_at: Math.floor(activity.at / 1000) } : {}), ...(notes.length ? { notes } : {}) });
|
|
391
|
+
if (activity) Object.assign(activity, { sent_phase: activity.phase, sent_at: activity.at, notes: activity.notes.slice(notes.length) });
|
|
374
392
|
this.save(member.name, { last_renew: Date.now() });
|
|
375
393
|
}
|
|
376
394
|
}
|
|
395
|
+
/** Note that the software produced output now, and its phase when the output names one. */
|
|
396
|
+
touch(execution_id, phase) {
|
|
397
|
+
// Updated in place: a renewal in flight marks what it sent on this same record.
|
|
398
|
+
if (!this.activity.has(execution_id)) this.activity.set(execution_id, { phase: '', notes: [], next_seq: 0 });
|
|
399
|
+
const current = this.activity.get(execution_id);
|
|
400
|
+
if (phase) current.phase = phase;
|
|
401
|
+
current.at = Date.now();
|
|
402
|
+
}
|
|
403
|
+
/** Queue one progress note for the next renewal; numbering makes a retried renewal harmless, and notes past the cap are dropped. */
|
|
404
|
+
note(execution_id, text) {
|
|
405
|
+
this.touch(execution_id);
|
|
406
|
+
const current = this.activity.get(execution_id);
|
|
407
|
+
if (current.next_seq >= MAX_EXECUTION_NOTES) return;
|
|
408
|
+
current.next_seq += 1;
|
|
409
|
+
// Cut by UTF-16 units like the website's check, without leaving half of a surrogate pair.
|
|
410
|
+
current.notes.push({ seq: current.next_seq, text: text.slice(0, MAX_NOTE_LENGTH).replace(/[\uD800-\uDBFF]$/, '') });
|
|
411
|
+
}
|
|
377
412
|
/** Report observed file changes once an execution has ended and its member's processes have exited. */
|
|
378
413
|
async report_finished(active) {
|
|
379
414
|
for (const [execution_id, snapshot] of this.snapshots) {
|
|
@@ -388,6 +423,7 @@ export class FreeRoomRunner {
|
|
|
388
423
|
}
|
|
389
424
|
/** Record the cause on the website, then stop this member's own processes. */
|
|
390
425
|
async fail(name, execution_id, reason) {
|
|
426
|
+
this.activity.delete(execution_id);
|
|
391
427
|
try { await api(this.config, `/executions/${execution_id}`, { action: 'fail', reason }); }
|
|
392
428
|
catch (error) { if (!/HTTP 409/.test(error.message)) throw error; }
|
|
393
429
|
this.save(name, { status: 'idle', error: reason });
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Run room launches that other devices relay through a paired website; directories and credentials stay here. */
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { atomic_json } from './discussion_adapters.mjs';
|
|
8
|
+
import { saved_api_keys } from './discussion_api.mjs';
|
|
9
|
+
import { launch_discussion, launch_projects, launch_settings, platform_of } from './discussion_launch.mjs';
|
|
10
|
+
|
|
11
|
+
const MACHINE_TOKEN_BYTES = 32;
|
|
12
|
+
const MACHINE_NAME_MAX = 60;
|
|
13
|
+
const SYNC_TIMEOUT_MS = 10000;
|
|
14
|
+
const LINKS_FILE = 'machine-links.json';
|
|
15
|
+
|
|
16
|
+
/** Read every site this computer is paired with. */
|
|
17
|
+
export function machine_links(home) {
|
|
18
|
+
const path = join(home, LINKS_FILE);
|
|
19
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Return this computer's identity for one site, creating it on first use; the browser then pairs it as administrator. */
|
|
23
|
+
export function machine_link(home, site) {
|
|
24
|
+
const origin = new URL(site).origin;
|
|
25
|
+
const links = machine_links(home);
|
|
26
|
+
links.sites[origin] ||= { id: randomUUID().replaceAll('-', ''), token: randomBytes(MACHINE_TOKEN_BYTES).toString('hex'),
|
|
27
|
+
name: (hostname() || '本机').slice(0, MACHINE_NAME_MAX) };
|
|
28
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
29
|
+
atomic_json(join(home, LINKS_FILE), links);
|
|
30
|
+
return links.sites[origin];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Forget a site's pairing so it can no longer hand launches to this computer. */
|
|
34
|
+
export function unlink_machine(home, site) {
|
|
35
|
+
const links = machine_links(home);
|
|
36
|
+
if (!links.sites[site]) return;
|
|
37
|
+
delete links.sites[site];
|
|
38
|
+
atomic_json(join(home, LINKS_FILE), links);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve the API key saved for a profile's base URL, as the connected browser does. */
|
|
42
|
+
function api_key_ref(profile, keys) {
|
|
43
|
+
if (profile.api_key_ref || !profile.base_url) return profile.api_key_ref;
|
|
44
|
+
try { return keys[new URL(profile.base_url).href.replace(/\/+$/, '')]; } catch { return undefined; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Platforms this computer can wake without further setup, and the client each uses. */
|
|
48
|
+
export function ready_clients(home) {
|
|
49
|
+
const keys = saved_api_keys(home);
|
|
50
|
+
return Object.fromEntries(Object.entries(launch_settings(home).participants).filter(([, profile]) => profile.adapter === 'api'
|
|
51
|
+
? Boolean(profile.base_url && profile.api_protocol && profile.model && api_key_ref(profile, keys))
|
|
52
|
+
: Boolean(profile.adapter && (profile.adapter === 'antigravity' ? profile.ipc_directory : profile.executable))).map(([name, profile]) => [name, profile.adapter]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Turn a relayed request into the request the connected browser would send, using this computer's directory and keys. */
|
|
56
|
+
export function local_request(home, site, request) {
|
|
57
|
+
const { local_project, ...input } = request;
|
|
58
|
+
if (input.cwd || input.access_file) throw new Error('远程启动只能选择电脑上已配置的项目');
|
|
59
|
+
const project = local_project ? launch_projects(home).projects.find(item => item.id === local_project) : null;
|
|
60
|
+
if (local_project && !project) throw new Error('所选项目已从这台电脑移除,请重新选择');
|
|
61
|
+
const keys = saved_api_keys(home), defaults = launch_settings(home).participants;
|
|
62
|
+
const participants = { ...(input.participants || {}) };
|
|
63
|
+
for (const member of input.room?.members || []) {
|
|
64
|
+
const profile = defaults[platform_of(member.name)];
|
|
65
|
+
const ref = profile?.adapter === 'api' && !participants[member.name]?.api_key_ref ? api_key_ref(profile, keys) : undefined;
|
|
66
|
+
if (ref) participants[member.name] = { ...participants[member.name], api_key_ref: ref };
|
|
67
|
+
}
|
|
68
|
+
return { ...input, participants, site, entry: 'web', ...(project ? { cwd: project.path } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function machine_request(site, link, path, body) {
|
|
72
|
+
const response = await fetch(`${site}/api/machines/${link.id}${path}`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
|
73
|
+
headers: { 'Content-Type': 'application/json', 'X-Machine-Token': link.token }, body: JSON.stringify(body) });
|
|
74
|
+
const result = await response.json();
|
|
75
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Publish projects and clients to each paired site, then run every launch it relays, one at a time. */
|
|
80
|
+
export async function sync_remote_launches(home, { launch = launch_discussion, log = () => {} } = {}) {
|
|
81
|
+
const projects = launch_projects(home).projects.filter(project => project.id).map(({ id, name }) => ({ id, name }));
|
|
82
|
+
const clients = ready_clients(home);
|
|
83
|
+
for (const [site, link] of Object.entries(machine_links(home).sites)) {
|
|
84
|
+
let launches;
|
|
85
|
+
try { ({ launches } = await machine_request(site, link, '/sync', { projects, clients })); }
|
|
86
|
+
catch (error) { log(`${site}: 同步电脑状态失败:${error.message}`); continue; }
|
|
87
|
+
for (const { id, request } of launches) {
|
|
88
|
+
const action = body => machine_request(site, link, `/launches/${encodeURIComponent(id)}`, body);
|
|
89
|
+
try {
|
|
90
|
+
await action({ action: 'claim' });
|
|
91
|
+
const result = await launch(home, local_request(home, site, request), { browser_request: true });
|
|
92
|
+
await action({ action: 'finish', room_id: result.room.id, status: result.status });
|
|
93
|
+
log(`${site}: 远程启动 ${id} 已创建房间 ${result.room.id}`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log(`${site}: 远程启动 ${id} 失败:${error.message}`);
|
|
96
|
+
await action({ action: 'fail', error: error.message, stage: error.launch?.stage || 'not_started',
|
|
97
|
+
...(error.launch?.room_id ? { room_id: error.launch.room_id } : {}) }).catch(() => {});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|