@wendongfly/myhi 1.3.197 → 1.3.199

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/dist/index.html CHANGED
@@ -17,6 +17,7 @@
17
17
  <title>myhi</title>
18
18
  <script src="/lib/machine.js"></script>
19
19
  <script src="/lib/prefs.js"></script>
20
+ <script src="/lib/attn.js"></script>
20
21
  <script src="/sio-client/socket.io.min.js"></script>
21
22
  <script src="/theme.js"></script>
22
23
  <script>
@@ -74,14 +75,36 @@
74
75
  justify-content: space-between;
75
76
  gap: 0.75rem;
76
77
  padding: 0 0.75rem;
77
- padding-top: max(1rem, env(safe-area-inset-top));
78
- padding-bottom: 0.75rem;
78
+ padding-top: max(0.45rem, env(safe-area-inset-top));
79
+ padding-bottom: 0.45rem;
79
80
  background: var(--bg);
80
81
  position: sticky;
81
82
  top: 0;
82
83
  z-index: 10;
83
84
  border-bottom: 1px solid var(--border);
84
85
  }
86
+ /* ── 任务迁移面板(导出 / 导入)── */
87
+ #mig-ov { position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.55); display: flex; align-items: flex-end; justify-content: center; }
88
+ #mig-box { background: var(--surface); color: var(--text); width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; border-radius: 16px 16px 0 0; padding: 1rem 1.1rem max(1rem, env(safe-area-inset-bottom)); font-size: 0.85rem; line-height: 1.6; }
89
+ @media (min-width: 700px) { #mig-ov { align-items: center; } #mig-box { border-radius: 16px; } }
90
+ #mig-box h3 { font-size: 1.02rem; margin: 0 0 0.5rem; display: flex; justify-content: space-between; align-items: center; }
91
+ #mig-box h3 button { background: none; border: none; color: var(--muted); font-size: 1.1rem; cursor: pointer; }
92
+ #mig-box .mig-intro { color: var(--muted); margin-bottom: 0.7rem; }
93
+ #mig-box .mig-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 0.6rem 0.75rem; margin-bottom: 0.6rem; }
94
+ #mig-box .mig-card b { color: var(--text); }
95
+ #mig-box .mig-row { display: flex; gap: 0.5rem; padding: 0.25rem 0; }
96
+ #mig-box .mig-row > span:first-child { flex-shrink: 0; }
97
+ #mig-box small, #mig-box .mig-dim { color: var(--muted); font-size: 0.76rem; }
98
+ #mig-box ol { margin: 0.2rem 0 0 1.2rem; padding: 0; }
99
+ #mig-box .mig-warn { color: #d29922; }
100
+ #mig-box .mig-btn { width: 100%; border: none; border-radius: 10px; padding: 0.75rem; font-size: 0.9rem; font-weight: 700; cursor: pointer; background: var(--accent); color: #fff; margin-top: 0.3rem; }
101
+ #mig-box .mig-btn:disabled { opacity: 0.5; cursor: default; }
102
+ #mig-box .mig-btn2 { width: 100%; border: 1px solid var(--border); border-radius: 10px; padding: 0.6rem; font-size: 0.84rem; cursor: pointer; background: var(--bg); color: var(--muted); margin-top: 0.45rem; }
103
+ #mig-box input[type=text] { width: 100%; box-sizing: border-box; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem 0.6rem; font-size: 0.82rem; font-family: 'SF Mono', Consolas, monospace; }
104
+ #mig-box .mig-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin: 0.4rem 0; }
105
+ #mig-box .mig-bar > div { height: 100%; width: 0; background: var(--accent); transition: width 0.2s; }
106
+ /* 普通浏览器标签页不留状态栏安全区(部分手机浏览器带地址栏仍报安全区 → 顶部白空一行) */
107
+ @media (display-mode: browser) { #header { padding-top: 0.45rem; } }
85
108
  .logo {
86
109
  font-size: 1.35rem;
87
110
  font-weight: 700;
@@ -402,6 +425,10 @@
402
425
  .st-busy::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: stPulse 1.2s ease-in-out infinite; }
403
426
  @keyframes stPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
404
427
  .st-dead { color: var(--muted); }
428
+ .st-wait { color: #db6d28; font-weight: 600; }
429
+ .st-wait::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #db6d28; animation: stPulse 0.8s ease-in-out infinite; }
430
+ .st-done { color: var(--green); font-weight: 600; }
431
+ .st-done::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--green); }
405
432
  .viewers {
406
433
  font-size: 0.7rem;
407
434
  color: var(--muted);
@@ -738,8 +765,6 @@
738
765
  <button id="ws-collapse" onclick="wsSetCollapsed(true)" title="收起任务栏(收起后左侧保留窄栏)">«</button>
739
766
  <div class="hd-actions">
740
767
  <button id="grid-btn" class="hd-btn" onclick="location.href='/grid'" title="分屏工作台(多任务并排)">▦<span class="hd-txt"> 分屏</span></button>
741
- <button id="doctor-btn" class="hd-btn" onclick="openDoctor()" title="会话体检">🩺<span class="hd-txt"> 体检</span></button>
742
- <button id="usage-btn" class="hd-btn" onclick="showUsage()" title="用量">📊<span class="hd-txt"> 用量</span></button>
743
768
  <button id="help-btn" class="hd-btn" onclick="openHelpSheet()" title="使用说明 / 快速上手">❓<span class="hd-txt"> 帮助</span></button>
744
769
  <button id="settings-btn" class="hd-btn" onclick="openSettings()" title="设置(外观 / 本机名称 / 语音 / 服务)">⚙<span class="hd-txt"> 设置</span></button>
745
770
  <button id="user-btn" class="hd-btn" onclick="toggleUserMenu(event)" title="账户:修改密码 / 退出登录" style="display:none">👤<span id="user-name" class="hd-txt"></span></button>
@@ -924,6 +949,12 @@
924
949
  <div id="set-asr-status" class="set-status"></div>
925
950
  </div>
926
951
 
952
+ <div class="set-group-t">工具</div>
953
+ <div class="set-card">
954
+ <button class="set-link" onclick="closeSettings();openDoctor()">🩺 会话体检 <small>查中毒 / 过大的会话,一键重开</small></button>
955
+ <button class="set-link" onclick="closeSettings();showUsage()">📊 用量 <small>费用 / token 统计</small></button>
956
+ </div>
957
+
927
958
  <div id="set-svc">
928
959
  <div class="set-group-t" id="set-svc-t">服务</div>
929
960
  <div class="set-card">
@@ -992,6 +1023,7 @@
992
1023
  <div id="recent-dirs" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem"></div>
993
1024
  </div>
994
1025
  <button id="btn-create" onclick="createSession()">创建</button>
1026
+ <button onclick="closeSheet();openMigrateImport()" style="width:100%;background:var(--bg);color:var(--accent);border:1.5px solid var(--accent);border-radius:10px;padding:0.7rem;font-size:0.85rem;font-weight:600;cursor:pointer;margin-top:0.5rem">📦 从迁移包导入<span style="font-weight:400;font-size:0.75rem;opacity:0.8">(从另一台 myhi 带过来的任务)</span></button>
995
1027
  <button id="btn-import" onclick="openImportSheet()" style="width:100%;background:var(--bg);color:var(--accent);border:1.5px solid var(--accent);border-radius:10px;padding:0.7rem;font-size:0.85rem;font-weight:600;cursor:pointer;margin-top:0.5rem">导入已有 Claude 会话</button>
996
1028
  </div>
997
1029
 
@@ -1561,7 +1593,11 @@
1561
1593
  const sub = esc([fmtCwd(s.cwd), fmtRelTime(sessionTime(s)), s.viewers > 0 ? `${s.viewers} 在线` : '', s.controlHolderName ? `${s.controlHolderName} 控制中` : ''].filter(Boolean).join(' · '));
1562
1594
  const tags = (s.tags || []).map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
1563
1595
  // 会话名片:一堆会话都叫 claude,靠第一条真人消息认出「这个任务当初是干啥的」
1564
- const gist = s.firstUserText
1596
+ const at = window.myhiAttn ? myhiAttn.of(s) : '';
1597
+ // 等你回复 / 新完成:比起「当初问了啥」,AI 最后说了啥更有用
1598
+ const gist = (at === 'wait' || at === 'done') && s.lastText
1599
+ ? `<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>`
1600
+ : s.firstUserText
1565
1601
  ? `<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.firstUserText)}</div>`
1566
1602
  : '';
1567
1603
  const nDraft = _draftCounts[s.id] || 0;
@@ -1579,8 +1615,10 @@
1579
1615
  </div>
1580
1616
  <div class="card-right">
1581
1617
  <button class="card-more" title="更多操作(重命名 / 标签 / 终止)">⋯</button>
1582
- ${!s.alive ? '<div class="st st-dead" title="会话进程已退出,点进去会自动恢复">已停止</div>'
1583
- : s.busy ? '<div class="st st-busy" title="AI 正在执行">执行中</div>' : ''}
1618
+ ${at === 'wait' ? '<div class="st st-wait" title="AI 在等你回答提问 / 审批方案">等你回复</div>'
1619
+ : !s.alive ? '<div class="st st-dead" title="会话进程已退出,点进去会自动恢复">已停止</div>'
1620
+ : s.busy ? '<div class="st st-busy" title="AI 正在执行">执行中</div>'
1621
+ : at === 'done' ? '<div class="st st-done" title="你上次看过之后有了新回复">新完成</div>' : ''}
1584
1622
  ${s.viewers > 1 ? `<div class="viewers">👁 ${s.viewers}</div>` : ''}
1585
1623
  </div>
1586
1624
  </div>`;
@@ -1608,8 +1646,11 @@
1608
1646
  </div>`;
1609
1647
  }
1610
1648
 
1649
+ // 手机(触屏窄屏)的「最近」视图按关注度分组;电脑工作台左侧列表保持纯时间序
1650
+ const _attnFirst = () => !!window.myhiAttn && matchMedia('(pointer: coarse) and (max-width: 768px)').matches;
1611
1651
  function renderSessions(sessions) {
1612
1652
  if (Array.isArray(sessions)) _allSessions = sessions;
1653
+ if (window.myhiAttn) myhiAttn.init(_allSessions);
1613
1654
  // 正在原地改名:列表每 5 秒刷新一次会把输入框冲掉,先攒着,改完再刷
1614
1655
  if (_renaming) { _renderPending = true; return; }
1615
1656
  renderTagFilter();
@@ -1670,6 +1711,14 @@
1670
1711
  html += groupHeaderHTML('未分类', untagged.length, 't:__none__');
1671
1712
  if (!isCollapsed('t:__none__') || q) html += untagged.map(cardHTML).join('');
1672
1713
  }
1714
+ } else if (_attnFirst()) {
1715
+ // 手机:需要你关注的排前面(等你回复 > 执行中 > 新完成),其余按时间
1716
+ const b = { wait: [], busy: [], done: [], '': [] };
1717
+ for (const s of items) b[myhiAttn.of(s)].push(s);
1718
+ const lbl = { wait: '🟠 等你回复', busy: '🔵 执行中', done: '🟢 新完成', '': '其他' };
1719
+ const any = b.wait.length || b.busy.length || b.done.length;
1720
+ html = !any ? items.map(cardHTML).join('')
1721
+ : ['wait', 'busy', 'done', ''].filter(k => b[k].length).map(k => groupHeaderHTML(lbl[k], b[k].length) + b[k].map(cardHTML).join('')).join('');
1673
1722
  } else {
1674
1723
  html = items.map(cardHTML).join('');
1675
1724
  }
