@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.
- package/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- 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 +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -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 +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- 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 +328 -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 +168 -0
- package/src/components/chat/message-list.tsx +666 -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 +444 -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 +110 -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/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- 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
|
+
}
|