@hed-hog/core 0.0.329 → 0.0.330
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/dist/ai/ai.service.d.ts +1 -1
- package/dist/auth/auth.controller.d.ts +23 -23
- package/dist/auth/auth.service.d.ts +41 -41
- package/dist/challenge/challenge.service.d.ts +14 -14
- package/dist/core/system.controller.d.ts +2 -2
- package/dist/core/system.service.d.ts +2 -2
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +7 -4
- package/dist/core.module.js.map +1 -1
- package/dist/dashboard/dashboard/dashboard.controller.d.ts +87 -87
- package/dist/dashboard/dashboard/dashboard.service.d.ts +87 -87
- package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts +33 -33
- package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts +33 -33
- package/dist/dashboard/dashboard-component-role/dashboard-component-role.controller.d.ts +42 -42
- package/dist/dashboard/dashboard-component-role/dashboard-component-role.service.d.ts +42 -42
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +12 -12
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +12 -12
- package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts +7 -7
- package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts +7 -7
- package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts +33 -33
- package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts +33 -33
- package/dist/dashboard/dashboard-user/dashboard-user.controller.d.ts +3 -3
- package/dist/dashboard/dashboard-user/dashboard-user.service.d.ts +3 -3
- package/dist/file/file.controller.d.ts +8 -8
- package/dist/file/file.service.d.ts +18 -18
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/mail/mail.controller.d.ts +15 -15
- package/dist/mail/mail.service.d.ts +15 -15
- package/dist/mail-profile/mail-profile.controller.d.ts +9 -9
- package/dist/mail-profile/mail-profile.service.d.ts +9 -9
- package/dist/mail-sent/mail-sent.controller.d.ts +17 -17
- package/dist/mail-sent/mail-sent.service.d.ts +17 -17
- package/dist/mcp-chat/mcp-chat.controller.d.ts +10 -10
- package/dist/mcp-chat/mcp-chat.service.d.ts +10 -10
- package/dist/menu/menu.controller.d.ts +11 -11
- package/dist/menu/menu.service.d.ts +11 -11
- package/dist/oauth/oauth.service.js +1 -1
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/pdf/components/index.d.ts +57 -0
- package/dist/pdf/components/index.d.ts.map +1 -0
- package/dist/pdf/components/index.js +258 -0
- package/dist/pdf/components/index.js.map +1 -0
- package/dist/pdf/dto/generate-pdf.dto.d.ts +5 -0
- package/dist/pdf/dto/generate-pdf.dto.d.ts.map +1 -0
- package/dist/pdf/dto/generate-pdf.dto.js +27 -0
- package/dist/pdf/dto/generate-pdf.dto.js.map +1 -0
- package/dist/pdf/interfaces/pdf-theme-config.interface.d.ts +10 -0
- package/dist/pdf/interfaces/pdf-theme-config.interface.d.ts.map +1 -0
- package/dist/pdf/interfaces/pdf-theme-config.interface.js +3 -0
- package/dist/pdf/interfaces/pdf-theme-config.interface.js.map +1 -0
- package/dist/pdf/pdf.controller.d.ts +16 -0
- package/dist/pdf/pdf.controller.d.ts.map +1 -0
- package/dist/pdf/pdf.controller.js +100 -0
- package/dist/pdf/pdf.controller.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts +3 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -0
- package/dist/pdf/pdf.module.js +29 -0
- package/dist/pdf/pdf.module.js.map +1 -0
- package/dist/pdf/pdf.service.d.ts +44 -0
- package/dist/pdf/pdf.service.d.ts.map +1 -0
- package/dist/pdf/pdf.service.js +185 -0
- package/dist/pdf/pdf.service.js.map +1 -0
- package/dist/pdf/templates/hcode-institutional.template.d.ts +36 -0
- package/dist/pdf/templates/hcode-institutional.template.d.ts.map +1 -0
- package/dist/pdf/templates/hcode-institutional.template.js +67 -0
- package/dist/pdf/templates/hcode-institutional.template.js.map +1 -0
- package/dist/pdf/themes/hcode.theme.d.ts +25 -0
- package/dist/pdf/themes/hcode.theme.d.ts.map +1 -0
- package/dist/pdf/themes/hcode.theme.js +504 -0
- package/dist/pdf/themes/hcode.theme.js.map +1 -0
- package/dist/profile/profile.controller.d.ts +17 -17
- package/dist/profile/profile.service.d.ts +17 -17
- package/dist/role/role.controller.d.ts +12 -12
- package/dist/role/role.service.d.ts +12 -12
- package/dist/screen/screen.controller.d.ts +3 -3
- package/dist/screen/screen.service.d.ts +3 -3
- package/dist/session/session.controller.d.ts +4 -4
- package/dist/session/session.service.d.ts +12 -12
- package/dist/setting/setting.controller.d.ts +13 -13
- package/dist/setting/setting.service.d.ts +13 -13
- package/dist/user/user.controller.d.ts +24 -24
- package/dist/user/user.service.d.ts +55 -55
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +60 -0
- package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +574 -574
- package/hedhog/frontend/app/users/user-sheet.tsx.ejs +5 -9
- package/hedhog/frontend/messages/en.json +2 -0
- package/hedhog/frontend/messages/pt.json +2 -0
- package/package.json +4 -4
- package/src/core.module.ts +7 -4
- package/src/index.ts +9 -6
- package/src/oauth/oauth.service.ts +12 -12
- package/src/pdf/components/index.ts +316 -0
- package/src/pdf/dto/generate-pdf.dto.ts +11 -0
- package/src/pdf/interfaces/pdf-theme-config.interface.ts +10 -0
- package/src/pdf/pdf.controller.ts +86 -0
- package/src/pdf/pdf.module.ts +17 -0
- package/src/pdf/pdf.service.ts +229 -0
- package/src/pdf/templates/hcode-institutional.template.ts +122 -0
- package/src/pdf/themes/hcode.theme.ts +515 -0
|
@@ -1,574 +1,574 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
AlertDialog,
|
|
5
|
-
AlertDialogAction,
|
|
6
|
-
AlertDialogCancel,
|
|
7
|
-
AlertDialogContent,
|
|
8
|
-
AlertDialogDescription,
|
|
9
|
-
AlertDialogFooter,
|
|
10
|
-
AlertDialogHeader,
|
|
11
|
-
AlertDialogTitle,
|
|
12
|
-
} from '@/components/ui/alert-dialog';
|
|
13
|
-
import { Button } from '@/components/ui/button';
|
|
14
|
-
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
15
|
-
import { Textarea } from '@/components/ui/textarea';
|
|
16
|
-
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
17
|
-
import {
|
|
18
|
-
Bot,
|
|
19
|
-
ChevronDown,
|
|
20
|
-
ChevronRight,
|
|
21
|
-
MessageSquare,
|
|
22
|
-
Plus,
|
|
23
|
-
Send,
|
|
24
|
-
Trash2,
|
|
25
|
-
Wrench,
|
|
26
|
-
} from 'lucide-react';
|
|
27
|
-
import { useTranslations } from 'next-intl';
|
|
28
|
-
import { useEffect, useRef, useState } from 'react';
|
|
29
|
-
import ReactMarkdown from 'react-markdown';
|
|
30
|
-
import remarkGfm from 'remark-gfm';
|
|
31
|
-
import { toast } from 'sonner';
|
|
32
|
-
|
|
33
|
-
type Conversation = {
|
|
34
|
-
id: number;
|
|
35
|
-
title: string | null;
|
|
36
|
-
created_at: string;
|
|
37
|
-
updated_at: string;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
type Message = {
|
|
41
|
-
id: number;
|
|
42
|
-
conversation_id: number;
|
|
43
|
-
role: 'user' | 'assistant' | 'tool_call' | 'tool_result';
|
|
44
|
-
content: string;
|
|
45
|
-
tool_name: string | null;
|
|
46
|
-
tool_call_id: string | null;
|
|
47
|
-
created_at: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
type MarkdownComponentProps = {
|
|
51
|
-
children?: React.ReactNode;
|
|
52
|
-
className?: string;
|
|
53
|
-
href?: string;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const markdownComponents: Record<
|
|
57
|
-
string,
|
|
58
|
-
(props: MarkdownComponentProps) => React.ReactNode
|
|
59
|
-
> = {
|
|
60
|
-
p({ children }) {
|
|
61
|
-
return <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>;
|
|
62
|
-
},
|
|
63
|
-
ul({ children }) {
|
|
64
|
-
return <ul className="mb-2 list-disc pl-4 space-y-1">{children}</ul>;
|
|
65
|
-
},
|
|
66
|
-
ol({ children }) {
|
|
67
|
-
return <ol className="mb-2 list-decimal pl-4 space-y-1">{children}</ol>;
|
|
68
|
-
},
|
|
69
|
-
li({ children }) {
|
|
70
|
-
return <li className="leading-relaxed">{children}</li>;
|
|
71
|
-
},
|
|
72
|
-
strong({ children }) {
|
|
73
|
-
return <strong className="font-semibold">{children}</strong>;
|
|
74
|
-
},
|
|
75
|
-
em({ children }) {
|
|
76
|
-
return <em className="italic">{children}</em>;
|
|
77
|
-
},
|
|
78
|
-
code({ children, className }) {
|
|
79
|
-
const isBlock = className?.includes('language-');
|
|
80
|
-
return isBlock ? (
|
|
81
|
-
<code className="block rounded-md bg-black/10 dark:bg-white/10 px-3 py-2 text-xs font-mono whitespace-pre-wrap my-2">
|
|
82
|
-
{children}
|
|
83
|
-
</code>
|
|
84
|
-
) : (
|
|
85
|
-
<code className="rounded bg-black/10 dark:bg-white/10 px-1 py-0.5 text-xs font-mono">
|
|
86
|
-
{children}
|
|
87
|
-
</code>
|
|
88
|
-
);
|
|
89
|
-
},
|
|
90
|
-
pre({ children }) {
|
|
91
|
-
return <pre className="my-2 overflow-x-auto rounded-md">{children}</pre>;
|
|
92
|
-
},
|
|
93
|
-
blockquote({ children }) {
|
|
94
|
-
return (
|
|
95
|
-
<blockquote className="border-l-2 border-current/30 pl-3 italic my-2 text-current/70">
|
|
96
|
-
{children}
|
|
97
|
-
</blockquote>
|
|
98
|
-
);
|
|
99
|
-
},
|
|
100
|
-
h1({ children }) {
|
|
101
|
-
return (
|
|
102
|
-
<h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>
|
|
103
|
-
);
|
|
104
|
-
},
|
|
105
|
-
h2({ children }) {
|
|
106
|
-
return (
|
|
107
|
-
<h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>
|
|
108
|
-
);
|
|
109
|
-
},
|
|
110
|
-
h3({ children }) {
|
|
111
|
-
return (
|
|
112
|
-
<h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>
|
|
113
|
-
);
|
|
114
|
-
},
|
|
115
|
-
a({ href, children }) {
|
|
116
|
-
return (
|
|
117
|
-
<a
|
|
118
|
-
href={href}
|
|
119
|
-
target="_blank"
|
|
120
|
-
rel="noopener noreferrer"
|
|
121
|
-
className="underline underline-offset-2 hover:opacity-80"
|
|
122
|
-
>
|
|
123
|
-
{children}
|
|
124
|
-
</a>
|
|
125
|
-
);
|
|
126
|
-
},
|
|
127
|
-
hr() {
|
|
128
|
-
return <hr className="my-3 border-current/20" />;
|
|
129
|
-
},
|
|
130
|
-
table({ children }) {
|
|
131
|
-
return (
|
|
132
|
-
<div className="my-2 overflow-x-auto">
|
|
133
|
-
<table className="text-xs border-collapse w-full">{children}</table>
|
|
134
|
-
</div>
|
|
135
|
-
);
|
|
136
|
-
},
|
|
137
|
-
th({ children }) {
|
|
138
|
-
return (
|
|
139
|
-
<th className="border border-current/20 px-2 py-1 font-semibold text-left bg-current/5">
|
|
140
|
-
{children}
|
|
141
|
-
</th>
|
|
142
|
-
);
|
|
143
|
-
},
|
|
144
|
-
td({ children }) {
|
|
145
|
-
return <td className="border border-current/20 px-2 py-1">{children}</td>;
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
function ToolBadge({
|
|
150
|
-
message,
|
|
151
|
-
variant,
|
|
152
|
-
}: {
|
|
153
|
-
message: Message;
|
|
154
|
-
variant: 'call' | 'result';
|
|
155
|
-
}) {
|
|
156
|
-
const [expanded, setExpanded] = useState(false);
|
|
157
|
-
let parsed: unknown;
|
|
158
|
-
try {
|
|
159
|
-
parsed = JSON.parse(message.content);
|
|
160
|
-
} catch {
|
|
161
|
-
parsed = message.content;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const colorClass =
|
|
165
|
-
variant === 'call'
|
|
166
|
-
? 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950 dark:text-violet-300'
|
|
167
|
-
: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300';
|
|
168
|
-
|
|
169
|
-
const preClass =
|
|
170
|
-
variant === 'call'
|
|
171
|
-
? 'border-violet-100 bg-violet-50/50 text-violet-800 dark:border-violet-900 dark:bg-violet-950/50 dark:text-violet-200'
|
|
172
|
-
: 'border-emerald-100 bg-emerald-50/50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-200';
|
|
173
|
-
|
|
174
|
-
return (
|
|
175
|
-
<div className="my-1 flex flex-col items-start">
|
|
176
|
-
<button
|
|
177
|
-
type="button"
|
|
178
|
-
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:opacity-80 ${colorClass}`}
|
|
179
|
-
onClick={() => setExpanded((v) => !v)}
|
|
180
|
-
>
|
|
181
|
-
<Wrench className="h-3 w-3 shrink-0" />
|
|
182
|
-
<span className="font-mono">{message.tool_name}</span>
|
|
183
|
-
{expanded ? (
|
|
184
|
-
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
185
|
-
) : (
|
|
186
|
-
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
187
|
-
)}
|
|
188
|
-
</button>
|
|
189
|
-
{expanded && (
|
|
190
|
-
<pre
|
|
191
|
-
className={`mt-1.5 w-full max-w-lg rounded-lg border px-3 py-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap ${preClass}`}
|
|
192
|
-
>
|
|
193
|
-
{typeof parsed === 'string'
|
|
194
|
-
? parsed
|
|
195
|
-
: JSON.stringify(parsed, null, 2)}
|
|
196
|
-
</pre>
|
|
197
|
-
)}
|
|
198
|
-
</div>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function TypingIndicator() {
|
|
203
|
-
return (
|
|
204
|
-
<div className="flex items-start gap-3">
|
|
205
|
-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-background">
|
|
206
|
-
<Bot className="h-4 w-4 text-muted-foreground" />
|
|
207
|
-
</div>
|
|
208
|
-
<div className="flex items-center gap-1 rounded-2xl rounded-tl-sm bg-muted px-4 py-3 shadow-sm">
|
|
209
|
-
<span
|
|
210
|
-
className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-bounce"
|
|
211
|
-
style={{ animationDelay: '0ms' }}
|
|
212
|
-
/>
|
|
213
|
-
<span
|
|
214
|
-
className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-bounce"
|
|
215
|
-
style={{ animationDelay: '150ms' }}
|
|
216
|
-
/>
|
|
217
|
-
<span
|
|
218
|
-
className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-bounce"
|
|
219
|
-
style={{ animationDelay: '300ms' }}
|
|
220
|
-
/>
|
|
221
|
-
</div>
|
|
222
|
-
</div>
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export default function McpChatPage() {
|
|
227
|
-
const { request, currentLocaleCode, refetchUser } = useApp();
|
|
228
|
-
const t = useTranslations('core.McpChatPage');
|
|
229
|
-
|
|
230
|
-
const [selectedConvId, setSelectedConvId] = useState<number | null>(null);
|
|
231
|
-
const [messages, setMessages] = useState<Message[]>([]);
|
|
232
|
-
const [input, setInput] = useState('');
|
|
233
|
-
const [isSending, setIsSending] = useState(false);
|
|
234
|
-
const [convToDelete, setConvToDelete] = useState<Conversation | null>(null);
|
|
235
|
-
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
236
|
-
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
237
|
-
|
|
238
|
-
const { data: conversations, refetch: refetchConvs } = useQuery<
|
|
239
|
-
Conversation[]
|
|
240
|
-
>({
|
|
241
|
-
queryKey: ['mcp-chat-conversations', currentLocaleCode],
|
|
242
|
-
queryFn: async () => {
|
|
243
|
-
const res = await request<Conversation[]>({
|
|
244
|
-
url: '/mcp-chat',
|
|
245
|
-
method: 'GET',
|
|
246
|
-
});
|
|
247
|
-
return res.data;
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const convList = conversations ?? [];
|
|
252
|
-
const selectedConv = convList.find((c) => c.id === selectedConvId) ?? null;
|
|
253
|
-
|
|
254
|
-
useEffect(() => {
|
|
255
|
-
if (!selectedConvId) return;
|
|
256
|
-
request<Message[]>({
|
|
257
|
-
url: `/mcp-chat/${selectedConvId}/messages`,
|
|
258
|
-
method: 'GET',
|
|
259
|
-
})
|
|
260
|
-
.then((res) => setMessages(res.data))
|
|
261
|
-
.catch(() => setMessages([]));
|
|
262
|
-
}, [selectedConvId]);
|
|
263
|
-
|
|
264
|
-
useEffect(() => {
|
|
265
|
-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
266
|
-
}, [messages, isSending]);
|
|
267
|
-
|
|
268
|
-
// Auto-resize textarea
|
|
269
|
-
useEffect(() => {
|
|
270
|
-
if (textareaRef.current) {
|
|
271
|
-
textareaRef.current.style.height = 'auto';
|
|
272
|
-
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 300)}px`;
|
|
273
|
-
}
|
|
274
|
-
}, [input]);
|
|
275
|
-
|
|
276
|
-
async function handleNewConversation() {
|
|
277
|
-
try {
|
|
278
|
-
const res = await request<Conversation>({
|
|
279
|
-
url: '/mcp-chat',
|
|
280
|
-
method: 'POST',
|
|
281
|
-
data: {},
|
|
282
|
-
});
|
|
283
|
-
await refetchConvs();
|
|
284
|
-
setSelectedConvId(res.data.id);
|
|
285
|
-
setMessages([]);
|
|
286
|
-
return res.data.id;
|
|
287
|
-
} catch {
|
|
288
|
-
toast.error('Failed to create conversation');
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function handleSend() {
|
|
294
|
-
if (!input.trim() || isSending) return;
|
|
295
|
-
let activeConvId = selectedConvId;
|
|
296
|
-
if (!activeConvId) {
|
|
297
|
-
activeConvId = await handleNewConversation();
|
|
298
|
-
if (!activeConvId) return;
|
|
299
|
-
}
|
|
300
|
-
setIsSending(true);
|
|
301
|
-
const currentInput = input;
|
|
302
|
-
setInput('');
|
|
303
|
-
|
|
304
|
-
setMessages((prev) => [
|
|
305
|
-
...prev,
|
|
306
|
-
{
|
|
307
|
-
id: -Date.now(),
|
|
308
|
-
conversation_id: activeConvId!,
|
|
309
|
-
role: 'user',
|
|
310
|
-
content: currentInput,
|
|
311
|
-
tool_name: null,
|
|
312
|
-
tool_call_id: null,
|
|
313
|
-
created_at: new Date().toISOString(),
|
|
314
|
-
},
|
|
315
|
-
]);
|
|
316
|
-
|
|
317
|
-
try {
|
|
318
|
-
const res = await request<Message[]>({
|
|
319
|
-
url: `/mcp-chat/${activeConvId}/messages`,
|
|
320
|
-
method: 'POST',
|
|
321
|
-
data: { message: currentInput },
|
|
322
|
-
});
|
|
323
|
-
setMessages(res.data);
|
|
324
|
-
await refetchConvs();
|
|
325
|
-
const hadToolUse = res.data.some(
|
|
326
|
-
(m) => m.role === 'tool_call' || m.role === 'tool_result'
|
|
327
|
-
);
|
|
328
|
-
if (hadToolUse) await refetchUser();
|
|
329
|
-
} catch {
|
|
330
|
-
toast.error('Failed to send message');
|
|
331
|
-
setMessages((prev) => prev.filter((m) => m.id !== -Date.now()));
|
|
332
|
-
} finally {
|
|
333
|
-
setIsSending(false);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function handleDeleteConversation() {
|
|
338
|
-
if (!convToDelete) return;
|
|
339
|
-
try {
|
|
340
|
-
await request({
|
|
341
|
-
url: '/mcp-chat',
|
|
342
|
-
method: 'DELETE',
|
|
343
|
-
data: { ids: [convToDelete.id] },
|
|
344
|
-
});
|
|
345
|
-
toast.success('Conversation deleted');
|
|
346
|
-
if (selectedConvId === convToDelete.id) {
|
|
347
|
-
setSelectedConvId(null);
|
|
348
|
-
setMessages([]);
|
|
349
|
-
}
|
|
350
|
-
setConvToDelete(null);
|
|
351
|
-
await refetchConvs();
|
|
352
|
-
} catch {
|
|
353
|
-
toast.error('Failed to delete conversation');
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const inputArea = (
|
|
358
|
-
<div className="relative flex items-end gap-2 rounded-2xl bg-background px-5 py-4">
|
|
359
|
-
<Textarea
|
|
360
|
-
ref={textareaRef}
|
|
361
|
-
className="resize-none flex-1 border-0 shadow-none focus-visible:ring-0 p-3 min-h-6 max-h-75 overflow-y-auto text-sm leading-relaxed bg-transparent"
|
|
362
|
-
placeholder={t('placeholder')}
|
|
363
|
-
value={input}
|
|
364
|
-
disabled={isSending}
|
|
365
|
-
onChange={(e) => setInput(e.target.value)}
|
|
366
|
-
onKeyDown={(e) => {
|
|
367
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
368
|
-
e.preventDefault();
|
|
369
|
-
void handleSend();
|
|
370
|
-
}
|
|
371
|
-
}}
|
|
372
|
-
rows={1}
|
|
373
|
-
/>
|
|
374
|
-
<Button
|
|
375
|
-
size="icon"
|
|
376
|
-
className="shrink-0 h-8 w-8 rounded-xl cursor-pointer"
|
|
377
|
-
disabled={isSending || !input.trim()}
|
|
378
|
-
onClick={() => void handleSend()}
|
|
379
|
-
>
|
|
380
|
-
<Send className="h-3.5 w-3.5" />
|
|
381
|
-
</Button>
|
|
382
|
-
</div>
|
|
383
|
-
);
|
|
384
|
-
|
|
385
|
-
return (
|
|
386
|
-
<div className="flex h-screen overflow-hidden bg-background">
|
|
387
|
-
{/* Sidebar */}
|
|
388
|
-
<aside className="w-64 shrink-0 flex flex-col border-r bg-muted/30">
|
|
389
|
-
<div className="p-3 border-b bg-background/50">
|
|
390
|
-
<Button
|
|
391
|
-
variant="outline"
|
|
392
|
-
className="w-full justify-start gap-2 cursor-pointer rounded-xl h-9 text-sm font-medium"
|
|
393
|
-
onClick={handleNewConversation}
|
|
394
|
-
>
|
|
395
|
-
<Plus className="h-4 w-4" />
|
|
396
|
-
{t('newConversation')}
|
|
397
|
-
</Button>
|
|
398
|
-
</div>
|
|
399
|
-
<div className="px-4 pt-4 pb-1.5">
|
|
400
|
-
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
401
|
-
{t('conversations')}
|
|
402
|
-
</p>
|
|
403
|
-
</div>
|
|
404
|
-
<ScrollArea className="flex-1 px-2 pb-2">
|
|
405
|
-
{convList.length === 0 ? (
|
|
406
|
-
<div className="flex flex-col items-center gap-2 py-8 px-4 text-center">
|
|
407
|
-
<MessageSquare className="h-8 w-8 text-muted-foreground/40" />
|
|
408
|
-
<p className="text-xs text-muted-foreground">
|
|
409
|
-
{t('emptyConversations')}
|
|
410
|
-
</p>
|
|
411
|
-
</div>
|
|
412
|
-
) : (
|
|
413
|
-
<div className="space-y-0.5 pt-1">
|
|
414
|
-
{convList.map((conv) => (
|
|
415
|
-
<div
|
|
416
|
-
key={conv.id}
|
|
417
|
-
className={`group flex items-center justify-between rounded-xl px-3 py-2 cursor-pointer transition-colors text-sm ${
|
|
418
|
-
selectedConvId === conv.id
|
|
419
|
-
? 'bg-primary text-primary-foreground'
|
|
420
|
-
: 'hover:bg-accent text-foreground'
|
|
421
|
-
}`}
|
|
422
|
-
onClick={() => {
|
|
423
|
-
setSelectedConvId(conv.id);
|
|
424
|
-
setMessages([]);
|
|
425
|
-
}}
|
|
426
|
-
>
|
|
427
|
-
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
428
|
-
<MessageSquare className="h-3.5 w-3.5 shrink-0 opacity-60" />
|
|
429
|
-
<span className="truncate text-xs font-medium">
|
|
430
|
-
{conv.title || `#${conv.id}`}
|
|
431
|
-
</span>
|
|
432
|
-
</div>
|
|
433
|
-
<Button
|
|
434
|
-
variant="ghost"
|
|
435
|
-
size="icon"
|
|
436
|
-
className={`h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0 cursor-pointer transition-opacity ${
|
|
437
|
-
selectedConvId === conv.id
|
|
438
|
-
? 'hover:bg-primary-foreground/20 text-primary-foreground'
|
|
439
|
-
: 'hover:bg-destructive/10 hover:text-destructive'
|
|
440
|
-
}`}
|
|
441
|
-
onClick={(e) => {
|
|
442
|
-
e.stopPropagation();
|
|
443
|
-
setConvToDelete(conv);
|
|
444
|
-
}}
|
|
445
|
-
>
|
|
446
|
-
<Trash2 className="h-3 w-3" />
|
|
447
|
-
</Button>
|
|
448
|
-
</div>
|
|
449
|
-
))}
|
|
450
|
-
</div>
|
|
451
|
-
)}
|
|
452
|
-
</ScrollArea>
|
|
453
|
-
</aside>
|
|
454
|
-
|
|
455
|
-
{/* Main area */}
|
|
456
|
-
<main className="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
457
|
-
{!selectedConvId ? (
|
|
458
|
-
/* Empty state */
|
|
459
|
-
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-6">
|
|
460
|
-
<div className="flex flex-col items-center gap-3 text-center">
|
|
461
|
-
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-primary/20">
|
|
462
|
-
<Bot className="h-8 w-8 text-primary" />
|
|
463
|
-
</div>
|
|
464
|
-
<h1 className="text-2xl font-bold tracking-tight">
|
|
465
|
-
{t('title')}
|
|
466
|
-
</h1>
|
|
467
|
-
<p className="text-sm text-muted-foreground max-w-sm">
|
|
468
|
-
{t('description')}
|
|
469
|
-
</p>
|
|
470
|
-
</div>
|
|
471
|
-
<div className="w-full max-w-2xl">{inputArea}</div>
|
|
472
|
-
</div>
|
|
473
|
-
) : (
|
|
474
|
-
/* Chat state */
|
|
475
|
-
<>
|
|
476
|
-
{/* Header */}
|
|
477
|
-
<header className="h-14 shrink-0 border-b flex items-center px-6 gap-3 bg-background/80 backdrop-blur-sm">
|
|
478
|
-
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
|
479
|
-
<Bot className="h-4 w-4 text-primary" />
|
|
480
|
-
</div>
|
|
481
|
-
<div className="flex flex-col leading-tight">
|
|
482
|
-
<span className="text-sm font-semibold">
|
|
483
|
-
{selectedConv?.title || `Chat #${selectedConvId}`}
|
|
484
|
-
</span>
|
|
485
|
-
<span className="text-xs text-muted-foreground">
|
|
486
|
-
{t('title')}
|
|
487
|
-
</span>
|
|
488
|
-
</div>
|
|
489
|
-
</header>
|
|
490
|
-
|
|
491
|
-
{/* Messages */}
|
|
492
|
-
<ScrollArea className="flex-1 min-h-0 px-4 py-6">
|
|
493
|
-
<div className="flex flex-col gap-4 max-w-3xl mx-auto">
|
|
494
|
-
{messages.map((msg, idx) => {
|
|
495
|
-
if (msg.role === 'tool_call') {
|
|
496
|
-
return (
|
|
497
|
-
<ToolBadge
|
|
498
|
-
key={msg.id ?? idx}
|
|
499
|
-
message={msg}
|
|
500
|
-
variant="call"
|
|
501
|
-
/>
|
|
502
|
-
);
|
|
503
|
-
}
|
|
504
|
-
if (msg.role === 'tool_result') {
|
|
505
|
-
return (
|
|
506
|
-
<ToolBadge
|
|
507
|
-
key={msg.id ?? idx}
|
|
508
|
-
message={msg}
|
|
509
|
-
variant="result"
|
|
510
|
-
/>
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
if (msg.role === 'user') {
|
|
514
|
-
return (
|
|
515
|
-
<div key={msg.id ?? idx} className="flex justify-end">
|
|
516
|
-
<div className="max-w-[75%] rounded-2xl rounded-tr-md bg-primary px-4 py-2.5 text-sm text-primary-foreground shadow-sm">
|
|
517
|
-
{msg.content}
|
|
518
|
-
</div>
|
|
519
|
-
</div>
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
return (
|
|
523
|
-
<div key={msg.id ?? idx} className="flex items-start gap-3">
|
|
524
|
-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-background shadow-sm">
|
|
525
|
-
<Bot className="h-4 w-4 text-muted-foreground" />
|
|
526
|
-
</div>
|
|
527
|
-
<div className="max-w-[75%] rounded-2xl rounded-tl-md bg-muted px-4 py-2.5 text-sm shadow-sm prose-sm">
|
|
528
|
-
<ReactMarkdown
|
|
529
|
-
remarkPlugins={[remarkGfm]}
|
|
530
|
-
components={markdownComponents as never}
|
|
531
|
-
>
|
|
532
|
-
{msg.content}
|
|
533
|
-
</ReactMarkdown>
|
|
534
|
-
</div>
|
|
535
|
-
</div>
|
|
536
|
-
);
|
|
537
|
-
})}
|
|
538
|
-
{isSending && <TypingIndicator />}
|
|
539
|
-
<div ref={messagesEndRef} />
|
|
540
|
-
</div>
|
|
541
|
-
</ScrollArea>
|
|
542
|
-
|
|
543
|
-
{/* Input */}
|
|
544
|
-
<div className="shrink-0 border-t bg-background/80 backdrop-blur-sm px-6 py-4">
|
|
545
|
-
<div className="max-w-3xl mx-auto">{inputArea}</div>
|
|
546
|
-
</div>
|
|
547
|
-
</>
|
|
548
|
-
)}
|
|
549
|
-
</main>
|
|
550
|
-
|
|
551
|
-
<AlertDialog
|
|
552
|
-
open={Boolean(convToDelete)}
|
|
553
|
-
onOpenChange={(open) => {
|
|
554
|
-
if (!open) setConvToDelete(null);
|
|
555
|
-
}}
|
|
556
|
-
>
|
|
557
|
-
<AlertDialogContent>
|
|
558
|
-
<AlertDialogHeader>
|
|
559
|
-
<AlertDialogTitle>{t('deleteConversation')}?</AlertDialogTitle>
|
|
560
|
-
<AlertDialogDescription>
|
|
561
|
-
{convToDelete?.title || `#${convToDelete?.id}`}
|
|
562
|
-
</AlertDialogDescription>
|
|
563
|
-
</AlertDialogHeader>
|
|
564
|
-
<AlertDialogFooter>
|
|
565
|
-
<AlertDialogCancel>
|
|
566
|
-
<AlertDialogAction onClick={handleDeleteConversation}>
|
|
567
|
-
{t('deleteConversation')}
|
|
568
|
-
</AlertDialogAction>
|
|
569
|
-
</AlertDialogFooter>
|
|
570
|
-
</AlertDialogContent>
|
|
571
|
-
</AlertDialog>
|
|
572
|
-
</div>
|
|
573
|
-
);
|
|
574
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogAction,
|
|
6
|
+
AlertDialogCancel,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogDescription,
|
|
9
|
+
AlertDialogFooter,
|
|
10
|
+
AlertDialogHeader,
|
|
11
|
+
AlertDialogTitle,
|
|
12
|
+
} from '@/components/ui/alert-dialog';
|
|
13
|
+
import { Button } from '@/components/ui/button';
|
|
14
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
15
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
16
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
17
|
+
import {
|
|
18
|
+
Bot,
|
|
19
|
+
ChevronDown,
|
|
20
|
+
ChevronRight,
|
|
21
|
+
MessageSquare,
|
|
22
|
+
Plus,
|
|
23
|
+
Send,
|
|
24
|
+
Trash2,
|
|
25
|
+
Wrench,
|
|
26
|
+
} from 'lucide-react';
|
|
27
|
+
import { useTranslations } from 'next-intl';
|
|
28
|
+
import { useEffect, useRef, useState } from 'react';
|
|
29
|
+
import ReactMarkdown from 'react-markdown';
|
|
30
|
+
import remarkGfm from 'remark-gfm';
|
|
31
|
+
import { toast } from 'sonner';
|
|
32
|
+
|
|
33
|
+
type Conversation = {
|
|
34
|
+
id: number;
|
|
35
|
+
title: string | null;
|
|
36
|
+
created_at: string;
|
|
37
|
+
updated_at: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type Message = {
|
|
41
|
+
id: number;
|
|
42
|
+
conversation_id: number;
|
|
43
|
+
role: 'user' | 'assistant' | 'tool_call' | 'tool_result';
|
|
44
|
+
content: string;
|
|
45
|
+
tool_name: string | null;
|
|
46
|
+
tool_call_id: string | null;
|
|
47
|
+
created_at: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type MarkdownComponentProps = {
|
|
51
|
+
children?: React.ReactNode;
|
|
52
|
+
className?: string;
|
|
53
|
+
href?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const markdownComponents: Record<
|
|
57
|
+
string,
|
|
58
|
+
(props: MarkdownComponentProps) => React.ReactNode
|
|
59
|
+
> = {
|
|
60
|
+
p({ children }) {
|
|
61
|
+
return <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>;
|
|
62
|
+
},
|
|
63
|
+
ul({ children }) {
|
|
64
|
+
return <ul className="mb-2 list-disc pl-4 space-y-1">{children}</ul>;
|
|
65
|
+
},
|
|
66
|
+
ol({ children }) {
|
|
67
|
+
return <ol className="mb-2 list-decimal pl-4 space-y-1">{children}</ol>;
|
|
68
|
+
},
|
|
69
|
+
li({ children }) {
|
|
70
|
+
return <li className="leading-relaxed">{children}</li>;
|
|
71
|
+
},
|
|
72
|
+
strong({ children }) {
|
|
73
|
+
return <strong className="font-semibold">{children}</strong>;
|
|
74
|
+
},
|
|
75
|
+
em({ children }) {
|
|
76
|
+
return <em className="italic">{children}</em>;
|
|
77
|
+
},
|
|
78
|
+
code({ children, className }) {
|
|
79
|
+
const isBlock = className?.includes('language-');
|
|
80
|
+
return isBlock ? (
|
|
81
|
+
<code className="block rounded-md bg-black/10 dark:bg-white/10 px-3 py-2 text-xs font-mono whitespace-pre-wrap my-2">
|
|
82
|
+
{children}
|
|
83
|
+
</code>
|
|
84
|
+
) : (
|
|
85
|
+
<code className="rounded bg-black/10 dark:bg-white/10 px-1 py-0.5 text-xs font-mono">
|
|
86
|
+
{children}
|
|
87
|
+
</code>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
pre({ children }) {
|
|
91
|
+
return <pre className="my-2 overflow-x-auto rounded-md">{children}</pre>;
|
|
92
|
+
},
|
|
93
|
+
blockquote({ children }) {
|
|
94
|
+
return (
|
|
95
|
+
<blockquote className="border-l-2 border-current/30 pl-3 italic my-2 text-current/70">
|
|
96
|
+
{children}
|
|
97
|
+
</blockquote>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
h1({ children }) {
|
|
101
|
+
return (
|
|
102
|
+
<h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
h2({ children }) {
|
|
106
|
+
return (
|
|
107
|
+
<h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
h3({ children }) {
|
|
111
|
+
return (
|
|
112
|
+
<h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
a({ href, children }) {
|
|
116
|
+
return (
|
|
117
|
+
<a
|
|
118
|
+
href={href}
|
|
119
|
+
target="_blank"
|
|
120
|
+
rel="noopener noreferrer"
|
|
121
|
+
className="underline underline-offset-2 hover:opacity-80"
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</a>
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
hr() {
|
|
128
|
+
return <hr className="my-3 border-current/20" />;
|
|
129
|
+
},
|
|
130
|
+
table({ children }) {
|
|
131
|
+
return (
|
|
132
|
+
<div className="my-2 overflow-x-auto">
|
|
133
|
+
<table className="text-xs border-collapse w-full">{children}</table>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
th({ children }) {
|
|
138
|
+
return (
|
|
139
|
+
<th className="border border-current/20 px-2 py-1 font-semibold text-left bg-current/5">
|
|
140
|
+
{children}
|
|
141
|
+
</th>
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
td({ children }) {
|
|
145
|
+
return <td className="border border-current/20 px-2 py-1">{children}</td>;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function ToolBadge({
|
|
150
|
+
message,
|
|
151
|
+
variant,
|
|
152
|
+
}: {
|
|
153
|
+
message: Message;
|
|
154
|
+
variant: 'call' | 'result';
|
|
155
|
+
}) {
|
|
156
|
+
const [expanded, setExpanded] = useState(false);
|
|
157
|
+
let parsed: unknown;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(message.content);
|
|
160
|
+
} catch {
|
|
161
|
+
parsed = message.content;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const colorClass =
|
|
165
|
+
variant === 'call'
|
|
166
|
+
? 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950 dark:text-violet-300'
|
|
167
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300';
|
|
168
|
+
|
|
169
|
+
const preClass =
|
|
170
|
+
variant === 'call'
|
|
171
|
+
? 'border-violet-100 bg-violet-50/50 text-violet-800 dark:border-violet-900 dark:bg-violet-950/50 dark:text-violet-200'
|
|
172
|
+
: 'border-emerald-100 bg-emerald-50/50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-200';
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="my-1 flex flex-col items-start">
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:opacity-80 ${colorClass}`}
|
|
179
|
+
onClick={() => setExpanded((v) => !v)}
|
|
180
|
+
>
|
|
181
|
+
<Wrench className="h-3 w-3 shrink-0" />
|
|
182
|
+
<span className="font-mono">{message.tool_name}</span>
|
|
183
|
+
{expanded ? (
|
|
184
|
+
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
185
|
+
) : (
|
|
186
|
+
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
187
|
+
)}
|
|
188
|
+
</button>
|
|
189
|
+
{expanded && (
|
|
190
|
+
<pre
|
|
191
|
+
className={`mt-1.5 w-full max-w-lg rounded-lg border px-3 py-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap ${preClass}`}
|
|
192
|
+
>
|
|
193
|
+
{typeof parsed === 'string'
|
|
194
|
+
? parsed
|
|
195
|
+
: JSON.stringify(parsed, null, 2)}
|
|
196
|
+
</pre>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function TypingIndicator() {
|
|
203
|
+
return (
|
|
204
|
+
<div className="flex items-start gap-3">
|
|
205
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-background">
|
|
206
|
+
<Bot className="h-4 w-4 text-muted-foreground" />
|
|
207
|
+
</div>
|
|
208
|
+
<div className="flex items-center gap-1 rounded-2xl rounded-tl-sm bg-muted px-4 py-3 shadow-sm">
|
|
209
|
+
<span
|
|
210
|
+
className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-bounce"
|
|
211
|
+
style={{ animationDelay: '0ms' }}
|
|
212
|
+
/>
|
|
213
|
+
<span
|
|
214
|
+
className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-bounce"
|
|
215
|
+
style={{ animationDelay: '150ms' }}
|
|
216
|
+
/>
|
|
217
|
+
<span
|
|
218
|
+
className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-bounce"
|
|
219
|
+
style={{ animationDelay: '300ms' }}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default function McpChatPage() {
|
|
227
|
+
const { request, currentLocaleCode, refetchUser } = useApp();
|
|
228
|
+
const t = useTranslations('core.McpChatPage');
|
|
229
|
+
|
|
230
|
+
const [selectedConvId, setSelectedConvId] = useState<number | null>(null);
|
|
231
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
232
|
+
const [input, setInput] = useState('');
|
|
233
|
+
const [isSending, setIsSending] = useState(false);
|
|
234
|
+
const [convToDelete, setConvToDelete] = useState<Conversation | null>(null);
|
|
235
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
236
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
237
|
+
|
|
238
|
+
const { data: conversations, refetch: refetchConvs } = useQuery<
|
|
239
|
+
Conversation[]
|
|
240
|
+
>({
|
|
241
|
+
queryKey: ['mcp-chat-conversations', currentLocaleCode],
|
|
242
|
+
queryFn: async () => {
|
|
243
|
+
const res = await request<Conversation[]>({
|
|
244
|
+
url: '/mcp-chat',
|
|
245
|
+
method: 'GET',
|
|
246
|
+
});
|
|
247
|
+
return res.data;
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const convList = conversations ?? [];
|
|
252
|
+
const selectedConv = convList.find((c) => c.id === selectedConvId) ?? null;
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!selectedConvId) return;
|
|
256
|
+
request<Message[]>({
|
|
257
|
+
url: `/mcp-chat/${selectedConvId}/messages`,
|
|
258
|
+
method: 'GET',
|
|
259
|
+
})
|
|
260
|
+
.then((res) => setMessages(res.data))
|
|
261
|
+
.catch(() => setMessages([]));
|
|
262
|
+
}, [selectedConvId]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
266
|
+
}, [messages, isSending]);
|
|
267
|
+
|
|
268
|
+
// Auto-resize textarea
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (textareaRef.current) {
|
|
271
|
+
textareaRef.current.style.height = 'auto';
|
|
272
|
+
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 300)}px`;
|
|
273
|
+
}
|
|
274
|
+
}, [input]);
|
|
275
|
+
|
|
276
|
+
async function handleNewConversation() {
|
|
277
|
+
try {
|
|
278
|
+
const res = await request<Conversation>({
|
|
279
|
+
url: '/mcp-chat',
|
|
280
|
+
method: 'POST',
|
|
281
|
+
data: {},
|
|
282
|
+
});
|
|
283
|
+
await refetchConvs();
|
|
284
|
+
setSelectedConvId(res.data.id);
|
|
285
|
+
setMessages([]);
|
|
286
|
+
return res.data.id;
|
|
287
|
+
} catch {
|
|
288
|
+
toast.error('Failed to create conversation');
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function handleSend() {
|
|
294
|
+
if (!input.trim() || isSending) return;
|
|
295
|
+
let activeConvId = selectedConvId;
|
|
296
|
+
if (!activeConvId) {
|
|
297
|
+
activeConvId = await handleNewConversation();
|
|
298
|
+
if (!activeConvId) return;
|
|
299
|
+
}
|
|
300
|
+
setIsSending(true);
|
|
301
|
+
const currentInput = input;
|
|
302
|
+
setInput('');
|
|
303
|
+
|
|
304
|
+
setMessages((prev) => [
|
|
305
|
+
...prev,
|
|
306
|
+
{
|
|
307
|
+
id: -Date.now(),
|
|
308
|
+
conversation_id: activeConvId!,
|
|
309
|
+
role: 'user',
|
|
310
|
+
content: currentInput,
|
|
311
|
+
tool_name: null,
|
|
312
|
+
tool_call_id: null,
|
|
313
|
+
created_at: new Date().toISOString(),
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const res = await request<Message[]>({
|
|
319
|
+
url: `/mcp-chat/${activeConvId}/messages`,
|
|
320
|
+
method: 'POST',
|
|
321
|
+
data: { message: currentInput },
|
|
322
|
+
});
|
|
323
|
+
setMessages(res.data);
|
|
324
|
+
await refetchConvs();
|
|
325
|
+
const hadToolUse = res.data.some(
|
|
326
|
+
(m) => m.role === 'tool_call' || m.role === 'tool_result'
|
|
327
|
+
);
|
|
328
|
+
if (hadToolUse) await refetchUser();
|
|
329
|
+
} catch {
|
|
330
|
+
toast.error('Failed to send message');
|
|
331
|
+
setMessages((prev) => prev.filter((m) => m.id !== -Date.now()));
|
|
332
|
+
} finally {
|
|
333
|
+
setIsSending(false);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function handleDeleteConversation() {
|
|
338
|
+
if (!convToDelete) return;
|
|
339
|
+
try {
|
|
340
|
+
await request({
|
|
341
|
+
url: '/mcp-chat',
|
|
342
|
+
method: 'DELETE',
|
|
343
|
+
data: { ids: [convToDelete.id] },
|
|
344
|
+
});
|
|
345
|
+
toast.success('Conversation deleted');
|
|
346
|
+
if (selectedConvId === convToDelete.id) {
|
|
347
|
+
setSelectedConvId(null);
|
|
348
|
+
setMessages([]);
|
|
349
|
+
}
|
|
350
|
+
setConvToDelete(null);
|
|
351
|
+
await refetchConvs();
|
|
352
|
+
} catch {
|
|
353
|
+
toast.error('Failed to delete conversation');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const inputArea = (
|
|
358
|
+
<div className="relative flex items-end gap-2 rounded-2xl bg-background px-5 py-4">
|
|
359
|
+
<Textarea
|
|
360
|
+
ref={textareaRef}
|
|
361
|
+
className="resize-none flex-1 border-0 shadow-none focus-visible:ring-0 p-3 min-h-6 max-h-75 overflow-y-auto text-sm leading-relaxed bg-transparent"
|
|
362
|
+
placeholder={t('placeholder')}
|
|
363
|
+
value={input}
|
|
364
|
+
disabled={isSending}
|
|
365
|
+
onChange={(e) => setInput(e.target.value)}
|
|
366
|
+
onKeyDown={(e) => {
|
|
367
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
void handleSend();
|
|
370
|
+
}
|
|
371
|
+
}}
|
|
372
|
+
rows={1}
|
|
373
|
+
/>
|
|
374
|
+
<Button
|
|
375
|
+
size="icon"
|
|
376
|
+
className="shrink-0 h-8 w-8 rounded-xl cursor-pointer"
|
|
377
|
+
disabled={isSending || !input.trim()}
|
|
378
|
+
onClick={() => void handleSend()}
|
|
379
|
+
>
|
|
380
|
+
<Send className="h-3.5 w-3.5" />
|
|
381
|
+
</Button>
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<div className="flex h-screen overflow-hidden bg-background">
|
|
387
|
+
{/* Sidebar */}
|
|
388
|
+
<aside className="w-64 shrink-0 flex flex-col border-r bg-muted/30">
|
|
389
|
+
<div className="p-3 border-b bg-background/50">
|
|
390
|
+
<Button
|
|
391
|
+
variant="outline"
|
|
392
|
+
className="w-full justify-start gap-2 cursor-pointer rounded-xl h-9 text-sm font-medium"
|
|
393
|
+
onClick={handleNewConversation}
|
|
394
|
+
>
|
|
395
|
+
<Plus className="h-4 w-4" />
|
|
396
|
+
{t('newConversation')}
|
|
397
|
+
</Button>
|
|
398
|
+
</div>
|
|
399
|
+
<div className="px-4 pt-4 pb-1.5">
|
|
400
|
+
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
401
|
+
{t('conversations')}
|
|
402
|
+
</p>
|
|
403
|
+
</div>
|
|
404
|
+
<ScrollArea className="flex-1 px-2 pb-2">
|
|
405
|
+
{convList.length === 0 ? (
|
|
406
|
+
<div className="flex flex-col items-center gap-2 py-8 px-4 text-center">
|
|
407
|
+
<MessageSquare className="h-8 w-8 text-muted-foreground/40" />
|
|
408
|
+
<p className="text-xs text-muted-foreground">
|
|
409
|
+
{t('emptyConversations')}
|
|
410
|
+
</p>
|
|
411
|
+
</div>
|
|
412
|
+
) : (
|
|
413
|
+
<div className="space-y-0.5 pt-1">
|
|
414
|
+
{convList.map((conv) => (
|
|
415
|
+
<div
|
|
416
|
+
key={conv.id}
|
|
417
|
+
className={`group flex items-center justify-between rounded-xl px-3 py-2 cursor-pointer transition-colors text-sm ${
|
|
418
|
+
selectedConvId === conv.id
|
|
419
|
+
? 'bg-primary text-primary-foreground'
|
|
420
|
+
: 'hover:bg-accent text-foreground'
|
|
421
|
+
}`}
|
|
422
|
+
onClick={() => {
|
|
423
|
+
setSelectedConvId(conv.id);
|
|
424
|
+
setMessages([]);
|
|
425
|
+
}}
|
|
426
|
+
>
|
|
427
|
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
428
|
+
<MessageSquare className="h-3.5 w-3.5 shrink-0 opacity-60" />
|
|
429
|
+
<span className="truncate text-xs font-medium">
|
|
430
|
+
{conv.title || `#${conv.id}`}
|
|
431
|
+
</span>
|
|
432
|
+
</div>
|
|
433
|
+
<Button
|
|
434
|
+
variant="ghost"
|
|
435
|
+
size="icon"
|
|
436
|
+
className={`h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0 cursor-pointer transition-opacity ${
|
|
437
|
+
selectedConvId === conv.id
|
|
438
|
+
? 'hover:bg-primary-foreground/20 text-primary-foreground'
|
|
439
|
+
: 'hover:bg-destructive/10 hover:text-destructive'
|
|
440
|
+
}`}
|
|
441
|
+
onClick={(e) => {
|
|
442
|
+
e.stopPropagation();
|
|
443
|
+
setConvToDelete(conv);
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<Trash2 className="h-3 w-3" />
|
|
447
|
+
</Button>
|
|
448
|
+
</div>
|
|
449
|
+
))}
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</ScrollArea>
|
|
453
|
+
</aside>
|
|
454
|
+
|
|
455
|
+
{/* Main area */}
|
|
456
|
+
<main className="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
457
|
+
{!selectedConvId ? (
|
|
458
|
+
/* Empty state */
|
|
459
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-6">
|
|
460
|
+
<div className="flex flex-col items-center gap-3 text-center">
|
|
461
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-primary/20">
|
|
462
|
+
<Bot className="h-8 w-8 text-primary" />
|
|
463
|
+
</div>
|
|
464
|
+
<h1 className="text-2xl font-bold tracking-tight">
|
|
465
|
+
{t('title')}
|
|
466
|
+
</h1>
|
|
467
|
+
<p className="text-sm text-muted-foreground max-w-sm">
|
|
468
|
+
{t('description')}
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
<div className="w-full max-w-2xl">{inputArea}</div>
|
|
472
|
+
</div>
|
|
473
|
+
) : (
|
|
474
|
+
/* Chat state */
|
|
475
|
+
<>
|
|
476
|
+
{/* Header */}
|
|
477
|
+
<header className="h-14 shrink-0 border-b flex items-center px-6 gap-3 bg-background/80 backdrop-blur-sm">
|
|
478
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
|
479
|
+
<Bot className="h-4 w-4 text-primary" />
|
|
480
|
+
</div>
|
|
481
|
+
<div className="flex flex-col leading-tight">
|
|
482
|
+
<span className="text-sm font-semibold">
|
|
483
|
+
{selectedConv?.title || `Chat #${selectedConvId}`}
|
|
484
|
+
</span>
|
|
485
|
+
<span className="text-xs text-muted-foreground">
|
|
486
|
+
{t('title')}
|
|
487
|
+
</span>
|
|
488
|
+
</div>
|
|
489
|
+
</header>
|
|
490
|
+
|
|
491
|
+
{/* Messages */}
|
|
492
|
+
<ScrollArea className="flex-1 min-h-0 px-4 py-6">
|
|
493
|
+
<div className="flex flex-col gap-4 max-w-3xl mx-auto">
|
|
494
|
+
{messages.map((msg, idx) => {
|
|
495
|
+
if (msg.role === 'tool_call') {
|
|
496
|
+
return (
|
|
497
|
+
<ToolBadge
|
|
498
|
+
key={msg.id ?? idx}
|
|
499
|
+
message={msg}
|
|
500
|
+
variant="call"
|
|
501
|
+
/>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (msg.role === 'tool_result') {
|
|
505
|
+
return (
|
|
506
|
+
<ToolBadge
|
|
507
|
+
key={msg.id ?? idx}
|
|
508
|
+
message={msg}
|
|
509
|
+
variant="result"
|
|
510
|
+
/>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (msg.role === 'user') {
|
|
514
|
+
return (
|
|
515
|
+
<div key={msg.id ?? idx} className="flex justify-end">
|
|
516
|
+
<div className="max-w-[75%] rounded-2xl rounded-tr-md bg-primary px-4 py-2.5 text-sm text-primary-foreground shadow-sm">
|
|
517
|
+
{msg.content}
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return (
|
|
523
|
+
<div key={msg.id ?? idx} className="flex items-start gap-3">
|
|
524
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-background shadow-sm">
|
|
525
|
+
<Bot className="h-4 w-4 text-muted-foreground" />
|
|
526
|
+
</div>
|
|
527
|
+
<div className="max-w-[75%] rounded-2xl rounded-tl-md bg-muted px-4 py-2.5 text-sm shadow-sm prose-sm">
|
|
528
|
+
<ReactMarkdown
|
|
529
|
+
remarkPlugins={[remarkGfm]}
|
|
530
|
+
components={markdownComponents as never}
|
|
531
|
+
>
|
|
532
|
+
{msg.content}
|
|
533
|
+
</ReactMarkdown>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
);
|
|
537
|
+
})}
|
|
538
|
+
{isSending && <TypingIndicator />}
|
|
539
|
+
<div ref={messagesEndRef} />
|
|
540
|
+
</div>
|
|
541
|
+
</ScrollArea>
|
|
542
|
+
|
|
543
|
+
{/* Input */}
|
|
544
|
+
<div className="shrink-0 border-t bg-background/80 backdrop-blur-sm px-6 py-4">
|
|
545
|
+
<div className="max-w-3xl mx-auto">{inputArea}</div>
|
|
546
|
+
</div>
|
|
547
|
+
</>
|
|
548
|
+
)}
|
|
549
|
+
</main>
|
|
550
|
+
|
|
551
|
+
<AlertDialog
|
|
552
|
+
open={Boolean(convToDelete)}
|
|
553
|
+
onOpenChange={(open) => {
|
|
554
|
+
if (!open) setConvToDelete(null);
|
|
555
|
+
}}
|
|
556
|
+
>
|
|
557
|
+
<AlertDialogContent>
|
|
558
|
+
<AlertDialogHeader>
|
|
559
|
+
<AlertDialogTitle>{t('deleteConversation')}?</AlertDialogTitle>
|
|
560
|
+
<AlertDialogDescription>
|
|
561
|
+
{convToDelete?.title || `#${convToDelete?.id}`}
|
|
562
|
+
</AlertDialogDescription>
|
|
563
|
+
</AlertDialogHeader>
|
|
564
|
+
<AlertDialogFooter>
|
|
565
|
+
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
|
566
|
+
<AlertDialogAction onClick={handleDeleteConversation}>
|
|
567
|
+
{t('deleteConversation')}
|
|
568
|
+
</AlertDialogAction>
|
|
569
|
+
</AlertDialogFooter>
|
|
570
|
+
</AlertDialogContent>
|
|
571
|
+
</AlertDialog>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|