device-center 0.2.0-beta.1 → 0.2.0-beta.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/src/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { api } from './api.js';
2
- import { expireSshSnapshots } from './device-state.js';
3
- import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands } from './onboarding.js';
2
+ import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
3
+ import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
4
4
 
5
5
  const root = document.querySelector('#root');
6
6
  const state = { loading: true, error: null, overview: null, setup: null, status: null, ssh: null, selectedAliases: [], saving: false, sshLoading: false, sshError: null, checkingSshAlias: null, dialog: null, routeDevice: null, method: 'settings', filter: 'all', query: '', project: 'all', toast: null, checking: false, checkMessage: null };
@@ -10,6 +10,8 @@ Object.assign(state, { connectionDraft: null, connectionSaving: false, connectio
10
10
  Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError: null, pairShare: false, requestSsh: false, revokeId: null, cloudRefreshError: null });
11
11
  Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
12
12
  let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
13
+ Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
14
+ Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
13
15
  const RELAY_PROVIDERS = [
14
16
  { id: 'self-hosted', name: '用户自建服务器', description: '设备 Agent 可使用自建 HTTPS 控制面进行密文中转;已有 SSH 设备仍沿用原配置,此处只保存计划。' },
15
17
  { id: 'managed', name: 'Device Center 服务', description: '设备 Agent 可使用官方 HTTPS 密文中转;需绑定自己的账号并单独授权日志。此处不会把 SSH 路径改成 Agent 通道。' },
@@ -30,7 +32,6 @@ const ICONS = {
30
32
  };
31
33
  const icon = (name, size = 18) => `<svg aria-hidden="true" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] ?? ICONS.device}</svg>`;
32
34
  const escapeHtml = (value = '') => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
33
- const statusText = (value) => ({ online: '在线', unknown: '未知', expired: '过期' })[value] ?? '未知';
34
35
  function formatAgo(time) {
35
36
  if (time == null) return '尚未采集';
36
37
  const elapsed = Math.max(0, Date.now() - time);
@@ -67,12 +68,64 @@ function deviceCard(device) {
67
68
  const route = device.ssh?.route;
68
69
  const path = local ? '本机进程 · 无网络转发' : device.source === 'driver-report' ? `${device.managementDriver.name} → ${new URL(device.managementDriver.origin).host} · HTTPS 回报` : route ? `本机 → ${route.hops.length ? `${route.hops.map((hop) => hop.alias).join(' → ')} → 设备` : endpointText(route.target)}` : '访问路径未核验';
69
70
  const detail = device.transport === 'ssh' ? !device.ssh.configured ? '本机 SSH 配置不可用' : device.ssh.error ? sshErrorText(device.ssh.error) : device.ssh.checkState !== 'verified' ? 'SSH 配置已关联 · 尚未检查' : null : local ? null : '设备状态回报';
70
- const statusLabel = device.transport === 'ssh' && device.state === 'expired' ? '待刷新' : statusText(device.state);
71
- const statusTitle = device.transport === 'ssh' && device.state === 'expired' ? '上次采集超过 5 分钟;数据待刷新,不代表设备断线或密钥过期。' : device.transport === 'ssh' && device.state === 'online' ? '最近一次 SSH 检查成功,不表示持续连接。' : statusLabel;
72
- const checking = state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking;
73
- const queued = device.ssh?.queued;
74
- const checkButton = device.transport === 'ssh' ? `<button class="button button-light ssh-check-button" data-action="ssh-check" data-alias="${escapeHtml(device.ssh.alias)}" ${!device.ssh.checkAvailable || state.checkingSshAlias || checking || queued ? 'disabled' : ''}>${checking ? '正在检查…' : queued ? '等待检查…' : '检查 SSH(只读)'}</button>` : '';
75
- return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2>${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(statusTitle)}">${statusLabel}</span></div><div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${escapeHtml(device.name)}的访问路径" title="查看连接方式与当前访问路径">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">${device.transport === 'ssh' ? '最近检查' : '最近采集'} <span>${formatAgo(device.transport === 'ssh' ? device.ssh.lastCheckedAt : device.lastHeartbeat)}</span></div>${checkButton}</div></article>`;
71
+ const status = deviceStatus(device), availability = collectionAvailability(device);
72
+ const collecting = state.collectingDevice === device.id || device.transport === 'ssh' && (state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking || device.ssh?.queued);
73
+ const collectButton = `<button class="button button-light ssh-check-button" data-action="collect-device" data-device="${escapeHtml(device.id)}" aria-label="采集 ${escapeHtml(device.name)}" title="${escapeHtml(availability.hint)}" ${!availability.available || collecting || state.collectingDevice ? 'disabled' : ''}>${collecting ? '正在采集…' : availability.pending ? '等待设备采集…' : '采集一次'}</button>`;
74
+ const profile = device.profile;
75
+ const description = profile && (profile.type !== 'unspecified' || profile.tags.length || profile.purpose) ? `<div class="device-description">${profile.type !== 'unspecified' ? `<span class="device-type">${DEVICE_TYPE_LABELS[profile.type]}</span>` : ''}${profile.tags.slice(0, 3).map(tag => `<span class="device-tag">${escapeHtml(tag)}</span>`).join('')}${profile.purpose ? `<p title="${escapeHtml(profile.purpose)}">${escapeHtml(profile.purpose)}</p>` : ''}</div>` : '';
76
+ const identityHint = device.managementDriver?.fingerprint ? ` · ${device.managementDriver.fingerprint.slice(0, 8)}` : '';
77
+ return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2 title="${escapeHtml(device.name)}">${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle + identityHint)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(status.title)}">${status.label}</span></div>${description}<div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${escapeHtml(device.name)}的访问路径" title="查看连接方式与当前访问路径">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">最近采集 <span>${formatAgo(device.lastHeartbeat)}</span></div>${collectButton}${!availability.available && !availability.pending && !collecting ? `<small class="collection-hint">${escapeHtml(availability.hint)}</small>` : ''}<button class="text-button profile-edit" data-action="profile" data-device="${escapeHtml(device.id)}" aria-label="编辑 ${escapeHtml(device.name)} 的设备说明">设备说明</button></div></article>`;
78
+ }
79
+
80
+ function openDeviceProfile(deviceId) {
81
+ const device = state.overview?.devices.find(d => d.id === deviceId);
82
+ if (!device) return;
83
+ const profile = device.profile ?? { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0 };
84
+ state.profileDevice = deviceId;
85
+ state.profileDraft = { ...profile, tags: profile.tags.join(','), services: profile.services.join(',') };
86
+ state.profileError = null; state.toast = null; state.dialog = 'profile';
87
+ }
88
+ function profileBody() {
89
+ const device = state.overview?.devices.find(d => d.id === state.profileDevice), draft = state.profileDraft;
90
+ if (!device || !draft) return '<p class="modal-intro">设备已不在列表,请刷新后查看。</p>';
91
+ const disabled = state.profileSaving ? 'disabled' : '';
92
+ return `<p class="modal-intro">给设备一个容易辨认的名字,让智能体知道它适合做什么。</p><form id="device-profile-form"><div class="profile-fields"><label class="field-label" for="profile-name">设备名称<input id="profile-name" data-profile-field="displayName" value="${escapeHtml(draft.displayName)}" placeholder="${escapeHtml(device.reportedName ?? device.name)}" maxlength="80" ${disabled}></label><div><label class="field-label" for="profile-type">设备类型</label><select id="profile-type" data-profile-field="type" ${disabled}>${Object.entries(DEVICE_TYPE_LABELS).map(([value, label]) => `<option value="${value}" ${draft.type === value ? 'selected' : ''}>${label}</option>`).join('')}</select></div></div><label class="field-label" for="profile-purpose">用途<textarea id="profile-purpose" data-profile-field="purpose" maxlength="240" rows="2" placeholder="例如:Windows 程序验收、读取测试日志" ${disabled}>${escapeHtml(draft.purpose)}</textarea></label><label class="field-label" for="profile-tags">标签<input id="profile-tags" data-profile-field="tags" value="${escapeHtml(draft.tags)}" maxlength="200" placeholder="例如:开发,测试,东京 · 最多 8 个" ${disabled}></label><label class="field-label" for="profile-services">服务与应用<input id="profile-services" data-profile-field="services" value="${escapeHtml(draft.services)}" maxlength="500" placeholder="例如:Docker,PostgreSQL,Windows SDK" ${disabled}></label><p class="small-note profile-service-note">标签、服务与应用用逗号分隔。服务清单为你手动登记,未自动检测安装或运行状态。</p><details class="profile-identity"><summary>原始名称与设备身份</summary><p>原始名称:${escapeHtml(device.reportedName ?? device.name)}</p><code class="fingerprint">${escapeHtml(device.id)}</code>${device.managementDriver?.fingerprint ? `<p>身份指纹</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code>` : ''}<p>自定义名称不改变主机名、SSH 别名、身份密钥或权限。</p></details><p class="small-note">这些说明会出现在智能体的资源清单中;具体操作仍需单独授权。${cloudMode() ? '保存到当前账号的云端设备清单。' : '保存到本机清单;本轮尚未同步到云端。'}</p>${state.profileError ? `<p class="form-error" role="alert">${escapeHtml(state.profileError)}</p>` : ''}<button type="submit" class="button button-primary save-profile" ${disabled}>${state.profileSaving ? '正在保存…' : '保存设备说明'}</button></form>`;
93
+ }
94
+ async function saveDeviceProfile() {
95
+ if (state.profileSaving || !state.profileDraft) return;
96
+ const draft = { ...state.profileDraft }, deviceId = state.profileDevice;
97
+ const split = value => value.split(/[,,\n]/).map(v => v.trim()).filter(Boolean);
98
+ state.profileSaving = true; state.profileError = null; render();
99
+ try {
100
+ await api.saveDeviceProfile({ deviceId, revision: draft.revision, profile: { displayName: draft.displayName, type: draft.type, purpose: draft.purpose, tags: split(draft.tags), services: split(draft.services) } });
101
+ state.overview = await api.overview();
102
+ if (state.dialog === 'profile' && state.profileDevice === deviceId) state.dialog = null;
103
+ toast('设备说明已保存,智能体可在资源清单中读取。');
104
+ } catch (cause) {
105
+ if (state.dialog === 'profile' && state.profileDevice === deviceId) state.profileError = ({ invalid_device_profile: '请检查字数与数量:名称 80 字,用途 240 字,标签最多 8 个(每个 24 字),服务最多 12 个(每个 40 字)。', device_profile_conflict: '其他页面已修改此设备。请关闭后重新打开,核对最新说明再保存。', device_not_found: '设备已移除或撤销,请刷新列表。' })[cause.message] ?? '未能确认保存结果,请重新打开设备说明核对后再试。';
106
+ } finally { state.profileSaving = false; render(); }
107
+ }
108
+ function updateOverview(overview) {
109
+ state.overview = overview;
110
+ for (const [id, requestedAt] of Object.entries(state.pendingCollections)) {
111
+ const device = overview.devices.find(d => d.id === id), result = device?.collection;
112
+ if (result?.requestedAt === requestedAt && result.status === 'pending') continue;
113
+ delete state.pendingCollections[id];
114
+ toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} 已返回新的资源数据。` : '未收到本次新数据,请确认设备上的 Driver 和网络正常后重试。');
115
+ }
116
+ }
117
+ async function collectDevice(deviceId) {
118
+ const device = state.overview?.devices.find(d => d.id === deviceId);
119
+ if (!device || state.collectingDevice || !collectionAvailability(device).available) return;
120
+ state.collectingDevice = deviceId; state.toast = null; render();
121
+ try {
122
+ const result = await api.collectDevice(deviceId);
123
+ if (result.status === 'pending') state.pendingCollections[deviceId] = result.collection.requestedAt;
124
+ updateOverview(await api.overview());
125
+ if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} 已重新采集内存与磁盘。`);
126
+ } catch (cause) {
127
+ toast(({ driver_not_connected: '设备未回报,请先启动设备上的 Driver;刷新列表不会让离线设备上线。', driver_upgrade_required: '当前 Driver 尚不支持手动采集,更新设备 Driver 后重试。', collection_rate_limited: '采集过于频繁,请稍等后重试。', collection_requires_local_ssh_check: '请在管理电脑检查这台 SSH 设备,云端不会替你执行 SSH。', collection_unavailable: '当前设备不支持采集,请在宿主运行 Driver。' })[cause.message] ?? '未能确认本次采集,请刷新列表核对时间;旧数据不会标为新数据。');
128
+ } finally { state.collectingDevice = null; render(); }
76
129
  }
