@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,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Loader2, RotateCcw, Trash2, Hash, MessageCircle } from "lucide-react";
|
|
5
|
+
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { formatDateTime, formatTimeAgo } from "@/lib/date-utils";
|
|
8
|
+
import type { Channel } from "@/lib/types/chat";
|
|
9
|
+
|
|
10
|
+
interface ArchivedChannelsProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
onRestored?: (channel: Channel) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ArchivedChannels({
|
|
17
|
+
open,
|
|
18
|
+
onOpenChange,
|
|
19
|
+
onRestored,
|
|
20
|
+
}: ArchivedChannelsProps) {
|
|
21
|
+
const [channels, setChannels] = useState<Channel[]>([]);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
24
|
+
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
const fetchArchived = async () => {
|
|
27
|
+
setLoading(true);
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch("/api/openclaw/chat/channels?archived=1");
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
setChannels(data.channels || []);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// silent
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (open) {
|
|
43
|
+
fetchArchived();
|
|
44
|
+
setConfirmDelete(null);
|
|
45
|
+
}
|
|
46
|
+
}, [open]);
|
|
47
|
+
|
|
48
|
+
const handleRestore = async (channel: Channel) => {
|
|
49
|
+
setActionLoading(channel.id);
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch("/api/openclaw/chat/channels", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ action: "restore", id: channel.id }),
|
|
55
|
+
});
|
|
56
|
+
if (res.ok) {
|
|
57
|
+
setChannels((prev) => prev.filter((c) => c.id !== channel.id));
|
|
58
|
+
onRestored?.(channel);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// silent
|
|
62
|
+
} finally {
|
|
63
|
+
setActionLoading(null);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleDelete = async (id: string) => {
|
|
68
|
+
setActionLoading(id);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch("/api/openclaw/chat/channels", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ action: "delete", id }),
|
|
74
|
+
});
|
|
75
|
+
if (res.ok) {
|
|
76
|
+
setChannels((prev) => prev.filter((c) => c.id !== id));
|
|
77
|
+
setConfirmDelete(null);
|
|
78
|
+
} else {
|
|
79
|
+
const data = await res.json().catch(() => ({}));
|
|
80
|
+
console.error("[Archive] Delete failed:", res.status, data);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error("[Archive] Delete error:", err);
|
|
84
|
+
} finally {
|
|
85
|
+
setActionLoading(null);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
91
|
+
<DialogHeader>
|
|
92
|
+
<DialogTitle>Archived Channels</DialogTitle>
|
|
93
|
+
</DialogHeader>
|
|
94
|
+
|
|
95
|
+
<div className="min-h-[200px]">
|
|
96
|
+
{loading ? (
|
|
97
|
+
<div className="flex items-center justify-center py-12">
|
|
98
|
+
<Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
|
|
99
|
+
</div>
|
|
100
|
+
) : channels.length === 0 ? (
|
|
101
|
+
<div className="text-center py-12 text-foreground-secondary">
|
|
102
|
+
<MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
103
|
+
<p className="text-sm">No archived channels</p>
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
<div className="space-y-3">
|
|
107
|
+
{channels.map((channel) => (
|
|
108
|
+
<div
|
|
109
|
+
key={channel.id}
|
|
110
|
+
className="flex items-center justify-between px-3 py-2.5 rounded-[var(--radius-sm)] bg-surface-hover/50 hover:bg-surface-hover group"
|
|
111
|
+
>
|
|
112
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
113
|
+
<Hash className="h-4 w-4 shrink-0 text-foreground-secondary" strokeWidth={2.5} />
|
|
114
|
+
<div className="min-w-0">
|
|
115
|
+
<span className="text-sm text-foreground truncate block">
|
|
116
|
+
{channel.name}
|
|
117
|
+
</span>
|
|
118
|
+
<span className="text-xs text-foreground-secondary">
|
|
119
|
+
Created {formatDateTime(new Date(channel.createdAt).getTime())}
|
|
120
|
+
{channel.archivedAt && (
|
|
121
|
+
<> · Archived {formatTimeAgo(new Date(channel.archivedAt).getTime())}</>
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
128
|
+
{confirmDelete === channel.id ? (
|
|
129
|
+
<>
|
|
130
|
+
<span className="text-xs text-red-400 mr-1">
|
|
131
|
+
Delete forever?
|
|
132
|
+
</span>
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="sm"
|
|
136
|
+
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 px-2 text-xs"
|
|
137
|
+
onClick={() => handleDelete(channel.id)}
|
|
138
|
+
disabled={actionLoading === channel.id}
|
|
139
|
+
>
|
|
140
|
+
{actionLoading === channel.id ? (
|
|
141
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
142
|
+
) : (
|
|
143
|
+
"Yes"
|
|
144
|
+
)}
|
|
145
|
+
</Button>
|
|
146
|
+
<Button
|
|
147
|
+
variant="ghost"
|
|
148
|
+
size="sm"
|
|
149
|
+
className="h-7 px-2 text-xs"
|
|
150
|
+
onClick={() => setConfirmDelete(null)}
|
|
151
|
+
>
|
|
152
|
+
No
|
|
153
|
+
</Button>
|
|
154
|
+
</>
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<Button
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="icon"
|
|
160
|
+
className="h-7 w-7 text-foreground-secondary hover:text-foreground"
|
|
161
|
+
onClick={() => handleRestore(channel)}
|
|
162
|
+
disabled={actionLoading === channel.id}
|
|
163
|
+
title="Restore channel"
|
|
164
|
+
>
|
|
165
|
+
{actionLoading === channel.id ? (
|
|
166
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
167
|
+
) : (
|
|
168
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
169
|
+
)}
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
variant="ghost"
|
|
173
|
+
size="icon"
|
|
174
|
+
className="h-7 w-7 text-foreground-secondary hover:text-red-400"
|
|
175
|
+
onClick={() => setConfirmDelete(channel.id)}
|
|
176
|
+
title="Delete permanently"
|
|
177
|
+
>
|
|
178
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
179
|
+
</Button>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</Dialog>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Hash, MessageCircle, Loader2, Archive } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import type { Channel } from "@/lib/types/chat";
|
|
9
|
+
|
|
10
|
+
interface ChannelListProps {
|
|
11
|
+
activeChannelId?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
showCreateDialog?: boolean;
|
|
14
|
+
onCreateDialogChange?: (open: boolean) => void;
|
|
15
|
+
newChannel?: Channel | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ChannelList({
|
|
19
|
+
activeChannelId,
|
|
20
|
+
className,
|
|
21
|
+
onCreateDialogChange,
|
|
22
|
+
newChannel,
|
|
23
|
+
}: ChannelListProps) {
|
|
24
|
+
const router = useRouter();
|
|
25
|
+
const [channels, setChannels] = useState<Channel[]>([]);
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
|
|
28
|
+
const setShowCreate = onCreateDialogChange ?? (() => {});
|
|
29
|
+
|
|
30
|
+
const fetchChannels = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch("/api/openclaw/chat/channels");
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
setChannels(data.channels || []);
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error("Failed to fetch channels:", error);
|
|
39
|
+
} finally {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
fetchChannels();
|
|
46
|
+
}, []); // Only fetch once on mount — channel list doesn't change on navigation
|
|
47
|
+
|
|
48
|
+
// Instantly add a newly created channel to the list
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (newChannel && !channels.some((c) => c.id === newChannel.id)) {
|
|
51
|
+
setChannels((prev) => [newChannel, ...prev]);
|
|
52
|
+
}
|
|
53
|
+
}, [newChannel]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn("flex flex-col gap-2", className)}>
|
|
57
|
+
{/* Loading skeleton */}
|
|
58
|
+
{loading && (
|
|
59
|
+
<div className="flex flex-col gap-1">
|
|
60
|
+
{[1, 2, 3, 4].map((i) => (
|
|
61
|
+
<div key={i} className="flex items-center gap-1.5 px-2 h-[34px] rounded-[var(--radius-sm)]">
|
|
62
|
+
<div className="skeleton h-4 w-4 rounded shrink-0" />
|
|
63
|
+
<div className="skeleton h-3.5 rounded" style={{ width: `${50 + i * 12}%` }} />
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{/* Empty state */}
|
|
70
|
+
{!loading && channels.length === 0 && (
|
|
71
|
+
<div className="text-center py-8 text-foreground-secondary">
|
|
72
|
+
<MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
73
|
+
<p className="text-sm">No active channels</p>
|
|
74
|
+
<Button
|
|
75
|
+
variant="outline"
|
|
76
|
+
size="sm"
|
|
77
|
+
className="mt-3"
|
|
78
|
+
onClick={() => setShowCreate(true)}
|
|
79
|
+
>
|
|
80
|
+
New channel
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Channel list */}
|
|
86
|
+
{!loading && channels.length > 0 && (
|
|
87
|
+
<div className="selectable-list">
|
|
88
|
+
{channels.map((channel) => (
|
|
89
|
+
<div
|
|
90
|
+
key={channel.id}
|
|
91
|
+
role="button"
|
|
92
|
+
tabIndex={0}
|
|
93
|
+
onClick={() => router.push(`/chat/${channel.id}`)}
|
|
94
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") router.push(`/chat/${channel.id}`); }}
|
|
95
|
+
className={cn(
|
|
96
|
+
"selectable-list-item transition-colors group relative flex items-center gap-1.5 w-full text-left cursor-pointer",
|
|
97
|
+
activeChannelId === channel.id
|
|
98
|
+
? "bg-accent/10 text-accent"
|
|
99
|
+
: "text-foreground"
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
<Hash className="h-4 w-4 shrink-0" strokeWidth={2.5} />
|
|
103
|
+
<span className="truncate flex-1 min-w-0">{channel.name}</span>
|
|
104
|
+
<button
|
|
105
|
+
onClick={async (e) => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch("/api/openclaw/chat/channels", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify({ action: "archive", id: channel.id }),
|
|
112
|
+
});
|
|
113
|
+
if (res.ok) {
|
|
114
|
+
setChannels((prev) => prev.filter((c) => c.id !== channel.id));
|
|
115
|
+
if (activeChannelId === channel.id) {
|
|
116
|
+
const remaining = channels.filter((c) => c.id !== channel.id);
|
|
117
|
+
if (remaining.length > 0) {
|
|
118
|
+
router.push(`/chat/${remaining[0].id}`);
|
|
119
|
+
} else {
|
|
120
|
+
router.push("/chat");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// silent
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface-hover text-foreground-secondary hover:text-foreground cursor-pointer"
|
|
129
|
+
title="Archive channel"
|
|
130
|
+
>
|
|
131
|
+
<Archive className="h-3.5 w-3.5" />
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
4
|
+
import { Send, Loader2, ImageIcon, X, Square } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { AgentMentionPopup, getFilteredAgents, type AgentInfo } from "./agent-mention-popup";
|
|
8
|
+
|
|
9
|
+
interface ChatInputProps {
|
|
10
|
+
onSend: (content: string, agentId?: string) => Promise<void>;
|
|
11
|
+
onAbort?: () => void;
|
|
12
|
+
sending?: boolean;
|
|
13
|
+
streaming?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
agents: AgentInfo[];
|
|
16
|
+
defaultAgentId?: string;
|
|
17
|
+
channelId?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
/** Called when user presses Shift+ArrowUp/Down to navigate between messages */
|
|
20
|
+
onNavigate?: (direction: "up" | "down") => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ChatInput({
|
|
24
|
+
onSend,
|
|
25
|
+
onAbort,
|
|
26
|
+
sending,
|
|
27
|
+
streaming,
|
|
28
|
+
disabled,
|
|
29
|
+
agents,
|
|
30
|
+
defaultAgentId,
|
|
31
|
+
channelId,
|
|
32
|
+
className,
|
|
33
|
+
onNavigate,
|
|
34
|
+
}: ChatInputProps) {
|
|
35
|
+
const [showMentions, setShowMentions] = useState(false);
|
|
36
|
+
const [mentionFilter, setMentionFilter] = useState("");
|
|
37
|
+
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
|
38
|
+
const [isEmpty, setIsEmpty] = useState(true);
|
|
39
|
+
|
|
40
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
|
|
42
|
+
// Get plain text content from editor
|
|
43
|
+
const getPlainText = useCallback(() => {
|
|
44
|
+
if (!editorRef.current) return "";
|
|
45
|
+
return editorRef.current.innerText || "";
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Get message content with mentions converted to IDs
|
|
49
|
+
const getMessageContent = useCallback(() => {
|
|
50
|
+
if (!editorRef.current) return { text: "", firstMentionId: undefined as string | undefined };
|
|
51
|
+
|
|
52
|
+
let text = "";
|
|
53
|
+
let firstMentionId: string | undefined;
|
|
54
|
+
|
|
55
|
+
const processNode = (node: Node) => {
|
|
56
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
57
|
+
text += node.textContent || "";
|
|
58
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
59
|
+
const element = node as HTMLElement;
|
|
60
|
+
if (element.classList.contains("mention-chip")) {
|
|
61
|
+
const agentId = element.dataset.agentId;
|
|
62
|
+
if (agentId) {
|
|
63
|
+
text += `@${agentId}`;
|
|
64
|
+
if (!firstMentionId) firstMentionId = agentId;
|
|
65
|
+
}
|
|
66
|
+
} else if (element.tagName === "BR") {
|
|
67
|
+
text += "\n";
|
|
68
|
+
} else {
|
|
69
|
+
element.childNodes.forEach(processNode);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
editorRef.current.childNodes.forEach(processNode);
|
|
75
|
+
return { text: text.trim(), firstMentionId };
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// Check for @mention trigger
|
|
79
|
+
const checkForMentionTrigger = useCallback(() => {
|
|
80
|
+
const selection = window.getSelection();
|
|
81
|
+
if (!selection || !selection.rangeCount) return;
|
|
82
|
+
|
|
83
|
+
const range = selection.getRangeAt(0);
|
|
84
|
+
const container = range.startContainer;
|
|
85
|
+
|
|
86
|
+
if (container.nodeType !== Node.TEXT_NODE) {
|
|
87
|
+
setShowMentions(false);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const text = container.textContent || "";
|
|
92
|
+
const cursorPos = range.startOffset;
|
|
93
|
+
const textBeforeCursor = text.slice(0, cursorPos);
|
|
94
|
+
const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
|
|
95
|
+
|
|
96
|
+
if (mentionMatch) {
|
|
97
|
+
const newFilter = mentionMatch[1].toLowerCase();
|
|
98
|
+
if (newFilter !== mentionFilter) setMentionHighlightIndex(0);
|
|
99
|
+
setMentionFilter(newFilter);
|
|
100
|
+
setShowMentions(true);
|
|
101
|
+
} else {
|
|
102
|
+
setShowMentions(false);
|
|
103
|
+
setMentionFilter("");
|
|
104
|
+
setMentionHighlightIndex(0);
|
|
105
|
+
}
|
|
106
|
+
}, [mentionFilter]);
|
|
107
|
+
|
|
108
|
+
// Handle input changes
|
|
109
|
+
const handleInput = useCallback(() => {
|
|
110
|
+
const text = getPlainText();
|
|
111
|
+
setIsEmpty(!text.trim());
|
|
112
|
+
checkForMentionTrigger();
|
|
113
|
+
}, [getPlainText, checkForMentionTrigger]);
|
|
114
|
+
|
|
115
|
+
// Insert mention chip
|
|
116
|
+
const insertMention = useCallback(
|
|
117
|
+
(agentId: string) => {
|
|
118
|
+
const agent = agents.find((a) => a.id === agentId);
|
|
119
|
+
const displayName = agent?.name || agentId;
|
|
120
|
+
|
|
121
|
+
const selection = window.getSelection();
|
|
122
|
+
if (!selection || !selection.rangeCount || !editorRef.current) return;
|
|
123
|
+
|
|
124
|
+
const range = selection.getRangeAt(0);
|
|
125
|
+
const container = range.startContainer;
|
|
126
|
+
|
|
127
|
+
if (container.nodeType !== Node.TEXT_NODE) {
|
|
128
|
+
setShowMentions(false);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const text = container.textContent || "";
|
|
133
|
+
const cursorPos = range.startOffset;
|
|
134
|
+
const textBeforeCursor = text.slice(0, cursorPos);
|
|
135
|
+
const atIndex = textBeforeCursor.lastIndexOf("@");
|
|
136
|
+
|
|
137
|
+
if (atIndex === -1) {
|
|
138
|
+
setShowMentions(false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const replaceRange = document.createRange();
|
|
143
|
+
replaceRange.setStart(container, atIndex);
|
|
144
|
+
replaceRange.setEnd(container, cursorPos);
|
|
145
|
+
selection.removeAllRanges();
|
|
146
|
+
selection.addRange(replaceRange);
|
|
147
|
+
|
|
148
|
+
const chipHtml = `<span class="mention-chip inline-flex items-center px-1.5 py-0.5 rounded bg-accent/20 text-accent font-medium text-sm cursor-default" contenteditable="false" data-agent-id="${agentId}">@${displayName}</span> `;
|
|
149
|
+
document.execCommand("insertHTML", false, chipHtml);
|
|
150
|
+
|
|
151
|
+
setShowMentions(false);
|
|
152
|
+
setMentionFilter("");
|
|
153
|
+
setIsEmpty(false);
|
|
154
|
+
editorRef.current.focus();
|
|
155
|
+
},
|
|
156
|
+
[agents]
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Handle paste (plain text only, with @mention chips)
|
|
160
|
+
const handlePaste = useCallback(
|
|
161
|
+
(e: React.ClipboardEvent) => {
|
|
162
|
+
const text = e.clipboardData.getData("text/plain");
|
|
163
|
+
if (text) {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
document.execCommand("insertText", false, text);
|
|
166
|
+
setIsEmpty(false);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
[]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Handle submit
|
|
173
|
+
const handleSubmit = useCallback(
|
|
174
|
+
async (e?: React.FormEvent) => {
|
|
175
|
+
e?.preventDefault();
|
|
176
|
+
const { text, firstMentionId } = getMessageContent();
|
|
177
|
+
if (!text || sending || streaming || disabled) return;
|
|
178
|
+
|
|
179
|
+
const targetAgent = firstMentionId || defaultAgentId;
|
|
180
|
+
|
|
181
|
+
if (editorRef.current) editorRef.current.innerHTML = "";
|
|
182
|
+
setIsEmpty(true);
|
|
183
|
+
|
|
184
|
+
await onSend(text, targetAgent);
|
|
185
|
+
},
|
|
186
|
+
[getMessageContent, sending, streaming, disabled, defaultAgentId, onSend]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Handle keyboard shortcuts
|
|
190
|
+
const handleKeyDown = useCallback(
|
|
191
|
+
(e: React.KeyboardEvent) => {
|
|
192
|
+
// Handle mention popup navigation
|
|
193
|
+
if (showMentions) {
|
|
194
|
+
const filteredAgents = getFilteredAgents(agents, mentionFilter);
|
|
195
|
+
|
|
196
|
+
if (e.key === "ArrowDown") {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
setMentionHighlightIndex((prev) =>
|
|
199
|
+
prev < filteredAgents.length - 1 ? prev + 1 : 0
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (e.key === "ArrowUp") {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
setMentionHighlightIndex((prev) =>
|
|
206
|
+
prev > 0 ? prev - 1 : filteredAgents.length - 1
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
if (filteredAgents.length > 0) {
|
|
213
|
+
insertMention(filteredAgents[mentionHighlightIndex].id);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (e.key === "Escape") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
setShowMentions(false);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Shift+ArrowUp/Down — navigate between messages
|
|
225
|
+
if (
|
|
226
|
+
(e.key === "ArrowUp" || e.key === "ArrowDown") &&
|
|
227
|
+
e.shiftKey &&
|
|
228
|
+
!e.ctrlKey &&
|
|
229
|
+
!e.metaKey &&
|
|
230
|
+
!e.altKey
|
|
231
|
+
) {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
onNavigate?.(e.key === "ArrowUp" ? "up" : "down");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Regular Enter to send
|
|
238
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
handleSubmit();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (e.key === "Escape") {
|
|
245
|
+
setShowMentions(false);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
[handleSubmit, showMentions, agents, mentionFilter, mentionHighlightIndex, insertMention, onNavigate]
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
// Focus editor on channel change or when it becomes enabled
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!disabled) {
|
|
257
|
+
editorRef.current?.focus();
|
|
258
|
+
}
|
|
259
|
+
}, [channelId, disabled]);
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div className={cn("space-y-3", className)}>
|
|
263
|
+
{/* Input Area */}
|
|
264
|
+
<form onSubmit={handleSubmit} className="relative">
|
|
265
|
+
{/* @mention popup */}
|
|
266
|
+
{showMentions && (
|
|
267
|
+
<AgentMentionPopup
|
|
268
|
+
agents={agents}
|
|
269
|
+
filter={mentionFilter}
|
|
270
|
+
onSelect={insertMention}
|
|
271
|
+
onClose={() => setShowMentions(false)}
|
|
272
|
+
highlightedIndex={mentionHighlightIndex}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
<div className="flex items-end gap-3">
|
|
277
|
+
<div className="flex-1 min-w-0 relative">
|
|
278
|
+
{/* ContentEditable editor */}
|
|
279
|
+
<div
|
|
280
|
+
ref={editorRef}
|
|
281
|
+
contentEditable={!disabled}
|
|
282
|
+
onInput={handleInput}
|
|
283
|
+
onKeyDown={handleKeyDown}
|
|
284
|
+
onPaste={handlePaste}
|
|
285
|
+
data-placeholder="Message (Enter to send, Shift+Enter for new line, @ to mention)"
|
|
286
|
+
className={cn(
|
|
287
|
+
"w-full px-4 py-3 rounded-[var(--radius-sm)] bg-surface border border-border resize-none min-h-[48px] max-h-[200px] overflow-y-auto text-sm focus:outline-none focus:border-accent/50 break-words",
|
|
288
|
+
"empty:before:content-[attr(data-placeholder)] empty:before:text-foreground-secondary/50 empty:before:pointer-events-none",
|
|
289
|
+
(sending || streaming || disabled) && "opacity-50 pointer-events-none"
|
|
290
|
+
)}
|
|
291
|
+
role="textbox"
|
|
292
|
+
aria-multiline="true"
|
|
293
|
+
suppressContentEditableWarning
|
|
294
|
+
/>
|
|
295
|
+
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* Stop / Send button */}
|
|
299
|
+
{streaming ? (
|
|
300
|
+
<Button
|
|
301
|
+
type="button"
|
|
302
|
+
variant="destructive"
|
|
303
|
+
size="icon"
|
|
304
|
+
onClick={onAbort}
|
|
305
|
+
className="h-12 w-12 rounded-[var(--radius-sm)] shrink-0"
|
|
306
|
+
title="Stop response"
|
|
307
|
+
>
|
|
308
|
+
<Square className="h-5 w-5" />
|
|
309
|
+
</Button>
|
|
310
|
+
) : (
|
|
311
|
+
<Button
|
|
312
|
+
type="submit"
|
|
313
|
+
size="icon"
|
|
314
|
+
disabled={isEmpty || sending || streaming || disabled}
|
|
315
|
+
className="h-12 w-12 rounded-[var(--radius-sm)] shrink-0"
|
|
316
|
+
>
|
|
317
|
+
{sending ? (
|
|
318
|
+
<Loader2 className="h-5 w-5 animate-spin" />
|
|
319
|
+
) : (
|
|
320
|
+
<Send className="h-5 w-5" />
|
|
321
|
+
)}
|
|
322
|
+
</Button>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
</form>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|