@hed-hog/core 0.0.347 → 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.
Files changed (126) hide show
  1. package/dist/auth/auth.controller.d.ts +1 -1
  2. package/dist/auth/auth.service.d.ts +1 -1
  3. package/dist/core.module.d.ts.map +1 -1
  4. package/dist/core.module.js +3 -0
  5. package/dist/core.module.js.map +1 -1
  6. package/dist/dashboard/dashboard-user/dashboard-user.controller.d.ts +3 -3
  7. package/dist/dashboard/dashboard-user/dashboard-user.service.d.ts +3 -3
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp/guards/mcp-auth.guard.d.ts +6 -1
  13. package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -1
  14. package/dist/mcp/guards/mcp-auth.guard.js +29 -2
  15. package/dist/mcp/guards/mcp-auth.guard.js.map +1 -1
  16. package/dist/mcp/mcp-api-key.controller.d.ts +32 -0
  17. package/dist/mcp/mcp-api-key.controller.d.ts.map +1 -0
  18. package/dist/mcp/mcp-api-key.controller.js +64 -0
  19. package/dist/mcp/mcp-api-key.controller.js.map +1 -0
  20. package/dist/mcp/mcp-api-key.service.d.ts +27 -0
  21. package/dist/mcp/mcp-api-key.service.d.ts.map +1 -0
  22. package/dist/mcp/mcp-api-key.service.js +74 -0
  23. package/dist/mcp/mcp-api-key.service.js.map +1 -0
  24. package/dist/mcp/mcp-registry.service.d.ts +1 -2
  25. package/dist/mcp/mcp-registry.service.d.ts.map +1 -1
  26. package/dist/mcp/mcp-registry.service.js +6 -2
  27. package/dist/mcp/mcp-registry.service.js.map +1 -1
  28. package/dist/mcp/mcp.module.d.ts.map +1 -1
  29. package/dist/mcp/mcp.module.js +8 -1
  30. package/dist/mcp/mcp.module.js.map +1 -1
  31. package/dist/mcp-chat/mcp-chat.controller.d.ts +4 -0
  32. package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -1
  33. package/dist/mcp-chat/mcp-chat.controller.js +46 -0
  34. package/dist/mcp-chat/mcp-chat.controller.js.map +1 -1
  35. package/dist/mcp-chat/mcp-chat.service.d.ts +6 -1
  36. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  37. package/dist/mcp-chat/mcp-chat.service.js +125 -17
  38. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  39. package/dist/notification/dto/create-notification.dto.d.ts +26 -0
  40. package/dist/notification/dto/create-notification.dto.d.ts.map +1 -0
  41. package/dist/notification/dto/create-notification.dto.js +86 -0
  42. package/dist/notification/dto/create-notification.dto.js.map +1 -0
  43. package/dist/notification/dto/update-progress.dto.d.ts +6 -0
  44. package/dist/notification/dto/update-progress.dto.d.ts.map +1 -0
  45. package/dist/notification/dto/update-progress.dto.js +33 -0
  46. package/dist/notification/dto/update-progress.dto.js.map +1 -0
  47. package/dist/notification/notification.controller.d.ts +108 -0
  48. package/dist/notification/notification.controller.d.ts.map +1 -0
  49. package/dist/notification/notification.controller.js +118 -0
  50. package/dist/notification/notification.controller.js.map +1 -0
  51. package/dist/notification/notification.module.d.ts +3 -0
  52. package/dist/notification/notification.module.d.ts.map +1 -0
  53. package/dist/notification/notification.module.js +29 -0
  54. package/dist/notification/notification.module.js.map +1 -0
  55. package/dist/notification/notification.service.d.ts +98 -0
  56. package/dist/notification/notification.service.d.ts.map +1 -0
  57. package/dist/notification/notification.service.js +123 -0
  58. package/dist/notification/notification.service.js.map +1 -0
  59. package/dist/profile/profile.controller.d.ts +1 -1
  60. package/dist/profile/profile.service.d.ts +1 -1
  61. package/dist/setting/dto/update-setting-list.dto.d.ts +9 -0
  62. package/dist/setting/dto/update-setting-list.dto.d.ts.map +1 -0
  63. package/dist/setting/dto/update-setting-list.dto.js +44 -0
  64. package/dist/setting/dto/update-setting-list.dto.js.map +1 -0
  65. package/dist/setting/setting.controller.d.ts +19 -0
  66. package/dist/setting/setting.controller.d.ts.map +1 -1
  67. package/dist/setting/setting.controller.js +12 -0
  68. package/dist/setting/setting.controller.js.map +1 -1
  69. package/dist/setting/setting.service.d.ts +19 -0
  70. package/dist/setting/setting.service.d.ts.map +1 -1
  71. package/dist/setting/setting.service.js +79 -0
  72. package/dist/setting/setting.service.js.map +1 -1
  73. package/dist/task/task.service.d.ts +12 -0
  74. package/dist/task/task.service.d.ts.map +1 -1
  75. package/dist/task/task.service.js +60 -0
  76. package/dist/task/task.service.js.map +1 -1
  77. package/dist/user/user.controller.d.ts +2 -2
  78. package/dist/user/user.service.d.ts +3 -3
  79. package/dist/webhook-integration/webhook-integration.service.d.ts +4 -0
  80. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  81. package/dist/webhook-integration/webhook-integration.service.js +184 -34
  82. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  83. package/dist/webhook-integration/webhook-utils.d.ts +1 -0
  84. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -1
  85. package/dist/webhook-integration/webhook-utils.js +38 -0
  86. package/dist/webhook-integration/webhook-utils.js.map +1 -1
  87. package/hedhog/data/menu.yaml +48 -4
  88. package/hedhog/data/route.yaml +133 -0
  89. package/hedhog/data/setting_group.yaml +45 -0
  90. package/hedhog/data/setting_subgroup.yaml +16 -0
  91. package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +285 -0
  92. package/hedhog/frontend/app/account/layout.tsx.ejs +32 -55
  93. package/hedhog/frontend/app/account/mcp-tokens/page.tsx.ejs +5 -0
  94. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +2098 -1835
  95. package/hedhog/frontend/app/mcp_chat/chat-components.tsx.ejs +595 -0
  96. package/hedhog/frontend/app/mcp_chat/chat-types.ts.ejs +16 -0
  97. package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +554 -383
  98. package/hedhog/frontend/app/setting-list/page.tsx.ejs +649 -0
  99. package/hedhog/frontend/app/users/page.tsx.ejs +445 -203
  100. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +16 -37
  101. package/hedhog/frontend/messages/en.json +241 -2
  102. package/hedhog/frontend/messages/pt.json +241 -2
  103. package/hedhog/table/mcp_api_key.yaml +30 -0
  104. package/hedhog/table/notification.yaml +72 -0
  105. package/hedhog/table/webhook_action_log.yaml +49 -0
  106. package/package.json +2 -2
  107. package/src/core.module.ts +3 -0
  108. package/src/index.ts +5 -0
  109. package/src/mcp/guards/mcp-auth.guard.ts +64 -32
  110. package/src/mcp/mcp-api-key.controller.ts +42 -0
  111. package/src/mcp/mcp-api-key.service.ts +66 -0
  112. package/src/mcp/mcp-registry.service.ts +75 -69
  113. package/src/mcp/mcp.module.ts +43 -36
  114. package/src/mcp-chat/mcp-chat.controller.ts +113 -60
  115. package/src/mcp-chat/mcp-chat.service.ts +494 -332
  116. package/src/notification/dto/create-notification.dto.ts +71 -0
  117. package/src/notification/dto/update-progress.dto.ts +16 -0
  118. package/src/notification/notification.controller.ts +71 -0
  119. package/src/notification/notification.module.ts +16 -0
  120. package/src/notification/notification.service.ts +124 -0
  121. package/src/setting/dto/update-setting-list.dto.ts +32 -0
  122. package/src/setting/setting.controller.ts +9 -0
  123. package/src/setting/setting.service.ts +91 -0
  124. package/src/task/task.service.ts +72 -0
  125. package/src/webhook-integration/webhook-integration.service.ts +934 -735
  126. 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
