@wener/mcps 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.mjs +15 -0
  3. package/dist/mcps-cli.mjs +174727 -0
  4. package/lib/chat/agent.js +187 -0
  5. package/lib/chat/agent.js.map +1 -0
  6. package/lib/chat/audit.js +238 -0
  7. package/lib/chat/audit.js.map +1 -0
  8. package/lib/chat/converters.js +467 -0
  9. package/lib/chat/converters.js.map +1 -0
  10. package/lib/chat/handler.js +1068 -0
  11. package/lib/chat/handler.js.map +1 -0
  12. package/lib/chat/index.js +12 -0
  13. package/lib/chat/index.js.map +1 -0
  14. package/lib/chat/types.js +35 -0
  15. package/lib/chat/types.js.map +1 -0
  16. package/lib/contracts/AuditContract.js +85 -0
  17. package/lib/contracts/AuditContract.js.map +1 -0
  18. package/lib/contracts/McpsContract.js +113 -0
  19. package/lib/contracts/McpsContract.js.map +1 -0
  20. package/lib/contracts/index.js +3 -0
  21. package/lib/contracts/index.js.map +1 -0
  22. package/lib/dev.server.js +7 -0
  23. package/lib/dev.server.js.map +1 -0
  24. package/lib/entities/ChatRequestEntity.js +318 -0
  25. package/lib/entities/ChatRequestEntity.js.map +1 -0
  26. package/lib/entities/McpRequestEntity.js +271 -0
  27. package/lib/entities/McpRequestEntity.js.map +1 -0
  28. package/lib/entities/RequestLogEntity.js +177 -0
  29. package/lib/entities/RequestLogEntity.js.map +1 -0
  30. package/lib/entities/ResponseEntity.js +150 -0
  31. package/lib/entities/ResponseEntity.js.map +1 -0
  32. package/lib/entities/index.js +11 -0
  33. package/lib/entities/index.js.map +1 -0
  34. package/lib/entities/types.js +11 -0
  35. package/lib/entities/types.js.map +1 -0
  36. package/lib/index.js +3 -0
  37. package/lib/index.js.map +1 -0
  38. package/lib/mcps-cli.js +44 -0
  39. package/lib/mcps-cli.js.map +1 -0
  40. package/lib/providers/McpServerHandlerDef.js +40 -0
  41. package/lib/providers/McpServerHandlerDef.js.map +1 -0
  42. package/lib/providers/findMcpServerDef.js +26 -0
  43. package/lib/providers/findMcpServerDef.js.map +1 -0
  44. package/lib/providers/prometheus/def.js +24 -0
  45. package/lib/providers/prometheus/def.js.map +1 -0
  46. package/lib/providers/prometheus/index.js +2 -0
  47. package/lib/providers/prometheus/index.js.map +1 -0
  48. package/lib/providers/relay/def.js +32 -0
  49. package/lib/providers/relay/def.js.map +1 -0
  50. package/lib/providers/relay/index.js +2 -0
  51. package/lib/providers/relay/index.js.map +1 -0
  52. package/lib/providers/sql/def.js +31 -0
  53. package/lib/providers/sql/def.js.map +1 -0
  54. package/lib/providers/sql/index.js +2 -0
  55. package/lib/providers/sql/index.js.map +1 -0
  56. package/lib/providers/tencent-cls/def.js +44 -0
  57. package/lib/providers/tencent-cls/def.js.map +1 -0
  58. package/lib/providers/tencent-cls/index.js +2 -0
  59. package/lib/providers/tencent-cls/index.js.map +1 -0
  60. package/lib/scripts/bundle.js +90 -0
  61. package/lib/scripts/bundle.js.map +1 -0
  62. package/lib/server/api-routes.js +96 -0
  63. package/lib/server/api-routes.js.map +1 -0
  64. package/lib/server/audit.js +274 -0
  65. package/lib/server/audit.js.map +1 -0
  66. package/lib/server/chat-routes.js +82 -0
  67. package/lib/server/chat-routes.js.map +1 -0
  68. package/lib/server/config.js +223 -0
  69. package/lib/server/config.js.map +1 -0
  70. package/lib/server/db.js +97 -0
  71. package/lib/server/db.js.map +1 -0
  72. package/lib/server/index.js +2 -0
  73. package/lib/server/index.js.map +1 -0
  74. package/lib/server/mcp-handler.js +167 -0
  75. package/lib/server/mcp-handler.js.map +1 -0
  76. package/lib/server/mcp-routes.js +112 -0
  77. package/lib/server/mcp-routes.js.map +1 -0
  78. package/lib/server/mcps-router.js +119 -0
  79. package/lib/server/mcps-router.js.map +1 -0
  80. package/lib/server/schema.js +129 -0
  81. package/lib/server/schema.js.map +1 -0
  82. package/lib/server/server.js +166 -0
  83. package/lib/server/server.js.map +1 -0
  84. package/lib/web/ChatPage.js +827 -0
  85. package/lib/web/ChatPage.js.map +1 -0
  86. package/lib/web/McpInspectorPage.js +214 -0
  87. package/lib/web/McpInspectorPage.js.map +1 -0
  88. package/lib/web/ServersPage.js +93 -0
  89. package/lib/web/ServersPage.js.map +1 -0
  90. package/lib/web/main.js +541 -0
  91. package/lib/web/main.js.map +1 -0
  92. package/package.json +83 -0
  93. package/src/chat/agent.ts +240 -0
  94. package/src/chat/audit.ts +377 -0
  95. package/src/chat/converters.test.ts +325 -0
  96. package/src/chat/converters.ts +459 -0
  97. package/src/chat/handler.test.ts +137 -0
  98. package/src/chat/handler.ts +1233 -0
  99. package/src/chat/index.ts +16 -0
  100. package/src/chat/types.ts +72 -0
  101. package/src/contracts/AuditContract.ts +93 -0
  102. package/src/contracts/McpsContract.ts +141 -0
  103. package/src/contracts/index.ts +18 -0
  104. package/src/dev.server.ts +7 -0
  105. package/src/entities/ChatRequestEntity.ts +157 -0
  106. package/src/entities/McpRequestEntity.ts +149 -0
  107. package/src/entities/RequestLogEntity.ts +78 -0
  108. package/src/entities/ResponseEntity.ts +75 -0
  109. package/src/entities/index.ts +12 -0
  110. package/src/entities/types.ts +188 -0
  111. package/src/index.ts +1 -0
  112. package/src/mcps-cli.ts +59 -0
  113. package/src/providers/McpServerHandlerDef.ts +105 -0
  114. package/src/providers/findMcpServerDef.ts +31 -0
  115. package/src/providers/prometheus/def.ts +21 -0
  116. package/src/providers/prometheus/index.ts +1 -0
  117. package/src/providers/relay/def.ts +31 -0
  118. package/src/providers/relay/index.ts +1 -0
  119. package/src/providers/relay/relay.test.ts +47 -0
  120. package/src/providers/sql/def.ts +33 -0
  121. package/src/providers/sql/index.ts +1 -0
  122. package/src/providers/tencent-cls/def.ts +38 -0
  123. package/src/providers/tencent-cls/index.ts +1 -0
  124. package/src/scripts/bundle.ts +82 -0
  125. package/src/server/api-routes.ts +98 -0
  126. package/src/server/audit.ts +310 -0
  127. package/src/server/chat-routes.ts +95 -0
  128. package/src/server/config.test.ts +162 -0
  129. package/src/server/config.ts +198 -0
  130. package/src/server/db.ts +115 -0
  131. package/src/server/index.ts +1 -0
  132. package/src/server/mcp-handler.ts +209 -0
  133. package/src/server/mcp-routes.ts +133 -0
  134. package/src/server/mcps-router.ts +133 -0
  135. package/src/server/schema.ts +175 -0
  136. package/src/server/server.ts +163 -0
  137. package/src/web/ChatPage.tsx +1005 -0
  138. package/src/web/McpInspectorPage.tsx +254 -0
  139. package/src/web/ServersPage.tsx +139 -0
  140. package/src/web/main.tsx +600 -0
  141. package/src/web/styles.css +15 -0
