@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.
- package/LICENSE +21 -0
- package/dist/index.mjs +15 -0
- package/dist/mcps-cli.mjs +174727 -0
- package/lib/chat/agent.js +187 -0
- package/lib/chat/agent.js.map +1 -0
- package/lib/chat/audit.js +238 -0
- package/lib/chat/audit.js.map +1 -0
- package/lib/chat/converters.js +467 -0
- package/lib/chat/converters.js.map +1 -0
- package/lib/chat/handler.js +1068 -0
- package/lib/chat/handler.js.map +1 -0
- package/lib/chat/index.js +12 -0
- package/lib/chat/index.js.map +1 -0
- package/lib/chat/types.js +35 -0
- package/lib/chat/types.js.map +1 -0
- package/lib/contracts/AuditContract.js +85 -0
- package/lib/contracts/AuditContract.js.map +1 -0
- package/lib/contracts/McpsContract.js +113 -0
- package/lib/contracts/McpsContract.js.map +1 -0
- package/lib/contracts/index.js +3 -0
- package/lib/contracts/index.js.map +1 -0
- package/lib/dev.server.js +7 -0
- package/lib/dev.server.js.map +1 -0
- package/lib/entities/ChatRequestEntity.js +318 -0
- package/lib/entities/ChatRequestEntity.js.map +1 -0
- package/lib/entities/McpRequestEntity.js +271 -0
- package/lib/entities/McpRequestEntity.js.map +1 -0
- package/lib/entities/RequestLogEntity.js +177 -0
- package/lib/entities/RequestLogEntity.js.map +1 -0
- package/lib/entities/ResponseEntity.js +150 -0
- package/lib/entities/ResponseEntity.js.map +1 -0
- package/lib/entities/index.js +11 -0
- package/lib/entities/index.js.map +1 -0
- package/lib/entities/types.js +11 -0
- package/lib/entities/types.js.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/mcps-cli.js +44 -0
- package/lib/mcps-cli.js.map +1 -0
- package/lib/providers/McpServerHandlerDef.js +40 -0
- package/lib/providers/McpServerHandlerDef.js.map +1 -0
- package/lib/providers/findMcpServerDef.js +26 -0
- package/lib/providers/findMcpServerDef.js.map +1 -0
- package/lib/providers/prometheus/def.js +24 -0
- package/lib/providers/prometheus/def.js.map +1 -0
- package/lib/providers/prometheus/index.js +2 -0
- package/lib/providers/prometheus/index.js.map +1 -0
- package/lib/providers/relay/def.js +32 -0
- package/lib/providers/relay/def.js.map +1 -0
- package/lib/providers/relay/index.js +2 -0
- package/lib/providers/relay/index.js.map +1 -0
- package/lib/providers/sql/def.js +31 -0
- package/lib/providers/sql/def.js.map +1 -0
- package/lib/providers/sql/index.js +2 -0
- package/lib/providers/sql/index.js.map +1 -0
- package/lib/providers/tencent-cls/def.js +44 -0
- package/lib/providers/tencent-cls/def.js.map +1 -0
- package/lib/providers/tencent-cls/index.js +2 -0
- package/lib/providers/tencent-cls/index.js.map +1 -0
- package/lib/scripts/bundle.js +90 -0
- package/lib/scripts/bundle.js.map +1 -0
- package/lib/server/api-routes.js +96 -0
- package/lib/server/api-routes.js.map +1 -0
- package/lib/server/audit.js +274 -0
- package/lib/server/audit.js.map +1 -0
- package/lib/server/chat-routes.js +82 -0
- package/lib/server/chat-routes.js.map +1 -0
- package/lib/server/config.js +223 -0
- package/lib/server/config.js.map +1 -0
- package/lib/server/db.js +97 -0
- package/lib/server/db.js.map +1 -0
- package/lib/server/index.js +2 -0
- package/lib/server/index.js.map +1 -0
- package/lib/server/mcp-handler.js +167 -0
- package/lib/server/mcp-handler.js.map +1 -0
- package/lib/server/mcp-routes.js +112 -0
- package/lib/server/mcp-routes.js.map +1 -0
- package/lib/server/mcps-router.js +119 -0
- package/lib/server/mcps-router.js.map +1 -0
- package/lib/server/schema.js +129 -0
- package/lib/server/schema.js.map +1 -0
- package/lib/server/server.js +166 -0
- package/lib/server/server.js.map +1 -0
- package/lib/web/ChatPage.js +827 -0
- package/lib/web/ChatPage.js.map +1 -0
- package/lib/web/McpInspectorPage.js +214 -0
- package/lib/web/McpInspectorPage.js.map +1 -0
- package/lib/web/ServersPage.js +93 -0
- package/lib/web/ServersPage.js.map +1 -0
- package/lib/web/main.js +541 -0
- package/lib/web/main.js.map +1 -0
- package/package.json +83 -0
- package/src/chat/agent.ts +240 -0
- package/src/chat/audit.ts +377 -0
- package/src/chat/converters.test.ts +325 -0
- package/src/chat/converters.ts +459 -0
- package/src/chat/handler.test.ts +137 -0
- package/src/chat/handler.ts +1233 -0
- package/src/chat/index.ts +16 -0
- package/src/chat/types.ts +72 -0
- package/src/contracts/AuditContract.ts +93 -0
- package/src/contracts/McpsContract.ts +141 -0
- package/src/contracts/index.ts +18 -0
- package/src/dev.server.ts +7 -0
- package/src/entities/ChatRequestEntity.ts +157 -0
- package/src/entities/McpRequestEntity.ts +149 -0
- package/src/entities/RequestLogEntity.ts +78 -0
- package/src/entities/ResponseEntity.ts +75 -0
- package/src/entities/index.ts +12 -0
- package/src/entities/types.ts +188 -0
- package/src/index.ts +1 -0
- package/src/mcps-cli.ts +59 -0
- package/src/providers/McpServerHandlerDef.ts +105 -0
- package/src/providers/findMcpServerDef.ts +31 -0
- package/src/providers/prometheus/def.ts +21 -0
- package/src/providers/prometheus/index.ts +1 -0
- package/src/providers/relay/def.ts +31 -0
- package/src/providers/relay/index.ts +1 -0
- package/src/providers/relay/relay.test.ts +47 -0
- package/src/providers/sql/def.ts +33 -0
- package/src/providers/sql/index.ts +1 -0
- package/src/providers/tencent-cls/def.ts +38 -0
- package/src/providers/tencent-cls/index.ts +1 -0
- package/src/scripts/bundle.ts +82 -0
- package/src/server/api-routes.ts +98 -0
- package/src/server/audit.ts +310 -0
- package/src/server/chat-routes.ts +95 -0
- package/src/server/config.test.ts +162 -0
- package/src/server/config.ts +198 -0
- package/src/server/db.ts +115 -0
- package/src/server/index.ts +1 -0
- package/src/server/mcp-handler.ts +209 -0
- package/src/server/mcp-routes.ts +133 -0
- package/src/server/mcps-router.ts +133 -0
- package/src/server/schema.ts +175 -0
- package/src/server/server.ts +163 -0
- package/src/web/ChatPage.tsx +1005 -0
- package/src/web/McpInspectorPage.tsx +254 -0
- package/src/web/ServersPage.tsx +139 -0
- package/src/web/main.tsx +600 -0
- 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
|
+
}
|