@chrysb/alphaclaw 0.4.0 → 0.4.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 (49) hide show
  1. package/lib/public/css/shell.css +21 -19
  2. package/lib/public/css/theme.css +17 -0
  3. package/lib/public/js/app.js +80 -5
  4. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  5. package/lib/public/js/components/file-viewer/index.js +3 -0
  6. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  7. package/lib/public/js/components/file-viewer/toolbar.js +13 -0
  8. package/lib/public/js/components/file-viewer/use-file-viewer.js +48 -13
  9. package/lib/public/js/components/google/account-row.js +34 -1
  10. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  11. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  12. package/lib/public/js/components/google/index.js +193 -44
  13. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  14. package/lib/public/js/components/scope-picker.js +1 -1
  15. package/lib/public/js/components/sidebar-git-panel.js +5 -6
  16. package/lib/public/js/components/sidebar.js +3 -1
  17. package/lib/public/js/components/telegram-workspace/onboarding.js +1 -1
  18. package/lib/public/js/components/toast.js +11 -7
  19. package/lib/public/js/components/usage-tab/constants.js +31 -0
  20. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  21. package/lib/public/js/components/usage-tab/index.js +72 -0
  22. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  23. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  24. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  25. package/lib/public/js/components/webhooks.js +180 -127
  26. package/lib/public/js/lib/api.js +106 -1
  27. package/lib/public/js/lib/format.js +71 -0
  28. package/lib/server/constants.js +27 -0
  29. package/lib/server/gmail-push.js +109 -0
  30. package/lib/server/gmail-serve.js +254 -0
  31. package/lib/server/gmail-watch.js +705 -0
  32. package/lib/server/google-state.js +130 -0
  33. package/lib/server/helpers.js +5 -7
  34. package/lib/server/internal-files-migration.js +31 -3
  35. package/lib/server/onboarding/openclaw.js +9 -1
  36. package/lib/server/routes/gmail.js +128 -0
  37. package/lib/server/routes/google.js +19 -0
  38. package/lib/server/routes/system.js +107 -0
  39. package/lib/server/routes/usage.js +29 -2
  40. package/lib/server/routes/webhooks.js +47 -14
  41. package/lib/server/usage-db.js +283 -15
  42. package/lib/server/watchdog.js +66 -0
  43. package/lib/server/webhook-middleware.js +99 -1
  44. package/lib/server/webhooks.js +213 -64
  45. package/lib/server.js +27 -0
  46. package/lib/setup/gitignore +3 -0
  47. package/lib/setup/hourly-git-sync.sh +1 -1
  48. package/package.json +1 -1
  49. package/lib/public/js/components/usage-tab.js +0 -531