@@ -1850,6 +1899,114 @@
1850
1899
 
1851
1900
  // ── Navigation ──────────────────────────────────────────────
1852
1901
  // 电脑端工作台:在右侧打开;手机/窄屏:进会话页
1902
+ // ── 任务迁移:导出(任务 ⋯ 菜单)/ 导入(新建任务面板)──────────────────
1903
+ // 场景:公司 myhi → 家里 myhi,两边网络不通也没有 GitLab —— 下载一个 .myhipack 文件带过去导入
1904
+ const fmtSize = (n) => n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
1905
+ function migSheet(html) {
1906
+ document.getElementById('mig-ov')?.remove();
1907
+ const ov = document.createElement('div'); ov.id = 'mig-ov';
1908
+ ov.innerHTML = '<div id="mig-box">' + html + '</div>';
1909
+ ov.addEventListener('click', (e) => { if (e.target === ov || e.target.closest('[data-mig-close]')) ov.remove(); });
1910
+ document.body.appendChild(ov);
1911
+ return ov.firstChild;
1912
+ }
1913
+ const MIG_SKIP_TIP = { '.git': '提交历史(需要的话用 git push / pull 同步)', node_modules: 'npm 依赖(到新电脑执行 npm install)', '.venv': 'Python 虚拟环境(到新电脑重新建)', venv: 'Python 虚拟环境(到新电脑重新建)', upload: '聊天里上传的截图' };
1914
+
1915
+ window.openMigrateExport = async function(id) {
1916
+ const s = _allSessions.find(x => x.id === id) || {};
1917
+ const box = migSheet(`<h3>📦 迁移到另一台 myhi<button data-mig-close>✕</button></h3>
1918
+ <div class="mig-intro">把「${esc(s.title || '')}」打包成<b>一个文件</b>,带到另一台电脑的 myhi 接着做(比如公司 → 家里)。两台电脑<b>不需要联网互通</b>,也不需要 GitLab。</div>
1919
+ <div class="mig-card" id="mig-what"><span class="mig-dim">正在统计要打包的内容…</span></div>
1920
+ <div class="mig-card"><b>怎么用(3 步)</b><ol>
1921
+ <li>点下面「下载迁移包」,得到一个 <code>.myhipack</code> 文件</li>
1922
+ <li>用微信文件传输助手 / U 盘 / 网盘,把它传到另一台电脑</li>
1923
+ <li>在那台电脑打开 myhi 首页 → <b>+ 新建任务</b> → <b>📦 从迁移包导入</b>,选这个文件</li></ol></div>
1924
+ <div class="mig-dim" style="margin-bottom:0.5rem">🔒 迁移包里是项目原文件(和对话记录),请像保管项目代码一样保管,别发到公开群里。</div>
1925
+ <button class="mig-btn" id="mig-dl" disabled>⬇ 下载迁移包</button>
1926
+ <button class="mig-btn2" data-mig-close>取消</button>`);
1927
+ let p;
1928
+ try { p = await (await fetch('/api/migrate/' + encodeURIComponent(id) + '/preview')).json(); } catch (e) { p = { ok: false, error: e.message }; }
1929
+ const what = box.querySelector('#mig-what');
1930
+ if (!p.ok) { what.innerHTML = '<span class="mig-warn">统计失败:' + esc(p.error || '') + '</span>'; return; }
1931
+ const skipped = (p.skipped || []).map(d => `<div class="mig-dim"> · <code>${esc(d)}</code> ${esc(MIG_SKIP_TIP[d] || '可重新生成的目录')}</div>`).join('');
1932
+ what.innerHTML = `<b>包里会有</b>
1933
+ <div class="mig-row"><span>📁</span><div>项目文件 <b>${p.files}</b> 个,共 <b>${fmtSize(p.bytes)}</b><br><small>${esc(p.cwd)}</small>${p.truncated ? '<div class="mig-warn">文件太多,只打包了前 20 万个</div>' : ''}</div></div>
1934
+ ${skipped ? `<div class="mig-row"><span>⏭</span><div>不打包(可重新生成,到新电脑再装):${skipped}</div></div>` : ''}
1935
+ <div class="mig-row"><span>💬</span><label style="cursor:pointer"><input type="checkbox" id="mig-conv" ${p.hasConv ? 'checked' : 'disabled'}> 带上 AI 对话记录${p.hasConv ? `(${fmtSize(p.convBytes)})` : ''}<br><small>${p.hasConv ? '带上后在新电脑上可以<b>接着聊</b>,AI 记得之前做过什么;对话里的项目路径会自动改成新位置。项目记忆也一起带走。' : '这个任务还没有对话记录,只带项目文件。'}</small></label></div>
1936
+ ${p.guard ? `<div class="mig-row"><span>🛡</span><div>隐私护栏配置(${p.guard.enabled ? '已开启' : '未开启'},主体词典 ${p.guard.entities} 个)和假名映射表一起带走,新电脑上继续脱敏</div></div>` : ''}
1937
+ ${p.secrets.length ? `<div class="mig-row"><span>🔑</span><div><b>密钥不会带走</b>(${p.secrets.map(esc).join('、')})<br><small>为了安全,密钥只留在这台电脑。到新电脑后在任务会话页「🔑 密钥」里重新录入。</small></div></div>` : ''}
1938
+ ${p.busy ? '<div class="mig-row mig-warn"><span>⚠️</span><div>AI 正在执行,建议等这一轮结束再导出,否则最后一轮对话可能不完整。</div></div>' : ''}`;
1939
+ const btn = box.querySelector('#mig-dl'); btn.disabled = false;
1940
+ btn.onclick = () => {
1941
+ const conv = box.querySelector('#mig-conv')?.checked ? '1' : '0';
1942
+ const a = document.createElement('a'); a.href = '/api/migrate/' + encodeURIComponent(id) + '/export?conv=' + conv; a.download = '';
1943
+ document.body.appendChild(a); a.click(); a.remove();
1944
+ btn.textContent = '✓ 已开始下载'; btn.disabled = true;
1945
+ btn.insertAdjacentHTML('afterend', '<div class="mig-dim" style="margin-top:0.4rem">文件大时浏览器会在后台下载,留意下载栏 / 通知。下载完成后按上面第 2、3 步操作。</div>');
1946
+ };
1947
+ };
1948
+
1949
+ window.openMigrateImport = function() {
1950
+ const box = migSheet(`<h3>📦 从迁移包导入<button data-mig-close>✕</button></h3>
1951
+ <div class="mig-intro">把另一台 myhi 导出的任务(<code>.myhipack</code> 文件)导入到这台电脑,导入后会生成一个新任务,项目文件和 AI 对话都在,可以接着做。</div>
1952
+ <div class="mig-card"><b>还没有迁移包?</b><div class="mig-dim">在原来那台电脑的 myhi 首页,点任务右边的 <b>⋯</b> → <b>📦 迁移到另一台 myhi</b> → 下载迁移包,再用微信 / U 盘 / 网盘传到这台电脑。</div></div>
1953
+ <div class="mig-card" id="mig-step1"><b>① 选择迁移包</b><div style="margin-top:0.4rem"><input type="file" id="mig-file" accept="${'.myhipack'},application/gzip"></div>
1954
+ <div id="mig-up" style="display:none"><div class="mig-bar"><div></div></div><span class="mig-dim" id="mig-up-t">上传中…</span></div></div>
1955
+ <div id="mig-step2"></div>
1956
+ <button class="mig-btn2" data-mig-close>取消</button>`);
1957
+ const step2 = box.querySelector('#mig-step2');
1958
+ box.querySelector('#mig-file').onchange = (ev) => {
1959
+ const f = ev.target.files[0]; if (!f) return;
1960
+ const up = box.querySelector('#mig-up'), bar = up.querySelector('.mig-bar > div'), t = box.querySelector('#mig-up-t');
1961
+ up.style.display = ''; step2.innerHTML = '';
1962
+ const fd = new FormData(); fd.append('pack', f);
1963
+ const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/migrate/upload');
1964
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable) { bar.style.width = (e.loaded / e.total * 100) + '%'; t.textContent = `上传中… ${fmtSize(e.loaded)} / ${fmtSize(e.total)}`; } };
1965
+ xhr.onload = () => {
1966
+ let r; try { r = JSON.parse(xhr.responseText); } catch { r = { ok: false, error: '上传失败(' + xhr.status + ')' }; }
1967
+ if (!r.ok) { t.innerHTML = '<span class="mig-warn">' + esc(r.error) + '</span>'; return; }
1968
+ bar.style.width = '100%'; t.textContent = '已上传,请确认下面的信息';
1969
+ showMigrateConfirm(step2, r);
1970
+ };
1971
+ xhr.onerror = () => { t.innerHTML = '<span class="mig-warn">上传失败,网络中断了,请重试</span>'; };
1972
+ xhr.send(fd);
1973
+ };
1974
+ };
1975
+ function showMigrateConfirm(el, r) {
1976
+ const m = r.manifest;
1977
+ const proj = String(m.cwd || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || m.title;
1978
+ let target = defaultTaskDir(proj);
1979
+ if (r.userDir && !target) target = r.userDir.replace(/[\\/]+$/, '') + (r.userDir.includes('\\') ? '\\' : '/') + sanitizeTaskName(proj);
1980
+ const skipped = (m.skipped || []).map(d => `<code>${esc(d)}</code>`).join('、');
1981
+ el.innerHTML = `<div class="mig-card"><b>② 确认迁移包内容</b>
1982
+ <div class="mig-row"><span>📝</span><div>任务:<b>${esc(m.title || '')}</b><br><small>来自「${esc(m.from || '另一台 myhi')}」,${esc(new Date(m.exportedAt).toLocaleString())} 导出</small></div></div>
1983
+ <div class="mig-row"><span>📁</span><div>项目文件 ${m.files} 个,共 ${fmtSize(m.bytes || 0)}<br><small>原位置:${esc(m.cwd || '')}</small></div></div>
1984
+ <div class="mig-row"><span>💬</span><div>${m.conv ? 'AI 对话记录:有 —— 导入后可以<b>接着聊</b>' : 'AI 对话记录:没带 —— 导入后是一个新对话(项目文件都在)'}</div></div>
1985
+ ${skipped ? `<div class="mig-row"><span>⏭</span><div>原电脑上没打包:${skipped}<br><small>依赖 / 构建产物需要在这台电脑重新安装或生成(例如 npm install);可以导入后直接让 AI 帮你装。</small></div></div>` : ''}
1986
+ ${m.guard ? `<div class="mig-row"><span>🛡</span><div>隐私护栏配置一起导入(主体词典 ${m.guard.entities} 个)</div></div>` : ''}
1987
+ ${(m.secrets || []).length ? `<div class="mig-row mig-warn"><span>🔑</span><div>需要重新录入的密钥:${m.secrets.map(esc).join('、')}<br><small>导入后在任务会话页「🔑 密钥」里填。</small></div></div>` : ''}
1988
+ </div>
1989
+ <div class="mig-card"><b>③ 放到这台电脑的哪个目录</b>
1990
+ <div style="margin:0.35rem 0"><input type="text" id="mig-cwd" value="${esc(target)}" placeholder="例如 D:\\projects\\${esc(proj)} 或 /home/me/${esc(proj)}" spellcheck="false"></div>
1991
+ <div class="mig-dim">填完整路径。目录不存在会自动创建;<b>已存在且有文件</b>会拒绝导入,避免覆盖。对话里的路径会自动改成这个目录。</div></div>
1992
+ <button class="mig-btn" id="mig-go">导入为新任务</button><div id="mig-res"></div>`;
1993
+ el.querySelector('#mig-go').onclick = async (ev) => {
1994
+ const btn = ev.target, res = el.querySelector('#mig-res');
1995
+ const cwd = el.querySelector('#mig-cwd').value.trim();
1996
+ if (!cwd) { res.innerHTML = '<div class="mig-warn">请填写目标目录</div>'; return; }
1997
+ btn.disabled = true; btn.textContent = '导入中…(文件多时要等一会儿)'; res.innerHTML = '';
1998
+ let d;
1999
+ try { d = await (await fetch('/api/migrate/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: r.token, cwd }) })).json(); }
2000
+ catch (e) { d = { ok: false, error: e.message }; }
2001
+ if (!d.ok) { btn.disabled = false; btn.textContent = '导入为新任务'; res.innerHTML = '<div class="mig-warn" style="margin-top:0.4rem">' + esc(d.error) + '</div>'; return; }
2002
+ btn.textContent = '✓ 导入完成';
2003
+ res.innerHTML = `<div style="margin-top:0.5rem">任务已导入到 <code>${esc(cwd)}</code>。${d.conv ? '打开后直接发消息就能接着聊。' : ''}${(d.secrets || []).length ? `<div class="mig-warn">别忘了在会话页「🔑 密钥」里重新录入:${d.secrets.map(esc).join('、')}</div>` : ''}</div>
2004
+ <button class="mig-btn" id="mig-open">打开这个任务</button>`;
2005
+ res.querySelector('#mig-open').onclick = () => { document.getElementById('mig-ov')?.remove(); openSession(d.id); };
2006
+ try { socket.emit('list'); } catch {}
2007
+ };
2008
+ }
2009
+
1853
2010
  function openSession(id) {
1854
2011
  if (wsOn()) return wsOpen(id);
1855
2012
  window.location.href = `/terminal/${id}`;
@@ -2357,6 +2514,7 @@
2357
2514
  ['✏️ 重命名', () => inlineRename(id)],
2358
2515
  ['🏷 设置标签', () => openTagEditor(id, anchor || { x: e.clientX, y: e.clientY })],
2359
2516
  ['↗ 在新窗口打开', () => window.open('/terminal/' + encodeURIComponent(id), '_blank')],
2517
+ ['📦 迁移到另一台 myhi', () => openMigrateExport(id)],
2360
2518
  ['🗑 终止会话', () => showKill(e, id, title), true],
2361
2519
  ];
2362
2520
  if (matchMedia('(pointer: fine)').matches) {