cicy-desktop 2.1.69 → 2.1.71

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.
Files changed (61) hide show
  1. package/package.json +7 -7
  2. package/src/backends/homepage-react/assets/index-BpljolQs.js +365 -0
  3. package/src/backends/homepage-react/assets/{index-BniEbx_j.css → index-C9AZlTew.css} +1 -1
  4. package/src/backends/homepage-react/index.html +2 -2
  5. package/src/backends/local-teams.js +42 -4
  6. package/src/backends/sidecar-ipc.js +23 -1
  7. package/src/i18n/locales/en.json +9 -7
  8. package/src/i18n/locales/zh-CN.json +9 -7
  9. package/src/sidecar/cicy-code.js +54 -111
  10. package/src/sidecar/localbin.js +133 -0
  11. package/src/sidecar/native.js +4 -2
  12. package/src/sidecar/runtime.js +7 -3
  13. package/workers/render/src/App.css +156 -10
  14. package/workers/render/src/App.jsx +254 -39
  15. package/.env.dev +0 -7
  16. package/src/backends/homepage-react/assets/index-B8gGhz8B.js +0 -365
  17. package/workers/render.bak.20260528-2338/DESIGN_v2.md +0 -254
  18. package/workers/render.bak.20260528-2338/index.html +0 -12
  19. package/workers/render.bak.20260528-2338/package-lock.json +0 -827
  20. package/workers/render.bak.20260528-2338/package.json +0 -19
  21. package/workers/render.bak.20260528-2338/public/_headers +0 -5
  22. package/workers/render.bak.20260528-2338/public/manifest.json +0 -6
  23. package/workers/render.bak.20260528-2338/src/App.css +0 -224
  24. package/workers/render.bak.20260528-2338/src/App.jsx +0 -1028
  25. package/workers/render.bak.20260528-2338/src/api.js +0 -285
  26. package/workers/render.bak.20260528-2338/src/cicycode-ops.js +0 -222
  27. package/workers/render.bak.20260528-2338/src/components/BackendCard.css +0 -299
  28. package/workers/render.bak.20260528-2338/src/components/BackendCard.jsx +0 -133
  29. package/workers/render.bak.20260528-2338/src/components/BackendModal.css +0 -161
  30. package/workers/render.bak.20260528-2338/src/components/BackendModal.jsx +0 -199
  31. package/workers/render.bak.20260528-2338/src/components/Button.css +0 -72
  32. package/workers/render.bak.20260528-2338/src/components/Button.jsx +0 -37
  33. package/workers/render.bak.20260528-2338/src/components/Card.css +0 -42
  34. package/workers/render.bak.20260528-2338/src/components/Card.jsx +0 -21
  35. package/workers/render.bak.20260528-2338/src/components/Icon.jsx +0 -30
  36. package/workers/render.bak.20260528-2338/src/components/Menu.css +0 -55
  37. package/workers/render.bak.20260528-2338/src/components/Menu.jsx +0 -91
  38. package/workers/render.bak.20260528-2338/src/components/SidecarBanner.css +0 -79
  39. package/workers/render.bak.20260528-2338/src/components/SidecarBanner.jsx +0 -84
  40. package/workers/render.bak.20260528-2338/src/components/StatusChip.css +0 -19
  41. package/workers/render.bak.20260528-2338/src/components/StatusChip.jsx +0 -31
  42. package/workers/render.bak.20260528-2338/src/components/Toast.css +0 -31
  43. package/workers/render.bak.20260528-2338/src/components/Toast.jsx +0 -23
  44. package/workers/render.bak.20260528-2338/src/components/WslSetupBanner.css +0 -464
  45. package/workers/render.bak.20260528-2338/src/components/WslSetupBanner.jsx +0 -716
  46. package/workers/render.bak.20260528-2338/src/dockerInstaller.js +0 -0
  47. package/workers/render.bak.20260528-2338/src/i18n/en.json +0 -116
  48. package/workers/render.bak.20260528-2338/src/i18n/fr.json +0 -116
  49. package/workers/render.bak.20260528-2338/src/i18n/index.js +0 -69
  50. package/workers/render.bak.20260528-2338/src/i18n/ja.json +0 -116
  51. package/workers/render.bak.20260528-2338/src/i18n/zh-CN.json +0 -121
  52. package/workers/render.bak.20260528-2338/src/main.js +0 -475
  53. package/workers/render.bak.20260528-2338/src/main.jsx +0 -18
  54. package/workers/render.bak.20260528-2338/src/style.css +0 -275
  55. package/workers/render.bak.20260528-2338/src/styles/base.css +0 -98
  56. package/workers/render.bak.20260528-2338/src/styles/tokens.css +0 -90
  57. package/workers/render.bak.20260528-2338/src/tos.js +0 -72
  58. package/workers/render.bak.20260528-2338/src/worker.js +0 -40
  59. package/workers/render.bak.20260528-2338/src/wslInstaller.js +0 -1563
  60. package/workers/render.bak.20260528-2338/vite.config.js +0 -36
  61. package/workers/render.bak.20260528-2338/wrangler.toml +0 -17