- Bot,
19
- ChevronDown,
20
- ChevronRight,
21
- MessageSquare,
22
- Plus,
23
- Send,
24
- Trash2,
25
- Wrench,
26
- } from 'lucide-react';
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
- 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
- }
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 { data: conversations, refetch: refetchConvs } = useQuery<
239
- Conversation[]
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 (!selectedConvId) return;
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) => setMessages(res.data))
261
- .catch(() => setMessages([]));
262
- }, [selectedConvId]);
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
- if (textareaRef.current) {
271
- textareaRef.current.style.height = 'auto';
272
- textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 300)}px`;
359
+ const textarea = textareaRef.current;
360
+ if (!textarea) {
361
+ return;
273
362
  }
274
- }, [input]);
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('Failed to create conversation');
403
+ toast.error(t('createConversationError'));
289
404
  return null;
290
405
  }
291
406
  }
292
407
 
293
408
  async function handleSend() {
294
- if (!input.trim() || isSending) return;
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: -Date.now(),
439
+ id: tempId,
308
440
  conversation_id: activeConvId!,
309
441
  role: 'user',
310
- content: currentInput,
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
- const res = await request<Message[]>({
319
- url: `/mcp-chat/${activeConvId}/messages`,
320
- method: 'POST',
321
- data: { message: currentInput },
322
- });
323
- setMessages(res.data);
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 = res.data.some(
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('Failed to send message');
331
- setMessages((prev) => prev.filter((m) => m.id !== -Date.now()));
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('Conversation deleted');
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('Failed to delete conversation');
562
+ toast.error(t('deleteConversationError'));
354
563
  }
355
564
  }
356
565
 
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>
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-screen overflow-hidden bg-background">
583
+ <div className="flex h-dvh min-h-0 overflow-hidden bg-background">
387
584
  {/* 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>
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 flex-col overflow-hidden min-w-0">
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">{inputArea}</div>
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 flex items-center px-6 gap-3 bg-background/80 backdrop-blur-sm">
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 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>
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">{inputArea}</div>
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
  )}