@chrysb/alphaclaw 0.8.0 → 0.8.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/public/js/app.js +100 -83
  2. package/lib/public/js/components/agents-tab/agent-pairing-section.js +47 -12
  3. package/lib/public/js/components/channels.js +14 -17
  4. package/lib/public/js/components/envars.js +42 -6
  5. package/lib/public/js/components/features.js +6 -12
  6. package/lib/public/js/components/general/use-general-tab.js +10 -5
  7. package/lib/public/js/components/google/use-gmail-watch.js +22 -18
  8. package/lib/public/js/components/google/use-google-accounts.js +23 -23
  9. package/lib/public/js/components/models-tab/use-models.js +20 -4
  10. package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +2 -2
  11. package/lib/public/js/components/nodes-tab/use-nodes-tab.js +13 -9
  12. package/lib/public/js/components/routes/webhooks-route.js +1 -1
  13. package/lib/public/js/components/webhooks/create-webhook-modal/index.js +176 -0
  14. package/lib/public/js/components/webhooks/helpers.js +106 -0
  15. package/lib/public/js/components/webhooks/index.js +148 -0
  16. package/lib/public/js/components/webhooks/request-history/index.js +241 -0
  17. package/lib/public/js/components/webhooks/request-history/use-request-history.js +167 -0
  18. package/lib/public/js/components/webhooks/webhook-detail/index.js +374 -0
  19. package/lib/public/js/components/webhooks/webhook-detail/use-webhook-detail.js +261 -0
  20. package/lib/public/js/components/webhooks/webhook-list/index.js +96 -0
  21. package/lib/public/js/components/webhooks/webhook-list/use-webhook-list.js +30 -0
  22. package/lib/public/js/hooks/use-app-shell-controller.js +59 -6
  23. package/lib/public/js/hooks/use-cached-fetch.js +63 -0
  24. package/lib/public/js/hooks/usePolling.js +45 -7
  25. package/lib/public/js/lib/api-cache.js +88 -0
  26. package/lib/public/js/lib/api.js +64 -1
  27. package/lib/server/db/webhooks/index.js +144 -0
  28. package/lib/server/db/webhooks/schema.js +13 -0
  29. package/lib/server/init/register-server-routes.js +21 -0
  30. package/lib/server/oauth-callback-middleware.js +34 -0
  31. package/lib/server/routes/proxy.js +2 -0
  32. package/lib/server/routes/system.js +50 -2
  33. package/lib/server/routes/webhooks.js +126 -18
  34. package/lib/server/webhook-middleware.js +6 -1
  35. package/lib/server.js +12 -0
  36. package/package.json +1 -1
  37. package/lib/public/js/components/webhooks.js +0 -1259
