@chrysb/alphaclaw 0.4.6-beta.3 → 0.4.6-beta.5

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 (39) hide show
  1. package/lib/public/js/app.js +158 -1073
  2. package/lib/public/js/components/doctor/index.js +1 -2
  3. package/lib/public/js/components/general/index.js +155 -0
  4. package/lib/public/js/components/general/use-general-tab.js +233 -0
  5. package/lib/public/js/components/models-tab/index.js +286 -0
  6. package/lib/public/js/components/models-tab/provider-auth-card.js +369 -0
  7. package/lib/public/js/components/models-tab/use-models.js +262 -0
  8. package/lib/public/js/components/routes/browse-route.js +35 -0
  9. package/lib/public/js/components/routes/doctor-route.js +21 -0
  10. package/lib/public/js/components/routes/envars-route.js +11 -0
  11. package/lib/public/js/components/routes/general-route.js +45 -0
  12. package/lib/public/js/components/routes/index.js +11 -0
  13. package/lib/public/js/components/routes/models-route.js +11 -0
  14. package/lib/public/js/components/routes/providers-route.js +11 -0
  15. package/lib/public/js/components/routes/route-redirect.js +10 -0
  16. package/lib/public/js/components/routes/telegram-route.js +11 -0
  17. package/lib/public/js/components/routes/usage-route.js +15 -0
  18. package/lib/public/js/components/routes/watchdog-route.js +32 -0
  19. package/lib/public/js/components/routes/webhooks-route.js +43 -0
  20. package/lib/public/js/components/sidebar.js +2 -3
  21. package/lib/public/js/components/usage-tab/constants.js +1 -1
  22. package/lib/public/js/components/usage-tab/overview-section.js +124 -50
  23. package/lib/public/js/components/usage-tab/use-usage-tab.js +42 -11
  24. package/lib/public/js/hooks/use-app-shell-controller.js +230 -0
  25. package/lib/public/js/hooks/use-app-shell-ui.js +112 -0
  26. package/lib/public/js/hooks/use-browse-navigation.js +193 -0
  27. package/lib/public/js/hooks/use-hash-location.js +32 -0
  28. package/lib/public/js/lib/api.js +35 -0
  29. package/lib/public/js/lib/app-navigation.js +39 -0
  30. package/lib/public/js/lib/browse-restart-policy.js +28 -0
  31. package/lib/public/js/lib/browse-route.js +57 -0
  32. package/lib/public/js/lib/format.js +12 -0
  33. package/lib/server/auth-profiles.js +223 -52
  34. package/lib/server/doctor/prompt.js +4 -1
  35. package/lib/server/gateway.js +29 -9
  36. package/lib/server/routes/models.js +170 -2
  37. package/lib/server/watchdog.js +14 -1
  38. package/lib/server.js +1 -0
  39. package/package.json +1 -1
