@castlekit/castle 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,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,310 @@
|
|
|
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
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ChatInput({
|
|
22
|
+
onSend,
|
|
23
|
+
onAbort,
|
|
24
|
+
sending,
|
|
25
|
+
streaming,
|
|
26
|
+
disabled,
|
|
27
|
+
agents,
|
|
28
|
+
defaultAgentId,
|
|
29
|
+
channelId,
|
|
30
|
+
className,
|
|
31
|
+
}: ChatInputProps) {
|
|
32
|
+
const [showMentions, setShowMentions] = useState(false);
|
|
33
|
+
const [mentionFilter, setMentionFilter] = useState("");
|
|
34
|
+
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
|
35
|
+
const [isEmpty, setIsEmpty] = useState(true);
|
|
36
|
+
|
|
37
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
|
|
39
|
+
// Get plain text content from editor
|
|
40
|
+
const getPlainText = useCallback(() => {
|
|
41
|
+
if (!editorRef.current) return "";
|
|
42
|
+
return editorRef.current.innerText || "";
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
// Get message content with mentions converted to IDs
|
|
46
|
+
const getMessageContent = useCallback(() => {
|
|
47
|
+
if (!editorRef.current) return { text: "", firstMentionId: undefined as string | undefined };
|
|
48
|
+
|
|
49
|
+
let text = "";
|
|
50
|
+
let firstMentionId: string | undefined;
|
|
51
|
+
|
|
52
|
+
const processNode = (node: Node) => {
|
|
53
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
54
|
+
text += node.textContent || "";
|
|
55
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
56
|
+
const element = node as HTMLElement;
|
|
57
|
+
if (element.classList.contains("mention-chip")) {
|
|
58
|
+
const agentId = element.dataset.agentId;
|
|
59
|
+
if (agentId) {
|
|
60
|
+
text += `@${agentId}`;
|
|
61
|
+
if (!firstMentionId) firstMentionId = agentId;
|
|
62
|
+
}
|
|
63
|
+
} else if (element.tagName === "BR") {
|
|
64
|
+
text += "\n";
|
|
65
|
+
} else {
|
|
66
|
+
element.childNodes.forEach(processNode);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
editorRef.current.childNodes.forEach(processNode);
|
|
72
|
+
return { text: text.trim(), firstMentionId };
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
// Check for @mention trigger
|
|
76
|
+
const checkForMentionTrigger = useCallback(() => {
|
|
77
|
+
const selection = window.getSelection();
|
|
78
|
+
if (!selection || !selection.rangeCount) return;
|
|
79
|
+
|
|
80
|
+
const range = selection.getRangeAt(0);
|
|
81
|
+
const container = range.startContainer;
|
|
82
|
+
|
|
83
|
+
if (container.nodeType !== Node.TEXT_NODE) {
|
|
84
|
+
setShowMentions(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const text = container.textContent || "";
|
|
89
|
+
const cursorPos = range.startOffset;
|
|
90
|
+
const textBeforeCursor = text.slice(0, cursorPos);
|
|
91
|
+
const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
|
|
92
|
+
|
|
93
|
+
if (mentionMatch) {
|
|
94
|
+
const newFilter = mentionMatch[1].toLowerCase();
|
|
95
|
+
if (newFilter !== mentionFilter) setMentionHighlightIndex(0);
|
|
96
|
+
setMentionFilter(newFilter);
|
|
97
|
+
setShowMentions(true);
|
|
98
|
+
} else {
|
|
99
|
+
setShowMentions(false);
|
|
100
|
+
setMentionFilter("");
|
|
101
|
+
setMentionHighlightIndex(0);
|
|
102
|
+
}
|
|
103
|
+
}, [mentionFilter]);
|
|
104
|
+
|
|
105
|
+
// Handle input changes
|
|
106
|
+
const handleInput = useCallback(() => {
|
|
107
|
+
const text = getPlainText();
|
|
108
|
+
setIsEmpty(!text.trim());
|
|
109
|
+
checkForMentionTrigger();
|
|
110
|
+
}, [getPlainText, checkForMentionTrigger]);
|
|
111
|
+
|
|
112
|
+
// Insert mention chip
|
|
113
|
+
const insertMention = useCallback(
|
|
114
|
+
(agentId: string) => {
|
|
115
|
+
const agent = agents.find((a) => a.id === agentId);
|
|
116
|
+
const displayName = agent?.name || agentId;
|
|
117
|
+
|
|
118
|
+
const selection = window.getSelection();
|
|
119
|
+
if (!selection || !selection.rangeCount || !editorRef.current) return;
|
|
120
|
+
|
|
121
|
+
const range = selection.getRangeAt(0);
|
|
122
|
+
const container = range.startContainer;
|
|
123
|
+
|
|
124
|
+
if (container.nodeType !== Node.TEXT_NODE) {
|
|
125
|
+
setShowMentions(false);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const text = container.textContent || "";
|
|
130
|
+
const cursorPos = range.startOffset;
|
|
131
|
+
const textBeforeCursor = text.slice(0, cursorPos);
|
|
132
|
+
const atIndex = textBeforeCursor.lastIndexOf("@");
|
|
133
|
+
|
|
134
|
+
if (atIndex === -1) {
|
|
135
|
+
setShowMentions(false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const replaceRange = document.createRange();
|
|
140
|
+
replaceRange.setStart(container, atIndex);
|
|
141
|
+
replaceRange.setEnd(container, cursorPos);
|
|
142
|
+
selection.removeAllRanges();
|
|
143
|
+
selection.addRange(replaceRange);
|
|
144
|
+
|
|
145
|
+
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> `;
|
|
146
|
+
document.execCommand("insertHTML", false, chipHtml);
|
|
147
|
+
|
|
148
|
+
setShowMentions(false);
|
|
149
|
+
setMentionFilter("");
|
|
150
|
+
setIsEmpty(false);
|
|
151
|
+
editorRef.current.focus();
|
|
152
|
+
},
|
|
153
|
+
[agents]
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Handle paste (plain text only, with @mention chips)
|
|
157
|
+
const handlePaste = useCallback(
|
|
158
|
+
(e: React.ClipboardEvent) => {
|
|
159
|
+
const text = e.clipboardData.getData("text/plain");
|
|
160
|
+
if (text) {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
document.execCommand("insertText", false, text);
|
|
163
|
+
setIsEmpty(false);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[]
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Handle submit
|
|
170
|
+
const handleSubmit = useCallback(
|
|
171
|
+
async (e?: React.FormEvent) => {
|
|
172
|
+
e?.preventDefault();
|
|
173
|
+
const { text, firstMentionId } = getMessageContent();
|
|
174
|
+
if (!text || sending || streaming || disabled) return;
|
|
175
|
+
|
|
176
|
+
const targetAgent = firstMentionId || defaultAgentId;
|
|
177
|
+
|
|
178
|
+
if (editorRef.current) editorRef.current.innerHTML = "";
|
|
179
|
+
setIsEmpty(true);
|
|
180
|
+
|
|
181
|
+
await onSend(text, targetAgent);
|
|
182
|
+
},
|
|
183
|
+
[getMessageContent, sending, streaming, disabled, defaultAgentId, onSend]
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Handle keyboard shortcuts
|
|
187
|
+
const handleKeyDown = useCallback(
|
|
188
|
+
(e: React.KeyboardEvent) => {
|
|
189
|
+
// Handle mention popup navigation
|
|
190
|
+
if (showMentions) {
|
|
191
|
+
const filteredAgents = getFilteredAgents(agents, mentionFilter);
|
|
192
|
+
|
|
193
|
+
if (e.key === "ArrowDown") {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
setMentionHighlightIndex((prev) =>
|
|
196
|
+
prev < filteredAgents.length - 1 ? prev + 1 : 0
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (e.key === "ArrowUp") {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
setMentionHighlightIndex((prev) =>
|
|
203
|
+
prev > 0 ? prev - 1 : filteredAgents.length - 1
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
if (filteredAgents.length > 0) {
|
|
210
|
+
insertMention(filteredAgents[mentionHighlightIndex].id);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (e.key === "Escape") {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
setShowMentions(false);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Regular Enter to send
|
|
222
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
handleSubmit();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (e.key === "Escape") {
|
|
229
|
+
setShowMentions(false);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[handleSubmit, showMentions, agents, mentionFilter, mentionHighlightIndex, insertMention]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
// Focus editor on mount
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
editorRef.current?.focus();
|
|
241
|
+
}, [channelId]);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className={cn("space-y-3", className)}>
|
|
245
|
+
{/* Input Area */}
|
|
246
|
+
<form onSubmit={handleSubmit} className="relative">
|
|
247
|
+
{/* @mention popup */}
|
|
248
|
+
{showMentions && (
|
|
249
|
+
<AgentMentionPopup
|
|
250
|
+
agents={agents}
|
|
251
|
+
filter={mentionFilter}
|
|
252
|
+
onSelect={insertMention}
|
|
253
|
+
onClose={() => setShowMentions(false)}
|
|
254
|
+
highlightedIndex={mentionHighlightIndex}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
<div className="flex items-end gap-3">
|
|
259
|
+
<div className="flex-1 min-w-0 relative">
|
|
260
|
+
{/* ContentEditable editor */}
|
|
261
|
+
<div
|
|
262
|
+
ref={editorRef}
|
|
263
|
+
contentEditable={!disabled}
|
|
264
|
+
onInput={handleInput}
|
|
265
|
+
onKeyDown={handleKeyDown}
|
|
266
|
+
onPaste={handlePaste}
|
|
267
|
+
data-placeholder="Message (Enter to send, Shift+Enter for new line, @ to mention)"
|
|
268
|
+
className={cn(
|
|
269
|
+
"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-all",
|
|
270
|
+
"empty:before:content-[attr(data-placeholder)] empty:before:text-foreground-secondary/50 empty:before:pointer-events-none",
|
|
271
|
+
(sending || streaming || disabled) && "opacity-50 pointer-events-none"
|
|
272
|
+
)}
|
|
273
|
+
role="textbox"
|
|
274
|
+
aria-multiline="true"
|
|
275
|
+
suppressContentEditableWarning
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* Stop / Send button */}
|
|
281
|
+
{streaming ? (
|
|
282
|
+
<Button
|
|
283
|
+
type="button"
|
|
284
|
+
variant="destructive"
|
|
285
|
+
size="icon"
|
|
286
|
+
onClick={onAbort}
|
|
287
|
+
className="h-12 w-12 rounded-[var(--radius-sm)] shrink-0"
|
|
288
|
+
title="Stop response"
|
|
289
|
+
>
|
|
290
|
+
<Square className="h-5 w-5" />
|
|
291
|
+
</Button>
|
|
292
|
+
) : (
|
|
293
|
+
<Button
|
|
294
|
+
type="submit"
|
|
295
|
+
size="icon"
|
|
296
|
+
disabled={isEmpty || sending || streaming || disabled}
|
|
297
|
+
className="h-12 w-12 rounded-[var(--radius-sm)] shrink-0"
|
|
298
|
+
>
|
|
299
|
+
{sending ? (
|
|
300
|
+
<Loader2 className="h-5 w-5 animate-spin" />
|
|
301
|
+
) : (
|
|
302
|
+
<Send className="h-5 w-5" />
|
|
303
|
+
)}
|
|
304
|
+
</Button>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</form>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Loader2, ChevronDown } from "lucide-react";
|
|
6
|
+
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
10
|
+
import { useOpenClaw } from "@/lib/hooks/use-openclaw";
|
|
11
|
+
import type { Channel } from "@/lib/types/chat";
|
|
12
|
+
|
|
13
|
+
interface CreateChannelDialogProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
onCreated?: (channel: Channel) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CreateChannelDialog({ open, onOpenChange, onCreated }: CreateChannelDialogProps) {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const { agents } = useOpenClaw();
|
|
22
|
+
const [name, setName] = useState("");
|
|
23
|
+
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
|
24
|
+
const [defaultAgentId, setDefaultAgentId] = useState("");
|
|
25
|
+
const [loading, setLoading] = useState(false);
|
|
26
|
+
const [error, setError] = useState("");
|
|
27
|
+
|
|
28
|
+
// Default to first agent when agents load (once only)
|
|
29
|
+
const didInit = useRef(false);
|
|
30
|
+
if (agents.length > 0 && !didInit.current) {
|
|
31
|
+
didInit.current = true;
|
|
32
|
+
setDefaultAgentId(agents[0].id);
|
|
33
|
+
setSelectedAgents([agents[0].id]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
if (!name.trim() || !defaultAgentId) {
|
|
39
|
+
setError("Please provide a name and select a default agent");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError("");
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch("/api/openclaw/chat/channels", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
name: name.trim(),
|
|
52
|
+
defaultAgentId,
|
|
53
|
+
agents: [...new Set([defaultAgentId, ...selectedAgents])],
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
|
|
59
|
+
if (res.ok && data.channel) {
|
|
60
|
+
onCreated?.(data.channel);
|
|
61
|
+
router.push(`/chat/${data.channel.id}`);
|
|
62
|
+
setName("");
|
|
63
|
+
setSelectedAgents([]);
|
|
64
|
+
setDefaultAgentId("");
|
|
65
|
+
onOpenChange(false);
|
|
66
|
+
} else {
|
|
67
|
+
setError(data.error || "Failed to create channel");
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error("Failed to create channel:", err);
|
|
71
|
+
setError("Failed to create channel");
|
|
72
|
+
} finally {
|
|
73
|
+
setLoading(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const toggleAgent = (agentId: string) => {
|
|
78
|
+
setSelectedAgents((prev) => {
|
|
79
|
+
const next = prev.includes(agentId)
|
|
80
|
+
? prev.filter((id) => id !== agentId)
|
|
81
|
+
: [...prev, agentId];
|
|
82
|
+
|
|
83
|
+
if (next.length === 1) {
|
|
84
|
+
setDefaultAgentId(next[0]);
|
|
85
|
+
} else {
|
|
86
|
+
setDefaultAgentId("");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return next;
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Dialog open={open} onClose={() => onOpenChange(false)}>
|
|
95
|
+
<DialogHeader>
|
|
96
|
+
<DialogTitle>Create Channel</DialogTitle>
|
|
97
|
+
</DialogHeader>
|
|
98
|
+
|
|
99
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
100
|
+
{/* Name */}
|
|
101
|
+
<div>
|
|
102
|
+
<label className="block text-sm font-medium mb-1.5">Channel Name</label>
|
|
103
|
+
<Input
|
|
104
|
+
value={name}
|
|
105
|
+
onChange={(e) => setName(e.target.value)}
|
|
106
|
+
placeholder="e.g., Project Discussion"
|
|
107
|
+
autoFocus
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Agent Selection */}
|
|
112
|
+
<div>
|
|
113
|
+
<label className="block text-sm font-medium mb-2">Agents</label>
|
|
114
|
+
<div className="selectable-list max-h-48 overflow-y-auto">
|
|
115
|
+
{agents.map((agent) => (
|
|
116
|
+
<div
|
|
117
|
+
key={agent.id}
|
|
118
|
+
className="selectable-list-item cursor-pointer"
|
|
119
|
+
onClick={() => toggleAgent(agent.id)}
|
|
120
|
+
>
|
|
121
|
+
<Checkbox
|
|
122
|
+
checked={selectedAgents.includes(agent.id)}
|
|
123
|
+
/>
|
|
124
|
+
<span className="text-sm select-none">{agent.name}</span>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Default Agent */}
|
|
131
|
+
<div>
|
|
132
|
+
<label className="block text-sm font-medium mb-1.5">Default Agent</label>
|
|
133
|
+
<div className="relative">
|
|
134
|
+
<select
|
|
135
|
+
value={defaultAgentId}
|
|
136
|
+
onChange={(e) => setDefaultAgentId(e.target.value)}
|
|
137
|
+
className="input-base appearance-none pr-10 cursor-pointer"
|
|
138
|
+
>
|
|
139
|
+
<option value="">Select an agent</option>
|
|
140
|
+
{agents.map((agent) => (
|
|
141
|
+
<option key={agent.id} value={agent.id} disabled={!selectedAgents.includes(agent.id)}>
|
|
142
|
+
{agent.name}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-foreground-secondary pointer-events-none" />
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{/* Error */}
|
|
151
|
+
{error && <p className="text-sm text-error">{error}</p>}
|
|
152
|
+
|
|
153
|
+
{/* Actions */}
|
|
154
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
155
|
+
<Button
|
|
156
|
+
type="button"
|
|
157
|
+
variant="ghost"
|
|
158
|
+
onClick={() => onOpenChange(false)}
|
|
159
|
+
disabled={loading}
|
|
160
|
+
>
|
|
161
|
+
Cancel
|
|
162
|
+
</Button>
|
|
163
|
+
<Button type="submit" disabled={loading || !name.trim() || !defaultAgentId}>
|
|
164
|
+
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
165
|
+
Create Channel
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</form>
|
|
169
|
+
</Dialog>
|
|
170
|
+
);
|
|
171
|
+
}
|