@@ -1,1028 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import { api, apiMode } from "./api.js";
3
- import Button from "./components/Button.jsx";
4
- import Icon from "./components/Icon.jsx";
5
- import BackendCard from "./components/BackendCard.jsx";
6
- import SidecarBanner from "./components/SidecarBanner.jsx";
7
- import WslSetupBanner from "./components/WslSetupBanner.jsx";
8
- import BackendModal from "./components/BackendModal.jsx";
9
- import Toast, { pushToast } from "./components/Toast.jsx";
10
- import { useT } from "./i18n";
11
- import "./App.css";
12
-
13
- // Helper-instance bootstrap. URL is the public-IP helper endpoint; token is
14
- // fetched at click time from /api/helper/token (CF Worker reads a wrangler-
15
- // secret env var) so it never ships in the client bundle. The helper SPA
16
- // is embedded as an in-homepage drawer iframe (no separate window) — the
17
- // `?token=` URL param auth-bootstraps the cicy-code SPA inside, since
18
- // localStorage on a cross-origin iframe is its own per-origin store and
19
- // re-using the `?token=` bootstrap is the simplest reliable handoff.
20
- const HELPER_URL = "http://43.99.56.150:8011/";
21
-
22
- export default function App() {
23
- const t = useT();
24
- const [backends, setBackends] = useState([]);
25
- const [healthMap, setHealthMap] = useState({});
26
- const [sidecar, setSidecar] = useState({ state: "checking", info: null, progress: null, error: null });
27
- const [wsl, setWsl] = useState(null);
28
- const [wslChecking, setWslChecking] = useState(false);
29
- const [openingId, setOpeningId] = useState(null);
30
- const [tab, setTab] = useState("all"); // "all" | "local" | "cloud"
31
- const [modalState, setModalState] = useState({ open: false, mode: "add", backend: null });
32
- const [appVersion, setAppVersion] = useState(""); // cicy-desktop version, populated in electron mode
33
- // Install state is ephemeral — surviving a reload only leaves zombie
34
- // step rows ("AI 引擎已就绪 ✓") whose phase finished in a *previous* epoch
35
- // but the new container/image state may be completely different. Cleaner
36
- // to start blank every mount; sidecar state polling will land the truth
37
- // within 5s anyway.
38
- const [installLog, setInstallLog] = useState([]);
39
- const [installSteps, setInstallSteps] = useState([]);
40
- // Agent picker handshake: wslInstaller awaits onPickAgents() and we
41
- // stash the resolver here. WslSetupBanner reads pickAgents, renders
42
- // the checkbox UI, and calls resolve() on click. Null when no pick
43
- // is pending.
44
- const [pickAgents, setPickAgents] = useState(null);
45
- // 团队助手 — 常驻右侧 webview, 默认 50% 宽,可拖左边缘调宽度。
46
- // 没有可见 chrome (无 header / 无 toggle 按钮),webview 上下通栏。
47
- const [helperCloudToken, setHelperCloudToken] = useState("");
48
- const [helperLocalUrl, setHelperLocalUrl] = useState("");
49
- const [helperBootError, setHelperBootError] = useState("");
50
- const [helperWidth, setHelperWidth] = useState(() => {
51
- if (typeof window === "undefined") return 720;
52
- return Math.round(window.innerWidth * 0.5);
53
- });
54
-
55
- // (install state persistence removed — see useState initializers above)
56
-
57
-
58
- // Docker model: WSL state is irrelevant (on Windows, Docker Desktop manages
59
- // WSL2 itself). Always clear it so no WSL-specific banner ("WSL not
60
- // supported / not installed") ever shows — sidecar.state (from dockerStatus)
61
- // drives the whole flow now.
62
- const checkWsl = useCallback(async () => {
63
- setWsl(null);
64
- }, []);
65
-
66
- // ── Loaders ──
67
- const loadBackends = useCallback(async () => {
68
- const list = await api.backends.list();
69
- setBackends(list);
70
- return list;
71
- }, []);
72
-
73
- const loadHealth = useCallback(async (list) => {
74
- const items = list || backends;
75
- if (!items.length) return;
76
- const results = await api.backends.healthAll();
77
- const next = {};
78
- for (const r of results) next[r.id] = r.health;
79
- setHealthMap(next);
80
- }, [backends]);
81
-
82
- const checkSidecar = useCallback(async () => {
83
- // Web / cloud mode (no Electron RPC) — nothing local to manage.
84
- if (apiMode !== "electron" || typeof window.electronRPC !== "function") {
85
- setSidecar({ state: "uptodate", info: null, progress: null, error: null });
86
- setInstallSteps([]);
87
- setInstallLog([]);
88
- return;
89
- }
90
- // Docker model: determine state from the renderer via `docker` over
91
- // exec_shell (no main-process sidecar API — fixes ship via the CF Worker).
92
- try {
93
- const { dockerStatus } = await import("./dockerInstaller.js");
94
- const st = await dockerStatus();
95
- // Docker CLI absent → first thing the user needs is Docker Desktop;
96
- // the install flow guides that download, so surface "install".
97
- // Also: image not here and no container yet → needs install.
98
- if (!st.dockerInstalled || (!st.imagePresent && !st.containerExists)) {
99
- setSidecar((s) => ({ ...s, state: "missing", info: null, running: false, error: null }));
100
- return;
101
- }
102
- // Installed. running = container up AND actually serving :8008, so the
103
- // "Open" button only shows when the workspace will really load. When the
104
- // daemon is down or the container is stopped, running:false → "Start"
105
- // (handleStart → ensureRunning, which also boots Docker Desktop).
106
- setSidecar((s) => ({
107
- ...s,
108
- state: st.updateAvailable ? "outdated" : "uptodate",
109
- info: { installedVersion: st.version, latest: st.target },
110
- running: !!(st.containerRunning && st.healthy),
111
- dockerDown: !st.dockerRunning,
112
- error: null,
113
- }));
114
- // Drop a stale partial install timeline (keep a completed one's "done").
115
- setInstallSteps((steps) => {
116
- if (!steps || steps.length === 0) return steps;
117
- return steps[steps.length - 1].phase === "done" ? steps : [];
118
- });
119
- } catch (e) {
120
- setSidecar((s) => ({ ...s, state: "error", error: e.message }));
121
- }
122
- }, []);
123
-
124
- // Click "启动" — spawn the daemon, then re-check status so the button
125
- // flips to "打开" once :8008 is reachable.
126
- const handleStart = useCallback(async () => {
127
- if (typeof window.electronRPC !== "function") return;
128
- setSidecar((s) => ({ ...s, starting: true }));
129
- try {
130
- // ensureRunning: boot Docker Desktop if needed → start/create the
131
- // container → wait for :8008. Idempotent. If the image isn't here yet
132
- // (needsInstall), fall through to the full install flow.
133
- const { ensureRunning } = await import("./dockerInstaller.js");
134
- const r = await ensureRunning({ onProgress: () => {} });
135
- if (r?.needsInstall) { await handleInstall(); return; }
136
- if (!r?.ok) pushToast(t("toast.start_failed", { error: "engine not ready" }), "error");
137
- } catch (e) {
138
- pushToast(t("toast.start_failed", { error: e.message }), "error");
139
- } finally {
140
- setSidecar((s) => ({ ...s, starting: false }));
141
- checkSidecar();
142
- }
143
- }, [checkSidecar, t]);
144
-
145
- // ── Init ──
146
- useEffect(() => {
147
- (async () => {
148
- await loadBackends();
149
- checkSidecar();
150
- checkWsl();
151
-
152
- // Pull cicy-desktop version (electron mode only) for the topbar badge.
153
- if (window.cicy?.app?.getVersion) {
154
- try {
155
- const v = await window.cicy.app.getVersion();
156
- if (v?.desktop) setAppVersion(`v${v.desktop}`);
157
- } catch {}
158
- }
159
- })();
160
- }, [loadBackends, checkSidecar, checkWsl]);
161
-
162
- // ── Sidecar / health polling ──
163
- // The card state (Install / Open / Update / Retry) is derived from
164
- // dockerStatus + /api/health. Without polling, the UI freezes on
165
- // whatever the first probe saw — e.g. you stop the container externally
166
- // and the card still shows "Open" until you reload the window.
167
- // 5s gives near-realtime feedback without hammering Docker.
168
- useEffect(() => {
169
- if (typeof window.electronRPC !== "function") return; // browser/cloud mode: nothing local to poll
170
- const tick = () => {
171
- // skip while an install is in flight — checkSidecar resets state and
172
- // would clobber the progress UI
173
- setSidecar((s) => {
174
- if (s && (s.state === "installing" || s.starting)) return s;
175
- // outside the install path: refresh from main process
176
- checkSidecar();
177
- return s;
178
- });
179
- // also refresh /api/health for the open-disabled gate
180
- if (backends.length > 0) loadHealth(backends);
181
- };
182
- const id = setInterval(tick, 5000);
183
- return () => clearInterval(id);
184
- }, [checkSidecar, loadHealth, backends]);
185
-
186
- // After backends load, kick off health.
187
- useEffect(() => {
188
- if (!backends.length) return;
189
- loadHealth(backends);
190
- const t = setInterval(() => loadHealth(backends), 8000);
191
- return () => clearInterval(t);
192
- }, [backends, loadHealth]);
193
-
194
- // Subscribe to sidecar progress events.
195
- useEffect(() => {
196
- if (apiMode !== "electron" || !window.cicy?.sidecar?.onProgress) return;
197
- return window.cicy.sidecar.onProgress((p) => {
198
- setSidecar((s) => {
199
- if (s.state !== "installing") return s;
200
- if (p.phase === "error") return { ...s, state: "error", error: p.error || p.message, progress: null };
201
- if (p.phase === "cancelled") { checkSidecar(); return { ...s, state: "checking", progress: null }; }
202
- return { ...s, progress: p };
203
- });
204
- });
205
- }, [checkSidecar]);
206
-
207
- // ── Deep link: cicy://addTeam?title=&url=&token= ──
208
- useEffect(() => {
209
- if (apiMode !== "electron" || !window.cicy?.deeplink?.onAddTeam) return;
210
- return window.cicy.deeplink.onAddTeam(async ({ title, url, token }) => {
211
- if (!url) return;
212
- // De-duplicate by URL origin
213
- const list = await api.backends.list();
214
- const exists = list.some((b) => {
215
- try { return new URL(b.url || "").origin === new URL(url).origin; } catch { return false; }
216
- });
217
- if (exists) return;
218
- await api.backends.add({ name: title || url, url, token: token || undefined });
219
- await loadBackends();
220
- });
221
- }, [loadBackends]);
222
-
223
- // ── Actions ──
224
- const handleOpen = async (b) => {
225
- setOpeningId(b.id);
226
- try {
227
- if (window.electronRPC) {
228
- // Local Docker backend: make sure the container is actually running
229
- // before reading its token / opening it. Handles the common cases —
230
- // post-reboot (container auto-restarts but maybe still booting),
231
- // stopped container, or Docker Desktop not started yet. No-op when
232
- // already healthy. If the image isn't installed yet, divert to install.
233
- if (b.id === "local") {
234
- // Fast path: if SOMETHING is already serving cicy-code on :8008
235
- // — the new Docker container (post-pivot), a legacy WSL-mode
236
- // daemon (Windows pre-Docker installs), or the bundled native
237
- // sidecar (older cicy-desktop builds) — skip the Docker-only
238
- // ensureRunning() gate and open it. dockerStatus has no way to
239
- // see legacy/sidecar instances, so without this fast path
240
- // upgrading users would be force-marched into a Docker install
241
- // they don't need.
242
- let alreadyHealthy = false;
243
- try {
244
- alreadyHealthy = (await fetch("http://127.0.0.1:8008/api/health", { cache: "no-store" })).ok;
245
- } catch (e) {}
246
- if (!alreadyHealthy) {
247
- try {
248
- const { ensureRunning } = await import("./dockerInstaller.js");
249
- const r = await ensureRunning({ onProgress: () => {} });
250
- if (r?.needsInstall) { setOpeningId(null); await handleInstall(); return; }
251
- } catch (e) {
252
- pushToast(t("toast.start_failed", { error: e.message }), "error");
253
- setOpeningId(null);
254
- return;
255
- }
256
- }
257
- }
258
- try {
259
- // Obtain the access token — and we WILL obtain it. The token always
260
- // physically exists in the WSL runtime (~/cicy-ai/global.json, written
261
- // by cicy-code on first run); a miss only means the distro is asleep
262
- // or still booting, and the read itself wakes it. So we retry until we
263
- // have a tokened URL rather than ever opening a "paste your token"
264
- // login page. Two sources, both retried:
265
- // 1. resolveUrl (the desktop's own resolver, handles local+remote)
266
- // 2. direct read of global.json over `wsl` (local backend fallback;
267
- // the wsl invocation also boots a stopped distro)
268
- let url = b.resolvedUrl || b.url;
269
- const hasToken = (u) => /[?&]token=/.test(u || "");
270
- for (let i = 0; i < 30 && !hasToken(url); i++) {
271
- if (window.cicy?.backends?.resolveUrl) {
272
- try { url = (await window.cicy.backends.resolveUrl(b.id)) || url; } catch (e) {}
273
- }
274
- if (!hasToken(url) && b.id === "local") {
275
- try {
276
- const rr = await window.electronRPC("exec_shell", {
277
- command: 'docker exec cicy cat /home/cicy/cicy-ai/global.json 2>/dev/null',
278
- timeout_ms: 9000,
279
- });
280
- let out = (rr?.content || []).map((c) => c.text).join("");
281
- try { out = JSON.parse(out).stdout || out; } catch (e) {}
282
- const tm = out.match(/"api_token"\s*:\s*"([^"]+)"/);
283
- if (tm) url = "http://127.0.0.1:8008/?token=" + tm[1];
284
- } catch (e) {}
285
- }
286
- if (hasToken(url)) break;
287
- // Tell the user we're waking the runtime rather than silently hanging.
288
- if (i === 0) pushToast("正在唤醒运行环境…", "info");
289
- await new Promise((r) => setTimeout(r, 2500));
290
- }
291
- if (!hasToken(url)) {
292
- // ~75s of retries and still nothing readable — the runtime is
293
- // genuinely broken (not just asleep). Surface it instead of opening
294
- // a dead login window.
295
- pushToast("无法获取访问令牌,请重启应用后重试", "error");
296
- return;
297
- }
298
- // For LOCAL ONLY: gate the open on /api/health. The local cicy-code
299
- // container can take minutes to bind :8008 on a cold first start
300
- // (cicy-mihomo download trails the API listener). Opening before
301
- // the server answers strands the window on a connection-error
302
- // / login page that no in-window reload can fix until the server
303
- // is up. A warm backend answers on the first probe (instant).
304
- //
305
- // Remote backends are skipped: they're just a cloud URL the user
306
- // typed in, no local supervisor to wake up. Also, the homepage
307
- // ships over https://desktop.cicy-ai.com so a renderer-side
308
- // fetch() against an http://… remote is blocked by mixed-content
309
- // (Failed to fetch), which would falsely fail the gate.
310
- if (b.id === "local") {
311
- const origin = (url.match(/^https?:\/\/[^/]+/) || [])[0];
312
- let apiUp = !origin;
313
- for (let i = 0; origin && i < 150 && !apiUp; i++) {
314
- try { apiUp = (await fetch(origin + "/api/health", { cache: "no-store" })).ok; } catch (e) {}
315
- if (apiUp) break;
316
- if (i === 0) pushToast("正在启动运行环境,请稍候…", "info");
317
- await new Promise((r) => setTimeout(r, 2000));
318
- }
319
- if (!apiUp) {
320
- pushToast("运行环境启动较慢,请稍后重新点击打开", "error");
321
- return;
322
- }
323
- }
324
- // Check if any window is already showing this backend. Compare by
325
- // origin+pathname only — the token in the query string rotates on
326
- // every resolveUrl call, so a full-string match would always miss.
327
- const wins = await fetchWindows();
328
- const targetBase = stripQuery(url);
329
- const existing = wins.find((w) => w.url && stripQuery(w.url) === targetBase);
330
- let targetWid = existing ? existing.id : null;
331
- if (targetWid == null) {
332
- const opened = await window.electronRPC("open_window", { url, reuseWindow: false });
333
- const openedTxt = (opened?.content || []).map((c) => c.text).join("");
334
- const m = openedTxt.match(/ID:\s*(\d+)/);
335
- targetWid = m ? parseInt(m[1], 10) : null;
336
- }
337
- // GUARANTEE the user lands authenticated — never on the "paste your
338
- // token" login page. The cicy-code SPA's "token from ?token=" auto-
339
- // login is racy on a brand-new window / just-started backend, but a
340
- // `webContents.loadURL` of the same tokened URL reliably consumes the
341
- // token (proven). We drive the retry from the RENDERER with short
342
- // RPC calls + real awaits — a single long-running main-process loop
343
- // proved unreliable (the RPC reply times out and the in-window loop
344
- // gets abandoned mid-flight, so loadURL never actually fires). Each
345
- // iteration: check localStorage.api_token; if absent, loadURL the
346
- // tokened URL. An already-authenticated live session passes the first
347
- // check and is never reloaded.
348
- if (targetWid != null) {
349
- try {
350
- await window.electronRPC("control_electron_BrowserWindow", {
351
- win_id: targetWid,
352
- code: "(win.isMinimized()&&win.restore(),win.show(),win.focus(),'shown')",
353
- });
354
- } catch (e) {}
355
- for (let i = 0; i < 12; i++) {
356
- let authed = false;
357
- try {
358
- const cr = await window.electronRPC("control_electron_BrowserWindow", {
359
- win_id: targetWid,
360
- code: "win.webContents.executeJavaScript(\"!!localStorage.getItem('api_token')\")",
361
- });
362
- authed = /true/.test((cr?.content || []).map((c) => c.text).join(""));
363
- } catch (e) {}
364
- if (authed) break;
365
- try {
366
- await window.electronRPC("control_electron_BrowserWindow", {
367
- win_id: targetWid,
368
- code: "win.webContents.loadURL(" + JSON.stringify(url) + ")",
369
- });
370
- } catch (e) {}
371
- await new Promise((r) => setTimeout(r, 2000));
372
- }
373
- }
374
- pushToast(t("toast.opened", { name: b.name }));
375
- return;
376
- } catch (e) {
377
- // fall through to api.backends.open
378
- }
379
- }
380
- const r = await api.backends.open(b.id);
381
- if (r?.ok) pushToast(t("toast.opened", { name: b.name }));
382
- else pushToast(t("toast.check_failed", { error: r?.error || "unknown" }), "error");
383
- } finally {
384
- setOpeningId(null);
385
- }
386
- };
387
-
388
- // 召唤云端小助手 — opens a BrowserWindow loading the helper-instance URL,
389
- // gaining cicy-desktop's preload (electronRPC bridge) so opencode inside
390
- // the helper SPA can drive the user's local machine for the install.
391
- //
392
- // Bootstrap path: piggyback on api.backends.add + .open since main-process
393
- // window creation is gated through the backend registry. We tag the
394
- // entry with HELPER_BACKEND_NAME so it's filtered out of the card grid.
395
- // For MVP the URL is hardcoded to the dev-box helper container; the
396
- // production path will hit /v1/helper/start to allocate a fresh spot.
397
- // Bootstrap helper cloud token once on mount — drawer is always
398
- // visible so we fetch immediately. Token lives only here in render
399
- // memory; never persisted, never put in localStorage by the homepage.
400
- // ABSOLUTE URL: on Mac/Linux the homepage is loaded over file://, so
401
- // a relative "/api/..." resolves to file:///api/... and 404s. Always
402
- // hit the deployed Worker endpoint.
403
- useEffect(() => {
404
- let cancelled = false;
405
- (async () => {
406
- try {
407
- const res = await fetch("https://desktop.cicy-ai.com/api/helper/token");
408
- if (!res.ok) {
409
- if (!cancelled) setHelperBootError(`token endpoint ${res.status}`);
410
- return;
411
- }
412
- const r = await res.json().catch(() => null);
413
- if (cancelled) return;
414
- if (r?.ok && r.token) {
415
- setHelperCloudToken(r.token);
416
- setHelperBootError("");
417
- } else {
418
- setHelperBootError(`token endpoint malformed: ${JSON.stringify(r).slice(0, 80)}`);
419
- }
420
- } catch (e) {
421
- if (!cancelled) setHelperBootError(`fetch failed: ${e.message}`);
422
- }
423
- })();
424
- return () => { cancelled = true; };
425
- }, []);
426
-
427
- // USER_CONTEXT push — lang + IP country, the MINIMUM dispatch info the
428
- // agent needs to pick its opening-line language and decide if it should
429
- // route Docker/cicy-code downloads through a proxy (only CN+slow).
430
- // No OS / no IP / no other PII; ipinfo runs from the user's host so
431
- // their real geo is seen, not the cloud helper container's IP.
432
- //
433
- // Triggered only ONCE per webview load, gated on:
434
- // 1) helperCloudToken fetched
435
- // 2) webview's did-finish-load event fired (cicy-code SPA ready)
436
- // 3) +6s grace for the SPA to spawn the opencode tmux pane to splash
437
- // POST goes to /api/tmux/send which routes the text through tmux into
438
- // opencode TUI as the conversational first user message.
439
- // Refs needed by the USER_CONTEXT effect (defined further down so it can
440
- // reference helperSrc without hitting TDZ).
441
- const helperWebviewRef = useRef(null);
442
- const userContextSentRef = useRef(false);
443
-
444
- // Drag-resize the helper drawer. mousedown on the left edge starts a
445
- // drag; listeners attach to window so the drag survives cursor leaving
446
- // the handle. CRITICAL: <webview> captures all mouse events inside its
447
- // own renderer process, so dragging across it loses mousemove. The
448
- // `helperResizing` state renders a transparent full-window mask above
449
- // the webview during the drag, restoring mousemove to the host page.
450
- const [helperResizing, setHelperResizing] = useState(false);
451
- const startHelperResize = useCallback((ev) => {
452
- ev.preventDefault();
453
- setHelperResizing(true);
454
- const onMove = (e) => {
455
- const w = window.innerWidth - e.clientX;
456
- const min = 320, max = window.innerWidth - 320;
457
- setHelperWidth(Math.max(min, Math.min(max, w)));
458
- };
459
- const onUp = () => {
460
- setHelperResizing(false);
461
- window.removeEventListener("mousemove", onMove);
462
- window.removeEventListener("mouseup", onUp);
463
- };
464
- window.addEventListener("mousemove", onMove);
465
- window.addEventListener("mouseup", onUp);
466
- }, []);
467
-
468
- const handleRemove = async (b) => {
469
- if (!confirm(t("confirm.remove", { name: b.name }))) return;
470
- await api.backends.remove(b.id);
471
- await loadBackends();
472
- pushToast(t("toast.removed", { name: b.name }));
473
- };
474
-
475
- const handleRestart = async () => {
476
- pushToast(t("toast.restarting"));
477
- const r = await api.backends.restartSidecar();
478
- if (r?.ok) {
479
- pushToast(t("toast.restarted", { pid: r.pid }), "success");
480
- setTimeout(() => loadHealth(backends), 1500);
481
- } else {
482
- pushToast(t("toast.restart_failed", { error: r?.error || "unknown" }), "error");
483
- }
484
- };
485
-
486
- const handleCheckUpdate = async () => {
487
- if (apiMode !== "electron" || !window.cicy?.sidecar) {
488
- pushToast(t("toast.install_only_electron"), "error");
489
- return;
490
- }
491
- pushToast(t("toast.checking"));
492
- const check = await window.cicy.sidecar.checkLatest();
493
- if (!check.ok) { pushToast(t("toast.check_failed", { error: check.error }), "error"); return; }
494
- if (check.installedVersion === check.latest) {
495
- setSidecar((s) => ({ ...s, state: "uptodate", info: check }));
496
- pushToast(t("toast.uptodate", { version: check.latest }), "success");
497
- } else {
498
- setSidecar((s) => ({ ...s, state: "outdated", info: check }));
499
- pushToast(t("toast.new_version", { version: check.latest }));
500
- }
501
- };
502
-
503
- const handleInstall = async () => {
504
- if (apiMode !== "electron" || !window.cicy?.sidecar) {
505
- pushToast(t("toast.install_only_electron"), "error");
506
- return;
507
- }
508
-
509
- setSidecar((s) => ({ ...s, state: "installing", progress: { phase: "init", message: "Preparing…" }, error: null }));
510
- setInstallLog([]);
511
- // Full reset every time. Keeping "done" phases across runs sounded
512
- // nice (resumed steps fly past) but is actively misleading: those
513
- // done marks came from a different install epoch, against possibly
514
- // a different cached state. The user sees ✓ next to "AI 引擎已就绪"
515
- // and assumes the engine is up even when the current install is
516
- // racing to pull the image from scratch. Start blank, every time.
517
- setInstallSteps([]);
518
-
519
- // All platforms (Win/Mac/Linux): drive the install from the renderer via
520
- // electronRPC.exec_shell using Docker. Bypasses the main-process
521
- // sidecar:install handler, so install fixes ship via the CF Worker —
522
- // no .exe rebuild required. Docker is uniform across OSes (on Windows
523
- // Docker Desktop runs on WSL2/Hyper-V under the hood).
524
- {
525
- try {
526
- const { dockerInstall, canRunRendererInstall } = await import("./dockerInstaller.js");
527
- if (canRunRendererInstall()) {
528
- const handleEvent = (ev) => {
529
- if (ev?.kind === "log") {
530
- // Append + cap at 300 lines so memory stays bounded on slow installs.
531
- setInstallLog((l) => {
532
- const next = [...l, ev.text];
533
- return next.length > 300 ? next.slice(next.length - 300) : next;
534
- });
535
- } else {
536
- setSidecar((s) => ({ ...s, progress: ev }));
537
- // Build a user-facing step timeline alongside the raw shell log.
538
- // Phase-keyed (not last-only) so concurrent phases — e.g. the
539
- // parallel `downloading` + `checking-wsl` — both update their
540
- // own row instead of overwriting each other.
541
- if (ev?.phase) {
542
- setInstallSteps((steps) => {
543
- const idx = steps.findIndex((s) => s.phase === ev.phase);
544
- const merged = (prev) => {
545
- const base = {
546
- phase: ev.phase,
547
- message: ev.message || prev?.message || ev.phase,
548
- at: Date.now(),
549
- progress: ev.progress != null ? ev.progress : prev?.progress,
550
- status: ev.status || prev?.status,
551
- error: ev.status === "error" ? (ev.error || ev.message) : prev?.error,
552
- bytes: ev.bytes != null ? ev.bytes : prev?.bytes,
553
- total: ev.total != null ? ev.total : prev?.total,
554
- // Speed/ETA: take whatever this event reported. Do NOT
555
- // fall back to prev — a stalled poll would otherwise
556
- // keep showing the last "1.5 MB/s" forever, masking
557
- // the slowdown the user actually wants to see.
558
- speed: ev.speed,
559
- eta: ev.eta,
560
- };
561
- // Preserve extra metadata across updates to the same phase —
562
- // later emits overwrite message but we still want e.g.
563
- // network (from detecting) + wslHomePath (from done).
564
- for (const k of ["network","installDrive","installDir","isSSD","version","agents","wslHomePath"]) {
565
- if (ev[k] != null) base[k] = ev[k];
566
- else if (prev?.[k] != null) base[k] = prev[k];
567
- }
568
- return base;
569
- };
570
- if (idx >= 0) {
571
- const next = [...steps];
572
- next[idx] = merged(steps[idx]);
573
- return next;
574
- }
575
- return [...steps, merged(null)];
576
- });
577
- }
578
- }
579
- };
580
- const r = await dockerInstall({
581
- onProgress: handleEvent,
582
- // Open-box-ready default: auto-pick the manifest defaults (just
583
- // "claude" today) without showing a picker. The user can change
584
- // selection later via per-backend agent settings, but the first
585
- // install must not stall waiting for a click — "click Install,
586
- // you're in" is the contract.
587
- onPickAgents: ({ defaults }) => Promise.resolve(
588
- defaults && defaults.length ? defaults : ["claude"]
589
- ),
590
- });
591
- if (r?.ok) {
592
- setSidecar({ state: "uptodate", info: { installedVersion: r.version }, progress: null, error: null });
593
- // Install finished cleanly — clear the timeline & log so the
594
- // banner self-dismisses (it stays open as long as there are
595
- // any progressSteps, by design, but with all steps "done"
596
- // there's nothing useful left to show).
597
- setInstallSteps([]);
598
- setInstallLog([]);
599
- pushToast(t("toast.installed", { version: r.version }), "success");
600
- const freshList = await loadBackends();
601
- await loadHealth(freshList);
602
- checkWsl();
603
- // Open-after-install: the user clicked Install on the local card,
604
- // so the implied next click is Open. Auto-launch the workspace
605
- // window so install-flow really is "click once, you're in".
606
- //
607
- // Tricky bit: cicy-code's SPA stores api_token in localStorage
608
- // keyed by origin. After reset→reinstall, the new cicy-code
609
- // container issues a FRESH api_token, but Electron persists
610
- // localStorage per partition across reinstalls, so the previous
611
- // install's stale state (no api_token, but other SPA keys)
612
- // makes the SPA treat the load as "already-initialized" and
613
- // SKIP the ?token= URL-bootstrap path. Result: stuck on the
614
- // login page despite the URL carrying the correct token.
615
- // Fix: nuke the partition's storage for the workspace origin
616
- // right before opening, so the SPA's first paint hits the
617
- // ?token= bootstrap. One-shot, only on fresh install.
618
- const localCard = freshList.find((b) => b.id === "local");
619
- if (localCard) {
620
- try {
621
- const openRes = await window.cicy.backends.open(localCard.id);
622
- if (openRes?.ok && openRes.windowId && window.electronRPC) {
623
- try {
624
- await window.electronRPC("control_electron_BrowserWindow", {
625
- win_id: openRes.windowId,
626
- code: "(async () => { try { await win.webContents.executeJavaScript('localStorage.clear(); sessionStorage.clear();'); } catch (e) {} win.webContents.reloadIgnoringCache(); return 'cleared'; })()",
627
- });
628
- } catch (e) {}
629
- }
630
- } catch (e) {}
631
- }
632
- return;
633
- }
634
- throw new Error("install returned not ok");
635
- }
636
- // Fall through to ipc-based install if electronRPC missing
637
- } catch (e) {
638
- setSidecar((s) => ({ ...s, state: "error", error: e.message, progress: null }));
639
- // Clear any orphaned agent-pick promise — if the installer threw
640
- // while the user was still on the picker step, leaving pickAgents
641
- // populated would freeze the drawer in pick mode forever even
642
- // after the error timeline rendered.
643
- setPickAgents(null);
644
- // Mark the step that was running as failed so the timeline shows
645
- // a red ✗ next to it and surfaces a retry button. Subsequent
646
- // retries replay the whole windowsInstall — every step is
647
- // idempotent (size-checked downloads, "skip if already done"
648
- // installs) so resumption "feels" like only the failing step
649
- // re-runs even though we're calling the full sequence.
650
- setInstallSteps((steps) => {
651
- if (!steps.length) return steps;
652
- const last = steps[steps.length - 1];
653
- return [...steps.slice(0, -1), { ...last, status: "error", error: e.message }];
654
- });
655
- return;
656
- }
657
- }
658
-
659
- try {
660
- const r = await window.cicy.sidecar.install();
661
- if (r?.ok) {
662
- setSidecar({ state: "uptodate", info: { installedVersion: r.version }, progress: null, error: null });
663
- pushToast(t("toast.installed", { version: r.version }), "success");
664
- const freshList = await loadBackends();
665
- await loadHealth(freshList);
666
- checkWsl();
667
- } else {
668
- setSidecar((s) => ({ ...s, state: "error", error: r?.error || "unknown", progress: null }));
669
- }
670
- } catch (e) {
671
- setSidecar((s) => ({ ...s, state: "error", error: e.message, progress: null }));
672
- }
673
- };
674
-
675
- const handleCancelInstall = async () => {
676
- if (window.cicy?.sidecar?.cancel) await window.cicy.sidecar.cancel();
677
- };
678
-
679
- // ── Derived ──
680
- const local = backends.filter((b) => b.kind === "local");
681
- const cloud = backends.filter((b) => b.kind !== "local");
682
-
683
- // (Auto-swap-to-local temporarily disabled — we want to test the cloud
684
- // helper UX on its own first. To re-enable: restore a useEffect that
685
- // resolves the local backend's URL into helperLocalUrl and add it back
686
- // into helperSrc's preference order.)
687
-
688
- // webview src: 暂时锁定云端 helper。drawer 常驻, 只在 token 还没
689
- // fetch 回来时是空串(显示"正在加载团队助手…"占位)。
690
- const helperSrc = helperCloudToken
691
- ? `${HELPER_URL}?token=${encodeURIComponent(helperCloudToken)}`
692
- : "";
693
-
694
- // USER_CONTEXT push — fires AT MOST ONCE per page load. Previously the
695
- // effect reset userContextSentRef each re-run, which combined with the
696
- // webview's did-finish-load firing on every cicy-code SPA internal
697
- // navigation, sent USER_CONTEXT repeatedly into the opencode chat
698
- // (the "刷屏" bug).
699
- useEffect(() => {
700
- if (!helperCloudToken) return;
701
- // Capture token once for the closure so it can't shift mid-send.
702
- const tokenAtMount = helperCloudToken;
703
-
704
- const sendUserContext = async () => {
705
- if (userContextSentRef.current) return;
706
- userContextSentRef.current = true; // gate immediately, before any await
707
- const lang = (navigator.language || "en").split("-")[0].toLowerCase();
708
- let country = "";
709
- try {
710
- const r = await fetch("https://ipinfo.io/country", {
711
- cache: "no-store",
712
- signal: AbortSignal.timeout(4000),
713
- });
714
- if (r.ok) country = (await r.text()).trim().toUpperCase();
715
- } catch {}
716
- const text = `USER_CONTEXT: lang=${lang} country=${country || "?"}`;
717
-
718
- // 6s grace after webview finishes loading: cicy-code SPA needs to
719
- // create the opencode tmux pane and let opencode boot to its splash.
720
- await new Promise((r) => setTimeout(r, 6000));
721
- try {
722
- await fetch(`${HELPER_URL}api/tmux/send`, {
723
- method: "POST",
724
- headers: {
725
- "Content-Type": "application/json",
726
- "Authorization": `Bearer ${tokenAtMount}`,
727
- },
728
- body: JSON.stringify({
729
- win_id: "w-10001:main.0",
730
- text,
731
- submit: true,
732
- }),
733
- });
734
- } catch {}
735
- };
736
-
737
- const wv = helperWebviewRef.current;
738
- if (wv && typeof wv.addEventListener === "function") {
739
- const onLoad = () => sendUserContext();
740
- wv.addEventListener("did-finish-load", onLoad);
741
- return () => { try { wv.removeEventListener("did-finish-load", onLoad); } catch {} };
742
- }
743
- const t = setTimeout(sendUserContext, 8000);
744
- return () => clearTimeout(t);
745
- }, [helperCloudToken]);
746
-
747
- // Always show a Local card. If backends API hasn't returned one yet
748
- // (cicy-code missing/installing), synthesize a placeholder so the user
749
- // sees a single, unified install/open surface — no separate banner.
750
- const localCard = local[0] || {
751
- id: "local-placeholder",
752
- name: "Local",
753
- kind: "local",
754
- port: 8008,
755
- };
756
-
757
- // State-driven primary button. Every sidecar.state maps to a self-consistent
758
- // (label, disabled, onClick) — no fallthrough to a generic "打开" that's
759
- // simultaneously displayed-but-disabled. The latter was the "card 显示打开
760
- // 但点不了" 莫名其妙 case: state=checking/installing → fall-through to
761
- // default Open → openDisabled=true → button text said "打开" but click did
762
- // nothing. Now every state names itself.
763
- const localPrimary = (() => {
764
- if (sidecar.state === "checking") return { label: t("card.checking"), icon: "spinner", disabled: true };
765
- if (sidecar.state === "installing") return { label: t("card.installing"), icon: "spinner", disabled: true };
766
- if (sidecar.state === "missing") return { label: t("card.install"), icon: "download", onClick: handleInstall };
767
- if (sidecar.state === "outdated") return { label: t("card.open"), icon: "open", onClick: () => handleOpen(localCard) };
768
- if (sidecar.state === "error") return { label: t("card.retry"), icon: "refresh", onClick: handleInstall, variant: "primary" };
769
- // Installed but daemon not on :8008 — Start. Polling flips running once
770
- // it binds, after which we fall through to default Open.
771
- if (sidecar.state === "uptodate" && sidecar.running === false) {
772
- return { label: t("card.start"), icon: "play", onClick: handleStart, loading: !!sidecar.starting };
773
- }
774
- return null; // uptodate + running → default Open
775
- })();
776
-
777
- // Banner above the cards owns the detailed step / log timeline. The
778
- // BackendCard only needs to know "install is happening" so it renders
779
- // a minimal placeholder ("安装中…") instead of a duplicate progress
780
- // panel. We intentionally strip the rich progress fields here.
781
- const localProgress = sidecar.state === "installing"
782
- ? { phase: "installing", minimal: true }
783
- : null;
784
- // Banner gets the full progress object — phase + message + progress
785
- // bar + step timeline live there.
786
- const bannerProgress = sidecar.state === "installing" ? sidecar.progress : null;
787
-
788
- const localMenu = (b) => {
789
- const items = [];
790
- items.push({ label: t("menu.edit"), icon: <Icon name="edit" />, onClick: () => setModalState({ open: true, mode: "edit", backend: b }) });
791
- items.push("divider");
792
- if (sidecar.state === "outdated" && sidecar.info?.latest) {
793
- items.push({
794
- label: t("menu.update_to", { version: sidecar.info.latest }),
795
- icon: <Icon name="download" />,
796
- badge: true,
797
- onClick: handleInstall,
798
- });
799
- items.push("divider");
800
- }
801
- items.push({ label: t("menu.restart"), icon: <Icon name="restart" />, onClick: handleRestart });
802
- items.push({ label: t("menu.check_update"), icon: <Icon name="refresh" />, onClick: handleCheckUpdate });
803
- return items;
804
- };
805
-
806
- const cloudMenu = (b) => [
807
- { label: t("menu.edit"), icon: <Icon name="edit" />, onClick: () => setModalState({ open: true, mode: "edit", backend: b }) },
808
- { label: t("menu.remove"), icon: <Icon name="trash" />, danger: true, onClick: () => handleRemove(b) },
809
- ];
810
-
811
- const localOpenDisabled = (b) => {
812
- if (sidecar.state === "missing" || sidecar.state === "installing") return true;
813
- const h = healthMap[b.id];
814
- return !!(h && h.ok === false);
815
- };
816
-
817
- return (
818
- <div className="app" style={{ paddingRight: helperWidth }}>
819
- <header className="app__topbar drag">
820
- <div className="app__brand">
821
- <span className="app__brand-mark" />
822
- CiCy Desktop
823
- {appVersion && (
824
- <span style={{ fontSize: 11, opacity: 0.5, marginLeft: 8, fontWeight: 400 }}>
825
- {appVersion}
826
- </span>
827
- )}
828
- {/* Worker build stamp — proves which CF Worker deploy is loaded.
829
- Format: <git_sha>·<MM-DD HH:mm UTC>. Click to copy full ISO time. */}
830
- <span
831
- style={{ fontSize: 10, opacity: 0.4, marginLeft: 6, fontWeight: 400, fontFamily: "ui-monospace, monospace", cursor: "pointer" }}
832
- title={`Worker build ${__WORKER_BUILD_TIME__} (${__WORKER_GIT_SHA__})`}
833
- onClick={() => {
834
- try { navigator.clipboard.writeText(`${__WORKER_GIT_SHA__} @ ${__WORKER_BUILD_TIME__}`); } catch {}
835
- }}
836
- >
837
- {__WORKER_GIT_SHA__}·{__WORKER_BUILD_TIME__.slice(5, 16).replace("T", " ")}
838
- </span>
839
- </div>
840
- </header>
841
-
842
- <main className="app__main">
843
- {/* Windows-only WSL setup card — surfaces above the cards. */}
844
- <WslSetupBanner
845
- wsl={wsl}
846
- onRecheck={checkWsl}
847
- recheckLoading={wslChecking}
848
- onInstall={handleInstall}
849
- progress={bannerProgress}
850
- progressLog={installLog}
851
- progressSteps={sidecar.state === "installing" ? installSteps : []}
852
- pickAgents={pickAgents}
853
- onDismiss={() => { setInstallSteps([]); setInstallLog([]); }}
854
- />
855
-
856
- {/* Tabs: 全部 / 本地 / 云团队 */}
857
- <div className="app__tabs">
858
- {["all", "local", "cloud"].map((k) => (
859
- <button
860
- key={k}
861
- type="button"
862
- className={`app__tab ${tab === k ? "is-active" : ""}`}
863
- onClick={() => setTab(k)}
864
- >
865
- {t(`tab.${k}`)}
866
- <span className="app__tab-count">
867
- {k === "all" ? (1 + cloud.length) : k === "local" ? 1 : cloud.length}
868
- </span>
869
- </button>
870
- ))}
871
- </div>
872
-
873
- {/* Unified grid driven by tab filter */}
874
- <div className="app__grid">
875
- {(tab === "all" || tab === "local") && (
876
- <BackendCard
877
- backend={localCard}
878
- health={healthMap[localCard.id]}
879
- menuItems={localMenu(localCard)}
880
- onOpen={() => handleOpen(localCard)}
881
- openLoading={openingId === localCard.id}
882
- openDisabled={localOpenDisabled(localCard)}
883
- primaryButton={localPrimary}
884
- progress={localProgress}
885
- onCancel={handleCancelInstall}
886
- />
887
- )}
888
- {(tab === "all" || tab === "cloud") && cloud.map((b) => (
889
- <BackendCard
890
- key={b.id}
891
- backend={b}
892
- health={healthMap[b.id]}
893
- menuItems={cloudMenu(b)}
894
- onOpen={() => handleOpen(b)}
895
- openLoading={openingId === b.id}
896
- />
897
- ))}
898
- {(tab === "all" || tab === "cloud") && (
899
- <button
900
- type="button"
901
- className="add-card"
902
- onClick={() => setModalState({ open: true, mode: "add", backend: null })}
903
- >
904
- <span className="add-card__plus"><Icon name="add" size={20} /></span>
905
- <span className="add-card__label">{t("team.add")}</span>
906
- </button>
907
- )}
908
- </div>
909
-
910
- <BackendModal
911
- open={modalState.open}
912
- mode={modalState.mode}
913
- backend={modalState.backend}
914
- onClose={() => setModalState({ open: false, mode: "add", backend: null })}
915
- onSaved={() => loadBackends()}
916
- />
917
- </main>
918
-
919
- {/* 拖宽期间盖一层全屏透明 mask, 拦住 webview 吞 mousemove,
920
- 鼠标变 ew-resize 给用户视觉反馈。zIndex 高于 aside。 */}
921
- {helperResizing && (
922
- <div
923
- style={{
924
- position: "fixed",
925
- top: 0, left: 0, right: 0, bottom: 0,
926
- cursor: "ew-resize",
927
- background: "transparent",
928
- userSelect: "none",
929
- zIndex: 9999,
930
- }}
931
- />
932
- )}
933
-
934
- {/* 🤖 团队助手 — 常驻右侧, top:0 right:0 bottom:0 上下通栏。
935
- 默认 50% 宽, 可拖左边缘调宽。无 header / 无 toggle, 全 webview。
936
- 风格与 cicy-code 一致: 无 box-shadow, 只一根 1px 发丝分割,
937
- 背景沿用 --bg(#0f1115) 与主页同源, webview 里 cicy-code SPA
938
- 自己 paint 内容。
939
- - webview preload 注入 cicy-desktop 的 homepage-preload.js,
940
- helper SPA 里 window.electronRPC + window.cicy 都可用。
941
- - 装机前 helper URL 指向云端体验容器, 装好后自动切到本地。 */}
942
- <aside
943
- style={{
944
- position: "fixed",
945
- top: 0,
946
- right: 0,
947
- bottom: 0,
948
- width: helperWidth,
949
- background: "var(--bg, #0f1115)",
950
- zIndex: 200,
951
- borderLeft: "1px solid var(--hairline, rgba(255,255,255,0.06))",
952
- display: "flex",
953
- flexDirection: "column",
954
- }}
955
- >
956
- {/* 6px 宽透明拖拽条贴在左缘, hover 时显淡色提示 */}
957
- <div
958
- onMouseDown={startHelperResize}
959
- style={{
960
- position: "absolute",
961
- left: -3,
962
- top: 0,
963
- bottom: 0,
964
- width: 6,
965
- cursor: "ew-resize",
966
- zIndex: 1,
967
- }}
968
- title="拖动调整宽度"
969
- />
970
- {helperSrc ? (
971
- <webview
972
- ref={helperWebviewRef}
973
- key={helperSrc}
974
- src={helperSrc}
975
- {...(window.cicy?.preloadPath
976
- ? { preload: `file://${window.cicy.preloadPath}` }
977
- : {})}
978
- style={{ flex: 1, border: 0, width: "100%", height: "100%" }}
979
- allowpopups="true"
980
- />
981
- ) : (
982
- <div style={{ padding: 24, color: "var(--fg-dim, #9aa3b2)", fontSize: 13, display: "flex", flexDirection: "column", gap: 12 }}>
983
- <div>{helperBootError ? "团队助手加载失败" : "正在加载团队助手…"}</div>
984
- {helperBootError && (
985
- <>
986
- <div style={{ fontSize: 11, opacity: 0.7, fontFamily: "ui-monospace, monospace", wordBreak: "break-all" }}>
987
- {helperBootError}
988
- </div>
989
- <button
990
- onClick={() => { setHelperBootError(""); setHelperCloudToken(""); window.location.reload(); }}
991
- style={{ padding: "6px 12px", background: "var(--accent, #5b8df7)", color: "#fff", border: 0, borderRadius: 6, cursor: "pointer", alignSelf: "flex-start" }}
992
- >重试</button>
993
- </>
994
- )}
995
- </div>
996
- )}
997
- </aside>
998
-
999
- <Toast />
1000
- </div>
1001
- );
1002
- }
1003
-
1004
-
1005
-
1006
- function stripQuery(url) {
1007
- try { const u = new URL(url); return (u.origin + u.pathname).toLowerCase(); } catch { return url.toLowerCase(); }
1008
- }
1009
-
1010
- async function fetchWindows() {
1011
- try {
1012
- const res = await window.electronRPC("get_windows", {});
1013
- const txt = (res.content || []).map((c) => c.text).join("");
1014
- const parsed = JSON.parse(txt);
1015
- return Array.isArray(parsed) ? parsed : [];
1016
- } catch { return []; }
1017
- }
1018
-
1019
-
1020
- // (install state persistence removed — installSteps/installLog are now
1021
- // ephemeral; loadPersistedLog/loadPersistedSteps and their LS_* keys are
1022
- // obsolete. Clean stale sessionStorage on mount so existing users don't
1023
- // carry the old payloads around.)
1024
- try {
1025
- sessionStorage.removeItem("cicy.installLog");
1026
- sessionStorage.removeItem("cicy.installSteps");
1027
- } catch {}
1028
-