botschat 0.1.4 → 0.1.7
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/README.md +64 -24
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +7 -2
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +162 -42
- package/packages/api/src/index.ts +132 -13
- package/packages/api/src/routes/auth.ts +127 -30
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +72 -24
- package/packages/api/src/routes/upload.ts +12 -8
- package/packages/api/src/utils/auth.ts +212 -43
- package/packages/api/src/utils/id.ts +30 -14
- package/packages/api/src/utils/rate-limit.ts +73 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +151 -9
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +14 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +4 -3
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
- package/packages/web/dist/botschat-icon.svg +4 -0
- package/packages/web/dist/index.html +23 -3
- package/packages/web/dist/manifest.json +24 -0
- package/packages/web/dist/sw.js +40 -0
- package/packages/web/index.html +21 -1
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +286 -103
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +67 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +477 -0
- package/packages/web/src/components/CronDetail.tsx +475 -235
- package/packages/web/src/components/CronSidebar.tsx +1 -1
- package/packages/web/src/components/DebugLogPanel.tsx +116 -3
- package/packages/web/src/components/E2ESettings.tsx +122 -0
- package/packages/web/src/components/IconRail.tsx +56 -27
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +143 -104
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +159 -21
- package/packages/web/src/components/ResizeHandle.tsx +34 -0
- package/packages/web/src/components/Sidebar.tsx +1 -1
- package/packages/web/src/components/TaskBar.tsx +2 -2
- package/packages/web/src/components/ThreadPanel.tsx +2 -5
- package/packages/web/src/e2e.ts +133 -0
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +12 -0
- package/packages/web/src/store.ts +16 -8
- package/packages/web/src/ws.ts +78 -4
- package/scripts/dev.sh +16 -16
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- package/wrangler.toml +3 -1
- package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
package/packages/web/src/api.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dlog } from "./debug-log";
|
|
|
5
5
|
const API_BASE = "/api";
|
|
6
6
|
|
|
7
7
|
let _token: string | null = localStorage.getItem("botschat_token");
|
|
8
|
+
let _refreshToken: string | null = localStorage.getItem("botschat_refresh_token");
|
|
8
9
|
|
|
9
10
|
export function setToken(token: string | null) {
|
|
10
11
|
_token = token;
|
|
@@ -12,10 +13,39 @@ export function setToken(token: string | null) {
|
|
|
12
13
|
else localStorage.removeItem("botschat_token");
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export function setRefreshToken(token: string | null) {
|
|
17
|
+
_refreshToken = token;
|
|
18
|
+
if (token) localStorage.setItem("botschat_refresh_token", token);
|
|
19
|
+
else localStorage.removeItem("botschat_refresh_token");
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export function getToken(): string | null {
|
|
16
23
|
return _token;
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
export function getRefreshToken(): string | null {
|
|
27
|
+
return _refreshToken;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Try to refresh the access token using the refresh token. */
|
|
31
|
+
async function tryRefreshAccessToken(): Promise<boolean> {
|
|
32
|
+
if (!_refreshToken) return false;
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
body: JSON.stringify({ refreshToken: _refreshToken }),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) return false;
|
|
40
|
+
const data = await res.json() as { token: string };
|
|
41
|
+
setToken(data.token);
|
|
42
|
+
dlog.info("API", "Access token refreshed successfully");
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
19
49
|
async function request<T>(
|
|
20
50
|
method: string,
|
|
21
51
|
path: string,
|
|
@@ -44,6 +74,27 @@ async function request<T>(
|
|
|
44
74
|
throw err;
|
|
45
75
|
}
|
|
46
76
|
|
|
77
|
+
// Auto-refresh on 401 (expired access token)
|
|
78
|
+
if (res.status === 401 && _refreshToken && !path.includes("/auth/refresh")) {
|
|
79
|
+
const refreshed = await tryRefreshAccessToken();
|
|
80
|
+
if (refreshed) {
|
|
81
|
+
// Retry the original request with the new token
|
|
82
|
+
headers["Authorization"] = `Bearer ${_token}`;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(`${API_BASE}${path}`, {
|
|
85
|
+
method,
|
|
86
|
+
headers,
|
|
87
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
88
|
+
cache: "no-store",
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const ms = Math.round(performance.now() - t0);
|
|
92
|
+
dlog.error("API", `✗ ${tag} — network error on retry (${ms}ms)`, String(err));
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
47
98
|
const ms = Math.round(performance.now() - t0);
|
|
48
99
|
|
|
49
100
|
if (!res.ok) {
|
|
@@ -59,11 +110,19 @@ async function request<T>(
|
|
|
59
110
|
}
|
|
60
111
|
|
|
61
112
|
// ---- Auth ----
|
|
62
|
-
export type AuthResponse = { id: string; email: string; token: string; displayName?: string };
|
|
113
|
+
export type AuthResponse = { id: string; email: string; token: string; refreshToken?: string; displayName?: string };
|
|
63
114
|
|
|
64
115
|
export type UserSettings = { defaultModel?: string };
|
|
65
116
|
|
|
117
|
+
export type AuthConfig = {
|
|
118
|
+
emailEnabled: boolean;
|
|
119
|
+
googleEnabled: boolean;
|
|
120
|
+
githubEnabled: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
66
123
|
export const authApi = {
|
|
124
|
+
/** Fetch server-side auth configuration (which methods are available). */
|
|
125
|
+
config: () => request<AuthConfig>("GET", "/auth/config"),
|
|
67
126
|
register: (email: string, password: string, displayName?: string) =>
|
|
68
127
|
request<AuthResponse>("POST", "/auth/register", { email, password, displayName }),
|
|
69
128
|
login: (email: string, password: string) =>
|
|
@@ -168,6 +227,8 @@ export type TaskScanEntry = {
|
|
|
168
227
|
instructions: string;
|
|
169
228
|
model: string;
|
|
170
229
|
enabled: boolean;
|
|
230
|
+
encrypted?: boolean;
|
|
231
|
+
iv?: string;
|
|
171
232
|
};
|
|
172
233
|
|
|
173
234
|
export const tasksApi = {
|
|
@@ -175,7 +236,7 @@ export const tasksApi = {
|
|
|
175
236
|
request<{ tasks: Task[] }>("GET", `/channels/${channelId}/tasks`),
|
|
176
237
|
listAll: (kind: "background" | "adhoc" = "background") =>
|
|
177
238
|
request<{ tasks: TaskWithChannel[] }>("GET", `/tasks?kind=${kind}`),
|
|
178
|
-
/** Fetch OpenClaw-owned fields (schedule/instructions/model)
|
|
239
|
+
/** Fetch OpenClaw-owned fields (schedule/instructions/model) from plugin via DO (live task.scan.request, no cache). */
|
|
179
240
|
scanData: () =>
|
|
180
241
|
request<{ tasks: TaskScanEntry[] }>("GET", "/task-scan"),
|
|
181
242
|
create: (channelId: string, data: { name: string; kind: "background" | "adhoc"; schedule?: string; instructions?: string }) =>
|
|
@@ -199,6 +260,7 @@ export type Job = {
|
|
|
199
260
|
durationMs: number | null;
|
|
200
261
|
summary: string;
|
|
201
262
|
time: string;
|
|
263
|
+
encrypted?: boolean;
|
|
202
264
|
};
|
|
203
265
|
|
|
204
266
|
export const jobsApi = {
|
|
@@ -217,6 +279,7 @@ export type MessageRecord = {
|
|
|
217
279
|
mediaUrl?: string;
|
|
218
280
|
a2ui?: string;
|
|
219
281
|
threadId?: string;
|
|
282
|
+
encrypted?: boolean;
|
|
220
283
|
};
|
|
221
284
|
|
|
222
285
|
export const messagesApi = {
|
|
@@ -230,7 +293,8 @@ export const messagesApi = {
|
|
|
230
293
|
// ---- Pairing Tokens ----
|
|
231
294
|
export type PairingToken = {
|
|
232
295
|
id: string;
|
|
233
|
-
token
|
|
296
|
+
// Full token is no longer returned by the GET endpoint (security).
|
|
297
|
+
// Only `tokenPreview` (masked) is available after creation.
|
|
234
298
|
tokenPreview: string;
|
|
235
299
|
label: string | null;
|
|
236
300
|
lastConnectedAt: number | null;
|
|
@@ -474,7 +474,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
474
474
|
|
|
475
475
|
if (!sessionKey) {
|
|
476
476
|
return (
|
|
477
|
-
<div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
|
|
477
|
+
<div className="flex-1 h-full flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
|
|
478
478
|
<div className="text-center">
|
|
479
479
|
<svg className="w-16 h-16 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1} style={{ color: "var(--text-muted)" }}>
|
|
480
480
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
@@ -493,7 +493,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
493
493
|
return (
|
|
494
494
|
<div
|
|
495
495
|
ref={dropZoneRef}
|
|
496
|
-
className="flex-1 flex flex-col min-w-0 relative"
|
|
496
|
+
className="flex-1 flex flex-col min-w-0 h-full relative"
|
|
497
497
|
style={{ background: "var(--bg-surface)" }}
|
|
498
498
|
onDragEnter={handleDragEnter}
|
|
499
499
|
onDragLeave={handleDragLeave}
|
|
@@ -519,24 +519,24 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
519
519
|
|
|
520
520
|
{/* Channel header */}
|
|
521
521
|
<div
|
|
522
|
-
className="flex items-center justify-between px-5"
|
|
522
|
+
className="flex items-center justify-between px-3 sm:px-5 gap-2 flex-shrink-0"
|
|
523
523
|
style={{
|
|
524
524
|
height: 44,
|
|
525
525
|
borderBottom: "1px solid var(--border)",
|
|
526
526
|
}}
|
|
527
527
|
>
|
|
528
|
-
<div className="flex items-center gap-2">
|
|
529
|
-
<span className="text-h1" style={{ color: "var(--text-primary)" }}>
|
|
528
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
529
|
+
<span className="text-h1 truncate" style={{ color: "var(--text-primary)" }}>
|
|
530
530
|
# {channelName}
|
|
531
531
|
</span>
|
|
532
532
|
{selectedAgent && !selectedAgent.isDefault && (
|
|
533
|
-
<span className="text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
533
|
+
<span className="text-caption hidden sm:inline flex-shrink-0" style={{ color: "var(--text-secondary)" }}>
|
|
534
534
|
— custom channel
|
|
535
535
|
</span>
|
|
536
536
|
)}
|
|
537
537
|
</div>
|
|
538
|
-
<div className="flex items-center gap-1.5">
|
|
539
|
-
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
|
|
538
|
+
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
539
|
+
<svg className="w-3.5 h-3.5 hidden sm:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
|
|
540
540
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
|
541
541
|
</svg>
|
|
542
542
|
<ModelSelect
|
|
@@ -554,7 +554,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
554
554
|
{showSessionTabs && <SessionTabs channelId={channelId} />}
|
|
555
555
|
|
|
556
556
|
{/* Messages – flat-row layout */}
|
|
557
|
-
<div className="flex-1 overflow-y-auto">
|
|
557
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
558
558
|
{state.messages.length === 0 && (
|
|
559
559
|
<div className="py-12 px-5 text-center">
|
|
560
560
|
<p className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
@@ -582,7 +582,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
|
582
582
|
</div>
|
|
583
583
|
|
|
584
584
|
{/* Composer (section 5.6) */}
|
|
585
|
-
<div className="px-5 pb-4 pt-2">
|
|
585
|
+
<div className="flex-shrink-0 px-3 sm:px-5 pb-3 sm:pb-4 pt-2">
|
|
586
586
|
{/* Skill buttons — sorted by recency-weighted score */}
|
|
587
587
|
<div className="flex items-center gap-1.5 pb-1.5 overflow-x-auto no-scrollbar">
|
|
588
588
|
{sortedSkills.map((skill) => {
|
|
@@ -744,7 +744,7 @@ function MessageRow({
|
|
|
744
744
|
|
|
745
745
|
return (
|
|
746
746
|
<div
|
|
747
|
-
className="group relative px-5 hover:bg-[--bg-hover] transition-colors"
|
|
747
|
+
className="group relative px-3 sm:px-5 hover:bg-[--bg-hover] transition-colors"
|
|
748
748
|
style={{ paddingTop: grouped ? 2 : 8, paddingBottom: 2 }}
|
|
749
749
|
>
|
|
750
750
|
<div className="flex gap-2 max-w-message">
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { pairingApi, setupApi, type PairingToken } from "../api";
|
|
3
|
+
import { useAppState } from "../store";
|
|
4
|
+
import { dlog } from "../debug-log";
|
|
5
|
+
import { E2eService } from "../e2e";
|
|
6
|
+
|
|
7
|
+
/** Clipboard copy button with feedback */
|
|
8
|
+
function CopyButton({ text }: { text: string }) {
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
|
|
11
|
+
const handleCopy = async () => {
|
|
12
|
+
try {
|
|
13
|
+
await navigator.clipboard.writeText(text);
|
|
14
|
+
setCopied(true);
|
|
15
|
+
setTimeout(() => setCopied(false), 2000);
|
|
16
|
+
} catch {
|
|
17
|
+
const ta = document.createElement("textarea");
|
|
18
|
+
ta.value = text;
|
|
19
|
+
document.body.appendChild(ta);
|
|
20
|
+
ta.select();
|
|
21
|
+
document.execCommand("copy");
|
|
22
|
+
document.body.removeChild(ta);
|
|
23
|
+
setCopied(true);
|
|
24
|
+
setTimeout(() => setCopied(false), 2000);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
onClick={handleCopy}
|
|
31
|
+
className="shrink-0 px-2.5 py-1 text-tiny font-medium rounded-sm transition-colors"
|
|
32
|
+
style={{
|
|
33
|
+
background: copied ? "var(--accent-green)" : "var(--bg-hover)",
|
|
34
|
+
color: copied ? "#fff" : "var(--text-secondary)",
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{copied ? "Copied!" : "Copy"}
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Code block with copy button */
|
|
43
|
+
function CodeBlock({ code }: { code: string }) {
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className="flex items-start gap-2 rounded-md px-3 py-2.5"
|
|
47
|
+
style={{ background: "var(--code-bg)", border: "1px solid var(--border)" }}
|
|
48
|
+
>
|
|
49
|
+
<pre
|
|
50
|
+
className="flex-1 text-caption font-mono overflow-x-auto whitespace-pre-wrap break-all"
|
|
51
|
+
style={{ color: "var(--text-primary)" }}
|
|
52
|
+
>
|
|
53
|
+
{code}
|
|
54
|
+
</pre>
|
|
55
|
+
<CopyButton text={code} />
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Relative time from a unix timestamp */
|
|
61
|
+
function timeAgo(unixTs: number): string {
|
|
62
|
+
const now = Date.now() / 1000;
|
|
63
|
+
const diff = now - unixTs;
|
|
64
|
+
if (diff < 60) return "Just now";
|
|
65
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
66
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
67
|
+
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
|
68
|
+
return new Date(unixTs * 1000).toLocaleDateString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ConnectionSettings() {
|
|
72
|
+
const state = useAppState();
|
|
73
|
+
|
|
74
|
+
const [tokens, setTokens] = useState<PairingToken[]>([]);
|
|
75
|
+
const [loadingTokens, setLoadingTokens] = useState(true);
|
|
76
|
+
|
|
77
|
+
const [cloudUrl, setCloudUrl] = useState<string>(
|
|
78
|
+
typeof window !== "undefined" ? window.location.origin : "https://console.botschat.app",
|
|
79
|
+
);
|
|
80
|
+
const [cloudUrlLoopback, setCloudUrlLoopback] = useState(false);
|
|
81
|
+
const [cloudUrlHint, setCloudUrlHint] = useState<string | undefined>();
|
|
82
|
+
const [editingUrl, setEditingUrl] = useState(false);
|
|
83
|
+
|
|
84
|
+
const [showCreateToken, setShowCreateToken] = useState(false);
|
|
85
|
+
const [newTokenLabel, setNewTokenLabel] = useState("");
|
|
86
|
+
const [creatingToken, setCreatingToken] = useState(false);
|
|
87
|
+
/** Token value of a freshly created token (only shown once, for the user to copy). */
|
|
88
|
+
const [freshToken, setFreshToken] = useState<{ id: string; token: string } | null>(null);
|
|
89
|
+
|
|
90
|
+
// Fetch recommended cloudUrl from backend
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
|
|
94
|
+
setupApi
|
|
95
|
+
.cloudUrl()
|
|
96
|
+
.then((data) => {
|
|
97
|
+
if (cancelled) return;
|
|
98
|
+
setCloudUrl(data.cloudUrl);
|
|
99
|
+
setCloudUrlLoopback(data.isLoopback);
|
|
100
|
+
setCloudUrlHint(data.hint);
|
|
101
|
+
})
|
|
102
|
+
.catch((err) => {
|
|
103
|
+
dlog.warn("ConnectionSettings", `Failed to fetch cloudUrl: ${err}`);
|
|
104
|
+
const host = window.location.hostname;
|
|
105
|
+
const loopback = host === "localhost" || host.startsWith("127.");
|
|
106
|
+
setCloudUrlLoopback(loopback);
|
|
107
|
+
if (loopback) {
|
|
108
|
+
setCloudUrlHint(
|
|
109
|
+
"This URL (localhost) only works on this machine. If your OpenClaw is on a different host, replace with its LAN IP.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
cancelled = true;
|
|
116
|
+
};
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
// Fetch pairing tokens
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
let cancelled = false;
|
|
122
|
+
setLoadingTokens(true);
|
|
123
|
+
|
|
124
|
+
pairingApi
|
|
125
|
+
.list()
|
|
126
|
+
.then(({ tokens: list }) => {
|
|
127
|
+
if (!cancelled) setTokens(list);
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
dlog.error("ConnectionSettings", `Failed to list tokens: ${err}`);
|
|
131
|
+
})
|
|
132
|
+
.finally(() => {
|
|
133
|
+
if (!cancelled) setLoadingTokens(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
};
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const handleCreateToken = useCallback(async () => {
|
|
142
|
+
setCreatingToken(true);
|
|
143
|
+
try {
|
|
144
|
+
const result = await pairingApi.create(newTokenLabel.trim() || undefined);
|
|
145
|
+
setFreshToken({ id: result.id, token: result.token });
|
|
146
|
+
// Refresh token list
|
|
147
|
+
const { tokens: refreshed } = await pairingApi.list();
|
|
148
|
+
setTokens(refreshed);
|
|
149
|
+
setNewTokenLabel("");
|
|
150
|
+
setShowCreateToken(false);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
dlog.error("ConnectionSettings", `Failed to create token: ${err}`);
|
|
153
|
+
} finally {
|
|
154
|
+
setCreatingToken(false);
|
|
155
|
+
}
|
|
156
|
+
}, [newTokenLabel]);
|
|
157
|
+
|
|
158
|
+
const handleRevokeToken = useCallback(async (tokenId: string) => {
|
|
159
|
+
try {
|
|
160
|
+
await pairingApi.delete(tokenId);
|
|
161
|
+
setTokens((prev) => prev.filter((t) => t.id !== tokenId));
|
|
162
|
+
if (freshToken?.id === tokenId) setFreshToken(null);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
dlog.error("ConnectionSettings", `Failed to revoke token: ${err}`);
|
|
165
|
+
}
|
|
166
|
+
}, [freshToken]);
|
|
167
|
+
|
|
168
|
+
// The token to use in the setup command: only the freshly created token (shown once).
|
|
169
|
+
// We never display full token values from the GET list (security: they are masked).
|
|
170
|
+
const commandToken = freshToken?.token ?? null;
|
|
171
|
+
|
|
172
|
+
const e2ePwd = E2eService.getPassword();
|
|
173
|
+
const setupCommand = commandToken
|
|
174
|
+
? `openclaw plugins install @botschat/botschat && \\
|
|
175
|
+
openclaw config set channels.botschat.cloudUrl ${cloudUrl} && \\
|
|
176
|
+
openclaw config set channels.botschat.pairingToken ${commandToken} && \\${e2ePwd ? `\nopenclaw config set channels.botschat.e2ePassword "${e2ePwd}" && \\` : ""}
|
|
177
|
+
openclaw config set channels.botschat.enabled true && \\
|
|
178
|
+
openclaw gateway restart`
|
|
179
|
+
: null;
|
|
180
|
+
|
|
181
|
+
const isConnected = state.openclawConnected;
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="space-y-5">
|
|
185
|
+
{/* ---- Connection Status ---- */}
|
|
186
|
+
<div>
|
|
187
|
+
<label
|
|
188
|
+
className="block text-caption font-bold mb-1.5"
|
|
189
|
+
style={{ color: "var(--text-secondary)" }}
|
|
190
|
+
>
|
|
191
|
+
OpenClaw Status
|
|
192
|
+
</label>
|
|
193
|
+
<div
|
|
194
|
+
className="flex items-center gap-3 rounded-md px-4 py-3"
|
|
195
|
+
style={{
|
|
196
|
+
background: isConnected ? "rgba(43, 172, 118, 0.1)" : "rgba(232, 162, 48, 0.1)",
|
|
197
|
+
border: `1px solid ${isConnected ? "rgba(43, 172, 118, 0.3)" : "rgba(232, 162, 48, 0.3)"}`,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<span
|
|
201
|
+
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
202
|
+
style={{ background: isConnected ? "var(--accent-green)" : "var(--accent-yellow)" }}
|
|
203
|
+
/>
|
|
204
|
+
<span
|
|
205
|
+
className="text-caption font-medium"
|
|
206
|
+
style={{ color: isConnected ? "var(--accent-green)" : "var(--accent-yellow)" }}
|
|
207
|
+
>
|
|
208
|
+
{isConnected ? "Connected to OpenClaw" : "Not connected"}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* ---- Setup Command ---- */}
|
|
214
|
+
<div>
|
|
215
|
+
<label
|
|
216
|
+
className="block text-caption font-bold mb-1.5"
|
|
217
|
+
style={{ color: "var(--text-secondary)" }}
|
|
218
|
+
>
|
|
219
|
+
Setup Command
|
|
220
|
+
</label>
|
|
221
|
+
<p className="text-tiny mb-2" style={{ color: "var(--text-muted)" }}>
|
|
222
|
+
Run this on your OpenClaw machine to install and connect the plugin.
|
|
223
|
+
</p>
|
|
224
|
+
|
|
225
|
+
{/* Loopback URL warning */}
|
|
226
|
+
{cloudUrlLoopback && (
|
|
227
|
+
<div
|
|
228
|
+
className="flex items-start gap-2 rounded-md px-3 py-2 mb-2 text-tiny"
|
|
229
|
+
style={{
|
|
230
|
+
background: "rgba(232, 162, 48, 0.1)",
|
|
231
|
+
border: "1px solid rgba(232, 162, 48, 0.25)",
|
|
232
|
+
color: "var(--accent-yellow)",
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
|
236
|
+
<path
|
|
237
|
+
fillRule="evenodd"
|
|
238
|
+
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.168 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
|
239
|
+
clipRule="evenodd"
|
|
240
|
+
/>
|
|
241
|
+
</svg>
|
|
242
|
+
<span>
|
|
243
|
+
{cloudUrlHint || "localhost URL may not be reachable from other machines."}{" "}
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setEditingUrl(true)}
|
|
246
|
+
className="underline font-medium hover:brightness-110"
|
|
247
|
+
style={{ color: "var(--text-link)" }}
|
|
248
|
+
>
|
|
249
|
+
Change URL
|
|
250
|
+
</button>
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{/* Editable cloud URL inline */}
|
|
256
|
+
{editingUrl && (
|
|
257
|
+
<div className="flex items-center gap-2 mb-2">
|
|
258
|
+
<label
|
|
259
|
+
className="text-tiny font-bold shrink-0"
|
|
260
|
+
style={{ color: "var(--text-secondary)" }}
|
|
261
|
+
>
|
|
262
|
+
Cloud URL:
|
|
263
|
+
</label>
|
|
264
|
+
<input
|
|
265
|
+
type="text"
|
|
266
|
+
value={cloudUrl}
|
|
267
|
+
onChange={(e) => {
|
|
268
|
+
setCloudUrl(e.target.value.replace(/\/+$/, ""));
|
|
269
|
+
setCloudUrlLoopback(false);
|
|
270
|
+
}}
|
|
271
|
+
className="flex-1 px-2.5 py-1 rounded-sm text-tiny font-mono"
|
|
272
|
+
style={{
|
|
273
|
+
background: "var(--code-bg)",
|
|
274
|
+
border: "1px solid var(--border)",
|
|
275
|
+
color: "var(--text-primary)",
|
|
276
|
+
outline: "none",
|
|
277
|
+
}}
|
|
278
|
+
placeholder="http://192.168.x.x:8787"
|
|
279
|
+
autoFocus
|
|
280
|
+
/>
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => setEditingUrl(false)}
|
|
283
|
+
className="px-2.5 py-1 text-tiny font-medium rounded-sm"
|
|
284
|
+
style={{ background: "var(--bg-active)", color: "#fff" }}
|
|
285
|
+
>
|
|
286
|
+
Done
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{loadingTokens ? (
|
|
292
|
+
<div
|
|
293
|
+
className="rounded-md px-3 py-2.5 animate-pulse"
|
|
294
|
+
style={{ background: "var(--code-bg)", height: "64px" }}
|
|
295
|
+
/>
|
|
296
|
+
) : setupCommand ? (
|
|
297
|
+
<CodeBlock code={setupCommand} />
|
|
298
|
+
) : (
|
|
299
|
+
<div
|
|
300
|
+
className="rounded-md px-4 py-3 text-caption"
|
|
301
|
+
style={{
|
|
302
|
+
background: "var(--code-bg)",
|
|
303
|
+
border: "1px solid var(--border)",
|
|
304
|
+
color: "var(--text-muted)",
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
{tokens.length > 0
|
|
308
|
+
? "Create a new pairing token below to generate the setup command. (Token values are only shown once at creation time.)"
|
|
309
|
+
: "No pairing tokens available. Create one below to generate the setup command."}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{/* Cloud URL (non-editable display, with Edit button) */}
|
|
314
|
+
{!editingUrl && (
|
|
315
|
+
<div className="flex items-center gap-2 mt-2">
|
|
316
|
+
<span className="text-tiny" style={{ color: "var(--text-muted)" }}>
|
|
317
|
+
Cloud URL:
|
|
318
|
+
</span>
|
|
319
|
+
<code className="text-tiny font-mono" style={{ color: "var(--text-secondary)" }}>
|
|
320
|
+
{cloudUrl}
|
|
321
|
+
</code>
|
|
322
|
+
<button
|
|
323
|
+
onClick={() => setEditingUrl(true)}
|
|
324
|
+
className="text-tiny hover:underline"
|
|
325
|
+
style={{ color: "var(--text-link)" }}
|
|
326
|
+
>
|
|
327
|
+
Edit
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* ---- Pairing Tokens ---- */}
|
|
334
|
+
<div>
|
|
335
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
336
|
+
<label
|
|
337
|
+
className="text-caption font-bold"
|
|
338
|
+
style={{ color: "var(--text-secondary)" }}
|
|
339
|
+
>
|
|
340
|
+
Pairing Tokens
|
|
341
|
+
</label>
|
|
342
|
+
<button
|
|
343
|
+
onClick={() => setShowCreateToken(!showCreateToken)}
|
|
344
|
+
className="text-tiny font-medium hover:underline"
|
|
345
|
+
style={{ color: "var(--text-link)" }}
|
|
346
|
+
>
|
|
347
|
+
{showCreateToken ? "Cancel" : "+ New Token"}
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Create token form */}
|
|
352
|
+
{showCreateToken && (
|
|
353
|
+
<div
|
|
354
|
+
className="flex items-center gap-2 rounded-md px-3 py-2.5 mb-2"
|
|
355
|
+
style={{ background: "var(--code-bg)", border: "1px solid var(--border)" }}
|
|
356
|
+
>
|
|
357
|
+
<input
|
|
358
|
+
type="text"
|
|
359
|
+
value={newTokenLabel}
|
|
360
|
+
onChange={(e) => setNewTokenLabel(e.target.value)}
|
|
361
|
+
placeholder="Token label (optional)"
|
|
362
|
+
className="flex-1 px-2 py-1 rounded-sm text-tiny"
|
|
363
|
+
style={{
|
|
364
|
+
background: "var(--bg-surface)",
|
|
365
|
+
border: "1px solid var(--border)",
|
|
366
|
+
color: "var(--text-primary)",
|
|
367
|
+
outline: "none",
|
|
368
|
+
}}
|
|
369
|
+
onKeyDown={(e) => {
|
|
370
|
+
if (e.key === "Enter") handleCreateToken();
|
|
371
|
+
}}
|
|
372
|
+
autoFocus
|
|
373
|
+
/>
|
|
374
|
+
<button
|
|
375
|
+
onClick={handleCreateToken}
|
|
376
|
+
disabled={creatingToken}
|
|
377
|
+
className="px-3 py-1 text-tiny font-medium rounded-sm text-white"
|
|
378
|
+
style={{
|
|
379
|
+
background: creatingToken ? "var(--text-muted)" : "var(--bg-active)",
|
|
380
|
+
}}
|
|
381
|
+
>
|
|
382
|
+
{creatingToken ? "Creating..." : "Create"}
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{/* Freshly created token (highlight) */}
|
|
388
|
+
{freshToken && (
|
|
389
|
+
<div
|
|
390
|
+
className="rounded-md px-3 py-2.5 mb-2"
|
|
391
|
+
style={{
|
|
392
|
+
background: "rgba(43, 172, 118, 0.08)",
|
|
393
|
+
border: "1px solid rgba(43, 172, 118, 0.3)",
|
|
394
|
+
}}
|
|
395
|
+
>
|
|
396
|
+
<div className="flex items-center justify-between mb-1">
|
|
397
|
+
<span
|
|
398
|
+
className="text-tiny font-bold"
|
|
399
|
+
style={{ color: "var(--accent-green)" }}
|
|
400
|
+
>
|
|
401
|
+
New token created — copy it now (only shown once)
|
|
402
|
+
</span>
|
|
403
|
+
<button
|
|
404
|
+
onClick={() => setFreshToken(null)}
|
|
405
|
+
className="text-tiny"
|
|
406
|
+
style={{ color: "var(--text-muted)" }}
|
|
407
|
+
>
|
|
408
|
+
Dismiss
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
<div className="flex items-center gap-2">
|
|
412
|
+
<code
|
|
413
|
+
className="flex-1 text-tiny font-mono break-all"
|
|
414
|
+
style={{ color: "var(--text-primary)" }}
|
|
415
|
+
>
|
|
416
|
+
{freshToken.token}
|
|
417
|
+
</code>
|
|
418
|
+
<CopyButton text={freshToken.token} />
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Token list */}
|
|
424
|
+
{loadingTokens ? (
|
|
425
|
+
<div
|
|
426
|
+
className="rounded-md px-3 py-4 animate-pulse"
|
|
427
|
+
style={{ background: "var(--code-bg)" }}
|
|
428
|
+
/>
|
|
429
|
+
) : tokens.length === 0 ? (
|
|
430
|
+
<p className="text-tiny py-2" style={{ color: "var(--text-muted)" }}>
|
|
431
|
+
No active pairing tokens. Create one to connect your OpenClaw agent.
|
|
432
|
+
</p>
|
|
433
|
+
) : (
|
|
434
|
+
<div className="space-y-1">
|
|
435
|
+
{tokens.map((t) => (
|
|
436
|
+
<div
|
|
437
|
+
key={t.id}
|
|
438
|
+
className="flex items-center gap-2 rounded-md px-3 py-2"
|
|
439
|
+
style={{ background: "var(--code-bg)", border: "1px solid var(--border)" }}
|
|
440
|
+
>
|
|
441
|
+
<code
|
|
442
|
+
className="text-tiny font-mono shrink-0"
|
|
443
|
+
style={{ color: "var(--text-primary)" }}
|
|
444
|
+
>
|
|
445
|
+
{t.tokenPreview}
|
|
446
|
+
</code>
|
|
447
|
+
{t.label && (
|
|
448
|
+
<span
|
|
449
|
+
className="text-tiny px-1.5 py-0.5 rounded"
|
|
450
|
+
style={{
|
|
451
|
+
background: "var(--bg-hover)",
|
|
452
|
+
color: "var(--text-secondary)",
|
|
453
|
+
}}
|
|
454
|
+
>
|
|
455
|
+
{t.label}
|
|
456
|
+
</span>
|
|
457
|
+
)}
|
|
458
|
+
<span className="flex-1" />
|
|
459
|
+
<span className="text-tiny" style={{ color: "var(--text-muted)" }}>
|
|
460
|
+
{t.lastConnectedAt ? timeAgo(t.lastConnectedAt) : "Never connected"}
|
|
461
|
+
</span>
|
|
462
|
+
<button
|
|
463
|
+
onClick={() => handleRevokeToken(t.id)}
|
|
464
|
+
className="text-tiny font-medium hover:underline shrink-0"
|
|
465
|
+
style={{ color: "var(--accent-red)" }}
|
|
466
|
+
title="Revoke this token"
|
|
467
|
+
>
|
|
468
|
+
Revoke
|
|
469
|
+
</button>
|
|
470
|
+
</div>
|
|
471
|
+
))}
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|