@chrysb/alphaclaw 0.8.5 → 0.8.6
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/css/explorer.css +48 -0
- package/lib/public/css/shell.css +149 -0
- package/lib/public/css/tailwind.generated.css +1 -1
- package/lib/public/css/theme.css +265 -0
- package/lib/public/dist/app.bundle.js +2269 -2200
- package/lib/public/js/app.js +4 -0
- package/lib/public/js/components/icons.js +38 -0
- package/lib/public/js/components/models-tab/provider-auth-card.js +60 -49
- package/lib/public/js/components/models-tab/use-models.js +74 -9
- package/lib/public/js/components/models.js +52 -37
- package/lib/public/js/components/onboarding/use-welcome-codex.js +34 -24
- package/lib/public/js/components/onboarding/welcome-config.js +76 -10
- package/lib/public/js/components/onboarding/welcome-form-step.js +2 -7
- package/lib/public/js/components/onboarding/welcome-header.js +12 -14
- package/lib/public/js/components/onboarding/welcome-setup-step.js +3 -3
- package/lib/public/js/components/providers.js +53 -42
- package/lib/public/js/components/sidebar.js +9 -1
- package/lib/public/js/components/theme-toggle.js +113 -0
- package/lib/public/js/components/welcome/index.js +0 -2
- package/lib/public/js/components/welcome/use-welcome.js +101 -36
- package/lib/public/js/lib/codex-oauth-window.js +22 -0
- package/lib/public/js/lib/model-catalog.js +20 -0
- package/lib/public/js/lib/storage-keys.js +1 -1
- package/lib/public/login.html +8 -4
- package/lib/public/setup.html +9 -0
- package/lib/server/db/webhooks/index.js +48 -8
- package/lib/server/model-catalog-cache.js +251 -0
- package/lib/server/routes/models.js +14 -23
- package/lib/server/routes/webhooks.js +12 -1
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
applyImport,
|
|
7
7
|
fetchModels,
|
|
8
8
|
} from "../../lib/api.js";
|
|
9
|
+
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
9
10
|
import {
|
|
10
11
|
getModelProvider,
|
|
11
12
|
getAuthProviderFromModelProvider,
|
|
@@ -13,8 +14,15 @@ import {
|
|
|
13
14
|
getVisibleAiFieldKeys,
|
|
14
15
|
kProviderAuthFields,
|
|
15
16
|
} from "../../lib/model-config.js";
|
|
17
|
+
import {
|
|
18
|
+
getInitialOnboardingModelKey,
|
|
19
|
+
getModelCatalogModels,
|
|
20
|
+
kModelCatalogCacheKey,
|
|
21
|
+
} from "../../lib/model-catalog.js";
|
|
16
22
|
import {
|
|
17
23
|
kWelcomeGroups,
|
|
24
|
+
getWelcomeGroupError,
|
|
25
|
+
findFirstInvalidWelcomeGroup,
|
|
18
26
|
isValidGithubRepoInput,
|
|
19
27
|
kGithubFlowFresh,
|
|
20
28
|
kGithubFlowImport,
|
|
@@ -76,11 +84,18 @@ const normalizePlaceholderReview = (review) => {
|
|
|
76
84
|
export const useWelcome = ({ onComplete }) => {
|
|
77
85
|
const kSetupStepIndex = kWelcomeGroups.length;
|
|
78
86
|
const kPairingStepIndex = kSetupStepIndex + 1;
|
|
79
|
-
const {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
const {
|
|
88
|
+
vals,
|
|
89
|
+
setVals,
|
|
90
|
+
setValue: setStoredValue,
|
|
91
|
+
step,
|
|
92
|
+
setStep,
|
|
93
|
+
setupError,
|
|
94
|
+
setSetupError,
|
|
95
|
+
} = useWelcomeStorage({
|
|
96
|
+
kSetupStepIndex,
|
|
97
|
+
kPairingStepIndex,
|
|
98
|
+
});
|
|
84
99
|
const [models, setModels] = useState([]);
|
|
85
100
|
const [modelsLoading, setModelsLoading] = useState(true);
|
|
86
101
|
const [modelsError, setModelsError] = useState(null);
|
|
@@ -110,6 +125,14 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
110
125
|
const [importScanResult, setImportScanResult] = useState(null);
|
|
111
126
|
const [importScanning, setImportScanning] = useState(false);
|
|
112
127
|
const [importError, setImportError] = useState(null);
|
|
128
|
+
const modelsFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, {
|
|
129
|
+
maxAgeMs: 30000,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const setValue = (key, value) => {
|
|
133
|
+
if (formError) setFormError(null);
|
|
134
|
+
setStoredValue(key, value);
|
|
135
|
+
};
|
|
113
136
|
|
|
114
137
|
const setImportStep = (nextStep) => {
|
|
115
138
|
setImportStepState(nextStep);
|
|
@@ -129,21 +152,54 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
129
152
|
};
|
|
130
153
|
|
|
131
154
|
useEffect(() => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
155
|
+
const list = getModelCatalogModels(modelsFetchState.data);
|
|
156
|
+
if (!modelsFetchState.data) return;
|
|
157
|
+
setModels(list);
|
|
158
|
+
setModelsError(list.length > 0 ? null : "No models found");
|
|
159
|
+
const defaultModelKey = getInitialOnboardingModelKey({
|
|
160
|
+
catalog: list,
|
|
161
|
+
currentModelKey: vals.MODEL_KEY,
|
|
162
|
+
});
|
|
163
|
+
if (!vals.MODEL_KEY && defaultModelKey) {
|
|
164
|
+
setVals((prev) => ({ ...prev, MODEL_KEY: defaultModelKey }));
|
|
165
|
+
}
|
|
166
|
+
}, [modelsFetchState.data, setVals, vals.MODEL_KEY]);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
const hasModels = getModelCatalogModels(modelsFetchState.data).length > 0;
|
|
170
|
+
setModelsLoading(modelsFetchState.loading && !hasModels);
|
|
171
|
+
}, [modelsFetchState.data, modelsFetchState.loading]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!modelsFetchState.error) return;
|
|
175
|
+
setModelsError("Failed to load models");
|
|
176
|
+
setModelsLoading(false);
|
|
177
|
+
}, [modelsFetchState.error]);
|
|
178
|
+
|
|
179
|
+
const getValidationContext = (currentVals = {}) => {
|
|
180
|
+
const currentSelectedProvider = getModelProvider(
|
|
181
|
+
String(currentVals.MODEL_KEY || "").trim(),
|
|
182
|
+
);
|
|
183
|
+
const currentSelectedAuthProvider =
|
|
184
|
+
getAuthProviderFromModelProvider(currentSelectedProvider);
|
|
185
|
+
const currentProviderAuthFields =
|
|
186
|
+
kProviderAuthFields[currentSelectedAuthProvider] || [];
|
|
187
|
+
const currentHasAi =
|
|
188
|
+
currentSelectedProvider === "openai-codex"
|
|
189
|
+
? !!codexStatus.connected
|
|
190
|
+
: currentProviderAuthFields.some((field) =>
|
|
191
|
+
!!String(currentVals[field.key] || "").trim(),
|
|
192
|
+
);
|
|
145
193
|
|
|
146
|
-
|
|
194
|
+
return {
|
|
195
|
+
hasAi: currentHasAi,
|
|
196
|
+
selectedProvider: currentSelectedProvider,
|
|
197
|
+
codexLoading,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const validationContext = getValidationContext(vals);
|
|
202
|
+
const { selectedProvider, hasAi } = validationContext;
|
|
147
203
|
const placeholderReview = normalizePlaceholderReview(
|
|
148
204
|
vals[kImportPlaceholderReviewKey],
|
|
149
205
|
);
|
|
@@ -164,23 +220,10 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
164
220
|
const canToggleFullCatalog =
|
|
165
221
|
featuredModels.length > 0 && models.length > featuredModels.length;
|
|
166
222
|
const visibleAiFieldKeys = getVisibleAiFieldKeys(selectedProvider);
|
|
167
|
-
const selectedAuthProvider = getAuthProviderFromModelProvider(selectedProvider);
|
|
168
|
-
const selectedProviderAuthFields = kProviderAuthFields[selectedAuthProvider] || [];
|
|
169
|
-
const hasAi =
|
|
170
|
-
selectedProvider === "openai-codex"
|
|
171
|
-
? !!codexStatus.connected
|
|
172
|
-
: selectedProviderAuthFields.some(
|
|
173
|
-
(field) => !!String(vals[field.key] || "").trim(),
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
const allValid = kWelcomeGroups.every((group) => group.validate(vals, { hasAi }));
|
|
177
223
|
const isPreStep = step === -1;
|
|
178
224
|
const isSetupStep = step === kSetupStepIndex;
|
|
179
225
|
const isPairingStep = step === kPairingStepIndex;
|
|
180
226
|
const activeGroup = step >= 0 && step < kSetupStepIndex ? kWelcomeGroups[step] : null;
|
|
181
|
-
const currentGroupValid = activeGroup
|
|
182
|
-
? activeGroup.validate(vals, { hasAi })
|
|
183
|
-
: false;
|
|
184
227
|
const selectedPairingChannel = String(
|
|
185
228
|
vals[kPairingChannelKey] || getPreferredPairingChannel(vals),
|
|
186
229
|
);
|
|
@@ -202,7 +245,21 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
202
245
|
const handleSubmit = async () => {
|
|
203
246
|
const { normalizedVals, didChange } = normalizeOnboardingVals(vals);
|
|
204
247
|
if (didChange) setVals(normalizedVals);
|
|
205
|
-
|
|
248
|
+
const submitValidationContext = getValidationContext(normalizedVals);
|
|
249
|
+
const invalidGroup = findFirstInvalidWelcomeGroup(
|
|
250
|
+
normalizedVals,
|
|
251
|
+
submitValidationContext,
|
|
252
|
+
);
|
|
253
|
+
if (invalidGroup) {
|
|
254
|
+
setFormError(
|
|
255
|
+
getWelcomeGroupError(
|
|
256
|
+
invalidGroup.id,
|
|
257
|
+
normalizedVals,
|
|
258
|
+
submitValidationContext,
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
setSetupError(null);
|
|
262
|
+
setStep(kWelcomeGroups.findIndex((group) => group.id === invalidGroup.id));
|
|
206
263
|
return;
|
|
207
264
|
}
|
|
208
265
|
if (loading) return;
|
|
@@ -309,7 +366,17 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
309
366
|
const goNext = async () => {
|
|
310
367
|
const { normalizedVals, didChange } = normalizeOnboardingVals(vals);
|
|
311
368
|
if (didChange) setVals(normalizedVals);
|
|
312
|
-
if (!activeGroup
|
|
369
|
+
if (!activeGroup) return;
|
|
370
|
+
const stepValidationContext = getValidationContext(normalizedVals);
|
|
371
|
+
const stepValidationError = getWelcomeGroupError(
|
|
372
|
+
activeGroup.id,
|
|
373
|
+
normalizedVals,
|
|
374
|
+
stepValidationContext,
|
|
375
|
+
);
|
|
376
|
+
if (stepValidationError) {
|
|
377
|
+
setFormError(stepValidationError);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
313
380
|
setFormError(null);
|
|
314
381
|
if (activeGroup.id === "github") {
|
|
315
382
|
const githubFlow = normalizedVals._GITHUB_FLOW || kGithubFlowFresh;
|
|
@@ -545,12 +612,10 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
545
612
|
canToggleFullCatalog,
|
|
546
613
|
visibleAiFieldKeys,
|
|
547
614
|
hasAi,
|
|
548
|
-
allValid,
|
|
549
615
|
isPreStep,
|
|
550
616
|
isSetupStep,
|
|
551
617
|
isPairingStep,
|
|
552
618
|
activeGroup,
|
|
553
|
-
currentGroupValid,
|
|
554
619
|
selectedPairingChannel,
|
|
555
620
|
placeholderReview,
|
|
556
621
|
isImportStep,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const kCodexAuthStartPath = "/auth/codex/start";
|
|
2
|
+
const kCodexAuthWindowName = "codex-auth";
|
|
3
|
+
const kCodexAuthPopupFeatures = "popup=yes,width=640,height=780";
|
|
4
|
+
const kCodexAuthCallbackMessageType = "callback-input";
|
|
5
|
+
|
|
6
|
+
export const openCodexAuthWindow = () => {
|
|
7
|
+
const popup = window.open(
|
|
8
|
+
kCodexAuthStartPath,
|
|
9
|
+
kCodexAuthWindowName,
|
|
10
|
+
kCodexAuthPopupFeatures,
|
|
11
|
+
);
|
|
12
|
+
if (!popup || popup.closed) {
|
|
13
|
+
window.location.href = kCodexAuthStartPath;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return popup;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const isCodexAuthCallbackMessage = (value) =>
|
|
20
|
+
value?.codex === kCodexAuthCallbackMessageType &&
|
|
21
|
+
typeof value.input === "string" &&
|
|
22
|
+
value.input.trim().length > 0;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getFeaturedModels } from "./model-config.js";
|
|
2
|
+
|
|
3
|
+
export const kModelCatalogCacheKey = "/api/models";
|
|
4
|
+
export const kModelCatalogPollIntervalMs = 3000;
|
|
5
|
+
|
|
6
|
+
export const getModelCatalogModels = (payload) =>
|
|
7
|
+
Array.isArray(payload?.models) ? payload.models : [];
|
|
8
|
+
|
|
9
|
+
export const isModelCatalogRefreshing = (payload) =>
|
|
10
|
+
Boolean(payload?.refreshing);
|
|
11
|
+
|
|
12
|
+
export const getInitialOnboardingModelKey = ({
|
|
13
|
+
catalog = [],
|
|
14
|
+
currentModelKey = "",
|
|
15
|
+
} = {}) => {
|
|
16
|
+
const normalizedCurrent = String(currentModelKey || "").trim();
|
|
17
|
+
if (normalizedCurrent) return normalizedCurrent;
|
|
18
|
+
const featuredModels = getFeaturedModels(catalog);
|
|
19
|
+
return String(featuredModels[0]?.key || catalog[0]?.key || "");
|
|
20
|
+
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
// --- UI settings (single JSON blob containing sub-keys) ---
|
|
8
8
|
export const kUiSettingsStorageKey = "alphaclaw.ui.settings";
|
|
9
|
+
export const kThemeStorageKey = "alphaclaw.ui.theme";
|
|
9
10
|
|
|
10
11
|
// --- Browse / file viewer ---
|
|
11
12
|
export const kFileViewerModeStorageKey = "alphaclaw.browse.viewerMode";
|
|
@@ -30,4 +31,3 @@ export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey";
|
|
|
30
31
|
|
|
31
32
|
// --- Chat ---
|
|
32
33
|
export const kChatSessionDraftsStorageKey = "alphaclaw.chat.sessionDrafts";
|
|
33
|
-
|
package/lib/public/login.html
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
<link rel="icon" type="image/svg+xml" href="./img/logo.svg" />
|
|
12
12
|
<link rel="stylesheet" href="./css/theme.css" />
|
|
13
13
|
<link rel="stylesheet" href="./css/tailwind.generated.css" />
|
|
14
|
+
<script>
|
|
15
|
+
try {
|
|
16
|
+
var t = localStorage.getItem("alphaclaw.ui.theme");
|
|
17
|
+
if (t === "system") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
18
|
+
else if (t !== "dark" && t !== "light") t = "dark";
|
|
19
|
+
document.documentElement.dataset.theme = t;
|
|
20
|
+
} catch {}
|
|
21
|
+
</script>
|
|
14
22
|
</head>
|
|
15
23
|
<body class="min-h-screen flex items-center justify-center p-4">
|
|
16
24
|
<div class="max-w-sm w-full relative z-10">
|
|
@@ -53,10 +61,6 @@
|
|
|
53
61
|
</form>
|
|
54
62
|
</div>
|
|
55
63
|
<script>
|
|
56
|
-
try {
|
|
57
|
-
window.localStorage?.clear?.();
|
|
58
|
-
} catch {}
|
|
59
|
-
|
|
60
64
|
const formEl = document.getElementById("login-form");
|
|
61
65
|
const passwordEl = document.getElementById("password");
|
|
62
66
|
const submitButtonEl = document.getElementById("submit-btn");
|
package/lib/public/setup.html
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
<link rel="stylesheet" href="./css/agents.css" />
|
|
17
17
|
<link rel="stylesheet" href="./css/chat.css" />
|
|
18
18
|
<link rel="stylesheet" href="./css/cron.css" />
|
|
19
|
+
<script>
|
|
20
|
+
// Apply saved theme before render to prevent flash.
|
|
21
|
+
try {
|
|
22
|
+
var t = localStorage.getItem("alphaclaw.ui.theme");
|
|
23
|
+
if (t === "system") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
24
|
+
else if (t !== "dark" && t !== "light") t = "dark";
|
|
25
|
+
document.documentElement.dataset.theme = t;
|
|
26
|
+
} catch {}
|
|
27
|
+
</script>
|
|
19
28
|
</head>
|
|
20
29
|
<body>
|
|
21
30
|
<div id="app"></div>
|
|
@@ -10,6 +10,7 @@ let pruneTimer = null;
|
|
|
10
10
|
const kDefaultRequestLimit = 50;
|
|
11
11
|
const kMaxRequestLimit = 200;
|
|
12
12
|
const kPruneIntervalMs = 12 * 60 * 60 * 1000;
|
|
13
|
+
const kHealthSummaryWindow = 25;
|
|
13
14
|
|
|
14
15
|
const ensureDb = () => {
|
|
15
16
|
if (!db) throw new Error("Webhooks DB not initialized");
|
|
@@ -202,22 +203,61 @@ const getHookSummaries = () => {
|
|
|
202
203
|
const database = ensureDb();
|
|
203
204
|
const rows = database
|
|
204
205
|
.prepare(`
|
|
206
|
+
WITH ranked_requests AS (
|
|
207
|
+
SELECT
|
|
208
|
+
hook_name,
|
|
209
|
+
created_at,
|
|
210
|
+
gateway_status,
|
|
211
|
+
ROW_NUMBER() OVER (
|
|
212
|
+
PARTITION BY hook_name
|
|
213
|
+
ORDER BY created_at DESC, id DESC
|
|
214
|
+
) AS row_num
|
|
215
|
+
FROM webhook_requests
|
|
216
|
+
),
|
|
217
|
+
overall_counts AS (
|
|
218
|
+
SELECT
|
|
219
|
+
hook_name,
|
|
220
|
+
MAX(created_at) AS last_received,
|
|
221
|
+
COUNT(*) AS total_count,
|
|
222
|
+
SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count,
|
|
223
|
+
SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count
|
|
224
|
+
FROM webhook_requests
|
|
225
|
+
GROUP BY hook_name
|
|
226
|
+
),
|
|
227
|
+
recent_counts AS (
|
|
228
|
+
SELECT
|
|
229
|
+
hook_name,
|
|
230
|
+
COUNT(*) AS recent_total_count,
|
|
231
|
+
SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS recent_success_count,
|
|
232
|
+
SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS recent_error_count
|
|
233
|
+
FROM ranked_requests
|
|
234
|
+
WHERE row_num <= $health_window
|
|
235
|
+
GROUP BY hook_name
|
|
236
|
+
)
|
|
205
237
|
SELECT
|
|
206
|
-
hook_name,
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
238
|
+
overall_counts.hook_name,
|
|
239
|
+
overall_counts.last_received,
|
|
240
|
+
overall_counts.total_count,
|
|
241
|
+
overall_counts.success_count,
|
|
242
|
+
overall_counts.error_count,
|
|
243
|
+
COALESCE(recent_counts.recent_total_count, 0) AS recent_total_count,
|
|
244
|
+
COALESCE(recent_counts.recent_success_count, 0) AS recent_success_count,
|
|
245
|
+
COALESCE(recent_counts.recent_error_count, 0) AS recent_error_count
|
|
246
|
+
FROM overall_counts
|
|
247
|
+
LEFT JOIN recent_counts
|
|
248
|
+
ON recent_counts.hook_name = overall_counts.hook_name
|
|
213
249
|
`)
|
|
214
|
-
.all();
|
|
250
|
+
.all({ $health_window: kHealthSummaryWindow });
|
|
215
251
|
return rows.map((row) => ({
|
|
216
252
|
hookName: row.hook_name,
|
|
217
253
|
lastReceived: row.last_received || null,
|
|
218
254
|
totalCount: Number(row.total_count || 0),
|
|
219
255
|
successCount: Number(row.success_count || 0),
|
|
220
256
|
errorCount: Number(row.error_count || 0),
|
|
257
|
+
recentTotalCount: Number(row.recent_total_count || 0),
|
|
258
|
+
recentSuccessCount: Number(row.recent_success_count || 0),
|
|
259
|
+
recentErrorCount: Number(row.recent_error_count || 0),
|
|
260
|
+
healthWindowSize: kHealthSummaryWindow,
|
|
221
261
|
}));
|
|
222
262
|
};
|
|
223
263
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { ALPHACLAW_DIR, kFallbackOnboardingModels } = require("./constants");
|
|
4
|
+
|
|
5
|
+
const kModelCatalogCacheVersion = 1;
|
|
6
|
+
const kModelCatalogRefreshBackoffMs = 30 * 1000;
|
|
7
|
+
const kDefaultCachePath = path.join(ALPHACLAW_DIR, "cache", "model-catalog.json");
|
|
8
|
+
|
|
9
|
+
const createResponse = ({
|
|
10
|
+
source = "fallback",
|
|
11
|
+
fetchedAt = null,
|
|
12
|
+
stale = false,
|
|
13
|
+
refreshing = false,
|
|
14
|
+
models = [],
|
|
15
|
+
} = {}) => ({
|
|
16
|
+
ok: true,
|
|
17
|
+
source,
|
|
18
|
+
fetchedAt,
|
|
19
|
+
stale,
|
|
20
|
+
refreshing,
|
|
21
|
+
models,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const normalizeCachedModels = ({
|
|
25
|
+
models,
|
|
26
|
+
normalizeOnboardingModels = (items) => items,
|
|
27
|
+
} = {}) =>
|
|
28
|
+
normalizeOnboardingModels(
|
|
29
|
+
(Array.isArray(models) ? models : []).map((model) => ({
|
|
30
|
+
key: model?.key,
|
|
31
|
+
name: model?.label || model?.name || model?.key,
|
|
32
|
+
})),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const normalizeCacheEntry = ({
|
|
36
|
+
raw,
|
|
37
|
+
normalizeOnboardingModels = (items) => items,
|
|
38
|
+
} = {}) => {
|
|
39
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
40
|
+
const fetchedAt = Number(raw.fetchedAt || 0);
|
|
41
|
+
const models = normalizeCachedModels({
|
|
42
|
+
models: raw.models,
|
|
43
|
+
normalizeOnboardingModels,
|
|
44
|
+
});
|
|
45
|
+
if (!Number.isFinite(fetchedAt) || fetchedAt <= 0 || models.length === 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
version: kModelCatalogCacheVersion,
|
|
50
|
+
fetchedAt,
|
|
51
|
+
models,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const createModelCatalogCache = ({
|
|
56
|
+
fsModule = fs,
|
|
57
|
+
pathModule = path,
|
|
58
|
+
shellCmd,
|
|
59
|
+
gatewayEnv = () => ({}),
|
|
60
|
+
parseJsonFromNoisyOutput = () => ({}),
|
|
61
|
+
normalizeOnboardingModels = (items) => items,
|
|
62
|
+
fallbackModels = kFallbackOnboardingModels,
|
|
63
|
+
cachePath = kDefaultCachePath,
|
|
64
|
+
refreshBackoffMs = kModelCatalogRefreshBackoffMs,
|
|
65
|
+
now = () => Date.now(),
|
|
66
|
+
setTimeoutFn = setTimeout,
|
|
67
|
+
clearTimeoutFn = clearTimeout,
|
|
68
|
+
logger = console,
|
|
69
|
+
} = {}) => {
|
|
70
|
+
let cacheLoaded = false;
|
|
71
|
+
let memoryCache = null;
|
|
72
|
+
let cacheIsStale = false;
|
|
73
|
+
let refreshPromise = null;
|
|
74
|
+
let retryTimer = null;
|
|
75
|
+
let backoffUntilMs = 0;
|
|
76
|
+
|
|
77
|
+
const clearRetryTimer = () => {
|
|
78
|
+
if (!retryTimer) return;
|
|
79
|
+
clearTimeoutFn(retryTimer);
|
|
80
|
+
retryTimer = null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const isRefreshPending = () => !!refreshPromise || !!retryTimer;
|
|
84
|
+
|
|
85
|
+
const setCacheEntry = (entry, { fresh = false } = {}) => {
|
|
86
|
+
memoryCache = entry;
|
|
87
|
+
cacheLoaded = true;
|
|
88
|
+
cacheIsStale = !fresh;
|
|
89
|
+
backoffUntilMs = 0;
|
|
90
|
+
clearRetryTimer();
|
|
91
|
+
return memoryCache;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const readDiskCache = () => {
|
|
95
|
+
if (cacheLoaded) return memoryCache;
|
|
96
|
+
cacheLoaded = true;
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(fsModule.readFileSync(cachePath, "utf8"));
|
|
99
|
+
const entry = normalizeCacheEntry({
|
|
100
|
+
raw,
|
|
101
|
+
normalizeOnboardingModels,
|
|
102
|
+
});
|
|
103
|
+
if (!entry) return null;
|
|
104
|
+
memoryCache = entry;
|
|
105
|
+
cacheIsStale = true;
|
|
106
|
+
return memoryCache;
|
|
107
|
+
} catch {
|
|
108
|
+
memoryCache = null;
|
|
109
|
+
cacheIsStale = false;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const writeDiskCache = (entry) => {
|
|
115
|
+
fsModule.mkdirSync(pathModule.dirname(cachePath), { recursive: true });
|
|
116
|
+
fsModule.writeFileSync(
|
|
117
|
+
cachePath,
|
|
118
|
+
`${JSON.stringify(entry, null, 2)}\n`,
|
|
119
|
+
"utf8",
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const loadFreshCatalog = async () => {
|
|
124
|
+
const output = await shellCmd("openclaw models list --all --json", {
|
|
125
|
+
env: gatewayEnv(),
|
|
126
|
+
timeout: 20000,
|
|
127
|
+
});
|
|
128
|
+
const parsed = parseJsonFromNoisyOutput(output);
|
|
129
|
+
const models = normalizeOnboardingModels(parsed?.models || []);
|
|
130
|
+
if (models.length === 0) {
|
|
131
|
+
throw new Error("No models found");
|
|
132
|
+
}
|
|
133
|
+
const entry = {
|
|
134
|
+
version: kModelCatalogCacheVersion,
|
|
135
|
+
fetchedAt: now(),
|
|
136
|
+
models,
|
|
137
|
+
};
|
|
138
|
+
writeDiskCache(entry);
|
|
139
|
+
setCacheEntry(entry, { fresh: true });
|
|
140
|
+
return entry;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const scheduleRetry = () => {
|
|
144
|
+
if (!memoryCache || retryTimer) return;
|
|
145
|
+
const delayMs = Math.max(backoffUntilMs - now(), 0);
|
|
146
|
+
retryTimer = setTimeoutFn(() => {
|
|
147
|
+
retryTimer = null;
|
|
148
|
+
if (!memoryCache || !cacheIsStale || refreshPromise) return;
|
|
149
|
+
void startBackgroundRefresh();
|
|
150
|
+
}, delayMs);
|
|
151
|
+
if (typeof retryTimer?.unref === "function") retryTimer.unref();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleRefreshFailure = (err) => {
|
|
155
|
+
if (memoryCache) {
|
|
156
|
+
cacheIsStale = true;
|
|
157
|
+
backoffUntilMs = now() + refreshBackoffMs;
|
|
158
|
+
scheduleRetry();
|
|
159
|
+
logger.error?.(
|
|
160
|
+
`[models] Failed to refresh cached models: ${err.message || String(err)}`,
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
logger.error?.(
|
|
165
|
+
`[models] Failed to load dynamic models: ${err.message || String(err)}`,
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const startBackgroundRefresh = () => {
|
|
170
|
+
readDiskCache();
|
|
171
|
+
if (!memoryCache) return null;
|
|
172
|
+
if (refreshPromise) return refreshPromise;
|
|
173
|
+
if (retryTimer) return null;
|
|
174
|
+
if (backoffUntilMs > now()) {
|
|
175
|
+
scheduleRetry();
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
refreshPromise = Promise.resolve()
|
|
179
|
+
.then(() => loadFreshCatalog())
|
|
180
|
+
.catch((err) => {
|
|
181
|
+
handleRefreshFailure(err);
|
|
182
|
+
return null;
|
|
183
|
+
})
|
|
184
|
+
.finally(() => {
|
|
185
|
+
refreshPromise = null;
|
|
186
|
+
});
|
|
187
|
+
return refreshPromise;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
async getCatalogResponse() {
|
|
192
|
+
readDiskCache();
|
|
193
|
+
if (memoryCache && !cacheIsStale) {
|
|
194
|
+
return createResponse({
|
|
195
|
+
source: "openclaw",
|
|
196
|
+
fetchedAt: memoryCache.fetchedAt,
|
|
197
|
+
stale: false,
|
|
198
|
+
refreshing: false,
|
|
199
|
+
models: memoryCache.models,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (memoryCache) {
|
|
203
|
+
startBackgroundRefresh();
|
|
204
|
+
return createResponse({
|
|
205
|
+
source: "cache",
|
|
206
|
+
fetchedAt: memoryCache.fetchedAt,
|
|
207
|
+
stale: true,
|
|
208
|
+
refreshing: isRefreshPending(),
|
|
209
|
+
models: memoryCache.models,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const freshEntry = await loadFreshCatalog();
|
|
214
|
+
return createResponse({
|
|
215
|
+
source: "openclaw",
|
|
216
|
+
fetchedAt: freshEntry.fetchedAt,
|
|
217
|
+
stale: false,
|
|
218
|
+
refreshing: false,
|
|
219
|
+
models: freshEntry.models,
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
handleRefreshFailure(err);
|
|
223
|
+
return createResponse({
|
|
224
|
+
source: "fallback",
|
|
225
|
+
fetchedAt: null,
|
|
226
|
+
stale: false,
|
|
227
|
+
refreshing: false,
|
|
228
|
+
models: fallbackModels,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
markStale() {
|
|
234
|
+
readDiskCache();
|
|
235
|
+
if (!memoryCache) return;
|
|
236
|
+
cacheIsStale = true;
|
|
237
|
+
backoffUntilMs = 0;
|
|
238
|
+
clearRetryTimer();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
createModelCatalogCache,
|
|
245
|
+
createResponse,
|
|
246
|
+
normalizeCachedModels,
|
|
247
|
+
normalizeCacheEntry,
|
|
248
|
+
kModelCatalogCacheVersion,
|
|
249
|
+
kModelCatalogRefreshBackoffMs,
|
|
250
|
+
kDefaultCachePath,
|
|
251
|
+
};
|