cicy-desktop 2.1.257 → 2.1.259

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.257",
3
+ "version": "2.1.259",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -52,10 +52,12 @@ const APP_PORT = Number(process.env.CICY_DOCKER_APP_PORT || 8008);
52
52
  // (含 port)区分。
53
53
  const APP_CONTAINER = process.env.CICY_DOCKER_APP_CONTAINER || `cicy-code-docker-${APP_PORT}`;
54
54
  const APP_VOLUME = process.env.CICY_DOCKER_APP_VOLUME || `cicy-team-${APP_PORT}`;
55
+ const KOUBO_PORT = 8770;
55
56
 
56
57
  // 用户自定义的额外发布端口(除 :8008 外,给容器内 agent 服务从 Windows 直达用)。
57
58
  // 持久化在 userData/docker-ports.json,bootstrap/recreate 都会带上 -p。lazy 取
58
- // 路径(app 未 ready 时不取)
59
+ // 路径(app 未 ready 时不取)。口播在这个容器内按需启动,因此容器创建时必须
60
+ // 预先发布 :8770;服务未启动时映射保持空闲,不会额外启动 koubo。
59
61
  function portsFile() { return path.join(require("electron").app.getPath("userData"), "docker-ports.json"); }
60
62
  function sanitizePorts(arr) {
61
63
  const out = [], seen = new Set([APP_PORT, 8008]);
@@ -67,7 +69,12 @@ function sanitizePorts(arr) {
67
69
  return out;
68
70
  }
69
71
  function readExtraPorts() {
70
- try { const j = JSON.parse(fs.readFileSync(portsFile(), "utf8")); return sanitizePorts(j && j.ports); } catch { return []; }
72
+ try {
73
+ const j = JSON.parse(fs.readFileSync(portsFile(), "utf8"));
74
+ return sanitizePorts([KOUBO_PORT, ...((j && Array.isArray(j.ports)) ? j.ports : [])]);
75
+ } catch {
76
+ return sanitizePorts([KOUBO_PORT]);
77
+ }
71
78
  }
72
79
  function writeExtraPorts(ports) {
73
80
  try { fs.writeFileSync(portsFile(), JSON.stringify({ ports: sanitizePorts(ports) }, null, 2), "utf8"); } catch (e) { log.warn("[docker-ports] write failed:", e.message); }
package/src/main.js CHANGED
@@ -1317,17 +1317,17 @@ electronApp.whenReady().then(async () => {
1317
1317
  }
1318
1318
 
1319
1319
  const profileStore = require("./profiles/profile-store");
1320
- const electronProfiles = (() => {
1320
+ const electronProfiles = () => {
1321
1321
  let rows = [];
1322
1322
  try { rows = profileStore.listProfiles("electron"); } catch {}
1323
1323
  // Profile 0 is the resident CiCy system window and must not be exposed in
1324
1324
  // the macOS title/top-bar launcher. Profile 9 is Chrome's source template.
1325
1325
  rows = rows.filter((p) => ![0, 9].includes(Number(p.accountIdx)));
1326
1326
  return rows;
1327
- })();
1328
- const chromeProfiles = (() => {
1327
+ };
1328
+ const chromeProfiles = () => {
1329
1329
  try { return profileStore.listProfiles("chrome"); } catch { return []; }
1330
- })();
1330
+ };
1331
1331
  const showProfileError = (kind, e) => {
1332
1332
  dialog.showMessageBox({
1333
1333
  type: "error",
@@ -1336,16 +1336,17 @@ electronApp.whenReady().then(async () => {
1336
1336
  buttons: ["OK"],
1337
1337
  });
1338
1338
  };
1339
- const openElectronProfile = async (accountIdx) => {
1339
+ const openElectronProfile = async (accountIdx, url) => {
1340
1340
  try {
1341
1341
  const tabs = require("./tools/tab-browser-tools");
1342
- await tabs.openTab(accountIdx, undefined, { activate: true });
1342
+ await tabs.openTab(accountIdx, url, { activate: true });
1343
1343
  const m = tabs.ensureManager(accountIdx);
1344
1344
  if (m.win.isMinimized()) m.win.restore();
1345
1345
  m.win.show();
1346
1346
  m.win.focus();
1347
1347
  } catch (e) { showProfileError("Electron", e); }
1348
1348
  };
1349
+ const openCicyAi = () => openElectronProfile(1, "https://cicy-ai.com");
1349
1350
  const openChromeProfile = async (accountIdx) => {
1350
1351
  try {
1351
1352
  await require("./tools/chrome-tools").launchOrActivateProfile({
@@ -1359,8 +1360,49 @@ electronApp.whenReady().then(async () => {
1359
1360
  catch (e) { showProfileError("Chrome", e); }
1360
1361
  };
1361
1362
 
1362
- const menuTemplate = [
1363
- ...(process.platform === "darwin" ? [{ role: "appMenu" }] : []),
1363
+ let installApplicationMenu = () => {};
1364
+ const addProfileFromMenu = async (kind) => {
1365
+ try {
1366
+ const result = await executeTool(kind === "Electron" ? "electron_add_profile" : "chrome_add_profile", {});
1367
+ const text = result && result.content && result.content[0] && result.content[0].text;
1368
+ let data = {};
1369
+ try { data = JSON.parse(text || "{}"); } catch {}
1370
+ if (result && result.isError) throw new Error(data.error || text || "unknown error");
1371
+ const accountIdx = Number(data.accountIdx ?? (data.created && data.created.accountIdx));
1372
+ installApplicationMenu(); // the new profile appears immediately in the native menu
1373
+ if (Number.isFinite(accountIdx)) {
1374
+ if (kind === "Electron") await openElectronProfile(accountIdx);
1375
+ else await openChromeProfile(accountIdx);
1376
+ }
1377
+ } catch (e) {
1378
+ dialog.showMessageBox({
1379
+ type: "error",
1380
+ message: `新增 ${kind} Profile 失败`,
1381
+ detail: String((e && e.message) || e),
1382
+ buttons: ["OK"],
1383
+ });
1384
+ }
1385
+ };
1386
+
1387
+ installApplicationMenu = () => {
1388
+ const electronRows = electronProfiles();
1389
+ const chromeRows = chromeProfiles();
1390
+ const menuTemplate = [
1391
+ ...(process.platform === "darwin" ? [{
1392
+ label: electronApp.name,
1393
+ submenu: [
1394
+ { role: "about" },
1395
+ { label: "打开 cicy-ai.com", click: openCicyAi },
1396
+ { type: "separator" },
1397
+ { role: "services" },
1398
+ { type: "separator" },
1399
+ { role: "hide" },
1400
+ { role: "hideOthers" },
1401
+ { role: "unhide" },
1402
+ { type: "separator" },
1403
+ { role: "quit" },
1404
+ ],
1405
+ }] : []),
1364
1406
  {
1365
1407
  label: i18n.t("menu.file"),
1366
1408
  submenu: [
@@ -1398,17 +1440,22 @@ electronApp.whenReady().then(async () => {
1398
1440
  },
1399
1441
  {
1400
1442
  label: "Electron",
1401
- submenu: electronProfiles.map((p) => ({
1402
- label: `Profile ${p.accountIdx}${p.name ? ` · ${p.name}` : ""}`,
1403
- click: () => openElectronProfile(Number(p.accountIdx)),
1404
- })),
1443
+ submenu: [
1444
+ { label: "新增 Profile", click: () => addProfileFromMenu("Electron") },
1445
+ ...(electronRows.length ? [{ type: "separator" }] : []),
1446
+ ...electronRows.map((p) => ({
1447
+ label: `Profile ${p.accountIdx}${p.name ? ` · ${p.name}` : ""}`,
1448
+ click: () => openElectronProfile(Number(p.accountIdx)),
1449
+ })),
1450
+ ],
1405
1451
  },
1406
1452
  {
1407
1453
  label: "Chrome",
1408
1454
  submenu: [
1455
+ { label: "新增 Profile", click: () => addProfileFromMenu("Chrome") },
1409
1456
  { label: "原生 Chrome", click: openNativeChrome },
1410
- { type: "separator" },
1411
- ...chromeProfiles.map((p) => ({
1457
+ ...(chromeRows.length ? [{ type: "separator" }] : []),
1458
+ ...chromeRows.map((p) => ({
1412
1459
  label: `Profile ${p.accountIdx}${p.gmail ? ` · ${p.gmail}` : p.note ? ` · ${p.note}` : ""}`,
1413
1460
  click: () => openChromeProfile(Number(p.accountIdx)),
1414
1461
  })),
@@ -1423,8 +1470,16 @@ electronApp.whenReady().then(async () => {
1423
1470
  : [{ label: i18n.t("menu.close"), role: "close" }]),
1424
1471
  ],
1425
1472
  },
1426
- ];
1427
- Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));
1473
+ ...(process.platform !== "darwin" ? [{
1474
+ label: i18n.t("menu.help"),
1475
+ submenu: [
1476
+ { label: "打开 cicy-ai.com", click: openCicyAi },
1477
+ ],
1478
+ }] : []),
1479
+ ];
1480
+ Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));
1481
+ };
1482
+ installApplicationMenu();
1428
1483
 
1429
1484
  // Always open the homepage unless launched at login with --hidden.
1430
1485
  const hidden = process.argv.includes("--hidden");
@@ -37,6 +37,32 @@ const ROOTFS_URL = process.env.CICY_WSL_ROOTFS_URL ||
37
37
  "https://cicy-1372193042-cn.oss-cn-shanghai.aliyuncs.com/rootfs/cicy-wsl-latest.tar.gz";
38
38
 
39
39
  function rootfsPath() { return path.join(docker.downloadsDir(), "cicy-wsl-rootfs.tar.gz"); }
40
+ function shellQuote(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; }
41
+
42
+ // `docker info`/Windows device detection is not enough: the NVIDIA Container
43
+ // Runtime may be missing even when Windows itself has an NVIDIA card. Prove that
44
+ // this Docker Engine can actually expose the GPU to the workload image. Cache
45
+ // briefly so a bootstrap/recreate flow does not launch repeated probe containers.
46
+ const gpuProbeCache = new Map();
47
+ async function dockerGpuAvailable({ image = IMAGE, force = false } = {}) {
48
+ const cacheKey = String(image);
49
+ const cached = gpuProbeCache.get(cacheKey);
50
+ if (!force && cached && Date.now() - cached.ts < 30000) return cached.value;
51
+
52
+ let value = false;
53
+ try {
54
+ await wslRun(
55
+ `docker run --rm --gpus all --entrypoint sh ${shellQuote(image)} -lc ` +
56
+ shellQuote("if command -v nvidia-smi >/dev/null 2>&1; then nvidia-smi -L >/dev/null; else test -c /dev/nvidiactl; fi"),
57
+ { timeout: 30000 }
58
+ );
59
+ value = true;
60
+ } catch (e) {
61
+ log.info(`[wsl-docker] NVIDIA GPU unavailable for ${cacheKey}: ${String(e.stderr || e.message || "").trim().slice(-300)}`);
62
+ }
63
+ gpuProbeCache.set(cacheKey, { ts: Date.now(), value });
64
+ return value;
65
+ }
40
66
  // WSL2 kernel update package (the small ~17MB MSI behind aka.ms/wsl2kernel).
41
67
  const KERNEL_MSI_URL = process.env.CICY_WSL_KERNEL_URL || "https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi";
42
68
 
@@ -251,7 +277,45 @@ function wslTerminate() {
251
277
  });
252
278
  }
253
279
 
254
- async function installDistro({ emit } = {}) {
280
+ function largestFixedDrive() {
281
+ return new Promise((resolve) => {
282
+ const script = [
283
+ "Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3'",
284
+ "| Where-Object { $_.FreeSpace -gt 0 }",
285
+ "| Sort-Object FreeSpace -Descending",
286
+ "| Select-Object -First 1 -ExpandProperty DeviceID",
287
+ ].join(" ");
288
+ execFile(
289
+ "powershell",
290
+ ["-NoProfile", "-NonInteractive", "-Command", script],
291
+ { timeout: 15000, windowsHide: true },
292
+ (err, stdout) => {
293
+ const drive = String(stdout || "").trim().toUpperCase();
294
+ resolve(!err && /^[A-Z]:$/.test(drive) ? drive : "");
295
+ },
296
+ );
297
+ });
298
+ }
299
+
300
+ async function resolveWslInstallDir(requested) {
301
+ const fallback = path.join(
302
+ process.env["LOCALAPPDATA"] || path.join(os.homedir(), "AppData", "Local"),
303
+ "cicy-code-wsl",
304
+ );
305
+ const raw = String(requested || process.env.CICY_WSL_INSTALL_DIR || "").trim();
306
+ if (!raw) {
307
+ const drive = await largestFixedDrive();
308
+ return drive ? path.win32.join(`${drive}\\`, "CiCy", "cicy-code-wsl") : fallback;
309
+ }
310
+ // This runs on Windows, but win32.isAbsolute also makes the validation
311
+ // deterministic in CI on other platforms.
312
+ if (!path.win32.isAbsolute(raw)) {
313
+ throw new Error("WSL 安装目录必须是绝对路径,例如 D:\\CiCy\\wsl");
314
+ }
315
+ return path.win32.normalize(raw);
316
+ }
317
+
318
+ async function installDistro({ emit, installDir: requestedInstallDir } = {}) {
255
319
  // 1) Download the PRE-BAKED rootfs (Ubuntu+Docker+image baked in, ~122MB) with
256
320
  // a real progress bar. curl is ~10× faster than node's downloader on OSS.
257
321
  const dest = rootfsPath();
@@ -279,7 +343,7 @@ async function installDistro({ emit } = {}) {
279
343
  // distro to v2 — we do NOT run `--set-default-version` (that would change the
280
344
  // user's global default). The WSL2 kernel is shared; we install it ONLY when
281
345
  // import actually fails for lack of it (never downgrade an existing kernel).
282
- const installDir = path.join(process.env["LOCALAPPDATA"] || path.join(os.homedir(), "AppData", "Local"), "cicy-code-wsl");
346
+ const installDir = await resolveWslInstallDir(requestedInstallDir);
283
347
  try { fs.mkdirSync(installDir, { recursive: true }); } catch {}
284
348
  emit && emit({ phase: "container", status: "running", message: `$ wsl --import ${DISTRO} "${installDir}" "${dest}" --version 2(1-4 分钟,无实时进度,请耐心)` });
285
349
  try {
@@ -601,7 +665,7 @@ function publishArgs(port, extraPorts = []) {
601
665
  return args;
602
666
  }
603
667
 
604
- async function runContainer({ port = 8008, container = "cicy-code-docker", volume = "cicy-team-8008", env = {}, extraPorts = [], dockerSock = false, emit } = {}) {
668
+ async function runContainer({ port = 8008, container = "cicy-code-docker", volume = "cicy-team-8008", env = {}, extraPorts = [], dockerSock = false, gpu = false, gpuProbeImage = IMAGE, emit } = {}) {
605
669
  // 每次容器"启动"(含已在跑被 adopt)都确保桌面快捷方式存在 —— 不存在就建,坏了就修。
606
670
  if (await probeHealth(port)) { ensureDesktopShortcut(volume, port).catch(() => {}); return { adopted: true }; }
607
671
  // Replace any stale same-named container.
@@ -626,7 +690,14 @@ async function runContainer({ port = 8008, container = "cicy-code-docker", volum
626
690
  let doodOn = dockerSock;
627
691
  if (!doodOn) { try { doodOn = require("./cicy-code").isDood(); } catch {} }
628
692
  const sockArg = doodOn ? await dockerSockMountArg() : "";
629
- const cmd = `docker run -d --name ${container} --restart unless-stopped --dns 223.5.5.5 --dns 8.8.8.8 ${publishArgs(port, extraPorts)} -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${projectsMountArg()} ${drivesArg} ${sockArg} ${envArgs} ${IMAGE}`;
693
+ const gpuEnabled = (gpu === true || gpu === "auto")
694
+ ? await dockerGpuAvailable({ image: gpuProbeImage })
695
+ : false;
696
+ const gpuArg = gpuEnabled ? "--gpus all" : "";
697
+ if (gpu && !gpuEnabled && emit) {
698
+ emit({ phase: "container", status: "running", message: "未检测到可用的 NVIDIA Container Runtime,按 CPU 模式启动" });
699
+ }
700
+ const cmd = `docker run -d --name ${container} --restart unless-stopped ${gpuArg} --dns 223.5.5.5 --dns 8.8.8.8 ${publishArgs(port, extraPorts)} -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${projectsMountArg()} ${drivesArg} ${sockArg} ${envArgs} ${IMAGE}`;
630
701
  emit && emit({ phase: "container", status: "running", message: `$ ${cmd.length > 220 ? cmd.slice(0, 220) + " …" : cmd}` });
631
702
  await wslRun(cmd, { timeout: 60000 }); // 失败时 err.stderr 带 docker 真错误 → _bootstrap 的 errTail 显示
632
703
  ensureDesktopShortcut(volume, port).catch(() => {});
@@ -791,7 +862,7 @@ async function bootstrap(opts = {}) {
791
862
  // 注意: 默认容器/卷名保持 cicy-team / cicy-code-docker(回退实测"现在不行了"的改动)。
792
863
  // live 路径(docker:app-bootstrap)始终传显式 APP_*(cicy-team-8008)名,不靠这里的默认;
793
864
  // 改默认会让既有 cicy-team 卷的装机对不上 → 退回原值。
794
- async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-docker", volume = "cicy-team", env = {}, extraPorts = [], dockerSock = false } = {}) {
865
+ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-docker", volume = "cicy-team", env = {}, extraPorts = [], dockerSock = false, gpu = false, gpuProbeImage = IMAGE, wslInstallDir } = {}) {
795
866
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
796
867
 
797
868
  // Structured, PERSISTED trace of the whole run (electron-log → main.log) so a
@@ -859,7 +930,7 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
859
930
  // 2) Ubuntu distro
860
931
  begin("ensure-distro");
861
932
  if (!(await distroInstalled())) {
862
- try { await installDistro({ emit }); } catch (e) { fail("distro_install_failed", e.message); emit({ phase: "install-docker", status: "error", message: `Ubuntu 安装失败:${e.message}(点重试)${errTail(e)}` }); finish(false, "distro_install_failed"); return { ok: false, reason: "distro_install_failed" }; }
933
+ try { await installDistro({ emit, installDir: wslInstallDir }); } catch (e) { fail("distro_install_failed", e.message); emit({ phase: "install-docker", status: "error", message: `Ubuntu 安装失败:${e.message}(点重试)${errTail(e)}` }); finish(false, "distro_install_failed"); return { ok: false, reason: "distro_install_failed" }; }
863
934
  const t0 = Date.now();
864
935
  const ok = await docker.waitUntil(() => distroInstalled(), { totalMs: 600000, everyMs: 5000, onTick: () => emit({ phase: "install-docker", status: "running", message: `正在下载/注册 Ubuntu…(已 ${Math.round((Date.now() - t0) / 1000)}s,首次较慢请耐心)` }) });
865
936
  if (!ok) { fail("distro_not_ready"); emit({ phase: "install-docker", status: "error", message: "Ubuntu 还没装好——稍等或点「重试」" }); finish(false, "distro_not_ready"); return { ok: false, reason: "distro_not_ready" }; }
@@ -902,7 +973,7 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
902
973
  begin("run-container");
903
974
  if (!(await probeHealth(port))) {
904
975
  emit({ phase: "container", status: "running", message: "启动 cicy-code 服务…" });
905
- try { await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit }); }
976
+ try { await runContainer({ port, container, volume, env, extraPorts, dockerSock, gpu, gpuProbeImage, emit }); }
906
977
  catch (e) { fail("container_start_failed", e.message); emit({ phase: "container", status: "error", message: `服务启动失败:${e.message}(点重试)${errTail(e)}` }); finish(false, "container_start_failed"); return { ok: false, reason: "container_start_failed" }; }
907
978
  done();
908
979
  } else done(true);
@@ -1055,14 +1126,14 @@ async function dockerRestart({ container = "cicy-code-docker-8008" } = {}) {
1055
1126
  // 重建容器:docker rm -f 旧容器 + docker run 新容器(用新 env,如新的 docker team 网关
1056
1127
  // key)。**保留 volume**(数据/api_token/deviceId 不丢),只是换掉容器本身 + env。
1057
1128
  // 破坏性(短暂中断 + 换 key)→ 调用方要 confirm。
1058
- async function recreate({ onProgress, port = 8008, container = "cicy-code-docker-8008", volume = "cicy-team-8008", env = {}, extraPorts = [], dockerSock = false } = {}) {
1129
+ async function recreate({ onProgress, port = 8008, container = "cicy-code-docker-8008", volume = "cicy-team-8008", env = {}, extraPorts = [], dockerSock = false, gpu = false, gpuProbeImage = IMAGE } = {}) {
1059
1130
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
1060
1131
  // 重建 = 用最新镜像重建。OSS 有更新版先刷新(非破坏性,不删发行版/volume),再 rm + run。
1061
1132
  try { await ensureFreshImage({ emit }); } catch (e) { emit({ phase: "image", status: "running", message: `镜像刷新跳过(${e.message}),用现有镜像重建` }); }
1062
1133
  // 强删占用该端口的**任何**容器(含老名字 cicy-code-docker)+ 目标容器 —— 否则
1063
1134
  // runContainer 开头的 probeHealth 看到旧容器还健康会 adopt 它、不重建,key 就换不了。
1064
1135
  try { await wslRun(`docker ps -aq --filter publish=${port} | xargs -r docker rm -f 2>/dev/null; docker rm -f ${container} 2>/dev/null; true`, { timeout: 30000 }); } catch {}
1065
- const r = await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit });
1136
+ const r = await runContainer({ port, container, volume, env, extraPorts, dockerSock, gpu, gpuProbeImage, emit });
1066
1137
  try { await ensureDesktopShortcut(volume, port); } catch {}
1067
1138
  // 等 :8008 真正起来再返回——否则 docker run 一返回卡片就探测「没运行」,显示「启动」让
1068
1139
  // 用户再点一下(实测:端口保存后重建,drawer 关了还要手动点启动)。
@@ -1085,13 +1156,13 @@ function unregisterDistro() {
1085
1156
  // downloader, which copes with the flaky CN DNS that bare curl can't) is the
1086
1157
  // only reliable CN update path. This RESETS the distro: the cicy-team volume is
1087
1158
  // re-created and the instance re-seeds (new token) on next boot.
1088
- async function upgrade({ onProgress, port = 8008, container = "cicy-code-docker", volume = "cicy-team", env = {} } = {}) {
1159
+ async function upgrade({ onProgress, port = 8008, container = "cicy-code-docker", volume = "cicy-team", env = {}, extraPorts = [], dockerSock = false, gpu = false, gpuProbeImage = IMAGE, wslInstallDir } = {}) {
1089
1160
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
1090
1161
  emit({ phase: "install-docker", status: "running", message: "升级 = 拉取最新运行环境并重装(会重置容器数据)…" });
1091
1162
  try { await stop({ container }); } catch {}
1092
1163
  try { await unregisterDistro(); } catch {}
1093
1164
  // Reuse the robust one-shot install flow (download → import → dockerd → run).
1094
- return await _bootstrap({ onProgress, port, container, volume, env, dockerSock });
1165
+ return await _bootstrap({ onProgress, port, container, volume, env, extraPorts, dockerSock, gpu, gpuProbeImage, wslInstallDir });
1095
1166
  }
1096
1167
 
1097
1168
  // 容器里有没有注入网关 key(reconcile 自愈用):printenv 看 CICY_AI_GATEWAY_LLM_API_KEY。
@@ -1109,5 +1180,5 @@ async function readMihomoConfig(container = "cicy-code-docker-8008") {
1109
1180
  module.exports = {
1110
1181
  bootstrap, status, restart, stop, dockerRestart, recreate, update, upgrade, runContainer, readContainerToken,
1111
1182
  distroInstalled, dockerInstalled, dockerEngineUp, imagePresent, probeHealth, wslRun, hasGatewayKey,
1112
- readMihomoConfig, repairWsl, lxssWedged,
1183
+ readMihomoConfig, repairWsl, lxssWedged, dockerGpuAvailable, resolveWslInstallDir,
1113
1184
  };
@@ -12,9 +12,10 @@ module.exports = [
12
12
  require("./exec-js"),
13
13
  require("./clipboard-tools"),
14
14
  require("./exec-tools"),
15
- require("./file-tools"),
16
- require("./system-tools"),
17
- require("./automation-tools"),
15
+ require("./file-tools"),
16
+ require("./system-tools"),
17
+ require("./notify-tools"),
18
+ require("./automation-tools"),
18
19
  require("./account-tools"),
19
20
  require("./device-tools"),
20
21
  require("./desktop-snapshot-tools"),
@@ -0,0 +1,60 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const { Notification, BrowserWindow, shell } = require("electron");
5
+ const { z } = require("zod");
6
+
7
+ // OS desktop notification (mac Notification Center / Windows toast / Linux
8
+ // libnotify). Main-process Electron Notification — no renderer/window needed,
9
+ // so it works even when every window is closed or minimized.
10
+ //
11
+ // Windows: toasts require an AppUserModelId, which main.js already sets
12
+ // ("com.cicy.desktop"). Packaged builds inherit it; dev runs show the toast
13
+ // attributed to Electron until packaged.
14
+ //
15
+ // Click behavior: focus the main CiCy Desktop window (default), then open
16
+ // `url` in the system browser if given. Deep-linking into an in-app tab is
17
+ // deliberately NOT done here — tab routing belongs to tab-browser tools.
18
+ function registerTools(registerTool) {
19
+ registerTool(
20
+ "notify",
21
+ "发送操作系统桌面通知(mac 通知中心 / Windows toast)。点击通知默认聚焦 CiCy Desktop 主窗口;可选 url 在点击时用系统浏览器打开。",
22
+ z.object({
23
+ title: z.string().describe("通知标题(大字)"),
24
+ body: z.string().optional().describe("通知正文(小字,放 prompt/消息摘要)"),
25
+ subtitle: z.string().optional().describe("副标题(仅 macOS 显示)"),
26
+ silent: z.boolean().optional().describe("true = 不播放提示音"),
27
+ url: z.string().optional().describe("点击通知时用系统默认浏览器打开的 URL"),
28
+ focus: z.boolean().optional().describe("点击通知时聚焦主窗口,默认 true"),
29
+ }),
30
+ async ({ title, body, subtitle, silent, url, focus }) => {
31
+ if (!Notification.isSupported()) {
32
+ return {
33
+ content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "notifications not supported on this platform" }) }],
34
+ isError: true,
35
+ };
36
+ }
37
+ const n = new Notification({ title, body: body || "", subtitle, silent: !!silent });
38
+ n.on("click", () => {
39
+ try {
40
+ if (focus !== false) {
41
+ const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed());
42
+ if (win) {
43
+ if (win.isMinimized()) win.restore();
44
+ win.show();
45
+ win.focus();
46
+ }
47
+ }
48
+ if (url) shell.openExternal(url);
49
+ } catch {}
50
+ });
51
+ n.show();
52
+ return {
53
+ content: [{ type: "text", text: JSON.stringify({ ok: true, platform: process.platform, title }) }],
54
+ };
55
+ },
56
+ { tag: "System" }
57
+ );
58
+ }
59
+
60
+ module.exports = registerTools;