@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,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
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback } from "react";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
7
|
+
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
8
|
+
import { Copy, Check } from "lucide-react";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
interface MarkdownContentProps {
|
|
12
|
+
content: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function CopyButton({ text }: { text: string }) {
|
|
17
|
+
const [copied, setCopied] = useState(false);
|
|
18
|
+
|
|
19
|
+
const handleCopy = useCallback(async () => {
|
|
20
|
+
try {
|
|
21
|
+
await navigator.clipboard.writeText(text);
|
|
22
|
+
setCopied(true);
|
|
23
|
+
setTimeout(() => setCopied(false), 2000);
|
|
24
|
+
} catch {
|
|
25
|
+
// Fallback for non-HTTPS
|
|
26
|
+
const textarea = document.createElement("textarea");
|
|
27
|
+
textarea.value = text;
|
|
28
|
+
document.body.appendChild(textarea);
|
|
29
|
+
textarea.select();
|
|
30
|
+
document.execCommand("copy");
|
|
31
|
+
document.body.removeChild(textarea);
|
|
32
|
+
setCopied(true);
|
|
33
|
+
setTimeout(() => setCopied(false), 2000);
|
|
34
|
+
}
|
|
35
|
+
}, [text]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={handleCopy}
|
|
41
|
+
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 hover:bg-white/20 transition-colors text-white/60 hover:text-white/90"
|
|
42
|
+
title={copied ? "Copied!" : "Copy code"}
|
|
43
|
+
>
|
|
44
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Renders markdown content with syntax highlighting for code blocks.
|
|
51
|
+
* HTML rendering is disabled for XSS safety.
|
|
52
|
+
*/
|
|
53
|
+
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn("prose prose-sm dark:prose-invert max-w-none [&>p]:my-0", className)}>
|
|
56
|
+
<ReactMarkdown
|
|
57
|
+
remarkPlugins={[remarkGfm]}
|
|
58
|
+
components={{
|
|
59
|
+
// Code blocks with syntax highlighting
|
|
60
|
+
code({ className: codeClassName, children, ...props }) {
|
|
61
|
+
const match = /language-(\w+)/.exec(codeClassName || "");
|
|
62
|
+
const codeString = String(children).replace(/\n$/, "");
|
|
63
|
+
|
|
64
|
+
// Block code (with language tag)
|
|
65
|
+
if (match) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="relative group not-prose my-3">
|
|
68
|
+
<div className="flex items-center justify-between px-4 py-1.5 bg-[#1e1e2e] rounded-t-lg border-b border-white/10">
|
|
69
|
+
<span className="text-xs text-white/40 font-mono">{match[1]}</span>
|
|
70
|
+
<CopyButton text={codeString} />
|
|
71
|
+
</div>
|
|
72
|
+
<SyntaxHighlighter
|
|
73
|
+
style={oneDark}
|
|
74
|
+
language={match[1]}
|
|
75
|
+
PreTag="div"
|
|
76
|
+
customStyle={{
|
|
77
|
+
margin: 0,
|
|
78
|
+
borderTopLeftRadius: 0,
|
|
79
|
+
borderTopRightRadius: 0,
|
|
80
|
+
borderBottomLeftRadius: "0.5rem",
|
|
81
|
+
borderBottomRightRadius: "0.5rem",
|
|
82
|
+
fontSize: "0.8125rem",
|
|
83
|
+
lineHeight: "1.5",
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{codeString}
|
|
87
|
+
</SyntaxHighlighter>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Multi-line code without language
|
|
93
|
+
if (codeString.includes("\n")) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="relative group not-prose my-3">
|
|
96
|
+
<CopyButton text={codeString} />
|
|
97
|
+
<SyntaxHighlighter
|
|
98
|
+
style={oneDark}
|
|
99
|
+
PreTag="div"
|
|
100
|
+
customStyle={{
|
|
101
|
+
borderRadius: "0.5rem",
|
|
102
|
+
fontSize: "0.8125rem",
|
|
103
|
+
lineHeight: "1.5",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{codeString}
|
|
107
|
+
</SyntaxHighlighter>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Inline code
|
|
113
|
+
return (
|
|
114
|
+
<code
|
|
115
|
+
className="px-1.5 py-0.5 rounded bg-surface-hover font-mono text-[0.8125rem]"
|
|
116
|
+
{...props}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</code>
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// Links
|
|
124
|
+
a({ href, children }) {
|
|
125
|
+
return (
|
|
126
|
+
<a
|
|
127
|
+
href={href}
|
|
128
|
+
target="_blank"
|
|
129
|
+
rel="noopener noreferrer"
|
|
130
|
+
className="text-accent hover:underline"
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</a>
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Tables
|
|
138
|
+
table({ children }) {
|
|
139
|
+
return (
|
|
140
|
+
<div className="overflow-x-auto my-3 not-prose">
|
|
141
|
+
<table className="min-w-full text-sm border-collapse">
|
|
142
|
+
{children}
|
|
143
|
+
</table>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
th({ children }) {
|
|
148
|
+
return (
|
|
149
|
+
<th className="px-3 py-2 text-left text-xs font-medium text-foreground-secondary border-b border-border bg-surface-hover">
|
|
150
|
+
{children}
|
|
151
|
+
</th>
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
td({ children }) {
|
|
155
|
+
return (
|
|
156
|
+
<td className="px-3 py-2 text-sm border-b border-border/50">
|
|
157
|
+
{children}
|
|
158
|
+
</td>
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// Blockquotes
|
|
163
|
+
blockquote({ children }) {
|
|
164
|
+
return (
|
|
165
|
+
<blockquote className="border-l-2 border-accent/50 pl-4 my-3 text-foreground-secondary italic">
|
|
166
|
+
{children}
|
|
167
|
+
</blockquote>
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// Lists
|
|
172
|
+
ul({ children }) {
|
|
173
|
+
return <ul className="list-disc pl-5 my-2 space-y-1">{children}</ul>;
|
|
174
|
+
},
|
|
175
|
+
ol({ children }) {
|
|
176
|
+
return <ol className="list-decimal pl-5 my-2 space-y-1">{children}</ol>;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// Horizontal rule
|
|
180
|
+
hr() {
|
|
181
|
+
return <hr className="my-4 border-border" />;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// Paragraphs
|
|
185
|
+
p({ children }) {
|
|
186
|
+
return <p>{children}</p>;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// Headings
|
|
190
|
+
h1({ children }) {
|
|
191
|
+
return <h1 className="text-lg font-bold mt-4 mb-2">{children}</h1>;
|
|
192
|
+
},
|
|
193
|
+
h2({ children }) {
|
|
194
|
+
return <h2 className="text-base font-bold mt-3 mb-1.5">{children}</h2>;
|
|
195
|
+
},
|
|
196
|
+
h3({ children }) {
|
|
197
|
+
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
|
|
198
|
+
},
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{content}
|
|
202
|
+
</ReactMarkdown>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Bot, User, AlertTriangle, StopCircle } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { formatTime, formatDateTime } from "@/lib/date-utils";
|
|
7
|
+
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
8
|
+
import { Tooltip } from "@/components/ui/tooltip";
|
|
9
|
+
import { MarkdownContent } from "./markdown-content";
|
|
10
|
+
import { TwemojiText } from "@/components/ui/twemoji-text";
|
|
11
|
+
import type { ChatMessage } from "@/lib/types/chat";
|
|
12
|
+
import type { AgentInfo } from "./agent-mention-popup";
|
|
13
|
+
import type { AgentStatus } from "@/lib/hooks/use-agent-status";
|
|
14
|
+
|
|
15
|
+
interface MessageBubbleProps {
|
|
16
|
+
message: ChatMessage;
|
|
17
|
+
isAgent: boolean;
|
|
18
|
+
agentName?: string;
|
|
19
|
+
agentAvatar?: string | null;
|
|
20
|
+
userAvatar?: string | null;
|
|
21
|
+
agents: AgentInfo[];
|
|
22
|
+
isStreaming?: boolean;
|
|
23
|
+
/** Whether to show avatar and name (false for consecutive messages from same sender) */
|
|
24
|
+
showHeader?: boolean;
|
|
25
|
+
agentStatus?: AgentStatus;
|
|
26
|
+
userStatus?: AgentStatus;
|
|
27
|
+
highlighted?: boolean;
|
|
28
|
+
/** When true, shows bouncing dots instead of message content (typing indicator) */
|
|
29
|
+
isTypingIndicator?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function MessageBubble({
|
|
33
|
+
message,
|
|
34
|
+
isAgent,
|
|
35
|
+
agentName,
|
|
36
|
+
agentAvatar,
|
|
37
|
+
userAvatar,
|
|
38
|
+
agents,
|
|
39
|
+
isStreaming,
|
|
40
|
+
showHeader = true,
|
|
41
|
+
agentStatus,
|
|
42
|
+
userStatus,
|
|
43
|
+
highlighted,
|
|
44
|
+
isTypingIndicator,
|
|
45
|
+
}: MessageBubbleProps) {
|
|
46
|
+
const formattedTime = formatTime(message.createdAt);
|
|
47
|
+
const fullDateTime = formatDateTime(message.createdAt);
|
|
48
|
+
|
|
49
|
+
// Prefer the name stored on the message (always available from DB, no FOUC).
|
|
50
|
+
// Fall back to the live agent name from the gateway, then a generic label.
|
|
51
|
+
const displayName = isAgent
|
|
52
|
+
? (message.senderName || agentName || "Unknown Agent")
|
|
53
|
+
: "You";
|
|
54
|
+
const avatarSrc = isAgent ? agentAvatar : userAvatar;
|
|
55
|
+
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
56
|
+
|
|
57
|
+
// Map status to Avatar status prop
|
|
58
|
+
const avatarStatus = isAgent && agentStatus
|
|
59
|
+
? ({ thinking: "away", active: "online", idle: "offline" } as const)[agentStatus]
|
|
60
|
+
: !isAgent && userStatus
|
|
61
|
+
? ({ active: "online", idle: "offline", thinking: "away" } as const)[userStatus]
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={cn("flex gap-3", showHeader ? "mb-[4px] first:mt-0" : "mt-0.5 pl-[48px]", highlighted && "bg-accent/10 -mx-2 px-2 py-1")}>
|
|
66
|
+
{/* Avatar — only shown on first message in a group */}
|
|
67
|
+
{showHeader && (
|
|
68
|
+
<div className="mt-0.5">
|
|
69
|
+
<Avatar size="sm" status={avatarStatus} statusPulse={agentStatus === "thinking"}>
|
|
70
|
+
{avatarSrc ? (
|
|
71
|
+
<AvatarImage src={avatarSrc} alt={displayName} />
|
|
72
|
+
) : (
|
|
73
|
+
<AvatarFallback
|
|
74
|
+
className={cn(
|
|
75
|
+
isAgent ? "bg-accent/20 text-accent" : "bg-foreground/10 text-foreground"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{isAgent ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
|
|
79
|
+
</AvatarFallback>
|
|
80
|
+
)}
|
|
81
|
+
</Avatar>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Message content */}
|
|
86
|
+
<div className="flex flex-col min-w-0">
|
|
87
|
+
{/* Name + time header — only on first message in a group */}
|
|
88
|
+
{showHeader && (
|
|
89
|
+
<div className="flex items-baseline gap-2">
|
|
90
|
+
<span className="font-bold text-[15px] text-foreground">
|
|
91
|
+
{displayName}
|
|
92
|
+
</span>
|
|
93
|
+
<Tooltip content={fullDateTime} side="top" delay={800}>
|
|
94
|
+
<span className={cn("text-xs text-foreground-secondary hover:text-foreground cursor-pointer transition-colors", isTypingIndicator && "opacity-0")}>
|
|
95
|
+
{formattedTime}
|
|
96
|
+
</span>
|
|
97
|
+
</Tooltip>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{/* Attachments */}
|
|
102
|
+
{!isTypingIndicator && hasAttachments && (
|
|
103
|
+
<div className="flex gap-2 flex-wrap mb-1">
|
|
104
|
+
{message.attachments.map((att) => (
|
|
105
|
+
<img
|
|
106
|
+
key={att.id}
|
|
107
|
+
src={`/api/openclaw/chat/attachments?path=${encodeURIComponent(att.filePath)}`}
|
|
108
|
+
alt={att.originalName || "Attachment"}
|
|
109
|
+
className="max-w-[200px] max-h-[200px] rounded-lg object-cover border border-border"
|
|
110
|
+
/>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Message text — no bubble, just plain text */}
|
|
116
|
+
<div className="text-[15px] text-foreground leading-[26px] break-words relative">
|
|
117
|
+
{isTypingIndicator ? (
|
|
118
|
+
<>
|
|
119
|
+
<span className="opacity-0" aria-hidden>Xxxxx</span>
|
|
120
|
+
<span className="absolute left-0 top-0 h-[26px] inline-flex items-center gap-1">
|
|
121
|
+
<span className="w-1.5 h-1.5 bg-foreground-secondary/60 rounded-full" style={{ animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: "0s" }} />
|
|
122
|
+
<span className="w-1.5 h-1.5 bg-foreground-secondary/60 rounded-full" style={{ animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: "0.16s" }} />
|
|
123
|
+
<span className="w-1.5 h-1.5 bg-foreground-secondary/60 rounded-full" style={{ animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: "0.32s" }} />
|
|
124
|
+
</span>
|
|
125
|
+
</>
|
|
126
|
+
) : (
|
|
127
|
+
<>
|
|
128
|
+
<TwemojiText>
|
|
129
|
+
{isAgent && message.content ? (
|
|
130
|
+
<MarkdownContent content={message.content} />
|
|
131
|
+
) : (
|
|
132
|
+
<span className="whitespace-pre-wrap">{message.content}</span>
|
|
133
|
+
)}
|
|
134
|
+
</TwemojiText>
|
|
135
|
+
|
|
136
|
+
{/* Streaming cursor */}
|
|
137
|
+
{isStreaming && (
|
|
138
|
+
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5 align-text-bottom" />
|
|
139
|
+
)}
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Status badges */}
|
|
145
|
+
{message.status === "interrupted" && (
|
|
146
|
+
<div className="flex items-center gap-1 text-xs text-warning mt-1">
|
|
147
|
+
<AlertTriangle className="h-3 w-3" />
|
|
148
|
+
<span>Response interrupted</span>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
{message.status === "aborted" && (
|
|
152
|
+
<div className="flex items-center gap-1 text-xs text-foreground-secondary mt-1">
|
|
153
|
+
<StopCircle className="h-3 w-3" />
|
|
154
|
+
<span>Response stopped</span>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Token info for agent messages */}
|
|
159
|
+
{isAgent && !isStreaming && (message.inputTokens || message.outputTokens) && (
|
|
160
|
+
<div className="text-xs text-foreground-secondary/60 flex items-center gap-2 mt-1">
|
|
161
|
+
{message.inputTokens != null && <span>{message.inputTokens.toLocaleString()} in</span>}
|
|
162
|
+
{message.outputTokens != null && <span>{message.outputTokens.toLocaleString()} out</span>}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|