@nastechai/agent 0.16.0 → 0.17.0
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/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
Check,
|
|
5
|
+
CheckCircle2,
|
|
6
|
+
ExternalLink,
|
|
7
|
+
PlugZap,
|
|
8
|
+
QrCode,
|
|
9
|
+
Radio,
|
|
10
|
+
RotateCw,
|
|
11
|
+
Save,
|
|
12
|
+
Settings2,
|
|
13
|
+
WifiOff,
|
|
14
|
+
X,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import * as QRCode from "qrcode";
|
|
17
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
18
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
19
|
+
import { Card, CardContent } from "@nastechai/ui/ui/components/card";
|
|
20
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
21
|
+
import { Label } from "@nastechai/ui/ui/components/label";
|
|
22
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
23
|
+
import { Switch } from "@nastechai/ui/ui/components/switch";
|
|
24
|
+
import { Toast } from "@nastechai/ui/ui/components/toast";
|
|
25
|
+
import { useToast } from "@nastechai/ui/hooks/use-toast";
|
|
26
|
+
import { api } from "@/lib/api";
|
|
27
|
+
import type {
|
|
28
|
+
MessagingPlatform,
|
|
29
|
+
MessagingPlatformEnvVar,
|
|
30
|
+
MessagingPlatformUpdate,
|
|
31
|
+
TelegramOnboardingStartResponse,
|
|
32
|
+
} from "@/lib/api";
|
|
33
|
+
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
34
|
+
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
35
|
+
import { cn, themedBody } from "@/lib/utils";
|
|
36
|
+
|
|
37
|
+
// State → badge mapping. The backend emits a small, fixed vocabulary plus
|
|
38
|
+
// whatever the live gateway runtime reports (connected/disconnected/fatal).
|
|
39
|
+
const STATE_BADGE: Record<
|
|
40
|
+
string,
|
|
41
|
+
{ tone: "success" | "warning" | "destructive" | "secondary" | "outline"; label: string }
|
|
42
|
+
> = {
|
|
43
|
+
connected: { tone: "success", label: "Connected" },
|
|
44
|
+
pending_restart: { tone: "warning", label: "Restart to apply" },
|
|
45
|
+
gateway_stopped: { tone: "warning", label: "Gateway stopped" },
|
|
46
|
+
disconnected: { tone: "warning", label: "Disconnected" },
|
|
47
|
+
not_configured: { tone: "outline", label: "Not configured" },
|
|
48
|
+
disabled: { tone: "secondary", label: "Disabled" },
|
|
49
|
+
fatal: { tone: "destructive", label: "Error" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function stateBadge(state: string) {
|
|
53
|
+
return STATE_BADGE[state] ?? { tone: "outline" as const, label: state };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TELEGRAM_USER_ID_RE = /^\d+$/;
|
|
57
|
+
|
|
58
|
+
function formatExpiry(expiresAt: string): string {
|
|
59
|
+
const ms = Date.parse(expiresAt) - Date.now();
|
|
60
|
+
if (!Number.isFinite(ms) || ms <= 0) return "expired";
|
|
61
|
+
const seconds = Math.ceil(ms / 1000);
|
|
62
|
+
const minutes = Math.floor(seconds / 60);
|
|
63
|
+
const rest = seconds % 60;
|
|
64
|
+
return `${minutes}:${rest.toString().padStart(2, "0")}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTerminalTelegramOnboardingError(error: unknown): boolean {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function ChannelsPage() {
|
|
73
|
+
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
|
|
74
|
+
const [loading, setLoading] = useState(true);
|
|
75
|
+
const { toast, showToast } = useToast();
|
|
76
|
+
const { setEnd } = usePageHeader();
|
|
77
|
+
|
|
78
|
+
// Config modal state
|
|
79
|
+
const [editing, setEditing] = useState<MessagingPlatform | null>(null);
|
|
80
|
+
const [draftEnv, setDraftEnv] = useState<Record<string, string>>({});
|
|
81
|
+
const [saving, setSaving] = useState(false);
|
|
82
|
+
const closeEdit = useCallback(() => setEditing(null), []);
|
|
83
|
+
const editModalRef = useModalBehavior({ open: editing !== null, onClose: closeEdit });
|
|
84
|
+
|
|
85
|
+
// Per-card busy + restart-needed tracking
|
|
86
|
+
const [togglingId, setTogglingId] = useState<string | null>(null);
|
|
87
|
+
const [testingId, setTestingId] = useState<string | null>(null);
|
|
88
|
+
const [restartNeeded, setRestartNeeded] = useState(false);
|
|
89
|
+
const [restarting, setRestarting] = useState(false);
|
|
90
|
+
|
|
91
|
+
const gatewayRunning = platforms.length > 0 && platforms[0].gateway_running;
|
|
92
|
+
|
|
93
|
+
const load = useCallback(() => {
|
|
94
|
+
return api
|
|
95
|
+
.getMessagingPlatforms()
|
|
96
|
+
.then((res) => setPlatforms(res.platforms))
|
|
97
|
+
.catch((e) => showToast(`Error: ${e}`, "error"));
|
|
98
|
+
}, [showToast]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
load().finally(() => setLoading(false));
|
|
102
|
+
}, [load]);
|
|
103
|
+
|
|
104
|
+
const openConfig = (platform: MessagingPlatform) => {
|
|
105
|
+
const initial: Record<string, string> = {};
|
|
106
|
+
platform.env_vars.forEach((v) => {
|
|
107
|
+
initial[v.key] = "";
|
|
108
|
+
});
|
|
109
|
+
setDraftEnv(initial);
|
|
110
|
+
setEditing(platform);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleSave = async () => {
|
|
114
|
+
if (!editing) return;
|
|
115
|
+
// Only send fields the user actually filled in — leaving a field blank
|
|
116
|
+
// preserves the existing value rather than clobbering it.
|
|
117
|
+
const env: Record<string, string> = {};
|
|
118
|
+
Object.entries(draftEnv).forEach(([k, v]) => {
|
|
119
|
+
if (v.trim()) env[k] = v.trim();
|
|
120
|
+
});
|
|
121
|
+
if (Object.keys(env).length === 0) {
|
|
122
|
+
showToast("Nothing to save — fill in at least one field.", "error");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const missing = editing.env_vars.filter(
|
|
126
|
+
(v) => v.required && !v.is_set && !env[v.key],
|
|
127
|
+
);
|
|
128
|
+
if (missing.length > 0) {
|
|
129
|
+
showToast(`${missing[0].prompt || missing[0].key} is required`, "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setSaving(true);
|
|
133
|
+
try {
|
|
134
|
+
const body: MessagingPlatformUpdate = { env, enabled: true };
|
|
135
|
+
await api.updateMessagingPlatform(editing.id, body);
|
|
136
|
+
showToast(`${editing.name} saved`, "success");
|
|
137
|
+
setEditing(null);
|
|
138
|
+
setRestartNeeded(true);
|
|
139
|
+
await load();
|
|
140
|
+
} catch (e) {
|
|
141
|
+
showToast(`Failed to save: ${e}`, "error");
|
|
142
|
+
} finally {
|
|
143
|
+
setSaving(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const handleToggle = async (platform: MessagingPlatform) => {
|
|
148
|
+
const next = !platform.enabled;
|
|
149
|
+
setTogglingId(platform.id);
|
|
150
|
+
try {
|
|
151
|
+
await api.updateMessagingPlatform(platform.id, { enabled: next });
|
|
152
|
+
setPlatforms((prev) =>
|
|
153
|
+
prev.map((p) =>
|
|
154
|
+
p.id === platform.id
|
|
155
|
+
? { ...p, enabled: next, state: next ? "pending_restart" : "disabled" }
|
|
156
|
+
: p,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
setRestartNeeded(true);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
showToast(`Error: ${e}`, "error");
|
|
162
|
+
} finally {
|
|
163
|
+
setTogglingId(null);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleTest = async (platform: MessagingPlatform) => {
|
|
168
|
+
setTestingId(platform.id);
|
|
169
|
+
try {
|
|
170
|
+
const res = await api.testMessagingPlatform(platform.id);
|
|
171
|
+
showToast(`${platform.name}: ${res.message}`, res.ok ? "success" : "error");
|
|
172
|
+
} catch (e) {
|
|
173
|
+
showToast(`Error: ${e}`, "error");
|
|
174
|
+
} finally {
|
|
175
|
+
setTestingId(null);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleRestart = async () => {
|
|
180
|
+
setRestarting(true);
|
|
181
|
+
try {
|
|
182
|
+
await api.restartGateway();
|
|
183
|
+
showToast("Gateway restarting…", "success");
|
|
184
|
+
setRestartNeeded(false);
|
|
185
|
+
// Give the gateway a moment to come up, then refresh status.
|
|
186
|
+
setTimeout(() => void load(), 4000);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
showToast(`Failed to restart: ${e}`, "error");
|
|
189
|
+
} finally {
|
|
190
|
+
setRestarting(false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
useLayoutEffect(() => {
|
|
195
|
+
setEnd(
|
|
196
|
+
<Button
|
|
197
|
+
className="uppercase"
|
|
198
|
+
size="sm"
|
|
199
|
+
onClick={handleRestart}
|
|
200
|
+
disabled={restarting}
|
|
201
|
+
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
|
|
202
|
+
>
|
|
203
|
+
{restarting ? "Restarting…" : "Restart gateway"}
|
|
204
|
+
</Button>,
|
|
205
|
+
);
|
|
206
|
+
return () => setEnd(null);
|
|
207
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
208
|
+
}, [setEnd, restarting]);
|
|
209
|
+
|
|
210
|
+
const configured = useMemo(
|
|
211
|
+
() => platforms.filter((p) => p.configured).length,
|
|
212
|
+
[platforms],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (loading) {
|
|
216
|
+
return (
|
|
217
|
+
<div className="flex items-center justify-center py-24">
|
|
218
|
+
<Spinner className="text-2xl text-primary" />
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className="flex flex-col gap-6">
|
|
225
|
+
<Toast toast={toast} />
|
|
226
|
+
|
|
227
|
+
{/* Restart banner */}
|
|
228
|
+
{restartNeeded && (
|
|
229
|
+
<Card className="border-warning/50">
|
|
230
|
+
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
231
|
+
<div className="flex items-center gap-2 text-sm">
|
|
232
|
+
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
|
|
233
|
+
<span>
|
|
234
|
+
Changes are saved. Restart the gateway for them to take effect.
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
<Button
|
|
238
|
+
size="sm"
|
|
239
|
+
className="uppercase shrink-0"
|
|
240
|
+
onClick={handleRestart}
|
|
241
|
+
disabled={restarting}
|
|
242
|
+
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
|
|
243
|
+
>
|
|
244
|
+
{restarting ? "Restarting…" : "Restart now"}
|
|
245
|
+
</Button>
|
|
246
|
+
</CardContent>
|
|
247
|
+
</Card>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{!gatewayRunning && !restartNeeded && (
|
|
251
|
+
<Card className="border-border">
|
|
252
|
+
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
|
253
|
+
<WifiOff className="h-4 w-4 shrink-0" />
|
|
254
|
+
<span>
|
|
255
|
+
The gateway is not running. Configure channels here, then start the
|
|
256
|
+
gateway with <code className="font-courier">nastech gateway start</code>{" "}
|
|
257
|
+
(or the Restart button above).
|
|
258
|
+
</span>
|
|
259
|
+
</CardContent>
|
|
260
|
+
</Card>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
<p className="text-xs text-muted-foreground">
|
|
264
|
+
{configured} of {platforms.length} channels configured. Credentials are
|
|
265
|
+
written to <code className="font-courier">~/.nastech/.env</code>; the
|
|
266
|
+
gateway connects each enabled channel on its next restart.
|
|
267
|
+
</p>
|
|
268
|
+
|
|
269
|
+
{/* Config modal */}
|
|
270
|
+
{editing && (
|
|
271
|
+
<div
|
|
272
|
+
ref={editModalRef}
|
|
273
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
274
|
+
onClick={(e) => e.target === e.currentTarget && setEditing(null)}
|
|
275
|
+
role="dialog"
|
|
276
|
+
aria-modal="true"
|
|
277
|
+
aria-labelledby="channel-config-title"
|
|
278
|
+
>
|
|
279
|
+
<div
|
|
280
|
+
className={cn(
|
|
281
|
+
themedBody,
|
|
282
|
+
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
|
|
283
|
+
)}
|
|
284
|
+
>
|
|
285
|
+
<Button
|
|
286
|
+
ghost
|
|
287
|
+
size="icon"
|
|
288
|
+
onClick={() => setEditing(null)}
|
|
289
|
+
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
290
|
+
aria-label="Close"
|
|
291
|
+
>
|
|
292
|
+
<X />
|
|
293
|
+
</Button>
|
|
294
|
+
|
|
295
|
+
<header className="p-5 pb-3 border-b border-border">
|
|
296
|
+
<h2
|
|
297
|
+
id="channel-config-title"
|
|
298
|
+
className="font-mondwest text-display text-base tracking-wider"
|
|
299
|
+
>
|
|
300
|
+
Configure {editing.name}
|
|
301
|
+
</h2>
|
|
302
|
+
{editing.docs_url && (
|
|
303
|
+
<a
|
|
304
|
+
href={editing.docs_url}
|
|
305
|
+
target="_blank"
|
|
306
|
+
rel="noopener noreferrer"
|
|
307
|
+
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
|
308
|
+
>
|
|
309
|
+
Setup guide <ExternalLink className="h-3 w-3" />
|
|
310
|
+
</a>
|
|
311
|
+
)}
|
|
312
|
+
</header>
|
|
313
|
+
|
|
314
|
+
<div className="p-5 grid gap-4 overflow-y-auto">
|
|
315
|
+
<p className="text-xs text-muted-foreground">
|
|
316
|
+
{editing.description}
|
|
317
|
+
</p>
|
|
318
|
+
{editing.env_vars.map((field: MessagingPlatformEnvVar) => (
|
|
319
|
+
<div className="grid gap-1.5" key={field.key}>
|
|
320
|
+
<Label htmlFor={`field-${field.key}`}>
|
|
321
|
+
{field.prompt || field.key}
|
|
322
|
+
{field.required ? " *" : ""}
|
|
323
|
+
</Label>
|
|
324
|
+
{field.description && (
|
|
325
|
+
<span className="text-xs text-muted-foreground">
|
|
326
|
+
{field.description}
|
|
327
|
+
</span>
|
|
328
|
+
)}
|
|
329
|
+
<Input
|
|
330
|
+
id={`field-${field.key}`}
|
|
331
|
+
type={field.is_password ? "password" : "text"}
|
|
332
|
+
placeholder={
|
|
333
|
+
field.is_set
|
|
334
|
+
? field.redacted_value || "•••••• (set — leave blank to keep)"
|
|
335
|
+
: field.key
|
|
336
|
+
}
|
|
337
|
+
value={draftEnv[field.key] ?? ""}
|
|
338
|
+
onChange={(e) =>
|
|
339
|
+
setDraftEnv((prev) => ({ ...prev, [field.key]: e.target.value }))
|
|
340
|
+
}
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
343
|
+
))}
|
|
344
|
+
|
|
345
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
346
|
+
<Button ghost size="sm" onClick={() => setEditing(null)}>
|
|
347
|
+
Cancel
|
|
348
|
+
</Button>
|
|
349
|
+
<Button
|
|
350
|
+
className="uppercase"
|
|
351
|
+
size="sm"
|
|
352
|
+
onClick={handleSave}
|
|
353
|
+
disabled={saving}
|
|
354
|
+
prefix={saving ? <Spinner /> : undefined}
|
|
355
|
+
>
|
|
356
|
+
{saving ? "Saving…" : "Save & enable"}
|
|
357
|
+
</Button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{/* Platform list */}
|
|
365
|
+
<div className="grid gap-3">
|
|
366
|
+
{platforms.map((platform) => {
|
|
367
|
+
const badge = stateBadge(platform.state ?? "");
|
|
368
|
+
const busy = togglingId === platform.id;
|
|
369
|
+
const StateIcon =
|
|
370
|
+
platform.state === "connected"
|
|
371
|
+
? CheckCircle2
|
|
372
|
+
: platform.state === "fatal"
|
|
373
|
+
? AlertTriangle
|
|
374
|
+
: Radio;
|
|
375
|
+
return (
|
|
376
|
+
<Card key={platform.id} className="border-border">
|
|
377
|
+
<CardContent className="flex flex-col gap-4 p-4">
|
|
378
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
379
|
+
<div className="flex items-start gap-3 min-w-0">
|
|
380
|
+
<StateIcon
|
|
381
|
+
className={cn(
|
|
382
|
+
"h-5 w-5 shrink-0 mt-0.5",
|
|
383
|
+
platform.state === "connected"
|
|
384
|
+
? "text-success"
|
|
385
|
+
: platform.state === "fatal"
|
|
386
|
+
? "text-destructive"
|
|
387
|
+
: "text-muted-foreground",
|
|
388
|
+
)}
|
|
389
|
+
/>
|
|
390
|
+
<div className="flex flex-col gap-0.5 min-w-0">
|
|
391
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
392
|
+
<span className="font-mondwest normal-case text-sm font-medium">
|
|
393
|
+
{platform.name}
|
|
394
|
+
</span>
|
|
395
|
+
<Badge tone={badge.tone}>{badge.label}</Badge>
|
|
396
|
+
</div>
|
|
397
|
+
<span className="text-xs text-muted-foreground">
|
|
398
|
+
{platform.description}
|
|
399
|
+
</span>
|
|
400
|
+
{platform.error_message && (
|
|
401
|
+
<span className="text-xs text-destructive">
|
|
402
|
+
{platform.error_message}
|
|
403
|
+
</span>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<div className="flex items-center gap-2 shrink-0 self-start sm:self-center">
|
|
409
|
+
<div className="flex items-center gap-1.5">
|
|
410
|
+
{busy ? (
|
|
411
|
+
<Spinner className="text-sm" />
|
|
412
|
+
) : (
|
|
413
|
+
<Switch
|
|
414
|
+
checked={platform.enabled}
|
|
415
|
+
onCheckedChange={() => void handleToggle(platform)}
|
|
416
|
+
aria-label={`Enable ${platform.name}`}
|
|
417
|
+
/>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
<Button
|
|
421
|
+
ghost
|
|
422
|
+
size="sm"
|
|
423
|
+
onClick={() => handleTest(platform)}
|
|
424
|
+
disabled={testingId === platform.id}
|
|
425
|
+
prefix={
|
|
426
|
+
testingId === platform.id ? (
|
|
427
|
+
<Spinner />
|
|
428
|
+
) : (
|
|
429
|
+
<PlugZap className="h-4 w-4" />
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
>
|
|
433
|
+
Test
|
|
434
|
+
</Button>
|
|
435
|
+
<Button
|
|
436
|
+
size="sm"
|
|
437
|
+
className="uppercase"
|
|
438
|
+
onClick={() => openConfig(platform)}
|
|
439
|
+
prefix={<Settings2 className="h-4 w-4" />}
|
|
440
|
+
>
|
|
441
|
+
Configure
|
|
442
|
+
</Button>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
{platform.id === "telegram" && (
|
|
446
|
+
<TelegramOnboardingPanel
|
|
447
|
+
onChanged={load}
|
|
448
|
+
onRestartNeeded={() => setRestartNeeded(true)}
|
|
449
|
+
platform={platform}
|
|
450
|
+
setRestartNeeded={setRestartNeeded}
|
|
451
|
+
showToast={showToast}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
</CardContent>
|
|
455
|
+
</Card>
|
|
456
|
+
);
|
|
457
|
+
})}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function TelegramOnboardingPanel({
|
|
464
|
+
onChanged,
|
|
465
|
+
onRestartNeeded,
|
|
466
|
+
platform,
|
|
467
|
+
setRestartNeeded,
|
|
468
|
+
showToast,
|
|
469
|
+
}: {
|
|
470
|
+
onChanged: () => Promise<void>;
|
|
471
|
+
onRestartNeeded: () => void;
|
|
472
|
+
platform: MessagingPlatform;
|
|
473
|
+
setRestartNeeded: (needed: boolean) => void;
|
|
474
|
+
showToast: (message: string, type: "success" | "error") => void;
|
|
475
|
+
}) {
|
|
476
|
+
const [setup, setSetup] = useState<TelegramOnboardingStartResponse | null>(
|
|
477
|
+
null,
|
|
478
|
+
);
|
|
479
|
+
const [qrDataUrl, setQrDataUrl] = useState("");
|
|
480
|
+
const [phase, setPhase] = useState<
|
|
481
|
+
"idle" | "starting" | "waiting" | "ready" | "applying"
|
|
482
|
+
>("idle");
|
|
483
|
+
const [botUsername, setBotUsername] = useState<string | null>(null);
|
|
484
|
+
const [allowedIds, setAllowedIds] = useState<string[]>([]);
|
|
485
|
+
const [detectedOwnerId, setDetectedOwnerId] = useState<string | null>(null);
|
|
486
|
+
const [newAllowedId, setNewAllowedId] = useState("");
|
|
487
|
+
const [error, setError] = useState("");
|
|
488
|
+
const [tick, setTick] = useState(0);
|
|
489
|
+
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
if (!setup || phase !== "waiting") return;
|
|
492
|
+
let cancelled = false;
|
|
493
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
494
|
+
|
|
495
|
+
const poll = async () => {
|
|
496
|
+
try {
|
|
497
|
+
const status = await api.getTelegramOnboardingStatus(setup.pairing_id);
|
|
498
|
+
if (cancelled) return;
|
|
499
|
+
if (status.status === "ready") {
|
|
500
|
+
setPhase("ready");
|
|
501
|
+
setBotUsername(status.bot_username ?? null);
|
|
502
|
+
setError("");
|
|
503
|
+
if (
|
|
504
|
+
status.owner_user_id &&
|
|
505
|
+
TELEGRAM_USER_ID_RE.test(status.owner_user_id)
|
|
506
|
+
) {
|
|
507
|
+
setDetectedOwnerId(status.owner_user_id);
|
|
508
|
+
setAllowedIds([status.owner_user_id]);
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
setError("");
|
|
513
|
+
timeout = setTimeout(poll, 2000);
|
|
514
|
+
} catch (pollError) {
|
|
515
|
+
if (cancelled) return;
|
|
516
|
+
|
|
517
|
+
const expiresAt = Date.parse(setup.expires_at);
|
|
518
|
+
const expired =
|
|
519
|
+
Number.isFinite(expiresAt) && Date.now() >= expiresAt;
|
|
520
|
+
if (isTerminalTelegramOnboardingError(pollError) || expired) {
|
|
521
|
+
setSetup(null);
|
|
522
|
+
setQrDataUrl("");
|
|
523
|
+
setPhase("idle");
|
|
524
|
+
setError("Telegram pairing expired. Start a new QR setup to try again.");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
setError(`Still waiting for Telegram. Retrying after: ${pollError}`);
|
|
529
|
+
timeout = setTimeout(poll, 2000);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
timeout = setTimeout(poll, 1200);
|
|
534
|
+
return () => {
|
|
535
|
+
cancelled = true;
|
|
536
|
+
if (timeout) clearTimeout(timeout);
|
|
537
|
+
};
|
|
538
|
+
}, [phase, setup]);
|
|
539
|
+
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
if (!setup) return;
|
|
542
|
+
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
|
543
|
+
return () => clearInterval(timer);
|
|
544
|
+
}, [setup]);
|
|
545
|
+
|
|
546
|
+
const resetSetup = () => {
|
|
547
|
+
setSetup(null);
|
|
548
|
+
setQrDataUrl("");
|
|
549
|
+
setPhase("idle");
|
|
550
|
+
setBotUsername(null);
|
|
551
|
+
setAllowedIds([]);
|
|
552
|
+
setDetectedOwnerId(null);
|
|
553
|
+
setNewAllowedId("");
|
|
554
|
+
setError("");
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const start = async () => {
|
|
558
|
+
setPhase("starting");
|
|
559
|
+
setError("");
|
|
560
|
+
setBotUsername(null);
|
|
561
|
+
setAllowedIds([]);
|
|
562
|
+
setDetectedOwnerId(null);
|
|
563
|
+
setNewAllowedId("");
|
|
564
|
+
try {
|
|
565
|
+
const res = await api.startTelegramOnboarding({ bot_name: "NasTech Agent" });
|
|
566
|
+
const dataUrl = await QRCode.toDataURL(res.qr_payload, {
|
|
567
|
+
errorCorrectionLevel: "M",
|
|
568
|
+
margin: 1,
|
|
569
|
+
width: 224,
|
|
570
|
+
});
|
|
571
|
+
setSetup(res);
|
|
572
|
+
setQrDataUrl(dataUrl);
|
|
573
|
+
setPhase("waiting");
|
|
574
|
+
} catch (startError) {
|
|
575
|
+
setPhase("idle");
|
|
576
|
+
setError(String(startError));
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const cancel = async () => {
|
|
581
|
+
if (setup) {
|
|
582
|
+
try {
|
|
583
|
+
await api.cancelTelegramOnboarding(setup.pairing_id);
|
|
584
|
+
} catch {
|
|
585
|
+
/* local cleanup still wins */
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
resetSetup();
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const addAllowedId = () => {
|
|
592
|
+
const trimmed = newAllowedId.trim();
|
|
593
|
+
if (!TELEGRAM_USER_ID_RE.test(trimmed)) {
|
|
594
|
+
setError("Allowed Telegram user IDs must be numeric.");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
setError("");
|
|
598
|
+
setAllowedIds((ids) => (ids.includes(trimmed) ? ids : [...ids, trimmed]));
|
|
599
|
+
setNewAllowedId("");
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const apply = async () => {
|
|
603
|
+
if (!setup) return;
|
|
604
|
+
if (allowedIds.length === 0) {
|
|
605
|
+
setError("Add at least one allowed Telegram user ID.");
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
setPhase("applying");
|
|
609
|
+
setError("");
|
|
610
|
+
try {
|
|
611
|
+
await api.applyTelegramOnboarding(setup.pairing_id, {
|
|
612
|
+
allowed_user_ids: allowedIds,
|
|
613
|
+
});
|
|
614
|
+
resetSetup();
|
|
615
|
+
showToast("Telegram saved", "success");
|
|
616
|
+
try {
|
|
617
|
+
await api.restartGateway();
|
|
618
|
+
showToast("Gateway restarting…", "success");
|
|
619
|
+
setRestartNeeded(false);
|
|
620
|
+
setTimeout(() => void onChanged(), 4000);
|
|
621
|
+
} catch (restartError) {
|
|
622
|
+
onRestartNeeded();
|
|
623
|
+
showToast(`Telegram saved; restart failed: ${restartError}`, "error");
|
|
624
|
+
}
|
|
625
|
+
await onChanged();
|
|
626
|
+
} catch (applyError) {
|
|
627
|
+
setPhase("ready");
|
|
628
|
+
setError(String(applyError));
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const expiresIn = useMemo(
|
|
633
|
+
() => (setup ? formatExpiry(setup.expires_at) : ""),
|
|
634
|
+
// tick keeps the memo fresh without recalculating on every render branch.
|
|
635
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
636
|
+
[setup, tick],
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<div className="rounded-sm border border-border bg-background/35 p-4">
|
|
641
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
642
|
+
<Button
|
|
643
|
+
size="sm"
|
|
644
|
+
className="uppercase"
|
|
645
|
+
onClick={() => void start()}
|
|
646
|
+
disabled={phase === "starting" || phase === "waiting" || phase === "applying"}
|
|
647
|
+
prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />}
|
|
648
|
+
>
|
|
649
|
+
{phase === "starting" ? "Starting…" : "Set up with QR"}
|
|
650
|
+
</Button>
|
|
651
|
+
{platform.configured && (
|
|
652
|
+
<span className="text-xs text-muted-foreground">
|
|
653
|
+
Existing Telegram credentials are configured.
|
|
654
|
+
</span>
|
|
655
|
+
)}
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
{error && (
|
|
659
|
+
<div className="mt-3 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
660
|
+
{error}
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
{setup && qrDataUrl && (
|
|
665
|
+
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]">
|
|
666
|
+
<div className="grid gap-3">
|
|
667
|
+
{(phase === "ready" || phase === "applying") && (
|
|
668
|
+
<div className="grid gap-3">
|
|
669
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
670
|
+
<Badge tone="success">Ready</Badge>
|
|
671
|
+
{botUsername && (
|
|
672
|
+
<span className="font-courier text-sm text-muted-foreground">
|
|
673
|
+
@{botUsername}
|
|
674
|
+
</span>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
|
|
678
|
+
<div className="grid gap-2">
|
|
679
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
680
|
+
<span className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
|
|
681
|
+
Allowed users
|
|
682
|
+
</span>
|
|
683
|
+
{detectedOwnerId && allowedIds.includes(detectedOwnerId) && (
|
|
684
|
+
<Badge tone="success">owner detected</Badge>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
<div className="flex flex-wrap gap-2">
|
|
688
|
+
{allowedIds.map((id) => (
|
|
689
|
+
<button
|
|
690
|
+
key={id}
|
|
691
|
+
type="button"
|
|
692
|
+
className="inline-flex items-center gap-1 border border-border px-2 py-1 font-courier text-xs text-foreground hover:border-destructive/50"
|
|
693
|
+
onClick={() =>
|
|
694
|
+
setAllowedIds((ids) =>
|
|
695
|
+
ids.filter((existing) => existing !== id),
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
>
|
|
699
|
+
{id}
|
|
700
|
+
<X className="h-3 w-3" />
|
|
701
|
+
</button>
|
|
702
|
+
))}
|
|
703
|
+
{allowedIds.length === 0 && (
|
|
704
|
+
<span className="text-sm text-muted-foreground">
|
|
705
|
+
Add at least one Telegram user ID.
|
|
706
|
+
</span>
|
|
707
|
+
)}
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
|
|
711
|
+
<div className="flex flex-col gap-2 sm:flex-row">
|
|
712
|
+
<Input
|
|
713
|
+
value={newAllowedId}
|
|
714
|
+
onChange={(event) => setNewAllowedId(event.target.value)}
|
|
715
|
+
placeholder="Telegram user ID"
|
|
716
|
+
className="font-courier"
|
|
717
|
+
/>
|
|
718
|
+
<Button size="sm" outlined onClick={addAllowedId} prefix={<Check />}>
|
|
719
|
+
Add
|
|
720
|
+
</Button>
|
|
721
|
+
</div>
|
|
722
|
+
|
|
723
|
+
<div className="flex flex-wrap gap-2">
|
|
724
|
+
<Button
|
|
725
|
+
size="sm"
|
|
726
|
+
className="uppercase"
|
|
727
|
+
onClick={() => void apply()}
|
|
728
|
+
disabled={phase === "applying"}
|
|
729
|
+
prefix={phase === "applying" ? <Spinner /> : <Save className="h-4 w-4" />}
|
|
730
|
+
>
|
|
731
|
+
{phase === "applying" ? "Saving…" : "Save and restart"}
|
|
732
|
+
</Button>
|
|
733
|
+
<Button size="sm" ghost onClick={() => void cancel()}>
|
|
734
|
+
Cancel
|
|
735
|
+
</Button>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
)}
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
<div className="flex flex-col items-center justify-center gap-3">
|
|
742
|
+
<img
|
|
743
|
+
src={qrDataUrl}
|
|
744
|
+
alt="Telegram setup QR code"
|
|
745
|
+
className="h-56 w-56 bg-white p-2"
|
|
746
|
+
/>
|
|
747
|
+
<div className="flex flex-wrap items-center justify-center gap-2 text-sm">
|
|
748
|
+
<Badge tone={expiresIn === "expired" ? "destructive" : "outline"}>
|
|
749
|
+
{expiresIn}
|
|
750
|
+
</Badge>
|
|
751
|
+
{phase === "waiting" && <Badge tone="warning">waiting</Badge>}
|
|
752
|
+
</div>
|
|
753
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
754
|
+
<a
|
|
755
|
+
href={setup.deep_link}
|
|
756
|
+
target="_blank"
|
|
757
|
+
rel="noreferrer"
|
|
758
|
+
className="inline-flex h-8 items-center gap-1 border border-border px-3 text-xs uppercase text-foreground hover:border-foreground/40"
|
|
759
|
+
>
|
|
760
|
+
<ExternalLink className="h-4 w-4" />
|
|
761
|
+
Open Telegram
|
|
762
|
+
</a>
|
|
763
|
+
<Button size="sm" ghost onClick={() => void cancel()}>
|
|
764
|
+
Cancel
|
|
765
|
+
</Button>
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
769
|
+
)}
|
|
770
|
+
</div>
|
|
771
|
+
);
|
|
772
|
+
}
|