@chrysb/alphaclaw 0.9.9 → 0.9.11
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/dist/app.bundle.js +1773 -1747
- package/lib/public/js/components/agents-tab/agent-overview/use-model-card.js +4 -5
- package/lib/public/js/components/agents-tab/agent-pairing-section.js +11 -5
- package/lib/public/js/components/cron-tab/cron-helpers.js +13 -1
- package/lib/public/js/components/cron-tab/use-cron-tab.js +4 -3
- package/lib/public/js/components/general/index.js +6 -1
- package/lib/public/js/components/general/use-general-tab.js +17 -20
- package/lib/public/js/components/models-tab/index.js +5 -1
- package/lib/public/js/components/models-tab/model-picker.js +52 -0
- package/lib/public/js/components/onboarding/use-welcome-pairing.js +4 -1
- package/lib/public/js/components/pairings.js +75 -4
- package/lib/public/js/components/welcome/use-welcome.js +37 -8
- package/lib/public/js/hooks/usePolling.js +46 -13
- package/lib/public/js/lib/model-config.js +6 -2
- package/lib/server/agents/channels.js +53 -9
- package/lib/server/commands.js +4 -1
- package/lib/server/constants.js +14 -3
- package/lib/server/cost-utils.js +9 -0
- package/lib/server/cron-service.js +12 -1
- package/lib/server/db/doctor/index.js +9 -0
- package/lib/server/db/usage/index.js +13 -0
- package/lib/server/db/watchdog/index.js +13 -1
- package/lib/server/db/webhooks/index.js +13 -1
- package/lib/server/gateway.js +119 -8
- package/lib/server/init/register-server-routes.js +3 -0
- package/lib/server/init/runtime-init.js +2 -0
- package/lib/server/internal-files-migration.js +11 -1
- package/lib/server/model-catalog-bootstrap.json +3193 -0
- package/lib/server/model-catalog-cache.js +124 -32
- package/lib/server/onboarding/github.js +79 -2
- package/lib/server/onboarding/index.js +2 -9
- package/lib/server/onboarding/openclaw.js +18 -4
- package/lib/server/openclaw-runtime-env.js +55 -0
- package/lib/server/openclaw-version.js +2 -1
- package/lib/server/routes/models.js +28 -0
- package/lib/server/routes/pairings.js +106 -15
- package/lib/server/usage-tracker-config.js +28 -3
- package/lib/server/utils/command-output.js +11 -0
- package/lib/server.js +4 -0
- package/lib/setup/gitignore +2 -0
- package/package.json +2 -2
- package/patches/openclaw+2026.4.23.patch +63 -0
- package/patches/openclaw+2026.4.15.patch +0 -13
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
|
|
|
2
2
|
import { useModels } from "../../models-tab/use-models.js";
|
|
3
3
|
import {
|
|
4
4
|
buildProviderHasAuth,
|
|
5
|
+
buildSyntheticModelEntry,
|
|
5
6
|
getModelCatalogProvider,
|
|
6
7
|
getModelsTabAuthProvider,
|
|
7
8
|
getProviderSortIndex,
|
|
@@ -51,10 +52,8 @@ export const useModelCard = ({
|
|
|
51
52
|
Object.keys(configuredModels || {})
|
|
52
53
|
.map(
|
|
53
54
|
(modelKey) =>
|
|
54
|
-
resolveCatalogModel(catalog, modelKey) ||
|
|
55
|
-
|
|
56
|
-
label: modelKey,
|
|
57
|
-
},
|
|
55
|
+
resolveCatalogModel(catalog, modelKey) ||
|
|
56
|
+
buildSyntheticModelEntry(modelKey),
|
|
58
57
|
)
|
|
59
58
|
.filter((model) => {
|
|
60
59
|
const provider = getModelsTabAuthProvider(model.key);
|
|
@@ -75,7 +74,7 @@ export const useModelCard = ({
|
|
|
75
74
|
const effectiveModelEntry = useMemo(
|
|
76
75
|
() =>
|
|
77
76
|
resolveCatalogModel(catalog, effectiveModel) ||
|
|
78
|
-
(effectiveModel ?
|
|
77
|
+
(effectiveModel ? buildSyntheticModelEntry(effectiveModel) : null),
|
|
79
78
|
[catalog, effectiveModel],
|
|
80
79
|
);
|
|
81
80
|
|
|
@@ -241,12 +241,14 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
241
241
|
},
|
|
242
242
|
3000,
|
|
243
243
|
{
|
|
244
|
-
enabled:
|
|
244
|
+
enabled: ownedAccounts.length > 0,
|
|
245
245
|
cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`,
|
|
246
|
+
dedupeInFlight: true,
|
|
246
247
|
},
|
|
247
248
|
);
|
|
248
249
|
|
|
249
250
|
const pending = pairingsPoll.data || [];
|
|
251
|
+
const showPairings = hasUnpaired || pending.length > 0 || pairingStatusRefreshing;
|
|
250
252
|
|
|
251
253
|
const refreshAfterPairingAction = useCallback(() => {
|
|
252
254
|
setPairingStatusRefreshing(true);
|
|
@@ -262,7 +264,7 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
262
264
|
}
|
|
263
265
|
pairingDelayedRefreshTimerRefs.current = [];
|
|
264
266
|
const refresh = () => {
|
|
265
|
-
pairingsPoll.refresh();
|
|
267
|
+
pairingsPoll.refresh({ force: true });
|
|
266
268
|
loadBindings();
|
|
267
269
|
announcePairingsChanged(agentId);
|
|
268
270
|
};
|
|
@@ -273,10 +275,12 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
273
275
|
|
|
274
276
|
const handleApprove = async (id, channel, accountId = "") => {
|
|
275
277
|
try {
|
|
276
|
-
await approvePairing(id, channel, accountId);
|
|
278
|
+
const result = await approvePairing(id, channel, accountId);
|
|
279
|
+
if (!result.ok) throw new Error(result.error || "Could not approve pairing");
|
|
277
280
|
refreshAfterPairingAction();
|
|
278
281
|
} catch (err) {
|
|
279
282
|
showToast(err.message || "Could not approve pairing", "error");
|
|
283
|
+
throw err;
|
|
280
284
|
}
|
|
281
285
|
};
|
|
282
286
|
|
|
@@ -286,6 +290,7 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
286
290
|
refreshAfterPairingAction();
|
|
287
291
|
} catch (err) {
|
|
288
292
|
showToast(err.message || "Could not reject pairing", "error");
|
|
293
|
+
throw err;
|
|
289
294
|
}
|
|
290
295
|
};
|
|
291
296
|
|
|
@@ -298,13 +303,14 @@ export const AgentPairingSection = ({ agent = {} }) => {
|
|
|
298
303
|
`;
|
|
299
304
|
}
|
|
300
305
|
|
|
301
|
-
if (!
|
|
306
|
+
if (!showPairings) return null;
|
|
302
307
|
|
|
303
308
|
return html`
|
|
304
309
|
<${Pairings}
|
|
305
310
|
pending=${pending}
|
|
306
311
|
channels=${ownedChannelsStatus}
|
|
307
|
-
visible=${
|
|
312
|
+
visible=${showPairings}
|
|
313
|
+
pollingInFlight=${pairingsPoll.isPolling}
|
|
308
314
|
statusRefreshing=${pairingStatusRefreshing}
|
|
309
315
|
onApprove=${handleApprove}
|
|
310
316
|
onReject=${handleReject}
|
|
@@ -7,6 +7,18 @@ import {
|
|
|
7
7
|
|
|
8
8
|
export const kAllCronJobsRouteKey = "__all__";
|
|
9
9
|
|
|
10
|
+
export const readCronJobPrompt = (job = {}) => {
|
|
11
|
+
const payload = job?.payload && typeof job.payload === "object" ? job.payload : {};
|
|
12
|
+
const kind = String(payload?.kind || "").trim();
|
|
13
|
+
if (kind === "systemEvent" && typeof payload.text === "string") {
|
|
14
|
+
return payload.text;
|
|
15
|
+
}
|
|
16
|
+
if (kind === "agentTurn" && typeof payload.message === "string") {
|
|
17
|
+
return payload.message;
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
};
|
|
21
|
+
|
|
10
22
|
const kWeekdayLabelByCronValue = {
|
|
11
23
|
"0": "Sun",
|
|
12
24
|
"1": "Mon",
|
|
@@ -388,7 +400,7 @@ export const buildCronOptimizationWarnings = (jobs = [], bulkRunsByJobId = {}) =
|
|
|
388
400
|
const warnings = [];
|
|
389
401
|
jobs.forEach((job) => {
|
|
390
402
|
const jobId = String(job?.id || "");
|
|
391
|
-
const prompt =
|
|
403
|
+
const prompt = readCronJobPrompt(job).toLowerCase();
|
|
392
404
|
const deliveryMode = String(job?.delivery?.mode || "").toLowerCase();
|
|
393
405
|
if (
|
|
394
406
|
deliveryMode === "none" &&
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "../../lib/api.js";
|
|
23
23
|
import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
|
|
24
24
|
import { showToast } from "../toast.js";
|
|
25
|
-
import { kAllCronJobsRouteKey } from "./cron-helpers.js";
|
|
25
|
+
import { kAllCronJobsRouteKey, readCronJobPrompt } from "./cron-helpers.js";
|
|
26
26
|
|
|
27
27
|
const kDefaultListPanelWidthPx = 372;
|
|
28
28
|
const kListPanelMinWidthPx = 220;
|
|
@@ -178,6 +178,7 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
|
|
|
178
178
|
() => jobs.find((job) => String(job?.id || "") === selectedJobId) || null,
|
|
179
179
|
[jobs, selectedJobId],
|
|
180
180
|
);
|
|
181
|
+
const selectedJobPrompt = readCronJobPrompt(selectedJob);
|
|
181
182
|
|
|
182
183
|
useEffect(() => {
|
|
183
184
|
if (!selectedJobId) {
|
|
@@ -186,11 +187,11 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
|
|
|
186
187
|
setRoutingDraft(kRoutingDefaults);
|
|
187
188
|
return;
|
|
188
189
|
}
|
|
189
|
-
const prompt =
|
|
190
|
+
const prompt = selectedJobPrompt;
|
|
190
191
|
setPromptValue(prompt);
|
|
191
192
|
setSavedPromptValue(prompt);
|
|
192
193
|
setRoutingDraft(readRoutingDraftFromJob(selectedJob));
|
|
193
|
-
}, [selectedJobId,
|
|
194
|
+
}, [selectedJobId, selectedJobPrompt]);
|
|
194
195
|
|
|
195
196
|
useEffect(() => {
|
|
196
197
|
if (!selectedJobId) return;
|
|
@@ -62,6 +62,10 @@ export const GeneralTab = ({
|
|
|
62
62
|
Array.isArray(state.pending) &&
|
|
63
63
|
state.pending.length === 0 &&
|
|
64
64
|
hasWhatsAppAwaitingPairing;
|
|
65
|
+
const showPairings =
|
|
66
|
+
state.hasUnpaired ||
|
|
67
|
+
(Array.isArray(state.pending) && state.pending.length > 0) ||
|
|
68
|
+
state.pairingStatusRefreshing;
|
|
65
69
|
|
|
66
70
|
return html`
|
|
67
71
|
<div class="space-y-4">
|
|
@@ -117,7 +121,8 @@ export const GeneralTab = ({
|
|
|
117
121
|
<${Pairings}
|
|
118
122
|
pending=${state.pending}
|
|
119
123
|
channels=${state.channels}
|
|
120
|
-
visible=${
|
|
124
|
+
visible=${showPairings}
|
|
125
|
+
pollingInFlight=${state.pairingsPolling}
|
|
121
126
|
statusRefreshing=${state.pairingStatusRefreshing}
|
|
122
127
|
onApprove=${actions.handleApprove}
|
|
123
128
|
onReject=${actions.handleReject}
|
|
@@ -56,6 +56,9 @@ export const useGeneralTab = ({
|
|
|
56
56
|
}
|
|
57
57
|
return info.status !== "paired";
|
|
58
58
|
});
|
|
59
|
+
const hasConfiguredPairingChannel = ALL_CHANNELS.some((channel) =>
|
|
60
|
+
Boolean(channels?.[channel]),
|
|
61
|
+
);
|
|
59
62
|
|
|
60
63
|
const pairingsPoll = usePolling(
|
|
61
64
|
async () => {
|
|
@@ -64,8 +67,9 @@ export const useGeneralTab = ({
|
|
|
64
67
|
},
|
|
65
68
|
3000,
|
|
66
69
|
{
|
|
67
|
-
enabled:
|
|
70
|
+
enabled: hasConfiguredPairingChannel && gatewayStatus === "running",
|
|
68
71
|
cacheKey: "/api/pairings",
|
|
72
|
+
dedupeInFlight: true,
|
|
69
73
|
},
|
|
70
74
|
);
|
|
71
75
|
const pending = pairingsPoll.data || [];
|
|
@@ -86,25 +90,10 @@ export const useGeneralTab = ({
|
|
|
86
90
|
);
|
|
87
91
|
const devicePending = devicePoll.data || [];
|
|
88
92
|
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
if (!isActive) return;
|
|
91
|
-
pairingsPoll.refresh();
|
|
92
|
-
if (shouldPollDevices) {
|
|
93
|
-
devicePoll.refresh();
|
|
94
|
-
}
|
|
95
|
-
}, [
|
|
96
|
-
devicePoll.refresh,
|
|
97
|
-
isActive,
|
|
98
|
-
onRefreshStatuses,
|
|
99
|
-
pairingsPoll.refresh,
|
|
100
|
-
devicePollingEnabled,
|
|
101
|
-
shouldPollDevices,
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
93
|
useEffect(() => {
|
|
105
94
|
if (!restartSignal || !isActive) return;
|
|
106
95
|
onRefreshStatuses();
|
|
107
|
-
pairingsPoll.refresh();
|
|
96
|
+
pairingsPoll.refresh({ force: true });
|
|
108
97
|
if (shouldPollDevices) {
|
|
109
98
|
devicePoll.refresh();
|
|
110
99
|
}
|
|
@@ -164,7 +153,7 @@ export const useGeneralTab = ({
|
|
|
164
153
|
pairingRefreshTimerRef.current = null;
|
|
165
154
|
}, 2800);
|
|
166
155
|
onRefreshStatuses();
|
|
167
|
-
pairingsPoll.refresh();
|
|
156
|
+
pairingsPoll.refresh({ force: true });
|
|
168
157
|
setTimeout(() => {
|
|
169
158
|
onRefreshStatuses();
|
|
170
159
|
pairingsPoll.refresh();
|
|
@@ -208,8 +197,14 @@ export const useGeneralTab = ({
|
|
|
208
197
|
};
|
|
209
198
|
|
|
210
199
|
const handleApprove = async (id, channel, accountId = "") => {
|
|
211
|
-
|
|
212
|
-
|
|
200
|
+
try {
|
|
201
|
+
const result = await approvePairing(id, channel, accountId);
|
|
202
|
+
if (!result.ok) throw new Error(result.error || "Could not approve pairing");
|
|
203
|
+
refreshAfterPairingAction();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
showToast(err.message || "Could not approve pairing", "error");
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
213
208
|
};
|
|
214
209
|
|
|
215
210
|
const handleReject = async (id, channel, accountId = "") => {
|
|
@@ -218,6 +213,7 @@ export const useGeneralTab = ({
|
|
|
218
213
|
refreshAfterPairingAction();
|
|
219
214
|
} catch (err) {
|
|
220
215
|
showToast(err.message || "Could not reject pairing", "error");
|
|
216
|
+
throw err;
|
|
221
217
|
}
|
|
222
218
|
};
|
|
223
219
|
|
|
@@ -276,6 +272,7 @@ export const useGeneralTab = ({
|
|
|
276
272
|
hasUnpaired,
|
|
277
273
|
openclawVersion,
|
|
278
274
|
pending,
|
|
275
|
+
pairingsPolling: pairingsPoll.isPolling,
|
|
279
276
|
pairingStatusRefreshing,
|
|
280
277
|
repairingWatchdog,
|
|
281
278
|
repo,
|
|
@@ -10,6 +10,7 @@ import { Badge } from "../badge.js";
|
|
|
10
10
|
import { useModels } from "./use-models.js";
|
|
11
11
|
import {
|
|
12
12
|
buildProviderHasAuth,
|
|
13
|
+
buildSyntheticModelEntry,
|
|
13
14
|
getModelCatalogProvider,
|
|
14
15
|
getModelsTabAuthProvider,
|
|
15
16
|
getProviderSortIndex,
|
|
@@ -111,12 +112,14 @@ export const Models = ({ onRestartRequired = () => {}, agentId, embedded = false
|
|
|
111
112
|
const configuredModelEntries = useMemo(
|
|
112
113
|
() =>
|
|
113
114
|
Object.keys(configuredModels).map((key) => {
|
|
114
|
-
const catalogEntry =
|
|
115
|
+
const catalogEntry =
|
|
116
|
+
catalog.find((m) => m.key === key) || buildSyntheticModelEntry(key);
|
|
115
117
|
const provider = getModelsTabAuthProvider(key);
|
|
116
118
|
const hasAuth = !!providerHasAuth[provider];
|
|
117
119
|
return {
|
|
118
120
|
key,
|
|
119
121
|
label: catalogEntry?.label || key,
|
|
122
|
+
provider: catalogEntry?.provider || provider,
|
|
120
123
|
isPrimary: key === primary,
|
|
121
124
|
hasAuth,
|
|
122
125
|
};
|
|
@@ -216,6 +219,7 @@ export const Models = ({ onRestartRequired = () => {}, agentId, embedded = false
|
|
|
216
219
|
<${SearchableModelPicker}
|
|
217
220
|
options=${pickerModels}
|
|
218
221
|
popularModels=${popularPickerModels}
|
|
222
|
+
configuredOptions=${configuredModelEntries}
|
|
219
223
|
placeholder="Add model..."
|
|
220
224
|
onSelect=${(modelKey) => {
|
|
221
225
|
addModel(modelKey);
|
|
@@ -36,6 +36,33 @@ const formatProviderSectionLabel = (provider) =>
|
|
|
36
36
|
|
|
37
37
|
const normalizeSearch = (value) => String(value || "").trim().toLowerCase();
|
|
38
38
|
|
|
39
|
+
const titleCaseToken = (value) => {
|
|
40
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
41
|
+
if (!normalized) return "";
|
|
42
|
+
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const formatAnthropicModelLabel = (modelId) => {
|
|
46
|
+
const match = /^claude-(opus|sonnet|haiku)-(\d+)[-.](\d+)$/i.exec(
|
|
47
|
+
String(modelId || "").trim(),
|
|
48
|
+
);
|
|
49
|
+
if (!match) return "";
|
|
50
|
+
return `Claude ${titleCaseToken(match[1])} ${match[2]}.${match[3]}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const buildSyntheticModelEntry = (modelKey) => {
|
|
54
|
+
const key = String(modelKey || "").trim();
|
|
55
|
+
const provider = getModelProvider(key);
|
|
56
|
+
const modelId = key.includes("/") ? key.split("/").slice(1).join("/") : key;
|
|
57
|
+
const anthropicLabel =
|
|
58
|
+
provider === "anthropic" ? formatAnthropicModelLabel(modelId) : "";
|
|
59
|
+
return {
|
|
60
|
+
key,
|
|
61
|
+
provider,
|
|
62
|
+
label: anthropicLabel || key,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
39
66
|
export const getModelDisplayLabel = (model) => model?.featuredLabel || model?.label || model?.key;
|
|
40
67
|
|
|
41
68
|
const buildModelSearchText = (model) =>
|
|
@@ -67,6 +94,7 @@ export const buildProviderHasAuth = ({
|
|
|
67
94
|
export const SearchableModelPicker = ({
|
|
68
95
|
options = [],
|
|
69
96
|
popularModels = [],
|
|
97
|
+
configuredOptions = [],
|
|
70
98
|
placeholder = "Add model...",
|
|
71
99
|
onSelect = () => {},
|
|
72
100
|
disabled = false,
|
|
@@ -84,6 +112,15 @@ export const SearchableModelPicker = ({
|
|
|
84
112
|
: options,
|
|
85
113
|
[options, normalizedQuery],
|
|
86
114
|
);
|
|
115
|
+
const matchingConfiguredOptions = useMemo(
|
|
116
|
+
() =>
|
|
117
|
+
normalizedQuery
|
|
118
|
+
? configuredOptions.filter((option) =>
|
|
119
|
+
buildModelSearchText(option).includes(normalizedQuery),
|
|
120
|
+
)
|
|
121
|
+
: [],
|
|
122
|
+
[configuredOptions, normalizedQuery],
|
|
123
|
+
);
|
|
87
124
|
const groupedOptions = useMemo(() => {
|
|
88
125
|
const groups = [];
|
|
89
126
|
const showPopularGroup = !normalizedQuery;
|
|
@@ -198,6 +235,21 @@ export const SearchableModelPicker = ({
|
|
|
198
235
|
</div>
|
|
199
236
|
`,
|
|
200
237
|
)
|
|
238
|
+
: matchingConfiguredOptions.length > 0
|
|
239
|
+
? html`
|
|
240
|
+
<div class="px-3 py-3 text-xs text-fg-muted">
|
|
241
|
+
${matchingConfiguredOptions.length === 1
|
|
242
|
+
? html`
|
|
243
|
+
Already added above:
|
|
244
|
+
<span class="text-body">
|
|
245
|
+
${getModelDisplayLabel(
|
|
246
|
+
matchingConfiguredOptions[0],
|
|
247
|
+
)}
|
|
248
|
+
</span>
|
|
249
|
+
`
|
|
250
|
+
: `${matchingConfiguredOptions.length} matching models are already added above.`}
|
|
251
|
+
</div>
|
|
252
|
+
`
|
|
201
253
|
: html`
|
|
202
254
|
<div class="px-3 py-3 text-xs text-fg-muted">
|
|
203
255
|
No models match that search.
|
|
@@ -20,7 +20,10 @@ export const useWelcomePairing = ({
|
|
|
20
20
|
return allPending.filter((p) => p.channel === selectedPairingChannel);
|
|
21
21
|
},
|
|
22
22
|
1000,
|
|
23
|
-
{
|
|
23
|
+
{
|
|
24
|
+
enabled: isPairingStep && !!selectedPairingChannel,
|
|
25
|
+
dedupeInFlight: true,
|
|
26
|
+
},
|
|
24
27
|
);
|
|
25
28
|
const pairingChannels = pairingStatusPoll.data?.channels || {};
|
|
26
29
|
const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { h } from 'preact';
|
|
2
|
-
import { useState } from 'preact/hooks';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
3
|
import htm from 'htm';
|
|
4
4
|
import { ActionButton } from './action-button.js';
|
|
5
|
+
import { LoadingSpinner } from './loading-spinner.js';
|
|
5
6
|
const html = htm.bind(h);
|
|
6
7
|
|
|
7
8
|
export const PairingRow = ({ p, onApprove, onReject }) => {
|
|
@@ -66,6 +67,13 @@ const ALL_CHANNELS = ['telegram', 'discord', 'slack', 'whatsapp'];
|
|
|
66
67
|
|
|
67
68
|
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
68
69
|
|
|
70
|
+
const getPairingKey = (p) => {
|
|
71
|
+
const channel = String(p?.channel || "").trim().toLowerCase();
|
|
72
|
+
const accountId = String(p?.accountId || "").trim() || "default";
|
|
73
|
+
const id = String(p?.id || p?.code || "").trim();
|
|
74
|
+
return channel && id ? `${channel}\u0000${accountId}\u0000${id}` : "";
|
|
75
|
+
};
|
|
76
|
+
|
|
69
77
|
export function Pairings({
|
|
70
78
|
pending,
|
|
71
79
|
channels,
|
|
@@ -73,7 +81,52 @@ export function Pairings({
|
|
|
73
81
|
onApprove,
|
|
74
82
|
onReject,
|
|
75
83
|
statusRefreshing = false,
|
|
84
|
+
pollingInFlight = false,
|
|
76
85
|
}) {
|
|
86
|
+
const [hiddenPairingKeys, setHiddenPairingKeys] = useState(() => new Set());
|
|
87
|
+
const pendingList = Array.isArray(pending) ? pending : [];
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
setHiddenPairingKeys((current) => {
|
|
91
|
+
if (current.size === 0) return current;
|
|
92
|
+
const pendingKeys = new Set(
|
|
93
|
+
pendingList.map(getPairingKey).filter(Boolean),
|
|
94
|
+
);
|
|
95
|
+
const next = new Set();
|
|
96
|
+
for (const key of current) {
|
|
97
|
+
if (pendingKeys.has(key)) {
|
|
98
|
+
next.add(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return next.size === current.size ? current : next;
|
|
102
|
+
});
|
|
103
|
+
}, [pending]);
|
|
104
|
+
|
|
105
|
+
const hidePairing = (p) => {
|
|
106
|
+
const key = getPairingKey(p);
|
|
107
|
+
if (!key) return;
|
|
108
|
+
setHiddenPairingKeys((current) => {
|
|
109
|
+
if (current.has(key)) return current;
|
|
110
|
+
const next = new Set(current);
|
|
111
|
+
next.add(key);
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleApprove = async (p) => {
|
|
117
|
+
await onApprove(p.id, p.channel, p.accountId);
|
|
118
|
+
hidePairing(p);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleReject = async (p) => {
|
|
122
|
+
await onReject(p.id, p.channel, p.accountId);
|
|
123
|
+
hidePairing(p);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const visiblePending = pendingList.filter(
|
|
127
|
+
(p) => !hiddenPairingKeys.has(getPairingKey(p)),
|
|
128
|
+
);
|
|
129
|
+
|
|
77
130
|
if (!visible) return null;
|
|
78
131
|
|
|
79
132
|
const unpaired = ALL_CHANNELS
|
|
@@ -95,12 +148,30 @@ export function Pairings({
|
|
|
95
148
|
? unpaired.join(' or ')
|
|
96
149
|
: unpaired.slice(0, -1).join(', ') + ', or ' + unpaired[unpaired.length - 1];
|
|
97
150
|
|
|
151
|
+
if (unpaired.length === 0 && visiblePending.length === 0) return null;
|
|
152
|
+
|
|
98
153
|
return html`
|
|
99
154
|
<div class="bg-surface border border-border rounded-xl p-4">
|
|
100
|
-
<
|
|
101
|
-
|
|
155
|
+
<div class="flex items-center justify-between gap-3 mb-3">
|
|
156
|
+
<h2 class="card-label">Pending Pairings</h2>
|
|
157
|
+
${pollingInFlight
|
|
158
|
+
? html`
|
|
159
|
+
<div class="inline-flex items-center text-fg-muted" aria-label="Pairings refresh in progress">
|
|
160
|
+
<${LoadingSpinner} className="h-3.5 w-3.5 text-fg-muted" />
|
|
161
|
+
</div>
|
|
162
|
+
`
|
|
163
|
+
: null}
|
|
164
|
+
</div>
|
|
165
|
+
${visiblePending.length > 0
|
|
102
166
|
? html`<div>
|
|
103
|
-
${
|
|
167
|
+
${visiblePending.map((p) => html`
|
|
168
|
+
<${PairingRow}
|
|
169
|
+
key=${getPairingKey(p) || p.id}
|
|
170
|
+
p=${p}
|
|
171
|
+
onApprove=${() => handleApprove(p)}
|
|
172
|
+
onReject=${() => handleReject(p)}
|
|
173
|
+
/>
|
|
174
|
+
`)}
|
|
104
175
|
</div>`
|
|
105
176
|
: statusRefreshing
|
|
106
177
|
? html`<div class="text-center py-4 space-y-2">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "preact/hooks";
|
|
1
|
+
import { useCallback, useEffect, useState } from "preact/hooks";
|
|
2
2
|
import {
|
|
3
3
|
runOnboard,
|
|
4
4
|
verifyGithubOnboardingRepo,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fetchModels,
|
|
8
8
|
} from "../../lib/api.js";
|
|
9
9
|
import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
|
|
10
|
+
import { usePolling } from "../../hooks/usePolling.js";
|
|
10
11
|
import {
|
|
11
12
|
getModelProvider,
|
|
12
13
|
getAuthProviderFromModelProvider,
|
|
@@ -17,7 +18,9 @@ import {
|
|
|
17
18
|
import {
|
|
18
19
|
getInitialOnboardingModelKey,
|
|
19
20
|
getModelCatalogModels,
|
|
21
|
+
isModelCatalogRefreshing,
|
|
20
22
|
kModelCatalogCacheKey,
|
|
23
|
+
kModelCatalogPollIntervalMs,
|
|
21
24
|
preloadModelCatalog,
|
|
22
25
|
} from "../../lib/model-catalog.js";
|
|
23
26
|
import {
|
|
@@ -100,6 +103,7 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
100
103
|
const [models, setModels] = useState([]);
|
|
101
104
|
const [modelsLoading, setModelsLoading] = useState(true);
|
|
102
105
|
const [modelsError, setModelsError] = useState(null);
|
|
106
|
+
const [modelsRefreshing, setModelsRefreshing] = useState(false);
|
|
103
107
|
const [showAllModels, setShowAllModels] = useState(false);
|
|
104
108
|
const [loading, setLoading] = useState(false);
|
|
105
109
|
const [githubStepLoading, setGithubStepLoading] = useState(false);
|
|
@@ -129,6 +133,11 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
129
133
|
const modelsFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, {
|
|
130
134
|
maxAgeMs: 30000,
|
|
131
135
|
});
|
|
136
|
+
const modelsPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, {
|
|
137
|
+
enabled: modelsRefreshing,
|
|
138
|
+
pauseWhenHidden: true,
|
|
139
|
+
cacheKey: kModelCatalogCacheKey,
|
|
140
|
+
});
|
|
132
141
|
|
|
133
142
|
useEffect(() => {
|
|
134
143
|
// Warm the real catalog immediately so the AI step usually opens ready.
|
|
@@ -157,11 +166,21 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
157
166
|
}));
|
|
158
167
|
};
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
const list = getModelCatalogModels(
|
|
162
|
-
if (!
|
|
169
|
+
const applyModelCatalog = useCallback((payload) => {
|
|
170
|
+
const list = getModelCatalogModels(payload);
|
|
171
|
+
if (!payload) return;
|
|
172
|
+
const isRefreshing = isModelCatalogRefreshing(payload);
|
|
173
|
+
const isFallbackRefresh =
|
|
174
|
+
String(payload?.source || "") === "fallback" && isRefreshing;
|
|
163
175
|
setModels(list);
|
|
164
|
-
|
|
176
|
+
setModelsRefreshing(isRefreshing);
|
|
177
|
+
setModelsError(
|
|
178
|
+
list.length > 0
|
|
179
|
+
? isFallbackRefresh
|
|
180
|
+
? "Loading full model catalog..."
|
|
181
|
+
: null
|
|
182
|
+
: "No models found",
|
|
183
|
+
);
|
|
165
184
|
const defaultModelKey = getInitialOnboardingModelKey({
|
|
166
185
|
catalog: list,
|
|
167
186
|
currentModelKey: vals.MODEL_KEY,
|
|
@@ -169,12 +188,22 @@ export const useWelcome = ({ onComplete }) => {
|
|
|
169
188
|
if (!vals.MODEL_KEY && defaultModelKey) {
|
|
170
189
|
setVals((prev) => ({ ...prev, MODEL_KEY: defaultModelKey }));
|
|
171
190
|
}
|
|
172
|
-
}, [
|
|
191
|
+
}, [setVals, vals.MODEL_KEY]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
applyModelCatalog(modelsFetchState.data);
|
|
195
|
+
}, [applyModelCatalog, modelsFetchState.data]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
applyModelCatalog(modelsPoll.data);
|
|
199
|
+
}, [applyModelCatalog, modelsPoll.data]);
|
|
173
200
|
|
|
174
201
|
useEffect(() => {
|
|
175
202
|
const hasModels = getModelCatalogModels(modelsFetchState.data).length > 0;
|
|
176
|
-
setModelsLoading(
|
|
177
|
-
|
|
203
|
+
setModelsLoading(
|
|
204
|
+
(modelsFetchState.loading || modelsPoll.isPolling) && !hasModels,
|
|
205
|
+
);
|
|
206
|
+
}, [modelsFetchState.data, modelsFetchState.loading, modelsPoll.isPolling]);
|
|
178
207
|
|
|
179
208
|
useEffect(() => {
|
|
180
209
|
if (!modelsFetchState.error) return;
|
|
@@ -8,6 +8,7 @@ export const usePolling = (
|
|
|
8
8
|
enabled = true,
|
|
9
9
|
pauseWhenHidden = true,
|
|
10
10
|
cacheKey = "",
|
|
11
|
+
dedupeInFlight = false,
|
|
11
12
|
} = {},
|
|
12
13
|
) => {
|
|
13
14
|
const normalizedCacheKey = String(cacheKey || "");
|
|
@@ -15,23 +16,55 @@ export const usePolling = (
|
|
|
15
16
|
normalizedCacheKey ? getCached(normalizedCacheKey) : null,
|
|
16
17
|
);
|
|
17
18
|
const [error, setError] = useState(null);
|
|
19
|
+
const [isPolling, setIsPolling] = useState(false);
|
|
18
20
|
const fetcherRef = useRef(fetcher);
|
|
21
|
+
const inFlightRefreshRef = useRef(null);
|
|
22
|
+
const activeRefreshCountRef = useRef(0);
|
|
23
|
+
const nextRefreshIdRef = useRef(0);
|
|
24
|
+
const latestRefreshIdRef = useRef(0);
|
|
19
25
|
fetcherRef.current = fetcher;
|
|
20
26
|
|
|
21
|
-
const refresh = useCallback(async () => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const refresh = useCallback(async ({ force = false } = {}) => {
|
|
28
|
+
if (dedupeInFlight && inFlightRefreshRef.current && !force) {
|
|
29
|
+
return inFlightRefreshRef.current;
|
|
30
|
+
}
|
|
31
|
+
const refreshId = nextRefreshIdRef.current + 1;
|
|
32
|
+
nextRefreshIdRef.current = refreshId;
|
|
33
|
+
latestRefreshIdRef.current = refreshId;
|
|
34
|
+
activeRefreshCountRef.current += 1;
|
|
35
|
+
setIsPolling(true);
|
|
36
|
+
const refreshPromise = Promise.resolve().then(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const result = await fetcherRef.current();
|
|
39
|
+
if (latestRefreshIdRef.current === refreshId) {
|
|
40
|
+
if (normalizedCacheKey) {
|
|
41
|
+
setCached(normalizedCacheKey, result);
|
|
42
|
+
}
|
|
43
|
+
setData(result);
|
|
44
|
+
setError(null);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (latestRefreshIdRef.current === refreshId) {
|
|
49
|
+
setError(err);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
} finally {
|
|
53
|
+
activeRefreshCountRef.current = Math.max(
|
|
54
|
+
0,
|
|
55
|
+
activeRefreshCountRef.current - 1,
|
|
56
|
+
);
|
|
57
|
+
setIsPolling(activeRefreshCountRef.current > 0);
|
|
58
|
+
if (inFlightRefreshRef.current === refreshPromise) {
|
|
59
|
+
inFlightRefreshRef.current = null;
|
|
60
|
+
}
|
|
26
61
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} catch (err) {
|
|
31
|
-
setError(err);
|
|
32
|
-
return null;
|
|
62
|
+
});
|
|
63
|
+
if (dedupeInFlight) {
|
|
64
|
+
inFlightRefreshRef.current = refreshPromise;
|
|
33
65
|
}
|
|
34
|
-
|
|
66
|
+
return refreshPromise;
|
|
67
|
+
}, [dedupeInFlight, normalizedCacheKey]);
|
|
35
68
|
|
|
36
69
|
useEffect(() => {
|
|
37
70
|
if (!normalizedCacheKey) return;
|
|
@@ -63,5 +96,5 @@ export const usePolling = (
|
|
|
63
96
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
64
97
|
}, [enabled, pauseWhenHidden, refresh]);
|
|
65
98
|
|
|
66
|
-
return { data, error, refresh };
|
|
99
|
+
return { data, error, refresh, isPolling };
|
|
67
100
|
};
|