@castlekit/castle 0.1.6 → 0.3.1

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. package/src/middleware.ts +52 -0
@@ -0,0 +1,217 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { checkCsrf, sanitizeForApi } from "@/lib/api-security";
3
+ import {
4
+ createChannel,
5
+ getChannels,
6
+ getChannel,
7
+ updateChannel,
8
+ deleteChannel,
9
+ archiveChannel,
10
+ restoreChannel,
11
+ touchChannel,
12
+ getLastAccessedChannelId,
13
+ } from "@/lib/db/queries";
14
+
15
+ const MAX_CHANNEL_NAME_LENGTH = 100;
16
+
17
+ /** Strip control characters from a string (keep printable + whitespace) */
18
+ function sanitizeName(name: string): string {
19
+ // eslint-disable-next-line no-control-regex
20
+ return name.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
21
+ }
22
+
23
+ /** Validate agent ID format */
24
+ function isValidAgentId(id: string): boolean {
25
+ return /^[a-zA-Z0-9._-]+$/.test(id);
26
+ }
27
+
28
+ // ============================================================================
29
+ // GET /api/openclaw/chat/channels — List channels
30
+ // ============================================================================
31
+
32
+ export async function GET(request: NextRequest) {
33
+ const { searchParams } = new URL(request.url);
34
+
35
+ try {
36
+ // GET /api/openclaw/chat/channels?last=1 — get last accessed channel ID
37
+ if (searchParams.get("last")) {
38
+ const lastId = getLastAccessedChannelId();
39
+ return NextResponse.json({ channelId: lastId });
40
+ }
41
+
42
+ const archived = searchParams.get("archived") === "1";
43
+ const all = getChannels(archived);
44
+ console.log(`[Channels API] GET list OK — ${all.length} channels (archived=${archived})`);
45
+ return NextResponse.json({ channels: all });
46
+ } catch (err) {
47
+ console.error("[Channels API] GET list FAILED:", (err as Error).message);
48
+ return NextResponse.json(
49
+ { error: sanitizeForApi((err as Error).message) },
50
+ { status: 500 }
51
+ );
52
+ }
53
+ }
54
+
55
+ // ============================================================================
56
+ // POST /api/openclaw/chat/channels — Create / update / delete channel
57
+ // ============================================================================
58
+
59
+ export async function POST(request: NextRequest) {
60
+ const csrf = checkCsrf(request);
61
+ if (csrf) return csrf;
62
+
63
+ let body: {
64
+ action?: "create" | "update" | "delete" | "archive" | "restore" | "touch";
65
+ id?: string;
66
+ name?: string;
67
+ defaultAgentId?: string;
68
+ agents?: string[];
69
+ };
70
+
71
+ try {
72
+ body = await request.json();
73
+ } catch {
74
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
75
+ }
76
+
77
+ const action = body.action || "create";
78
+
79
+ // ------ TOUCH (mark as last accessed) ------
80
+ if (action === "touch") {
81
+ if (!body.id) {
82
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
83
+ }
84
+ touchChannel(body.id);
85
+ return NextResponse.json({ ok: true });
86
+ }
87
+
88
+ // ------ ARCHIVE ------
89
+ if (action === "archive") {
90
+ if (!body.id) {
91
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
92
+ }
93
+ const archived = archiveChannel(body.id);
94
+ if (!archived) {
95
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
96
+ }
97
+ return NextResponse.json({ ok: true });
98
+ }
99
+
100
+ // ------ RESTORE ------
101
+ if (action === "restore") {
102
+ if (!body.id) {
103
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
104
+ }
105
+ const restored = restoreChannel(body.id);
106
+ if (!restored) {
107
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
108
+ }
109
+ return NextResponse.json({ ok: true });
110
+ }
111
+
112
+ // ------ DELETE (permanent — only for archived channels) ------
113
+ if (action === "delete") {
114
+ if (!body.id) {
115
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
116
+ }
117
+ // Only allow deleting archived channels
118
+ const ch = getChannel(body.id);
119
+ if (!ch) {
120
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
121
+ }
122
+ if (!ch.archivedAt) {
123
+ return NextResponse.json(
124
+ { error: "Channel must be archived before it can be permanently deleted" },
125
+ { status: 400 }
126
+ );
127
+ }
128
+ try {
129
+ const deleted = deleteChannel(body.id);
130
+ if (!deleted) {
131
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
132
+ }
133
+ console.log(`[Channels API] POST delete OK — id=${body.id}`);
134
+ return NextResponse.json({ ok: true });
135
+ } catch (err) {
136
+ console.error(`[Channels API] POST delete FAILED — id=${body.id}:`, (err as Error).message);
137
+ return NextResponse.json(
138
+ { error: sanitizeForApi((err as Error).message) },
139
+ { status: 500 }
140
+ );
141
+ }
142
+ }
143
+
144
+ // ------ UPDATE ------
145
+ if (action === "update") {
146
+ if (!body.id) {
147
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
148
+ }
149
+ const updates: { name?: string; defaultAgentId?: string } = {};
150
+ if (body.name) {
151
+ const cleanName = sanitizeName(body.name);
152
+ if (!cleanName || cleanName.length > MAX_CHANNEL_NAME_LENGTH) {
153
+ return NextResponse.json(
154
+ { error: `Channel name must be 1-${MAX_CHANNEL_NAME_LENGTH} characters` },
155
+ { status: 400 }
156
+ );
157
+ }
158
+ updates.name = cleanName;
159
+ }
160
+ if (body.defaultAgentId) {
161
+ if (!isValidAgentId(body.defaultAgentId)) {
162
+ return NextResponse.json({ error: "Invalid agent ID format" }, { status: 400 });
163
+ }
164
+ updates.defaultAgentId = body.defaultAgentId;
165
+ }
166
+ const updated = updateChannel(body.id, updates);
167
+ if (!updated) {
168
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
169
+ }
170
+ const channel = getChannel(body.id);
171
+ return NextResponse.json({ channel });
172
+ }
173
+
174
+ // ------ CREATE ------
175
+ if (!body.name || typeof body.name !== "string") {
176
+ return NextResponse.json({ error: "name is required" }, { status: 400 });
177
+ }
178
+ if (!body.defaultAgentId || typeof body.defaultAgentId !== "string") {
179
+ return NextResponse.json({ error: "defaultAgentId is required" }, { status: 400 });
180
+ }
181
+
182
+ const cleanName = sanitizeName(body.name);
183
+ if (!cleanName || cleanName.length > MAX_CHANNEL_NAME_LENGTH) {
184
+ return NextResponse.json(
185
+ { error: `Channel name must be 1-${MAX_CHANNEL_NAME_LENGTH} characters` },
186
+ { status: 400 }
187
+ );
188
+ }
189
+
190
+ if (!isValidAgentId(body.defaultAgentId)) {
191
+ return NextResponse.json({ error: "Invalid agent ID format" }, { status: 400 });
192
+ }
193
+
194
+ // Validate all agent IDs
195
+ if (body.agents) {
196
+ for (const agentId of body.agents) {
197
+ if (!isValidAgentId(agentId)) {
198
+ return NextResponse.json(
199
+ { error: `Invalid agent ID format: ${agentId}` },
200
+ { status: 400 }
201
+ );
202
+ }
203
+ }
204
+ }
205
+
206
+ try {
207
+ const channel = createChannel(cleanName, body.defaultAgentId, body.agents);
208
+ console.log(`[Channels API] POST create OK — id=${channel.id} name="${cleanName}"`);
209
+ return NextResponse.json({ channel }, { status: 201 });
210
+ } catch (err) {
211
+ console.error(`[Channels API] POST create FAILED — name="${cleanName}":`, (err as Error).message);
212
+ return NextResponse.json(
213
+ { error: sanitizeForApi((err as Error).message) },
214
+ { status: 500 }
215
+ );
216
+ }
217
+ }
@@ -0,0 +1,283 @@
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 _start = Date.now();
26
+ const csrf = checkCsrf(request);
27
+ if (csrf) return csrf;
28
+
29
+ // Rate limit: 30 messages per minute
30
+ const rl = checkRateLimit(rateLimitKey(request, "chat:send"), 30);
31
+ if (rl) {
32
+ console.warn("[Chat API] Rate limited on chat:send");
33
+ return rl;
34
+ }
35
+
36
+ let body: ChatSendRequest;
37
+ try {
38
+ body = await request.json();
39
+ } catch {
40
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
41
+ }
42
+
43
+ // Validate
44
+ if (!body.channelId || typeof body.channelId !== "string") {
45
+ return NextResponse.json({ error: "channelId is required" }, { status: 400 });
46
+ }
47
+ if (!body.content || typeof body.content !== "string") {
48
+ return NextResponse.json({ error: "content is required" }, { status: 400 });
49
+ }
50
+ if (body.content.length > MAX_MESSAGE_LENGTH) {
51
+ return NextResponse.json(
52
+ { error: `Message too long (max ${MAX_MESSAGE_LENGTH} chars)` },
53
+ { status: 400 }
54
+ );
55
+ }
56
+
57
+ // Persist user message to DB
58
+ const userMsg = createMessage({
59
+ channelId: body.channelId,
60
+ senderType: "user",
61
+ senderId: "local-user",
62
+ senderName: "You",
63
+ content: body.content,
64
+ mentionedAgentId: body.agentId,
65
+ });
66
+
67
+ try {
68
+ const gateway = ensureGateway();
69
+
70
+ // Get existing session key for this channel (if any)
71
+ let sessionKey = getLatestSessionKey(body.channelId);
72
+
73
+ // If no existing session, create one with a proper structured key:
74
+ // agent:<agentId>:castle:<channelId>
75
+ // This maps Castle channels to Gateway sessions 1:1, so the session
76
+ // is stable and traceable between Castle DB and Gateway transcripts.
77
+ if (!sessionKey) {
78
+ const agentId = body.agentId || "main";
79
+ sessionKey = `agent:${agentId}:castle:${body.channelId}`;
80
+
81
+ createSession({
82
+ channelId: body.channelId,
83
+ sessionKey,
84
+ });
85
+ }
86
+
87
+ // Build chat.send params per Gateway protocol
88
+ const rpcParams: Record<string, unknown> = {
89
+ message: body.content,
90
+ sessionKey,
91
+ idempotencyKey: randomUUID(),
92
+ timeoutMs: 120000,
93
+ };
94
+
95
+ const result = await gateway.request<{
96
+ runId: string;
97
+ status?: string;
98
+ }>("chat.send", rpcParams);
99
+
100
+ // Update user message with runId and sessionKey
101
+ const runId = result.runId;
102
+ updateMessage(userMsg.id, {
103
+ runId,
104
+ sessionKey,
105
+ });
106
+
107
+ console.log(`[Chat API] POST send OK — runId=${runId} channel=${body.channelId} (${Date.now() - _start}ms)`);
108
+ return NextResponse.json({
109
+ runId,
110
+ messageId: userMsg.id,
111
+ sessionKey,
112
+ });
113
+ } catch (err) {
114
+ // RPC failed — try to remove the optimistic user message
115
+ try {
116
+ deleteMessage(userMsg.id);
117
+ } catch (delErr) {
118
+ console.error("[Chat API] Cleanup of optimistic message failed:", (delErr as Error).message);
119
+ }
120
+ console.error(`[Chat API] POST send FAILED — channel=${body.channelId} (${Date.now() - _start}ms):`, (err as Error).message);
121
+ return NextResponse.json(
122
+ { error: sanitizeForApi((err as Error).message) },
123
+ { status: 502 }
124
+ );
125
+ }
126
+ }
127
+
128
+ // ============================================================================
129
+ // PUT /api/openclaw/chat — Complete/update an agent message
130
+ // ============================================================================
131
+
132
+ export async function PUT(request: NextRequest) {
133
+ const _start = Date.now();
134
+ const csrf = checkCsrf(request);
135
+ if (csrf) return csrf;
136
+
137
+ let body: ChatCompleteRequest;
138
+ try {
139
+ body = await request.json();
140
+ } catch {
141
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
142
+ }
143
+
144
+ if (!body.runId || !body.channelId || !body.content) {
145
+ return NextResponse.json(
146
+ { error: "runId, channelId, and content are required" },
147
+ { status: 400 }
148
+ );
149
+ }
150
+
151
+ try {
152
+ // Check if we already have a message for this runId (from a previous partial save)
153
+ const existing = getMessageByRunId(body.runId);
154
+
155
+ if (existing) {
156
+ // Already complete with same content — idempotent, skip update to avoid
157
+ // triggering FTS5 update trigger with identical content (causes SQL error)
158
+ if (existing.status === "complete" && existing.content === body.content) {
159
+ return NextResponse.json({ messageId: existing.id, updated: false });
160
+ }
161
+
162
+ // Update existing message (e.g. partial → complete, or content changed)
163
+ updateMessage(existing.id, {
164
+ content: body.content,
165
+ status: body.status,
166
+ sessionKey: body.sessionKey,
167
+ inputTokens: body.inputTokens,
168
+ outputTokens: body.outputTokens,
169
+ });
170
+ return NextResponse.json({ messageId: existing.id, updated: true });
171
+ }
172
+
173
+ // Create new agent message
174
+ const agentMsg = createMessage({
175
+ channelId: body.channelId,
176
+ senderType: "agent",
177
+ senderId: body.agentId,
178
+ senderName: body.agentName,
179
+ content: body.content,
180
+ status: body.status,
181
+ runId: body.runId,
182
+ sessionKey: body.sessionKey,
183
+ inputTokens: body.inputTokens,
184
+ outputTokens: body.outputTokens,
185
+ });
186
+
187
+ console.log(`[Chat API] PUT complete OK — runId=${body.runId} new msg=${agentMsg.id} (${Date.now() - _start}ms)`);
188
+ return NextResponse.json({ messageId: agentMsg.id, updated: false });
189
+ } catch (err) {
190
+ console.error(`[Chat API] PUT complete FAILED — runId=${body.runId} (${Date.now() - _start}ms):`, (err as Error).message);
191
+ return NextResponse.json(
192
+ { error: sanitizeForApi((err as Error).message) },
193
+ { status: 500 }
194
+ );
195
+ }
196
+ }
197
+
198
+ // ============================================================================
199
+ // DELETE /api/openclaw/chat — Abort streaming
200
+ // ============================================================================
201
+
202
+ export async function DELETE(request: NextRequest) {
203
+ const csrf = checkCsrf(request);
204
+ if (csrf) return csrf;
205
+
206
+ console.log("[Chat API] DELETE abort requested");
207
+ try {
208
+ const gateway = ensureGateway();
209
+ await gateway.request("chat.abort", {});
210
+ console.log("[Chat API] DELETE abort OK");
211
+ return NextResponse.json({ ok: true });
212
+ } catch (err) {
213
+ console.error("[Chat API] DELETE abort FAILED:", (err as Error).message);
214
+ return NextResponse.json(
215
+ { error: sanitizeForApi((err as Error).message) },
216
+ { status: 502 }
217
+ );
218
+ }
219
+ }
220
+
221
+ // ============================================================================
222
+ // GET /api/openclaw/chat?channelId=X&limit=50&before=Y — Load history
223
+ // ?around=msgId — Load a window centered on a specific message
224
+ // ?after=msgId — Forward pagination (newer messages)
225
+ // ?before=msgId — Backward pagination (older messages, existing)
226
+ // ============================================================================
227
+
228
+ export async function GET(request: NextRequest) {
229
+ const _start = Date.now();
230
+ const { searchParams } = new URL(request.url);
231
+ const channelId = searchParams.get("channelId");
232
+ const limit = Math.min(parseInt(searchParams.get("limit") || "50", 10), 200);
233
+ const around = searchParams.get("around") || undefined;
234
+ const after = searchParams.get("after") || undefined;
235
+ const before = searchParams.get("before") || undefined;
236
+
237
+ if (!channelId) {
238
+ return NextResponse.json({ error: "channelId is required" }, { status: 400 });
239
+ }
240
+
241
+ try {
242
+ // Anchor mode: load a window of messages around a specific message
243
+ if (around) {
244
+ const result = getMessagesAround(channelId, around, limit);
245
+ if (!result) {
246
+ // Anchor message not found — fall back to latest messages
247
+ const msgs = getMessagesByChannel(channelId, limit);
248
+ return NextResponse.json({
249
+ messages: msgs,
250
+ hasMore: msgs.length === limit,
251
+ });
252
+ }
253
+ return NextResponse.json({
254
+ messages: result.messages,
255
+ hasMoreBefore: result.hasMoreBefore,
256
+ hasMoreAfter: result.hasMoreAfter,
257
+ });
258
+ }
259
+
260
+ // Forward pagination: load messages newer than cursor
261
+ if (after) {
262
+ const msgs = getMessagesAfter(channelId, after, limit);
263
+ return NextResponse.json({
264
+ messages: msgs,
265
+ hasMore: msgs.length === limit,
266
+ });
267
+ }
268
+
269
+ // Default / backward pagination
270
+ const msgs = getMessagesByChannel(channelId, limit, before);
271
+ console.log(`[Chat API] GET history OK — channel=${channelId} msgs=${msgs.length} (${Date.now() - _start}ms)`);
272
+ return NextResponse.json({
273
+ messages: msgs,
274
+ hasMore: msgs.length === limit,
275
+ });
276
+ } catch (err) {
277
+ console.error(`[Chat API] GET history FAILED — channel=${channelId} (${Date.now() - _start}ms):`, (err as Error).message);
278
+ return NextResponse.json(
279
+ { error: sanitizeForApi((err as Error).message) },
280
+ { status: 500 }
281
+ );
282
+ }
283
+ }
@@ -0,0 +1,150 @@
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
+ console.log(`[Search API] GET OK — query="${q.slice(0, 50)}" results=${results.length}`);
97
+ return NextResponse.json({ results });
98
+ } catch (err) {
99
+ console.error(`[Search API] GET FAILED — query="${q?.slice(0, 50)}":`, (err as Error).message);
100
+ return NextResponse.json(
101
+ { error: sanitizeForApi((err as Error).message) },
102
+ { status: 500 }
103
+ );
104
+ }
105
+ }
106
+
107
+ // ============================================================================
108
+ // POST /api/openclaw/chat/search — Save a recent search
109
+ // ============================================================================
110
+
111
+ export async function POST(request: NextRequest) {
112
+ const csrf = checkCsrf(request);
113
+ if (csrf) return csrf;
114
+
115
+ try {
116
+ const body = await request.json();
117
+ const q = body.query;
118
+ if (!q || typeof q !== "string" || !q.trim()) {
119
+ return NextResponse.json({ error: "query is required" }, { status: 400 });
120
+ }
121
+ addRecentSearch(q.trim());
122
+ return NextResponse.json({ ok: true });
123
+ } catch (err) {
124
+ console.error("[Chat Search] Save recent failed:", (err as Error).message);
125
+ return NextResponse.json(
126
+ { error: sanitizeForApi((err as Error).message) },
127
+ { status: 500 }
128
+ );
129
+ }
130
+ }
131
+
132
+ // ============================================================================
133
+ // DELETE /api/openclaw/chat/search — Clear all recent searches
134
+ // ============================================================================
135
+
136
+ export async function DELETE(request: NextRequest) {
137
+ const csrf = checkCsrf(request);
138
+ if (csrf) return csrf;
139
+
140
+ try {
141
+ clearRecentSearches();
142
+ return NextResponse.json({ ok: true });
143
+ } catch (err) {
144
+ console.error("[Chat Search] Clear recent failed:", (err as Error).message);
145
+ return NextResponse.json(
146
+ { error: sanitizeForApi((err as Error).message) },
147
+ { status: 500 }
148
+ );
149
+ }
150
+ }