@wendongfly/myhi 1.3.201 → 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 +28 -19
- package/bin/install-check.js +59 -3
- package/dist/admin.html +12 -10
- package/dist/admin.html.gz +0 -0
- package/dist/chat.html +93 -5
- package/dist/chat.html.gz +0 -0
- package/dist/grid.html.gz +0 -0
- package/dist/index.html +31 -10
- package/dist/index.html.gz +0 -0
- package/dist/index.js +6 -6
- package/dist/index.min.js +157 -149
- package/dist/lib/attn.js +6 -4
- package/dist/login.html.gz +0 -0
- package/dist/terminal.html.gz +0 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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() {
|
package/bin/install-check.js
CHANGED
|
@@ -41,11 +41,67 @@ export function globalPrefixOf(pkgRoot) {
|
|
|
41
41
|
return basename(dirname(nm)) === 'lib' ? dirname(dirname(nm)) : null;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
+
|
package/dist/admin.html
CHANGED
|
@@ -473,18 +473,20 @@ async function refresh() {
|
|
|
473
473
|
} catch {}
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
-
//
|
|
476
|
+
// 使用模式:0~1 个登录账号 = 单用户(那个账号就是主人,全部权限);≥2 个 = 多用户(账号变成成员,权限受限)
|
|
477
477
|
let _users = [];
|
|
478
478
|
function renderUsers(users, exclusive) {
|
|
479
479
|
_users = users;
|
|
480
|
-
const multi = users.length
|
|
480
|
+
const multi = users.length >= 2;
|
|
481
481
|
document.getElementById('mode-status').innerHTML = multi
|
|
482
482
|
? `<b>当前:多用户模式 · ${users.length} 个成员</b><small>成员用各自密码登录,可以升级 / 重启,不能改全局设置;你用管理密码或二维码登录,权限不变。</small>`
|
|
483
|
+
: users.length === 1
|
|
484
|
+
? `<b>当前:单用户模式 · 登录账号「${esc(users[0].name)}」</b><small>只有一个账号时它就是主人,用自己的密码登录、所有功能都能用,不需要来这里管理。再添加一个成员才进入多用户模式。</small>`
|
|
483
485
|
: `<b>当前:单用户模式</b><small>只有你在用,所有功能都能用。需要给别人用时再添加成员。</small>`;
|
|
484
|
-
document.getElementById('add-open').textContent = multi ? '+ 添加成员' : '+ 添加成员,开启多用户模式';
|
|
486
|
+
document.getElementById('add-open').textContent = multi ? '+ 添加成员' : users.length === 1 ? '+ 添加成员,开启多用户模式' : '+ 添加登录账号';
|
|
485
487
|
document.getElementById('excl-row').style.display = multi ? '' : 'none';
|
|
486
488
|
document.getElementById('excl-toggle').checked = !!exclusive;
|
|
487
|
-
document.getElementById('mode-foot').textContent = multi ? '
|
|
489
|
+
document.getElementById('mode-foot').textContent = multi ? '删到只剩一个账号即回到单用户模式(剩下的那个拥有全部权限)。删除成员或重置密码后,他当前的登录会立即失效。' : '';
|
|
488
490
|
const el = document.getElementById('user-list');
|
|
489
491
|
el.innerHTML = users.map(u => `
|
|
490
492
|
<div class="row">
|
|
@@ -545,13 +547,13 @@ async function addUser() {
|
|
|
545
547
|
const dir = document.getElementById('u-dir').value.trim();
|
|
546
548
|
const msg = document.getElementById('u-msg');
|
|
547
549
|
if (!name || !pwd) { msg.className = 'msg msg-err'; msg.textContent = '成员名称和登录密码必填'; return; }
|
|
548
|
-
//
|
|
549
|
-
if (
|
|
550
|
-
'
|
|
551
|
-
|
|
550
|
+
// 第二个账号 = 从单用户切到多用户:先讲清楚会发生什么(尤其原来那个账号会降为成员)
|
|
551
|
+
if (_users.length === 1 && !confirm(
|
|
552
|
+
'添加后共 2 个账号,myhi 进入「多用户模式」:\n\n' +
|
|
553
|
+
`· 原来的账号「${_users[0].name}」和新成员都变成「成员」,各用自己的密码登录` + (dir ? ',新成员只能在绑定的目录里建任务' : '') + ';\n' +
|
|
552
554
|
'· 成员可以升级、重启,但不能改全局设置(本机显示名、语音、用户、网关等);\n' +
|
|
553
555
|
'· 你(管理员)照常用管理密码或二维码登录,权限不变。\n\n' +
|
|
554
|
-
'
|
|
556
|
+
'以后删到只剩一个账号即回到单用户模式。确定添加?')) return;
|
|
555
557
|
try {
|
|
556
558
|
const r = await fetch('/api/admin/user', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd, name, dir }) });
|
|
557
559
|
const d = await r.json();
|
|
@@ -575,7 +577,7 @@ async function editUserPwd(name) {
|
|
|
575
577
|
}
|
|
576
578
|
|
|
577
579
|
async function delUser(name) {
|
|
578
|
-
if (!confirm(
|
|
580
|
+
if (!confirm(`确定删除「${name}」?他当前的登录会立即失效。` + (_users.length === 2 ? `\n\n删除后只剩「${_users.find(u => u.name !== name)?.name}」一个账号,回到单用户模式,它将拥有全部权限。` : _users.length === 1 ? '\n\n这是最后一个登录账号,删除后只能用管理密码登录。' : ''))) return;
|
|
579
581
|
try {
|
|
580
582
|
const r = await fetch('/api/admin/user', { method: 'DELETE', headers: H(), body: JSON.stringify({ name }) });
|
|
581
583
|
const d = await r.json();
|
|
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 (
|
|
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
|
@@ -857,7 +857,7 @@
|
|
|
857
857
|
</div>
|
|
858
858
|
<ol class="hs-steps">
|
|
859
859
|
<li><span class="hs-num">1</span><div><b>建一个任务</b><p>点右下角 <kbd>+</kbd>,选「默认」预设、起个名字,点创建。</p></div></li>
|
|
860
|
-
<li><span class="hs-num">2</span><div><b>像发微信一样说需求</b><p
|
|
860
|
+
<li><span class="hs-num">2</span><div><b>像发微信一样说需求</b><p>进入任务就是对话界面,说出你的需求,AI 会理解您说的话,自己读文件、写文件、执行命令。</p></div></li>
|
|
861
861
|
<li><span class="hs-num">3</span><div><b>用 <kbd>@</kbd> 引用文件</b><p>消息里打 @ 弹出文件搜索,选中即插入路径,不用手打文件名。</p></div></li>
|
|
862
862
|
</ol>
|
|
863
863
|
<div class="hs-label">常用功能</div>
|
|
@@ -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"
|
|
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
|
-
|
|
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>
|
|
@@ -1112,13 +1118,14 @@
|
|
|
1112
1118
|
// 成员用户(users.json 登录)才显示自助改密入口;管理员改密码在 /admin
|
|
1113
1119
|
// 改全局配置只给管理员(后端同样拦):成员的「服务」组没有管理后台入口,本机显示名与语音只读
|
|
1114
1120
|
_isAdmin = (data.role || 'admin') === 'admin';
|
|
1115
|
-
|
|
1121
|
+
// 0~1 个登录账号是单用户(hibox 默认建的那个账号就是主人),≥2 个才是多用户
|
|
1122
|
+
document.getElementById('set-mode').innerHTML = !data.multiUser ? '👤 单用户模式 · 所有功能都可用'
|
|
1116
1123
|
: _isAdmin ? '👥 多用户模式 · 你是管理员 · <a href="/admin#mode">管理成员 →</a>'
|
|
1117
1124
|
: `👥 多用户模式 · 你是成员「${esc(data.name || '')}」· 本机显示名、语音等全局设置需管理员修改`;
|
|
1118
1125
|
if (!_isAdmin) document.querySelectorAll('#set-svc .admin-only').forEach(el => el.remove());
|
|
1119
1126
|
checkAiEngine();
|
|
1120
1127
|
checkHomeVersion();
|
|
1121
|
-
if (data.hasUsers && data.
|
|
1128
|
+
if (data.hasUsers && data.member) { document.getElementById('passwd-btn').style.display = ''; document.getElementById('ws-menu-pw').style.display = ''; }
|
|
1122
1129
|
// 单用户模式没有「登录账号」可退:⚙ 菜单不显示账户段
|
|
1123
1130
|
if (!(data.exclusive || data.hasUsers)) document.getElementById('ws-menu-acct').style.display = 'none';
|
|
1124
1131
|
if (data.dir) {
|
|
@@ -1308,7 +1315,7 @@
|
|
|
1308
1315
|
window.openSettings = function() {
|
|
1309
1316
|
renderAppearance();
|
|
1310
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, ''));
|
|
1311
|
-
if (_isAdmin) loadProto();
|
|
1318
|
+
if (_isAdmin) { loadProto(); loadRegistry(); }
|
|
1312
1319
|
document.querySelectorAll('#set-sheet .set-btn').forEach(b => b.classList.remove('dirty'));
|
|
1313
1320
|
document.getElementById('set-backdrop').classList.add('open');
|
|
1314
1321
|
document.getElementById('set-sheet').classList.add('open');
|
|
@@ -1473,6 +1480,20 @@
|
|
|
1473
1480
|
});
|
|
1474
1481
|
if (d.fromEnv) setStatus('set-proto-status', '启动时设了环境变量 MYHI_NO_HTTPS,固定为 HTTP');
|
|
1475
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
|
+
}
|
|
1476
1497
|
async function switchProto(httpOnly, cur) {
|
|
1477
1498
|
if (httpOnly === cur.httpOnly) return;
|
|
1478
1499
|
const url = `${httpOnly ? 'http' : 'https'}://${location.hostname}:${cur.port}/`;
|
|
@@ -1621,7 +1642,8 @@
|
|
|
1621
1642
|
const sub = esc([fmtCwd(s.cwd), fmtRelTime(sessionTime(s)), s.viewers > 0 ? `${s.viewers} 在线` : '', s.controlHolderName ? `${s.controlHolderName} 控制中` : ''].filter(Boolean).join(' · '));
|
|
1622
1643
|
const tags = (s.tags || []).map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
|
|
1623
1644
|
// 会话名片:一堆会话都叫 claude,靠第一条真人消息认出「这个任务当初是干啥的」
|
|
1624
|
-
|
|
1645
|
+
let at = window.myhiAttn ? myhiAttn.of(s) : '';
|
|
1646
|
+
if (at === 'done' && s.id === _wsCur && wsOn()) at = ''; // 工作台右边正开着的任务:你就在看它,不算「新完成」
|
|
1625
1647
|
// 等你回复 / 新完成:比起「当初问了啥」,AI 最后说了啥更有用
|
|
1626
1648
|
const gist = (at === 'wait' || at === 'done') && s.lastText
|
|
1627
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>`
|
|
@@ -2241,8 +2263,7 @@
|
|
|
2241
2263
|
document.querySelectorAll('.chip').forEach((c, j) =>
|
|
2242
2264
|
c.classList.toggle('selected', j === i));
|
|
2243
2265
|
const p = PRESETS[i];
|
|
2244
|
-
// 不再自动把名称填成 claude/shell ——
|
|
2245
|
-
document.getElementById('inp-cmd').value = p.cmd || '';
|
|
2266
|
+
// 不再自动把名称填成 claude/shell —— 名称必填、留给用户输入任务名;启动命令字段已隐藏,不再回填
|
|
2246
2267
|
}
|
|
2247
2268
|
|
|
2248
2269
|
// ── Create sheet ─────────────────────────────────────────────
|
|
@@ -2322,7 +2343,7 @@
|
|
|
2322
2343
|
titleInput.focus();
|
|
2323
2344
|
return;
|
|
2324
2345
|
}
|
|
2325
|
-
const initCmd =
|
|
2346
|
+
const initCmd = undefined; // 启动命令已隐藏:AI 任务不用,Shell 直接开终端
|
|
2326
2347
|
const cwd = document.getElementById('inp-cwd').value.trim() || undefined;
|
|
2327
2348
|
if (cwd) recordDir(cwd);
|
|
2328
2349
|
const preset = selectedPreset != null ? PRESETS[selectedPreset] : null;
|
|
Binary file
|