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.
- package/package.json +7 -7
- package/src/backends/homepage-react/assets/index-BpljolQs.js +365 -0
- package/src/backends/homepage-react/assets/{index-BniEbx_j.css → index-C9AZlTew.css} +1 -1
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/local-teams.js +42 -4
- package/src/backends/sidecar-ipc.js +23 -1
- package/src/i18n/locales/en.json +9 -7
- package/src/i18n/locales/zh-CN.json +9 -7
- package/src/sidecar/cicy-code.js +54 -111
- package/src/sidecar/localbin.js +133 -0
- package/src/sidecar/native.js +4 -2
- package/src/sidecar/runtime.js +7 -3
- package/workers/render/src/App.css +156 -10
- package/workers/render/src/App.jsx +254 -39
- package/.env.dev +0 -7
- package/src/backends/homepage-react/assets/index-B8gGhz8B.js +0 -365
- package/workers/render.bak.20260528-2338/DESIGN_v2.md +0 -254
- package/workers/render.bak.20260528-2338/index.html +0 -12
- package/workers/render.bak.20260528-2338/package-lock.json +0 -827
- package/workers/render.bak.20260528-2338/package.json +0 -19
- package/workers/render.bak.20260528-2338/public/_headers +0 -5
- package/workers/render.bak.20260528-2338/public/manifest.json +0 -6
- package/workers/render.bak.20260528-2338/src/App.css +0 -224
- package/workers/render.bak.20260528-2338/src/App.jsx +0 -1028
- package/workers/render.bak.20260528-2338/src/api.js +0 -285
- package/workers/render.bak.20260528-2338/src/cicycode-ops.js +0 -222
- package/workers/render.bak.20260528-2338/src/components/BackendCard.css +0 -299
- package/workers/render.bak.20260528-2338/src/components/BackendCard.jsx +0 -133
- package/workers/render.bak.20260528-2338/src/components/BackendModal.css +0 -161
- package/workers/render.bak.20260528-2338/src/components/BackendModal.jsx +0 -199
- package/workers/render.bak.20260528-2338/src/components/Button.css +0 -72
- package/workers/render.bak.20260528-2338/src/components/Button.jsx +0 -37
- package/workers/render.bak.20260528-2338/src/components/Card.css +0 -42
- package/workers/render.bak.20260528-2338/src/components/Card.jsx +0 -21
- package/workers/render.bak.20260528-2338/src/components/Icon.jsx +0 -30
- package/workers/render.bak.20260528-2338/src/components/Menu.css +0 -55
- package/workers/render.bak.20260528-2338/src/components/Menu.jsx +0 -91
- package/workers/render.bak.20260528-2338/src/components/SidecarBanner.css +0 -79
- package/workers/render.bak.20260528-2338/src/components/SidecarBanner.jsx +0 -84
- package/workers/render.bak.20260528-2338/src/components/StatusChip.css +0 -19
- package/workers/render.bak.20260528-2338/src/components/StatusChip.jsx +0 -31
- package/workers/render.bak.20260528-2338/src/components/Toast.css +0 -31
- package/workers/render.bak.20260528-2338/src/components/Toast.jsx +0 -23
- package/workers/render.bak.20260528-2338/src/components/WslSetupBanner.css +0 -464
- package/workers/render.bak.20260528-2338/src/components/WslSetupBanner.jsx +0 -716
- package/workers/render.bak.20260528-2338/src/dockerInstaller.js +0 -0
- package/workers/render.bak.20260528-2338/src/i18n/en.json +0 -116
- package/workers/render.bak.20260528-2338/src/i18n/fr.json +0 -116
- package/workers/render.bak.20260528-2338/src/i18n/index.js +0 -69
- package/workers/render.bak.20260528-2338/src/i18n/ja.json +0 -116
- package/workers/render.bak.20260528-2338/src/i18n/zh-CN.json +0 -121
- package/workers/render.bak.20260528-2338/src/main.js +0 -475
- package/workers/render.bak.20260528-2338/src/main.jsx +0 -18
- package/workers/render.bak.20260528-2338/src/style.css +0 -275
- package/workers/render.bak.20260528-2338/src/styles/base.css +0 -98
- package/workers/render.bak.20260528-2338/src/styles/tokens.css +0 -90
- package/workers/render.bak.20260528-2338/src/tos.js +0 -72
- package/workers/render.bak.20260528-2338/src/worker.js +0 -40
- package/workers/render.bak.20260528-2338/src/wslInstaller.js +0 -1563
- package/workers/render.bak.20260528-2338/vite.config.js +0 -36
- 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
|
-
|