@castlekit/castle 0.1.5 → 0.3.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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
package/src/cli/onboarding.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
writeConfig,
|
|
15
15
|
type CastleConfig,
|
|
16
16
|
} from "../lib/config.js";
|
|
17
|
+
// Device identity is handled by gateway-connection.ts for the persistent connection.
|
|
18
|
+
// The onboarding wizard uses simple token-only auth for agent discovery.
|
|
17
19
|
|
|
18
20
|
// Read version from package.json at the project root
|
|
19
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -56,9 +58,15 @@ interface DiscoveredAgent {
|
|
|
56
58
|
|
|
57
59
|
/**
|
|
58
60
|
* Connect to Gateway and discover agents via agents.list.
|
|
61
|
+
* Supports both port-based (local) and full URL (remote) connections.
|
|
59
62
|
* Returns the list of agents or an empty array on failure.
|
|
60
63
|
*/
|
|
61
|
-
async function discoverAgents(
|
|
64
|
+
async function discoverAgents(
|
|
65
|
+
portOrUrl: number | string,
|
|
66
|
+
token: string | null
|
|
67
|
+
): Promise<DiscoveredAgent[]> {
|
|
68
|
+
const wsUrl = typeof portOrUrl === "string" ? portOrUrl : `ws://127.0.0.1:${portOrUrl}`;
|
|
69
|
+
|
|
62
70
|
return new Promise((resolve) => {
|
|
63
71
|
const timeout = setTimeout(() => {
|
|
64
72
|
try { ws.close(); } catch { /* ignore */ }
|
|
@@ -67,7 +75,7 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
|
|
|
67
75
|
|
|
68
76
|
let ws: WebSocket;
|
|
69
77
|
try {
|
|
70
|
-
ws = new WebSocket(
|
|
78
|
+
ws = new WebSocket(wsUrl);
|
|
71
79
|
} catch {
|
|
72
80
|
clearTimeout(timeout);
|
|
73
81
|
resolve([]);
|
|
@@ -81,6 +89,8 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
|
|
|
81
89
|
|
|
82
90
|
ws.on("open", () => {
|
|
83
91
|
// Send connect handshake
|
|
92
|
+
// NOTE: No device identity here — discoverAgents is just for listing agents
|
|
93
|
+
// during setup. Device auth happens in gateway-connection.ts for the real connection.
|
|
84
94
|
const connectId = randomUUID();
|
|
85
95
|
const connectFrame = {
|
|
86
96
|
type: "req",
|
|
@@ -149,6 +159,137 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
|
|
|
149
159
|
});
|
|
150
160
|
}
|
|
151
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Test a Gateway connection. Returns true if connection succeeds.
|
|
164
|
+
*/
|
|
165
|
+
async function testConnection(
|
|
166
|
+
portOrUrl: number | string,
|
|
167
|
+
token: string | null
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
const wsUrl = typeof portOrUrl === "string" ? portOrUrl : `ws://127.0.0.1:${portOrUrl}`;
|
|
170
|
+
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
174
|
+
resolve(false);
|
|
175
|
+
}, 5000);
|
|
176
|
+
|
|
177
|
+
let ws: WebSocket;
|
|
178
|
+
try {
|
|
179
|
+
ws = new WebSocket(wsUrl);
|
|
180
|
+
} catch {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
resolve(false);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ws.on("error", () => {
|
|
187
|
+
clearTimeout(timeout);
|
|
188
|
+
resolve(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
ws.on("open", () => {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
ws.close();
|
|
194
|
+
resolve(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Prompt for manual Gateway configuration (remote or local).
|
|
201
|
+
* Returns connection details or null if cancelled.
|
|
202
|
+
*/
|
|
203
|
+
async function promptManualGateway(): Promise<{
|
|
204
|
+
port: number;
|
|
205
|
+
token: string | null;
|
|
206
|
+
gatewayUrl?: string;
|
|
207
|
+
isRemote: boolean;
|
|
208
|
+
} | null> {
|
|
209
|
+
const locationType = await p.select({
|
|
210
|
+
message: "Where is your OpenClaw Gateway?",
|
|
211
|
+
options: [
|
|
212
|
+
{
|
|
213
|
+
value: "local",
|
|
214
|
+
label: "Local machine",
|
|
215
|
+
hint: "Running on this device (127.0.0.1)",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
value: "remote",
|
|
219
|
+
label: "Remote / Tailscale",
|
|
220
|
+
hint: "Running on another machine",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (p.isCancel(locationType)) return null;
|
|
226
|
+
|
|
227
|
+
let port = 18789;
|
|
228
|
+
let gatewayUrl: string | undefined;
|
|
229
|
+
const isRemote = locationType === "remote";
|
|
230
|
+
|
|
231
|
+
if (isRemote) {
|
|
232
|
+
const urlInput = await p.text({
|
|
233
|
+
message: "Gateway WebSocket URL",
|
|
234
|
+
placeholder: "ws://192.168.1.50:18789",
|
|
235
|
+
validate(value: string | undefined) {
|
|
236
|
+
if (!value?.trim()) return "URL is required";
|
|
237
|
+
if (!value.startsWith("ws://") && !value.startsWith("wss://")) {
|
|
238
|
+
return "URL must start with ws:// or wss://";
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (p.isCancel(urlInput)) return null;
|
|
244
|
+
gatewayUrl = urlInput as string;
|
|
245
|
+
|
|
246
|
+
// Extract port from URL for config compatibility
|
|
247
|
+
try {
|
|
248
|
+
const parsed = new URL(gatewayUrl);
|
|
249
|
+
port = parseInt(parsed.port, 10) || 18789;
|
|
250
|
+
} catch {
|
|
251
|
+
port = 18789;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Test the connection
|
|
255
|
+
const testSpinner = p.spinner();
|
|
256
|
+
testSpinner.start("Testing connection...");
|
|
257
|
+
const ok = await testConnection(gatewayUrl, null);
|
|
258
|
+
if (ok) {
|
|
259
|
+
testSpinner.stop(`\x1b[92m✔\x1b[0m Gateway reachable`);
|
|
260
|
+
} else {
|
|
261
|
+
testSpinner.stop(pc.dim("Could not reach Gateway — it may not be running yet"));
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
const gatewayPort = await p.text({
|
|
265
|
+
message: "OpenClaw Gateway port",
|
|
266
|
+
initialValue: "18789",
|
|
267
|
+
validate(value: string | undefined) {
|
|
268
|
+
const num = parseInt(value || "0", 10);
|
|
269
|
+
if (isNaN(num) || num < 1 || num > 65535) {
|
|
270
|
+
return "Please enter a valid port number (1-65535)";
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (p.isCancel(gatewayPort)) return null;
|
|
276
|
+
port = parseInt(gatewayPort as string, 10);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Token entry
|
|
280
|
+
const tokenInput = await p.text({
|
|
281
|
+
message: "Gateway auth token",
|
|
282
|
+
placeholder: "Paste your token (or press Enter to skip)",
|
|
283
|
+
defaultValue: "",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (p.isCancel(tokenInput)) return null;
|
|
287
|
+
|
|
288
|
+
const token = (tokenInput as string) || null;
|
|
289
|
+
|
|
290
|
+
return { port, token, gatewayUrl, isRemote };
|
|
291
|
+
}
|
|
292
|
+
|
|
152
293
|
export async function runOnboarding(): Promise<void> {
|
|
153
294
|
|
|
154
295
|
p.intro(BLUE_BOLD("Castle Setup"));
|
|
@@ -253,47 +394,65 @@ export async function runOnboarding(): Promise<void> {
|
|
|
253
394
|
}
|
|
254
395
|
}
|
|
255
396
|
|
|
256
|
-
// Step 2:
|
|
397
|
+
// Step 2: Connection mode — auto-detect or manual entry
|
|
257
398
|
let port = readOpenClawPort() || 18789;
|
|
258
399
|
let token = readOpenClawToken();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
400
|
+
let gatewayUrl: string | undefined;
|
|
401
|
+
let isRemote = false;
|
|
402
|
+
|
|
403
|
+
// If we have auto-detected config, offer a choice
|
|
404
|
+
const hasLocalConfig = !!readOpenClawPort() || isOpenClawInstalled();
|
|
405
|
+
|
|
406
|
+
if (hasLocalConfig && token) {
|
|
407
|
+
// Both auto-detect and manual are available
|
|
408
|
+
const connectionMode = await p.select({
|
|
409
|
+
message: "How would you like to connect?",
|
|
410
|
+
options: [
|
|
411
|
+
{
|
|
412
|
+
value: "auto",
|
|
413
|
+
label: `Auto-detected local Gateway ${pc.dim(`(port ${port})`)}`,
|
|
414
|
+
hint: "Recommended for local setups",
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
value: "manual",
|
|
418
|
+
label: "Enter Gateway details manually",
|
|
419
|
+
hint: "For remote, Tailscale, or custom setups",
|
|
420
|
+
},
|
|
421
|
+
],
|
|
270
422
|
});
|
|
271
423
|
|
|
272
|
-
if (p.isCancel(
|
|
424
|
+
if (p.isCancel(connectionMode)) {
|
|
273
425
|
p.cancel("Setup cancelled.");
|
|
274
426
|
process.exit(0);
|
|
275
427
|
}
|
|
276
428
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
429
|
+
if (connectionMode === "manual") {
|
|
430
|
+
const manualResult = await promptManualGateway();
|
|
431
|
+
if (!manualResult) {
|
|
432
|
+
p.cancel("Setup cancelled.");
|
|
433
|
+
process.exit(0);
|
|
434
|
+
}
|
|
435
|
+
port = manualResult.port;
|
|
436
|
+
token = manualResult.token;
|
|
437
|
+
gatewayUrl = manualResult.gatewayUrl;
|
|
438
|
+
isRemote = manualResult.isRemote;
|
|
439
|
+
}
|
|
440
|
+
} else if (!token) {
|
|
441
|
+
// No auto-detected token — fall through to manual entry
|
|
442
|
+
const manualResult = await promptManualGateway();
|
|
443
|
+
if (!manualResult) {
|
|
288
444
|
p.cancel("Setup cancelled.");
|
|
289
445
|
process.exit(0);
|
|
290
446
|
}
|
|
291
|
-
|
|
292
|
-
token =
|
|
447
|
+
port = manualResult.port;
|
|
448
|
+
token = manualResult.token;
|
|
449
|
+
gatewayUrl = manualResult.gatewayUrl;
|
|
450
|
+
isRemote = manualResult.isRemote;
|
|
293
451
|
}
|
|
294
452
|
|
|
295
|
-
// Step
|
|
296
|
-
const
|
|
453
|
+
// Step 3: Agent Discovery (use URL if remote, port if local)
|
|
454
|
+
const agentTarget = gatewayUrl || port;
|
|
455
|
+
const agents = await discoverAgents(agentTarget, token);
|
|
297
456
|
|
|
298
457
|
let primaryAgent: string;
|
|
299
458
|
|
|
@@ -341,6 +500,8 @@ export async function runOnboarding(): Promise<void> {
|
|
|
341
500
|
openclaw: {
|
|
342
501
|
gateway_port: port,
|
|
343
502
|
gateway_token: token || undefined,
|
|
503
|
+
gateway_url: gatewayUrl,
|
|
504
|
+
is_remote: isRemote || undefined,
|
|
344
505
|
primary_agent: primaryAgent,
|
|
345
506
|
},
|
|
346
507
|
server: {
|
|
@@ -417,6 +578,10 @@ export async function runOnboarding(): Promise<void> {
|
|
|
417
578
|
// Nothing on port or lsof not available
|
|
418
579
|
}
|
|
419
580
|
|
|
581
|
+
// Escape XML special characters for plist values
|
|
582
|
+
const xmlEscape = (s: string) =>
|
|
583
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
584
|
+
|
|
420
585
|
// Install as a persistent service (auto-start on login)
|
|
421
586
|
if (process.platform === "darwin") {
|
|
422
587
|
const plistDir = join(home(), "Library", "LaunchAgents");
|
|
@@ -430,14 +595,14 @@ export async function runOnboarding(): Promise<void> {
|
|
|
430
595
|
<string>com.castlekit.castle</string>
|
|
431
596
|
<key>ProgramArguments</key>
|
|
432
597
|
<array>
|
|
433
|
-
<string>${nodePath}</string>
|
|
434
|
-
<string>${nextBin}</string>
|
|
598
|
+
<string>${xmlEscape(nodePath)}</string>
|
|
599
|
+
<string>${xmlEscape(nextBin)}</string>
|
|
435
600
|
<string>start</string>
|
|
436
601
|
<string>-p</string>
|
|
437
|
-
<string>${castlePort}</string>
|
|
602
|
+
<string>${xmlEscape(castlePort)}</string>
|
|
438
603
|
</array>
|
|
439
604
|
<key>WorkingDirectory</key>
|
|
440
|
-
<string>${PROJECT_ROOT}</string>
|
|
605
|
+
<string>${xmlEscape(PROJECT_ROOT)}</string>
|
|
441
606
|
<key>RunAtLoad</key>
|
|
442
607
|
<true/>
|
|
443
608
|
<key>KeepAlive</key>
|
|
@@ -446,15 +611,15 @@ export async function runOnboarding(): Promise<void> {
|
|
|
446
611
|
<false/>
|
|
447
612
|
</dict>
|
|
448
613
|
<key>StandardOutPath</key>
|
|
449
|
-
<string>${logsDir}/server.log</string>
|
|
614
|
+
<string>${xmlEscape(logsDir)}/server.log</string>
|
|
450
615
|
<key>StandardErrorPath</key>
|
|
451
|
-
<string>${logsDir}/server.err</string>
|
|
616
|
+
<string>${xmlEscape(logsDir)}/server.err</string>
|
|
452
617
|
<key>EnvironmentVariables</key>
|
|
453
618
|
<dict>
|
|
454
619
|
<key>NODE_ENV</key>
|
|
455
620
|
<string>production</string>
|
|
456
621
|
<key>PATH</key>
|
|
457
|
-
<string>${process.env.PATH}</string>
|
|
622
|
+
<string>${xmlEscape(process.env.PATH || "")}</string>
|
|
458
623
|
</dict>
|
|
459
624
|
</dict>
|
|
460
625
|
</plist>`;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Bot } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
|
|
7
|
+
export interface AgentInfo {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
avatar?: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AgentMentionPopupProps {
|
|
14
|
+
agents: AgentInfo[];
|
|
15
|
+
filter: string;
|
|
16
|
+
onSelect: (agentId: string) => void;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
highlightedIndex?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AgentMentionPopup({ agents, filter, onSelect, onClose, highlightedIndex = 0 }: AgentMentionPopupProps) {
|
|
22
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
const filteredAgents = getFilteredAgents(agents, filter);
|
|
25
|
+
|
|
26
|
+
// Scroll highlighted item into view
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (listRef.current && highlightedIndex >= 0) {
|
|
29
|
+
const items = listRef.current.querySelectorAll("button");
|
|
30
|
+
const item = items[highlightedIndex];
|
|
31
|
+
if (item) {
|
|
32
|
+
item.scrollIntoView({ block: "nearest" });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}, [highlightedIndex]);
|
|
36
|
+
|
|
37
|
+
if (filteredAgents.length === 0) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface border border-border rounded-xl shadow-lg overflow-hidden z-50">
|
|
43
|
+
<div className="p-2" ref={listRef}>
|
|
44
|
+
<p className="text-xs text-foreground-secondary px-2 py-1 mb-1">
|
|
45
|
+
Mention an agent (Up/Down to navigate, Tab/Enter to select)
|
|
46
|
+
</p>
|
|
47
|
+
{filteredAgents.map((agent, index) => (
|
|
48
|
+
<button
|
|
49
|
+
key={agent.id}
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={() => onSelect(agent.id)}
|
|
52
|
+
className={cn(
|
|
53
|
+
"w-full flex items-center gap-2 px-2 py-2 rounded-lg text-left text-sm",
|
|
54
|
+
"hover:bg-accent/80 hover:text-white focus:outline-none",
|
|
55
|
+
index === highlightedIndex
|
|
56
|
+
? "bg-accent text-white"
|
|
57
|
+
: "text-foreground"
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{agent.avatar ? (
|
|
61
|
+
<img
|
|
62
|
+
src={agent.avatar}
|
|
63
|
+
alt={agent.name}
|
|
64
|
+
className="w-6 h-6 rounded-full object-cover"
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<div className={cn(
|
|
68
|
+
"flex items-center justify-center w-6 h-6 rounded-full",
|
|
69
|
+
index === highlightedIndex
|
|
70
|
+
? "bg-white/20 text-white"
|
|
71
|
+
: "bg-accent/20 text-accent"
|
|
72
|
+
)}>
|
|
73
|
+
<Bot className="w-3 h-3" />
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
<span className="font-medium">{agent.name}</span>
|
|
77
|
+
</button>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Filter agents by name match */
|
|
85
|
+
export function getFilteredAgents(agents: AgentInfo[], filter: string): AgentInfo[] {
|
|
86
|
+
return agents.filter(agent =>
|
|
87
|
+
agent.name.toLowerCase().includes(filter.toLowerCase())
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Loader2, RotateCcw, Trash2, Hash, MessageCircle } from "lucide-react";
|
|
5
|
+
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { formatDateTime, formatTimeAgo } from "@/lib/date-utils";
|
|
8
|
+
import type { Channel } from "@/lib/types/chat";
|
|
9
|
+
|
|
10
|
+
interface ArchivedChannelsProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
onRestored?: (channel: Channel) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ArchivedChannels({
|
|
17
|
+
open,
|
|
18
|
+
onOpenChange,
|
|
19
|
+
onRestored,
|
|
20
|
+
}: ArchivedChannelsProps) {
|
|
21
|
+
const [channels, setChannels] = useState<Channel[]>([]);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
24
|
+
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
const fetchArchived = async () => {
|
|
27
|
+
setLoading(true);
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch("/api/openclaw/chat/channels?archived=1");
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
setChannels(data.channels || []);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// silent
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (open) {
|
|
43
|
+
fetchArchived();
|
|
44
|
+
setConfirmDelete(null);
|
|
45
|
+
}
|
|
46
|
+
}, [open]);
|
|
47
|
+
|
|
48
|
+
const handleRestore = async (channel: Channel) => {
|
|
49
|
+
setActionLoading(channel.id);
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch("/api/openclaw/chat/channels", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ action: "restore", id: channel.id }),
|
|
55
|
+
});
|
|
56
|
+
if (res.ok) {
|
|
57
|
+
setChannels((prev) => prev.filter((c) => c.id !== channel.id));
|
|
58
|
+
onRestored?.(channel);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// silent
|
|
62
|
+
} finally {
|
|
63
|
+
setActionLoading(null);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleDelete = async (id: string) => {
|
|
68
|
+
setActionLoading(id);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch("/api/openclaw/chat/channels", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ action: "delete", id }),
|
|
74
|
+
});
|
|
75
|
+
if (res.ok) {
|
|
76
|
+
setChannels((prev) => prev.filter((c) => c.id !== id));
|
|
77
|
+
setConfirmDelete(null);
|
|
78
|
+
} else {
|
|
79
|
+
const data = await res.json().catch(() => ({}));
|
|
80
|
+
console.error("[Archive] Delete failed:", res.status, data);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error("[Archive] Delete error:", err);
|
|
84
|
+
} finally {
|
|
85
|
+
setActionLoading(null);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
91
|
+
<DialogHeader>
|
|
92
|
+
<DialogTitle>Archived Channels</DialogTitle>
|
|
93
|
+
</DialogHeader>
|
|
94
|
+
|
|
95
|
+
<div className="min-h-[200px]">
|
|
96
|
+
{loading ? (
|
|
97
|
+
<div className="flex items-center justify-center py-12">
|
|
98
|
+
<Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
|
|
99
|
+
</div>
|
|
100
|
+
) : channels.length === 0 ? (
|
|
101
|
+
<div className="text-center py-12 text-foreground-secondary">
|
|
102
|
+
<MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
103
|
+
<p className="text-sm">No archived channels</p>
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
<div className="space-y-3">
|
|
107
|
+
{channels.map((channel) => (
|
|
108
|
+
<div
|
|
109
|
+
key={channel.id}
|
|
110
|
+
className="flex items-center justify-between px-3 py-2.5 rounded-[var(--radius-sm)] bg-surface-hover/50 hover:bg-surface-hover group"
|
|
111
|
+
>
|
|
112
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
113
|
+
<Hash className="h-4 w-4 shrink-0 text-foreground-secondary" strokeWidth={2.5} />
|
|
114
|
+
<div className="min-w-0">
|
|
115
|
+
<span className="text-sm text-foreground truncate block">
|
|
116
|
+
{channel.name}
|
|
117
|
+
</span>
|
|
118
|
+
<span className="text-xs text-foreground-secondary">
|
|
119
|
+
Created {formatDateTime(new Date(channel.createdAt).getTime())}
|
|
120
|
+
{channel.archivedAt && (
|
|
121
|
+
<> · Archived {formatTimeAgo(new Date(channel.archivedAt).getTime())}</>
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
128
|
+
{confirmDelete === channel.id ? (
|
|
129
|
+
<>
|
|
130
|
+
<span className="text-xs text-red-400 mr-1">
|
|
131
|
+
Delete forever?
|
|
132
|
+
</span>
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="sm"
|
|
136
|
+
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 px-2 text-xs"
|
|
137
|
+
onClick={() => handleDelete(channel.id)}
|
|
138
|
+
disabled={actionLoading === channel.id}
|
|
139
|
+
>
|
|
140
|
+
{actionLoading === channel.id ? (
|
|
141
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
142
|
+
) : (
|
|
143
|
+
"Yes"
|
|
144
|
+
)}
|
|
145
|
+
</Button>
|
|
146
|
+
<Button
|
|
147
|
+
variant="ghost"
|
|
148
|
+
size="sm"
|
|
149
|
+
className="h-7 px-2 text-xs"
|
|
150
|
+
onClick={() => setConfirmDelete(null)}
|
|
151
|
+
>
|
|
152
|
+
No
|
|
153
|
+
</Button>
|
|
154
|
+
</>
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<Button
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="icon"
|
|
160
|
+
className="h-7 w-7 text-foreground-secondary hover:text-foreground"
|
|
161
|
+
onClick={() => handleRestore(channel)}
|
|
162
|
+
disabled={actionLoading === channel.id}
|
|
163
|
+
title="Restore channel"
|
|
164
|
+
>
|
|
165
|
+
{actionLoading === channel.id ? (
|
|
166
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
167
|
+
) : (
|
|
168
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
169
|
+
)}
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
variant="ghost"
|
|
173
|
+
size="icon"
|
|
174
|
+
className="h-7 w-7 text-foreground-secondary hover:text-red-400"
|
|
175
|
+
onClick={() => setConfirmDelete(channel.id)}
|
|
176
|
+
title="Delete permanently"
|
|
177
|
+
>
|
|
178
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
179
|
+
</Button>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</Dialog>
|
|
189
|
+
);
|
|
190
|
+
}
|