@wendongfly/myhi 1.3.202 → 1.3.203

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/bin/daemon.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'url';
9
9
  import { dirname, join } from 'path';
10
10
  import { writeFileSync, readFileSync, mkdirSync, unlinkSync } from 'fs';
11
11
  import { homedir } from 'os';
12
- import { checkInstall, upgradeStatusFile, npmUpgradeCmd } from './install-check.js';
12
+ import { checkInstall, upgradeStatusFile, npmUpgradeCmd, REGISTRIES, registryName } from './install-check.js';
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
15
15
  const __dirname = dirname(__filename);
@@ -75,7 +75,7 @@ function startServer() {
75
75
  });
76
76
 
77
77
  child.on('message', (msg) => {
78
- if (msg?.type === 'upgrade') doUpgrade(msg.target);
78
+ if (msg?.type === 'upgrade') doUpgrade(msg.target, msg.candidates);
79
79
  else if (msg?.type === 'restart') doRestart();
80
80
  });
81
81
 
@@ -123,31 +123,40 @@ function doRestart() {
123
123
  // 停服升级:先让 server 退出(释放 node-pty 的 pty.node,Windows 上它被占用时 npm 覆盖会 EBUSY、
124
124
  // 装出半成品),再 npm install,装完校验文件齐全 + 版本对,最后拉起 server。
125
125
  // 不论成败都拉起 server(失败时 npm 一般已回滚,旧版照常跑);结果写 upgrade-status.json 给网页看。
126
- function doUpgrade(target) {
126
+ // candidates:server 算好的下载源顺序 [{registry, version}](官方 → 国内镜像),装失败就换下一个
127
+ function doUpgrade(target, candidates) {
127
128
  if (upgrading) { log('升级已在进行中,忽略'); return; }
128
129
  upgrading = true;
129
130
  const from = (() => { try { return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version; } catch { return null; } })();
130
131
  log(`收到升级请求(${from} → ${target || 'latest'}),先停 server 释放文件占用...`);
131
- stopChild(() => {
132
- const cmd = npmUpgradeCmd(pkgRoot); // 同一套 Node 的 npm + 装回 myhi 当前目录(见 install-check.js)
133
- log('执行: ' + cmd);
132
+ const list = Array.isArray(candidates) && candidates.length ? candidates : [{ registry: REGISTRIES.official, version: target }];
133
+ const fails = [];
134
+ stopChild(() => tryNext(0));
135
+ function tryNext(i) {
136
+ if (i >= list.length) return finish({ ok: false, from, to: target || null, error: fails.join('\n\n') });
137
+ const c = list[i];
138
+ const cmd = npmUpgradeCmd(pkgRoot, c.registry); // 同一套 Node 的 npm + 装回 myhi 当前目录(见 install-check.js)
139
+ log(`从${registryName(c.registry)}安装,执行: ` + cmd);
134
140
  exec(cmd, { timeout: 600000, encoding: 'utf8', windowsHide: true }, (err, stdout, stderr) => {
135
- let result;
136
141
  if (err) {
137
- const tail = String(stderr || stdout || err.message).split('\n').filter((l) => l.trim()).slice(-5).join('\n');
138
- result = { ok: false, from, to: target || null, error: 'npm install 失败:\n' + tail };
139
- } else {
140
- const chk = checkInstall(pkgRoot, target);
141
- result = chk.ok ? { ok: true, from, to: chk.version } : { ok: false, from, to: target || null, error: chk.error };
142
+ const tail = String(stderr || stdout || err.message).split('\n').filter((l) => l.trim()).slice(-4).join('\n');
143
+ fails.push(`${registryName(c.registry)}:npm install 失败\n${tail}`);
144
+ return tryNext(i + 1);
142
145
  }
143
- result.at = Date.now();
144
- log(result.ok ? `升级成功:${from} → ${result.to}` : `升级失败:${result.error}`);
145
- try { writeFileSync(upgradeStatusFile(configDir), JSON.stringify(result, null, 2)); } catch {}
146
- upgrading = false;
147
- crashCount = 0;
148
- startServer();
146
+ const chk = checkInstall(pkgRoot, c.version || target);
147
+ if (!chk.ok) { fails.push(`${registryName(c.registry)}:${chk.error}`); return tryNext(i + 1); }
148
+ finish({ ok: true, from, to: chk.version });
149
149
  });
150
- });
150
+ }
151
+ // 不论成败都拉起 server(失败时 npm 一般已回滚,旧版照常跑);结果写 upgrade-status.json 给网页看
152
+ function finish(result) {
153
+ result.at = Date.now();
154
+ log(result.ok ? `升级成功:${from} → ${result.to}` : `升级失败:${result.error}`);
155
+ try { writeFileSync(upgradeStatusFile(configDir), JSON.stringify(result, null, 2)); } catch {}
156
+ upgrading = false;
157
+ crashCount = 0;
158
+ startServer();
159
+ }
151
160
  }
152
161
 
153
162
  function cleanup() {
@@ -41,11 +41,67 @@ export function globalPrefixOf(pkgRoot) {
41
41
  return basename(dirname(nm)) === 'lib' ? dirname(dirname(nm)) : null;
42
42
  }
43
43
 
44
- export function npmUpgradeCmd(pkgRoot) {
44
+ // registry:本次用哪个下载源(见下面 upgradeCandidates);装的是该源上的 latest
45
+ export function npmUpgradeCmd(pkgRoot, registry = REGISTRIES.official) {
45
46
  const win = process.platform === 'win32';
46
47
  const local = join(dirname(process.execPath), win ? 'npm.cmd' : 'npm');
47
48
  const npm = existsSync(local) ? `"${local}"` : (win ? 'npm.cmd' : 'npm');
48
49
  const prefix = globalPrefixOf(pkgRoot);
49
- // 显式官方 registry:更新检查读官方源,安装若走镜像(同步延迟)会「一直提示新版却装到旧版」
50
- return `${npm} install -g @wendongfly/myhi@latest --registry https://registry.npmjs.org${prefix ? ` --prefix "${prefix}"` : ''}`;
50
+ return `${npm} install -g @wendongfly/myhi@latest --registry ${registry}${prefix ? ` --prefix "${prefix}"` : ''}`;
51
51
  }
52
+
53
+ // ── 下载源:官方 / 国内镜像 自动兼容(myhi 升级、Claude 升级共用)──────────────
54
+ // 国内网络常连不上 / 很慢 registry.npmjs.org;而镜像同步有延迟(几分钟~几小时)。
55
+ // 「查新版」和「安装」必须对齐到同一个源,否则会「提示有新版、装上的却是旧版」。
56
+ // config.json 的 npmRegistry:auto(默认:先官方,连不上 / 装失败再国内镜像)/ official / mirror / 自定义 URL
57
+ export const REGISTRIES = { official: 'https://registry.npmjs.org', mirror: 'https://registry.npmmirror.com' };
58
+ export const registryName = (r) => r === REGISTRIES.official ? '官方源' : r === REGISTRIES.mirror ? '国内镜像' : r;
59
+
60
+ export function registryOrder(pref) {
61
+ if (pref === 'official') return [REGISTRIES.official];
62
+ if (pref === 'mirror') return [REGISTRIES.mirror];
63
+ if (pref && /^https?:\/\//.test(pref)) return [String(pref).replace(/\/+$/, '')];
64
+ return [REGISTRIES.official, REGISTRIES.mirror];
65
+ }
66
+
67
+ export async function fetchLatest(registry, pkg, ms = 8000) {
68
+ try {
69
+ const r = await fetch(`${registry}/${pkg}/latest`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
70
+ return r.ok ? (await r.json()).version || null : null;
71
+ } catch { return null; }
72
+ }
73
+
74
+ export function versionGt(a, b) {
75
+ const pa = String(a || '').split(/[.-]/).map(Number), pb = String(b || '').split(/[.-]/).map(Number);
76
+ for (let i = 0; i < 3; i++) { const x = pa[i] || 0, y = pb[i] || 0; if (x !== y) return x > y; }
77
+ return false;
78
+ }
79
+
80
+ /** 查最新版:按顺序问各源,第一个答上来的为准 → { version, registry } | null */
81
+ export async function latestVersion(pkg, pref) {
82
+ for (const registry of registryOrder(pref)) {
83
+ const version = await fetchLatest(registry, pkg);
84
+ if (version) return { version, registry };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * 升级要尝试的源:比当前版本新的才算(镜像没同步到就跳过它)。
91
+ * → { candidates: [{registry, version}], reachable: [...], note }
92
+ * candidates 为空时 note 说明原因:都连不上 / 已是最新 / 镜像还没同步
93
+ */
94
+ export async function upgradeCandidates(pkg, current, pref) {
95
+ const order = registryOrder(pref);
96
+ const got = await Promise.all(order.map(async (registry) => ({ registry, version: await fetchLatest(registry, pkg) })));
97
+ const reachable = got.filter((g) => g.version);
98
+ const candidates = reachable.filter((g) => !current || versionGt(g.version, current));
99
+ let note = null;
100
+ if (!candidates.length) {
101
+ if (!reachable.length) note = `${order.map(registryName).join('和')}${order.length > 1 ? '都' : ''}连不上,请检查网络(或在 设置 › 服务 › 升级下载源 换一个)`;
102
+ else note = `已经是最新版本 ${current}` + (reachable.some((g) => g.registry !== REGISTRIES.official) && !reachable.some((g) => g.registry === REGISTRIES.official)
103
+ ? '(官方源连不上,以国内镜像为准;镜像可能还没同步到最新,过一会儿再试)' : '');
104
+ }
105
+ return { candidates, reachable, note };
106
+ }
107
+
Binary file
package/dist/chat.html CHANGED
@@ -414,6 +414,9 @@
414
414
  /* 文件名允许换行显全名(长报告名不再被掐断);动作收成图标省宽度 */
415
415
  .fp-item .fp-name { flex: 1; min-width: 0; word-break: break-all; line-height: 1.35; }
416
416
  .fp-item .fp-size { color: var(--muted); font-size: 0.7rem; white-space: nowrap; align-self: flex-start; padding-top: 0.05rem; }
417
+ .fp-item .fp-chk { width: 1rem; height: 1rem; flex-shrink: 0; align-self: flex-start; margin-top: 0.1rem; accent-color: var(--purple); }
418
+ .fp-item.sel { background: var(--surface2); }
419
+ #fp-multi-btn.on { border-color: var(--purple); color: var(--purple); }
417
420
  .fp-item .fp-dl { color: var(--blue); font-size: 0.78rem; text-decoration: none; padding: 0.2rem 0.3rem; flex-shrink: 0; align-self: flex-start; }
418
421
  .fp-empty { text-align: center; color: var(--muted); padding: 2rem 0.75rem; font-size: 0.85rem; }
419
422
  .fp-drop-zone {
@@ -837,8 +840,16 @@
837
840
  <div id="fp-tools" style="display:flex;gap:0.4rem;align-items:center;padding:0.3rem 0.75rem;flex-wrap:wrap">
838
841
  <button class="fp-btn" onclick="fpOpenFolder()" title="在本机桌面打开该任务的文件夹——仅当浏览器与 myhi 服务在同一台机器时有效;远程访问不会有效果,请用下方列表浏览/下载">📂 打开当前文件夹(不支持远程)</button>
839
842
  <span style="flex:1"></span>
843
+ <button class="fp-btn" id="fp-multi-btn" onclick="fpToggleMulti()" title="勾选多个文件 / 文件夹,打包成一个 zip 下载">☑ 多选</button>
840
844
  <button class="fp-btn" id="fp-sort-btn" onclick="fpToggleSort()" title="切换排序">排序: 名称</button>
841
845
  </div>
846
+ <div id="fp-sel-bar" style="display:none;align-items:center;gap:0.4rem;padding:0.35rem 0.75rem;background:var(--surface2);font-size:0.78rem;flex-wrap:wrap">
847
+ <span id="fp-sel-count" style="color:var(--muted)">已选 0 项</span>
848
+ <span style="flex:1"></span>
849
+ <button class="fp-btn" onclick="fpSelAll()">全选</button>
850
+ <button class="fp-btn fp-btn-primary" id="fp-sel-dl" onclick="fpZipSelected()" disabled>⬇ 打包下载</button>
851
+ <button class="fp-btn" onclick="fpToggleMulti(false)">取消</button>
852
+ </div>
842
853
  <div class="fp-list" id="fp-list"></div>
843
854
  <div class="fp-diff-actions" id="fp-diff-actions" style="display:none">
844
855
  <button class="fp-btn fp-btn-primary" onclick="fpSyncToLocal()">下载到本地</button>
@@ -2059,8 +2070,19 @@
2059
2070
  if (t.startsWith('<command-name>')) return 'command';
2060
2071
  if (t.startsWith('<local-command-caveat>') || t.startsWith('Caveat:')) return 'caveat';
2061
2072
  if (t.startsWith('<system-reminder>') || t.startsWith('<local-command-stdout>')) return 'noise';
2073
+ // 后台任务(AI 放到后台跑的命令 / 子代理)结束时 claude 自动插给 AI 的通知,不是用户发言
2074
+ if (t.startsWith('<task-notification>')) return 'task';
2062
2075
  return null;
2063
2076
  }
2077
+ // <task-notification> → 一行人话:「⚙ 后台任务「…」已完成 / 已停止 / 失败」
2078
+ function taskNotificationText(text) {
2079
+ const tag = (n) => ((text.match(new RegExp('<' + n + '>([\\s\\S]*?)</' + n + '>')) || [])[1] || '').trim();
2080
+ const st = tag('status');
2081
+ const word = { completed: '已完成', killed: '已停止(到达时间上限或被中止)', failed: '失败', stopped: '已停止' }[st] || (st || '有新状态');
2082
+ const sum = tag('summary');
2083
+ const what = (sum.match(/"([^"]+)"/) || [])[1] || sum; // 摘要里引号括起的是任务名
2084
+ return `⚙ ${/^Agent\b/i.test(sum) ? '子代理' : '后台任务'}${what ? '「' + what.slice(0, 80) + '」' : ''}${word}`;
2085
+ }
2064
2086
 
2065
2087
  function buildSyntheticUserEl(content) {
2066
2088
  const el = document.createElement('div');
@@ -2070,6 +2092,12 @@
2070
2092
  el.textContent = '— ⏹ 请求已中断 —';
2071
2093
  return el;
2072
2094
  }
2095
+ if (kind === 'task') {
2096
+ el.className = 'msg msg-status';
2097
+ el.textContent = '— ' + taskNotificationText(content) + ' —';
2098
+ el.title = '这是 AI 放到后台运行的任务结束时,系统自动发给 AI 的通知,不是你发的消息';
2099
+ return el;
2100
+ }
2073
2101
  if (kind === 'command') {
2074
2102
  // 本地命令回显:状态行显示执行了什么命令(用户自己发的命令已有气泡,这是 claude 的回显)
2075
2103
  el.className = 'msg msg-status';
@@ -3127,9 +3155,9 @@
3127
3155
  if (kind === 'compact' || kind === 'interrupt') {
3128
3156
  const sel = buildSyntheticUserEl(msg.content);
3129
3157
  if (sel) { chatArea.appendChild(sel); trimMessages(); scrollToBottom(); }
3130
- } else if (kind === 'command') {
3158
+ } else if (kind === 'command' || kind === 'task') {
3131
3159
  const sel = buildSyntheticUserEl(msg.content);
3132
- if (sel) addStatusMessage(sel.textContent);
3160
+ if (sel) addStatusMessage(sel.textContent.replace(/^— | —$/g, ''));
3133
3161
  } else if (!kind) {
3134
3162
  addInputMessage(msg.content, msg.inject);
3135
3163
  }
@@ -4118,10 +4146,18 @@
4118
4146
  const entries = (data.entries || []).slice();
4119
4147
  if (fpSort === 'recent') entries.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); // 最近修改在前,看 AI 刚生成啥
4120
4148
  else entries.sort((a, b) => (Number(b.isDir) - Number(a.isDir)) || a.name.localeCompare(b.name));
4149
+ fpVisible = [];
4121
4150
  for (const e of entries) {
4122
4151
  const rel = fpCurrentPath ? fpCurrentPath + '/' + e.name : e.name;
4152
+ fpVisible.push(rel);
4153
+ if (fpSel) {
4154
+ // 多选模式:点整行 = 勾选 / 取消,不进入文件夹、不打开编辑
4155
+ const on = fpSel.has(rel);
4156
+ html += `<div class="fp-item${on ? ' sel' : ''}" onclick="fpToggleSel('${escAttr(rel)}')"><input type="checkbox" class="fp-chk" ${on ? 'checked' : ''} tabindex="-1" style="pointer-events:none"><span class="fp-icon">${e.isDir ? '📁' : '📄'}</span><span class="fp-name" title="${escAttr(rel)}">${escHtml(e.name)}</span>${e.isDir ? '' : `<span class="fp-size">${fpFormatSize(e.size)}</span>`}</div>`;
4157
+ continue;
4158
+ }
4123
4159
  if (e.isDir) {
4124
- html += `<div class="fp-item" onclick="fpNav('${escHtml(rel)}')"><span class="fp-icon">📁</span><span class="fp-name" title="${escAttr(rel)}">${escHtml(e.name)}</span><span class="fp-dl" onclick="event.stopPropagation();fpRefer('${escAttr(rel)}/')" title="引用:把目录路径插入消息框">@</span></div>`;
4160
+ html += `<div class="fp-item" onclick="fpNav('${escHtml(rel)}')"><span class="fp-icon">📁</span><span class="fp-name" title="${escAttr(rel)}">${escHtml(e.name)}</span><span class="fp-dl" onclick="event.stopPropagation();fpRefer('${escAttr(rel)}/')" title="引用:把目录路径插入消息框">@</span><span class="fp-dl" onclick="event.stopPropagation();fpZip(['${escAttr(rel)}'])" title="打包下载这个文件夹(zip)">⬇</span></div>`;
4125
4161
  } else {
4126
4162
  const editable = isEditableFile(e.name);
4127
4163
  html += `<div class="fp-item" ${editable ? `style="cursor:pointer" onclick="openFileEditor('${escAttr(rel)}')" title="点击在线编辑"` : ''}>
@@ -4140,7 +4176,52 @@
4140
4176
  }
4141
4177
  }
4142
4178
 
4143
- window.fpNav = function(path) { fpCurrentPath = path; fpLoadFiles(); };
4179
+ window.fpNav = function(path) { fpCurrentPath = path; if (fpSel) fpSel.clear(); fpSyncSelBar(); fpLoadFiles(); };
4180
+
4181
+ // ── 文件夹 / 多选打包下载(zip):先登记(服务端算好文件数、大小,超限当场提示),再触发浏览器下载 ──
4182
+ let fpSel = null; // 多选模式时是 Set(相对路径),否则 null
4183
+ let fpVisible = []; // 当前目录列出的条目(全选用)
4184
+ function fpSyncSelBar() {
4185
+ const bar = document.getElementById('fp-sel-bar');
4186
+ bar.style.display = fpSel ? 'flex' : 'none';
4187
+ document.getElementById('fp-multi-btn').classList.toggle('on', !!fpSel);
4188
+ if (!fpSel) return;
4189
+ document.getElementById('fp-sel-count').textContent = `已选 ${fpSel.size} 项`;
4190
+ document.getElementById('fp-sel-dl').disabled = !fpSel.size;
4191
+ }
4192
+ window.fpToggleMulti = function(on) {
4193
+ fpSel = (on ?? !fpSel) ? new Set() : null;
4194
+ fpSyncSelBar(); fpLoadFiles();
4195
+ };
4196
+ window.fpToggleSel = function(rel) {
4197
+ if (!fpSel) return;
4198
+ fpSel.has(rel) ? fpSel.delete(rel) : fpSel.add(rel);
4199
+ fpSyncSelBar(); fpLoadFiles();
4200
+ };
4201
+ window.fpSelAll = function() {
4202
+ if (!fpSel) return;
4203
+ const all = fpVisible.every(r => fpSel.has(r));
4204
+ fpVisible.forEach(r => all ? fpSel.delete(r) : fpSel.add(r));
4205
+ fpSyncSelBar(); fpLoadFiles();
4206
+ };
4207
+ window.fpZipSelected = function() { if (fpSel?.size) fpZip([...fpSel]); };
4208
+ window.fpZip = async function(paths) {
4209
+ const prog = document.getElementById('fp-progress');
4210
+ prog.textContent = '正在统计要打包的文件…'; prog.style.display = 'block';
4211
+ let d;
4212
+ try {
4213
+ const r = await fetch(`/api/files/${SESSION_ID}/zip-ticket`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) });
4214
+ d = await r.json();
4215
+ } catch (e) { d = { ok: false, error: e.message }; }
4216
+ if (!d.ok) { prog.textContent = '打包下载失败:' + (d.error || ''); setTimeout(() => { prog.style.display = 'none'; }, 6000); return; }
4217
+ const a = document.createElement('a');
4218
+ a.href = `/api/files/${SESSION_ID}/zip?ticket=${encodeURIComponent(d.ticket)}`; a.download = d.name;
4219
+ document.body.appendChild(a); a.click(); a.remove();
4220
+ prog.textContent = `开始下载「${d.name}」:${d.files} 个文件,共 ${fpFormatSize(d.bytes)}(打包前)` +
4221
+ (d.skipped?.length ? `;已跳过 ${d.skipped.join('、')}(可重新生成的目录)` : '') + '。文件多时浏览器要等一会儿才开始显示进度。';
4222
+ setTimeout(() => { prog.style.display = 'none'; }, 9000);
4223
+ if (fpSel) fpToggleMulti(false);
4224
+ };
4144
4225
 
4145
4226
  // 把文本插进消息框光标处(前面没空白就补一个,后面留一个空格方便接着打字)
4146
4227
  function insertIntoInput(text) {
@@ -4779,8 +4860,15 @@
4779
4860
 
4780
4861
  // 本页在看 = 本任务已读(切换面板 / 首页据此算「新完成」)
4781
4862
  let _seenAt = 0;
4863
+ // 真的在屏幕上才算「看过」:工作台把最近 5 个任务页藏在后台(display:none 的 iframe),
4864
+ // 它们的 document.hidden 仍是 false,不判断的话这几个任务永远不会显示「新完成」
4865
+ function pageOnScreen() {
4866
+ if (document.hidden) return false;
4867
+ try { if (window.frameElement && !window.frameElement.getClientRects().length) return false; } catch {}
4868
+ return true;
4869
+ }
4782
4870
  function markSeenNow(force) {
4783
- if (document.hidden || !window.myhiAttn) return;
4871
+ if (!pageOnScreen() || !window.myhiAttn) return;
4784
4872
  if (!force && Date.now() - _seenAt < 2000) return;
4785
4873
  _seenAt = Date.now(); myhiAttn.markSeen(SESSION_ID);
4786
4874
  }
Binary file
Binary file
package/dist/index.html CHANGED
@@ -968,6 +968,11 @@
968
968
  <div class="set-seg" id="set-proto"><button data-p="https">HTTPS</button><button data-p="http">HTTP</button></div>
969
969
  </div>
970
970
  <div id="set-proto-status" class="set-status"></div>
971
+ <div class="set-item admin-only">
972
+ <div class="set-lbl">升级下载源<small>myhi 和 Claude 升级从哪下载。自动:先官方源,连不上或装失败自动换国内镜像</small></div>
973
+ <div class="set-seg" id="set-reg"><button data-r="auto">自动</button><button data-r="official">官方</button><button data-r="mirror">国内镜像</button></div>
974
+ </div>
975
+ <div id="set-reg-status" class="set-status"></div>
971
976
  <a id="set-admin-link" class="set-link admin-only" href="/admin">🛠 管理后台 <small>用户 · 网关 · 密钥 · Claude 升级</small></a>
972
977
  <button class="set-link" onclick="restartMyhi()">🔄 重启 myhi</button>
973
978
  </div>
@@ -1005,7 +1010,7 @@
1005
1010
  <div id="sheet-backdrop" onclick="closeSheet()"></div>
1006
1011
  <div id="sheet">
1007
1012
  <div class="sheet-handle"></div>
1008
- <div class="sheet-title">新建终端会话</div>
1013
+ <div class="sheet-title">新建任务</div>
1009
1014
 
1010
1015
  <!-- Preset chips -->
1011
1016
  <div class="presets" id="presets"></div>
@@ -1014,7 +1019,8 @@
1014
1019
  <label>名称 <span style="font-weight:400;text-transform:none;color:var(--red,var(--red))">必填</span></label>
1015
1020
  <input id="inp-title" type="text" placeholder="请输入任务名称" autocomplete="off" spellcheck="false">
1016
1021
  </div>
1017
- <div class="field">
1022
+ <!-- 启动命令:默认就是 AI 任务(claude),Shell 直接开终端,都不需要填 —— 隐藏,按默认来 -->
1023
+ <div class="field" style="display:none">
1018
1024
  <label>启动命令 <span style="font-weight:400;text-transform:none">(可选)</span></label>
1019
1025
  <input id="inp-cmd" type="text" placeholder="留空仅打开 shell" autocomplete="off" spellcheck="false" style="font-family:'SF Mono',Consolas,monospace;font-size:0.85rem">
1020
1026
  </div>
@@ -1309,7 +1315,7 @@
1309
1315
  window.openSettings = function() {
1310
1316
  renderAppearance();
1311
1317
  ['set-machine-status', 'set-asr-status', 'set-restart-status', 'set-up-status', 'set-proto-status'].filter(id => document.getElementById(id)).forEach(id => setStatus(id, ''));
1312
- if (_isAdmin) loadProto();
1318
+ if (_isAdmin) { loadProto(); loadRegistry(); }
1313
1319
  document.querySelectorAll('#set-sheet .set-btn').forEach(b => b.classList.remove('dirty'));
1314
1320
  document.getElementById('set-backdrop').classList.add('open');
1315
1321
  document.getElementById('set-sheet').classList.add('open');
@@ -1474,6 +1480,20 @@
1474
1480
  });
1475
1481
  if (d.fromEnv) setStatus('set-proto-status', '启动时设了环境变量 MYHI_NO_HTTPS,固定为 HTTP');
1476
1482
  }
1483
+ async function loadRegistry() {
1484
+ let d; try { d = await (await fetch('/api/npm-registry')).json(); } catch { return; }
1485
+ const seg = document.getElementById('set-reg'); if (!seg || !d.ok) return;
1486
+ const custom = !['auto', 'official', 'mirror'].includes(d.pref);
1487
+ const mark = (p) => seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.r === p));
1488
+ mark(d.pref);
1489
+ if (custom) setStatus('set-reg-status', '当前用的是 config.json 里自定义的源:' + d.pref);
1490
+ seg.querySelectorAll('button').forEach(b => b.onclick = async () => {
1491
+ let r; try { r = await (await fetch('/api/npm-registry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pref: b.dataset.r }) })).json(); } catch (e) { r = { ok: false, error: e.message }; }
1492
+ if (!r.ok) { setStatus('set-reg-status', '保存失败:' + (r.error || '')); return; }
1493
+ mark(r.pref);
1494
+ setStatus('set-reg-status', { auto: '已设为自动:先官方源,不行再国内镜像', official: '已设为只用官方源(registry.npmjs.org)', mirror: '已设为只用国内镜像(registry.npmmirror.com,可能比官方晚几分钟到几小时)' }[r.pref]);
1495
+ });
1496
+ }
1477
1497
  async function switchProto(httpOnly, cur) {
1478
1498
  if (httpOnly === cur.httpOnly) return;
1479
1499
  const url = `${httpOnly ? 'http' : 'https'}://${location.hostname}:${cur.port}/`;
@@ -1622,7 +1642,8 @@
1622
1642
  const sub = esc([fmtCwd(s.cwd), fmtRelTime(sessionTime(s)), s.viewers > 0 ? `${s.viewers} 在线` : '', s.controlHolderName ? `${s.controlHolderName} 控制中` : ''].filter(Boolean).join(' · '));
1623
1643
  const tags = (s.tags || []).map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
1624
1644
  // 会话名片:一堆会话都叫 claude,靠第一条真人消息认出「这个任务当初是干啥的」
1625
- const at = window.myhiAttn ? myhiAttn.of(s) : '';
1645
+ let at = window.myhiAttn ? myhiAttn.of(s) : '';
1646
+ if (at === 'done' && s.id === _wsCur && wsOn()) at = ''; // 工作台右边正开着的任务:你就在看它,不算「新完成」
1626
1647
  // 等你回复 / 新完成:比起「当初问了啥」,AI 最后说了啥更有用
1627
1648
  const gist = (at === 'wait' || at === 'done') && s.lastText
1628
1649
  ? `<div class="card-gist" style="font-size:0.72rem;color:var(--muted,var(--muted));margin-top:0.18rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.85">🤖 ${esc(s.lastText)}</div>`
@@ -2242,8 +2263,7 @@
2242
2263
  document.querySelectorAll('.chip').forEach((c, j) =>
2243
2264
  c.classList.toggle('selected', j === i));
2244
2265
  const p = PRESETS[i];
2245
- // 不再自动把名称填成 claude/shell —— 名称必填、留给用户输入任务名
2246
- document.getElementById('inp-cmd').value = p.cmd || '';
2266
+ // 不再自动把名称填成 claude/shell —— 名称必填、留给用户输入任务名;启动命令字段已隐藏,不再回填
2247
2267
  }
2248
2268
 
2249
2269
  // ── Create sheet ─────────────────────────────────────────────
@@ -2323,7 +2343,7 @@
2323
2343
  titleInput.focus();
2324
2344
  return;
2325
2345
  }
2326
- const initCmd = document.getElementById('inp-cmd').value.trim() || undefined;
2346
+ const initCmd = undefined; // 启动命令已隐藏:AI 任务不用,Shell 直接开终端
2327
2347
  const cwd = document.getElementById('inp-cwd').value.trim() || undefined;
2328
2348
  if (cwd) recordDir(cwd);
2329
2349
  const preset = selectedPreset != null ? PRESETS[selectedPreset] : null;
Binary file