@@ -0,0 +1,1005 @@
1
+ 'use client';
2
+
3
+ import { Combobox } from '@base-ui/react/combobox';
4
+ import { cjk } from '@streamdown/cjk';
5
+ import { code } from '@streamdown/code';
6
+ import { math } from '@streamdown/math';
7
+ import {
8
+ Brain,
9
+ Check,
10
+ ChevronDown,
11
+ ChevronUp,
12
+ Clock,
13
+ Copy,
14
+ Edit2,
15
+ ImagePlus,
16
+ Menu,
17
+ MessageSquarePlus,
18
+ RefreshCw,
19
+ Send,
20
+ Settings,
21
+ Square,
22
+ Trash2,
23
+ Wrench,
24
+ X,
25
+ XCircle,
26
+ Zap,
27
+ } from 'lucide-react';
28
+ import { useCallback, useEffect, useRef, useState, memo, type KeyboardEvent } from 'react';
29
+ import { Streamdown } from 'streamdown';
30
+
31
+ // Types
32
+ interface ToolCall {
33
+ id: string;
34
+ name: string;
35
+ arguments: Record<string, unknown>;
36
+ result?: unknown;
37
+ error?: string;
38
+ status: 'pending' | 'running' | 'completed' | 'error';
39
+ }
40
+
41
+ interface ImageContent {
42
+ type: 'image';
43
+ url: string;
44
+ base64?: string;
45
+ }
46
+
47
+ interface Message {
48
+ id: string;
49
+ role: 'user' | 'assistant' | 'system';
50
+ content: string;
51
+ images?: ImageContent[];
52
+ reasoning?: string;
53
+ toolCalls?: ToolCall[];
54
+ createdAt?: Date;
55
+ error?: string;
56
+ usage?: {
57
+ promptTokens?: number;
58
+ completionTokens?: number;
59
+ totalTokens?: number;
60
+ };
61
+ durationMs?: number;
62
+ }
63
+
64
+ interface ChatSession {
65
+ id: string;
66
+ title: string;
67
+ model: string;
68
+ messages: Message[];
69
+ createdAt: Date;
70
+ updatedAt: Date;
71
+ }
72
+
73
+ interface ModelItem {
74
+ id: string;
75
+ value: string;
76
+ adapter?: string;
77
+ baseUrl?: string;
78
+ }
79
+
80
+ interface McpServer {
81
+ name: string;
82
+ type: string;
83
+ }
84
+
85
+ interface ChatSettings {
86
+ temperature: number;
87
+ topP: number;
88
+ topK: number;
89
+ maxTokens: number;
90
+ mcpServers: string[];
91
+ }
92
+
93
+ // Streamdown Markdown component
94
+ const MarkdownContent = memo(
95
+ ({ children, className }: { children: string; className?: string }) => (
96
+ <Streamdown className={className} plugins={{ code, math, cjk }}>
97
+ {children}
98
+ </Streamdown>
99
+ ),
100
+ (prev, next) => prev.children === next.children,
101
+ );
102
+ MarkdownContent.displayName = 'MarkdownContent';
103
+
104
+ // Tool Call Display
105
+ function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
106
+ const [isOpen, setIsOpen] = useState(false);
107
+
108
+ return (
109
+ <div className='border border-base-300 rounded-lg my-2 overflow-hidden bg-base-100'>
110
+ <button
111
+ type='button'
112
+ className='w-full flex items-center justify-between p-2 hover:bg-base-200'
113
+ onClick={() => setIsOpen(!isOpen)}
114
+ >
115
+ <div className='flex items-center gap-2'>
116
+ {toolCall.status === 'completed' ? (
117
+ <Check className='w-3.5 h-3.5 text-success' />
118
+ ) : toolCall.status === 'error' ? (
119
+ <XCircle className='w-3.5 h-3.5 text-error' />
120
+ ) : (
121
+ <Clock className='w-3.5 h-3.5 text-warning animate-pulse' />
122
+ )}
123
+ <Wrench className='w-3.5 h-3.5 text-base-content/60' />
124
+ <span className='font-mono text-sm'>{toolCall.name}</span>
125
+ </div>
126
+ {isOpen ? <ChevronUp className='w-4 h-4' /> : <ChevronDown className='w-4 h-4' />}
127
+ </button>
128
+ {isOpen && (
129
+ <div className='p-2 space-y-2 text-xs border-t border-base-300'>
130
+ <div>
131
+ <div className='font-semibold text-base-content/70 mb-1'>Arguments:</div>
132
+ <pre className='bg-base-200 p-2 rounded overflow-auto max-h-32'>
133
+ {JSON.stringify(toolCall.arguments, null, 2)}
134
+ </pre>
135
+ </div>
136
+ {toolCall.result !== undefined && (
137
+ <div>
138
+ <div className='font-semibold text-base-content/70 mb-1'>Result:</div>
139
+ <pre className='bg-base-200 p-2 rounded overflow-auto max-h-32'>
140
+ {typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result, null, 2)}
141
+ </pre>
142
+ </div>
143
+ )}
144
+ {toolCall.error && (
145
+ <div>
146
+ <div className='font-semibold text-error mb-1'>Error:</div>
147
+ <pre className='bg-error/10 text-error p-2 rounded'>{toolCall.error}</pre>
148
+ </div>
149
+ )}
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ // Reasoning/Thinking Display
157
+ function ReasoningDisplay({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
158
+ const [isOpen, setIsOpen] = useState(true);
159
+
160
+ return (
161
+ <div className='border border-base-300 rounded-lg my-2 overflow-hidden bg-base-100'>
162
+ <button
163
+ type='button'
164
+ className='w-full flex items-center justify-between p-2 hover:bg-base-200'
165
+ onClick={() => setIsOpen(!isOpen)}
166
+ >
167
+ <div className='flex items-center gap-2 text-base-content/70'>
168
+ <Brain className={`w-4 h-4 ${isStreaming ? 'animate-pulse' : ''}`} />
169
+ <span className='text-sm'>{isStreaming ? 'Thinking...' : 'Reasoning'}</span>
170
+ </div>
171
+ {isOpen ? <ChevronUp className='w-4 h-4' /> : <ChevronDown className='w-4 h-4' />}
172
+ </button>
173
+ {isOpen && (
174
+ <div className='p-3 text-sm text-base-content/80 prose prose-sm max-w-none border-t border-base-300'>
175
+ <MarkdownContent>{content || '...'}</MarkdownContent>
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ // Helper functions
183
+ function generateSessionId() {
184
+ return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
185
+ }
186
+
187
+ function generateTitle(messages: Message[]): string {
188
+ const firstUserMessage = messages.find((m) => m.role === 'user');
189
+ if (firstUserMessage) {
190
+ const content = firstUserMessage.content.slice(0, 50);
191
+ return content.length < firstUserMessage.content.length ? `${content}...` : content;
192
+ }
193
+ return 'New Chat';
194
+ }
195
+
196
+ function loadSessions(): ChatSession[] {
197
+ try {
198
+ const data = localStorage.getItem('mcps-chat-sessions');
199
+ if (data) return JSON.parse(data);
200
+ } catch (e) {
201
+ console.error('Failed to load sessions:', e);
202
+ }
203
+ return [];
204
+ }
205
+
206
+ function saveSessions(sessions: ChatSession[]) {
207
+ try {
208
+ localStorage.setItem('mcps-chat-sessions', JSON.stringify(sessions));
209
+ } catch (e) {
210
+ console.error('Failed to save sessions:', e);
211
+ }
212
+ }
213
+
214
+ function loadSettings(): ChatSettings {
215
+ try {
216
+ const data = localStorage.getItem('mcps-chat-settings');
217
+ if (data) return { ...defaultSettings, ...JSON.parse(data) };
218
+ } catch (e) {
219
+ console.error('Failed to load settings:', e);
220
+ }
221
+ return defaultSettings;
222
+ }
223
+
224
+ function saveSettings(settings: ChatSettings) {
225
+ try {
226
+ localStorage.setItem('mcps-chat-settings', JSON.stringify(settings));
227
+ } catch (e) {
228
+ console.error('Failed to save settings:', e);
229
+ }
230
+ }
231
+
232
+ const defaultSettings: ChatSettings = {
233
+ temperature: 0.7,
234
+ topP: 1.0,
235
+ topK: 40,
236
+ maxTokens: 4096,
237
+ mcpServers: [],
238
+ };
239
+
240
+ export function ChatPage() {
241
+ const [models, setModels] = useState<ModelItem[]>([]);
242
+ const [selectedModel, setSelectedModel] = useState<string>('');
243
+ const [input, setInput] = useState('');
244
+ const [images, setImages] = useState<ImageContent[]>([]);
245
+ const [isLoading, setIsLoading] = useState(false);
246
+ const [sessions, setSessions] = useState<ChatSession[]>(() => loadSessions());
247
+ const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
248
+ const [showSidebar, setShowSidebar] = useState(true);
249
+ const [showSettings, setShowSettings] = useState(false);
250
+ const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
251
+ const [editContent, setEditContent] = useState('');
252
+ const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
253
+ const [settings, setSettings] = useState<ChatSettings>(() => loadSettings());
254
+ const [inputHistory, setInputHistory] = useState<string[]>([]);
255
+ const [historyIndex, setHistoryIndex] = useState(-1);
256
+
257
+ const messagesEndRef = useRef<HTMLDivElement>(null);
258
+ const abortControllerRef = useRef<AbortController | null>(null);
259
+ const fileInputRef = useRef<HTMLInputElement>(null);
260
+ const inputRef = useRef<HTMLInputElement>(null);
261
+
262
+ const currentSession = sessions.find((s) => s.id === currentSessionId);
263
+ const messages = currentSession?.messages || [];
264
+
265
+ useEffect(() => {
266
+ saveSessions(sessions);
267
+ }, [sessions]);
268
+
269
+ useEffect(() => {
270
+ saveSettings(settings);
271
+ }, [settings]);
272
+
273
+ useEffect(() => {
274
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
275
+ }, [messages]);
276
+
277
+ useEffect(() => {
278
+ fetch('/api/mcps/models')
279
+ .then((res) => res.json())
280
+ .then((data) => {
281
+ const modelList = (data.models || []) as { name: string; adapter?: string; baseUrl?: string }[];
282
+ const validModels: ModelItem[] = modelList
283
+ .filter((m) => !m.name.includes('*'))
284
+ .map((m) => ({
285
+ id: m.name,
286
+ value: m.name,
287
+ adapter: m.adapter || undefined,
288
+ baseUrl: m.baseUrl || undefined,
289
+ }));
290
+ setModels(validModels);
291
+ if (validModels.length > 0 && !selectedModel) {
292
+ setSelectedModel(validModels[0].value);
293
+ }
294
+ })
295
+ .catch(console.error);
296
+
297
+ fetch('/api/mcps/servers')
298
+ .then((res) => res.json())
299
+ .then((data) => {
300
+ setMcpServers(data.servers || []);
301
+ })
302
+ .catch(console.error);
303
+ }, []);
304
+
305
+ const updateSession = useCallback((sessionId: string, updater: (s: ChatSession) => ChatSession) => {
306
+ setSessions((prev) => prev.map((s) => (s.id === sessionId ? updater(s) : s)));
307
+ }, []);
308
+
309
+ const sendMessage = async (
310
+ content: string,
311
+ sessionId: string,
312
+ existingMessages: Message[],
313
+ msgImages?: ImageContent[],
314
+ ) => {
315
+ const startTime = Date.now();
316
+ const userMessage: Message = {
317
+ id: `user-${Date.now()}`,
318
+ role: 'user',
319
+ content,
320
+ images: msgImages,
321
+ createdAt: new Date(),
322
+ };
323
+
324
+ const assistantMessage: Message = {
325
+ id: `assistant-${Date.now()}`,
326
+ role: 'assistant',
327
+ content: '',
328
+ createdAt: new Date(),
329
+ };
330
+
331
+ updateSession(sessionId, (s) => ({
332
+ ...s,
333
+ messages: [...existingMessages, userMessage, assistantMessage],
334
+ title: generateTitle([...existingMessages, userMessage]),
335
+ updatedAt: new Date(),
336
+ }));
337
+
338
+ setIsLoading(true);
339
+ abortControllerRef.current = new AbortController();
340
+
341
+ try {
342
+ // Build messages with image support
343
+ const apiMessages = [...existingMessages, userMessage].map((m) => {
344
+ if (m.images && m.images.length > 0) {
345
+ // Multi-modal message
346
+ return {
347
+ role: m.role,
348
+ content: [
349
+ { type: 'text', text: m.content },
350
+ ...m.images.map((img) => ({
351
+ type: 'image_url',
352
+ image_url: { url: img.base64 || img.url },
353
+ })),
354
+ ],
355
+ };
356
+ }
357
+ return { role: m.role, content: m.content };
358
+ });
359
+
360
+ // Always use agent endpoint for tool support
361
+ const requestBody: Record<string, unknown> = {
362
+ model: selectedModel,
363
+ messages: apiMessages,
364
+ stream: true,
365
+ temperature: settings.temperature,
366
+ top_p: settings.topP,
367
+ max_tokens: settings.maxTokens,
368
+ };
369
+
370
+ if (settings.mcpServers.length > 0) {
371
+ requestBody.mcpServers = settings.mcpServers;
372
+ }
373
+
374
+ const response = await fetch('/v1/agent/chat', {
375
+ method: 'POST',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify(requestBody),
378
+ signal: abortControllerRef.current.signal,
379
+ });
380
+
381
+ if (!response.ok) {
382
+ const errorData = await response.json().catch(() => ({}));
383
+ throw new Error(errorData.error?.message || `HTTP ${response.status}`);
384
+ }
385
+
386
+ const reader = response.body?.getReader();
387
+ if (!reader) throw new Error('No response body');
388
+
389
+ const decoder = new TextDecoder();
390
+ let buffer = '';
391
+ let fullContent = '';
392
+ let reasoning = '';
393
+ let usage: Message['usage'] | undefined;
394
+
395
+ while (true) {
396
+ const { done, value } = await reader.read();
397
+ if (done) break;
398
+
399
+ buffer += decoder.decode(value, { stream: true });
400
+ const lines = buffer.split('\n');
401
+ buffer = lines.pop() || '';
402
+
403
+ for (const line of lines) {
404
+ if (!line.trim() || !line.startsWith('data: ')) continue;
405
+ const data = line.slice(6);
406
+ if (data === '[DONE]') continue;
407
+
408
+ try {
409
+ const parsed = JSON.parse(data);
410
+
411
+ // Handle agent streaming format
412
+ if (parsed.type === 'text') {
413
+ fullContent += parsed.content || '';
414
+ } else if (parsed.type === 'usage') {
415
+ usage = {
416
+ promptTokens: parsed.usage?.promptTokens,
417
+ completionTokens: parsed.usage?.completionTokens,
418
+ totalTokens: parsed.usage?.totalTokens,
419
+ };
420
+ } else if (parsed.type === 'step') {
421
+ // Handle step with reasoning
422
+ if (parsed.text) fullContent = parsed.text;
423
+ }
424
+
425
+ // Handle OpenAI format
426
+ const delta = parsed.choices?.[0]?.delta;
427
+ if (delta?.content) {
428
+ fullContent += delta.content;
429
+ }
430
+ if (delta?.reasoning_content) {
431
+ reasoning += delta.reasoning_content;
432
+ }
433
+ if (parsed.usage) {
434
+ usage = {
435
+ promptTokens: parsed.usage.prompt_tokens,
436
+ completionTokens: parsed.usage.completion_tokens,
437
+ totalTokens: parsed.usage.total_tokens,
438
+ };
439
+ }
440
+
441
+ updateSession(sessionId, (s) => ({
442
+ ...s,
443
+ messages: s.messages.map((m) =>
444
+ m.id === assistantMessage.id
445
+ ? {
446
+ ...m,
447
+ content: fullContent,
448
+ reasoning: reasoning || undefined,
449
+ usage,
450
+ durationMs: Date.now() - startTime,
451
+ }
452
+ : m,
453
+ ),
454
+ }));
455
+ } catch {
456
+ // Skip invalid JSON
457
+ }
458
+ }
459
+ }
460
+
461
+ updateSession(sessionId, (s) => ({
462
+ ...s,
463
+ messages: s.messages.map((m) =>
464
+ m.id === assistantMessage.id ? { ...m, durationMs: Date.now() - startTime } : m,
465
+ ),
466
+ }));
467
+ } catch (err) {
468
+ if ((err as Error).name === 'AbortError') return;
469
+ const errorMsg = err instanceof Error ? err.message : 'Failed to send message';
470
+ updateSession(sessionId, (s) => ({
471
+ ...s,
472
+ messages: s.messages.map((m) => (m.id === assistantMessage.id ? { ...m, content: '', error: errorMsg } : m)),
473
+ }));
474
+ } finally {
475
+ setIsLoading(false);
476
+ abortControllerRef.current = null;
477
+ }
478
+ };
479
+
480
+ const handleSubmit = async (e: React.FormEvent) => {
481
+ e.preventDefault();
482
+ if (!input.trim() || !selectedModel || isLoading) return;
483
+
484
+ // Add to history
485
+ setInputHistory((prev) => [input.trim(), ...prev.slice(0, 49)]);
486
+ setHistoryIndex(-1);
487
+
488
+ let sessionId = currentSessionId;
489
+ let existingMessages = messages;
490
+
491
+ if (!sessionId) {
492
+ const newSession: ChatSession = {
493
+ id: generateSessionId(),
494
+ title: 'New Chat',
495
+ model: selectedModel,
496
+ messages: [],
497
+ createdAt: new Date(),
498
+ updatedAt: new Date(),
499
+ };
500
+ setSessions((prev) => [newSession, ...prev]);
501
+ setCurrentSessionId(newSession.id);
502
+ sessionId = newSession.id;
503
+ existingMessages = [];
504
+ }
505
+
506
+ const content = input.trim();
507
+ const msgImages = images.length > 0 ? [...images] : undefined;
508
+ setInput('');
509
+ setImages([]);
510
+ await sendMessage(content, sessionId, existingMessages, msgImages);
511
+ };
512
+
513
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
514
+ if (e.key === 'ArrowUp' && !input && inputHistory.length > 0) {
515
+ e.preventDefault();
516
+ const newIndex = Math.min(historyIndex + 1, inputHistory.length - 1);
517
+ setHistoryIndex(newIndex);
518
+ setInput(inputHistory[newIndex]);
519
+ } else if (e.key === 'ArrowDown' && historyIndex >= 0) {
520
+ e.preventDefault();
521
+ const newIndex = historyIndex - 1;
522
+ setHistoryIndex(newIndex);
523
+ setInput(newIndex >= 0 ? inputHistory[newIndex] : '');
524
+ }
525
+ };
526
+
527
+ const handleRetry = async (messageId: string) => {
528
+ if (!currentSessionId || isLoading) return;
529
+ const msgIndex = messages.findIndex((m) => m.id === messageId);
530
+ if (msgIndex === -1) return;
531
+ const message = messages[msgIndex];
532
+ if (message.role !== 'assistant') return;
533
+ const userMsgIndex = msgIndex - 1;
534
+ if (userMsgIndex < 0) return;
535
+ const userMessage = messages[userMsgIndex];
536
+ if (userMessage.role !== 'user') return;
537
+ const existingMessages = messages.slice(0, userMsgIndex);
538
+ updateSession(currentSessionId, (s) => ({ ...s, messages: existingMessages }));
539
+ await sendMessage(userMessage.content, currentSessionId, existingMessages, userMessage.images);
540
+ };
541
+
542
+ const handleEditMessage = (messageId: string) => {
543
+ const message = messages.find((m) => m.id === messageId);
544
+ if (!message || message.role !== 'user') return;
545
+ setEditingMessageId(messageId);
546
+ setEditContent(message.content);
547
+ };
548
+
549
+ const handleSaveEdit = async () => {
550
+ if (!editingMessageId || !currentSessionId || !editContent.trim() || isLoading) return;
551
+ const msgIndex = messages.findIndex((m) => m.id === editingMessageId);
552
+ if (msgIndex === -1) return;
553
+ const existingMessages = messages.slice(0, msgIndex);
554
+ setEditingMessageId(null);
555
+ setEditContent('');
556
+ updateSession(currentSessionId, (s) => ({ ...s, messages: existingMessages }));
557
+ await sendMessage(editContent.trim(), currentSessionId, existingMessages);
558
+ };
559
+
560
+ const handleCancelEdit = () => {
561
+ setEditingMessageId(null);
562
+ setEditContent('');
563
+ };
564
+
565
+ const handleStop = () => {
566
+ abortControllerRef.current?.abort();
567
+ };
568
+
569
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
570
+ const files = e.target.files;
571
+ if (!files) return;
572
+ for (const file of files) {
573
+ const reader = new FileReader();
574
+ reader.onload = (ev) => {
575
+ const base64 = ev.target?.result as string;
576
+ setImages((prev) => [...prev, { type: 'image', url: file.name, base64 }]);
577
+ };
578
+ reader.readAsDataURL(file);
579
+ }
580
+ if (fileInputRef.current) fileInputRef.current.value = '';
581
+ };
582
+
583
+ const removeImage = (index: number) => {
584
+ setImages((prev) => prev.filter((_, i) => i !== index));
585
+ };
586
+
587
+ const copyToClipboard = (text: string) => {
588
+ navigator.clipboard.writeText(text);
589
+ };
590
+
591
+ const createSession = useCallback(() => {
592
+ const newSession: ChatSession = {
593
+ id: generateSessionId(),
594
+ title: 'New Chat',
595
+ model: selectedModel,
596
+ messages: [],
597
+ createdAt: new Date(),
598
+ updatedAt: new Date(),
599
+ };
600
+ setSessions((prev) => [newSession, ...prev]);
601
+ setCurrentSessionId(newSession.id);
602
+ }, [selectedModel]);
603
+
604
+ const deleteSession = useCallback(
605
+ (sessionId: string) => {
606
+ setSessions((prev) => prev.filter((s) => s.id !== sessionId));
607
+ if (currentSessionId === sessionId) setCurrentSessionId(null);
608
+ },
609
+ [currentSessionId],
610
+ );
611
+
612
+ return (
613
+ <div className='flex h-full min-h-[600px]'>
614
+ {/* Sessions Sidebar */}
615
+ {showSidebar && (
616
+ <div className='w-56 border-r border-base-300 bg-base-100 flex flex-col flex-shrink-0'>
617
+ <div className='p-2 border-b border-base-300'>
618
+ <button type='button' className='btn btn-primary btn-sm w-full gap-1' onClick={createSession}>
619
+ <MessageSquarePlus className='w-4 h-4' /> New Chat
620
+ </button>
621
+ </div>
622
+ <div className='flex-1 overflow-y-auto'>
623
+ {sessions.length === 0 ? (
624
+ <div className='p-4 text-center text-base-content/50 text-sm'>No chat history</div>
625
+ ) : (
626
+ <ul className='menu p-1 gap-0.5'>
627
+ {sessions.map((session) => (
628
+ <li key={session.id}>
629
+ <button
630
+ type='button'
631
+ className={`flex justify-between items-center w-full text-left py-2 px-2 ${currentSessionId === session.id ? 'active' : ''}`}
632
+ onClick={() => setCurrentSessionId(session.id)}
633
+ >
634
+ <span className='flex-1 truncate text-xs'>{session.title}</span>
635
+ <button
636
+ type='button'
637
+ className='btn btn-ghost btn-xs opacity-50 hover:opacity-100'
638
+ onClick={(e) => {
639
+ e.stopPropagation();
640
+ deleteSession(session.id);
641
+ }}
642
+ >
643
+ <Trash2 className='w-3 h-3' />
644
+ </button>
645
+ </button>
646
+ </li>
647
+ ))}
648
+ </ul>
649
+ )}
650
+ </div>
651
+ </div>
652
+ )}
653
+
654
+ {/* Main Chat Area */}
655
+ <div className='flex-1 flex flex-col min-w-0'>
656
+ {/* Header */}
657
+ <div className='h-12 px-3 border-b border-base-300 bg-base-100 flex items-center gap-2 flex-shrink-0'>
658
+ <button
659
+ type='button'
660
+ className='btn btn-ghost btn-sm btn-square'
661
+ onClick={() => setShowSidebar(!showSidebar)}
662
+ >
663
+ <Menu className='w-4 h-4' />
664
+ </button>
665
+
666
+ {/* Model Selector */}
667
+ <div className='flex-1 max-w-xs'>
668
+ <Combobox.Root
669
+ items={models}
670
+ itemToStringValue={(item: ModelItem) => item.value}
671
+ value={models.find((m) => m.value === selectedModel) || null}
672
+ onValueChange={(item: ModelItem | null) => {
673
+ if (item) setSelectedModel(item.value);
674
+ }}
675
+ onInputValueChange={(value) => {
676
+ if (value) setSelectedModel(value);
677
+ }}
678
+ >
679
+ <Combobox.Input placeholder='Select model...' className='input input-bordered input-sm w-full' />
680
+ <Combobox.Portal>
681
+ <Combobox.Positioner sideOffset={4}>
682
+ <Combobox.Popup className='bg-base-100 rounded-box shadow-lg border border-base-300 max-h-60 overflow-auto z-50'>
683
+ <Combobox.Empty className='p-2 text-sm text-base-content/50'>No models</Combobox.Empty>
684
+ <Combobox.List className='p-1'>
685
+ {(item: ModelItem) => (
686
+ <Combobox.Item
687
+ key={item.id}
688
+ value={item}
689
+ className='p-2 rounded cursor-pointer hover:bg-base-200 data-[highlighted]:bg-base-200 text-sm'
690
+ >
691
+ {item.value}
692
+ </Combobox.Item>
693
+ )}
694
+ </Combobox.List>
695
+ </Combobox.Popup>
696
+ </Combobox.Positioner>
697
+ </Combobox.Portal>
698
+ </Combobox.Root>
699
+ </div>
700
+
701
+ <div className='flex-1' />
702
+
703
+ <button
704
+ type='button'
705
+ className={`btn btn-ghost btn-sm btn-square ${showSettings ? 'btn-active' : ''}`}
706
+ onClick={() => setShowSettings(!showSettings)}
707
+ >
708
+ <Settings className='w-4 h-4' />
709
+ </button>
710
+ </div>
711
+
712
+ <div className='flex-1 flex overflow-hidden'>
713
+ {/* Messages Area */}
714
+ <div className='flex-1 overflow-y-auto p-4'>
715
+ {messages.length === 0 && (
716
+ <div className='text-center text-base-content/50 py-8'>
717
+ <p>Start a conversation by sending a message.</p>
718
+ {selectedModel && <p className='text-sm mt-2 opacity-70'>Model: {selectedModel}</p>}
719
+ </div>
720
+ )}
721
+
722
+ <div className='space-y-6 max-w-3xl mx-auto'>
723
+ {messages.map((message) => (
724
+ <div key={message.id}>
725
+ {message.role === 'user' ? (
726
+ // User message
727
+ <div className='flex justify-end'>
728
+ <div className='max-w-[80%]'>
729
+ {editingMessageId === message.id ? (
730
+ <div className='bg-base-200 rounded-lg p-3'>
731
+ <textarea
732
+ className='textarea textarea-bordered w-full min-w-64'
733
+ value={editContent}
734
+ onChange={(e) => setEditContent(e.target.value)}
735
+ rows={3}
736
+ />
737
+ <div className='flex gap-2 mt-2'>
738
+ <button type='button' className='btn btn-primary btn-xs' onClick={handleSaveEdit}>
739
+ Save & Send
740
+ </button>
741
+ <button type='button' className='btn btn-ghost btn-xs' onClick={handleCancelEdit}>
742
+ Cancel
743
+ </button>
744
+ </div>
745
+ </div>
746
+ ) : (
747
+ <>
748
+ <div className='bg-primary text-primary-content rounded-2xl rounded-br-md px-4 py-2'>
749
+ {message.images && message.images.length > 0 && (
750
+ <div className='flex flex-wrap gap-2 mb-2'>
751
+ {message.images.map((img, i) => (
752
+ <img
753
+ key={i}
754
+ src={img.base64 || img.url}
755
+ alt='uploaded'
756
+ className='max-h-32 rounded'
757
+ />
758
+ ))}
759
+ </div>
760
+ )}
761
+ <p className='whitespace-pre-wrap'>{message.content}</p>
762
+ </div>
763
+ <div className='flex justify-end gap-1 mt-1'>
764
+ <button
765
+ type='button'
766
+ className='btn btn-ghost btn-xs opacity-50 hover:opacity-100'
767
+ onClick={() => handleEditMessage(message.id)}
768
+ >
769
+ <Edit2 className='w-3 h-3' />
770
+ </button>
771
+ </div>
772
+ </>
773
+ )}
774
+ </div>
775
+ </div>
776
+ ) : (
777
+ // Assistant message - flat display
778
+ <div>
779
+ {message.error ? (
780
+ <div className='text-error flex items-center gap-2'>
781
+ <XCircle className='w-4 h-4' />
782
+ <span>Error: {message.error}</span>
783
+ <button
784
+ type='button'
785
+ className='btn btn-ghost btn-xs'
786
+ onClick={() => handleRetry(message.id)}
787
+ >
788
+ <RefreshCw className='w-3 h-3' />
789
+ </button>
790
+ </div>
791
+ ) : (
792
+ <>
793
+ {message.reasoning && (
794
+ <ReasoningDisplay content={message.reasoning} isStreaming={isLoading} />
795
+ )}
796
+
797
+ {message.toolCalls?.map((tc) => (
798
+ <ToolCallDisplay key={tc.id} toolCall={tc} />
799
+ ))}
800
+
801
+ <div className='prose prose-sm max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
802
+ <MarkdownContent>{message.content || (isLoading ? '...' : '')}</MarkdownContent>
803
+ </div>
804
+
805
+ {/* Actions and metadata */}
806
+ <div className='flex items-center gap-3 mt-2 text-xs text-base-content/50'>
807
+ {message.usage && (
808
+ <span className='flex items-center gap-1'>
809
+ <Zap className='w-3 h-3' />
810
+ {message.usage.totalTokens} tokens
811
+ {message.usage.promptTokens != null && (
812
+ <span className='opacity-70'>
813
+ ({message.usage.promptTokens}/{message.usage.completionTokens})
814
+ </span>
815
+ )}
816
+ </span>
817
+ )}
818
+ {message.durationMs && (
819
+ <span className='flex items-center gap-1'>
820
+ <Clock className='w-3 h-3' />
821
+ {(message.durationMs / 1000).toFixed(1)}s
822
+ </span>
823
+ )}
824
+ {message.usage?.completionTokens && message.durationMs && (
825
+ <span>
826
+ {Math.round((message.usage.completionTokens / message.durationMs) * 1000)} tok/s
827
+ </span>
828
+ )}
829
+ <button
830
+ type='button'
831
+ className='btn btn-ghost btn-xs opacity-50 hover:opacity-100'
832
+ onClick={() => copyToClipboard(message.content)}
833
+ >
834
+ <Copy className='w-3 h-3' />
835
+ </button>
836
+ <button
837
+ type='button'
838
+ className='btn btn-ghost btn-xs opacity-50 hover:opacity-100'
839
+ onClick={() => handleRetry(message.id)}
840
+ >
841
+ <RefreshCw className='w-3 h-3' />
842
+ </button>
843
+ </div>
844
+ </>
845
+ )}
846
+ </div>
847
+ )}
848
+ </div>
849
+ ))}
850
+ </div>
851
+
852
+ <div ref={messagesEndRef} />
853
+ </div>
854
+
855
+ {/* Settings Panel */}
856
+ {showSettings && (
857
+ <div className='w-64 border-l border-base-300 bg-base-100 p-4 overflow-y-auto flex-shrink-0'>
858
+ <h3 className='font-semibold mb-4'>Settings</h3>
859
+
860
+ <div className='space-y-4'>
861
+ <div>
862
+ <label className='text-xs font-medium'>Temperature: {settings.temperature}</label>
863
+ <input
864
+ type='range'
865
+ min='0'
866
+ max='2'
867
+ step='0.1'
868
+ value={settings.temperature}
869
+ onChange={(e) => setSettings((s) => ({ ...s, temperature: parseFloat(e.target.value) }))}
870
+ className='range range-xs range-primary w-full'
871
+ />
872
+ </div>
873
+
874
+ <div>
875
+ <label className='text-xs font-medium'>Top P: {settings.topP}</label>
876
+ <input
877
+ type='range'
878
+ min='0'
879
+ max='1'
880
+ step='0.05'
881
+ value={settings.topP}
882
+ onChange={(e) => setSettings((s) => ({ ...s, topP: parseFloat(e.target.value) }))}
883
+ className='range range-xs range-primary w-full'
884
+ />
885
+ </div>
886
+
887
+ <div>
888
+ <label className='text-xs font-medium'>Top K: {settings.topK}</label>
889
+ <input
890
+ type='range'
891
+ min='1'
892
+ max='100'
893
+ step='1'
894
+ value={settings.topK}
895
+ onChange={(e) => setSettings((s) => ({ ...s, topK: parseInt(e.target.value, 10) }))}
896
+ className='range range-xs range-primary w-full'
897
+ />
898
+ </div>
899
+
900
+ <div>
901
+ <label className='text-xs font-medium'>Max Tokens: {settings.maxTokens}</label>
902
+ <input
903
+ type='range'
904
+ min='256'
905
+ max='16384'
906
+ step='256'
907
+ value={settings.maxTokens}
908
+ onChange={(e) => setSettings((s) => ({ ...s, maxTokens: parseInt(e.target.value, 10) }))}
909
+ className='range range-xs range-primary w-full'
910
+ />
911
+ </div>
912
+
913
+ <div className='divider text-xs'>MCP Servers</div>
914
+
915
+ {mcpServers.length === 0 ? (
916
+ <p className='text-xs text-base-content/50'>No servers configured</p>
917
+ ) : (
918
+ <div className='space-y-2'>
919
+ {mcpServers.map((server) => (
920
+ <label key={server.name} className='flex items-center gap-2 cursor-pointer'>
921
+ <input
922
+ type='checkbox'
923
+ className='checkbox checkbox-xs checkbox-primary'
924
+ checked={settings.mcpServers.includes(server.name)}
925
+ onChange={(e) => {
926
+ setSettings((s) => ({
927
+ ...s,
928
+ mcpServers: e.target.checked
929
+ ? [...s.mcpServers, server.name]
930
+ : s.mcpServers.filter((n) => n !== server.name),
931
+ }));
932
+ }}
933
+ />
934
+ <span className='text-xs'>{server.name}</span>
935
+ <span className='text-xs text-base-content/50'>({server.type})</span>
936
+ </label>
937
+ ))}
938
+ </div>
939
+ )}
940
+ </div>
941
+ </div>
942
+ )}
943
+ </div>
944
+
945
+ {/* Input Area */}
946
+ <div className='p-3 border-t border-base-300 bg-base-100 flex-shrink-0'>
947
+ {/* Image Previews */}
948
+ {images.length > 0 && (
949
+ <div className='flex flex-wrap gap-2 mb-2'>
950
+ {images.map((img, i) => (
951
+ <div key={i} className='relative'>
952
+ <img src={img.base64 || img.url} alt='preview' className='h-16 rounded' />
953
+ <button
954
+ type='button'
955
+ className='btn btn-circle btn-xs absolute -top-1 -right-1 btn-error'
956
+ onClick={() => removeImage(i)}
957
+ >
958
+ <X className='w-3 h-3' />
959
+ </button>
960
+ </div>
961
+ ))}
962
+ </div>
963
+ )}
964
+
965
+ <form onSubmit={handleSubmit} className='flex gap-2'>
966
+ <input
967
+ type='file'
968
+ ref={fileInputRef}
969
+ accept='image/*'
970
+ multiple
971
+ className='hidden'
972
+ onChange={handleImageUpload}
973
+ />
974
+ <button
975
+ type='button'
976
+ className='btn btn-ghost btn-sm btn-square'
977
+ onClick={() => fileInputRef.current?.click()}
978
+ >
979
+ <ImagePlus className='w-4 h-4' />
980
+ </button>
981
+ <input
982
+ ref={inputRef}
983
+ type='text'
984
+ className='input input-bordered flex-1 input-sm'
985
+ value={input}
986
+ onChange={(e) => setInput(e.target.value)}
987
+ onKeyDown={handleKeyDown}
988
+ placeholder={selectedModel ? 'Type a message... (Up arrow for history)' : 'Select a model first'}
989
+ disabled={!selectedModel || isLoading}
990
+ />
991
+ {isLoading ? (
992
+ <button type='button' className='btn btn-error btn-sm' onClick={handleStop}>
993
+ <Square className='w-4 h-4' />
994
+ </button>
995
+ ) : (
996
+ <button type='submit' className='btn btn-primary btn-sm' disabled={!input.trim() || !selectedModel}>
997
+ <Send className='w-4 h-4' />
998
+ </button>
999
+ )}
1000
+ </form>
1001
+ </div>
1002
+ </div>
1003
+ </div>
1004
+ );
1005
+ }