@hed-hog/core 0.0.349 → 0.0.350
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/auth/auth.controller.d.ts +3 -3
- package/dist/auth/auth.service.d.ts +8 -8
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/guards/mcp-auth.guard.d.ts +6 -1
- package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -1
- package/dist/mcp/guards/mcp-auth.guard.js +29 -2
- package/dist/mcp/guards/mcp-auth.guard.js.map +1 -1
- package/dist/mcp/mcp-api-key.controller.d.ts +32 -0
- package/dist/mcp/mcp-api-key.controller.d.ts.map +1 -0
- package/dist/mcp/mcp-api-key.controller.js +64 -0
- package/dist/mcp/mcp-api-key.controller.js.map +1 -0
- package/dist/mcp/mcp-api-key.service.d.ts +27 -0
- package/dist/mcp/mcp-api-key.service.d.ts.map +1 -0
- package/dist/mcp/mcp-api-key.service.js +74 -0
- package/dist/mcp/mcp-api-key.service.js.map +1 -0
- package/dist/mcp/mcp-registry.service.d.ts +1 -2
- package/dist/mcp/mcp-registry.service.d.ts.map +1 -1
- package/dist/mcp/mcp-registry.service.js +6 -2
- package/dist/mcp/mcp-registry.service.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +8 -1
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp-chat/mcp-chat.controller.d.ts +4 -0
- package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -1
- package/dist/mcp-chat/mcp-chat.controller.js +46 -0
- package/dist/mcp-chat/mcp-chat.controller.js.map +1 -1
- package/dist/mcp-chat/mcp-chat.service.d.ts +6 -1
- package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
- package/dist/mcp-chat/mcp-chat.service.js +125 -17
- package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
- package/dist/notification/dto/create-notification.dto.d.ts +26 -0
- package/dist/notification/dto/create-notification.dto.d.ts.map +1 -0
- package/dist/notification/dto/create-notification.dto.js +86 -0
- package/dist/notification/dto/create-notification.dto.js.map +1 -0
- package/dist/notification/dto/update-progress.dto.d.ts +6 -0
- package/dist/notification/dto/update-progress.dto.d.ts.map +1 -0
- package/dist/notification/dto/update-progress.dto.js +33 -0
- package/dist/notification/dto/update-progress.dto.js.map +1 -0
- package/dist/notification/notification.controller.d.ts +108 -0
- package/dist/notification/notification.controller.d.ts.map +1 -0
- package/dist/notification/notification.controller.js +118 -0
- package/dist/notification/notification.controller.js.map +1 -0
- package/dist/notification/notification.module.d.ts +3 -0
- package/dist/notification/notification.module.d.ts.map +1 -0
- package/dist/notification/notification.module.js +29 -0
- package/dist/notification/notification.module.js.map +1 -0
- package/dist/notification/notification.service.d.ts +98 -0
- package/dist/notification/notification.service.d.ts.map +1 -0
- package/dist/notification/notification.service.js +123 -0
- package/dist/notification/notification.service.js.map +1 -0
- package/dist/profile/profile.controller.d.ts +1 -1
- package/dist/profile/profile.service.d.ts +1 -1
- package/dist/session/session.controller.d.ts +1 -1
- package/dist/session/session.service.d.ts +3 -3
- package/dist/setting/dto/update-setting-list.dto.d.ts +9 -0
- package/dist/setting/dto/update-setting-list.dto.d.ts.map +1 -0
- package/dist/setting/dto/update-setting-list.dto.js +44 -0
- package/dist/setting/dto/update-setting-list.dto.js.map +1 -0
- package/dist/setting/setting.controller.d.ts +19 -0
- package/dist/setting/setting.controller.d.ts.map +1 -1
- package/dist/setting/setting.controller.js +12 -0
- package/dist/setting/setting.controller.js.map +1 -1
- package/dist/setting/setting.service.d.ts +19 -0
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +79 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +12 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +60 -0
- package/dist/task/task.service.js.map +1 -1
- package/dist/user/user.controller.d.ts +2 -2
- package/dist/user/user.service.d.ts +6 -6
- package/dist/webhook-integration/webhook-integration.service.d.ts +4 -0
- package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.js +184 -34
- package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
- package/dist/webhook-integration/webhook-utils.d.ts +1 -0
- package/dist/webhook-integration/webhook-utils.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-utils.js +38 -0
- package/dist/webhook-integration/webhook-utils.js.map +1 -1
- package/hedhog/data/menu.yaml +48 -4
- package/hedhog/data/route.yaml +133 -0
- package/hedhog/data/setting_group.yaml +45 -0
- package/hedhog/data/setting_subgroup.yaml +16 -0
- package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +285 -0
- package/hedhog/frontend/app/account/layout.tsx.ejs +32 -55
- package/hedhog/frontend/app/account/mcp-tokens/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +2098 -1835
- package/hedhog/frontend/app/mcp_chat/chat-components.tsx.ejs +595 -0
- package/hedhog/frontend/app/mcp_chat/chat-types.ts.ejs +16 -0
- package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +554 -383
- package/hedhog/frontend/app/setting-list/page.tsx.ejs +649 -0
- package/hedhog/frontend/app/users/page.tsx.ejs +445 -203
- package/hedhog/frontend/app/users/user-sheet.tsx.ejs +16 -37
- package/hedhog/frontend/messages/en.json +241 -2
- package/hedhog/frontend/messages/pt.json +241 -2
- package/hedhog/table/mcp_api_key.yaml +30 -0
- package/hedhog/table/notification.yaml +72 -0
- package/hedhog/table/webhook_action_log.yaml +49 -0
- package/package.json +4 -4
- package/src/core.module.ts +3 -0
- package/src/index.ts +5 -0
- package/src/mcp/guards/mcp-auth.guard.ts +64 -32
- package/src/mcp/mcp-api-key.controller.ts +42 -0
- package/src/mcp/mcp-api-key.service.ts +66 -0
- package/src/mcp/mcp-registry.service.ts +75 -69
- package/src/mcp/mcp.module.ts +43 -36
- package/src/mcp-chat/mcp-chat.controller.ts +113 -60
- package/src/mcp-chat/mcp-chat.service.ts +494 -332
- package/src/notification/dto/create-notification.dto.ts +71 -0
- package/src/notification/dto/update-progress.dto.ts +16 -0
- package/src/notification/notification.controller.ts +71 -0
- package/src/notification/notification.module.ts +16 -0
- package/src/notification/notification.service.ts +124 -0
- package/src/setting/dto/update-setting-list.dto.ts +32 -0
- package/src/setting/setting.controller.ts +9 -0
- package/src/setting/setting.service.ts +91 -0
- package/src/task/task.service.ts +72 -0
- package/src/webhook-integration/webhook-integration.service.ts +934 -735
- package/src/webhook-integration/webhook-utils.ts +136 -79
|
@@ -12,232 +12,70 @@ import {
|
|
|
12
12
|
} from '@/components/ui/alert-dialog';
|
|
13
13
|
import { Button } from '@/components/ui/button';
|
|
14
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
15
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from '
|
|
16
|
+
Sheet,
|
|
17
|
+
SheetContent,
|
|
18
|
+
SheetDescription,
|
|
19
|
+
SheetHeader,
|
|
20
|
+
SheetTitle,
|
|
21
|
+
} from '@/components/ui/sheet';
|
|
22
|
+
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
23
|
+
import { useFormDraft } from '@/hooks/use-form-draft';
|
|
24
|
+
import { useIsMobile } from '@/hooks/use-mobile';
|
|
25
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
26
|
+
import { Bot, MessageSquare } from 'lucide-react';
|
|
27
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';
|
|
28
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
31
29
|
import { toast } from 'sonner';
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
}
|
|
30
|
+
import { ChatInput, ConversationSidebar, MessageFeed } from './chat-components';
|
|
31
|
+
import { Conversation, Message } from './chat-types';
|
|
225
32
|
|
|
226
33
|
export default function McpChatPage() {
|
|
227
|
-
const { request, currentLocaleCode, refetchUser } = useApp();
|
|
34
|
+
const { request, currentLocaleCode, refetchUser, accessToken } = useApp();
|
|
228
35
|
const t = useTranslations('core.McpChatPage');
|
|
36
|
+
const isMobile = useIsMobile();
|
|
229
37
|
|
|
230
38
|
const [selectedConvId, setSelectedConvId] = useState<number | null>(null);
|
|
231
39
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
232
40
|
const [input, setInput] = useState('');
|
|
41
|
+
const [attachments, setAttachments] = useState<
|
|
42
|
+
Array<{ id: number; name: string }>
|
|
43
|
+
>([]);
|
|
44
|
+
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
|
233
45
|
const [isSending, setIsSending] = useState(false);
|
|
46
|
+
const [isStreamingResponse, setIsStreamingResponse] = useState(false);
|
|
47
|
+
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
48
|
+
const [isMobileConversationsOpen, setIsMobileConversationsOpen] =
|
|
49
|
+
useState(false);
|
|
234
50
|
const [convToDelete, setConvToDelete] = useState<Conversation | null>(null);
|
|
235
51
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
236
52
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
53
|
+
const streamingTimeoutRef = useRef<number | null>(null);
|
|
54
|
+
const streamingRunRef = useRef(0);
|
|
55
|
+
const streamAbortRef = useRef<AbortController | null>(null);
|
|
237
56
|
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
57
|
+
const selectedConversationDraft = useFormDraft<number | null>({
|
|
58
|
+
storageKey: 'mcp-chat-selected-conversation-draft',
|
|
59
|
+
value: selectedConvId,
|
|
60
|
+
hasData: selectedConvId !== null,
|
|
61
|
+
debounceMs: 250,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
clearDraft: clearMessageInputDraft,
|
|
66
|
+
loadDraft: loadMessageInputDraft,
|
|
67
|
+
} = useFormDraft<string>({
|
|
68
|
+
storageKey: `mcp-chat-input-${selectedConvId ?? 'new'}-draft`,
|
|
69
|
+
value: input,
|
|
70
|
+
hasData: input.trim().length > 0,
|
|
71
|
+
debounceMs: 300,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
data: conversations,
|
|
76
|
+
refetch: refetchConvs,
|
|
77
|
+
isLoading: isLoadingConversations,
|
|
78
|
+
} = useQuery<Conversation[]>({
|
|
241
79
|
queryKey: ['mcp-chat-conversations', currentLocaleCode],
|
|
242
80
|
queryFn: async () => {
|
|
243
81
|
const res = await request<Conversation[]>({
|
|
@@ -248,33 +86,306 @@ export default function McpChatPage() {
|
|
|
248
86
|
},
|
|
249
87
|
});
|
|
250
88
|
|
|
251
|
-
const convList = conversations ?? [];
|
|
89
|
+
const convList = useMemo(() => conversations ?? [], [conversations]);
|
|
252
90
|
const selectedConv = convList.find((c) => c.id === selectedConvId) ?? null;
|
|
253
91
|
|
|
254
92
|
useEffect(() => {
|
|
255
|
-
if (
|
|
93
|
+
if (selectedConvId !== null || convList.length === 0) return;
|
|
94
|
+
const draft = selectedConversationDraft.loadDraft();
|
|
95
|
+
const draftConvId = draft?.payload;
|
|
96
|
+
if (typeof draftConvId !== 'number') return;
|
|
97
|
+
|
|
98
|
+
if (convList.some((conv) => conv.id === draftConvId)) {
|
|
99
|
+
setSelectedConvId(draftConvId);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
selectedConversationDraft.clearDraft();
|
|
104
|
+
}, [convList, selectedConvId, selectedConversationDraft]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const draft = loadMessageInputDraft();
|
|
108
|
+
if (typeof draft?.payload === 'string') {
|
|
109
|
+
setInput(draft.payload);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setInput('');
|
|
114
|
+
}, [selectedConvId, loadMessageInputDraft]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!selectedConvId) {
|
|
118
|
+
setMessages([]);
|
|
119
|
+
setIsLoadingMessages(false);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let isMounted = true;
|
|
124
|
+
setIsLoadingMessages(true);
|
|
125
|
+
|
|
256
126
|
request<Message[]>({
|
|
257
127
|
url: `/mcp-chat/${selectedConvId}/messages`,
|
|
258
128
|
method: 'GET',
|
|
259
129
|
})
|
|
260
|
-
.then((res) =>
|
|
261
|
-
|
|
262
|
-
|
|
130
|
+
.then((res) => {
|
|
131
|
+
if (isMounted) {
|
|
132
|
+
setMessages(res.data);
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.catch(() => {
|
|
136
|
+
if (isMounted) {
|
|
137
|
+
setMessages([]);
|
|
138
|
+
toast.error(t('loadMessagesError'));
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
if (isMounted) {
|
|
143
|
+
setIsLoadingMessages(false);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
isMounted = false;
|
|
149
|
+
};
|
|
150
|
+
}, [request, selectedConvId, t]);
|
|
263
151
|
|
|
264
152
|
useEffect(() => {
|
|
265
153
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
266
|
-
}, [messages, isSending]);
|
|
154
|
+
}, [messages, isSending, isLoadingMessages, isStreamingResponse]);
|
|
155
|
+
|
|
156
|
+
function stopStreaming() {
|
|
157
|
+
streamingRunRef.current += 1;
|
|
158
|
+
if (streamAbortRef.current) {
|
|
159
|
+
streamAbortRef.current.abort();
|
|
160
|
+
streamAbortRef.current = null;
|
|
161
|
+
}
|
|
162
|
+
if (streamingTimeoutRef.current !== null) {
|
|
163
|
+
window.clearTimeout(streamingTimeoutRef.current);
|
|
164
|
+
streamingTimeoutRef.current = null;
|
|
165
|
+
}
|
|
166
|
+
setIsStreamingResponse(false);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
return () => {
|
|
171
|
+
if (streamAbortRef.current) {
|
|
172
|
+
streamAbortRef.current.abort();
|
|
173
|
+
}
|
|
174
|
+
if (streamingTimeoutRef.current !== null) {
|
|
175
|
+
window.clearTimeout(streamingTimeoutRef.current);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
async function streamAssistantMessage(responseMessages: Message[]) {
|
|
181
|
+
const lastAssistantIdx = [...responseMessages]
|
|
182
|
+
.map((msg, idx) => ({ msg, idx }))
|
|
183
|
+
.reverse()
|
|
184
|
+
.find(({ msg }) => msg.role === 'assistant')?.idx;
|
|
185
|
+
|
|
186
|
+
if (lastAssistantIdx === undefined) {
|
|
187
|
+
setMessages(responseMessages);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const target = responseMessages[lastAssistantIdx] as Message;
|
|
192
|
+
const fullContent = target.content ?? '';
|
|
193
|
+
|
|
194
|
+
if (fullContent.length < 24) {
|
|
195
|
+
setMessages(responseMessages);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
stopStreaming();
|
|
200
|
+
const runId = ++streamingRunRef.current;
|
|
201
|
+
setIsStreamingResponse(true);
|
|
202
|
+
|
|
203
|
+
const baseMessages: Message[] = responseMessages.map(
|
|
204
|
+
(msg, idx): Message =>
|
|
205
|
+
idx === lastAssistantIdx ? { ...msg, content: '' } : msg
|
|
206
|
+
);
|
|
207
|
+
setMessages(baseMessages);
|
|
208
|
+
|
|
209
|
+
await new Promise<void>((resolve) => {
|
|
210
|
+
let cursor = 0;
|
|
211
|
+
const chunkSize = Math.max(
|
|
212
|
+
8,
|
|
213
|
+
Math.min(32, Math.ceil(fullContent.length / 80))
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const tick = () => {
|
|
217
|
+
if (streamingRunRef.current !== runId) {
|
|
218
|
+
resolve();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
cursor = Math.min(fullContent.length, cursor + chunkSize);
|
|
223
|
+
const nextMessages: Message[] = responseMessages.map(
|
|
224
|
+
(msg, idx): Message =>
|
|
225
|
+
idx === lastAssistantIdx
|
|
226
|
+
? { ...target, content: fullContent.slice(0, cursor) }
|
|
227
|
+
: msg
|
|
228
|
+
);
|
|
229
|
+
setMessages(nextMessages);
|
|
230
|
+
|
|
231
|
+
if (cursor >= fullContent.length) {
|
|
232
|
+
resolve();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
streamingTimeoutRef.current = window.setTimeout(tick, 16);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
tick();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (streamingRunRef.current === runId) {
|
|
243
|
+
setMessages(responseMessages);
|
|
244
|
+
setIsStreamingResponse(false);
|
|
245
|
+
streamingTimeoutRef.current = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function sendMessageViaStream(
|
|
250
|
+
conversationId: number,
|
|
251
|
+
message: string,
|
|
252
|
+
tempAssistantId: number
|
|
253
|
+
): Promise<Message[] | null> {
|
|
254
|
+
const rawBase = process.env.NEXT_PUBLIC_API_BASE_URL || '/api';
|
|
255
|
+
const apiBase = rawBase.replace(/\/$/, '');
|
|
256
|
+
const url = `${apiBase}/mcp-chat/${conversationId}/messages/stream`;
|
|
257
|
+
|
|
258
|
+
const controller = new AbortController();
|
|
259
|
+
streamAbortRef.current = controller;
|
|
260
|
+
setIsStreamingResponse(true);
|
|
261
|
+
|
|
262
|
+
const headers: Record<string, string> = {
|
|
263
|
+
'Content-Type': 'application/json',
|
|
264
|
+
Accept: 'text/event-stream',
|
|
265
|
+
'Accept-Language': currentLocaleCode,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (accessToken) {
|
|
269
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const response = await fetch(url, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers,
|
|
275
|
+
body: JSON.stringify({ message }),
|
|
276
|
+
signal: controller.signal,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!response.ok || !response.body) {
|
|
280
|
+
throw new Error(`stream-unavailable:${response.status}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let accumulated = '';
|
|
284
|
+
setMessages((prev) => [
|
|
285
|
+
...prev,
|
|
286
|
+
{
|
|
287
|
+
id: tempAssistantId,
|
|
288
|
+
conversation_id: conversationId,
|
|
289
|
+
role: 'assistant',
|
|
290
|
+
content: '',
|
|
291
|
+
tool_name: null,
|
|
292
|
+
tool_call_id: null,
|
|
293
|
+
created_at: new Date().toISOString(),
|
|
294
|
+
},
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
const reader = response.body.getReader();
|
|
298
|
+
const decoder = new TextDecoder();
|
|
299
|
+
let buffer = '';
|
|
300
|
+
let doneMessages: Message[] | null = null;
|
|
301
|
+
|
|
302
|
+
while (true) {
|
|
303
|
+
const { value, done } = await reader.read();
|
|
304
|
+
if (done) break;
|
|
305
|
+
|
|
306
|
+
buffer += decoder.decode(value, { stream: true });
|
|
307
|
+
const events = buffer.split('\n\n');
|
|
308
|
+
buffer = events.pop() ?? '';
|
|
309
|
+
|
|
310
|
+
for (const eventBlock of events) {
|
|
311
|
+
const dataLines = eventBlock
|
|
312
|
+
.split('\n')
|
|
313
|
+
.filter((line) => line.startsWith('data:'))
|
|
314
|
+
.map((line) => line.slice(5).trim());
|
|
315
|
+
|
|
316
|
+
if (dataLines.length === 0) continue;
|
|
317
|
+
const payloadText = dataLines.join('\n');
|
|
318
|
+
|
|
319
|
+
let payload: {
|
|
320
|
+
type?: 'chunk' | 'done' | 'error';
|
|
321
|
+
content?: string;
|
|
322
|
+
message?: string;
|
|
323
|
+
messages?: Message[];
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
payload = JSON.parse(payloadText);
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (payload.type === 'chunk') {
|
|
332
|
+
const chunk = payload.content ?? '';
|
|
333
|
+
if (!chunk) continue;
|
|
334
|
+
accumulated += chunk;
|
|
335
|
+
setMessages((prev) =>
|
|
336
|
+
prev.map((msg) =>
|
|
337
|
+
msg.id === tempAssistantId
|
|
338
|
+
? { ...msg, content: accumulated }
|
|
339
|
+
: msg
|
|
340
|
+
)
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (payload.type === 'done' && Array.isArray(payload.messages)) {
|
|
345
|
+
doneMessages = payload.messages;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (payload.type === 'error') {
|
|
349
|
+
throw new Error(payload.message || 'stream-error');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return doneMessages;
|
|
355
|
+
}
|
|
267
356
|
|
|
268
357
|
// Auto-resize textarea
|
|
269
358
|
useEffect(() => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
359
|
+
const textarea = textareaRef.current;
|
|
360
|
+
if (!textarea) {
|
|
361
|
+
return;
|
|
273
362
|
}
|
|
274
|
-
|
|
363
|
+
|
|
364
|
+
textarea.style.height = 'auto';
|
|
365
|
+
const maxHeight = isMobile ? 160 : 300;
|
|
366
|
+
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
367
|
+
|
|
368
|
+
const frameId = window.requestAnimationFrame(() => {
|
|
369
|
+
textarea.style.height = `${nextHeight}px`;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return () => {
|
|
373
|
+
window.cancelAnimationFrame(frameId);
|
|
374
|
+
};
|
|
375
|
+
}, [input, isMobile]);
|
|
376
|
+
|
|
377
|
+
function handleSelectConversation(convId: number) {
|
|
378
|
+
stopStreaming();
|
|
379
|
+
setSelectedConvId(convId);
|
|
380
|
+
setMessages([]);
|
|
381
|
+
if (isMobile) {
|
|
382
|
+
setIsMobileConversationsOpen(false);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
275
385
|
|
|
276
386
|
async function handleNewConversation() {
|
|
277
387
|
try {
|
|
388
|
+
stopStreaming();
|
|
278
389
|
const res = await request<Conversation>({
|
|
279
390
|
url: '/mcp-chat',
|
|
280
391
|
method: 'POST',
|
|
@@ -283,15 +394,24 @@ export default function McpChatPage() {
|
|
|
283
394
|
await refetchConvs();
|
|
284
395
|
setSelectedConvId(res.data.id);
|
|
285
396
|
setMessages([]);
|
|
397
|
+
setInput('');
|
|
398
|
+
if (isMobile) {
|
|
399
|
+
setIsMobileConversationsOpen(false);
|
|
400
|
+
}
|
|
286
401
|
return res.data.id;
|
|
287
402
|
} catch {
|
|
288
|
-
toast.error('
|
|
403
|
+
toast.error(t('createConversationError'));
|
|
289
404
|
return null;
|
|
290
405
|
}
|
|
291
406
|
}
|
|
292
407
|
|
|
293
408
|
async function handleSend() {
|
|
294
|
-
if (
|
|
409
|
+
if (
|
|
410
|
+
(!input.trim() && attachments.length === 0) ||
|
|
411
|
+
isSending ||
|
|
412
|
+
isUploadingFile
|
|
413
|
+
)
|
|
414
|
+
return;
|
|
295
415
|
let activeConvId = selectedConvId;
|
|
296
416
|
if (!activeConvId) {
|
|
297
417
|
activeConvId = await handleNewConversation();
|
|
@@ -299,15 +419,27 @@ export default function McpChatPage() {
|
|
|
299
419
|
}
|
|
300
420
|
setIsSending(true);
|
|
301
421
|
const currentInput = input;
|
|
422
|
+
const currentAttachments = attachments;
|
|
423
|
+
const tempId = -Date.now();
|
|
424
|
+
const attachmentsBlock =
|
|
425
|
+
currentAttachments.length > 0
|
|
426
|
+
? `\n\n${t('attachedFilesPrefix')}\n${currentAttachments
|
|
427
|
+
.map((file) => `- ${file.name} (file_id: ${file.id})`)
|
|
428
|
+
.join('\n')}`
|
|
429
|
+
: '';
|
|
430
|
+
const payloadMessage = `${currentInput.trim()}${attachmentsBlock}`.trim();
|
|
431
|
+
|
|
302
432
|
setInput('');
|
|
433
|
+
setAttachments([]);
|
|
434
|
+
clearMessageInputDraft();
|
|
303
435
|
|
|
304
436
|
setMessages((prev) => [
|
|
305
437
|
...prev,
|
|
306
438
|
{
|
|
307
|
-
id:
|
|
439
|
+
id: tempId,
|
|
308
440
|
conversation_id: activeConvId!,
|
|
309
441
|
role: 'user',
|
|
310
|
-
content:
|
|
442
|
+
content: payloadMessage,
|
|
311
443
|
tool_name: null,
|
|
312
444
|
tool_call_id: null,
|
|
313
445
|
created_at: new Date().toISOString(),
|
|
@@ -315,25 +447,101 @@ export default function McpChatPage() {
|
|
|
315
447
|
]);
|
|
316
448
|
|
|
317
449
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
450
|
+
stopStreaming();
|
|
451
|
+
const tempAssistantId = -(Date.now() + 1);
|
|
452
|
+
let finalMessages: Message[] | null = null;
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
finalMessages = await sendMessageViaStream(
|
|
456
|
+
activeConvId,
|
|
457
|
+
payloadMessage,
|
|
458
|
+
tempAssistantId
|
|
459
|
+
);
|
|
460
|
+
} catch {
|
|
461
|
+
setMessages((prev) => prev.filter((m) => m.id !== tempAssistantId));
|
|
462
|
+
|
|
463
|
+
const fallbackRes = await request<Message[]>({
|
|
464
|
+
url: `/mcp-chat/${activeConvId}/messages`,
|
|
465
|
+
method: 'POST',
|
|
466
|
+
data: { message: payloadMessage },
|
|
467
|
+
});
|
|
468
|
+
finalMessages = fallbackRes.data;
|
|
469
|
+
await streamAssistantMessage(finalMessages);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (finalMessages) {
|
|
473
|
+
setMessages(finalMessages);
|
|
474
|
+
}
|
|
475
|
+
|
|
324
476
|
await refetchConvs();
|
|
325
|
-
const hadToolUse =
|
|
477
|
+
const hadToolUse = (finalMessages ?? []).some(
|
|
326
478
|
(m) => m.role === 'tool_call' || m.role === 'tool_result'
|
|
327
479
|
);
|
|
328
480
|
if (hadToolUse) await refetchUser();
|
|
329
481
|
} catch {
|
|
330
|
-
toast.error('
|
|
331
|
-
setMessages((prev) => prev.filter((m) => m.id !==
|
|
482
|
+
toast.error(t('sendMessageError'));
|
|
483
|
+
setMessages((prev) => prev.filter((m) => m.id !== tempId));
|
|
484
|
+
setInput(currentInput);
|
|
485
|
+
setAttachments(currentAttachments);
|
|
332
486
|
} finally {
|
|
487
|
+
streamAbortRef.current = null;
|
|
488
|
+
setIsStreamingResponse(false);
|
|
333
489
|
setIsSending(false);
|
|
334
490
|
}
|
|
335
491
|
}
|
|
336
492
|
|
|
493
|
+
const uploadAttachment = async (file: File) => {
|
|
494
|
+
const existingNames = new Set(attachments.map((item) => item.name));
|
|
495
|
+
if (existingNames.has(file.name)) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
setIsUploadingFile(true);
|
|
500
|
+
try {
|
|
501
|
+
const formData = new FormData();
|
|
502
|
+
formData.append('file', file);
|
|
503
|
+
formData.append('destination', 'mcp-chat/attachments');
|
|
504
|
+
|
|
505
|
+
const { data } = await request<{ id: number; filename?: string }>({
|
|
506
|
+
url: '/file',
|
|
507
|
+
method: 'POST',
|
|
508
|
+
data: formData,
|
|
509
|
+
headers: {
|
|
510
|
+
'Content-Type': 'multipart/form-data',
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
if (!data?.id) {
|
|
515
|
+
throw new Error('invalid-file-id');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
setAttachments((prev) => [
|
|
519
|
+
...prev,
|
|
520
|
+
{
|
|
521
|
+
id: data.id,
|
|
522
|
+
name: data.filename || file.name,
|
|
523
|
+
},
|
|
524
|
+
]);
|
|
525
|
+
} catch {
|
|
526
|
+
toast.error(t('uploadFileError'));
|
|
527
|
+
} finally {
|
|
528
|
+
setIsUploadingFile(false);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const handleUploadFiles = (files: FileList) => {
|
|
533
|
+
const queue = Array.from(files);
|
|
534
|
+
void (async () => {
|
|
535
|
+
for (const file of queue) {
|
|
536
|
+
await uploadAttachment(file);
|
|
537
|
+
}
|
|
538
|
+
})();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const handleRemoveAttachment = (id: number) => {
|
|
542
|
+
setAttachments((prev) => prev.filter((item) => item.id !== id));
|
|
543
|
+
};
|
|
544
|
+
|
|
337
545
|
async function handleDeleteConversation() {
|
|
338
546
|
if (!convToDelete) return;
|
|
339
547
|
try {
|
|
@@ -342,121 +550,83 @@ export default function McpChatPage() {
|
|
|
342
550
|
method: 'DELETE',
|
|
343
551
|
data: { ids: [convToDelete.id] },
|
|
344
552
|
});
|
|
345
|
-
toast.success('
|
|
553
|
+
toast.success(t('deleteConversationSuccess'));
|
|
346
554
|
if (selectedConvId === convToDelete.id) {
|
|
347
555
|
setSelectedConvId(null);
|
|
348
556
|
setMessages([]);
|
|
557
|
+
setInput('');
|
|
349
558
|
}
|
|
350
559
|
setConvToDelete(null);
|
|
351
560
|
await refetchConvs();
|
|
352
561
|
} catch {
|
|
353
|
-
toast.error('
|
|
562
|
+
toast.error(t('deleteConversationError'));
|
|
354
563
|
}
|
|
355
564
|
}
|
|
356
565
|
|
|
357
|
-
const
|
|
358
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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>
|
|
566
|
+
const sidebarContent = (
|
|
567
|
+
<ConversationSidebar
|
|
568
|
+
conversations={convList}
|
|
569
|
+
selectedConvId={selectedConvId}
|
|
570
|
+
isLoading={isLoadingConversations}
|
|
571
|
+
emptyLabel={t('emptyConversations')}
|
|
572
|
+
conversationsLabel={t('conversations')}
|
|
573
|
+
newConversationLabel={t('newConversation')}
|
|
574
|
+
onCreate={() => {
|
|
575
|
+
void handleNewConversation();
|
|
576
|
+
}}
|
|
577
|
+
onSelect={handleSelectConversation}
|
|
578
|
+
onDelete={setConvToDelete}
|
|
579
|
+
/>
|
|
383
580
|
);
|
|
384
581
|
|
|
385
582
|
return (
|
|
386
|
-
<div className="flex h-
|
|
583
|
+
<div className="flex h-dvh min-h-0 overflow-hidden bg-background">
|
|
387
584
|
{/* Sidebar */}
|
|
388
|
-
<aside className="w-64 shrink-0 flex
|
|
389
|
-
|
|
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>
|
|
585
|
+
<aside className="hidden md:flex w-64 shrink-0 flex-col border-r bg-muted/30">
|
|
586
|
+
{sidebarContent}
|
|
453
587
|
</aside>
|
|
454
588
|
|
|
589
|
+
<Sheet
|
|
590
|
+
open={isMobileConversationsOpen}
|
|
591
|
+
onOpenChange={setIsMobileConversationsOpen}
|
|
592
|
+
>
|
|
593
|
+
<SheetContent
|
|
594
|
+
side="left"
|
|
595
|
+
className="w-[85vw] max-w-[320px] p-0 [&>button]:top-2 [&>button]:right-2 [&>button]:z-20"
|
|
596
|
+
>
|
|
597
|
+
<SheetHeader className="sr-only">
|
|
598
|
+
<SheetTitle>{t('conversations')}</SheetTitle>
|
|
599
|
+
<SheetDescription>{t('openConversations')}</SheetDescription>
|
|
600
|
+
</SheetHeader>
|
|
601
|
+
<div className="flex h-full flex-col bg-muted/30 pt-11">
|
|
602
|
+
{sidebarContent}
|
|
603
|
+
</div>
|
|
604
|
+
</SheetContent>
|
|
605
|
+
</Sheet>
|
|
606
|
+
|
|
455
607
|
{/* Main area */}
|
|
456
|
-
<main className="flex-1 flex
|
|
608
|
+
<main className="flex-1 flex min-h-0 min-w-0 flex-col overflow-hidden">
|
|
609
|
+
{isMobile && (
|
|
610
|
+
<header className="h-12 shrink-0 border-b flex items-center px-3 gap-2 bg-background/90 backdrop-blur-sm md:hidden">
|
|
611
|
+
<SidebarTrigger className="h-8 w-8 cursor-pointer" />
|
|
612
|
+
<Button
|
|
613
|
+
variant="ghost"
|
|
614
|
+
size="icon"
|
|
615
|
+
className="h-8 w-8 cursor-pointer"
|
|
616
|
+
onClick={() => setIsMobileConversationsOpen(true)}
|
|
617
|
+
aria-label={t('openConversations')}
|
|
618
|
+
>
|
|
619
|
+
<MessageSquare className="h-4 w-4" />
|
|
620
|
+
</Button>
|
|
621
|
+
<span className="text-sm font-medium truncate">
|
|
622
|
+
{selectedConv?.title || t('title')}
|
|
623
|
+
</span>
|
|
624
|
+
</header>
|
|
625
|
+
)}
|
|
626
|
+
|
|
457
627
|
{!selectedConvId ? (
|
|
458
628
|
/* Empty state */
|
|
459
|
-
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-6">
|
|
629
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-6 px-4 md:gap-8 md:px-6">
|
|
460
630
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
461
631
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-primary/20">
|
|
462
632
|
<Bot className="h-8 w-8 text-primary" />
|
|
@@ -468,13 +638,34 @@ export default function McpChatPage() {
|
|
|
468
638
|
{t('description')}
|
|
469
639
|
</p>
|
|
470
640
|
</div>
|
|
471
|
-
<div className="w-full max-w-2xl">
|
|
641
|
+
<div className="w-full max-w-2xl px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:px-0 md:pb-0">
|
|
642
|
+
<ChatInput
|
|
643
|
+
textareaRef={textareaRef}
|
|
644
|
+
submitOnEnter={!isMobile}
|
|
645
|
+
input={input}
|
|
646
|
+
isSending={isSending}
|
|
647
|
+
isUploadingFile={isUploadingFile}
|
|
648
|
+
attachments={attachments}
|
|
649
|
+
placeholder={t('placeholder')}
|
|
650
|
+
sendLabel={t('send')}
|
|
651
|
+
sendingLabel={t('sending')}
|
|
652
|
+
attachLabel={t('attachFile')}
|
|
653
|
+
uploadingFileLabel={t('uploadingFile')}
|
|
654
|
+
removeAttachmentLabel={t('removeAttachment')}
|
|
655
|
+
onInputChange={setInput}
|
|
656
|
+
onSend={() => {
|
|
657
|
+
void handleSend();
|
|
658
|
+
}}
|
|
659
|
+
onUploadFiles={handleUploadFiles}
|
|
660
|
+
onRemoveAttachment={handleRemoveAttachment}
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
472
663
|
</div>
|
|
473
664
|
) : (
|
|
474
665
|
/* Chat state */
|
|
475
666
|
<>
|
|
476
667
|
{/* Header */}
|
|
477
|
-
<header className="h-14 shrink-0 border-b
|
|
668
|
+
<header className="h-14 shrink-0 border-b items-center px-4 md:px-6 gap-3 bg-background/80 backdrop-blur-sm hidden md:flex">
|
|
478
669
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
|
479
670
|
<Bot className="h-4 w-4 text-primary" />
|
|
480
671
|
</div>
|
|
@@ -489,60 +680,40 @@ export default function McpChatPage() {
|
|
|
489
680
|
</header>
|
|
490
681
|
|
|
491
682
|
{/* Messages */}
|
|
492
|
-
<ScrollArea className="flex-1
|
|
493
|
-
<
|
|
494
|
-
{messages
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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>
|
|
683
|
+
<ScrollArea className="min-h-0 flex-1 px-3 py-4 md:px-4 md:py-6">
|
|
684
|
+
<MessageFeed
|
|
685
|
+
messages={messages}
|
|
686
|
+
isLoadingMessages={isLoadingMessages}
|
|
687
|
+
isSending={isSending}
|
|
688
|
+
isStreamingResponse={isStreamingResponse}
|
|
689
|
+
messagesEndRef={messagesEndRef}
|
|
690
|
+
/>
|
|
541
691
|
</ScrollArea>
|
|
542
692
|
|
|
543
693
|
{/* 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">
|
|
694
|
+
<div className="shrink-0 border-t bg-background/80 px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-sm md:px-6 md:py-4">
|
|
695
|
+
<div className="max-w-3xl mx-auto">
|
|
696
|
+
<ChatInput
|
|
697
|
+
textareaRef={textareaRef}
|
|
698
|
+
submitOnEnter={!isMobile}
|
|
699
|
+
input={input}
|
|
700
|
+
isSending={isSending}
|
|
701
|
+
isUploadingFile={isUploadingFile}
|
|
702
|
+
attachments={attachments}
|
|
703
|
+
placeholder={t('placeholder')}
|
|
704
|
+
sendLabel={t('send')}
|
|
705
|
+
sendingLabel={t('sending')}
|
|
706
|
+
attachLabel={t('attachFile')}
|
|
707
|
+
uploadingFileLabel={t('uploadingFile')}
|
|
708
|
+
removeAttachmentLabel={t('removeAttachment')}
|
|
709
|
+
onInputChange={setInput}
|
|
710
|
+
onSend={() => {
|
|
711
|
+
void handleSend();
|
|
712
|
+
}}
|
|
713
|
+
onUploadFiles={handleUploadFiles}
|
|
714
|
+
onRemoveAttachment={handleRemoveAttachment}
|
|
715
|
+
/>
|
|
716
|
+
</div>
|
|
546
717
|
</div>
|
|
547
718
|
</>
|
|
548
719
|
)}
|