@@ -0,0 +1,230 @@
1
+ import { useState, useEffect, useCallback } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ fetchStatus,
4
+ fetchOnboardStatus,
5
+ fetchAuthStatus,
6
+ fetchAlphaclawVersion,
7
+ updateAlphaclaw,
8
+ fetchRestartStatus,
9
+ restartGateway,
10
+ fetchWatchdogStatus,
11
+ fetchDoctorStatus,
12
+ updateOpenclaw,
13
+ } from "../lib/api.js";
14
+ import { shouldRequireRestartForBrowsePath } from "../lib/browse-restart-policy.js";
15
+ import { usePolling } from "./usePolling.js";
16
+ import { showToast } from "../components/toast.js";
17
+
18
+ export const useAppShellController = ({ location = "" } = {}) => {
19
+ const [onboarded, setOnboarded] = useState(null);
20
+ const [authEnabled, setAuthEnabled] = useState(false);
21
+ const [acVersion, setAcVersion] = useState(null);
22
+ const [acLatest, setAcLatest] = useState(null);
23
+ const [acHasUpdate, setAcHasUpdate] = useState(false);
24
+ const [acUpdating, setAcUpdating] = useState(false);
25
+ const [restartRequired, setRestartRequired] = useState(false);
26
+ const [browseRestartRequired, setBrowseRestartRequired] = useState(false);
27
+ const [restartingGateway, setRestartingGateway] = useState(false);
28
+ const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0);
29
+ const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000);
30
+ const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
31
+
32
+ const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
33
+ enabled: onboarded === true,
34
+ });
35
+ const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {
36
+ enabled: onboarded === true,
37
+ });
38
+ const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {
39
+ enabled: onboarded === true,
40
+ });
41
+ const sharedStatus = sharedStatusPoll.data || null;
42
+ const sharedWatchdogStatus = sharedWatchdogPoll.data?.status || null;
43
+ const sharedDoctorStatus = sharedDoctorPoll.data?.status || null;
44
+ const isAnyRestartRequired = restartRequired || browseRestartRequired;
45
+
46
+ const refreshSharedStatuses = useCallback(() => {
47
+ sharedStatusPoll.refresh();
48
+ sharedWatchdogPoll.refresh();
49
+ sharedDoctorPoll.refresh();
50
+ }, [sharedDoctorPoll.refresh, sharedStatusPoll.refresh, sharedWatchdogPoll.refresh]);
51
+
52
+ useEffect(() => {
53
+ fetchOnboardStatus()
54
+ .then((data) => setOnboarded(data.onboarded))
55
+ .catch(() => setOnboarded(false));
56
+ fetchAuthStatus()
57
+ .then((data) => setAuthEnabled(!!data.authEnabled))
58
+ .catch(() => {});
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ if (!onboarded) return;
63
+ let active = true;
64
+ const check = async (refresh = false) => {
65
+ try {
66
+ const data = await fetchAlphaclawVersion(refresh);
67
+ if (!active) return;
68
+ setAcVersion(data.currentVersion || null);
69
+ setAcLatest(data.latestVersion || null);
70
+ setAcHasUpdate(!!data.hasUpdate);
71
+ } catch {}
72
+ };
73
+ check(true);
74
+ const id = setInterval(() => check(false), 5 * 60 * 1000);
75
+ return () => {
76
+ active = false;
77
+ clearInterval(id);
78
+ };
79
+ }, [onboarded]);
80
+
81
+ const refreshRestartStatus = useCallback(async () => {
82
+ if (!onboarded) return;
83
+ try {
84
+ const data = await fetchRestartStatus();
85
+ setRestartRequired(!!data.restartRequired);
86
+ setRestartingGateway(!!data.restartInProgress);
87
+ } catch {}
88
+ }, [onboarded]);
89
+
90
+ useEffect(() => {
91
+ if (!onboarded) return;
92
+ refreshRestartStatus();
93
+ }, [onboarded, refreshRestartStatus]);
94
+
95
+ useEffect(() => {
96
+ if (onboarded !== true) return;
97
+ const inStatusView =
98
+ location.startsWith("/general") || location.startsWith("/watchdog");
99
+ const gatewayStatus = sharedStatus?.gateway ?? null;
100
+ const watchdogHealth = String(sharedWatchdogStatus?.health || "").toLowerCase();
101
+ const watchdogLifecycle = String(sharedWatchdogStatus?.lifecycle || "").toLowerCase();
102
+ const shouldFastPollWatchdog =
103
+ watchdogHealth === "unknown" ||
104
+ watchdogLifecycle === "restarting" ||
105
+ watchdogLifecycle === "stopped" ||
106
+ !!sharedWatchdogStatus?.operationInProgress;
107
+ const shouldFastPollGateway = !gatewayStatus || gatewayStatus !== "running";
108
+ const nextCadenceMs =
109
+ inStatusView && (shouldFastPollWatchdog || shouldFastPollGateway) ? 2000 : 15000;
110
+ setStatusPollCadenceMs((currentCadenceMs) =>
111
+ currentCadenceMs === nextCadenceMs ? currentCadenceMs : nextCadenceMs,
112
+ );
113
+ }, [
114
+ location,
115
+ onboarded,
116
+ sharedStatus?.gateway,
117
+ sharedWatchdogStatus?.health,
118
+ sharedWatchdogStatus?.lifecycle,
119
+ sharedWatchdogStatus?.operationInProgress,
120
+ ]);
121
+
122
+ useEffect(() => {
123
+ if (!onboarded || (!restartRequired && !restartingGateway)) return;
124
+ const id = setInterval(refreshRestartStatus, 2000);
125
+ return () => clearInterval(id);
126
+ }, [onboarded, restartRequired, restartingGateway, refreshRestartStatus]);
127
+
128
+ useEffect(() => {
129
+ const handleBrowseFileSaved = (event) => {
130
+ const savedPath = String(event?.detail?.path || "");
131
+ if (!shouldRequireRestartForBrowsePath(savedPath)) return;
132
+ setBrowseRestartRequired(true);
133
+ };
134
+ window.addEventListener("alphaclaw:browse-file-saved", handleBrowseFileSaved);
135
+ return () => {
136
+ window.removeEventListener("alphaclaw:browse-file-saved", handleBrowseFileSaved);
137
+ };
138
+ }, []);
139
+
140
+ const handleGatewayRestart = useCallback(async () => {
141
+ if (restartingGateway) return;
142
+ setRestartingGateway(true);
143
+ try {
144
+ const data = await restartGateway();
145
+ if (!data?.ok) throw new Error(data?.error || "Gateway restart failed");
146
+ setRestartRequired(!!data.restartRequired);
147
+ setBrowseRestartRequired(false);
148
+ setGatewayRestartSignal(Date.now());
149
+ refreshSharedStatuses();
150
+ showToast("Gateway restarted", "success");
151
+ setTimeout(refreshRestartStatus, 800);
152
+ } catch (err) {
153
+ showToast(err.message || "Restart failed", "error");
154
+ setTimeout(refreshRestartStatus, 800);
155
+ } finally {
156
+ setRestartingGateway(false);
157
+ }
158
+ }, [refreshRestartStatus, refreshSharedStatuses, restartingGateway]);
159
+
160
+ const handleOpenclawUpdate = useCallback(async () => {
161
+ if (openclawUpdateInProgress) {
162
+ return { ok: false, error: "OpenClaw update already in progress" };
163
+ }
164
+ setOpenclawUpdateInProgress(true);
165
+ try {
166
+ const data = await updateOpenclaw();
167
+ return data;
168
+ } finally {
169
+ setOpenclawUpdateInProgress(false);
170
+ refreshSharedStatuses();
171
+ setTimeout(refreshSharedStatuses, 1200);
172
+ setTimeout(refreshSharedStatuses, 3500);
173
+ setTimeout(refreshRestartStatus, 1200);
174
+ }
175
+ }, [openclawUpdateInProgress, refreshRestartStatus, refreshSharedStatuses]);
176
+
177
+ const handleOpenclawVersionActionComplete = useCallback(
178
+ ({ type }) => {
179
+ if (type !== "update") return;
180
+ refreshSharedStatuses();
181
+ setTimeout(refreshSharedStatuses, 1200);
182
+ },
183
+ [refreshSharedStatuses],
184
+ );
185
+
186
+ const handleAcUpdate = useCallback(async () => {
187
+ if (acUpdating) return;
188
+ setAcUpdating(true);
189
+ try {
190
+ const data = await updateAlphaclaw();
191
+ if (data.ok) {
192
+ showToast("AlphaClaw updated — restarting...", "success");
193
+ setTimeout(() => window.location.reload(), 5000);
194
+ } else {
195
+ showToast(data.error || "AlphaClaw update failed", "error");
196
+ setAcUpdating(false);
197
+ }
198
+ } catch (err) {
199
+ showToast(err.message || "Could not update AlphaClaw", "error");
200
+ setAcUpdating(false);
201
+ }
202
+ }, [acUpdating]);
203
+
204
+ return {
205
+ state: {
206
+ acHasUpdate,
207
+ acLatest,
208
+ acUpdating,
209
+ acVersion,
210
+ authEnabled,
211
+ gatewayRestartSignal,
212
+ isAnyRestartRequired,
213
+ onboarded,
214
+ openclawUpdateInProgress,
215
+ restartingGateway,
216
+ sharedDoctorStatus,
217
+ sharedStatus,
218
+ sharedWatchdogStatus,
219
+ },
220
+ actions: {
221
+ handleAcUpdate,
222
+ handleGatewayRestart,
223
+ handleOnboardingComplete: () => setOnboarded(true),
224
+ handleOpenclawUpdate,
225
+ handleOpenclawVersionActionComplete,
226
+ refreshSharedStatuses,
227
+ setRestartRequired,
228
+ },
229
+ };
230
+ };
@@ -0,0 +1,112 @@
1
+ import { useState, useEffect, useRef, useCallback } from "https://esm.sh/preact/hooks";
2
+ import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js";
3
+
4
+ const kDefaultSidebarWidthPx = 220;
5
+ const kSidebarMinWidthPx = 180;
6
+ const kSidebarMaxWidthPx = 460;
7
+
8
+ const clampSidebarWidth = (value) =>
9
+ Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value));
10
+
11
+ export const useAppShellUi = () => {
12
+ const appShellRef = useRef(null);
13
+ const menuRef = useRef(null);
14
+ const [menuOpen, setMenuOpen] = useState(false);
15
+ const [sidebarWidthPx, setSidebarWidthPx] = useState(() => {
16
+ const settings = readUiSettings();
17
+ if (!Number.isFinite(settings.sidebarWidthPx)) return kDefaultSidebarWidthPx;
18
+ return clampSidebarWidth(settings.sidebarWidthPx);
19
+ });
20
+ const [isResizingSidebar, setIsResizingSidebar] = useState(false);
21
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
22
+ const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false);
23
+
24
+ const closeMenu = useCallback((event) => {
25
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
26
+ setMenuOpen(false);
27
+ }
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ if (menuOpen) {
32
+ document.addEventListener("click", closeMenu, true);
33
+ return () => document.removeEventListener("click", closeMenu, true);
34
+ }
35
+ }, [closeMenu, menuOpen]);
36
+
37
+ useEffect(() => {
38
+ if (!mobileSidebarOpen) return;
39
+ const previousOverflow = document.body.style.overflow;
40
+ document.body.style.overflow = "hidden";
41
+ return () => {
42
+ document.body.style.overflow = previousOverflow;
43
+ };
44
+ }, [mobileSidebarOpen]);
45
+
46
+ useEffect(() => {
47
+ const settings = readUiSettings();
48
+ settings.sidebarWidthPx = sidebarWidthPx;
49
+ writeUiSettings(settings);
50
+ }, [sidebarWidthPx]);
51
+
52
+ const resizeSidebarWithClientX = useCallback((clientX) => {
53
+ const shellElement = appShellRef.current;
54
+ if (!shellElement) return;
55
+ const shellBounds = shellElement.getBoundingClientRect();
56
+ const nextWidth = clampSidebarWidth(Math.round(clientX - shellBounds.left));
57
+ setSidebarWidthPx(nextWidth);
58
+ }, []);
59
+
60
+ const onSidebarResizerPointerDown = useCallback((event) => {
61
+ event.preventDefault();
62
+ setIsResizingSidebar(true);
63
+ resizeSidebarWithClientX(event.clientX);
64
+ }, [resizeSidebarWithClientX]);
65
+
66
+ useEffect(() => {
67
+ if (!isResizingSidebar) return () => {};
68
+ const onPointerMove = (event) => resizeSidebarWithClientX(event.clientX);
69
+ const onPointerUp = () => setIsResizingSidebar(false);
70
+ window.addEventListener("pointermove", onPointerMove);
71
+ window.addEventListener("pointerup", onPointerUp);
72
+ const previousUserSelect = document.body.style.userSelect;
73
+ const previousCursor = document.body.style.cursor;
74
+ document.body.style.userSelect = "none";
75
+ document.body.style.cursor = "col-resize";
76
+ return () => {
77
+ window.removeEventListener("pointermove", onPointerMove);
78
+ window.removeEventListener("pointerup", onPointerUp);
79
+ document.body.style.userSelect = previousUserSelect;
80
+ document.body.style.cursor = previousCursor;
81
+ };
82
+ }, [isResizingSidebar, resizeSidebarWithClientX]);
83
+
84
+ const handlePaneScroll = useCallback((event) => {
85
+ const nextScrolled = event.currentTarget.scrollTop > 0;
86
+ setMobileTopbarScrolled((currentScrolled) =>
87
+ currentScrolled === nextScrolled ? currentScrolled : nextScrolled,
88
+ );
89
+ }, []);
90
+
91
+ return {
92
+ refs: {
93
+ appShellRef,
94
+ menuRef,
95
+ },
96
+ state: {
97
+ isResizingSidebar,
98
+ menuOpen,
99
+ mobileSidebarOpen,
100
+ mobileTopbarScrolled,
101
+ sidebarWidthPx,
102
+ },
103
+ actions: {
104
+ closeMobileSidebar: () => setMobileSidebarOpen(false),
105
+ handlePaneScroll,
106
+ onSidebarResizerPointerDown,
107
+ onToggleMenu: () => setMenuOpen((open) => !open),
108
+ setMenuOpen,
109
+ setMobileSidebarOpen,
110
+ },
111
+ };
112
+ };
@@ -0,0 +1,193 @@
1
+ import { useState, useEffect, useRef, useCallback } from "https://esm.sh/preact/hooks";
2
+ import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js";
3
+ import { kDefaultUiTab, getSelectedNavId, kNavSections } from "../lib/app-navigation.js";
4
+ import { buildBrowseRoute, normalizeBrowsePath, parseBrowseRoute } from "../lib/browse-route.js";
5
+
6
+ const kBrowseLastPathUiSettingKey = "browseLastPath";
7
+ const kLastMenuRouteUiSettingKey = "lastMenuRoute";
8
+
9
+ export const useBrowseNavigation = ({
10
+ location = "",
11
+ setLocation = () => {},
12
+ onCloseMobileSidebar = () => {},
13
+ } = {}) => {
14
+ const [sidebarTab, setSidebarTab] = useState(() =>
15
+ location.startsWith("/browse") ? "browse" : "menu",
16
+ );
17
+ const [lastBrowsePath, setLastBrowsePath] = useState(() => {
18
+ const settings = readUiSettings();
19
+ return typeof settings[kBrowseLastPathUiSettingKey] === "string"
20
+ ? settings[kBrowseLastPathUiSettingKey]
21
+ : "";
22
+ });
23
+ const [lastMenuRoute, setLastMenuRoute] = useState(() => {
24
+ const settings = readUiSettings();
25
+ const storedRoute = settings[kLastMenuRouteUiSettingKey];
26
+ if (
27
+ typeof storedRoute === "string" &&
28
+ storedRoute.startsWith("/") &&
29
+ !storedRoute.startsWith("/browse")
30
+ ) {
31
+ return storedRoute;
32
+ }
33
+ return `/${kDefaultUiTab}`;
34
+ });
35
+ const [browsePreviewPath, setBrowsePreviewPath] = useState("");
36
+ const routeHistoryRef = useRef([]);
37
+
38
+ const {
39
+ activeBrowsePath,
40
+ browseLineEndTarget,
41
+ browseLineTarget,
42
+ browseViewerMode,
43
+ isBrowseRoute,
44
+ selectedBrowsePath,
45
+ } = parseBrowseRoute({
46
+ location,
47
+ browsePreviewPath,
48
+ });
49
+
50
+ const selectedNavId = getSelectedNavId({
51
+ isBrowseRoute,
52
+ location,
53
+ });
54
+
55
+ useEffect(() => {
56
+ setSidebarTab((currentTab) => {
57
+ if (location.startsWith("/browse")) return "browse";
58
+ if (currentTab === "browse") return "menu";
59
+ return currentTab;
60
+ });
61
+ }, [location]);
62
+
63
+ useEffect(() => {
64
+ if (location.startsWith("/browse")) return;
65
+ setBrowsePreviewPath("");
66
+ }, [location]);
67
+
68
+ useEffect(() => {
69
+ const historyStack = routeHistoryRef.current;
70
+ const lastEntry = historyStack[historyStack.length - 1];
71
+ if (lastEntry === location) return;
72
+ historyStack.push(location);
73
+ if (historyStack.length > 100) {
74
+ historyStack.shift();
75
+ }
76
+ }, [location]);
77
+
78
+ useEffect(() => {
79
+ if (location.startsWith("/browse")) return;
80
+ if (location === "/telegram") return;
81
+ setLastMenuRoute((currentRoute) =>
82
+ currentRoute === location ? currentRoute : location,
83
+ );
84
+ }, [location]);
85
+
86
+ useEffect(() => {
87
+ if (!isBrowseRoute) return;
88
+ if (!selectedBrowsePath) return;
89
+ setLastBrowsePath((currentPath) =>
90
+ currentPath === selectedBrowsePath ? currentPath : selectedBrowsePath,
91
+ );
92
+ }, [isBrowseRoute, selectedBrowsePath]);
93
+
94
+ useEffect(() => {
95
+ const handleBrowseGitSynced = () => {
96
+ if (!isBrowseRoute || browseViewerMode !== "diff") return;
97
+ const activePath = String(selectedBrowsePath || "").trim();
98
+ if (!activePath) return;
99
+ setLocation(buildBrowseRoute(activePath, { view: "edit" }));
100
+ };
101
+ window.addEventListener("alphaclaw:browse-git-synced", handleBrowseGitSynced);
102
+ return () => {
103
+ window.removeEventListener("alphaclaw:browse-git-synced", handleBrowseGitSynced);
104
+ };
105
+ }, [browseViewerMode, isBrowseRoute, selectedBrowsePath, setLocation]);
106
+
107
+ useEffect(() => {
108
+ const settings = readUiSettings();
109
+ settings[kBrowseLastPathUiSettingKey] = lastBrowsePath;
110
+ settings[kLastMenuRouteUiSettingKey] = lastMenuRoute;
111
+ writeUiSettings(settings);
112
+ }, [lastBrowsePath, lastMenuRoute]);
113
+
114
+ const navigateToSubScreen = useCallback((screen) => {
115
+ setLocation(`/${screen}`);
116
+ onCloseMobileSidebar();
117
+ }, [onCloseMobileSidebar, setLocation]);
118
+
119
+ const handleBrowsePreviewFile = useCallback((nextPreviewPath) => {
120
+ const normalizedPreviewPath = normalizeBrowsePath(nextPreviewPath);
121
+ setBrowsePreviewPath(normalizedPreviewPath);
122
+ }, []);
123
+
124
+ const navigateToBrowseFile = useCallback((relativePath, options = {}) => {
125
+ const normalizedTargetPath = normalizeBrowsePath(relativePath);
126
+ const selectingDirectory =
127
+ !!options.directory || String(relativePath || "").trim().endsWith("/");
128
+ const shouldPreservePreview = selectingDirectory && !!options.preservePreview;
129
+ const activePath = normalizeBrowsePath(
130
+ browsePreviewPath || selectedBrowsePath || "",
131
+ );
132
+ const nextPreviewPath =
133
+ shouldPreservePreview && activePath && activePath !== normalizedTargetPath
134
+ ? activePath
135
+ : "";
136
+ setBrowsePreviewPath(nextPreviewPath);
137
+ const routeOptions = selectingDirectory
138
+ ? { ...options, view: "edit" }
139
+ : options;
140
+ setLocation(buildBrowseRoute(normalizedTargetPath, routeOptions));
141
+ onCloseMobileSidebar();
142
+ }, [browsePreviewPath, onCloseMobileSidebar, selectedBrowsePath, setLocation]);
143
+
144
+ const handleSelectSidebarTab = useCallback((nextTab) => {
145
+ setSidebarTab(nextTab);
146
+ if (nextTab === "menu" && location.startsWith("/browse")) {
147
+ setBrowsePreviewPath("");
148
+ setLocation(lastMenuRoute || `/${kDefaultUiTab}`);
149
+ return;
150
+ }
151
+ if (nextTab === "browse" && !location.startsWith("/browse")) {
152
+ setLocation(buildBrowseRoute(lastBrowsePath));
153
+ }
154
+ }, [lastBrowsePath, lastMenuRoute, location, setLocation]);
155
+
156
+ const handleSelectNavItem = useCallback((itemId) => {
157
+ setLocation(`/${itemId}`);
158
+ onCloseMobileSidebar();
159
+ }, [onCloseMobileSidebar, setLocation]);
160
+
161
+ const exitSubScreen = useCallback(() => {
162
+ setLocation(`/${kDefaultUiTab}`);
163
+ onCloseMobileSidebar();
164
+ }, [onCloseMobileSidebar, setLocation]);
165
+
166
+ return {
167
+ state: {
168
+ activeBrowsePath,
169
+ browseLineEndTarget,
170
+ browseLineTarget,
171
+ browsePreviewPath,
172
+ browseViewerMode,
173
+ isBrowseRoute,
174
+ routeHistoryRef,
175
+ selectedBrowsePath,
176
+ selectedNavId,
177
+ sidebarTab,
178
+ },
179
+ actions: {
180
+ buildBrowseRoute,
181
+ clearBrowsePreview: () => setBrowsePreviewPath(""),
182
+ exitSubScreen,
183
+ handleBrowsePreviewFile,
184
+ handleSelectNavItem,
185
+ handleSelectSidebarTab,
186
+ navigateToBrowseFile,
187
+ navigateToSubScreen,
188
+ },
189
+ constants: {
190
+ kNavSections,
191
+ },
192
+ };
193
+ };
@@ -0,0 +1,32 @@
1
+ import { useState, useEffect, useCallback } from "https://esm.sh/preact/hooks";
2
+ import { kDefaultUiTab } from "../lib/app-navigation.js";
3
+
4
+ const getHashPath = () => {
5
+ const hash = window.location.hash.replace(/^#/, "");
6
+ if (!hash) return `/${kDefaultUiTab}`;
7
+ return hash.startsWith("/") ? hash : `/${hash}`;
8
+ };
9
+
10
+ export const useHashLocation = () => {
11
+ const [location, setLocationState] = useState(getHashPath);
12
+
13
+ useEffect(() => {
14
+ const onHashChange = () => setLocationState(getHashPath());
15
+ window.addEventListener("hashchange", onHashChange);
16
+ return () => window.removeEventListener("hashchange", onHashChange);
17
+ }, []);
18
+
19
+ const setLocation = useCallback((to) => {
20
+ const normalized = to.startsWith("/") ? to : `/${to}`;
21
+ const nextHash = `#${normalized}`;
22
+ if (window.location.hash !== nextHash) {
23
+ window.location.hash = normalized;
24
+ return;
25
+ }
26
+ setLocationState(normalized);
27
+ }, []);
28
+
29
+ return [location, setLocation];
30
+ };
31
+
32
+ export const getHashRouterPath = getHashPath;
@@ -496,6 +496,41 @@ export const setPrimaryModel = async (modelKey) => {
496
496
  return res.json();
497
497
  };
498
498
 
499
+ export const fetchModelsConfig = async () => {
500
+ const res = await authFetch('/api/models/config');
501
+ return res.json();
502
+ };
503
+
504
+ export const saveModelsConfig = async ({ primary, configuredModels, profiles, authOrder }) => {
505
+ const res = await authFetch('/api/models/config', {
506
+ method: 'PUT',
507
+ headers: { 'Content-Type': 'application/json' },
508
+ body: JSON.stringify({ primary, configuredModels, profiles, authOrder }),
509
+ });
510
+ return res.json();
511
+ };
512
+
513
+ export const fetchAuthProfiles = async () => {
514
+ const res = await authFetch('/api/models/auth');
515
+ return res.json();
516
+ };
517
+
518
+ export const upsertAuthProfile = async (profileId, credential) => {
519
+ const res = await authFetch(`/api/models/auth/${encodeURIComponent(profileId)}`, {
520
+ method: 'PUT',
521
+ headers: { 'Content-Type': 'application/json' },
522
+ body: JSON.stringify(credential),
523
+ });
524
+ return res.json();
525
+ };
526
+
527
+ export const deleteAuthProfile = async (profileId) => {
528
+ const res = await authFetch(`/api/models/auth/${encodeURIComponent(profileId)}`, {
529
+ method: 'DELETE',
530
+ });
531
+ return res.json();
532
+ };
533
+
499
534
  export const fetchCodexStatus = async () => {
500
535
  const res = await authFetch('/api/codex/status');
501
536
  return res.json();
@@ -0,0 +1,39 @@
1
+ export const kDefaultUiTab = "general";
2
+
3
+ export const kNavSections = [
4
+ {
5
+ label: "Setup",
6
+ items: [
7
+ { id: "general", label: "General" },
8
+ ],
9
+ },
10
+ {
11
+ label: "Monitoring",
12
+ items: [
13
+ { id: "watchdog", label: "Watchdog" },
14
+ { id: "usage", label: "Usage" },
15
+ { id: "doctor", label: "Doctor" },
16
+ ],
17
+ },
18
+ {
19
+ label: "Config",
20
+ items: [
21
+ { id: "models", label: "Models" },
22
+ { id: "envars", label: "Envars" },
23
+ { id: "webhooks", label: "Webhooks" },
24
+ ],
25
+ },
26
+ ];
27
+
28
+ export const getSelectedNavId = ({ isBrowseRoute = false, location = "" } = {}) => {
29
+ if (isBrowseRoute) return "browse";
30
+ if (location === "/telegram") return "";
31
+ if (location.startsWith("/models")) return "models";
32
+ if (location.startsWith("/providers")) return "models";
33
+ if (location.startsWith("/watchdog")) return "watchdog";
34
+ if (location.startsWith("/usage")) return "usage";
35
+ if (location.startsWith("/doctor")) return "doctor";
36
+ if (location.startsWith("/envars")) return "envars";
37
+ if (location.startsWith("/webhooks")) return "webhooks";
38
+ return kDefaultUiTab;
39
+ };
@@ -0,0 +1,28 @@
1
+ const kBrowseRestartRequiredRules = [
2
+ { type: "file", path: "openclaw.json" },
3
+ { type: "directory", path: "hooks/transforms" },
4
+ ];
5
+
6
+ const normalizeRestartRulePath = (value) =>
7
+ String(value || "")
8
+ .trim()
9
+ .replace(/^\/+|\/+$/g, "");
10
+
11
+ const matchesBrowseRestartRequiredRule = (path, rule) => {
12
+ const normalizedPath = normalizeRestartRulePath(path);
13
+ if (!normalizedPath) return false;
14
+ if (!rule || typeof rule !== "object") return false;
15
+ const type = String(rule.type || "").toLowerCase();
16
+ const targetPath = normalizeRestartRulePath(rule.path);
17
+ if (!targetPath) return false;
18
+ if (type === "directory") {
19
+ return normalizedPath === targetPath || normalizedPath.startsWith(`${targetPath}/`);
20
+ }
21
+ if (type === "file") {
22
+ return normalizedPath === targetPath;
23
+ }
24
+ return false;
25
+ };
26
+
27
+ export const shouldRequireRestartForBrowsePath = (path) =>
28
+ kBrowseRestartRequiredRules.some((rule) => matchesBrowseRestartRequiredRule(path, rule));