@castlekit/castle 0.1.6 → 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 +20 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- 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/logs/route.ts +17 -3
- package/src/app/api/openclaw/restart/route.ts +6 -1
- 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 +49 -17
- package/src/app/settings/page.tsx +300 -0
- 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 +125 -0
- 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/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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { setSetting } from "@/lib/db/queries";
|
|
7
|
+
import { checkCsrf, checkRateLimit, rateLimitKey } from "@/lib/api-security";
|
|
8
|
+
|
|
9
|
+
export const dynamic = "force-dynamic";
|
|
10
|
+
|
|
11
|
+
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; // 5MB raw upload limit
|
|
12
|
+
const AVATAR_SIZE = 256; // px (square)
|
|
13
|
+
const MAX_OUTPUT_SIZE = 100 * 1024; // 100KB after processing
|
|
14
|
+
|
|
15
|
+
const ALLOWED_TYPES = new Set([
|
|
16
|
+
"image/png",
|
|
17
|
+
"image/jpeg",
|
|
18
|
+
"image/webp",
|
|
19
|
+
"image/gif",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const AVATARS_DIR = join(homedir(), ".castle", "avatars");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resize and compress an avatar image to 256x256, under 100KB.
|
|
26
|
+
*/
|
|
27
|
+
async function processAvatar(input: Buffer): Promise<{ data: Buffer; ext: string }> {
|
|
28
|
+
const processed = sharp(input).resize(AVATAR_SIZE, AVATAR_SIZE, {
|
|
29
|
+
fit: "cover",
|
|
30
|
+
position: "centre",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Try PNG first (transparency support)
|
|
34
|
+
let data = await processed.png({ quality: 80, compressionLevel: 9 }).toBuffer();
|
|
35
|
+
if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".png" };
|
|
36
|
+
|
|
37
|
+
// WebP (better compression, keeps transparency)
|
|
38
|
+
data = await processed.webp({ quality: 80 }).toBuffer();
|
|
39
|
+
if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".webp" };
|
|
40
|
+
|
|
41
|
+
// JPEG with lower quality
|
|
42
|
+
data = await processed.jpeg({ quality: 70 }).toBuffer();
|
|
43
|
+
if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".jpg" };
|
|
44
|
+
|
|
45
|
+
// Last resort
|
|
46
|
+
data = await processed.jpeg({ quality: 40 }).toBuffer();
|
|
47
|
+
return { data, ext: ".jpg" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// GET /api/settings/avatar — Serve user avatar
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
const MIME_TYPES: Record<string, string> = {
|
|
55
|
+
".png": "image/png",
|
|
56
|
+
".jpg": "image/jpeg",
|
|
57
|
+
".jpeg": "image/jpeg",
|
|
58
|
+
".gif": "image/gif",
|
|
59
|
+
".webp": "image/webp",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export async function GET() {
|
|
63
|
+
if (!existsSync(AVATARS_DIR)) {
|
|
64
|
+
return new NextResponse(null, { status: 404 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Find user avatar file
|
|
68
|
+
const files = readdirSync(AVATARS_DIR).filter((f) => f.startsWith("user."));
|
|
69
|
+
if (files.length === 0) {
|
|
70
|
+
return new NextResponse(null, { status: 404 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const filePath = join(AVATARS_DIR, files[0]);
|
|
74
|
+
const data = readFileSync(filePath);
|
|
75
|
+
const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
|
|
76
|
+
const contentType = MIME_TYPES[ext] || "image/png";
|
|
77
|
+
|
|
78
|
+
return new NextResponse(data, {
|
|
79
|
+
headers: {
|
|
80
|
+
"Content-Type": contentType,
|
|
81
|
+
"Cache-Control": "public, max-age=60",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// POST /api/settings/avatar — Upload user avatar
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
export async function POST(request: NextRequest) {
|
|
91
|
+
const csrfError = checkCsrf(request);
|
|
92
|
+
if (csrfError) return csrfError;
|
|
93
|
+
|
|
94
|
+
// Rate limit: 10 avatar uploads per minute
|
|
95
|
+
const rl = checkRateLimit(rateLimitKey(request, "avatar:upload"), 10);
|
|
96
|
+
if (rl) return rl;
|
|
97
|
+
|
|
98
|
+
const formData = await request.formData();
|
|
99
|
+
const file = formData.get("avatar") as File | null;
|
|
100
|
+
|
|
101
|
+
if (!file) {
|
|
102
|
+
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!ALLOWED_TYPES.has(file.type)) {
|
|
106
|
+
return NextResponse.json(
|
|
107
|
+
{ error: "Unsupported format. Use PNG, JPEG, WebP, or GIF." },
|
|
108
|
+
{ status: 400 }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (file.size > MAX_UPLOAD_SIZE) {
|
|
113
|
+
return NextResponse.json(
|
|
114
|
+
{ error: "File too large (max 5MB)" },
|
|
115
|
+
{ status: 400 }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Process image
|
|
120
|
+
let processed: { data: Buffer; ext: string };
|
|
121
|
+
try {
|
|
122
|
+
const rawBuffer = Buffer.from(await file.arrayBuffer());
|
|
123
|
+
processed = await processAvatar(rawBuffer);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return NextResponse.json(
|
|
126
|
+
{ error: `Failed to process image: ${err instanceof Error ? err.message : "unknown"}` },
|
|
127
|
+
{ status: 400 }
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Remove any existing user avatar files
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(AVATARS_DIR, { recursive: true, mode: 0o755 });
|
|
134
|
+
if (existsSync(AVATARS_DIR)) {
|
|
135
|
+
const existing = readdirSync(AVATARS_DIR).filter((f) => f.startsWith("user."));
|
|
136
|
+
for (const f of existing) {
|
|
137
|
+
unlinkSync(join(AVATARS_DIR, f));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// silent
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Save new avatar
|
|
145
|
+
const fileName = `user${processed.ext}`;
|
|
146
|
+
const filePath = join(AVATARS_DIR, fileName);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
writeFileSync(filePath, processed.data);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return NextResponse.json(
|
|
152
|
+
{ error: `Failed to save avatar: ${err instanceof Error ? err.message : "unknown"}` },
|
|
153
|
+
{ status: 500 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Store the avatar path with a version timestamp for cache-busting
|
|
158
|
+
setSetting("avatarPath", `${fileName}?t=${Date.now()}`);
|
|
159
|
+
|
|
160
|
+
return NextResponse.json({
|
|
161
|
+
success: true,
|
|
162
|
+
avatar: `/api/settings/avatar?t=${Date.now()}`,
|
|
163
|
+
size: processed.data.length,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// DELETE /api/settings/avatar — Remove user avatar
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
export async function DELETE(request: NextRequest) {
|
|
172
|
+
const csrfError = checkCsrf(request);
|
|
173
|
+
if (csrfError) return csrfError;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (existsSync(AVATARS_DIR)) {
|
|
177
|
+
const existing = readdirSync(AVATARS_DIR).filter((f) => f.startsWith("user."));
|
|
178
|
+
for (const f of existing) {
|
|
179
|
+
unlinkSync(join(AVATARS_DIR, f));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
setSetting("avatarPath", "");
|
|
183
|
+
return NextResponse.json({ ok: true });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return NextResponse.json(
|
|
186
|
+
{ error: `Failed to remove avatar: ${err instanceof Error ? err.message : "unknown"}` },
|
|
187
|
+
{ status: 500 }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getAllSettings, setSetting } from "@/lib/db/queries";
|
|
3
|
+
import { checkCsrf } from "@/lib/api-security";
|
|
4
|
+
|
|
5
|
+
// Known setting keys and their validation
|
|
6
|
+
const VALID_KEYS: Record<string, { maxLength: number }> = {
|
|
7
|
+
displayName: { maxLength: 100 },
|
|
8
|
+
avatarPath: { maxLength: 255 },
|
|
9
|
+
tooltips: { maxLength: 5 }, // "true" or "false"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// GET /api/settings — Get all settings
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
export async function GET() {
|
|
17
|
+
try {
|
|
18
|
+
const all = getAllSettings();
|
|
19
|
+
return NextResponse.json(all);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error("[Settings] GET failed:", (err as Error).message);
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: "Failed to load settings" },
|
|
24
|
+
{ status: 500 }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// POST /api/settings — Update one or more settings
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
export async function POST(request: NextRequest) {
|
|
34
|
+
const csrfError = checkCsrf(request);
|
|
35
|
+
if (csrfError) return csrfError;
|
|
36
|
+
|
|
37
|
+
let body: Record<string, string>;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
body = await request.json();
|
|
41
|
+
} catch {
|
|
42
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!body || typeof body !== "object") {
|
|
46
|
+
return NextResponse.json({ error: "Body must be an object" }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
for (const [key, value] of Object.entries(body)) {
|
|
51
|
+
// Validate key
|
|
52
|
+
if (!VALID_KEYS[key]) {
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ error: `Unknown setting: ${key}` },
|
|
55
|
+
{ status: 400 }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate value type
|
|
60
|
+
if (typeof value !== "string") {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: `Setting "${key}" must be a string` },
|
|
63
|
+
{ status: 400 }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate length
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
if (trimmed.length > VALID_KEYS[key].maxLength) {
|
|
70
|
+
return NextResponse.json(
|
|
71
|
+
{ error: `Setting "${key}" must be at most ${VALID_KEYS[key].maxLength} characters` },
|
|
72
|
+
{ status: 400 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setSetting(key, trimmed);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const all = getAllSettings();
|
|
80
|
+
return NextResponse.json(all);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error("[Settings] POST failed:", (err as Error).message);
|
|
83
|
+
return NextResponse.json(
|
|
84
|
+
{ error: "Failed to save settings" },
|
|
85
|
+
{ status: 500 }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { AlertTriangle, RefreshCw } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
|
|
7
|
+
interface ErrorBoundaryState {
|
|
8
|
+
hasError: boolean;
|
|
9
|
+
error?: Error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ErrorBoundaryProps {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ChatErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
17
|
+
constructor(props: ErrorBoundaryProps) {
|
|
18
|
+
super(props);
|
|
19
|
+
this.state = { hasError: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
23
|
+
return { hasError: true, error };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
27
|
+
console.error("[Chat Error Boundary]", error, info.componentStack);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
handleRetry = () => {
|
|
31
|
+
this.setState({ hasError: false, error: undefined });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
render() {
|
|
35
|
+
if (this.state.hasError) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex-1 flex items-center justify-center p-8">
|
|
38
|
+
<div className="text-center space-y-4 max-w-md">
|
|
39
|
+
<AlertTriangle className="h-10 w-10 mx-auto text-warning" />
|
|
40
|
+
<div>
|
|
41
|
+
<h3 className="text-lg font-semibold text-foreground">
|
|
42
|
+
Something went wrong
|
|
43
|
+
</h3>
|
|
44
|
+
<p className="text-sm text-foreground-secondary mt-1">
|
|
45
|
+
The chat encountered an unexpected error. Your messages are safe in the database.
|
|
46
|
+
</p>
|
|
47
|
+
{this.state.error && (
|
|
48
|
+
<p className="text-xs text-foreground-secondary/60 mt-2 font-mono">
|
|
49
|
+
{this.state.error.message}
|
|
50
|
+
</p>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
<Button onClick={this.handleRetry} variant="primary" size="sm">
|
|
54
|
+
<RefreshCw className="h-4 w-4 mr-2" />
|
|
55
|
+
Try Again
|
|
56
|
+
</Button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return this.props.children;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { use, useState, useEffect } from "react";
|
|
4
|
+
import { useSearchParams } from "next/navigation";
|
|
5
|
+
import { WifiOff, X, AlertCircle } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { useOpenClaw } from "@/lib/hooks/use-openclaw";
|
|
8
|
+
import { useChat } from "@/lib/hooks/use-chat";
|
|
9
|
+
import { useSessionStats } from "@/lib/hooks/use-session-stats";
|
|
10
|
+
import { useUserSettings } from "@/lib/hooks/use-user-settings";
|
|
11
|
+
import { MessageList } from "@/components/chat/message-list";
|
|
12
|
+
import { ChatInput } from "@/components/chat/chat-input";
|
|
13
|
+
import { SessionStatsPanel } from "@/components/chat/session-stats-panel";
|
|
14
|
+
import { ChatErrorBoundary } from "./error-boundary";
|
|
15
|
+
import type { AgentInfo } from "@/components/chat/agent-mention-popup";
|
|
16
|
+
|
|
17
|
+
interface ChannelPageProps {
|
|
18
|
+
params: Promise<{ channelId: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Module-level flag: once any channel has rendered real content, subsequent
|
|
22
|
+
// channel switches use a smooth opacity transition instead of the skeleton.
|
|
23
|
+
// NOTE: This persists for the entire browser session and never resets.
|
|
24
|
+
// If Castle ever supports logout or multi-user, this will need a reset mechanism.
|
|
25
|
+
let hasEverRendered = false;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Skeleton loader — Facebook-style placeholder while data loads
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const LINE_WIDTHS = ["80%", "60%", "40%", "75%", "55%", "85%", "45%", "70%", "50%"];
|
|
31
|
+
|
|
32
|
+
function SkeletonMessage({ lines = 2, short = false, offset = 0 }: { lines?: number; short?: boolean; offset?: number }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex gap-3 mb-[4px]">
|
|
35
|
+
<div className="skeleton w-9 h-9 rounded-full shrink-0 mt-0.5" />
|
|
36
|
+
<div className="flex flex-col gap-1.5 flex-1">
|
|
37
|
+
<div className="flex items-center gap-2">
|
|
38
|
+
<div className={cn("skeleton h-3.5", short ? "w-16" : "w-24")} />
|
|
39
|
+
<div className="skeleton h-3 w-14" />
|
|
40
|
+
</div>
|
|
41
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
42
|
+
<div
|
|
43
|
+
key={i}
|
|
44
|
+
className="skeleton h-3.5"
|
|
45
|
+
style={{ width: LINE_WIDTHS[(offset + i) % LINE_WIDTHS.length] }}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ChatSkeleton() {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
56
|
+
{/* Header skeleton */}
|
|
57
|
+
<div className="py-4 border-b border-border shrink-0 min-h-[83px] flex items-center">
|
|
58
|
+
<div>
|
|
59
|
+
<div className="skeleton h-5 w-40 mb-1.5" />
|
|
60
|
+
<div className="skeleton h-3.5 w-28" />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Messages skeleton */}
|
|
65
|
+
<div className="flex-1 overflow-hidden py-[20px] pr-[20px]">
|
|
66
|
+
<div className="flex flex-col gap-6">
|
|
67
|
+
<SkeletonMessage lines={3} offset={0} />
|
|
68
|
+
<SkeletonMessage lines={1} short offset={3} />
|
|
69
|
+
<SkeletonMessage lines={2} offset={4} />
|
|
70
|
+
<SkeletonMessage lines={1} short offset={6} />
|
|
71
|
+
<SkeletonMessage lines={2} offset={7} />
|
|
72
|
+
<SkeletonMessage lines={3} offset={1} />
|
|
73
|
+
<SkeletonMessage lines={1} short offset={5} />
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Real input, just disabled while loading */}
|
|
78
|
+
<div className="shrink-0">
|
|
79
|
+
<ChatInput
|
|
80
|
+
onSend={() => Promise.resolve()}
|
|
81
|
+
onAbort={() => Promise.resolve()}
|
|
82
|
+
sending={false}
|
|
83
|
+
streaming={false}
|
|
84
|
+
disabled
|
|
85
|
+
agents={[]}
|
|
86
|
+
channelId=""
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ChannelChatContent({ channelId }: { channelId: string }) {
|
|
94
|
+
// Read ?m= param for scroll-to-message from search results.
|
|
95
|
+
// useSearchParams() is reactive — it updates when the query string
|
|
96
|
+
// changes, even for same-channel navigation (e.g. clicking two
|
|
97
|
+
// different search results in the same channel).
|
|
98
|
+
const searchParams = useSearchParams();
|
|
99
|
+
const highlightMessageId = searchParams.get("m") || undefined;
|
|
100
|
+
const { agents, isConnected, isLoading: gatewayLoading, agentsLoading } = useOpenClaw();
|
|
101
|
+
const [channelName, setChannelName] = useState<string | null>(null);
|
|
102
|
+
const [channelAgentIds, setChannelAgentIds] = useState<string[]>([]);
|
|
103
|
+
const [channelCreatedAt, setChannelCreatedAt] = useState<number | null>(null);
|
|
104
|
+
const [channelArchived, setChannelArchived] = useState(false);
|
|
105
|
+
const { displayName, avatarUrl: userAvatar, isLoading: userSettingsLoading } = useUserSettings();
|
|
106
|
+
|
|
107
|
+
// Mark this channel as last accessed and fetch channel info
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
// Touch (mark as last accessed)
|
|
110
|
+
fetch("/api/openclaw/chat/channels", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ action: "touch", id: channelId }),
|
|
114
|
+
}).catch(() => {});
|
|
115
|
+
|
|
116
|
+
// Fetch channel details for the name and agents.
|
|
117
|
+
// Try active channels first, then archived if not found.
|
|
118
|
+
// Falls back to channelId as name if both fail (prevents stuck loading).
|
|
119
|
+
fetch("/api/openclaw/chat/channels")
|
|
120
|
+
.then((r) => r.json())
|
|
121
|
+
.then((data) => {
|
|
122
|
+
const ch = (data.channels || []).find(
|
|
123
|
+
(c: { id: string; name: string; agents?: string[] }) =>
|
|
124
|
+
c.id === channelId
|
|
125
|
+
);
|
|
126
|
+
if (ch) {
|
|
127
|
+
setChannelName(ch.name);
|
|
128
|
+
setChannelAgentIds(ch.agents || []);
|
|
129
|
+
setChannelCreatedAt(ch.createdAt ?? null);
|
|
130
|
+
setChannelArchived(false);
|
|
131
|
+
} else {
|
|
132
|
+
// Channel not in active list — check archived channels
|
|
133
|
+
return fetch("/api/openclaw/chat/channels?archived=1")
|
|
134
|
+
.then((r) => r.json())
|
|
135
|
+
.then((archived) => {
|
|
136
|
+
const archivedCh = (archived.channels || []).find(
|
|
137
|
+
(c: { id: string; name: string; agents?: string[] }) =>
|
|
138
|
+
c.id === channelId
|
|
139
|
+
);
|
|
140
|
+
if (archivedCh) {
|
|
141
|
+
setChannelName(archivedCh.name);
|
|
142
|
+
setChannelAgentIds(archivedCh.agents || []);
|
|
143
|
+
setChannelCreatedAt(archivedCh.createdAt ?? null);
|
|
144
|
+
setChannelArchived(true);
|
|
145
|
+
} else {
|
|
146
|
+
// Channel not found anywhere — use ID as fallback name
|
|
147
|
+
setChannelName("Chat");
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
.catch(() => {
|
|
153
|
+
// Network error — fall back so page doesn't stay stuck
|
|
154
|
+
setChannelName("Chat");
|
|
155
|
+
});
|
|
156
|
+
}, [channelId]);
|
|
157
|
+
|
|
158
|
+
// Map agents to the AgentInfo format used by chat components
|
|
159
|
+
const chatAgents: AgentInfo[] = agents.map((a) => ({
|
|
160
|
+
id: a.id,
|
|
161
|
+
name: a.name,
|
|
162
|
+
avatar: a.avatar,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// Get default agent (first in list)
|
|
166
|
+
const defaultAgentId = agents[0]?.id;
|
|
167
|
+
|
|
168
|
+
const {
|
|
169
|
+
messages,
|
|
170
|
+
isLoading,
|
|
171
|
+
hasMore,
|
|
172
|
+
loadMore,
|
|
173
|
+
loadingMore,
|
|
174
|
+
hasMoreAfter,
|
|
175
|
+
loadNewer,
|
|
176
|
+
loadingNewer,
|
|
177
|
+
streamingMessages,
|
|
178
|
+
isStreaming,
|
|
179
|
+
currentSessionKey,
|
|
180
|
+
sendMessage,
|
|
181
|
+
abortResponse,
|
|
182
|
+
sending,
|
|
183
|
+
sendError,
|
|
184
|
+
clearSendError,
|
|
185
|
+
} = useChat({ channelId, defaultAgentId, anchorMessageId: highlightMessageId });
|
|
186
|
+
|
|
187
|
+
const { stats, isLoading: statsLoading } = useSessionStats({
|
|
188
|
+
sessionKey: currentSessionKey,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Don't render until channel name, agents, and user settings have all loaded.
|
|
192
|
+
const channelReady = channelName !== null && !agentsLoading && !userSettingsLoading;
|
|
193
|
+
|
|
194
|
+
// First cold load → skeleton. If agents/user are already cached (e.g.
|
|
195
|
+
// navigated from dashboard) or we've rendered before, use opacity transition.
|
|
196
|
+
const dataAlreadyCached = !agentsLoading && !userSettingsLoading;
|
|
197
|
+
if (channelReady) hasEverRendered = true;
|
|
198
|
+
|
|
199
|
+
if (!channelReady && !hasEverRendered && !dataAlreadyCached) {
|
|
200
|
+
return <ChatSkeleton />;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className={cn("flex-1 flex flex-col h-full overflow-hidden transition-opacity duration-150", channelReady ? "opacity-100" : "opacity-0")}>
|
|
205
|
+
{/* Channel header — sticky */}
|
|
206
|
+
<div className="pb-4 border-b border-border flex items-center justify-between shrink-0 min-h-[83px]">
|
|
207
|
+
<div>
|
|
208
|
+
<h2 className="text-lg font-semibold text-foreground">
|
|
209
|
+
{channelName}
|
|
210
|
+
{channelArchived && (
|
|
211
|
+
<span className="ml-2 text-sm font-normal text-foreground-secondary">(Archived)</span>
|
|
212
|
+
)}
|
|
213
|
+
</h2>
|
|
214
|
+
{(displayName || channelAgentIds.length > 0) && agents.length > 0 && (
|
|
215
|
+
<p className="text-sm text-foreground-secondary mt-0.5">
|
|
216
|
+
with{" "}
|
|
217
|
+
{(() => {
|
|
218
|
+
const names = [
|
|
219
|
+
displayName,
|
|
220
|
+
...channelAgentIds.map(
|
|
221
|
+
(id) => agents.find((a) => a.id === id)?.name || id
|
|
222
|
+
),
|
|
223
|
+
].filter(Boolean);
|
|
224
|
+
if (names.length <= 2) return names.join(" & ");
|
|
225
|
+
return names.slice(0, -1).join(", ") + " & " + names[names.length - 1];
|
|
226
|
+
})()}
|
|
227
|
+
</p>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Connection warning banner — sticky */}
|
|
233
|
+
{!isConnected && !gatewayLoading && (
|
|
234
|
+
<div className="px-4 py-2 bg-error/10 border-b border-error/20 flex items-center gap-2 text-sm text-error shrink-0">
|
|
235
|
+
<WifiOff className="h-4 w-4" />
|
|
236
|
+
<span>Gateway disconnected. Reconnecting...</span>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{/* Session stats — sticky */}
|
|
241
|
+
<div className="shrink-0">
|
|
242
|
+
<SessionStatsPanel stats={stats} isLoading={statsLoading} />
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{/* Messages — this is the ONLY scrollable area */}
|
|
246
|
+
<MessageList
|
|
247
|
+
messages={messages}
|
|
248
|
+
loading={isLoading}
|
|
249
|
+
loadingMore={loadingMore}
|
|
250
|
+
hasMore={hasMore}
|
|
251
|
+
agents={chatAgents}
|
|
252
|
+
userAvatar={userAvatar}
|
|
253
|
+
streamingMessages={streamingMessages}
|
|
254
|
+
onLoadMore={loadMore}
|
|
255
|
+
hasMoreAfter={hasMoreAfter}
|
|
256
|
+
onLoadNewer={loadNewer}
|
|
257
|
+
loadingNewer={loadingNewer}
|
|
258
|
+
channelId={channelId}
|
|
259
|
+
channelName={channelName}
|
|
260
|
+
channelCreatedAt={channelCreatedAt}
|
|
261
|
+
highlightMessageId={highlightMessageId}
|
|
262
|
+
/>
|
|
263
|
+
|
|
264
|
+
{/* Error toast — sticky above input */}
|
|
265
|
+
{sendError && (
|
|
266
|
+
<div className="mx-4 mb-2 px-4 py-2 rounded-lg bg-error/10 border border-error/20 flex items-center justify-between shrink-0">
|
|
267
|
+
<div className="flex items-center gap-2 text-sm text-error">
|
|
268
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
269
|
+
<span>{sendError}</span>
|
|
270
|
+
</div>
|
|
271
|
+
<button
|
|
272
|
+
onClick={clearSendError}
|
|
273
|
+
className="p-1 hover:bg-error/20 rounded"
|
|
274
|
+
>
|
|
275
|
+
<X className="h-3 w-3 text-error" />
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{/* Input — sticky at bottom */}
|
|
281
|
+
<div className="shrink-0">
|
|
282
|
+
<ChatInput
|
|
283
|
+
onSend={sendMessage}
|
|
284
|
+
onAbort={abortResponse}
|
|
285
|
+
sending={sending}
|
|
286
|
+
streaming={isStreaming}
|
|
287
|
+
disabled={!isConnected && !gatewayLoading}
|
|
288
|
+
agents={chatAgents}
|
|
289
|
+
defaultAgentId={defaultAgentId}
|
|
290
|
+
channelId={channelId}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export default function ChannelPage({ params }: ChannelPageProps) {
|
|
298
|
+
const { channelId } = use(params);
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<ChatErrorBoundary>
|
|
302
|
+
<ChannelChatContent channelId={channelId} />
|
|
303
|
+
</ChatErrorBoundary>
|
|
304
|
+
);
|
|
305
|
+
}
|