77
130
  function endpointText(endpoint) {
78
131
  return `${endpoint.host.includes(':') ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}`;
@@ -173,6 +226,32 @@ function renderConnection() {
173
226
  return `<section class="driver-summary" aria-label="本机 Driver"><span class="local-dot"></span><strong>${container ? '容器 Driver 已运行' : '本机已作为 Driver'}</strong><p>${container ? '宿主设备不可见' : '已有 SSH 可直接导入'}</p><button class="text-button initialize-entry" data-action="start">开始使用</button></section>`;
174
227
  }
175
228
  function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
229
+ function openAgentInstall() {
230
+ const origin = cloudMode() ? state.overview.controlOrigin : null;
231
+ state.installMode = origin ? origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted' : 'local';
232
+ state.installOrigin = origin && origin !== OFFICIAL_CONTROL_ORIGIN ? origin : '';
233
+ state.toast = null; state.dialog = 'agent-install';
234
+ }
235
+ function installDraft() {
236
+ try { return { text: agentInstallPrompt({ mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
237
+ catch (error) { return { text: '', error: error.message }; }
238
+ }
239
+ function agentInstallBody() {
240
+ const draft = installDraft();
241
+ return `<p class="modal-intro">复制一次,交给正在这台电脑上运行的 Agent。它会检查环境、安装本机 Driver,并配置智能体连接。</p><div class="method-tabs install-mode-tabs" role="group" aria-label="安装方式">${[['local', '本机使用'], ['official', '官方托管'], ['self-hosted', '自建服务']].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? '无需账号。本机面板与 MCP 都在电脑上,默认不申请云端绑定。' : state.installMode === 'official' ? '使用你的 App Services 账号管理设备,官方服务提供面板与加密中转;Driver 和私钥仍在设备上。' : '接入已经部署的自建控制面。这里不自动部署服务器或迁移已有绑定。'}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">自建控制面的 HTTPS 地址</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>登录系统后在后台运行(用户级服务)</label><ol class="install-flow"><li>Agent 检查环境并安装</li><li>${state.installMode === 'local' ? '无需云端配对' : '你登录账号并核对绑定'}</li><li>Agent 核验面板、资源和 MCP</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} 复制给 Agent</button><details class="install-details"><summary>查看完整安装说明</summary><textarea id="agent-install-text" aria-label="交给 Agent 的安装说明" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">支持 macOS / Linux,需要 Node.js 24。说明固定使用 0.2.0-beta.2。本页只复制说明;安装由你交给的 Agent 在本机执行。</p>`;
242
+ }
243
+ async function copyAgentInstall() {
244
+ const field = root.querySelector('#agent-install-text');
245
+ if (!field?.value) return;
246
+ let copied = false;
247
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
248
+ if (!copied) {
249
+ root.querySelector('.install-details').open = true; field.focus(); field.select();
250
+ try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
251
+ }
252
+ toast(copied ? '已复制。粘贴给目标电脑上的 Agent;需要绑定时由你登录并核对。' : '请手动复制已选中的安装说明。');
253
+ if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
254
+ }
176
255
  function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
177
256
  function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
178
257
  function managementBody() {
@@ -239,7 +318,7 @@ function logCommandFields() {
239
318
  return `<div class="plain-panel"><h3>生成授权命令</h3><label class="field-label" for="log-manager">管理电脑(运行智能体)</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">目标设备(日志所在)</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">目标上的日志绝对路径</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">日志标签</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??'核对两端完整指纹后分别执行;60分钟有效。')}</p><label class="field-label" for="log-trust-command">在管理电脑运行</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>复制管理电脑命令</button><label class="field-label" for="log-grant-command">在目标设备运行</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>复制目标授权命令</button><p class="small-note">不会在网页执行或授予权限。日志路径只用于本页生成命令,不提交到云端或写入浏览器存储。</p></div>`;
240
319
  }
241
320
  function secureHelpBody() {
242
- return `<p class="modal-intro">先将管理电脑与目标设备接入同一个账号。绑定只回报资源;以下授权在各自电脑的终端完成。</p>${logCommandFields()}<ol class="simple-steps"><li>两台电脑固定安装 <code>npm install -g device-center@0.2.0-beta.1</code>,保持 Driver 运行。旧版身份先运行 <code>device-center access enable --confirm</code>。</li><li>运行 <code>device-center access peers</code>,通过可信渠道核对两台电脑的完整指纹。</li><li>管理电脑信任目标;目标只授予指定日志,有效期默认 60 分钟。</li></ol><details open><summary>在管理电脑运行</summary><code class="block-code">device-center access trust --peer 目标完整指纹 --confirm</code></details><details open><summary>在目标设备运行</summary><code class="block-code">device-center access grant --peer 管理电脑完整指纹 --log /绝对路径/app.log --label app --minutes 60 --confirm</code></details><p>授权完成后,让本机智能体调用 <code>device_center_read_log</code>,传入目标 fingerprint 与 label。每次最多 64KiB,返回实际访问路径。</p><details><summary>可选:同局域网优先</summary><p>目标执行 <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>;管理电脑重新 trust,并增加 <code>--lan 192.168.1.10 --port 5181</code>。替换为已确认的目标地址;不会扫描网段或修改防火墙。</p><p>网络不可达时走云端中转;证书、身份或权限错误直接停止。两种路径都需要控制面在线核验设备是否已撤销。</p></details><p class="small-note">目标设备可运行 <code>device-center access revoke --peer 管理电脑完整指纹 --confirm</code> 随时撤销。私钥与日志授权路径留在本机;云端仅转发 TLS 密文。Windows 原生 Agent 尚未提供。</p>`;
321
+ return `<p class="modal-intro">先将管理电脑与目标设备接入同一个账号。绑定只回报资源;以下授权在各自电脑的终端完成。</p>${logCommandFields()}<ol class="simple-steps"><li>两台电脑固定安装 <code>npm install -g device-center@0.2.0-beta.2</code>,保持 Driver 运行。旧版身份先运行 <code>device-center access enable --confirm</code>。</li><li>运行 <code>device-center access peers</code>,通过可信渠道核对两台电脑的完整指纹。</li><li>管理电脑信任目标;目标只授予指定日志,有效期默认 60 分钟。</li></ol><details open><summary>在管理电脑运行</summary><code class="block-code">device-center access trust --peer 目标完整指纹 --confirm</code></details><details open><summary>在目标设备运行</summary><code class="block-code">device-center access grant --peer 管理电脑完整指纹 --log /绝对路径/app.log --label app --minutes 60 --confirm</code></details><p>授权完成后,让本机智能体调用 <code>device_center_read_log</code>,传入目标 fingerprint 与 label。每次最多 64KiB,返回实际访问路径。</p><details><summary>可选:同局域网优先</summary><p>目标执行 <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>;管理电脑重新 trust,并增加 <code>--lan 192.168.1.10 --port 5181</code>。替换为已确认的目标地址;不会扫描网段或修改防火墙。</p><p>网络不可达时走云端中转;证书、身份或权限错误直接停止。两种路径都需要控制面在线核验设备是否已撤销。</p></details><p class="small-note">目标设备可运行 <code>device-center access revoke --peer 管理电脑完整指纹 --confirm</code> 随时撤销。私钥与日志授权路径留在本机;云端仅转发 TLS 密文。Windows 原生 Agent 尚未提供。</p>`;
243
322
  }
244
323
  function cloudBindingBody() {
245
324
  const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
@@ -295,13 +374,13 @@ function residentBody() {
295
374
  }
296
375
  function renderDialog() {
297
376
  if (!state.dialog) return '';
298
- const title = { 'secure-help': '授权远程日志', setup: '开始使用', connect: '连接本机 Codex', 'cloud-connect': '云端面板 · 可选', add: cloudMode() ? '连接我的电脑' : '添加设备', initialize: '初始化 · 导入本机 SSH', resident: '让服务在后台运行', route: '连接方式' }[state.dialog];
299
- const body = state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
377
+ const title = { 'agent-install': '交给 Agent 安装', profile: '设备说明', 'secure-help': '授权远程日志', setup: '开始使用', connect: '连接本机 Codex', 'cloud-connect': '云端面板 · 可选', add: cloudMode() ? '连接我的电脑' : '添加设备', initialize: '初始化 · 导入本机 SSH', resident: '让服务在后台运行', route: '连接方式' }[state.dialog];
378
+ const body = state.dialog === 'agent-install' ? agentInstallBody() : state.dialog === 'profile' ? profileBody() : state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
300
379
  return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2><button class="icon-button" data-action="close" aria-label="关闭">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
301
380
  }
302
381
  function filteredDevices() {
303
382
  const needle = state.query.trim().toLocaleLowerCase();
304
- return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || `${d.name} ${d.platform}`.toLocaleLowerCase().includes(needle)));
383
+ return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || [d.name, d.reportedName, d.platform, DEVICE_TYPE_LABELS[d.profile?.type], d.profile?.purpose, ...(d.profile?.tags ?? []), ...(d.profile?.services ?? [])].join(' ').toLocaleLowerCase().includes(needle)));
305
384
  }
306
385
  function renderDevices() {
307
386
  if (state.loading) return '<div class="empty-state"><p>正在读取设备…</p></div>';
@@ -330,7 +409,7 @@ function render() {
330
409
  const focusAction = focused?.dataset.action;
331
410
  const focusId = oldDialog?.contains(focused) ? focused.id : null;
332
411
  const focusField = focused?.dataset.field;
333
- const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step']
412
+ const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
334
413
  .filter((key) => focused?.dataset[key])
335
414
  .map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
336
415
  const findFocus = (container) => focusId
@@ -342,16 +421,16 @@ function render() {
342
421
  root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
343
422
  <header class="topbar"><span>${cloudMode() ? '我的云端设备' : '本机工作空间'}</span><span class="environment-badge">${cloudMode() ? 'HTTPS · 已登录' : '本机 Driver'}</span></header>
344
423
  <main class="workspace">
345
- <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? '所有设备')}</h1><p>把自己的电脑,变成智能体可用的资源。</p></div><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? '管理连接' : '连接我的电脑' : '添加设备'}</button></section>
424
+ <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? '所有设备')}</h1><p>把自己的电脑,变成智能体可用的资源。</p></div><div class="heading-actions"><button class="button button-light" data-action="agent-install">交给 Agent 安装</button><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? '管理连接' : '连接我的电脑' : '添加设备'}</button></div></section>
346
425
  ${state.error ? '' : renderConnection()}