@@ -0,0 +1,450 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { ModalShell } from "../modal-shell.js";
5
+ import { PageHeader } from "../page-header.js";
6
+ import { CloseIcon } from "../icons.js";
7
+ import { ActionButton } from "../action-button.js";
8
+ import { fetchAgentSessions, sendAgentMessage } from "../../lib/api.js";
9
+ import { showToast } from "../toast.js";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ const copyText = async (value) => {
14
+ const text = String(value || "");
15
+ if (!text) return false;
16
+ try {
17
+ if (navigator?.clipboard?.writeText) {
18
+ await navigator.clipboard.writeText(text);
19
+ return true;
20
+ }
21
+ } catch {}
22
+ try {
23
+ const element = document.createElement("textarea");
24
+ element.value = text;
25
+ element.setAttribute("readonly", "");
26
+ element.style.position = "fixed";
27
+ element.style.opacity = "0";
28
+ document.body.appendChild(element);
29
+ element.select();
30
+ document.execCommand("copy");
31
+ document.body.removeChild(element);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ };
37
+
38
+ const kStepTitles = [
39
+ "Install + Authenticate gcloud",
40
+ "Enable APIs",
41
+ "Create Topic + IAM",
42
+ "Create Push Subscription",
43
+ "Build with your Agent",
44
+ ];
45
+ const kTotalSteps = kStepTitles.length;
46
+ const kNoSessionSelectedValue = "__none__";
47
+
48
+ const renderCommandBlock = (command = "", onCopy = () => {}) => html`
49
+ <div class="rounded-lg border border-border bg-black/30 p-3">
50
+ <pre
51
+ class="pt-1 pl-2 text-[11px] leading-5 whitespace-pre-wrap break-all font-mono text-gray-300"
52
+ >
53
+ ${command}</pre
54
+ >
55
+ <div class="pt-3">
56
+ <button
57
+ type="button"
58
+ onclick=${onCopy}
59
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
60
+ >
61
+ Copy
62
+ </button>
63
+ </div>
64
+ </div>
65
+ `;
66
+
67
+ export const GmailSetupWizard = ({
68
+ visible = false,
69
+ account = null,
70
+ clientConfig = null,
71
+ saving = false,
72
+ onClose = () => {},
73
+ onSaveSetup = async () => {},
74
+ onFinish = async () => {},
75
+ }) => {
76
+ const [step, setStep] = useState(0);
77
+ const [projectIdInput, setProjectIdInput] = useState("");
78
+ const [localError, setLocalError] = useState("");
79
+ const [projectIdResolved, setProjectIdResolved] = useState(false);
80
+ const [watchEnabled, setWatchEnabled] = useState(false);
81
+ const [sendingToAgent, setSendingToAgent] = useState(false);
82
+ const [agentMessageSent, setAgentMessageSent] = useState(false);
83
+ const [agentSessions, setAgentSessions] = useState([]);
84
+ const [selectedSessionKey, setSelectedSessionKey] = useState("");
85
+ const [loadingAgentSessions, setLoadingAgentSessions] = useState(false);
86
+ const [agentSessionsError, setAgentSessionsError] = useState("");
87
+
88
+ useEffect(() => {
89
+ if (!visible) return;
90
+ setStep(0);
91
+ setLocalError("");
92
+ setProjectIdInput("");
93
+ setProjectIdResolved(false);
94
+ setWatchEnabled(false);
95
+ setSendingToAgent(false);
96
+ setAgentMessageSent(false);
97
+ setAgentSessions([]);
98
+ setSelectedSessionKey("");
99
+ setLoadingAgentSessions(false);
100
+ setAgentSessionsError("");
101
+ }, [visible, account?.id]);
102
+
103
+ useEffect(() => {
104
+ if (!visible) return;
105
+ let active = true;
106
+ const loadAgentSessions = async () => {
107
+ try {
108
+ setLoadingAgentSessions(true);
109
+ setAgentSessionsError("");
110
+ const data = await fetchAgentSessions();
111
+ if (!active) return;
112
+ const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
113
+ setAgentSessions(sessions);
114
+ const defaultSession = sessions.find((sessionRow) => {
115
+ const key = String(sessionRow?.key || "").toLowerCase();
116
+ return key.includes(":direct:") || key.includes(":group:");
117
+ });
118
+ setSelectedSessionKey((currentKey) => currentKey || String(defaultSession?.key || ""));
119
+ } catch (err) {
120
+ if (!active) return;
121
+ setAgentSessions([]);
122
+ setSelectedSessionKey("");
123
+ setAgentSessionsError(err.message || "Could not load sessions");
124
+ } finally {
125
+ if (active) setLoadingAgentSessions(false);
126
+ }
127
+ };
128
+ loadAgentSessions();
129
+ return () => {
130
+ active = false;
131
+ };
132
+ }, [visible, account?.id]);
133
+
134
+ const commands = clientConfig?.commands || null;
135
+ const hasProjectIdFromConfig = Boolean(
136
+ String(clientConfig?.projectId || "").trim() || commands,
137
+ );
138
+ const needsProjectId = !hasProjectIdFromConfig && !projectIdResolved;
139
+ const detectedProjectId =
140
+ String(clientConfig?.projectId || "").trim() ||
141
+ String(projectIdInput || "").trim() ||
142
+ "<project-id>";
143
+ const client =
144
+ String(account?.client || clientConfig?.client || "default").trim() ||
145
+ "default";
146
+
147
+ const canAdvance = useMemo(() => {
148
+ if (needsProjectId) {
149
+ return String(projectIdInput || "").trim().length > 0;
150
+ }
151
+ return true;
152
+ }, [needsProjectId, projectIdInput]);
153
+ const selectableAgentSessions = useMemo(
154
+ () =>
155
+ agentSessions.filter((sessionRow) => {
156
+ const key = String(sessionRow?.key || "").toLowerCase();
157
+ return key.includes(":direct:") || key.includes(":group:");
158
+ }),
159
+ [agentSessions],
160
+ );
161
+
162
+ const handleCopy = async (value) => {
163
+ const ok = await copyText(value);
164
+ if (ok) {
165
+ showToast("Copied to clipboard", "success");
166
+ return;
167
+ }
168
+ showToast("Could not copy text", "error");
169
+ };
170
+
171
+ const handleFinish = async () => {
172
+ try {
173
+ setLocalError("");
174
+ await onFinish({
175
+ client,
176
+ projectId: String(projectIdInput || "").trim(),
177
+ });
178
+ setWatchEnabled(true);
179
+ setStep((prev) => Math.min(prev + 1, kTotalSteps - 1));
180
+ } catch (err) {
181
+ setLocalError(err.message || "Could not finish setup");
182
+ }
183
+ };
184
+
185
+ const handleNext = async () => {
186
+ if (saving) return;
187
+ if (needsProjectId) {
188
+ if (!canAdvance) return;
189
+ setLocalError("");
190
+ try {
191
+ await onSaveSetup({
192
+ client,
193
+ projectId: String(projectIdInput || "").trim(),
194
+ });
195
+ setProjectIdResolved(true);
196
+ } catch (err) {
197
+ setLocalError(err.message || "Could not save project id");
198
+ return;
199
+ }
200
+ return;
201
+ }
202
+ setStep((prev) => Math.min(prev + 1, kTotalSteps - 1));
203
+ };
204
+
205
+ const handleSendToAgent = async () => {
206
+ if (sendingToAgent || agentMessageSent) return;
207
+ try {
208
+ setSendingToAgent(true);
209
+ const accountEmail = String(account?.email || "this account").trim() || "this account";
210
+ const message =
211
+ `I just enabled Gmail watch for "${accountEmail}", set up the webhook, ` +
212
+ `and created the transform file. Help me set up what I want to do ` +
213
+ `with incoming email.`;
214
+ await sendAgentMessage({
215
+ message,
216
+ sessionKey: selectedSessionKey,
217
+ });
218
+ setAgentMessageSent(true);
219
+ showToast("Message sent to your agent", "success");
220
+ } catch (err) {
221
+ showToast(err.message || "Could not send message to agent", "error");
222
+ } finally {
223
+ setSendingToAgent(false);
224
+ }
225
+ };
226
+
227
+ return html`
228
+ <${ModalShell}
229
+ visible=${visible}
230
+ onClose=${onClose}
231
+ closeOnOverlayClick=${false}
232
+ closeOnEscape=${false}
233
+ panelClassName="relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4"
234
+ >
235
+ <button
236
+ type="button"
237
+ onclick=${onClose}
238
+ class="absolute top-6 right-6 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
239
+ aria-label="Close modal"
240
+ >
241
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
242
+ </button>
243
+ <div class="text-xs text-gray-500">Gmail Pub / Sub Setup</div>
244
+ <div class="flex items-center gap-1">
245
+ ${kStepTitles.map(
246
+ (title, idx) => html`
247
+ <div
248
+ class=${`h-1 flex-1 rounded-full transition-colors ${idx <= step ? "bg-accent" : "bg-border"}`}
249
+ style=${idx <= step ? "background: var(--accent)" : ""}
250
+ title=${title}
251
+ ></div>
252
+ `,
253
+ )}
254
+ </div>
255
+ <${PageHeader}
256
+ title=${`Step ${step + 1} of ${kTotalSteps}: ${kStepTitles[step]}`}
257
+ actions=${null}
258
+ />
259
+ ${localError ? html`<div class="text-xs text-red-400">${localError}</div>` : null}
260
+ ${
261
+ needsProjectId
262
+ ? html`
263
+ <div
264
+ class="rounded-lg border border-border bg-black/20 p-3 space-y-2"
265
+ >
266
+ <div class="text-sm">Project ID required</div>
267
+ <div class="text-xs text-gray-500">
268
+ Find it in the${" "}
269
+ <a
270
+ href="https://console.cloud.google.com/home/dashboard"
271
+ target="_blank"
272
+ rel="noreferrer"
273
+ class="ac-tip-link"
274
+ >
275
+ Google Cloud Console Project Selector
276
+ </a>
277
+ </div>
278
+ <input
279
+ type="text"
280
+ value=${projectIdInput}
281
+ oninput=${(event) => setProjectIdInput(event.target.value)}
282
+ class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none"
283
+ placeholder="my-gcp-project"
284
+ />
285
+ </div>
286
+ `
287
+ : null
288
+ }
289
+ ${
290
+ !needsProjectId && step === 0
291
+ ? html`
292
+ <div class="space-y-1">
293
+ <div class="text-xs text-gray-500">
294
+ If <code>gcloud</code> is not installed on your computer,
295
+ follow the official install guide:${" "}
296
+ <a
297
+ href="https://docs.cloud.google.com/sdk/docs/install-sdk"
298
+ target="_blank"
299
+ rel="noreferrer"
300
+ class="ac-tip-link"
301
+ >
302
+ Google Cloud SDK install docs
303
+ </a>
304
+ </div>
305
+ </div>
306
+ ${renderCommandBlock(
307
+ `gcloud auth login\n` +
308
+ `gcloud config set project ${detectedProjectId}`,
309
+ () =>
310
+ handleCopy(
311
+ `gcloud auth login\n` +
312
+ `gcloud config set project ${detectedProjectId}`,
313
+ ),
314
+ )}
315
+ `
316
+ : null
317
+ }
318
+ ${
319
+ !needsProjectId && step === 1
320
+ ? renderCommandBlock(commands?.enableApis || "", () =>
321
+ handleCopy(commands?.enableApis || ""),
322
+ )
323
+ : null
324
+ }
325
+ ${
326
+ !needsProjectId && step === 2
327
+ ? html`
328
+ ${renderCommandBlock(
329
+ `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(),
330
+ () =>
331
+ handleCopy(
332
+ `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(),
333
+ ),
334
+ )}
335
+ `
336
+ : null
337
+ }
338
+ ${
339
+ !needsProjectId && step === 3
340
+ ? renderCommandBlock(commands?.createSubscription || "", () =>
341
+ handleCopy(commands?.createSubscription || ""),
342
+ )
343
+ : null
344
+ }
345
+ ${
346
+ step === 4
347
+ ? html`
348
+ <div
349
+ class="rounded-lg border border-border bg-black/20 p-3 space-y-3"
350
+ >
351
+ <div class="pt-1 space-y-1">
352
+ <div class="text-sm">Continue with your agent</div>
353
+ <div class="text-xs text-gray-500">
354
+ Tell your OpenClaw agent about what you want to build with
355
+ incoming email to continue the setup.
356
+ </div>
357
+ <div class="pt-2 space-y-2">
358
+ <div class="text-[11px] text-gray-500">Send this to session</div>
359
+ <div class="flex items-center gap-2">
360
+ <select
361
+ value=${selectedSessionKey || kNoSessionSelectedValue}
362
+ oninput=${(event) => {
363
+ const nextValue = String(event.target.value || "");
364
+ setSelectedSessionKey(
365
+ nextValue === kNoSessionSelectedValue ? "" : nextValue,
366
+ );
367
+ }}
368
+ disabled=${loadingAgentSessions || sendingToAgent || agentMessageSent}
369
+ class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
370
+ >
371
+ ${!selectedSessionKey
372
+ ? html`<option value=${kNoSessionSelectedValue}>Select a session...</option>`
373
+ : null}
374
+ ${selectableAgentSessions.map(
375
+ (sessionRow) => html`
376
+ <option value=${sessionRow.key}>
377
+ ${sessionRow.label || sessionRow.key}
378
+ </option>
379
+ `,
380
+ )}
381
+ </select>
382
+ <${ActionButton}
383
+ onClick=${handleSendToAgent}
384
+ disabled=${!selectedSessionKey || agentMessageSent}
385
+ loading=${sendingToAgent}
386
+ loadingMode="inline"
387
+ idleLabel=${agentMessageSent ? "Sent" : "Send to Agent"}
388
+ loadingLabel="Sending..."
389
+ tone="primary"
390
+ size="sm"
391
+ className="h-[34px] px-3"
392
+ />
393
+ </div>
394
+ ${loadingAgentSessions
395
+ ? html`<div class="text-[11px] text-gray-500">Loading sessions...</div>`
396
+ : null}
397
+ ${agentSessionsError
398
+ ? html`<div class="text-[11px] text-red-400">${agentSessionsError}</div>`
399
+ : null}
400
+ </div>
401
+ </div>
402
+ </div>
403
+ `
404
+ : null
405
+ }
406
+ <div class="grid grid-cols-2 gap-2 pt-2">
407
+ ${step === 0
408
+ ? html`<div></div>`
409
+ : html`<${ActionButton}
410
+ onClick=${() => setStep((prev) => Math.max(prev - 1, 0))}
411
+ disabled=${saving}
412
+ idleLabel="Back"
413
+ tone="secondary"
414
+ size="md"
415
+ className="w-full justify-center"
416
+ />`}
417
+ ${
418
+ step < kTotalSteps - 2
419
+ ? html`<${ActionButton}
420
+ onClick=${handleNext}
421
+ disabled=${saving || (needsProjectId && !canAdvance)}
422
+ idleLabel="Next"
423
+ tone="primary"
424
+ size="md"
425
+ className="w-full justify-center"
426
+ />`
427
+ : step === kTotalSteps - 2
428
+ ? html`<${ActionButton}
429
+ onClick=${handleFinish}
430
+ disabled=${false}
431
+ loading=${saving}
432
+ idleLabel="Enable watch"
433
+ loadingLabel="Enabling..."
434
+ tone="primary"
435
+ size="md"
436
+ className="w-full justify-center"
437
+ />`
438
+ : html`<${ActionButton}
439
+ onClick=${onClose}
440
+ disabled=${saving || sendingToAgent}
441
+ idleLabel="Done"
442
+ tone="secondary"
443
+ size="md"
444
+ className="w-full justify-center"
445
+ />`
446
+ }
447
+ </div>
448
+ </${ModalShell}>
449
+ `;
450
+ };
@@ -0,0 +1,81 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { Badge } from "../badge.js";
4
+ import { ToggleSwitch } from "../toggle-switch.js";
5
+ import { InfoTooltip } from "../info-tooltip.js";
6
+
7
+ const html = htm.bind(h);
8
+
9
+ const resolveWatchState = ({ watchStatus, busy = false }) => {
10
+ if (busy) return { label: "Starting", tone: "warning" };
11
+ if (!watchStatus?.enabled) return { label: "Stopped", tone: "neutral" };
12
+ if (watchStatus.enabled && !watchStatus.running)
13
+ return { label: "Error", tone: "danger" };
14
+ return { label: "Watching", tone: "success" };
15
+ };
16
+
17
+ export const GmailWatchToggle = ({
18
+ account,
19
+ watchStatus = null,
20
+ busy = false,
21
+ onEnable = () => {},
22
+ onDisable = () => {},
23
+ onOpenWebhook = () => {},
24
+ }) => {
25
+ const hasGmailReadScope = Array.isArray(account?.activeScopes)
26
+ ? account.activeScopes.includes("gmail:read")
27
+ : Array.isArray(account?.services)
28
+ ? account.services.includes("gmail:read")
29
+ : false;
30
+ if (!hasGmailReadScope) {
31
+ return html`
32
+ <div class="bg-black/30 rounded-lg px-3 py-2">
33
+ <div class="text-xs text-gray-500">
34
+ Gmail watch requires <code>gmail:read</code>. Add it in permissions
35
+ above, then update permissions.
36
+ </div>
37
+ </div>
38
+ `;
39
+ }
40
+
41
+ const state = resolveWatchState({ watchStatus, busy });
42
+ const enabled = Boolean(watchStatus?.enabled);
43
+ return html`
44
+ <div
45
+ class="flex items-center justify-between bg-black/30 border border-transparent rounded-lg px-3 py-2 cursor-pointer hover:bg-black/40 hover:border-white/20 transition-colors"
46
+ role="button"
47
+ tabindex="0"
48
+ onClick=${() => onOpenWebhook?.()}
49
+ onKeyDown=${(event) => {
50
+ if (event.key !== "Enter" && event.key !== " ") return;
51
+ event.preventDefault();
52
+ onOpenWebhook?.();
53
+ }}
54
+ >
55
+ <div class="flex items-center gap-1.5 text-sm">
56
+ <span>🔔 Gmail</span>
57
+ <${InfoTooltip}
58
+ text="Watches this inbox for new email events and routes them to your agent via the Gmail hook."
59
+ widthClass="w-72"
60
+ />
61
+ </div>
62
+ <div
63
+ class="flex items-center gap-2"
64
+ onClick=${(event) => event.stopPropagation()}
65
+ onKeyDown=${(event) => event.stopPropagation()}
66
+ >
67
+ <${Badge} tone=${state.tone}>${state.label}</${Badge}>
68
+ <${ToggleSwitch}
69
+ checked=${enabled}
70
+ disabled=${busy}
71
+ label=""
72
+ onChange=${(nextChecked) => {
73
+ if (busy) return;
74
+ if (nextChecked) onEnable?.();
75
+ else onDisable?.();
76
+ }}
77
+ />
78
+ </div>
79
+ </div>
80
+ `;
81
+ };