@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,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import useSWR from "swr";
|
|
4
|
+
|
|
5
|
+
const SETTINGS_KEY = "/api/settings";
|
|
6
|
+
|
|
7
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
8
|
+
|
|
9
|
+
interface UserSettings {
|
|
10
|
+
displayName?: string;
|
|
11
|
+
avatarPath?: string;
|
|
12
|
+
tooltips?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shared hook for user settings (display name, avatar, etc).
|
|
17
|
+
*
|
|
18
|
+
* All consumers share the same SWR cache. Call `refresh()` after
|
|
19
|
+
* updating settings to instantly propagate changes everywhere
|
|
20
|
+
* (user menu, chat headers, message avatars).
|
|
21
|
+
*/
|
|
22
|
+
export function useUserSettings() {
|
|
23
|
+
const { data, mutate, isLoading } = useSWR<UserSettings>(
|
|
24
|
+
SETTINGS_KEY,
|
|
25
|
+
fetcher,
|
|
26
|
+
{
|
|
27
|
+
revalidateOnFocus: true,
|
|
28
|
+
dedupingInterval: 5000,
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const displayName = data?.displayName || "";
|
|
33
|
+
const avatarUrl = data?.avatarPath
|
|
34
|
+
? `/api/settings/avatar?v=${encodeURIComponent(data.avatarPath)}`
|
|
35
|
+
: null;
|
|
36
|
+
const tooltips = data?.tooltips !== "false"; // default true
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
displayName,
|
|
40
|
+
avatarUrl,
|
|
41
|
+
tooltips,
|
|
42
|
+
isLoading,
|
|
43
|
+
/** Call after saving settings to refresh all consumers */
|
|
44
|
+
refresh: () => mutate(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSE singleton for Castle.
|
|
3
|
+
*
|
|
4
|
+
* Creates a SINGLE EventSource connection to /api/openclaw/events that is
|
|
5
|
+
* shared across all consumers (use-chat, use-openclaw, etc.).
|
|
6
|
+
*
|
|
7
|
+
* Ref-counted: the first subscriber opens the connection, the last
|
|
8
|
+
* unsubscribe closes it. Events are dispatched to handlers by event type.
|
|
9
|
+
*
|
|
10
|
+
* Deduplication: tracks the last seen `seq` number and drops duplicates
|
|
11
|
+
* as a safety net against multiple connections or reconnect replays.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface SSEEvent {
|
|
15
|
+
event: string;
|
|
16
|
+
payload?: unknown;
|
|
17
|
+
seq?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type EventHandler = (evt: SSEEvent) => void;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Module-level singleton state
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
let es: EventSource | null = null;
|
|
27
|
+
let refCount = 0;
|
|
28
|
+
let lastSeq = -1;
|
|
29
|
+
let lastEventTimestamp = Date.now();
|
|
30
|
+
|
|
31
|
+
/** Handlers keyed by event pattern. Exact match or "*" for all events. */
|
|
32
|
+
const handlers = new Map<string, Set<EventHandler>>();
|
|
33
|
+
|
|
34
|
+
/** Error handlers notified on connection loss */
|
|
35
|
+
const errorHandlers = new Set<() => void>();
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Internal helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function matchesPattern(eventType: string, pattern: string): boolean {
|
|
42
|
+
if (pattern === "*") return true;
|
|
43
|
+
if (pattern.endsWith(".*")) {
|
|
44
|
+
return eventType.startsWith(pattern.slice(0, -1));
|
|
45
|
+
}
|
|
46
|
+
return eventType === pattern;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dispatch(evt: SSEEvent) {
|
|
50
|
+
// Update last-event timestamp for heartbeat monitoring
|
|
51
|
+
lastEventTimestamp = Date.now();
|
|
52
|
+
|
|
53
|
+
// Dedup by seq — drop events we've already seen
|
|
54
|
+
if (typeof evt.seq === "number") {
|
|
55
|
+
if (evt.seq <= lastSeq) {
|
|
56
|
+
console.debug("[SSE Client] Dropped duplicate event seq:", evt.seq, "event:", evt.event);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
lastSeq = evt.seq;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const eventType = evt.event;
|
|
63
|
+
for (const [pattern, handlerSet] of handlers) {
|
|
64
|
+
if (matchesPattern(eventType, pattern)) {
|
|
65
|
+
for (const handler of handlerSet) {
|
|
66
|
+
try {
|
|
67
|
+
handler(evt);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("[SSE] Handler error:", err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function openConnection() {
|
|
77
|
+
if (es) return;
|
|
78
|
+
|
|
79
|
+
console.log("[SSE Client] Opening connection (subscribers:", refCount + ")");
|
|
80
|
+
es = new EventSource("/api/openclaw/events");
|
|
81
|
+
lastEventTimestamp = Date.now();
|
|
82
|
+
|
|
83
|
+
es.onopen = () => {
|
|
84
|
+
console.log("[SSE Client] Connected");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
es.onmessage = (e) => {
|
|
88
|
+
try {
|
|
89
|
+
const evt: SSEEvent = JSON.parse(e.data);
|
|
90
|
+
dispatch(evt);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn("[SSE Client] Failed to parse event:", (err as Error).message, "data:", e.data?.slice(0, 100));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
es.onerror = (e) => {
|
|
97
|
+
console.warn("[SSE Client] Connection error (readyState:", es?.readyState + ")");
|
|
98
|
+
// EventSource auto-reconnects. Notify error handlers so they can
|
|
99
|
+
// update UI state (e.g. show "disconnected").
|
|
100
|
+
for (const handler of errorHandlers) {
|
|
101
|
+
try {
|
|
102
|
+
handler();
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeConnection() {
|
|
111
|
+
if (es) {
|
|
112
|
+
console.log("[SSE Client] Closing connection");
|
|
113
|
+
es.close();
|
|
114
|
+
es = null;
|
|
115
|
+
}
|
|
116
|
+
lastSeq = -1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Public API
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Subscribe to SSE events matching a pattern.
|
|
125
|
+
*
|
|
126
|
+
* Patterns:
|
|
127
|
+
* - `"chat"` — exact match for chat events
|
|
128
|
+
* - `"castle.state"` — exact match for state events
|
|
129
|
+
* - `"agent.*"` — wildcard: matches agent.created, agent.updated, etc.
|
|
130
|
+
* - `"*"` — all events
|
|
131
|
+
*
|
|
132
|
+
* Returns an unsubscribe function. Call it in your useEffect cleanup.
|
|
133
|
+
*/
|
|
134
|
+
export function subscribe(pattern: string, handler: EventHandler): () => void {
|
|
135
|
+
if (!handlers.has(pattern)) {
|
|
136
|
+
handlers.set(pattern, new Set());
|
|
137
|
+
}
|
|
138
|
+
handlers.get(pattern)!.add(handler);
|
|
139
|
+
|
|
140
|
+
refCount++;
|
|
141
|
+
if (refCount === 1) {
|
|
142
|
+
openConnection();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
const set = handlers.get(pattern);
|
|
147
|
+
if (set) {
|
|
148
|
+
set.delete(handler);
|
|
149
|
+
if (set.size === 0) handlers.delete(pattern);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
refCount--;
|
|
153
|
+
if (refCount <= 0) {
|
|
154
|
+
refCount = 0;
|
|
155
|
+
closeConnection();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Subscribe to connection error events.
|
|
162
|
+
* Returns an unsubscribe function.
|
|
163
|
+
*/
|
|
164
|
+
export function onError(handler: () => void): () => void {
|
|
165
|
+
errorHandlers.add(handler);
|
|
166
|
+
return () => {
|
|
167
|
+
errorHandlers.delete(handler);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the timestamp of the last received SSE event.
|
|
173
|
+
* Used for heartbeat-based timeout detection.
|
|
174
|
+
*/
|
|
175
|
+
export function getLastEventTimestamp(): number {
|
|
176
|
+
return lastEventTimestamp;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if the SSE connection is currently open.
|
|
181
|
+
*/
|
|
182
|
+
export function isConnected(): boolean {
|
|
183
|
+
return es !== null && es.readyState !== EventSource.CLOSED;
|
|
184
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Channel
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export interface Channel {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
defaultAgentId: string;
|
|
9
|
+
agents: string[]; // Agent IDs in this channel
|
|
10
|
+
createdAt: number; // unix ms
|
|
11
|
+
archivedAt?: number | null; // unix ms — null if active
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Session
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
export interface ChannelSession {
|
|
19
|
+
id: string;
|
|
20
|
+
channelId: string;
|
|
21
|
+
sessionKey: string | null; // Gateway session key
|
|
22
|
+
startedAt: number; // unix ms
|
|
23
|
+
endedAt: number | null; // unix ms
|
|
24
|
+
summary: string | null;
|
|
25
|
+
totalInputTokens: number;
|
|
26
|
+
totalOutputTokens: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Message
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
export type MessageStatus = "complete" | "interrupted" | "aborted";
|
|
34
|
+
|
|
35
|
+
export interface ChatMessage {
|
|
36
|
+
id: string;
|
|
37
|
+
channelId: string;
|
|
38
|
+
sessionId: string | null;
|
|
39
|
+
senderType: "user" | "agent";
|
|
40
|
+
senderId: string;
|
|
41
|
+
senderName: string | null;
|
|
42
|
+
content: string;
|
|
43
|
+
status: MessageStatus;
|
|
44
|
+
mentionedAgentId: string | null;
|
|
45
|
+
runId: string | null; // Gateway run ID for streaming correlation
|
|
46
|
+
sessionKey: string | null; // Gateway session key
|
|
47
|
+
inputTokens: number | null;
|
|
48
|
+
outputTokens: number | null;
|
|
49
|
+
createdAt: number; // unix ms
|
|
50
|
+
attachments: MessageAttachment[];
|
|
51
|
+
reactions: MessageReaction[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Attachment
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export interface MessageAttachment {
|
|
59
|
+
id: string;
|
|
60
|
+
messageId: string;
|
|
61
|
+
attachmentType: "image" | "audio";
|
|
62
|
+
filePath: string;
|
|
63
|
+
mimeType: string | null;
|
|
64
|
+
fileSize: number | null;
|
|
65
|
+
originalName: string | null;
|
|
66
|
+
createdAt: number; // unix ms
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Reaction
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
export interface MessageReaction {
|
|
74
|
+
id: string;
|
|
75
|
+
messageId: string;
|
|
76
|
+
agentId: string | null;
|
|
77
|
+
emoji: string;
|
|
78
|
+
emojiChar: string;
|
|
79
|
+
createdAt: number; // unix ms
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Streaming
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/** A message currently being streamed from the Gateway */
|
|
87
|
+
export interface StreamingMessage {
|
|
88
|
+
runId: string;
|
|
89
|
+
agentId: string;
|
|
90
|
+
agentName: string;
|
|
91
|
+
sessionKey: string;
|
|
92
|
+
content: string; // Accumulated text so far
|
|
93
|
+
startedAt: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A single streaming delta from the Gateway SSE */
|
|
97
|
+
export interface ChatDelta {
|
|
98
|
+
runId: string;
|
|
99
|
+
sessionKey: string;
|
|
100
|
+
seq: number;
|
|
101
|
+
state: "delta" | "final" | "error";
|
|
102
|
+
text?: string;
|
|
103
|
+
errorMessage?: string;
|
|
104
|
+
message?: {
|
|
105
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
106
|
+
role?: string;
|
|
107
|
+
inputTokens?: number;
|
|
108
|
+
outputTokens?: number;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Message Queue
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
export interface QueuedMessage {
|
|
117
|
+
id: string; // Temp ID for tracking
|
|
118
|
+
content: string;
|
|
119
|
+
agentId?: string;
|
|
120
|
+
attachments?: File[];
|
|
121
|
+
addedAt: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Session Status (read from OpenClaw session store on filesystem)
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
export interface SessionStatus {
|
|
129
|
+
sessionKey: string;
|
|
130
|
+
sessionId: string;
|
|
131
|
+
agentId: string;
|
|
132
|
+
model: string;
|
|
133
|
+
modelProvider: string;
|
|
134
|
+
tokens: {
|
|
135
|
+
input: number;
|
|
136
|
+
output: number;
|
|
137
|
+
total: number;
|
|
138
|
+
};
|
|
139
|
+
context: {
|
|
140
|
+
used: number; // totalTokens (current context window usage)
|
|
141
|
+
limit: number; // effective operating limit (from config, default 200k)
|
|
142
|
+
modelMax: number; // model's theoretical max (e.g. 1M for Sonnet 4.5)
|
|
143
|
+
percentage: number; // used / limit * 100
|
|
144
|
+
};
|
|
145
|
+
compactions: number;
|
|
146
|
+
thinkingLevel: string | null;
|
|
147
|
+
updatedAt: number;
|
|
148
|
+
/** System prompt breakdown — only present when the session has run at least once */
|
|
149
|
+
systemPrompt?: {
|
|
150
|
+
totalChars: number;
|
|
151
|
+
projectContextChars: number;
|
|
152
|
+
nonProjectContextChars: number;
|
|
153
|
+
skills: { promptChars: number; count: number };
|
|
154
|
+
tools: { listChars: number; schemaChars: number; count: number };
|
|
155
|
+
workspaceFiles: Array<{
|
|
156
|
+
name: string;
|
|
157
|
+
injectedChars: number;
|
|
158
|
+
truncated: boolean;
|
|
159
|
+
}>;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Storage Stats
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
export interface StorageStats {
|
|
168
|
+
messages: number;
|
|
169
|
+
channels: number;
|
|
170
|
+
attachments: number;
|
|
171
|
+
totalAttachmentBytes: number;
|
|
172
|
+
dbSizeBytes?: number; // Size of castle.db file
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// API Payloads
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
export interface ChatSendRequest {
|
|
180
|
+
channelId: string;
|
|
181
|
+
content: string;
|
|
182
|
+
agentId?: string;
|
|
183
|
+
attachmentIds?: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ChatSendResponse {
|
|
187
|
+
runId: string;
|
|
188
|
+
messageId: string;
|
|
189
|
+
sessionKey: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ChatCompleteRequest {
|
|
193
|
+
runId: string;
|
|
194
|
+
channelId: string;
|
|
195
|
+
content: string;
|
|
196
|
+
sessionKey: string;
|
|
197
|
+
agentId: string;
|
|
198
|
+
agentName?: string;
|
|
199
|
+
status: MessageStatus;
|
|
200
|
+
inputTokens?: number;
|
|
201
|
+
outputTokens?: number;
|
|
202
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Universal Search — Type System
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Discriminated union so every result carries its type and the dialog can
|
|
6
|
+
// render + navigate each type differently. Adding a new searchable content
|
|
7
|
+
// type later requires only:
|
|
8
|
+
//
|
|
9
|
+
// 1. Add a new interface extending SearchResultBase
|
|
10
|
+
// 2. Add it to the SearchResult union
|
|
11
|
+
// 3. Add a renderer in the search dialog
|
|
12
|
+
// 4. Add a search function in the API
|
|
13
|
+
//
|
|
14
|
+
// No changes to the dialog, hook, provider, keyboard shortcuts, or routing.
|
|
15
|
+
|
|
16
|
+
// --- Content type registry ---
|
|
17
|
+
|
|
18
|
+
export type SearchResultType = "message" | "task" | "note" | "project";
|
|
19
|
+
// ^ Extend this union as new content types are added
|
|
20
|
+
|
|
21
|
+
// --- Base result shape (shared fields) ---
|
|
22
|
+
|
|
23
|
+
export interface SearchResultBase {
|
|
24
|
+
id: string;
|
|
25
|
+
type: SearchResultType;
|
|
26
|
+
title: string; // Primary display text
|
|
27
|
+
subtitle?: string; // Secondary line (sender, project name, etc.)
|
|
28
|
+
snippet: string; // Content excerpt with match context
|
|
29
|
+
timestamp: number; // For sorting / display
|
|
30
|
+
href: string; // Where to navigate on click
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Content-specific result types ---
|
|
34
|
+
|
|
35
|
+
export interface MessageSearchResult extends SearchResultBase {
|
|
36
|
+
type: "message";
|
|
37
|
+
channelId: string;
|
|
38
|
+
channelName: string;
|
|
39
|
+
messageId: string;
|
|
40
|
+
senderType: "user" | "agent";
|
|
41
|
+
senderName: string;
|
|
42
|
+
archived?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Future examples (not implemented in v1):
|
|
46
|
+
// export interface TaskSearchResult extends SearchResultBase {
|
|
47
|
+
// type: "task";
|
|
48
|
+
// projectId: string;
|
|
49
|
+
// status: "todo" | "in_progress" | "done";
|
|
50
|
+
// }
|
|
51
|
+
//
|
|
52
|
+
// export interface NoteSearchResult extends SearchResultBase {
|
|
53
|
+
// type: "note";
|
|
54
|
+
// notebookId: string;
|
|
55
|
+
// }
|
|
56
|
+
|
|
57
|
+
// --- Discriminated union ---
|
|
58
|
+
|
|
59
|
+
export type SearchResult = MessageSearchResult;
|
|
60
|
+
// ^ Becomes: MessageSearchResult | TaskSearchResult | NoteSearchResult | ...
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Next.js Middleware — applies security checks to all API routes.
|
|
5
|
+
*
|
|
6
|
+
* In production, rejects requests from non-localhost IPs.
|
|
7
|
+
* Castle is a local-first app and should never be exposed to the internet.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LOCALHOST_HOSTS = new Set([
|
|
11
|
+
"localhost",
|
|
12
|
+
"127.0.0.1",
|
|
13
|
+
"::1",
|
|
14
|
+
"::ffff:127.0.0.1",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function middleware(request: NextRequest) {
|
|
18
|
+
// Only enforce in production — dev is always allowed
|
|
19
|
+
if (process.env.NODE_ENV === "development") {
|
|
20
|
+
return NextResponse.next();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Check if the request originates from localhost
|
|
24
|
+
const host = request.headers.get("host") || "";
|
|
25
|
+
const hostname = host.split(":")[0];
|
|
26
|
+
|
|
27
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
28
|
+
const clientIp = forwarded?.split(",")[0].trim();
|
|
29
|
+
|
|
30
|
+
// If x-forwarded-for is present and it's not localhost, reject
|
|
31
|
+
if (clientIp && !LOCALHOST_HOSTS.has(clientIp)) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: "Forbidden — Castle is only accessible from localhost" },
|
|
34
|
+
{ status: 403 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If the host header isn't a localhost variant, reject
|
|
39
|
+
if (hostname && !LOCALHOST_HOSTS.has(hostname)) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: "Forbidden — Castle is only accessible from localhost" },
|
|
42
|
+
{ status: 403 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return NextResponse.next();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Only run middleware on API routes
|
|
50
|
+
export const config = {
|
|
51
|
+
matcher: "/api/:path*",
|
|
52
|
+
};
|