@@ -240,87 +240,105 @@ const App = () => {
240
240
  <span style="color: var(--accent)">alpha</span>claw
241
241
  </span>
242
242
  </div>
243
- <div
244
- class="app-content-pane browse-pane"
245
- style=${{ display: browseState.isBrowseRoute ? "block" : "none" }}
246
- >
247
- <${BrowseRoute}
248
- activeBrowsePath=${browseState.activeBrowsePath}
249
- browseView=${browseState.browseViewerMode}
250
- lineTarget=${browseState.browseLineTarget}
251
- lineEndTarget=${browseState.browseLineEndTarget}
252
- selectedBrowsePath=${browseState.selectedBrowsePath}
253
- onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
254
- onEditSelectedBrowseFile=${() =>
255
- setLocation(
256
- browseActions.buildBrowseRoute(browseState.selectedBrowsePath, {
257
- view: "edit",
258
- }),
259
- )}
260
- onClearSelection=${() => {
261
- browseActions.clearBrowsePreview();
262
- setLocation("/browse");
263
- }}
264
- />
265
- </div>
266
- <div
267
- class="app-content-pane agents-pane"
268
- style=${{ display: isAgentsRoute ? "block" : "none" }}
269
- >
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) => setLocation(`/agents/${encodeURIComponent(agentId)}`)}
278
- onSelectTab=${(tab) => {
279
- const safePath = tab && tab !== "overview"
280
- ? `/agents/${encodeURIComponent(selectedAgentId)}/${tab}`
281
- : `/agents/${encodeURIComponent(selectedAgentId)}`;
282
- setLocation(safePath);
283
- }}
284
- onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
285
- onSetLocation=${setLocation}
286
- />
287
- </div>
288
- <div
289
- class="app-content-pane cron-pane"
290
- style=${{ display: isCronRoute ? "block" : "none" }}
291
- >
292
- <${CronRoute}
293
- jobId=${selectedCronJobId}
294
- onSetLocation=${setLocation}
295
- />
296
- </div>
297
- <div
298
- class="app-content-pane ac-fixed-header-pane"
299
- style=${{ display: isEnvarsRoute ? "block" : "none" }}
300
- >
301
- <${EnvarsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
302
- </div>
303
- <div
304
- class="app-content-pane ac-fixed-header-pane"
305
- style=${{ display: isModelsRoute ? "block" : "none" }}
306
- >
307
- <${ModelsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
308
- </div>
309
- <div
310
- class="app-content-pane"
311
- style=${{ display: isNodesRoute ? "block" : "none" }}
312
- >
313
- <${NodesRoute} onRestartRequired=${controllerActions.setRestartRequired} />
314
- </div>
315
- <div
316
- class="app-content-pane"
317
- onscroll=${shellActions.handlePaneScroll}
318
- style=${{ display: browseState.isBrowseRoute || isAgentsRoute || isCronRoute || isEnvarsRoute || isModelsRoute || isNodesRoute ? "none" : "block" }}
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
- ${!browseState.isBrowseRoute && !isAgentsRoute && !isCronRoute && !isEnvarsRoute && !isModelsRoute && !isNodesRoute
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
- </div>
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 [bindingsResult, channelsResult] = await Promise.all([
51
- fetchAgentBindings(agent.id),
52
- fetchChannelAccounts(),
70
+ const [nextBindingsPayload, nextChannelsPayload] = await Promise.all([
71
+ refreshBindingsPayload({ force: true }),
72
+ refreshChannelsPayload({ force: true }),
53
73
  ]);
54
- setBindings(Array.isArray(bindingsResult?.bindings) ? bindingsResult.bindings : []);
55
- setChannels(Array.isArray(channelsResult?.channels) ? channelsResult.channels : []);
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
- }, [agent.id]);
90
+ }, [refreshBindingsPayload, refreshChannelsPayload]);
63
91
 
64
92
  useEffect(() => {
65
- if (String(agent?.id || "").trim()) {
66
- loadBindings();
67
- }
68
- }, [agent.id, loadBindings]);
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
- 1000,
211
- { enabled: hasUnpaired && ownedAccounts.length > 0 },
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
- const payload = await fetchChannelAccounts();
155
- setChannelAccounts(
156
- Array.isArray(payload?.channels) ? payload.channels : [],
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 load = useCallback(async () => {
320
- try {
321
- const data = await fetchEnvVars();
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
- load();
335
- }, [load]);
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 [envVars, setEnvVars] = useState([]);
27
- const [loaded, setLoaded] = useState(false);
28
-
29
- useEffect(() => {
30
- fetchEnvVars()
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
- 1000,
66
- { enabled: hasUnpaired && gatewayStatus === "running" },
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
- 2000,
79
- { enabled: shouldPollDevices },
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
- setLoading(true);
18
- try {
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, useState } from "https://esm.sh/preact/hooks";
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 [accounts, setAccounts] = useState([]);
6
- const [loading, setLoading] = useState(true);
7
- const [hasCompanyCredentials, setHasCompanyCredentials] = useState(false);
8
- const [hasPersonalCredentials, setHasPersonalCredentials] = useState(false);
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 refreshAccounts = useCallback(async () => {
11
- setLoading(true);
12
- try {
13
- const data = await fetchGoogleAccounts();
14
- if (data.ok) {
15
- setAccounts(Array.isArray(data.accounts) ? data.accounts : []);
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
- useEffect(() => {
25
- refreshAccounts();
26
- }, [refreshAccounts]);
20
+ const refreshAccounts = useCallback(async () => {
21
+ return refresh({ force: true });
22
+ }, [refresh]);
27
23
 
28
24
  useEffect(() => {
29
- if (gatewayStatus === "running") {
30
- refreshAccounts();
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
- fetchModels(),
53
- fetchModelsConfig(isScoped ? { agentId } : undefined),
54
- fetchCodexStatus(),
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();