@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,272 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { checkCsrf, sanitizeForApi, checkRateLimit, rateLimitKey } from "@/lib/api-security";
|
|
4
|
+
import { ensureGateway } from "@/lib/gateway-connection";
|
|
5
|
+
import {
|
|
6
|
+
createMessage,
|
|
7
|
+
updateMessage,
|
|
8
|
+
deleteMessage,
|
|
9
|
+
getMessagesByChannel,
|
|
10
|
+
getMessagesAfter,
|
|
11
|
+
getMessagesAround,
|
|
12
|
+
getMessageByRunId,
|
|
13
|
+
getLatestSessionKey,
|
|
14
|
+
createSession,
|
|
15
|
+
} from "@/lib/db/queries";
|
|
16
|
+
import type { ChatSendRequest, ChatCompleteRequest } from "@/lib/types/chat";
|
|
17
|
+
|
|
18
|
+
const MAX_MESSAGE_LENGTH = 32768; // 32KB
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// POST /api/openclaw/chat — Send a message
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export async function POST(request: NextRequest) {
|
|
25
|
+
const csrf = checkCsrf(request);
|
|
26
|
+
if (csrf) return csrf;
|
|
27
|
+
|
|
28
|
+
// Rate limit: 30 messages per minute
|
|
29
|
+
const rl = checkRateLimit(rateLimitKey(request, "chat:send"), 30);
|
|
30
|
+
if (rl) return rl;
|
|
31
|
+
|
|
32
|
+
let body: ChatSendRequest;
|
|
33
|
+
try {
|
|
34
|
+
body = await request.json();
|
|
35
|
+
} catch {
|
|
36
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate
|
|
40
|
+
if (!body.channelId || typeof body.channelId !== "string") {
|
|
41
|
+
return NextResponse.json({ error: "channelId is required" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
if (!body.content || typeof body.content !== "string") {
|
|
44
|
+
return NextResponse.json({ error: "content is required" }, { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
if (body.content.length > MAX_MESSAGE_LENGTH) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: `Message too long (max ${MAX_MESSAGE_LENGTH} chars)` },
|
|
49
|
+
{ status: 400 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Persist user message to DB
|
|
54
|
+
const userMsg = createMessage({
|
|
55
|
+
channelId: body.channelId,
|
|
56
|
+
senderType: "user",
|
|
57
|
+
senderId: "local-user",
|
|
58
|
+
senderName: "You",
|
|
59
|
+
content: body.content,
|
|
60
|
+
mentionedAgentId: body.agentId,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const gateway = ensureGateway();
|
|
65
|
+
|
|
66
|
+
// Get existing session key for this channel (if any)
|
|
67
|
+
let sessionKey = getLatestSessionKey(body.channelId);
|
|
68
|
+
|
|
69
|
+
// If no existing session, create one with a proper structured key:
|
|
70
|
+
// agent:<agentId>:castle:<channelId>
|
|
71
|
+
// This maps Castle channels to Gateway sessions 1:1, so the session
|
|
72
|
+
// is stable and traceable between Castle DB and Gateway transcripts.
|
|
73
|
+
if (!sessionKey) {
|
|
74
|
+
const agentId = body.agentId || "main";
|
|
75
|
+
sessionKey = `agent:${agentId}:castle:${body.channelId}`;
|
|
76
|
+
|
|
77
|
+
createSession({
|
|
78
|
+
channelId: body.channelId,
|
|
79
|
+
sessionKey,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build chat.send params per Gateway protocol
|
|
84
|
+
const rpcParams: Record<string, unknown> = {
|
|
85
|
+
message: body.content,
|
|
86
|
+
sessionKey,
|
|
87
|
+
idempotencyKey: randomUUID(),
|
|
88
|
+
timeoutMs: 120000,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const result = await gateway.request<{
|
|
92
|
+
runId: string;
|
|
93
|
+
status?: string;
|
|
94
|
+
}>("chat.send", rpcParams);
|
|
95
|
+
|
|
96
|
+
// Update user message with runId and sessionKey
|
|
97
|
+
const runId = result.runId;
|
|
98
|
+
updateMessage(userMsg.id, {
|
|
99
|
+
runId,
|
|
100
|
+
sessionKey,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return NextResponse.json({
|
|
104
|
+
runId,
|
|
105
|
+
messageId: userMsg.id,
|
|
106
|
+
sessionKey,
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// RPC failed — try to remove the optimistic user message
|
|
110
|
+
try {
|
|
111
|
+
deleteMessage(userMsg.id);
|
|
112
|
+
} catch (delErr) {
|
|
113
|
+
console.error("[Chat API] Cleanup failed:", (delErr as Error).message);
|
|
114
|
+
}
|
|
115
|
+
console.error("[Chat API] Send failed:", (err as Error).message);
|
|
116
|
+
return NextResponse.json(
|
|
117
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
118
|
+
{ status: 502 }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// PUT /api/openclaw/chat — Complete/update an agent message
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
export async function PUT(request: NextRequest) {
|
|
128
|
+
const csrf = checkCsrf(request);
|
|
129
|
+
if (csrf) return csrf;
|
|
130
|
+
|
|
131
|
+
let body: ChatCompleteRequest;
|
|
132
|
+
try {
|
|
133
|
+
body = await request.json();
|
|
134
|
+
} catch {
|
|
135
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!body.runId || !body.channelId || !body.content) {
|
|
139
|
+
return NextResponse.json(
|
|
140
|
+
{ error: "runId, channelId, and content are required" },
|
|
141
|
+
{ status: 400 }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// Check if we already have a message for this runId (from a previous partial save)
|
|
147
|
+
const existing = getMessageByRunId(body.runId);
|
|
148
|
+
|
|
149
|
+
if (existing) {
|
|
150
|
+
// Already complete with same content — idempotent, skip update to avoid
|
|
151
|
+
// triggering FTS5 update trigger with identical content (causes SQL error)
|
|
152
|
+
if (existing.status === "complete" && existing.content === body.content) {
|
|
153
|
+
return NextResponse.json({ messageId: existing.id, updated: false });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Update existing message (e.g. partial → complete, or content changed)
|
|
157
|
+
updateMessage(existing.id, {
|
|
158
|
+
content: body.content,
|
|
159
|
+
status: body.status,
|
|
160
|
+
sessionKey: body.sessionKey,
|
|
161
|
+
inputTokens: body.inputTokens,
|
|
162
|
+
outputTokens: body.outputTokens,
|
|
163
|
+
});
|
|
164
|
+
return NextResponse.json({ messageId: existing.id, updated: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Create new agent message
|
|
168
|
+
const agentMsg = createMessage({
|
|
169
|
+
channelId: body.channelId,
|
|
170
|
+
senderType: "agent",
|
|
171
|
+
senderId: body.agentId,
|
|
172
|
+
senderName: body.agentName,
|
|
173
|
+
content: body.content,
|
|
174
|
+
status: body.status,
|
|
175
|
+
runId: body.runId,
|
|
176
|
+
sessionKey: body.sessionKey,
|
|
177
|
+
inputTokens: body.inputTokens,
|
|
178
|
+
outputTokens: body.outputTokens,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return NextResponse.json({ messageId: agentMsg.id, updated: false });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error("[Chat API] Complete failed:", (err as Error).message);
|
|
184
|
+
return NextResponse.json(
|
|
185
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
186
|
+
{ status: 500 }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ============================================================================
|
|
192
|
+
// DELETE /api/openclaw/chat — Abort streaming
|
|
193
|
+
// ============================================================================
|
|
194
|
+
|
|
195
|
+
export async function DELETE(request: NextRequest) {
|
|
196
|
+
const csrf = checkCsrf(request);
|
|
197
|
+
if (csrf) return csrf;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const gateway = ensureGateway();
|
|
201
|
+
await gateway.request("chat.abort", {});
|
|
202
|
+
return NextResponse.json({ ok: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error("[Chat API] Abort failed:", (err as Error).message);
|
|
205
|
+
return NextResponse.json(
|
|
206
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
207
|
+
{ status: 502 }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// GET /api/openclaw/chat?channelId=X&limit=50&before=Y — Load history
|
|
214
|
+
// ?around=msgId — Load a window centered on a specific message
|
|
215
|
+
// ?after=msgId — Forward pagination (newer messages)
|
|
216
|
+
// ?before=msgId — Backward pagination (older messages, existing)
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
export async function GET(request: NextRequest) {
|
|
220
|
+
const { searchParams } = new URL(request.url);
|
|
221
|
+
const channelId = searchParams.get("channelId");
|
|
222
|
+
const limit = Math.min(parseInt(searchParams.get("limit") || "50", 10), 200);
|
|
223
|
+
const around = searchParams.get("around") || undefined;
|
|
224
|
+
const after = searchParams.get("after") || undefined;
|
|
225
|
+
const before = searchParams.get("before") || undefined;
|
|
226
|
+
|
|
227
|
+
if (!channelId) {
|
|
228
|
+
return NextResponse.json({ error: "channelId is required" }, { status: 400 });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
// Anchor mode: load a window of messages around a specific message
|
|
233
|
+
if (around) {
|
|
234
|
+
const result = getMessagesAround(channelId, around, limit);
|
|
235
|
+
if (!result) {
|
|
236
|
+
// Anchor message not found — fall back to latest messages
|
|
237
|
+
const msgs = getMessagesByChannel(channelId, limit);
|
|
238
|
+
return NextResponse.json({
|
|
239
|
+
messages: msgs,
|
|
240
|
+
hasMore: msgs.length === limit,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return NextResponse.json({
|
|
244
|
+
messages: result.messages,
|
|
245
|
+
hasMoreBefore: result.hasMoreBefore,
|
|
246
|
+
hasMoreAfter: result.hasMoreAfter,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Forward pagination: load messages newer than cursor
|
|
251
|
+
if (after) {
|
|
252
|
+
const msgs = getMessagesAfter(channelId, after, limit);
|
|
253
|
+
return NextResponse.json({
|
|
254
|
+
messages: msgs,
|
|
255
|
+
hasMore: msgs.length === limit,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Default / backward pagination
|
|
260
|
+
const msgs = getMessagesByChannel(channelId, limit, before);
|
|
261
|
+
return NextResponse.json({
|
|
262
|
+
messages: msgs,
|
|
263
|
+
hasMore: msgs.length === limit,
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error("[Chat API] History failed:", (err as Error).message);
|
|
267
|
+
return NextResponse.json(
|
|
268
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
269
|
+
{ status: 500 }
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sanitizeForApi, checkRateLimit, rateLimitKey, checkCsrf } from "@/lib/api-security";
|
|
3
|
+
import { searchMessages, getChannels, getRecentSearches, addRecentSearch, clearRecentSearches } from "@/lib/db/queries";
|
|
4
|
+
import type { MessageSearchResult, SearchResult } from "@/lib/types/search";
|
|
5
|
+
|
|
6
|
+
const MAX_QUERY_LENGTH = 500;
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// GET /api/openclaw/chat/search?q=X — Universal full-text search
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export async function GET(request: NextRequest) {
|
|
13
|
+
// Rate limit: 60 searches per minute
|
|
14
|
+
const rl = checkRateLimit(rateLimitKey(request, "chat:search"), 60);
|
|
15
|
+
if (rl) return rl;
|
|
16
|
+
|
|
17
|
+
const { searchParams } = new URL(request.url);
|
|
18
|
+
|
|
19
|
+
// GET /api/openclaw/chat/search?recent=1 — return recent searches
|
|
20
|
+
if (searchParams.get("recent") === "1") {
|
|
21
|
+
try {
|
|
22
|
+
const recent = getRecentSearches();
|
|
23
|
+
return NextResponse.json({ recent });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error("[Chat Search] Recent failed:", (err as Error).message);
|
|
26
|
+
return NextResponse.json({ recent: [] });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const q = searchParams.get("q");
|
|
31
|
+
const limit = Math.min(parseInt(searchParams.get("limit") || "30", 10), 100);
|
|
32
|
+
|
|
33
|
+
if (!q || !q.trim()) {
|
|
34
|
+
return NextResponse.json({ results: [] });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (q.length > MAX_QUERY_LENGTH) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ error: `Search query too long (max ${MAX_QUERY_LENGTH} chars)` },
|
|
40
|
+
{ status: 400 }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Build channel name lookup — include archived channels so their
|
|
46
|
+
// names still resolve in search results.
|
|
47
|
+
const activeChannels = getChannels(false);
|
|
48
|
+
const archivedChannels = getChannels(true);
|
|
49
|
+
const channelMap = new Map<string, { name: string; archived: boolean }>();
|
|
50
|
+
for (const ch of activeChannels) {
|
|
51
|
+
channelMap.set(ch.id, { name: ch.name, archived: false });
|
|
52
|
+
}
|
|
53
|
+
for (const ch of archivedChannels) {
|
|
54
|
+
channelMap.set(ch.id, { name: ch.name, archived: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Search messages across all channels
|
|
58
|
+
const rawMessages = searchMessages(q, undefined, limit);
|
|
59
|
+
|
|
60
|
+
// Map raw ChatMessage[] into typed MessageSearchResult[]
|
|
61
|
+
const results: SearchResult[] = rawMessages.map((msg): MessageSearchResult => {
|
|
62
|
+
const ch = channelMap.get(msg.channelId);
|
|
63
|
+
const channelName = ch?.name || "Unknown";
|
|
64
|
+
const archived = ch?.archived ?? false;
|
|
65
|
+
const senderName =
|
|
66
|
+
msg.senderType === "user"
|
|
67
|
+
? "You"
|
|
68
|
+
: msg.senderName || msg.senderId;
|
|
69
|
+
|
|
70
|
+
// Truncate snippet to ~200 chars
|
|
71
|
+
const snippet =
|
|
72
|
+
msg.content.length > 200
|
|
73
|
+
? msg.content.slice(0, 200) + "…"
|
|
74
|
+
: msg.content;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id: msg.id,
|
|
78
|
+
type: "message",
|
|
79
|
+
title: `#${channelName}`,
|
|
80
|
+
subtitle: senderName,
|
|
81
|
+
snippet,
|
|
82
|
+
timestamp: msg.createdAt,
|
|
83
|
+
href: `/chat/${msg.channelId}?m=${msg.id}`,
|
|
84
|
+
channelId: msg.channelId,
|
|
85
|
+
channelName,
|
|
86
|
+
messageId: msg.id,
|
|
87
|
+
senderType: msg.senderType,
|
|
88
|
+
senderName,
|
|
89
|
+
archived,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Future: merge results from searchTasks(), searchNotes(), etc.
|
|
94
|
+
// Sort by timestamp descending (already sorted by FTS query)
|
|
95
|
+
|
|
96
|
+
return NextResponse.json({ results });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error("[Chat Search] Failed:", (err as Error).message);
|
|
99
|
+
return NextResponse.json(
|
|
100
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
101
|
+
{ status: 500 }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// POST /api/openclaw/chat/search — Save a recent search
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
export async function POST(request: NextRequest) {
|
|
111
|
+
const csrf = checkCsrf(request);
|
|
112
|
+
if (csrf) return csrf;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const body = await request.json();
|
|
116
|
+
const q = body.query;
|
|
117
|
+
if (!q || typeof q !== "string" || !q.trim()) {
|
|
118
|
+
return NextResponse.json({ error: "query is required" }, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
addRecentSearch(q.trim());
|
|
121
|
+
return NextResponse.json({ ok: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error("[Chat Search] Save recent failed:", (err as Error).message);
|
|
124
|
+
return NextResponse.json(
|
|
125
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
126
|
+
{ status: 500 }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// DELETE /api/openclaw/chat/search — Clear all recent searches
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
export async function DELETE(request: NextRequest) {
|
|
136
|
+
const csrf = checkCsrf(request);
|
|
137
|
+
if (csrf) return csrf;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
clearRecentSearches();
|
|
141
|
+
return NextResponse.json({ ok: true });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error("[Chat Search] Clear recent failed:", (err as Error).message);
|
|
144
|
+
return NextResponse.json(
|
|
145
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
146
|
+
{ status: 500 }
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { existsSync, statSync, readdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getCastleDir } from "@/lib/config";
|
|
5
|
+
import { getStorageStats } from "@/lib/db/queries";
|
|
6
|
+
import { getDbPath } from "@/lib/db/index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively calculate total size of a directory.
|
|
10
|
+
*/
|
|
11
|
+
function getDirSize(dir: string): number {
|
|
12
|
+
if (!existsSync(dir)) return 0;
|
|
13
|
+
|
|
14
|
+
let total = 0;
|
|
15
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const fullPath = join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
total += getDirSize(fullPath);
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
total += statSync(fullPath).size;
|
|
23
|
+
} catch {
|
|
24
|
+
// Skip files we can't stat
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// GET /api/openclaw/chat/storage — Storage stats
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export async function GET() {
|
|
36
|
+
try {
|
|
37
|
+
const stats = getStorageStats();
|
|
38
|
+
|
|
39
|
+
// Get actual DB file size
|
|
40
|
+
const dbPath = getDbPath();
|
|
41
|
+
let dbSizeBytes = 0;
|
|
42
|
+
if (existsSync(dbPath)) {
|
|
43
|
+
dbSizeBytes = statSync(dbPath).size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get actual attachments directory size
|
|
47
|
+
const attachmentsDir = join(getCastleDir(), "data", "attachments");
|
|
48
|
+
const attachmentsDirSize = getDirSize(attachmentsDir);
|
|
49
|
+
|
|
50
|
+
// Define warning thresholds
|
|
51
|
+
const WARN_DB_SIZE = 500 * 1024 * 1024; // 500MB
|
|
52
|
+
const WARN_ATTACHMENTS = 2 * 1024 * 1024 * 1024; // 2GB
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
|
|
55
|
+
if (dbSizeBytes > WARN_DB_SIZE) {
|
|
56
|
+
warnings.push(`Database is ${(dbSizeBytes / 1024 / 1024).toFixed(0)}MB — consider archiving old messages`);
|
|
57
|
+
}
|
|
58
|
+
if (attachmentsDirSize > WARN_ATTACHMENTS) {
|
|
59
|
+
warnings.push(`Attachments using ${(attachmentsDirSize / 1024 / 1024 / 1024).toFixed(1)}GB of disk space`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return NextResponse.json({
|
|
63
|
+
...stats,
|
|
64
|
+
dbSizeBytes,
|
|
65
|
+
attachmentsDirBytes: attachmentsDirSize,
|
|
66
|
+
warnings,
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("[Storage] Stats failed:", (err as Error).message);
|
|
70
|
+
return NextResponse.json(
|
|
71
|
+
{ error: "Failed to get storage stats" },
|
|
72
|
+
{ status: 500 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
4
|
import { getOpenClawDir } from "@/lib/config";
|
|
5
5
|
import { sanitizeForApi } from "@/lib/api-security";
|
|
6
6
|
|
|
@@ -14,7 +14,17 @@ export async function GET(request: NextRequest) {
|
|
|
14
14
|
const { searchParams } = new URL(request.url);
|
|
15
15
|
const rawLines = parseInt(searchParams.get("lines") || "100", 10);
|
|
16
16
|
const lines = Math.min(Math.max(1, Number.isFinite(rawLines) ? rawLines : 100), 10000);
|
|
17
|
-
const
|
|
17
|
+
const rawFile = searchParams.get("file")?.trim() || "gateway";
|
|
18
|
+
|
|
19
|
+
// Sanitize file parameter: only allow alphanumeric, hyphens, underscores, dots
|
|
20
|
+
// Reject anything with path separators or traversal patterns
|
|
21
|
+
const file = rawFile.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
22
|
+
if (!file || file.includes("..") || file !== rawFile) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Invalid log file name" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
const logsDir = join(getOpenClawDir(), "logs");
|
|
20
30
|
|
|
@@ -27,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|
|
27
37
|
? readdirSync(logsDir).filter((f) => f.endsWith(".log"))
|
|
28
38
|
: [];
|
|
29
39
|
|
|
30
|
-
// Find matching log file
|
|
40
|
+
// Find matching log file — must match prefix exactly within the logs directory
|
|
31
41
|
const logFile = availableFiles.find((f) => f.startsWith(file));
|
|
32
42
|
if (!logFile) {
|
|
33
43
|
return NextResponse.json({
|
|
@@ -39,6 +49,10 @@ export async function GET(request: NextRequest) {
|
|
|
39
49
|
|
|
40
50
|
try {
|
|
41
51
|
const logPath = join(logsDir, logFile);
|
|
52
|
+
// Final safety check: resolved path must be inside the logs directory
|
|
53
|
+
if (!resolve(logPath).startsWith(resolve(logsDir))) {
|
|
54
|
+
return NextResponse.json({ error: "Invalid log file path" }, { status: 400 });
|
|
55
|
+
}
|
|
42
56
|
const content = readFileSync(logPath, "utf-8");
|
|
43
57
|
const allLines = content.split("\n").filter(Boolean);
|
|
44
58
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
-
import { checkCsrf } from "@/lib/api-security";
|
|
3
|
+
import { checkCsrf, checkRateLimit, rateLimitKey } from "@/lib/api-security";
|
|
4
4
|
|
|
5
5
|
export const dynamic = "force-dynamic";
|
|
6
6
|
|
|
@@ -12,6 +12,11 @@ export const dynamic = "force-dynamic";
|
|
|
12
12
|
export async function POST(request: NextRequest) {
|
|
13
13
|
const csrf = checkCsrf(request);
|
|
14
14
|
if (csrf) return csrf;
|
|
15
|
+
|
|
16
|
+
// Rate limit: 5 restarts per minute
|
|
17
|
+
const rl = checkRateLimit(rateLimitKey(request, "restart"), 5);
|
|
18
|
+
if (rl) return rl;
|
|
19
|
+
|
|
15
20
|
try {
|
|
16
21
|
// Try openclaw CLI restart first
|
|
17
22
|
try {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { ensureGateway } from "@/lib/gateway-connection";
|
|
3
|
+
import { sanitizeForApi } from "@/lib/api-security";
|
|
4
|
+
import type { SessionStatus } from "@/lib/types/chat";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// GET /api/openclaw/session/status?sessionKey=X — Session stats
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export async function GET(request: NextRequest) {
|
|
11
|
+
const { searchParams } = new URL(request.url);
|
|
12
|
+
const sessionKey = searchParams.get("sessionKey");
|
|
13
|
+
|
|
14
|
+
if (!sessionKey) {
|
|
15
|
+
return NextResponse.json(
|
|
16
|
+
{ error: "sessionKey is required" },
|
|
17
|
+
{ status: 400 }
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const gateway = ensureGateway();
|
|
23
|
+
const result = await gateway.request<SessionStatus>("session.status", {
|
|
24
|
+
sessionKey,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return NextResponse.json(result);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const message = (err as Error).message;
|
|
30
|
+
|
|
31
|
+
// Session not found is not an error — just return empty
|
|
32
|
+
if (message.includes("not found") || message.includes("unknown")) {
|
|
33
|
+
return new NextResponse(null, { status: 204 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.error("[Session Status] Failed:", message);
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ error: sanitizeForApi(message) },
|
|
39
|
+
{ status: 502 }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|