@chrysb/alphaclaw 0.8.0 → 0.8.1-beta.0
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/lib/public/js/app.js +100 -83
- package/lib/public/js/components/agents-tab/agent-pairing-section.js +47 -12
- package/lib/public/js/components/channels.js +14 -17
- package/lib/public/js/components/envars.js +42 -6
- package/lib/public/js/components/features.js +6 -12
- package/lib/public/js/components/general/use-general-tab.js +10 -5
- package/lib/public/js/components/google/use-gmail-watch.js +22 -18
- package/lib/public/js/components/google/use-google-accounts.js +23 -23
- package/lib/public/js/components/models-tab/use-models.js +20 -4
- package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +2 -2
- package/lib/public/js/components/nodes-tab/use-nodes-tab.js +13 -9
- package/lib/public/js/hooks/use-app-shell-controller.js +59 -6
- package/lib/public/js/hooks/use-cached-fetch.js +63 -0
- package/lib/public/js/hooks/usePolling.js +45 -7
- package/lib/public/js/lib/api-cache.js +88 -0
- package/lib/public/js/lib/api.js +29 -0
- package/lib/server/init/register-server-routes.js +2 -0
- package/lib/server/routes/system.js +50 -2
- package/package.json +1 -1
package/lib/public/js/app.js
CHANGED
|
@@ -240,87 +240,105 @@ const App = () => {
|
|
|
240
240
|
<span style="color: var(--accent)">alpha</span>claw
|
|
241
241
|
</span>
|
|
242
242
|
</div>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
243
|
+
${browseState.isBrowseRoute
|
|
244
|
+
? html`
|
|
245
|
+
<div class="app-content-pane browse-pane">
|
|
246
|
+
<${BrowseRoute}
|
|
247
|
+
activeBrowsePath=${browseState.activeBrowsePath}
|
|
248
|
+
browseView=${browseState.browseViewerMode}
|
|
249
|
+
lineTarget=${browseState.browseLineTarget}
|
|
250
|
+
lineEndTarget=${browseState.browseLineEndTarget}
|
|
251
|
+
selectedBrowsePath=${browseState.selectedBrowsePath}
|
|
252
|
+
onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
|
|
253
|
+
onEditSelectedBrowseFile=${() =>
|
|
254
|
+
setLocation(
|
|
255
|
+
browseActions.buildBrowseRoute(browseState.selectedBrowsePath, {
|
|
256
|
+
view: "edit",
|
|
257
|
+
}),
|
|
258
|
+
)}
|
|
259
|
+
onClearSelection=${() => {
|
|
260
|
+
browseActions.clearBrowsePreview();
|
|
261
|
+
setLocation("/browse");
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
`
|
|
266
|
+
: null}
|
|
267
|
+
${isAgentsRoute
|
|
268
|
+
? html`
|
|
269
|
+
<div class="app-content-pane agents-pane">
|
|
270
|
+
<${AgentsRoute}
|
|
271
|
+
agents=${agentsState.agents}
|
|
272
|
+
loading=${agentsState.loading}
|
|
273
|
+
saving=${agentsState.saving}
|
|
274
|
+
agentsActions=${agentsActions}
|
|
275
|
+
selectedAgentId=${selectedAgentId}
|
|
276
|
+
activeTab=${agentDetailTab}
|
|
277
|
+
onSelectAgent=${(agentId) =>
|
|
278
|
+
setLocation(`/agents/${encodeURIComponent(agentId)}`)}
|
|
279
|
+
onSelectTab=${(tab) => {
|
|
280
|
+
const safePath = tab && tab !== "overview"
|
|
281
|
+
? `/agents/${encodeURIComponent(selectedAgentId)}/${tab}`
|
|
282
|
+
: `/agents/${encodeURIComponent(selectedAgentId)}`;
|
|
283
|
+
setLocation(safePath);
|
|
284
|
+
}}
|
|
285
|
+
onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
|
|
286
|
+
onSetLocation=${setLocation}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
`
|
|
290
|
+
: null}
|
|
291
|
+
${isCronRoute
|
|
292
|
+
? html`
|
|
293
|
+
<div class="app-content-pane cron-pane">
|
|
294
|
+
<${CronRoute}
|
|
295
|
+
jobId=${selectedCronJobId}
|
|
296
|
+
onSetLocation=${setLocation}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
`
|
|
300
|
+
: null}
|
|
301
|
+
${isEnvarsRoute
|
|
302
|
+
? html`
|
|
303
|
+
<div class="app-content-pane ac-fixed-header-pane">
|
|
304
|
+
<${EnvarsRoute}
|
|
305
|
+
onRestartRequired=${controllerActions.setRestartRequired}
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
`
|
|
309
|
+
: null}
|
|
310
|
+
${isModelsRoute
|
|
311
|
+
? html`
|
|
312
|
+
<div class="app-content-pane ac-fixed-header-pane">
|
|
313
|
+
<${ModelsRoute}
|
|
314
|
+
onRestartRequired=${controllerActions.setRestartRequired}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
`
|
|
318
|
+
: null}
|
|
319
|
+
${isNodesRoute
|
|
320
|
+
? html`
|
|
321
|
+
<div class="app-content-pane">
|
|
322
|
+
<${NodesRoute}
|
|
323
|
+
onRestartRequired=${controllerActions.setRestartRequired}
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
`
|
|
327
|
+
: null}
|
|
328
|
+
${browseState.isBrowseRoute ||
|
|
329
|
+
isAgentsRoute ||
|
|
330
|
+
isCronRoute ||
|
|
331
|
+
isEnvarsRoute ||
|
|
332
|
+
isModelsRoute ||
|
|
333
|
+
isNodesRoute
|
|
334
|
+
? null
|
|
335
|
+
: html`
|
|
336
|
+
<div
|
|
337
|
+
class="app-content-pane"
|
|
338
|
+
onscroll=${shellActions.handlePaneScroll}
|
|
339
|
+
>
|
|
320
340
|
<div class="max-w-2xl w-full mx-auto">
|
|
321
|
-
|
|
322
|
-
? html`
|
|
323
|
-
<${Switch}>
|
|
341
|
+
<${Switch}>
|
|
324
342
|
<${Route} path="/general">
|
|
325
343
|
<${GeneralRoute}
|
|
326
344
|
statusData=${controllerState.sharedStatus}
|
|
@@ -412,10 +430,9 @@ const App = () => {
|
|
|
412
430
|
<${RouteRedirect} to="/general" />
|
|
413
431
|
</${Route}>
|
|
414
432
|
</${Switch}>
|
|
415
|
-
`
|
|
416
|
-
: null}
|
|
417
433
|
</div>
|
|
418
|
-
|
|
434
|
+
</div>
|
|
435
|
+
`}
|
|
419
436
|
<${ToastContainer}
|
|
420
437
|
className="fixed top-4 right-4 z-[60] space-y-2 pointer-events-none"
|
|
421
438
|
/>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
rejectPairing,
|
|
18
18
|
} from "../../lib/api.js";
|
|
19
19
|
import { showToast } from "../toast.js";
|
|
20
|
+
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
20
21
|
|
|
21
22
|
const html = htm.bind(h);
|
|
22
23
|
|
|
@@ -43,29 +44,60 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
43
44
|
const pairingDelayedRefreshTimerRefs = useRef([]);
|
|
44
45
|
const agentId = String(agent?.id || "").trim();
|
|
45
46
|
const isDefaultAgent = !!agent?.default;
|
|
47
|
+
const {
|
|
48
|
+
data: bindingsPayload,
|
|
49
|
+
loading: bindingsLoading,
|
|
50
|
+
refresh: refreshBindingsPayload,
|
|
51
|
+
} = useCachedFetch(
|
|
52
|
+
`/api/agents/${encodeURIComponent(String(agentId || ""))}/bindings`,
|
|
53
|
+
() => fetchAgentBindings(agent.id),
|
|
54
|
+
{
|
|
55
|
+
enabled: Boolean(agentId),
|
|
56
|
+
maxAgeMs: 30000,
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
const {
|
|
60
|
+
data: channelsPayload,
|
|
61
|
+
loading: channelsLoading,
|
|
62
|
+
refresh: refreshChannelsPayload,
|
|
63
|
+
} = useCachedFetch("/api/channels/accounts", fetchChannelAccounts, {
|
|
64
|
+
maxAgeMs: 30000,
|
|
65
|
+
});
|
|
46
66
|
|
|
47
67
|
const loadBindings = useCallback(async () => {
|
|
48
68
|
setLoadingBindings(true);
|
|
49
69
|
try {
|
|
50
|
-
const [
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
const [nextBindingsPayload, nextChannelsPayload] = await Promise.all([
|
|
71
|
+
refreshBindingsPayload({ force: true }),
|
|
72
|
+
refreshChannelsPayload({ force: true }),
|
|
53
73
|
]);
|
|
54
|
-
setBindings(
|
|
55
|
-
|
|
74
|
+
setBindings(
|
|
75
|
+
Array.isArray(nextBindingsPayload?.bindings)
|
|
76
|
+
? nextBindingsPayload.bindings
|
|
77
|
+
: [],
|
|
78
|
+
);
|
|
79
|
+
setChannels(
|
|
80
|
+
Array.isArray(nextChannelsPayload?.channels)
|
|
81
|
+
? nextChannelsPayload.channels
|
|
82
|
+
: [],
|
|
83
|
+
);
|
|
56
84
|
} catch {
|
|
57
85
|
setBindings([]);
|
|
58
86
|
setChannels([]);
|
|
59
87
|
} finally {
|
|
60
88
|
setLoadingBindings(false);
|
|
61
89
|
}
|
|
62
|
-
}, [
|
|
90
|
+
}, [refreshBindingsPayload, refreshChannelsPayload]);
|
|
63
91
|
|
|
64
92
|
useEffect(() => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
setBindings(
|
|
94
|
+
Array.isArray(bindingsPayload?.bindings) ? bindingsPayload.bindings : [],
|
|
95
|
+
);
|
|
96
|
+
setChannels(
|
|
97
|
+
Array.isArray(channelsPayload?.channels) ? channelsPayload.channels : [],
|
|
98
|
+
);
|
|
99
|
+
setLoadingBindings(Boolean(bindingsLoading || channelsLoading));
|
|
100
|
+
}, [bindingsLoading, bindingsPayload, channelsLoading, channelsPayload]);
|
|
69
101
|
|
|
70
102
|
useEffect(() => {
|
|
71
103
|
const handleBindingsChanged = (event) => {
|
|
@@ -207,8 +239,11 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
207
239
|
};
|
|
208
240
|
});
|
|
209
241
|
},
|
|
210
|
-
|
|
211
|
-
{
|
|
242
|
+
3000,
|
|
243
|
+
{
|
|
244
|
+
enabled: hasUnpaired && ownedAccounts.length > 0,
|
|
245
|
+
cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`,
|
|
246
|
+
},
|
|
212
247
|
);
|
|
213
248
|
|
|
214
249
|
const pending = pairingsPoll.data || [];
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
fetchChannelAccounts,
|
|
16
16
|
updateChannelAccount,
|
|
17
17
|
} from "../lib/api.js";
|
|
18
|
+
import { useCachedFetch } from "../hooks/use-cached-fetch.js";
|
|
18
19
|
import {
|
|
19
20
|
isImplicitDefaultAccount,
|
|
20
21
|
resolveChannelAccountLabel,
|
|
@@ -140,31 +141,27 @@ export const Channels = ({
|
|
|
140
141
|
onNavigate = () => {},
|
|
141
142
|
onRefreshStatuses = () => {},
|
|
142
143
|
}) => {
|
|
143
|
-
const [channelAccounts, setChannelAccounts] = useState([]);
|
|
144
|
-
const [loadingAccounts, setLoadingAccounts] = useState(true);
|
|
145
144
|
const [saving, setSaving] = useState(false);
|
|
146
145
|
const [createLoadingLabel, setCreateLoadingLabel] = useState("Creating...");
|
|
147
146
|
const [menuOpenId, setMenuOpenId] = useState("");
|
|
148
147
|
const [editingAccount, setEditingAccount] = useState(null);
|
|
149
148
|
const [deletingAccount, setDeletingAccount] = useState(null);
|
|
149
|
+
const {
|
|
150
|
+
data: channelAccountsPayload,
|
|
151
|
+
loading: loadingAccounts,
|
|
152
|
+
refresh: refreshChannelAccounts,
|
|
153
|
+
} = useCachedFetch("/api/channels/accounts", fetchChannelAccounts, {
|
|
154
|
+
maxAgeMs: 30000,
|
|
155
|
+
});
|
|
156
|
+
const channelAccounts = Array.isArray(channelAccountsPayload?.channels)
|
|
157
|
+
? channelAccountsPayload.channels
|
|
158
|
+
: [];
|
|
150
159
|
|
|
151
160
|
const loadChannelAccounts = useCallback(async () => {
|
|
152
|
-
setLoadingAccounts(true);
|
|
153
161
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
} catch {
|
|
159
|
-
setChannelAccounts([]);
|
|
160
|
-
} finally {
|
|
161
|
-
setLoadingAccounts(false);
|
|
162
|
-
}
|
|
163
|
-
}, []);
|
|
164
|
-
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
loadChannelAccounts();
|
|
167
|
-
}, [loadChannelAccounts]);
|
|
162
|
+
await refreshChannelAccounts({ force: true });
|
|
163
|
+
} catch {}
|
|
164
|
+
}, [refreshChannelAccounts]);
|
|
168
165
|
|
|
169
166
|
|
|
170
167
|
const configuredChannelMap = useMemo(
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "https://esm.sh/preact/hooks";
|
|
8
8
|
import htm from "https://esm.sh/htm";
|
|
9
9
|
import { fetchEnvVars, saveEnvVars } from "../lib/api.js";
|
|
10
|
+
import { useCachedFetch } from "../hooks/use-cached-fetch.js";
|
|
10
11
|
import { showToast } from "./toast.js";
|
|
11
12
|
import { SecretInput } from "./secret-input.js";
|
|
12
13
|
import { PageHeader } from "./page-header.js";
|
|
@@ -315,24 +316,47 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
|
|
|
315
316
|
const [showAllAiKeys, setShowAllAiKeys] = useState(false);
|
|
316
317
|
const [newKey, setNewKey] = useState("");
|
|
317
318
|
const baselineSignatureRef = useRef("[]");
|
|
319
|
+
const {
|
|
320
|
+
data: envPayload,
|
|
321
|
+
error: envPayloadError,
|
|
322
|
+
loading: envPayloadLoading,
|
|
323
|
+
refresh: refreshEnvPayload,
|
|
324
|
+
} = useCachedFetch("/api/env", fetchEnvVars, {
|
|
325
|
+
maxAgeMs: 30000,
|
|
326
|
+
});
|
|
318
327
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
328
|
+
const applyEnvPayload = useCallback(
|
|
329
|
+
(data) => {
|
|
330
|
+
if (!data) return;
|
|
322
331
|
const nextVars = sortCustomVarsAlphabetically(data.vars || []);
|
|
323
332
|
baselineSignatureRef.current = getVarsSignature(nextVars);
|
|
324
333
|
setVars(nextVars);
|
|
325
334
|
setPendingCustomKeys([]);
|
|
326
335
|
setReservedKeys(new Set(data.reservedKeys || []));
|
|
327
336
|
onRestartRequired(!!data.restartRequired);
|
|
337
|
+
},
|
|
338
|
+
[onRestartRequired],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const load = useCallback(async () => {
|
|
342
|
+
try {
|
|
343
|
+
const data = await refreshEnvPayload({ force: true });
|
|
344
|
+
applyEnvPayload(data);
|
|
328
345
|
} catch (err) {
|
|
329
346
|
console.error("Failed to load env vars:", err);
|
|
330
347
|
}
|
|
331
|
-
}, []);
|
|
348
|
+
}, [applyEnvPayload, refreshEnvPayload]);
|
|
332
349
|
|
|
333
350
|
useEffect(() => {
|
|
334
|
-
|
|
335
|
-
|
|
351
|
+
if (!envPayload) return;
|
|
352
|
+
if (dirty || saving) return;
|
|
353
|
+
applyEnvPayload(envPayload);
|
|
354
|
+
}, [applyEnvPayload, dirty, envPayload, saving]);
|
|
355
|
+
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (!envPayloadError) return;
|
|
358
|
+
console.error("Failed to load env vars:", envPayloadError);
|
|
359
|
+
}, [envPayloadError]);
|
|
336
360
|
|
|
337
361
|
useEffect(() => {
|
|
338
362
|
setDirty(getVarsSignature(vars) !== baselineSignatureRef.current);
|
|
@@ -592,6 +616,18 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
|
|
|
592
616
|
`;
|
|
593
617
|
};
|
|
594
618
|
|
|
619
|
+
if (envPayloadLoading && !vars.length) {
|
|
620
|
+
return html`
|
|
621
|
+
<${PaneShell}
|
|
622
|
+
header=${html`<${PageHeader} title="Envars" />`}
|
|
623
|
+
>
|
|
624
|
+
<div class="bg-surface border border-border rounded-xl p-4 text-sm text-gray-500">
|
|
625
|
+
Loading environment variables...
|
|
626
|
+
</div>
|
|
627
|
+
</${PaneShell}>
|
|
628
|
+
`;
|
|
629
|
+
}
|
|
630
|
+
|
|
595
631
|
return html`
|
|
596
632
|
<${PaneShell}
|
|
597
633
|
header=${html`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { h } from "https://esm.sh/preact";
|
|
2
|
-
import { useState, useEffect } from "https://esm.sh/preact/hooks";
|
|
3
2
|
import htm from "https://esm.sh/htm";
|
|
4
3
|
import { fetchEnvVars } from "../lib/api.js";
|
|
4
|
+
import { useCachedFetch } from "../hooks/use-cached-fetch.js";
|
|
5
5
|
import { Badge } from "./badge.js";
|
|
6
6
|
import {
|
|
7
7
|
kFeatureDefs,
|
|
@@ -23,17 +23,11 @@ const resolveFeatureStatus = (feature, envVars) => {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
export const Features = ({ onSwitchTab }) => {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.then((data) => {
|
|
32
|
-
setEnvVars(data.vars || []);
|
|
33
|
-
setLoaded(true);
|
|
34
|
-
})
|
|
35
|
-
.catch(() => setLoaded(true));
|
|
36
|
-
}, []);
|
|
26
|
+
const { data, loading } = useCachedFetch("/api/env", fetchEnvVars, {
|
|
27
|
+
maxAgeMs: 30000,
|
|
28
|
+
});
|
|
29
|
+
const envVars = Array.isArray(data?.vars) ? data.vars : [];
|
|
30
|
+
const loaded = !loading;
|
|
37
31
|
|
|
38
32
|
if (!loaded) return null;
|
|
39
33
|
|
|
@@ -62,8 +62,11 @@ export const useGeneralTab = ({
|
|
|
62
62
|
const data = await fetchPairings();
|
|
63
63
|
return data.pending || [];
|
|
64
64
|
},
|
|
65
|
-
|
|
66
|
-
{
|
|
65
|
+
3000,
|
|
66
|
+
{
|
|
67
|
+
enabled: hasUnpaired && gatewayStatus === "running",
|
|
68
|
+
cacheKey: "/api/pairings",
|
|
69
|
+
},
|
|
67
70
|
);
|
|
68
71
|
const pending = pairingsPoll.data || [];
|
|
69
72
|
const shouldPollDevices =
|
|
@@ -75,14 +78,16 @@ export const useGeneralTab = ({
|
|
|
75
78
|
setCliAutoApproveComplete(data?.cliAutoApproveComplete === true);
|
|
76
79
|
return data.pending || [];
|
|
77
80
|
},
|
|
78
|
-
|
|
79
|
-
{
|
|
81
|
+
5000,
|
|
82
|
+
{
|
|
83
|
+
enabled: shouldPollDevices,
|
|
84
|
+
cacheKey: "/api/devices",
|
|
85
|
+
},
|
|
80
86
|
);
|
|
81
87
|
const devicePending = devicePoll.data || [];
|
|
82
88
|
|
|
83
89
|
useEffect(() => {
|
|
84
90
|
if (!isActive) return;
|
|
85
|
-
onRefreshStatuses();
|
|
86
91
|
pairingsPoll.refresh();
|
|
87
92
|
if (shouldPollDevices) {
|
|
88
93
|
devicePoll.refresh();
|
|
@@ -6,34 +6,38 @@ import {
|
|
|
6
6
|
startGmailWatch,
|
|
7
7
|
stopGmailWatch,
|
|
8
8
|
} from "../../lib/api.js";
|
|
9
|
+
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
9
10
|
|
|
10
11
|
export const useGmailWatch = ({ gatewayStatus, accounts = [] }) => {
|
|
11
|
-
const [loading, setLoading] = useState(true);
|
|
12
|
-
const [config, setConfig] = useState(null);
|
|
13
12
|
const [busyByAccountId, setBusyByAccountId] = useState({});
|
|
14
13
|
const [savingClient, setSavingClient] = useState(false);
|
|
14
|
+
const accountSignature = useMemo(
|
|
15
|
+
() =>
|
|
16
|
+
accounts
|
|
17
|
+
.map((entry) => String(entry?.id || "").trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.sort()
|
|
20
|
+
.join("|"),
|
|
21
|
+
[accounts],
|
|
22
|
+
);
|
|
23
|
+
const {
|
|
24
|
+
data: config,
|
|
25
|
+
loading,
|
|
26
|
+
refresh: refreshCachedConfig,
|
|
27
|
+
} = useCachedFetch("/api/gmail/config", fetchGmailConfig, {
|
|
28
|
+
enabled: gatewayStatus === "running",
|
|
29
|
+
maxAgeMs: 30000,
|
|
30
|
+
});
|
|
15
31
|
|
|
16
32
|
const refresh = useCallback(async () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const nextConfig = await fetchGmailConfig();
|
|
20
|
-
setConfig(nextConfig);
|
|
21
|
-
return nextConfig;
|
|
22
|
-
} finally {
|
|
23
|
-
setLoading(false);
|
|
24
|
-
}
|
|
25
|
-
}, []);
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
if (gatewayStatus !== "running") return;
|
|
29
|
-
refresh();
|
|
30
|
-
}, [gatewayStatus, refresh]);
|
|
33
|
+
return refreshCachedConfig({ force: true });
|
|
34
|
+
}, [refreshCachedConfig]);
|
|
31
35
|
|
|
32
36
|
useEffect(() => {
|
|
33
37
|
if (gatewayStatus !== "running") return;
|
|
34
38
|
if (!accounts.length) return;
|
|
35
|
-
refresh();
|
|
36
|
-
}, [accounts, gatewayStatus, refresh]);
|
|
39
|
+
refresh().catch(() => {});
|
|
40
|
+
}, [accountSignature, accounts.length, gatewayStatus, refresh]);
|
|
37
41
|
|
|
38
42
|
const watchByAccountId = useMemo(() => {
|
|
39
43
|
const map = new Map();
|
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import { useCallback, useEffect,
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "https://esm.sh/preact/hooks";
|
|
2
2
|
import { fetchGoogleAccounts } from "../../lib/api.js";
|
|
3
|
+
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
3
4
|
|
|
4
5
|
export const useGoogleAccounts = ({ gatewayStatus }) => {
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const hasRefreshedAfterGatewayRunningRef = useRef(false);
|
|
7
|
+
const { data, loading, refresh } = useCachedFetch(
|
|
8
|
+
"/api/google/accounts",
|
|
9
|
+
fetchGoogleAccounts,
|
|
10
|
+
{ maxAgeMs: 30000 },
|
|
11
|
+
);
|
|
9
12
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
setHasCompanyCredentials(Boolean(data.hasCompanyCredentials));
|
|
17
|
-
setHasPersonalCredentials(Boolean(data.hasPersonalCredentials));
|
|
18
|
-
}
|
|
19
|
-
} finally {
|
|
20
|
-
setLoading(false);
|
|
21
|
-
}
|
|
22
|
-
}, []);
|
|
13
|
+
const accounts = useMemo(
|
|
14
|
+
() => (Array.isArray(data?.accounts) ? data.accounts : []),
|
|
15
|
+
[data?.accounts],
|
|
16
|
+
);
|
|
17
|
+
const hasCompanyCredentials = Boolean(data?.hasCompanyCredentials);
|
|
18
|
+
const hasPersonalCredentials = Boolean(data?.hasPersonalCredentials);
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}, [
|
|
20
|
+
const refreshAccounts = useCallback(async () => {
|
|
21
|
+
return refresh({ force: true });
|
|
22
|
+
}, [refresh]);
|
|
27
23
|
|
|
28
24
|
useEffect(() => {
|
|
29
|
-
if (gatewayStatus
|
|
30
|
-
|
|
25
|
+
if (gatewayStatus !== "running") {
|
|
26
|
+
hasRefreshedAfterGatewayRunningRef.current = false;
|
|
27
|
+
return;
|
|
31
28
|
}
|
|
29
|
+
if (hasRefreshedAfterGatewayRunningRef.current) return;
|
|
30
|
+
hasRefreshedAfterGatewayRunningRef.current = true;
|
|
31
|
+
refreshAccounts().catch(() => {});
|
|
32
32
|
}, [gatewayStatus, refreshAccounts]);
|
|
33
33
|
|
|
34
34
|
return {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
disconnectCodex,
|
|
8
8
|
} from "../../lib/api.js";
|
|
9
9
|
import { showToast } from "../toast.js";
|
|
10
|
+
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
10
11
|
|
|
11
12
|
let kModelsTabCache = null;
|
|
12
13
|
const getCredentialValue = (value) =>
|
|
@@ -14,6 +15,7 @@ const getCredentialValue = (value) =>
|
|
|
14
15
|
|
|
15
16
|
export const useModels = (agentId) => {
|
|
16
17
|
const isScoped = !!agentId;
|
|
18
|
+
const normalizedAgentId = String(agentId || "").trim();
|
|
17
19
|
const useCache = !isScoped;
|
|
18
20
|
const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []);
|
|
19
21
|
const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || "");
|
|
@@ -43,15 +45,29 @@ export const useModels = (agentId) => {
|
|
|
43
45
|
const updateCache = useCallback((patch) => {
|
|
44
46
|
if (!isScoped) kModelsTabCache = { ...(kModelsTabCache || {}), ...patch };
|
|
45
47
|
}, [isScoped]);
|
|
48
|
+
const modelsConfigCacheKey = normalizedAgentId
|
|
49
|
+
? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}`
|
|
50
|
+
: "/api/models/config";
|
|
51
|
+
const catalogFetchState = useCachedFetch("/api/models", fetchModels, {
|
|
52
|
+
maxAgeMs: 30000,
|
|
53
|
+
});
|
|
54
|
+
const configFetchState = useCachedFetch(
|
|
55
|
+
modelsConfigCacheKey,
|
|
56
|
+
() => fetchModelsConfig(isScoped ? { agentId } : undefined),
|
|
57
|
+
{ maxAgeMs: 30000 },
|
|
58
|
+
);
|
|
59
|
+
const codexFetchState = useCachedFetch("/api/codex/status", fetchCodexStatus, {
|
|
60
|
+
maxAgeMs: 15000,
|
|
61
|
+
});
|
|
46
62
|
|
|
47
63
|
const refresh = useCallback(async () => {
|
|
48
64
|
if (!ready) setLoading(true);
|
|
49
65
|
setError("");
|
|
50
66
|
try {
|
|
51
67
|
const [catalogResult, configResult, codex] = await Promise.all([
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
catalogFetchState.refresh({ force: true }),
|
|
69
|
+
configFetchState.refresh({ force: true }),
|
|
70
|
+
codexFetchState.refresh({ force: true }),
|
|
55
71
|
]);
|
|
56
72
|
const catalogModels = Array.isArray(catalogResult.models)
|
|
57
73
|
? catalogResult.models
|
|
@@ -86,7 +102,7 @@ export const useModels = (agentId) => {
|
|
|
86
102
|
setReady(true);
|
|
87
103
|
setLoading(false);
|
|
88
104
|
}
|
|
89
|
-
}, [ready, updateCache, agentId, isScoped]);
|
|
105
|
+
}, [catalogFetchState, codexFetchState, configFetchState, ready, updateCache, agentId, isScoped]);
|
|
90
106
|
|
|
91
107
|
useEffect(() => {
|
|
92
108
|
refresh();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { usePolling } from "../../../hooks/usePolling.js";
|
|
2
2
|
import { fetchNodesStatus } from "../../../lib/api.js";
|
|
3
3
|
|
|
4
|
-
const kNodesPollIntervalMs =
|
|
4
|
+
const kNodesPollIntervalMs = 10000;
|
|
5
5
|
|
|
6
6
|
export const useConnectedNodes = ({ enabled = true } = {}) => {
|
|
7
7
|
const poll = usePolling(
|
|
@@ -12,7 +12,7 @@ export const useConnectedNodes = ({ enabled = true } = {}) => {
|
|
|
12
12
|
return { nodes, pending };
|
|
13
13
|
},
|
|
14
14
|
kNodesPollIntervalMs,
|
|
15
|
-
{ enabled },
|
|
15
|
+
{ enabled, cacheKey: "/api/nodes" },
|
|
16
16
|
);
|
|
17
17
|
|
|
18
18
|
return {
|
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
|
|
2
2
|
import { fetchNodeConnectInfo } from "../../lib/api.js";
|
|
3
|
+
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
3
4
|
import { showToast } from "../toast.js";
|
|
4
5
|
import { useConnectedNodes } from "./connected-nodes/user-connected-nodes.js";
|
|
5
6
|
|
|
6
7
|
export const useNodesTab = () => {
|
|
7
8
|
const connectedNodesState = useConnectedNodes({ enabled: true });
|
|
8
9
|
const [wizardVisible, setWizardVisible] = useState(false);
|
|
9
|
-
const [connectInfo, setConnectInfo] = useState(null);
|
|
10
10
|
const [refreshingNodes, setRefreshingNodes] = useState(false);
|
|
11
|
+
const {
|
|
12
|
+
data: connectInfo,
|
|
13
|
+
error: connectInfoError,
|
|
14
|
+
} = useCachedFetch("/api/nodes/connect-info", fetchNodeConnectInfo, {
|
|
15
|
+
maxAgeMs: 60000,
|
|
16
|
+
});
|
|
11
17
|
const pairedNodes = Array.isArray(connectedNodesState.nodes)
|
|
12
18
|
? connectedNodesState.nodes.filter((entry) => entry?.paired !== false)
|
|
13
19
|
: [];
|
|
14
20
|
|
|
15
21
|
useEffect(() => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
23
|
-
}, []);
|
|
22
|
+
if (!connectInfoError) return;
|
|
23
|
+
showToast(
|
|
24
|
+
connectInfoError.message || "Could not load node connect command",
|
|
25
|
+
"error",
|
|
26
|
+
);
|
|
27
|
+
}, [connectInfoError]);
|
|
24
28
|
|
|
25
29
|
const refreshNodes = useCallback(async () => {
|
|
26
30
|
if (refreshingNodes) return;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
fetchWatchdogStatus,
|
|
11
11
|
fetchDoctorStatus,
|
|
12
12
|
updateOpenclaw,
|
|
13
|
+
subscribeStatusEvents,
|
|
13
14
|
} from "../lib/api.js";
|
|
14
15
|
import { shouldRequireRestartForBrowsePath } from "../lib/browse-restart-policy.js";
|
|
15
16
|
import { usePolling } from "./usePolling.js";
|
|
@@ -28,19 +29,28 @@ export const useAppShellController = ({ location = "" } = {}) => {
|
|
|
28
29
|
const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0);
|
|
29
30
|
const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000);
|
|
30
31
|
const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
|
|
32
|
+
const [statusStreamConnected, setStatusStreamConnected] = useState(false);
|
|
33
|
+
const [statusStreamStatus, setStatusStreamStatus] = useState(null);
|
|
34
|
+
const [statusStreamWatchdog, setStatusStreamWatchdog] = useState(null);
|
|
35
|
+
const [statusStreamDoctor, setStatusStreamDoctor] = useState(null);
|
|
31
36
|
|
|
32
37
|
const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
|
|
33
|
-
enabled: onboarded === true,
|
|
38
|
+
enabled: onboarded === true && !statusStreamConnected,
|
|
39
|
+
cacheKey: "/api/status",
|
|
34
40
|
});
|
|
35
41
|
const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {
|
|
36
|
-
enabled: onboarded === true,
|
|
42
|
+
enabled: onboarded === true && !statusStreamConnected,
|
|
43
|
+
cacheKey: "/api/watchdog/status",
|
|
37
44
|
});
|
|
38
45
|
const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {
|
|
39
|
-
enabled: onboarded === true,
|
|
46
|
+
enabled: onboarded === true && !statusStreamConnected,
|
|
47
|
+
cacheKey: "/api/doctor/status",
|
|
40
48
|
});
|
|
41
|
-
const sharedStatus = sharedStatusPoll.data || null;
|
|
42
|
-
const sharedWatchdogStatus =
|
|
43
|
-
|
|
49
|
+
const sharedStatus = statusStreamStatus || sharedStatusPoll.data || null;
|
|
50
|
+
const sharedWatchdogStatus =
|
|
51
|
+
statusStreamWatchdog || sharedWatchdogPoll.data?.status || null;
|
|
52
|
+
const sharedDoctorStatus =
|
|
53
|
+
statusStreamDoctor || sharedDoctorPoll.data?.status || null;
|
|
44
54
|
const isAnyRestartRequired = restartRequired || browseRestartRequired;
|
|
45
55
|
|
|
46
56
|
const refreshSharedStatuses = useCallback(() => {
|
|
@@ -58,6 +68,49 @@ export const useAppShellController = ({ location = "" } = {}) => {
|
|
|
58
68
|
.catch(() => {});
|
|
59
69
|
}, []);
|
|
60
70
|
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (onboarded !== true) return;
|
|
73
|
+
let disposed = false;
|
|
74
|
+
const startStream = () => {
|
|
75
|
+
if (disposed) return;
|
|
76
|
+
try {
|
|
77
|
+
return subscribeStatusEvents({
|
|
78
|
+
onOpen: () => {
|
|
79
|
+
if (disposed) return;
|
|
80
|
+
setStatusStreamConnected(true);
|
|
81
|
+
},
|
|
82
|
+
onMessage: (payload = {}) => {
|
|
83
|
+
if (disposed) return;
|
|
84
|
+
if (payload.status && typeof payload.status === "object") {
|
|
85
|
+
setStatusStreamStatus(payload.status);
|
|
86
|
+
}
|
|
87
|
+
if (payload.watchdogStatus && typeof payload.watchdogStatus === "object") {
|
|
88
|
+
setStatusStreamWatchdog(payload.watchdogStatus);
|
|
89
|
+
}
|
|
90
|
+
if (payload.doctorStatus && typeof payload.doctorStatus === "object") {
|
|
91
|
+
setStatusStreamDoctor(payload.doctorStatus);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
onError: () => {
|
|
95
|
+
if (disposed) return;
|
|
96
|
+
setStatusStreamConnected(false);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
setStatusStreamConnected(false);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
let cleanup = startStream();
|
|
105
|
+
return () => {
|
|
106
|
+
disposed = true;
|
|
107
|
+
setStatusStreamConnected(false);
|
|
108
|
+
if (typeof cleanup === "function") {
|
|
109
|
+
cleanup();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, [onboarded]);
|
|
113
|
+
|
|
61
114
|
useEffect(() => {
|
|
62
115
|
if (!onboarded) return;
|
|
63
116
|
let active = true;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
|
|
2
|
+
import { cachedFetch, getCached } from "../lib/api-cache.js";
|
|
3
|
+
|
|
4
|
+
export const useCachedFetch = (
|
|
5
|
+
key,
|
|
6
|
+
fetcher,
|
|
7
|
+
{
|
|
8
|
+
enabled = true,
|
|
9
|
+
maxAgeMs = 15000,
|
|
10
|
+
staleWhileRevalidate = true,
|
|
11
|
+
} = {},
|
|
12
|
+
) => {
|
|
13
|
+
const normalizedKey = useMemo(() => String(key || ""), [key]);
|
|
14
|
+
const initialCachedData = useMemo(() => getCached(normalizedKey), [normalizedKey]);
|
|
15
|
+
const [data, setData] = useState(initialCachedData);
|
|
16
|
+
const [loading, setLoading] = useState(initialCachedData === null);
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setData(getCached(normalizedKey));
|
|
21
|
+
}, [normalizedKey]);
|
|
22
|
+
|
|
23
|
+
const refresh = useCallback(
|
|
24
|
+
async ({ force = false } = {}) => {
|
|
25
|
+
if (!enabled) return getCached(normalizedKey);
|
|
26
|
+
if (getCached(normalizedKey) === null) {
|
|
27
|
+
setLoading(true);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const next = await cachedFetch(normalizedKey, fetcher, {
|
|
31
|
+
maxAgeMs,
|
|
32
|
+
force,
|
|
33
|
+
staleWhileRevalidate,
|
|
34
|
+
onRevalidate: (revalidatedData) => {
|
|
35
|
+
setData(revalidatedData);
|
|
36
|
+
setError(null);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
setData(next);
|
|
40
|
+
setError(null);
|
|
41
|
+
return next;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
setError(err);
|
|
44
|
+
throw err;
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[enabled, fetcher, maxAgeMs, normalizedKey, staleWhileRevalidate],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!enabled) return;
|
|
54
|
+
refresh().catch(() => {});
|
|
55
|
+
}, [enabled, refresh]);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
data,
|
|
59
|
+
error,
|
|
60
|
+
loading,
|
|
61
|
+
refresh,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "https://esm.sh/preact/hooks";
|
|
2
|
+
import { getCached, setCached } from "../lib/api-cache.js";
|
|
2
3
|
|
|
3
|
-
export const usePolling = (
|
|
4
|
-
|
|
4
|
+
export const usePolling = (
|
|
5
|
+
fetcher,
|
|
6
|
+
interval,
|
|
7
|
+
{
|
|
8
|
+
enabled = true,
|
|
9
|
+
pauseWhenHidden = true,
|
|
10
|
+
cacheKey = "",
|
|
11
|
+
} = {},
|
|
12
|
+
) => {
|
|
13
|
+
const normalizedCacheKey = String(cacheKey || "");
|
|
14
|
+
const [data, setData] = useState(() =>
|
|
15
|
+
normalizedCacheKey ? getCached(normalizedCacheKey) : null,
|
|
16
|
+
);
|
|
5
17
|
const [error, setError] = useState(null);
|
|
6
18
|
const fetcherRef = useRef(fetcher);
|
|
7
19
|
fetcherRef.current = fetcher;
|
|
@@ -9,6 +21,9 @@ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
|
|
|
9
21
|
const refresh = useCallback(async () => {
|
|
10
22
|
try {
|
|
11
23
|
const result = await fetcherRef.current();
|
|
24
|
+
if (normalizedCacheKey) {
|
|
25
|
+
setCached(normalizedCacheKey, result);
|
|
26
|
+
}
|
|
12
27
|
setData(result);
|
|
13
28
|
setError(null);
|
|
14
29
|
return result;
|
|
@@ -16,14 +31,37 @@ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
|
|
|
16
31
|
setError(err);
|
|
17
32
|
return null;
|
|
18
33
|
}
|
|
19
|
-
}, []);
|
|
34
|
+
}, [normalizedCacheKey]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!normalizedCacheKey) return;
|
|
38
|
+
const cached = getCached(normalizedCacheKey);
|
|
39
|
+
if (cached !== null) {
|
|
40
|
+
setData(cached);
|
|
41
|
+
}
|
|
42
|
+
}, [normalizedCacheKey]);
|
|
20
43
|
|
|
21
44
|
useEffect(() => {
|
|
22
45
|
if (!enabled) return;
|
|
46
|
+
if (pauseWhenHidden && typeof document !== "undefined" && document.hidden) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
23
49
|
refresh();
|
|
24
|
-
const
|
|
25
|
-
return () => clearInterval(
|
|
26
|
-
}, [enabled, interval, refresh]);
|
|
50
|
+
const intervalId = setInterval(refresh, interval);
|
|
51
|
+
return () => clearInterval(intervalId);
|
|
52
|
+
}, [enabled, interval, pauseWhenHidden, refresh]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!enabled || !pauseWhenHidden || typeof document === "undefined") return;
|
|
56
|
+
const handleVisibilityChange = () => {
|
|
57
|
+
if (!document.hidden) {
|
|
58
|
+
refresh();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
62
|
+
return () =>
|
|
63
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
64
|
+
}, [enabled, pauseWhenHidden, refresh]);
|
|
27
65
|
|
|
28
66
|
return { data, error, refresh };
|
|
29
67
|
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const kApiCache = new Map();
|
|
2
|
+
const kInFlightByKey = new Map();
|
|
3
|
+
|
|
4
|
+
const nowMs = () => Date.now();
|
|
5
|
+
|
|
6
|
+
const isFresh = (entry, maxAgeMs) => {
|
|
7
|
+
if (!entry) return false;
|
|
8
|
+
return nowMs() - Number(entry.fetchedAt || 0) < Number(maxAgeMs || 0);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getCached = (key = "") => {
|
|
12
|
+
const normalizedKey = String(key || "");
|
|
13
|
+
if (!normalizedKey) return null;
|
|
14
|
+
return kApiCache.get(normalizedKey)?.data ?? null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const setCached = (key = "", data = null) => {
|
|
18
|
+
const normalizedKey = String(key || "");
|
|
19
|
+
if (!normalizedKey) return data;
|
|
20
|
+
kApiCache.set(normalizedKey, {
|
|
21
|
+
data,
|
|
22
|
+
fetchedAt: nowMs(),
|
|
23
|
+
});
|
|
24
|
+
return data;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const invalidateCache = (key = "") => {
|
|
28
|
+
const normalizedKey = String(key || "");
|
|
29
|
+
if (!normalizedKey) return;
|
|
30
|
+
kApiCache.delete(normalizedKey);
|
|
31
|
+
kInFlightByKey.delete(normalizedKey);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const cachedFetch = async (
|
|
35
|
+
key,
|
|
36
|
+
fetcher,
|
|
37
|
+
{
|
|
38
|
+
maxAgeMs = 15000,
|
|
39
|
+
force = false,
|
|
40
|
+
staleWhileRevalidate = true,
|
|
41
|
+
onRevalidate = null,
|
|
42
|
+
} = {},
|
|
43
|
+
) => {
|
|
44
|
+
const normalizedKey = String(key || "");
|
|
45
|
+
if (!normalizedKey || typeof fetcher !== "function") {
|
|
46
|
+
return fetcher();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const entry = kApiCache.get(normalizedKey);
|
|
50
|
+
if (!force && isFresh(entry, maxAgeMs)) {
|
|
51
|
+
return entry.data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!force && staleWhileRevalidate && entry) {
|
|
55
|
+
if (!kInFlightByKey.has(normalizedKey)) {
|
|
56
|
+
const backgroundPromise = Promise.resolve()
|
|
57
|
+
.then(() => fetcher())
|
|
58
|
+
.then((result) => {
|
|
59
|
+
setCached(normalizedKey, result);
|
|
60
|
+
if (typeof onRevalidate === "function") {
|
|
61
|
+
onRevalidate(result);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
kInFlightByKey.delete(normalizedKey);
|
|
67
|
+
});
|
|
68
|
+
kInFlightByKey.set(normalizedKey, backgroundPromise);
|
|
69
|
+
}
|
|
70
|
+
return entry.data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (kInFlightByKey.has(normalizedKey)) {
|
|
74
|
+
return kInFlightByKey.get(normalizedKey);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const requestPromise = Promise.resolve()
|
|
78
|
+
.then(() => fetcher())
|
|
79
|
+
.then((result) => {
|
|
80
|
+
setCached(normalizedKey, result);
|
|
81
|
+
return result;
|
|
82
|
+
})
|
|
83
|
+
.finally(() => {
|
|
84
|
+
kInFlightByKey.delete(normalizedKey);
|
|
85
|
+
});
|
|
86
|
+
kInFlightByKey.set(normalizedKey, requestPromise);
|
|
87
|
+
return requestPromise;
|
|
88
|
+
};
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -31,6 +31,35 @@ export const authFetch = async (url, opts = {}) => {
|
|
|
31
31
|
return res;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export const subscribeStatusEvents = ({
|
|
35
|
+
onMessage = () => {},
|
|
36
|
+
onOpen = () => {},
|
|
37
|
+
onError = () => {},
|
|
38
|
+
} = {}) => {
|
|
39
|
+
if (typeof window?.EventSource !== "function") {
|
|
40
|
+
throw new Error("Server events are not supported in this browser");
|
|
41
|
+
}
|
|
42
|
+
const source = new window.EventSource("/api/events/status", {
|
|
43
|
+
withCredentials: true,
|
|
44
|
+
});
|
|
45
|
+
const handleStatus = (event) => {
|
|
46
|
+
let payload = {};
|
|
47
|
+
try {
|
|
48
|
+
payload = event?.data ? JSON.parse(event.data) : {};
|
|
49
|
+
} catch {}
|
|
50
|
+
onMessage(payload || {});
|
|
51
|
+
};
|
|
52
|
+
source.addEventListener("status", handleStatus);
|
|
53
|
+
source.onopen = () => onOpen();
|
|
54
|
+
source.onerror = (event) => onError(event);
|
|
55
|
+
return () => {
|
|
56
|
+
source.removeEventListener("status", handleStatus);
|
|
57
|
+
source.onopen = null;
|
|
58
|
+
source.onerror = null;
|
|
59
|
+
source.close();
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
34
63
|
export async function fetchStatus() {
|
|
35
64
|
const res = await authFetch("/api/status");
|
|
36
65
|
return res.json();
|
|
@@ -25,6 +25,8 @@ const registerSystemRoutes = ({
|
|
|
25
25
|
restartRequiredState,
|
|
26
26
|
topicRegistry,
|
|
27
27
|
authProfiles,
|
|
28
|
+
watchdog,
|
|
29
|
+
doctorService,
|
|
28
30
|
}) => {
|
|
29
31
|
let envRestartPending = false;
|
|
30
32
|
const kManagedChannelTokenPattern =
|
|
@@ -465,12 +467,12 @@ const registerSystemRoutes = ({
|
|
|
465
467
|
res.json({ ok: true, changed, restartRequired });
|
|
466
468
|
});
|
|
467
469
|
|
|
468
|
-
|
|
470
|
+
const buildStatusPayload = async () => {
|
|
469
471
|
const configExists = fs.existsSync(`${OPENCLAW_DIR}/openclaw.json`);
|
|
470
472
|
const running = await isGatewayRunning();
|
|
471
473
|
const repo = process.env.GITHUB_WORKSPACE_REPO || "";
|
|
472
474
|
const openclawVersion = openclawVersionService.readOpenclawVersion();
|
|
473
|
-
|
|
475
|
+
return {
|
|
474
476
|
gateway: running
|
|
475
477
|
? "running"
|
|
476
478
|
: configExists
|
|
@@ -481,6 +483,52 @@ const registerSystemRoutes = ({
|
|
|
481
483
|
repo,
|
|
482
484
|
openclawVersion,
|
|
483
485
|
syncCron: getSystemCronStatus(),
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
app.get("/api/status", async (req, res) => {
|
|
490
|
+
const payload = await buildStatusPayload();
|
|
491
|
+
res.json(payload);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
app.get("/api/events/status", async (req, res) => {
|
|
495
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
496
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
497
|
+
res.setHeader("Connection", "keep-alive");
|
|
498
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
499
|
+
res.flushHeaders?.();
|
|
500
|
+
|
|
501
|
+
const writeStatusEvent = async () => {
|
|
502
|
+
try {
|
|
503
|
+
const status = await buildStatusPayload();
|
|
504
|
+
const watchdogStatus =
|
|
505
|
+
typeof watchdog?.getStatus === "function" ? watchdog.getStatus() : null;
|
|
506
|
+
const doctorStatus =
|
|
507
|
+
typeof doctorService?.buildStatus === "function"
|
|
508
|
+
? doctorService.buildStatus()
|
|
509
|
+
: null;
|
|
510
|
+
res.write("event: status\n");
|
|
511
|
+
res.write(
|
|
512
|
+
`data: ${JSON.stringify({
|
|
513
|
+
status,
|
|
514
|
+
watchdogStatus,
|
|
515
|
+
doctorStatus,
|
|
516
|
+
timestamp: new Date().toISOString(),
|
|
517
|
+
})}\n\n`,
|
|
518
|
+
);
|
|
519
|
+
} catch {}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
await writeStatusEvent();
|
|
523
|
+
const statusIntervalId = setInterval(writeStatusEvent, 2000);
|
|
524
|
+
const keepAliveIntervalId = setInterval(() => {
|
|
525
|
+
res.write(": keepalive\n\n");
|
|
526
|
+
}, 15000);
|
|
527
|
+
|
|
528
|
+
req.on("close", () => {
|
|
529
|
+
clearInterval(statusIntervalId);
|
|
530
|
+
clearInterval(keepAliveIntervalId);
|
|
531
|
+
res.end();
|
|
484
532
|
});
|
|
485
533
|
});
|
|
486
534
|
|