@geminilight/mindos 0.5.22 → 0.5.23
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/app/app/api/ask/route.ts +7 -14
- package/app/app/api/bootstrap/route.ts +1 -0
- package/app/app/globals.css +14 -0
- package/app/app/setup/page.tsx +3 -2
- package/app/components/ActivityBar.tsx +183 -0
- package/app/components/AskFab.tsx +39 -97
- package/app/components/AskModal.tsx +13 -371
- package/app/components/Breadcrumb.tsx +4 -4
- package/app/components/FileTree.tsx +21 -4
- package/app/components/Logo.tsx +39 -0
- package/app/components/Panel.tsx +152 -0
- package/app/components/RightAskPanel.tsx +72 -0
- package/app/components/SettingsModal.tsx +9 -241
- package/app/components/SidebarLayout.tsx +426 -12
- package/app/components/SyncStatusBar.tsx +74 -53
- package/app/components/TableOfContents.tsx +4 -2
- package/app/components/ask/AskContent.tsx +418 -0
- package/app/components/ask/MessageList.tsx +2 -2
- package/app/components/panels/AgentsPanel.tsx +231 -0
- package/app/components/panels/PanelHeader.tsx +35 -0
- package/app/components/panels/PluginsPanel.tsx +106 -0
- package/app/components/panels/SearchPanel.tsx +178 -0
- package/app/components/panels/SyncPopover.tsx +105 -0
- package/app/components/renderers/csv/TableView.tsx +4 -4
- package/app/components/settings/AiTab.tsx +39 -1
- package/app/components/settings/KnowledgeTab.tsx +116 -2
- package/app/components/settings/McpTab.tsx +6 -6
- package/app/components/settings/SettingsContent.tsx +343 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/setup/index.tsx +2 -23
- package/app/hooks/useResizeDrag.ts +78 -0
- package/app/lib/agent/index.ts +0 -1
- package/app/lib/agent/model.ts +33 -10
- package/app/lib/format.ts +19 -0
- package/app/lib/i18n-en.ts +6 -6
- package/app/lib/i18n-zh.ts +5 -5
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +27 -97
- package/package.json +4 -2
- package/scripts/setup.js +2 -12
- package/skills/mindos/SKILL.md +226 -8
- package/skills/mindos-zh/SKILL.md +226 -8
- package/app/lib/agent/skill-rules.ts +0 -70
- package/app/package-lock.json +0 -15736
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
4
|
+
import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
|
|
5
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
|
+
import type { Message } from '@/lib/types';
|
|
7
|
+
import { useAskSession } from '@/hooks/useAskSession';
|
|
8
|
+
import { useFileUpload } from '@/hooks/useFileUpload';
|
|
9
|
+
import { useMention } from '@/hooks/useMention';
|
|
10
|
+
import MessageList from '@/components/ask/MessageList';
|
|
11
|
+
import MentionPopover from '@/components/ask/MentionPopover';
|
|
12
|
+
import SessionHistory from '@/components/ask/SessionHistory';
|
|
13
|
+
import FileChip from '@/components/ask/FileChip';
|
|
14
|
+
import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
|
|
15
|
+
|
|
16
|
+
interface AskContentProps {
|
|
17
|
+
/** Controls visibility — 'open' for modal, 'active' for panel */
|
|
18
|
+
visible: boolean;
|
|
19
|
+
currentFile?: string;
|
|
20
|
+
initialMessage?: string;
|
|
21
|
+
onFirstMessage?: () => void;
|
|
22
|
+
/** 'modal' renders close button + ESC handler; 'panel' renders compact header */
|
|
23
|
+
variant: 'modal' | 'panel';
|
|
24
|
+
/** Required for modal variant — called on close button / ESC / backdrop click */
|
|
25
|
+
onClose?: () => void;
|
|
26
|
+
maximized?: boolean;
|
|
27
|
+
onMaximize?: () => void;
|
|
28
|
+
/** Current Ask display mode */
|
|
29
|
+
askMode?: 'panel' | 'popup';
|
|
30
|
+
/** Switch between panel ↔ popup */
|
|
31
|
+
onModeSwitch?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function AskContent({ visible, currentFile, initialMessage, onFirstMessage, variant, onClose, maximized, onMaximize, askMode, onModeSwitch }: AskContentProps) {
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
37
|
+
const firstMessageFired = useRef(false);
|
|
38
|
+
const { t } = useLocale();
|
|
39
|
+
|
|
40
|
+
const [input, setInput] = useState('');
|
|
41
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
42
|
+
const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
|
|
43
|
+
const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
|
|
44
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
45
|
+
|
|
46
|
+
const session = useAskSession(currentFile);
|
|
47
|
+
const upload = useFileUpload();
|
|
48
|
+
const mention = useMention();
|
|
49
|
+
|
|
50
|
+
// Focus and init session when becoming visible (edge-triggered for panel, level-triggered for modal)
|
|
51
|
+
const prevVisibleRef = useRef(false);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const justOpened = variant === 'panel'
|
|
54
|
+
? (visible && !prevVisibleRef.current) // panel: edge detection
|
|
55
|
+
: visible; // modal: level detection (reset every open)
|
|
56
|
+
|
|
57
|
+
if (justOpened) {
|
|
58
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
59
|
+
void session.initSessions();
|
|
60
|
+
setInput(initialMessage || '');
|
|
61
|
+
firstMessageFired.current = false;
|
|
62
|
+
setAttachedFiles(currentFile ? [currentFile] : []);
|
|
63
|
+
upload.clearAttachments();
|
|
64
|
+
mention.resetMention();
|
|
65
|
+
setShowHistory(false);
|
|
66
|
+
} else if (!visible && variant === 'modal') {
|
|
67
|
+
// Modal: abort streaming on close
|
|
68
|
+
abortRef.current?.abort();
|
|
69
|
+
}
|
|
70
|
+
prevVisibleRef.current = visible;
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
}, [visible, currentFile]);
|
|
73
|
+
|
|
74
|
+
// Persist session on message changes
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!visible || !session.activeSessionId) return;
|
|
77
|
+
session.persistSession(session.messages, session.activeSessionId);
|
|
78
|
+
return () => session.clearPersistTimer();
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [visible, session.messages, session.activeSessionId]);
|
|
81
|
+
|
|
82
|
+
// Esc to close — modal only
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (variant !== 'modal' || !visible || !onClose) return;
|
|
85
|
+
const handler = (e: KeyboardEvent) => {
|
|
86
|
+
if (e.key === 'Escape') {
|
|
87
|
+
if (mention.mentionQuery !== null) { mention.resetMention(); return; }
|
|
88
|
+
onClose();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
window.addEventListener('keydown', handler);
|
|
92
|
+
return () => window.removeEventListener('keydown', handler);
|
|
93
|
+
}, [variant, visible, onClose, mention]);
|
|
94
|
+
|
|
95
|
+
const handleInputChange = useCallback((val: string) => {
|
|
96
|
+
setInput(val);
|
|
97
|
+
mention.updateMentionFromInput(val);
|
|
98
|
+
}, [mention]);
|
|
99
|
+
|
|
100
|
+
const selectMention = useCallback((filePath: string) => {
|
|
101
|
+
const atIdx = input.lastIndexOf('@');
|
|
102
|
+
setInput(input.slice(0, atIdx));
|
|
103
|
+
mention.resetMention();
|
|
104
|
+
if (!attachedFiles.includes(filePath)) {
|
|
105
|
+
setAttachedFiles(prev => [...prev, filePath]);
|
|
106
|
+
}
|
|
107
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
108
|
+
}, [input, attachedFiles, mention]);
|
|
109
|
+
|
|
110
|
+
const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
111
|
+
if (mention.mentionQuery === null) return;
|
|
112
|
+
if (e.key === 'ArrowDown') {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
mention.navigateMention('down');
|
|
115
|
+
} else if (e.key === 'ArrowUp') {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
mention.navigateMention('up');
|
|
118
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
119
|
+
if (mention.mentionResults.length > 0) {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
selectMention(mention.mentionResults[mention.mentionIndex]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}, [mention, selectMention]);
|
|
125
|
+
|
|
126
|
+
const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
|
|
127
|
+
|
|
128
|
+
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
if (mention.mentionQuery !== null) return;
|
|
131
|
+
const text = input.trim();
|
|
132
|
+
if (!text || isLoading) return;
|
|
133
|
+
|
|
134
|
+
const userMsg: Message = { role: 'user', content: text };
|
|
135
|
+
const requestMessages = [...session.messages, userMsg];
|
|
136
|
+
session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
|
|
137
|
+
setInput('');
|
|
138
|
+
if (onFirstMessage && !firstMessageFired.current) {
|
|
139
|
+
firstMessageFired.current = true;
|
|
140
|
+
onFirstMessage();
|
|
141
|
+
}
|
|
142
|
+
setAttachedFiles(currentFile ? [currentFile] : []);
|
|
143
|
+
setIsLoading(true);
|
|
144
|
+
setLoadingPhase('connecting');
|
|
145
|
+
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
abortRef.current = controller;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetch('/api/ask', {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
messages: requestMessages,
|
|
155
|
+
currentFile,
|
|
156
|
+
attachedFiles,
|
|
157
|
+
uploadedFiles: upload.localAttachments,
|
|
158
|
+
}),
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
let errorMsg = `Request failed (${res.status})`;
|
|
164
|
+
try {
|
|
165
|
+
const errBody = await res.json();
|
|
166
|
+
if (errBody.error) errorMsg = errBody.error;
|
|
167
|
+
} catch {}
|
|
168
|
+
throw new Error(errorMsg);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!res.body) throw new Error('No response body');
|
|
172
|
+
|
|
173
|
+
setLoadingPhase('thinking');
|
|
174
|
+
|
|
175
|
+
const finalMessage = await consumeUIMessageStream(
|
|
176
|
+
res.body,
|
|
177
|
+
(msg) => {
|
|
178
|
+
setLoadingPhase('streaming');
|
|
179
|
+
session.setMessages(prev => {
|
|
180
|
+
const updated = [...prev];
|
|
181
|
+
updated[updated.length - 1] = msg;
|
|
182
|
+
return updated;
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
controller.signal,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
|
|
189
|
+
session.setMessages(prev => {
|
|
190
|
+
const updated = [...prev];
|
|
191
|
+
updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
|
|
192
|
+
return updated;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if ((err as Error).name === 'AbortError') {
|
|
197
|
+
session.setMessages(prev => {
|
|
198
|
+
const updated = [...prev];
|
|
199
|
+
const lastIdx = updated.length - 1;
|
|
200
|
+
if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
|
|
201
|
+
const last = updated[lastIdx];
|
|
202
|
+
const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
|
|
203
|
+
if (!hasContent) {
|
|
204
|
+
updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return updated;
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
const errMsg = err instanceof Error ? err.message : 'Something went wrong';
|
|
211
|
+
session.setMessages(prev => {
|
|
212
|
+
const updated = [...prev];
|
|
213
|
+
const lastIdx = updated.length - 1;
|
|
214
|
+
if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
|
|
215
|
+
const last = updated[lastIdx];
|
|
216
|
+
const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
|
|
217
|
+
if (!hasContent) {
|
|
218
|
+
updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
|
|
219
|
+
return updated;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return [...updated, { role: 'assistant', content: `__error__${errMsg}` }];
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
setIsLoading(false);
|
|
227
|
+
abortRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
}, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
|
|
230
|
+
|
|
231
|
+
const handleResetSession = useCallback(() => {
|
|
232
|
+
if (isLoading) return;
|
|
233
|
+
session.resetSession();
|
|
234
|
+
setInput('');
|
|
235
|
+
setAttachedFiles(currentFile ? [currentFile] : []);
|
|
236
|
+
upload.clearAttachments();
|
|
237
|
+
mention.resetMention();
|
|
238
|
+
setShowHistory(false);
|
|
239
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
240
|
+
}, [isLoading, currentFile, session, upload, mention]);
|
|
241
|
+
|
|
242
|
+
const handleLoadSession = useCallback((id: string) => {
|
|
243
|
+
session.loadSession(id);
|
|
244
|
+
setShowHistory(false);
|
|
245
|
+
setInput('');
|
|
246
|
+
setAttachedFiles(currentFile ? [currentFile] : []);
|
|
247
|
+
upload.clearAttachments();
|
|
248
|
+
mention.resetMention();
|
|
249
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
250
|
+
}, [session, currentFile, upload, mention]);
|
|
251
|
+
|
|
252
|
+
const isPanel = variant === 'panel';
|
|
253
|
+
const iconSize = isPanel ? 13 : 14;
|
|
254
|
+
const inputIconSize = isPanel ? 14 : 15;
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<>
|
|
258
|
+
{/* Header */}
|
|
259
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
|
260
|
+
{!isPanel && (
|
|
261
|
+
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-8 h-1 rounded-full bg-muted-foreground/20 md:hidden" />
|
|
262
|
+
)}
|
|
263
|
+
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
264
|
+
<Sparkles size={isPanel ? 14 : 15} style={{ color: 'var(--amber)' }} />
|
|
265
|
+
<span className={isPanel ? 'font-display text-xs uppercase tracking-wider text-muted-foreground' : 'font-display'}>
|
|
266
|
+
{isPanel ? 'MindOS Agent' : t.ask.title}
|
|
267
|
+
</span>
|
|
268
|
+
</div>
|
|
269
|
+
<div className="flex items-center gap-1">
|
|
270
|
+
<button type="button" onClick={() => setShowHistory(v => !v)} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Session history">
|
|
271
|
+
<History size={iconSize} />
|
|
272
|
+
</button>
|
|
273
|
+
<button type="button" onClick={handleResetSession} disabled={isLoading} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40" title="New session">
|
|
274
|
+
<RotateCcw size={iconSize} />
|
|
275
|
+
</button>
|
|
276
|
+
{isPanel && onMaximize && (
|
|
277
|
+
<button type="button" onClick={onMaximize} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={maximized ? 'Restore panel' : 'Maximize panel'}>
|
|
278
|
+
{maximized ? <Minimize2 size={iconSize} /> : <Maximize2 size={iconSize} />}
|
|
279
|
+
</button>
|
|
280
|
+
)}
|
|
281
|
+
{onModeSwitch && (
|
|
282
|
+
<button type="button" onClick={onModeSwitch} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={askMode === 'popup' ? 'Dock to side panel' : 'Open as popup'}>
|
|
283
|
+
{askMode === 'popup' ? <PanelRight size={iconSize} /> : <AppWindow size={iconSize} />}
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
{onClose && (
|
|
287
|
+
<button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label="Close">
|
|
288
|
+
<X size={isPanel ? iconSize : 15} />
|
|
289
|
+
</button>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{showHistory && (
|
|
295
|
+
<SessionHistory
|
|
296
|
+
sessions={session.sessions}
|
|
297
|
+
activeSessionId={session.activeSessionId}
|
|
298
|
+
onLoad={handleLoadSession}
|
|
299
|
+
onDelete={session.deleteSession}
|
|
300
|
+
/>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{/* Messages */}
|
|
304
|
+
<MessageList
|
|
305
|
+
messages={session.messages}
|
|
306
|
+
isLoading={isLoading}
|
|
307
|
+
loadingPhase={loadingPhase}
|
|
308
|
+
emptyPrompt={t.ask.emptyPrompt}
|
|
309
|
+
suggestions={t.ask.suggestions}
|
|
310
|
+
onSuggestionClick={setInput}
|
|
311
|
+
labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
|
|
312
|
+
/>
|
|
313
|
+
|
|
314
|
+
{/* Input area */}
|
|
315
|
+
<div className="border-t border-border shrink-0">
|
|
316
|
+
{attachedFiles.length > 0 && (
|
|
317
|
+
<div className={isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1'}>
|
|
318
|
+
<div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
|
|
319
|
+
{isPanel ? 'Context' : 'Knowledge Base Context'}
|
|
320
|
+
</div>
|
|
321
|
+
<div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
|
|
322
|
+
{attachedFiles.map(f => (
|
|
323
|
+
<FileChip key={f} path={f} onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{upload.localAttachments.length > 0 && (
|
|
330
|
+
<div className={isPanel ? 'px-3 pb-1' : 'px-4 pb-1'}>
|
|
331
|
+
<div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
|
|
332
|
+
{isPanel ? 'Uploaded' : 'Uploaded Files'}
|
|
333
|
+
</div>
|
|
334
|
+
<div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
|
|
335
|
+
{upload.localAttachments.map((f, idx) => (
|
|
336
|
+
<FileChip key={`${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{upload.uploadError && (
|
|
343
|
+
<div className={`${isPanel ? 'px-3' : 'px-4'} pb-1 text-xs text-error`}>{upload.uploadError}</div>
|
|
344
|
+
)}
|
|
345
|
+
|
|
346
|
+
{mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
|
|
347
|
+
<MentionPopover
|
|
348
|
+
results={mention.mentionResults}
|
|
349
|
+
selectedIndex={mention.mentionIndex}
|
|
350
|
+
onSelect={selectMention}
|
|
351
|
+
/>
|
|
352
|
+
)}
|
|
353
|
+
|
|
354
|
+
<form onSubmit={handleSubmit} className={`flex items-center ${isPanel ? 'gap-1.5 px-2 py-2.5' : 'gap-2 px-3 py-3'}`}>
|
|
355
|
+
<button type="button" onClick={() => upload.uploadInputRef.current?.click()} className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0" title="Attach local file">
|
|
356
|
+
<Paperclip size={inputIconSize} />
|
|
357
|
+
</button>
|
|
358
|
+
|
|
359
|
+
<input
|
|
360
|
+
ref={upload.uploadInputRef}
|
|
361
|
+
type="file"
|
|
362
|
+
className="hidden"
|
|
363
|
+
multiple
|
|
364
|
+
accept=".txt,.md,.markdown,.csv,.json,.yaml,.yml,.xml,.html,.htm,.pdf,text/plain,text/markdown,text/csv,application/json,application/pdf"
|
|
365
|
+
onChange={async (e) => {
|
|
366
|
+
const inputEl = e.currentTarget;
|
|
367
|
+
await upload.pickFiles(inputEl.files);
|
|
368
|
+
inputEl.value = '';
|
|
369
|
+
}}
|
|
370
|
+
/>
|
|
371
|
+
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={() => {
|
|
375
|
+
const el = inputRef.current;
|
|
376
|
+
if (!el) return;
|
|
377
|
+
const pos = el.selectionStart ?? input.length;
|
|
378
|
+
const newVal = input.slice(0, pos) + '@' + input.slice(pos);
|
|
379
|
+
handleInputChange(newVal);
|
|
380
|
+
setTimeout(() => { el.focus(); el.setSelectionRange(pos + 1, pos + 1); }, 0);
|
|
381
|
+
}}
|
|
382
|
+
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
|
|
383
|
+
title="@ mention file"
|
|
384
|
+
>
|
|
385
|
+
<AtSign size={inputIconSize} />
|
|
386
|
+
</button>
|
|
387
|
+
|
|
388
|
+
<input
|
|
389
|
+
ref={inputRef}
|
|
390
|
+
value={input}
|
|
391
|
+
onChange={e => handleInputChange(e.target.value)}
|
|
392
|
+
onKeyDown={handleInputKeyDown}
|
|
393
|
+
placeholder={t.ask.placeholder}
|
|
394
|
+
disabled={isLoading}
|
|
395
|
+
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 min-w-0"
|
|
396
|
+
/>
|
|
397
|
+
|
|
398
|
+
{isLoading ? (
|
|
399
|
+
<button type="button" onClick={handleStop} className="p-1.5 rounded-md transition-colors shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted" title={t.ask.stopTitle}>
|
|
400
|
+
<StopCircle size={inputIconSize} />
|
|
401
|
+
</button>
|
|
402
|
+
) : (
|
|
403
|
+
<button type="submit" disabled={!input.trim()} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0" style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
|
|
404
|
+
<Send size={isPanel ? 13 : 14} />
|
|
405
|
+
</button>
|
|
406
|
+
)}
|
|
407
|
+
</form>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{/* Footer hints */}
|
|
411
|
+
<div className={`${isPanel ? 'px-3 pb-1.5' : 'hidden md:flex px-4 pb-2'} flex items-center gap-${isPanel ? '2' : '3'} text-${isPanel ? '[10px]' : 'xs'} text-muted-foreground/${isPanel ? '40' : '50'} shrink-0`}>
|
|
412
|
+
<span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
|
|
413
|
+
<span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
|
|
414
|
+
{!isPanel && <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>}
|
|
415
|
+
</div>
|
|
416
|
+
</>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
@@ -10,9 +10,9 @@ import ThinkingBlock from './ThinkingBlock';
|
|
|
10
10
|
|
|
11
11
|
function AssistantMessage({ content, isStreaming }: { content: string; isStreaming: boolean }) {
|
|
12
12
|
return (
|
|
13
|
-
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground
|
|
13
|
+
<div className="prose prose-sm prose-panel dark:prose-invert max-w-none text-foreground
|
|
14
14
|
prose-p:my-1 prose-p:leading-relaxed
|
|
15
|
-
prose-headings:font-semibold prose-headings:my-2
|
|
15
|
+
prose-headings:font-semibold prose-headings:my-2 prose-headings:text-[13px]
|
|
16
16
|
prose-ul:my-1 prose-li:my-0.5
|
|
17
17
|
prose-ol:my-1
|
|
18
18
|
prose-code:text-[0.8em] prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { Loader2, RefreshCw, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
|
|
5
|
+
import { apiFetch } from '@/lib/api';
|
|
6
|
+
import type { McpStatus, AgentInfo } from '../settings/types';
|
|
7
|
+
import PanelHeader from './PanelHeader';
|
|
8
|
+
|
|
9
|
+
interface AgentsPanelProps {
|
|
10
|
+
active: boolean;
|
|
11
|
+
maximized?: boolean;
|
|
12
|
+
onMaximize?: () => void;
|
|
13
|
+
/** Opens Settings Modal on a specific tab */
|
|
14
|
+
onOpenSettings?: (tab: 'mcp') => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function AgentsPanel({ active, maximized, onMaximize, onOpenSettings }: AgentsPanelProps) {
|
|
18
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
19
|
+
const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState(false);
|
|
22
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
23
|
+
const [showNotDetected, setShowNotDetected] = useState(false);
|
|
24
|
+
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
25
|
+
|
|
26
|
+
const fetchAll = useCallback(async (silent = false) => {
|
|
27
|
+
if (!silent) setError(false);
|
|
28
|
+
try {
|
|
29
|
+
const [statusData, agentsData] = await Promise.all([
|
|
30
|
+
apiFetch<McpStatus>('/api/mcp/status'),
|
|
31
|
+
apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
|
|
32
|
+
]);
|
|
33
|
+
setMcpStatus(statusData);
|
|
34
|
+
setAgents(agentsData.agents);
|
|
35
|
+
setError(false);
|
|
36
|
+
} catch {
|
|
37
|
+
if (!silent) setError(true);
|
|
38
|
+
}
|
|
39
|
+
setLoading(false);
|
|
40
|
+
setRefreshing(false);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
// Fetch when panel becomes active + 30s auto-refresh
|
|
44
|
+
const prevActive = useRef(false);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (active && !prevActive.current) {
|
|
47
|
+
fetchAll();
|
|
48
|
+
}
|
|
49
|
+
prevActive.current = active;
|
|
50
|
+
}, [active, fetchAll]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!active) {
|
|
54
|
+
clearInterval(intervalRef.current);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
intervalRef.current = setInterval(() => fetchAll(true), 30_000);
|
|
58
|
+
return () => clearInterval(intervalRef.current);
|
|
59
|
+
}, [active, fetchAll]);
|
|
60
|
+
|
|
61
|
+
const handleRefresh = () => {
|
|
62
|
+
setRefreshing(true);
|
|
63
|
+
fetchAll();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Group agents
|
|
67
|
+
const connected = agents.filter(a => a.present && a.installed);
|
|
68
|
+
const detected = agents.filter(a => a.present && !a.installed);
|
|
69
|
+
const notFound = agents.filter(a => !a.present);
|
|
70
|
+
const connectedCount = connected.length;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
|
|
74
|
+
<PanelHeader title="Agents" maximized={maximized} onMaximize={onMaximize}>
|
|
75
|
+
<div className="flex items-center gap-1.5">
|
|
76
|
+
{!loading && (
|
|
77
|
+
<span className="text-2xs text-muted-foreground">{connectedCount} connected</span>
|
|
78
|
+
)}
|
|
79
|
+
<button
|
|
80
|
+
onClick={handleRefresh}
|
|
81
|
+
disabled={refreshing}
|
|
82
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-50 transition-colors"
|
|
83
|
+
aria-label="Refresh"
|
|
84
|
+
title="Refresh agent status"
|
|
85
|
+
>
|
|
86
|
+
<RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</PanelHeader>
|
|
90
|
+
|
|
91
|
+
<div className="flex-1 overflow-y-auto min-h-0">
|
|
92
|
+
{loading ? (
|
|
93
|
+
<div className="flex justify-center py-8">
|
|
94
|
+
<Loader2 size={16} className="animate-spin text-muted-foreground" />
|
|
95
|
+
</div>
|
|
96
|
+
) : error && agents.length === 0 ? (
|
|
97
|
+
<div className="flex flex-col items-center gap-2 py-8 text-center px-4">
|
|
98
|
+
<p className="text-xs text-destructive">Failed to load agents</p>
|
|
99
|
+
<button
|
|
100
|
+
onClick={handleRefresh}
|
|
101
|
+
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
102
|
+
>
|
|
103
|
+
<RefreshCw size={11} /> Retry
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="px-3 py-3 space-y-4">
|
|
108
|
+
{/* MCP Server Status — compact */}
|
|
109
|
+
<div className="rounded-lg border border-border bg-card/50 px-3 py-2.5 flex items-center justify-between">
|
|
110
|
+
<span className="text-xs font-medium text-foreground">MCP Server</span>
|
|
111
|
+
{mcpStatus?.running ? (
|
|
112
|
+
<span className="flex items-center gap-1.5 text-[11px]">
|
|
113
|
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block" />
|
|
114
|
+
<span className="text-emerald-600 dark:text-emerald-400">:{mcpStatus.port}</span>
|
|
115
|
+
</span>
|
|
116
|
+
) : (
|
|
117
|
+
<span className="flex items-center gap-1.5 text-[11px]">
|
|
118
|
+
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400 inline-block" />
|
|
119
|
+
<span className="text-muted-foreground">Stopped</span>
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Connected */}
|
|
125
|
+
{connected.length > 0 && (
|
|
126
|
+
<section>
|
|
127
|
+
<h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
|
128
|
+
Connected ({connected.length})
|
|
129
|
+
</h3>
|
|
130
|
+
<div className="space-y-1.5">
|
|
131
|
+
{connected.map(agent => (
|
|
132
|
+
<AgentCard key={agent.key} agent={agent} status="connected" />
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</section>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{/* Detected but not configured */}
|
|
139
|
+
{detected.length > 0 && (
|
|
140
|
+
<section>
|
|
141
|
+
<h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
|
142
|
+
Detected ({detected.length})
|
|
143
|
+
</h3>
|
|
144
|
+
<div className="space-y-1.5">
|
|
145
|
+
{detected.map(agent => (
|
|
146
|
+
<AgentCard
|
|
147
|
+
key={agent.key}
|
|
148
|
+
agent={agent}
|
|
149
|
+
status="detected"
|
|
150
|
+
onConnect={() => onOpenSettings?.('mcp')}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Not Detected — collapsible */}
|
|
158
|
+
{notFound.length > 0 && (
|
|
159
|
+
<section>
|
|
160
|
+
<button
|
|
161
|
+
onClick={() => setShowNotDetected(!showNotDetected)}
|
|
162
|
+
className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2 hover:text-foreground transition-colors"
|
|
163
|
+
>
|
|
164
|
+
{showNotDetected ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
165
|
+
Not Detected ({notFound.length})
|
|
166
|
+
</button>
|
|
167
|
+
{showNotDetected && (
|
|
168
|
+
<div className="space-y-1.5">
|
|
169
|
+
{notFound.map(agent => (
|
|
170
|
+
<AgentCard key={agent.key} agent={agent} status="notFound" />
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</section>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{/* Empty state */}
|
|
178
|
+
{agents.length === 0 && (
|
|
179
|
+
<p className="text-xs text-muted-foreground text-center py-4">
|
|
180
|
+
No agents detected.
|
|
181
|
+
</p>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Footer */}
|
|
188
|
+
<div className="px-3 py-2 border-t border-border shrink-0">
|
|
189
|
+
<p className="text-2xs text-muted-foreground/60">Auto-refresh every 30s</p>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ── Agent Card (panel-compact variant) ── */
|
|
196
|
+
|
|
197
|
+
function AgentCard({
|
|
198
|
+
agent,
|
|
199
|
+
status,
|
|
200
|
+
onConnect,
|
|
201
|
+
}: {
|
|
202
|
+
agent: AgentInfo;
|
|
203
|
+
status: 'connected' | 'detected' | 'notFound';
|
|
204
|
+
onConnect?: () => void;
|
|
205
|
+
}) {
|
|
206
|
+
const dot =
|
|
207
|
+
status === 'connected' ? 'bg-emerald-500' :
|
|
208
|
+
status === 'detected' ? 'bg-amber-500' :
|
|
209
|
+
'bg-zinc-400';
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className="rounded-lg border border-border/60 bg-card/30 px-3 py-2 flex items-center justify-between gap-2">
|
|
213
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
214
|
+
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} />
|
|
215
|
+
<span className="text-xs font-medium text-foreground truncate">{agent.name}</span>
|
|
216
|
+
{status === 'connected' && agent.transport && (
|
|
217
|
+
<span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{agent.transport}</span>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
{status === 'detected' && onConnect && (
|
|
221
|
+
<button
|
|
222
|
+
onClick={onConnect}
|
|
223
|
+
className="flex items-center gap-1 px-2 py-0.5 text-2xs rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-colors shrink-0"
|
|
224
|
+
>
|
|
225
|
+
Connect
|
|
226
|
+
<ExternalLink size={10} />
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|