@chrysb/alphaclaw 0.8.0 → 0.8.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/public/js/app.js +100 -83
  2. package/lib/public/js/components/agents-tab/agent-pairing-section.js +47 -12
  3. package/lib/public/js/components/channels.js +14 -17
  4. package/lib/public/js/components/envars.js +42 -6
  5. package/lib/public/js/components/features.js +6 -12
  6. package/lib/public/js/components/general/use-general-tab.js +10 -5
  7. package/lib/public/js/components/google/use-gmail-watch.js +22 -18
  8. package/lib/public/js/components/google/use-google-accounts.js +23 -23
  9. package/lib/public/js/components/models-tab/use-models.js +20 -4
  10. package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +2 -2
  11. package/lib/public/js/components/nodes-tab/use-nodes-tab.js +13 -9
  12. package/lib/public/js/components/routes/webhooks-route.js +1 -1
  13. package/lib/public/js/components/webhooks/create-webhook-modal/index.js +176 -0
  14. package/lib/public/js/components/webhooks/helpers.js +106 -0
  15. package/lib/public/js/components/webhooks/index.js +148 -0
  16. package/lib/public/js/components/webhooks/request-history/index.js +241 -0
  17. package/lib/public/js/components/webhooks/request-history/use-request-history.js +167 -0
  18. package/lib/public/js/components/webhooks/webhook-detail/index.js +374 -0
  19. package/lib/public/js/components/webhooks/webhook-detail/use-webhook-detail.js +261 -0
  20. package/lib/public/js/components/webhooks/webhook-list/index.js +96 -0
  21. package/lib/public/js/components/webhooks/webhook-list/use-webhook-list.js +30 -0
  22. package/lib/public/js/hooks/use-app-shell-controller.js +59 -6
  23. package/lib/public/js/hooks/use-cached-fetch.js +63 -0
  24. package/lib/public/js/hooks/usePolling.js +45 -7
  25. package/lib/public/js/lib/api-cache.js +88 -0
  26. package/lib/public/js/lib/api.js +64 -1
  27. package/lib/server/db/webhooks/index.js +144 -0
  28. package/lib/server/db/webhooks/schema.js +13 -0
  29. package/lib/server/init/register-server-routes.js +21 -0
  30. package/lib/server/oauth-callback-middleware.js +34 -0
  31. package/lib/server/routes/proxy.js +2 -0
  32. package/lib/server/routes/system.js +50 -2
  33. package/lib/server/routes/webhooks.js +126 -18
  34. package/lib/server/webhook-middleware.js +6 -1
  35. package/lib/server.js +12 -0
  36. package/package.json +1 -1
  37. package/lib/public/js/components/webhooks.js +0 -1259
@@ -1,7 +1,7 @@
1
1
  import { usePolling } from "../../../hooks/usePolling.js";
2
2
  import { fetchNodesStatus } from "../../../lib/api.js";
3
3
 
4
- const kNodesPollIntervalMs = 3000;
4
+ const kNodesPollIntervalMs = 10000;
5
5
 