347
426
  <section class="list-section" aria-label="设备资源">
348
- <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="设备状态">${[['all', '全部', counts.total], ['online', '在线', counts.online], ['unknown', '未知', counts.unknown], ['expired', '待刷新', counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
349
- <div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="搜索设备" aria-label="搜索设备" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="刷新设备">${icon('refresh', 17)}</button></div>
427
+ <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="设备状态">${[['all', '全部', counts.total], ['online', '在线', counts.online], ['unknown', '未知', counts.unknown], ['expired', '需更新', counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
428
+ <div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="搜索设备" aria-label="搜索设备" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="刷新列表" title="只读取最新回报;需要重新采集时点击设备上的采集一次">${icon('refresh', 17)}</button></div>
350
429
  </div>
351
430
  <div class="device-grid" role="region" aria-label="设备列表" tabindex="0">${renderDevices()}</div>
352
- <p class="list-footnote">${cloudMode() ? '本机回报每 30 秒 · 超过 90 秒待刷新 · SSH 缓存超过 5 分钟待刷新' : '导入后检查 SSH · 快照超过 5 分钟待刷新 · 点击路径看详情'}</p>
431
+ <p class="list-footnote">${cloudMode() ? '设备约每 30 秒回报 · 停止回报时保留旧数据 · 支持手动采集' : '采集一次更新资源 · 设备说明可命名与标记用途 · 点击路径看详情'}</p>
353
432
  </section>
354
- <footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ? `中转 ${(state.overview.relayUsage.bytes / 1048576).toFixed(1)} / ${state.overview.relayUsage.limitBytes / 1048576} MiB · UTC 当日` : '资源采集 · 日志需单独授权'}</span><details class="status-help"><summary>状态说明</summary><p>${cloudMode() ? '未知:尚无资源回报。在线:管理电脑最近 90 秒内回报,且资源快照未过期。待刷新:管理电脑回报超过 90 秒,或 SSH 缓存超过 5 分钟;不代表密钥过期。SSH 快照由管理电脑手动检查后取得,云端刷新不会执行 SSH。回报路径为管理电脑主动连接云端 HTTPS,不是 SSH 隧道。云端不能执行远程任务或自动选路。' : '本机在线表示服务进程运行。SSH 在线表示近期取得资源快照;连通标记表示当前 SSH 路径检查成功。未取得资源的设备为未知。超过 5 分钟显示待刷新,只说明快照较早,不代表设备断线或密钥过期。刷新只读取现有结果,检查 SSH 才重新采集。自动选路、远程任务尚未实现。'}内存与磁盘分别采集;内存含系统缓存,磁盘为用户目录所在卷。</p></details></footer>
433
+ <footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ? `中转 ${(state.overview.relayUsage.bytes / 1048576).toFixed(1)} / ${state.overview.relayUsage.limitBytes / 1048576} MiB · UTC 当日` : '资源采集 · 日志需单独授权'}</span><details class="status-help"><summary>状态说明</summary><p>${cloudMode() ? '未知:尚无资源回报。在线:管理电脑最近 90 秒内回报,且资源快照未过期。未回报:超过 90 秒未收到 Driver 数据;请检查休眠、Driver 进程和网络。待采集:资源快照较早;均不代表密钥过期。采集一次只要求在线 Driver 重采资源,等待真实返回后才更新。SSH 快照需在管理电脑检查,云端不会执行 SSH。回报路径为管理电脑主动连接云端 HTTPS,不是 SSH 隧道。云端不能执行远程任务或自动选路。' : '本机在线表示服务进程运行。SSH 在线表示近期取得资源快照;连通标记表示当前 SSH 路径检查成功。未取得资源的设备为未知。超过 5 分钟显示待采集,只说明快照较早,不代表设备断线或密钥过期。刷新列表只读取现有结果,采集一次才重新读取资源;SSH 设备会使用当前配置进行只读检查。自动选路、远程任务尚未实现。'}内存与磁盘分别采集;内存含系统缓存,磁盘为用户目录所在卷。</p></details></footer>
355
434
  </main>
356
435
  </div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
357
436
  const dialog = root.querySelector('[role="dialog"]');
@@ -362,7 +441,7 @@ function render() {
362
441
  if (dialog) {
363
442
  dialog.scrollTop = dialogScroll;
364
443
  (oldDialog ? findFocus(dialog) ?? dialog.querySelector('button') : dialog.querySelector('button'))?.focus({ preventScroll: true });
365
- } else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ connect: 'connect', 'cloud-connect': 'cloud-connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
444
+ } else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'profile' ? `[data-action="profile"][data-device="${CSS.escape(state.profileDevice)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ 'agent-install': 'agent-install', connect: 'connect', 'cloud-connect': 'cloud-connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
366
445
  else findFocus(root)?.focus({ preventScroll: true });
367
446
  if (dialog) dialog.dataset.kind = state.dialog;
368
447
  }
@@ -373,10 +452,10 @@ function scheduleCloudRefresh() {
373
452
  cloudRefreshTimer = setTimeout(async () => {
374
453
  if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
375
454
  cloudRefreshBusy = true;
376
- try { state.overview = await api.overview(); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
455
+ try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
377
456
  catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? '自动刷新已暂停,请手动刷新' : '暂时无法刷新,正在重试'; }
378
457
  finally { cloudRefreshBusy = false; render(); }
379
- }, 15_000);
458
+ }, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
380
459
  }
381
460
  async function load() {
382
461
  checkRefreshFailures = 0;
@@ -386,7 +465,7 @@ async function load() {
386
465
  try {
387
466
  const [overview, setup, status] = await Promise.all([api.overview(), api.connectorSetup(), api.connectorStatus()]);
388
467
  if (version !== requestVersion) return;
389
- Object.assign(state, { overview, setup, status });
468
+ Object.assign(state, { setup, status }); updateOverview(overview);
390
469
  } catch { if (version === requestVersion) state.error = 'service_unavailable'; }
391
470
  finally { if (version === requestVersion) { state.loading = false; render(); } }
392
471
  }
@@ -433,7 +512,6 @@ async function copyField(kind) {
433
512
  const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
434
513
  const field = root.querySelector('#copy-field');
435
514
  if (!text || !field) return toast('地址尚未就绪,请先重新连接服务。');
436
- field.focus(); field.select();
437
515
  let copied = false;
438
516
  let timer;
439
517
  if (navigator.clipboard?.writeText) {
@@ -448,6 +526,9 @@ root.addEventListener('click', async (event) => {
448
526
  const button = event.target.closest('[data-action]');
449
527
  if (!button) return;
450
528
  const action = button.dataset.action;
529
+ if (action === 'agent-install') { openAgentInstall(); render(); return; }
530
+ if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
531
+ if (action === 'agent-install-copy') return copyAgentInstall();
451
532
  if (action === 'logout') { try { await api.logout(); location.replace('/login'); } catch { state.toast = '退出失败,请重试'; render(); } return; }
452
533
  if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
453
534
  if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
@@ -458,13 +539,15 @@ root.addEventListener('click', async (event) => {
458
539
  if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
459
540
  if (action === 'revoke-arm') { state.revokeId = button.dataset.device; render(); return; }
460
541
  if (action === 'revoke-cancel') { state.revokeId = null; render(); return; }
461
- if (action === 'close') { if (button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
542
+ if (action === 'close') { if (state.dialog === 'profile' && state.profileSaving || button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
462
543
  else if (action === 'start') return openSetup('start');
463
544
  else if (action === 'connect') return openSetup('connect', 1);
464
545
  else if (action === 'initialize') return openSetup('start', 2);
465
546
  else if (action === 'setup-step') return selectSetupStep(Number(button.dataset.step));
466
547
  else if (action === 'setup-finish') { state.dialog = null; state.toast = null; state.filter = 'all'; state.query = ''; state.project = 'all'; }
467
548
  else if (action === 'route') openConnections(button.dataset.device);
549
+ else if (action === 'profile') openDeviceProfile(button.dataset.device);
550
+ else if (action === 'collect-device') return collectDevice(button.dataset.device);
468
551
  else if (action === 'save-connections') return saveConnections();
469
552
  else if (action === 'clear-relay') state.connectionDraft.relayProvider = null;
470
553
  else if (['connect', 'resident', 'add', 'initialize'].includes(action)) { state.dialog = action; state.toast = null; state.checkMessage = null; if (['add', 'initialize'].includes(action)) { state.ssh = null; state.selectedAliases = []; state.sshError = null; render(); return loadSsh(); } }
@@ -491,6 +574,8 @@ root.addEventListener('click', async (event) => {
491
574
  render();
492
575
  });
493
576
  root.addEventListener('change', (event) => {
577
+ if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
578
+ if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
494
579
  if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
495
580
  if (['relayProvider', 'preference'].includes(event.target.dataset.field) && state.connectionDraft) { state.connectionDraft[event.target.dataset.field] = event.target.value; state.connectionError = null; render(); return; }
496
581
  if (event.target.dataset.field !== 'ssh-alias') return;
@@ -499,6 +584,15 @@ root.addEventListener('change', (event) => {
499
584
  render();
500
585
  });
501
586
  root.addEventListener('input', (event) => {
587
+ if (event.target.dataset.field === 'installOrigin') {
588
+ state.installOrigin = event.target.value;
589
+ const draft = installDraft();
590
+ root.querySelector('#agent-install-text').value = draft.text;
591
+ const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
592
+ root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
593
+ return;
594
+ }
595
+ if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
502
596
  if (event.target.dataset.field === 'customOrigin') {
503
597
  state.customOrigin = event.target.value;
504
598
  const { command, error } = cloudConnectDraft();
@@ -515,10 +609,14 @@ root.addEventListener('input', (event) => {
515
609
  root.querySelector('.device-grid').innerHTML = renderDevices();
516
610
  root.querySelector('.device-grid').scrollTop = 0;
517
611
  });
612
+ root.addEventListener('submit', (event) => {
613
+ if (event.target.id !== 'device-profile-form') return;
614
+ event.preventDefault(); void saveDeviceProfile();
615
+ });
518
616
  root.addEventListener('keydown', (event) => {
519
617
  const dialog = root.querySelector('[role="dialog"]');
520
618
  if (!dialog) return;
521
- if (event.key === 'Escape') { state.dialog = null; state.toast = null; render(); return; }
619
+ if (event.key === 'Escape') { if (state.dialog === 'profile' && state.profileSaving) return; state.dialog = null; state.toast = null; render(); return; }
522
620
  if (event.key !== 'Tab') return;
523
621
  const controls = [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), summary, a[href]')].filter((el) => el.getClientRects().length);
524
622
  const first = controls[0], last = controls.at(-1);
package/src/onboarding.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export const OFFICIAL_CONTROL_ORIGIN = 'https://devices.owenshen.top';
2
- // This existing public CLI already supports pairing with either server mode.
3
- export const PUBLISHED_DRIVER_VERSION = '0.2.0-beta.1';
2
+ // Pin the CLI shipped with this release; never silently install a moving tag.
3
+ export const PUBLISHED_DRIVER_VERSION = '0.2.0-beta.2';
4
4
  export function connectCommand(value, shareSsh = false) {
5
5
  let url;
6
6
  try { url = new URL(value); } catch { throw new Error('请输入完整的 HTTPS 域名,例如 https://devices.example.com'); }
@@ -9,6 +9,50 @@ export function connectCommand(value, shareSsh = false) {
9
9
  // The allowlist excludes shell syntax. Quotes also work in POSIX shells and PowerShell.
10
10
  return `npx --yes device-center@${PUBLISHED_DRIVER_VERSION} connect --cloud '${url.origin}'${shareSsh ? ' --share-ssh' : ''} --confirm`;
11
11
  }
12
+
13
+ // A reproducible handoff, not an installer. Reading/copying it has no machine effects.
14
+ export function agentInstallPlan({ mode = 'local', origin, port = 5174, background = true } = {}) {
15
+ if (!['local', 'official', 'self-hosted'].includes(mode) || !Number.isInteger(port) || port < 1024 || port > 65535 || typeof background !== 'boolean') throw new Error('安装模式或本机端口无效。');
16
+ const controlOrigin = mode === 'local' ? null : mode === 'official' ? OFFICIAL_CONTROL_ORIGIN : origin;
17
+ if (controlOrigin) connectCommand(controlOrigin); // Same strict HTTPS and shell-safe origin validation.
18
+ if (mode === 'self-hosted' && !controlOrigin) throw new Error('请输入自建控制面的 HTTPS 地址。');
19
+ if (origin !== undefined && mode !== 'self-hosted') throw new Error('只有自建模式可以填写控制面地址。');
20
+ const cli = '"$HOME/.local/bin/device-center"';
21
+ const url = `http://127.0.0.1:${port}`;
22
+ return { mode, version: PUBLISHED_DRIVER_VERSION, controlOrigin, background, url, mcpUrl: `${url}/mcp`, commands: {
23
+ install: `npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@${PUBLISHED_DRIVER_VERSION}`,
24
+ version: `${cli} --version`, bindingStatus: `${cli} status`,
25
+ serviceStatus: `${cli} service status --port ${port}`, servicePlan: `${cli} service plan --port ${port}`,
26
+ serviceInstall: `${cli} service install --port ${port} --confirm`,
27
+ foreground: `${cli} start --port ${port}`,
28
+ pairingPlan: controlOrigin ? `${cli} plan --cloud '${controlOrigin}' --port ${port}` : null,
29
+ connect: controlOrigin ? `${cli} connect --cloud '${controlOrigin}' --port ${port} --confirm` : null,
30
+ uninstallService: `${cli} service uninstall --port ${port} --confirm`,
31
+ } };
32
+ }
33
+
34
+ export function agentInstallPrompt(options) {
35
+ const plan = agentInstallPlan(options), c = plan.commands;
36
+ return `请帮我在正在操作的这台电脑安装 Device Center,并接入这台电脑上的智能体。
37
+
38
+ 我的选择:${plan.mode === 'local' ? '本机使用,无需账号,不申请云端绑定' : plan.mode === 'official' ? '使用 Device Center 官方托管控制面和加密中转' : '接入我已经部署的自建控制面'}。
39
+ ${plan.controlOrigin ? `控制面:${plan.controlOrigin}。设备 Driver 仍在本机运行,身份私钥不托管到云端。\n` : ''}版本固定为 ${plan.version};${plan.background ? '安装用户级后台服务,登录系统后自动启动' : '仅前台运行,不安装后台服务'}。
40
+
41
+ 请按以下顺序执行并核验实际结果,不要只给我命令:
42
+ 1. 检查 macOS/Linux、Node.js >=24、npm 和当前智能体的 MCP 支持。Windows 原生 Driver 尚未支持,请说明限制并停止,不能假称安装成功。缺少 Node.js 时使用官方来源;遇到管理员权限或系统授权再让我处理。
43
+ 2. 先检查已有 device-center、绑定、用户服务及 ${plan.url}/api/health,保留原配置。CLI 已在固定位置时可运行 ${c.bindingStatus} 和 ${c.serviceStatus};不要打开身份私钥文件。端口被其他程序使用时不要停止它,改用空闲的已知本机端口并同步后续地址。已有服务或绑定时先核对版本、控制面和启动方式,匹配则复用;冲突时解释并让我选择,不能覆盖、解绑或重复安装。
44
+ 3. 对全新安装使用用户可写的固定位置,无需 sudo:
45
+ ${c.install}
46
+ ${c.version}
47
+ 不修改全局 npm 配置或 shell 启动文件;后续使用上面的绝对命令路径。
48
+ 4. ${plan.background ? `先查看并核对后台服务计划:\n ${c.servicePlan}\n 确认是用户级、只监听本机回环后安装:\n ${c.serviceInstall}\n 再核验:${c.serviceStatus}\n 若配置已存在或健康检查失败,保留现场并排查,不自动清理重装。Linux 需要可用的 systemd --user,macOS 需要当前登录用户的 launchd;不支持时如实提示可改用前台模式。` : `启动并保持前台进程:\n ${plan.controlOrigin ? '先在下一步完成 connect;它会在批准后启动本机 Driver。' : c.foreground}\n 关闭网页不会停止进程;关闭终端会停止回报。`}
49
+ 5. ${plan.controlOrigin ? `查看云端回报范围,然后申请绑定:\n ${c.pairingPlan}\n ${c.connect}\n 请我在 ${plan.controlOrigin} 登录自己的账号,在本机终端核对完整公钥指纹并手动批准绑定。不要代我批准新增设备访问权限,不读取账号密码、cookie 或私钥。配对码留在本机终端,不粘贴到智能体对话、脚本或链接;若工具会把配对码自动记录进对话,让我仅在本机终端执行这条 connect 命令,你继续完成其他检查。批准后核验设备确实回报资源;不能仅凭本地 configured=true 声称云端连通。` : '不执行 connect、不生成设备身份或申请账号。若发现已有云端绑定,先说明 start/后台服务可能恢复它;不能把已有绑定伪装成本机专用模式。'}
50
+ 6. 读取 ${plan.url}/api/connector/setup,使用当前智能体支持的官方 MCP 配置接口添加 Streamable HTTP 地址 ${plan.mcpUrl}。复用一致的配置,不覆盖其他 MCP。若当前工具不能配置,只让我粘贴这一个地址;不要编造已接通。
51
+ 7. 核验 /api/health、/api/overview 的本机真实内存与磁盘,以及智能体实际调用 device_center_list_resources。最后报告版本、面板/MCP 地址、前台或后台状态、云端绑定结果(如适用)与尚未完成的步骤。后台卸载命令仅供我需要时使用:${c.uninstallService},默认保留数据和身份。
52
+
53
+ 默认不导入或连接 SSH 主机、不分享 SSH、不扫描局域网、不开放入站端口、不配置隧道、不授予日志/命令权限。安装和配对不等于远程操作授权;后续访问目标设备需另行授权。
54
+ 此说明固定使用 ${plan.version},包含设备说明与手动采集。已有旧版需先核对升级;不能假称未运行的设备已接通。`;
55
+ }
12
56
  export function logAccessCommands({ manager, target, path, label = 'app' }) {
13
57
  if (![manager, target].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)) || manager === target) throw new Error('请选择两台不同的已绑定设备。');
14
58
  if (typeof path !== 'string' || !path.startsWith('/') || path.length > 4096 || /[\x00-\x1f\x7f]/.test(path)) throw new Error('填写目标设备上日志的绝对路径。');
package/src/start.css ADDED
@@ -0,0 +1,47 @@
1
+ :root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #25392d; background: #f5f7f2; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; }
4
+ a { color: #2f6d4c; text-underline-offset: 3px; }
5
+ header, main, footer { width: min(860px, calc(100% - 40px)); margin: 0 auto; }
6
+ header { display: flex; justify-content: space-between; align-items: center; padding: 24px 0; gap: 16px; font-size: 13px; }
7
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; text-decoration: none; }
8
+ .brand span { color: #25392d; }
9
+ .hero { padding: 48px 0 38px; }
10
+ .eyebrow { font-size: 12px; color: #70816d; letter-spacing: .8px; }
11
+ h1 { margin: 18px 0 20px; font-size: clamp(34px, 6vw, 54px); line-height: 1.2; letter-spacing: -1.8px; }
12
+ .lead { max-width: 630px; font-size: 17px; line-height: 1.8; color: #62705e; }
13
+ .platform { font-size: 12px; color: #70816d; }
14
+ section:not(.hero) { padding: 26px 0; border-top: 1px solid #dae3d6; }
15
+ h2 { margin: 0 0 22px; font-size: 19px; line-height: 1.6; }
16
+ .step { display: inline-grid; place-items: center; width: 28px; height: 28px; margin-right: 7px; border-radius: 50%; background: #e3eddd; font-size: 13px; }
17
+ .choices, .checks { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18
+ article { display: flex; flex-direction: column; padding: 22px; border: 1px solid #dce5d7; border-radius: 12px; background: #fff; }
19
+ h3 { margin: 0 0 10px; font-size: 16px; }
20
+ p, li { font-size: 14px; line-height: 1.85; color: #62705e; }
21
+ article p { flex: 1; margin: 0 0 20px; }
22
+ button { min-height: 42px; border: 1px solid #c9d8c4; border-radius: 8px; padding: 10px 14px; color: #2f6d4c; background: #f4f8ef; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
23
+ button.primary { color: white; background: #356c4c; border-color: #356c4c; }
24
+ button:disabled { opacity: .6; }
25
+ button:hover { filter: brightness(.96); }
26
+ :focus-visible { outline: 2px solid #5c965d; outline-offset: 3px; }
27
+ .hint { font-size: 12px; }
28
+ details { margin-top: 18px; font-size: 13px; }
29
+ summary { color: #436440; cursor: pointer; line-height: 1.8; }
30
+ .custom label { display: block; margin: 14px 0 8px; }
31
+ .custom-row { display: flex; gap: 10px; }
32
+ input, textarea { width: 100%; min-width: 0; border: 1px solid #cbd7c5; border-radius: 8px; padding: 10px; background: white; color: #35482f; font: inherit; }
33
+ textarea { margin-top: 12px; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.7; resize: vertical; }
34
+ .custom-row button { flex-shrink: 0; }
35
+ #copy-result { min-height: 24px; margin: 12px 0; font-size: 13px; }
36
+ .success { color: #2f6d4c; }
37
+ .error { color: #9e3f32; }
38
+ ol { padding-left: 24px; }
39
+ li { padding: 0 0 12px 5px; }
40
+ .checks div { padding: 10px 0; }
41
+ .checks p { margin: 0; }
42
+ code { overflow-wrap: anywhere; font-size: 12px; }
43
+ aside { margin: 10px 0 32px; padding: 20px 22px; border: 1px solid #dce5d7; border-radius: 10px; background: #edf2e8; font-size: 14px; }
44
+ aside p { margin: 10px 0 0; font-size: 12px; }
45
+ footer { display: flex; flex-wrap: wrap; gap: 14px 22px; padding: 14px 0 32px; font-size: 12px; }
46
+ footer span { width: 100%; color: #7b8875; }
47
+ @media (max-width: 560px) { .choices, .checks { grid-template-columns: 1fr; gap: 12px; } .hero { padding-top: 25px; } .custom-row { flex-direction: column; } header { font-size: 12px; } h2 { font-size: 17px; } }
package/src/start.js ADDED
@@ -0,0 +1,23 @@
1
+ import { agentInstallPrompt } from './onboarding.js';
2
+
3
+ const text = document.querySelector('#install-instructions');
4
+ const result = document.querySelector('#copy-result');
5
+ text.value = agentInstallPrompt();
6
+ document.addEventListener('click', async event => {
7
+ const button = event.target.closest('button[data-mode]');
8
+ if (!button || button.disabled) return;
9
+ const mode = button.dataset.mode;
10
+ try {
11
+ text.value = agentInstallPrompt({ mode, ...(mode === 'self-hosted' ? { origin: document.querySelector('#custom-origin').value.trim() } : {}) });
12
+ } catch (error) { text.value = ''; result.textContent = error.message; result.className = 'error'; return; }
13
+ button.disabled = true;
14
+ let copied = false;
15
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text.value); copied = true; } } catch { /* Keep manual copy available. */ }
16
+ if (!copied) {
17
+ document.querySelector('#instruction-details').open = true; text.focus(); text.select();
18
+ try { copied = document.execCommand('copy'); } catch { /* Selection is the fallback. */ }
19
+ }
20
+ result.className = copied ? 'success' : '';
21
+ result.textContent = copied ? '已复制。粘贴给本机 Agent;托管绑定时由你登录并核对。' : '请手动复制下方已选中的完整说明。';
22
+ button.disabled = false;
23
+ });
package/src/styles.css CHANGED
@@ -35,6 +35,13 @@ h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -.6px; font-weight: 680;
35
35
  .button-primary:hover { background: #1b6444; }
36
36
  .button-light { color: #607167; border-color: #dfe7df; background: #fff; }
37
37
  .button-light:hover { background: #f6f9f4; }
38
+ .heading-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; min-width: 0; }
39
+ .install-flow { margin: 14px 0; padding-left: 20px; color: #657660; font-size: 12px; line-height: 1.9; }
40
+ #agent-install-text { max-height: 170px; resize: vertical; margin-top: 10px; font-size: 11px; line-height: 1.7; color: #51644d; }
41
+ .install-copy { width: 100%; margin-top: 2px; }
42
+ .install-details { margin-top: 14px; color: #657660; font-size: 12px; }
43
+ #install-origin { width: 100%; border: 1px solid #dce4d8; padding: 9px 10px; border-radius: 7px; font: inherit; font-size: 12px; }
44
+ .install-mode-tabs { margin-bottom: 12px; }
38
45
  .text-button { border: 0; background: none; color: #688a73; }
39
46
  .icon-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; border: 1px solid var(--line); background: #fff; border-radius: 8px; color: #839486; flex: 0 0 auto; }
40
47
  .connection-card { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 20px; box-shadow: 0 8px 26px #20352405; }
@@ -67,6 +74,19 @@ h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -.6px; font-weight: 680;
67
74
  .device-card { flex: 0 0 auto; display: grid; grid-template-columns: minmax(200px, 1.1fr) minmax(220px, 1fr) 130px; align-items: center; gap: 16px; background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
68
75
  .device-identity { min-width: 0; }
69
76
  .device-actions { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
77
+ .device-description { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin: 7px 0 0 33px; min-width: 0; }
78
+ .device-type, .device-tag { border-radius: 4px; padding: 2px 5px; font-size: 10px; color: #59675c; background: #f1f4ef; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
+ .device-type { color: #386842; background: #e9f3e6; }
80
+ .device-description p { flex-basis: 100%; margin: 2px 0 0; color: #69736a; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
+ .collection-hint { font-size: 10px; color: #7b8178; line-height: 1.4; }
82
+ .profile-edit { align-self: flex-start; font-size: 11px; }
83
+ .profile-fields { display: grid; grid-template-columns: minmax(0, 2fr) minmax(105px, 1fr); gap: 12px; }
84
+ #device-profile-form .field-label { display: block; margin: 14px 0 0; }
85
+ #device-profile-form input, #device-profile-form select, #device-profile-form textarea { display: block; width: 100%; margin: 7px 0 0; border: 1px solid #dce4d8; border-radius: 7px; padding: 9px 10px; font: inherit; font-size: 12px; background: white; color: #344137; }
86
+ #device-profile-form textarea { resize: vertical; min-height: 64px; }
87
+ .profile-identity { margin: 16px 0; font-size: 11px; color: #6a7569; }
88
+ .profile-identity summary { cursor: pointer; }
89
+ .save-profile { margin-top: 5px; width: 100%; }
70
90
  .driver-summary { grid-row: 2; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 6px 0; }
71
91
  .driver-summary strong { font-size: 11px; color: #407553; }
72
92
  .driver-summary p { margin: 0; font-size: 11px; color: #6d7d70; line-height: 1.5; }
@@ -221,6 +241,8 @@ details p { font-size: 12px; color: #8da37c; line-height: 1.8; }
221
241
  .status-help p { position: absolute; z-index: 3; right: 0; bottom: calc(100% + 8px); width: min(300px, calc(100vw - 28px)); max-height: 50dvh; overflow: auto; padding: 14px; margin: 0; font-size: 11px; color: #63765d; background: #fff; border: 1px solid var(--line); border-radius: 9px; box-shadow: 0 8px 30px #20352416; }
222
242
  @media (max-width: 960px) { .device-card { grid-template-columns: minmax(0, 1fr) 128px; gap: 9px 16px; } .device-identity { grid-column: 1 / -1; } }
223
243
  @media (max-width: 760px) {
244
+ .heading-actions { max-width: 140px; gap: 5px; }
245
+ .heading-actions > button { min-height: 30px; padding: 6px 8px; font-size: 11px; }
224
246
  .app-shell { grid-template-columns: 140px minmax(0, 1fr); }
225
247
  .sidebar { padding: 18px 10px; }
226
248
  .brand { gap: 7px; } .brand-mark { width: 28px; height: 28px; } .brand strong { font-size: 11px; } .brand small { font-size: 9px; }
package/start.html ADDED
@@ -0,0 +1,45 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="description" content="把自己的闲置电脑接入本机智能体。复制安装说明交给 Agent,选择本机使用、官方托管或自建服务。">
7
+ <title>开始试用 · Device Center</title>
8
+ <link rel="stylesheet" href="/src/start.css">
9
+ </head>
10
+ <body>
11
+ <header><a class="brand" href="/start">DC <span>Device Center</span></a><a href="/login">进入设备面板 ↗</a></header>
12
+ <main>
13
+ <section class="hero">
14
+ <p class="eyebrow">公开试用 · 0.2.0-beta.2 · MIT</p>
15
+ <h1>让你的 Agent<br>看见自己的闲置电脑。</h1>
16
+ <p class="lead">查看设备资源、记住每台电脑的用途,并在单独授权后读取指定日志。把安装交给本机 Agent,从一台电脑开始。</p>
17
+ <p class="platform">macOS / Linux · Node.js 24+ · Windows 原生版暂未支持</p>
18
+ </section>
19
+ <section aria-labelledby="install-title">
20
+ <h2 id="install-title"><span class="step">1</span> 选一种方式,复制给本机 Agent</h2>
21
+ <div class="choices">
22
+ <article><h3>本机使用</h3><p>本机面板和 MCP,无需账号。适合先了解自己的电脑与已有设备资源。</p><button data-mode="local">复制本机安装说明</button></article>
23
+ <article><h3>官方托管</h3><p>用自己的账号管理设备,使用官方加密中转。Driver 和私钥仍留在设备上。</p><button class="primary" data-mode="official">复制托管安装说明</button></article>
24
+ </div>
25
+ <p class="hint">粘贴给<strong>正在目标电脑上运行、能安装软件的 Agent</strong>。它会检查已有配置、安装固定版本、配置用户级后台服务及 MCP,再核验真实结果。网页本身不会安装软件。</p>
26
+ <details class="custom"><summary>已有自己的控制面?</summary><label for="custom-origin">自建控制面的 HTTPS 地址</label><div class="custom-row"><input id="custom-origin" type="url" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false"><button data-mode="self-hosted">复制自建接入说明</button></div><p class="hint">连接已经部署的服务;不会替你部署服务器或迁移原有绑定。</p></details>
27
+ <p id="copy-result" role="status" aria-live="polite"></p>
28
+ <details id="instruction-details"><summary>查看或手动复制完整说明</summary><textarea id="install-instructions" aria-label="Agent 安装说明" rows="9" readonly spellcheck="false"></textarea></details>
29
+ </section>
30
+ <section aria-labelledby="pair-title">
31
+ <h2 id="pair-title"><span class="step">2</span> 使用托管时,登录并认领自己的电脑</h2>
32
+ <ol><li>按 Agent 提示打开<a href="/login">官方设备面板</a>,使用自己的 App Services/个人站账号登录。没有账号时在账号页注册。</li><li>在面板输入本机终端显示的五分钟配对码,核对<strong>完整公钥指纹</strong>后批准。密码、私钥和配对码不要发给别人或贴到 Agent 对话。</li><li>在另一台自己的电脑重复安装并绑定同一账号。没有真实回报的设备会显示未知或未回报。</li></ol>
33
+ <p class="hint">本机模式跳过此步。绑定只允许已说明的资源回报;读取日志需要目标设备另外授权。</p>
34
+ </section>
35
+ <section aria-labelledby="try-title">
36
+ <h2 id="try-title"><span class="step">3</span> 试用时看这四件事</h2>
37
+ <div class="checks"><div><h3>资源真实吗?</h3><p>分别查看内存、磁盘和最近采集时间。</p></div><div><h3>设备容易辨认吗?</h3><p>在“设备说明”填写名称、用途、标签。服务清单由你登记。</p></div><div><h3>更新状态顺畅吗?</h3><p>点“采集一次”;休眠或停止的 Driver 不会被伪装在线。旧 Driver 需要更新。</p></div><div><h3>Agent 能读清单吗?</h3><p>让本机 Agent 调用 <code>device_center_list_resources</code>,核对设备名称与用途。</p></div></div>
38
+ <details><summary>遇到问题先查什么?</summary><p>确认 Node.js 为 24+、Driver 正在运行、电脑已唤醒、网络及系统时间正常。端口被占用时改用空闲本机端口,不停止其他程序。云端“未回报”表示近期没收到数据,不是私钥过期。</p><p>反馈时说明系统、版本、所选模式、失败步骤和去掉敏感信息的错误。不要附私钥、配对码、cookie 或日志正文。</p></details>
39
+ </section>
40
+ <aside><strong>这版的范围</strong><p>支持真实资源、设备说明、手动采集和经单独授权的日志尾读。暂不提供任意远程命令、通用文件传输、Windows 原生 Driver、自动网络扫描、离线局域网授权或本机/云端说明同步。</p><p>官方中转为试用额度:每账号每日 100 MiB(UTC),每方向 16 KiB/s;不是无限带宽服务。服务与应用为手动登记,未经自动检测。</p></aside>
41
+ </main>
42
+ <footer><a href="/install/agent.md">Agent 完整安装指南</a><a href="/install/trial.md">文字版试用指南</a><a href="https://www.npmjs.com/package/device-center/v/0.2.0-beta.2" target="_blank" rel="noopener noreferrer">MIT npm 源码包 ↗</a><span>本机安装 · 私钥留在设备 · 操作单独授权</span></footer>
43
+ <script type="module" src="/src/start.js"></script>
44
+ </body>
45
+ </html>