6
6
  export const useConnectedNodes = ({ enabled = true } = {}) => {
7
7
  const poll = usePolling(
@@ -12,7 +12,7 @@ export const useConnectedNodes = ({ enabled = true } = {}) => {
12
12
  return { nodes, pending };
13
13
  },
14
14
  kNodesPollIntervalMs,
15
- { enabled },
15
+ { enabled, cacheKey: "/api/nodes" },
16
16
  );
17
17
 
18
18
  return {
@@ -1,26 +1,30 @@
1
1
  import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
2
  import { fetchNodeConnectInfo } from "../../lib/api.js";
3
+ import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
3
4
  import { showToast } from "../toast.js";
4
5
  import { useConnectedNodes } from "./connected-nodes/user-connected-nodes.js";
5
6
 
6
7
  export const useNodesTab = () => {
7
8
  const connectedNodesState = useConnectedNodes({ enabled: true });
8
9
  const [wizardVisible, setWizardVisible] = useState(false);
9
- const [connectInfo, setConnectInfo] = useState(null);
10
10
  const [refreshingNodes, setRefreshingNodes] = useState(false);
11
+ const {
12
+ data: connectInfo,
13
+ error: connectInfoError,
14
+ } = useCachedFetch("/api/nodes/connect-info", fetchNodeConnectInfo, {
15
+ maxAgeMs: 60000,
16
+ });
11
17
  const pairedNodes = Array.isArray(connectedNodesState.nodes)
12
18
  ? connectedNodesState.nodes.filter((entry) => entry?.paired !== false)
13
19
  : [];
14
20
 
15
21
  useEffect(() => {
16
- fetchNodeConnectInfo()
17
- .then((result) => {
18
- setConnectInfo(result || null);
19
- })
20
- .catch((error) => {
21
- showToast(error.message || "Could not load node connect command", "error");
22
- });
23
- }, []);
22
+ if (!connectInfoError) return;
23
+ showToast(
24
+ connectInfoError.message || "Could not load node connect command",
25
+ "error",
26
+ );
27
+ }, [connectInfoError]);
24
28
 
25
29
  const refreshNodes = useCallback(async () => {
26
30
  if (refreshingNodes) return;
@@ -1,6 +1,6 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
- import { Webhooks } from "../webhooks.js";
3
+ import { Webhooks } from "../webhooks/index.js";
4
4
 
5
5
  const html = htm.bind(h);
6
6
 
@@ -0,0 +1,176 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import {
4
+ kNoDestinationSessionValue,
5
+ useDestinationSessionSelection,
6
+ } from "../../../hooks/use-destination-session-selection.js";
7
+ import { ActionButton } from "../../action-button.js";
8
+ import { CloseIcon } from "../../icons.js";
9
+ import { ModalShell } from "../../modal-shell.js";
10
+ import { PageHeader } from "../../page-header.js";
11
+ import { SessionSelectField } from "../../session-select-field.js";
12
+
13
+ const html = htm.bind(h);
14
+
15
+ export const CreateWebhookModal = ({
16
+ visible,
17
+ name,
18
+ mode = "webhook",
19
+ onModeChange = () => {},
20
+ onNameChange = () => {},
21
+ canCreate = false,
22
+ creating = false,
23
+ onCreate = () => {},
24
+ onClose = () => {},
25
+ }) => {
26
+ const {
27
+ sessions: selectableSessions,
28
+ loading: loadingSessions,
29
+ error: destinationLoadError,
30
+ destinationSessionKey,
31
+ setDestinationSessionKey,
32
+ selectedDestination,
33
+ } = useDestinationSessionSelection({
34
+ enabled: visible,
35
+ resetKey: String(visible),
36
+ });
37
+
38
+ const normalized = String(name || "")
39
+ .trim()
40
+ .toLowerCase();
41
+ const previewName = normalized || "{name}";
42
+ const previewPath = `/hooks/${previewName}`;
43
+ const previewUrl =
44
+ mode === "oauth"
45
+ ? `${window.location.origin}/oauth/{id}`
46
+ : `${window.location.origin}${previewPath}`;
47
+ if (!visible) return null;
48
+
49
+ return html`
50
+ <${ModalShell}
51
+ visible=${visible}
52
+ onClose=${onClose}
53
+ panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
54
+ >
55
+ <${PageHeader}
56
+ title="Create Webhook"
57
+ actions=${html`
58
+ <button
59
+ type="button"
60
+ onclick=${onClose}
61
+ class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
62
+ aria-label="Close modal"
63
+ >
64
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
65
+ </button>
66
+ `}
67
+ />
68
+ <div class="space-y-2">
69
+ <p class="text-xs text-gray-500">Endpoint mode</p>
70
+ <div class="flex items-center gap-2">
71
+ <button
72
+ class="text-xs px-2 py-1 rounded border transition-colors ${mode ===
73
+ "webhook"
74
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
75
+ : "border-border text-gray-400 hover:text-gray-200"}"
76
+ onclick=${() => onModeChange("webhook")}
77
+ >
78
+ Webhook
79
+ </button>
80
+ <button
81
+ class="text-xs px-2 py-1 rounded border transition-colors ${mode ===
82
+ "oauth"
83
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
84
+ : "border-border text-gray-400 hover:text-gray-200"}"
85
+ onclick=${() => onModeChange("oauth")}
86
+ >
87
+ OAuth Callback
88
+ </button>
89
+ </div>
90
+ </div>
91
+ <div class="space-y-2">
92
+ <p class="text-xs text-gray-500">Name</p>
93
+ <input
94
+ type="text"
95
+ value=${name}
96
+ placeholder="fathom"
97
+ onInput=${(e) => onNameChange(e.target.value)}
98
+ onKeyDown=${(e) => {
99
+ if (e.key === "Enter" && canCreate && !creating) {
100
+ onCreate(selectedDestination, mode);
101
+ }
102
+ if (e.key === "Escape") onClose();
103
+ }}
104
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-1.5 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
105
+ />
106
+ </div>
107
+ <${SessionSelectField}
108
+ label="Deliver to"
109
+ sessions=${selectableSessions}
110
+ selectedSessionKey=${destinationSessionKey}
111
+ onChangeSessionKey=${setDestinationSessionKey}
112
+ disabled=${loadingSessions || creating}
113
+ loading=${loadingSessions}
114
+ error=${destinationLoadError}
115
+ allowNone=${true}
116
+ noneValue=${kNoDestinationSessionValue}
117
+ noneLabel="Default"
118
+ emptyStateText="No paired chat sessions found yet. You can still create the webhook without a default destination."
119
+ loadingLabel="Loading destinations..."
120
+ />
121
+ <div class="border border-border rounded-lg overflow-hidden">
122
+ <table class="w-full text-xs">
123
+ <tbody>
124
+ <tr class="border-b border-border">
125
+ <td class="w-24 px-3 py-2 text-gray-500">Path</td>
126
+ <td class="px-3 py-2 text-gray-300 font-mono">
127
+ <code>${previewPath}</code>
128
+ </td>
129
+ </tr>
130
+ <tr class="border-b border-border">
131
+ <td class="w-24 px-3 py-2 text-gray-500">URL</td>
132
+ <td class="px-3 py-2 text-gray-300 font-mono break-all">
133
+ <code>${previewUrl}</code>
134
+ </td>
135
+ </tr>
136
+ <tr>
137
+ <td class="w-24 px-3 py-2 text-gray-500">Transform</td>
138
+ <td class="px-3 py-2 text-gray-300 font-mono">
139
+ <code>hooks/transforms/${previewName}/${previewName}-transform.mjs</code>
140
+ </td>
141
+ </tr>
142
+ </tbody>
143
+ </table>
144
+ </div>
145
+ ${mode === "oauth"
146
+ ? html`
147
+ <div class="space-y-1">
148
+ <p class="text-xs text-gray-500">
149
+ For OAuth providers that can't send auth headers. AlphaClaw
150
+ injects webhook auth before forwarding to /hooks/{name}.
151
+ </p>
152
+ </div>
153
+ `
154
+ : null}
155
+ <div class="pt-1 flex items-center justify-end gap-2">
156
+ <${ActionButton}
157
+ onClick=${onClose}
158
+ tone="secondary"
159
+ size="md"
160
+ idleLabel="Cancel"
161
+ className="px-4 py-2 rounded-lg text-sm"
162
+ />
163
+ <${ActionButton}
164
+ onClick=${() => onCreate(selectedDestination, mode)}
165
+ disabled=${!canCreate || creating}
166
+ loading=${creating}
167
+ tone="primary"
168
+ size="md"
169
+ idleLabel="Create"
170
+ loadingLabel="Creating..."
171
+ className="px-4 py-2 rounded-lg text-sm"
172
+ />
173
+ </div>
174
+ </${ModalShell}>
175
+ `;
176
+ };
@@ -0,0 +1,106 @@
1
+ import {
2
+ formatLocaleDateTime,
3
+ formatLocaleDateTimeWithTodayTime,
4
+ } from "../../lib/format.js";
5
+
6
+ export const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ export const kStatusFilters = ["all", "success", "error"];
8
+
9
+ export const formatDateTime = (value) => {
10
+ return formatLocaleDateTime(value, { fallback: "—" });
11
+ };
12
+
13
+ export const formatLastReceived = (value) => {
14
+ return formatLocaleDateTimeWithTodayTime(value, { fallback: "—" });
15
+ };
16
+
17
+ export const formatBytes = (size) => {
18
+ const bytes = Number(size || 0);
19
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0B";
20
+ if (bytes < 1024) return `${bytes}B`;
21
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
22
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
23
+ };
24
+
25
+ export const healthClassName = (health) => {
26
+ if (health === "red") return "bg-red-500";
27
+ if (health === "yellow") return "bg-yellow-500";
28
+ return "bg-green-500";
29
+ };
30
+
31
+ export const getRequestStatusTone = (status) => {
32
+ if (status === "success") {
33
+ return {
34
+ dotClass: "bg-green-500/90",
35
+ textClass: "text-green-500/90",
36
+ };
37
+ }
38
+ if (status === "error") {
39
+ return {
40
+ dotClass: "bg-red-500/90",
41
+ textClass: "text-red-500/90",
42
+ };
43
+ }
44
+ return {
45
+ dotClass: "bg-gray-500/70",
46
+ textClass: "text-gray-400",
47
+ };
48
+ };
49
+
50
+ export const formatAgentFallbackName = (agentId = "") =>
51
+ String(agentId || "")
52
+ .trim()
53
+ .split(/[-_\s]+/)
54
+ .filter(Boolean)
55
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
56
+ .join(" ") || "Main Agent";
57
+
58
+ export const jsonPretty = (value) => {
59
+ if (typeof value === "string") {
60
+ try {
61
+ const parsed = JSON.parse(value);
62
+ return JSON.stringify(parsed, null, 2);
63
+ } catch {
64
+ return value;
65
+ }
66
+ }
67
+ try {
68
+ return JSON.stringify(value || {}, null, 2);
69
+ } catch {
70
+ return String(value || "");
71
+ }
72
+ };
73
+
74
+ export const buildWebhookDebugMessage = ({
75
+ hookName = "",
76
+ webhook = null,
77
+ request = null,
78
+ }) => {
79
+ const hookPath =
80
+ String(webhook?.path || "").trim() ||
81
+ (hookName ? `/hooks/${hookName}` : "/hooks/unknown");
82
+ const gatewayStatus =
83
+ request?.gatewayStatus == null ? "n/a" : String(request.gatewayStatus);
84
+ return [
85
+ "Investigate this failed webhook request and share findings before fixing anything.",
86
+ "Reply with your diagnosis first, including the likely root cause, any relevant risks, and what you would change if I approve a fix.",
87
+ "",
88
+ `Webhook: ${hookPath}`,
89
+ `Request ID: ${String(request?.id || "unknown")}`,
90
+ `Time: ${String(request?.createdAt || "unknown")}`,
91
+ `Method: ${String(request?.method || "unknown")}`,
92
+ `Source IP: ${String(request?.sourceIp || "unknown")}`,
93
+ `Gateway status: ${gatewayStatus}`,
94
+ `Transform path: ${String(webhook?.transformPath || "unknown")}`,
95
+ `Payload truncated: ${request?.payloadTruncated ? "yes" : "no"}`,
96
+ "",
97
+ "Headers:",
98
+ jsonPretty(request?.headers),
99
+ "",
100
+ "Payload:",
101
+ jsonPretty(request?.payload),
102
+ "",
103
+ "Gateway response:",
104
+ jsonPretty(request?.gatewayBody),
105
+ ].join("\n");
106
+ };
@@ -0,0 +1,148 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { createWebhook } from "../../lib/api.js";
5
+ import { ActionButton } from "../action-button.js";
6
+ import { PageHeader } from "../page-header.js";
7
+ import { showToast } from "../toast.js";
8
+ import { CreateWebhookModal } from "./create-webhook-modal/index.js";
9
+ import { kNamePattern } from "./helpers.js";
10
+ import { WebhookDetail } from "./webhook-detail/index.js";
11
+ import { WebhookList } from "./webhook-list/index.js";
12
+
13
+ const html = htm.bind(h);
14
+
15
+ export const Webhooks = ({
16
+ selectedHookName = "",
17
+ onSelectHook = () => {},
18
+ onBackToList = () => {},
19
+ onRestartRequired = () => {},
20
+ onOpenFile = () => {},
21
+ }) => {
22
+ const [isCreating, setIsCreating] = useState(false);
23
+ const [newName, setNewName] = useState("");
24
+ const [createMode, setCreateMode] = useState("webhook");
25
+ const [creating, setCreating] = useState(false);
26
+
27
+ const canCreate = useMemo(() => {
28
+ const name = String(newName || "")
29
+ .trim()
30
+ .toLowerCase();
31
+ return kNamePattern.test(name);
32
+ }, [newName]);
33
+
34
+ const handleCreate = useCallback(
35
+ async (destination = null, mode = "webhook") => {
36
+ const candidateName = String(newName || "")
37
+ .trim()
38
+ .toLowerCase();
39
+ if (!kNamePattern.test(candidateName)) {
40
+ showToast(
41
+ "Name must be lowercase letters, numbers, and hyphens",
42
+ "error",
43
+ );
44
+ return;
45
+ }
46
+ if (creating) return;
47
+ setCreating(true);
48
+ try {
49
+ const data = await createWebhook(candidateName, {
50
+ destination,
51
+ oauthCallback: mode === "oauth",
52
+ });
53
+ setIsCreating(false);
54
+ setNewName("");
55
+ setCreateMode("webhook");
56
+ onSelectHook(candidateName);
57
+ if (data.restartRequired) onRestartRequired(true);
58
+ if (mode === "oauth" && data?.webhook?.oauthCallbackUrl) {
59
+ showToast("Webhook + OAuth callback created", "success");
60
+ } else {
61
+ showToast("Webhook created", "success");
62
+ }
63
+ if (data.syncWarning) {
64
+ showToast(`Created, but git-sync failed: ${data.syncWarning}`, "warning");
65
+ }
66
+ } catch (err) {
67
+ showToast(err.message || "Could not create webhook", "error");
68
+ } finally {
69
+ setCreating(false);
70
+ }
71
+ },
72
+ [creating, newName, onRestartRequired, onSelectHook],
73
+ );
74
+
75
+ return html`
76
+ <div class="space-y-4">
77
+ <${PageHeader}
78
+ title="Webhooks"
79
+ leading=${selectedHookName
80
+ ? html`
81
+ <button
82
+ class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 transition-colors"
83
+ onclick=${onBackToList}
84
+ >
85
+ <svg
86
+ width="16"
87
+ height="16"
88
+ viewBox="0 0 16 16"
89
+ fill="currentColor"
90
+ >
91
+ <path
92
+ d="M10.354 3.354a.5.5 0 00-.708-.708l-5 5a.5.5 0 000 .708l5 5a.5.5 0 00.708-.708L5.707 8l4.647-4.646z"
93
+ />
94
+ </svg>
95
+ Back
96
+ </button>
97
+ `
98
+ : null}
99
+ actions=${selectedHookName
100
+ ? null
101
+ : html`
102
+ <${ActionButton}
103
+ onClick=${() => {
104
+ setCreateMode("webhook");
105
+ setIsCreating((open) => !open);
106
+ }}
107
+ tone="secondary"
108
+ size="sm"
109
+ idleLabel="Create new"
110
+ className="px-3 py-1.5"
111
+ />
112
+ `}
113
+ />
114
+
115
+ ${selectedHookName
116
+ ? html`
117
+ <${WebhookDetail}
118
+ selectedHookName=${selectedHookName}
119
+ onBackToList=${onBackToList}
120
+ onRestartRequired=${onRestartRequired}
121
+ onOpenFile=${onOpenFile}
122
+ />
123
+ `
124
+ : html`
125
+ <${WebhookList}
126
+ onSelectHook=${(name) => {
127
+ onSelectHook(name);
128
+ }}
129
+ />
130
+ `}
131
+
132
+ <${CreateWebhookModal}
133
+ visible=${isCreating && !selectedHookName}
134
+ name=${newName}
135
+ mode=${createMode}
136
+ onModeChange=${setCreateMode}
137
+ onNameChange=${setNewName}
138
+ canCreate=${canCreate}
139
+ creating=${creating}
140
+ onCreate=${handleCreate}
141
+ onClose=${() => {
142
+ setIsCreating(false);
143
+ setCreateMode("webhook");
144
+ }}
145
+ />
146
+ </div>
147
+ `;
148
+ };