@swarmclawai/swarmclaw 1.5.63 → 1.5.64
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/README.md +17 -0
- package/package.json +2 -2
- package/src/app/api/chats/[id]/clear/route.ts +7 -3
- package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
- package/src/app/api/chats/[id]/compact/route.ts +72 -0
- package/src/app/api/chats/[id]/context-status/route.ts +21 -0
- package/src/app/api/chats/clear-route.test.ts +121 -0
- package/src/app/api/chats/compact-route.test.ts +70 -0
- package/src/app/api/chats/context-status-route.test.ts +68 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/cli/index.js +5 -1
- package/src/cli/spec.js +4 -1
- package/src/components/chat/chat-area.tsx +62 -6
- package/src/components/chat/chat-header.tsx +13 -1
- package/src/components/chat/context-meter-badge.tsx +227 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +224 -0
- package/src/lib/chat/chats.ts +37 -1
- package/src/lib/server/chats/chat-session-service.ts +75 -0
- package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
- package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
- package/src/lib/server/mcp-connection-pool.test.ts +98 -0
- package/src/lib/server/mcp-connection-pool.ts +134 -0
- package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
- package/src/lib/server/mcp-gateway-runtime.ts +138 -0
- package/src/lib/server/session-tools/index.ts +83 -15
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/types/agent.ts +1 -0
- package/src/types/misc.ts +7 -0
|
@@ -6,7 +6,9 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
6
6
|
import { selectActiveSessionId } from '@/stores/slices/session-slice'
|
|
7
7
|
import { useWs } from '@/hooks/use-ws'
|
|
8
8
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
9
|
-
import { fetchMessages, fetchMessagesPaginated, clearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
|
|
9
|
+
import { fetchMessages, fetchMessagesPaginated, clearMessages, undoClearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
|
|
10
|
+
import { toast } from 'sonner'
|
|
11
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
10
12
|
import { uploadImage } from '@/lib/upload'
|
|
11
13
|
import { deleteAgent } from '@/lib/agents'
|
|
12
14
|
import { useMediaQuery } from '@/hooks/use-media-query'
|
|
@@ -432,11 +434,59 @@ export function ChatArea() {
|
|
|
432
434
|
const handleClear = useCallback(async () => {
|
|
433
435
|
setConfirmClear(false)
|
|
434
436
|
if (!sessionId) return
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
437
|
+
const targetSessionId = sessionId
|
|
438
|
+
let result
|
|
439
|
+
try {
|
|
440
|
+
result = await clearMessages(targetSessionId)
|
|
441
|
+
} catch (err) {
|
|
442
|
+
toast.error(`Clear failed: ${errorMessage(err)}`)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
|
|
446
|
+
setMessages([], { startIndex: 0, totalMessages: 0 })
|
|
447
|
+
}
|
|
448
|
+
await refreshSession(targetSessionId)
|
|
449
|
+
const { undoToken, cleared } = result
|
|
450
|
+
if (!undoToken) return
|
|
451
|
+
const clearedLabel = cleared === 1 ? '1 message cleared' : `${cleared.toLocaleString()} messages cleared`
|
|
452
|
+
toast(clearedLabel, {
|
|
453
|
+
duration: 10_000,
|
|
454
|
+
action: {
|
|
455
|
+
label: 'Undo',
|
|
456
|
+
onClick: async () => {
|
|
457
|
+
try {
|
|
458
|
+
await undoClearMessages(targetSessionId, undoToken)
|
|
459
|
+
const restored = await fetchMessages(targetSessionId)
|
|
460
|
+
if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
|
|
461
|
+
setMessages(restored, { startIndex: 0, totalMessages: restored.length })
|
|
462
|
+
}
|
|
463
|
+
await refreshSession(targetSessionId)
|
|
464
|
+
toast.success('Chat restored.')
|
|
465
|
+
} catch (err) {
|
|
466
|
+
toast.error(`Undo failed: ${errorMessage(err)}`)
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
})
|
|
438
471
|
}, [refreshSession, sessionId, setMessages])
|
|
439
472
|
|
|
473
|
+
const handleCompactComplete = useCallback(async () => {
|
|
474
|
+
if (!sessionId) return
|
|
475
|
+
const targetSessionId = sessionId
|
|
476
|
+
try {
|
|
477
|
+
const refreshed = await fetchMessages(targetSessionId)
|
|
478
|
+
if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
|
|
479
|
+
setMessages(refreshed, { startIndex: 0, totalMessages: refreshed.length })
|
|
480
|
+
}
|
|
481
|
+
} catch {
|
|
482
|
+
// silent — next poll will catch up
|
|
483
|
+
}
|
|
484
|
+
}, [sessionId, setMessages])
|
|
485
|
+
|
|
486
|
+
const handleClearRequest = useCallback(() => {
|
|
487
|
+
setConfirmClear(true)
|
|
488
|
+
}, [])
|
|
489
|
+
|
|
440
490
|
const handleDelete = useCallback(async () => {
|
|
441
491
|
setConfirmDelete(false)
|
|
442
492
|
if (!sessionId) return
|
|
@@ -515,6 +565,9 @@ export function ChatArea() {
|
|
|
515
565
|
connectorFilter={connectorFilter}
|
|
516
566
|
onConnectorFilterChange={setConnectorFilter}
|
|
517
567
|
hasMultipleSources={hasMultipleSources}
|
|
568
|
+
messageCount={messages.length}
|
|
569
|
+
onCompactComplete={handleCompactComplete}
|
|
570
|
+
onClearRequest={handleClearRequest}
|
|
518
571
|
/>
|
|
519
572
|
)}
|
|
520
573
|
{!isDesktop && (
|
|
@@ -533,6 +586,9 @@ export function ChatArea() {
|
|
|
533
586
|
connectorFilter={connectorFilter}
|
|
534
587
|
onConnectorFilterChange={setConnectorFilter}
|
|
535
588
|
hasMultipleSources={hasMultipleSources}
|
|
589
|
+
messageCount={messages.length}
|
|
590
|
+
onCompactComplete={handleCompactComplete}
|
|
591
|
+
onClearRequest={handleClearRequest}
|
|
536
592
|
/>
|
|
537
593
|
)}
|
|
538
594
|
<DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
|
|
@@ -660,8 +716,8 @@ export function ChatArea() {
|
|
|
660
716
|
|
|
661
717
|
<ConfirmDialog
|
|
662
718
|
open={confirmClear}
|
|
663
|
-
title="Clear
|
|
664
|
-
message="
|
|
719
|
+
title="Clear chat"
|
|
720
|
+
message="Clear every message in this chat. Long-term memory, skills, and facts are preserved. You'll have 10 seconds to undo."
|
|
665
721
|
confirmLabel="Clear"
|
|
666
722
|
danger
|
|
667
723
|
onConfirm={handleClear}
|
|
@@ -17,6 +17,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
|
|
|
17
17
|
import { copyTextToClipboard } from '@/lib/clipboard'
|
|
18
18
|
import { useNavigate } from '@/lib/app/navigation'
|
|
19
19
|
import { getEnabledToolIds } from '@/lib/capability-selection'
|
|
20
|
+
import { ContextMeterBadge } from './context-meter-badge'
|
|
20
21
|
|
|
21
22
|
function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
|
|
22
23
|
return (
|
|
@@ -80,9 +81,12 @@ interface Props {
|
|
|
80
81
|
connectorFilter?: string | null
|
|
81
82
|
onConnectorFilterChange?: (filter: string | null) => void
|
|
82
83
|
hasMultipleSources?: boolean
|
|
84
|
+
messageCount?: number
|
|
85
|
+
onCompactComplete?: () => void
|
|
86
|
+
onClearRequest?: () => void
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources }: Props) {
|
|
89
|
+
export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest }: Props) {
|
|
86
90
|
const now = useNow()
|
|
87
91
|
const agentStatus = useChatStore((s) => s.agentStatus)
|
|
88
92
|
const agents = useAppStore((s) => s.agents)
|
|
@@ -419,6 +423,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
419
423
|
Responding
|
|
420
424
|
</HeaderChip>
|
|
421
425
|
)}
|
|
426
|
+
{messageCount > 0 && onCompactComplete && onClearRequest && (
|
|
427
|
+
<ContextMeterBadge
|
|
428
|
+
sessionId={session.id}
|
|
429
|
+
messageCount={messageCount}
|
|
430
|
+
onCompactComplete={onCompactComplete}
|
|
431
|
+
onClearRequest={onClearRequest}
|
|
432
|
+
/>
|
|
433
|
+
)}
|
|
422
434
|
</div>
|
|
423
435
|
{liveStatus?.status && (
|
|
424
436
|
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import {
|
|
6
|
+
compactChat,
|
|
7
|
+
fetchContextStatus,
|
|
8
|
+
type ContextStatusResponse,
|
|
9
|
+
} from '@/lib/chat/chats'
|
|
10
|
+
import { useWs } from '@/hooks/use-ws'
|
|
11
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
sessionId: string
|
|
15
|
+
messageCount: number
|
|
16
|
+
onCompactComplete: () => void
|
|
17
|
+
onClearRequest: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatTokens(n: number): string {
|
|
21
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
22
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`
|
|
23
|
+
return `${n}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveColor(strategy: ContextStatusResponse['strategy']): {
|
|
27
|
+
dot: string
|
|
28
|
+
text: string
|
|
29
|
+
border: string
|
|
30
|
+
bg: string
|
|
31
|
+
} {
|
|
32
|
+
if (strategy === 'critical') {
|
|
33
|
+
return {
|
|
34
|
+
dot: 'bg-red-400',
|
|
35
|
+
text: 'text-red-300',
|
|
36
|
+
border: 'border-red-500/25',
|
|
37
|
+
bg: 'bg-red-500/10',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (strategy === 'warning') {
|
|
41
|
+
return {
|
|
42
|
+
dot: 'bg-amber-400',
|
|
43
|
+
text: 'text-amber-300',
|
|
44
|
+
border: 'border-amber-500/25',
|
|
45
|
+
bg: 'bg-amber-500/10',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
dot: 'bg-emerald-400/80',
|
|
50
|
+
text: 'text-text-3/70',
|
|
51
|
+
border: 'border-white/[0.06]',
|
|
52
|
+
bg: 'bg-white/[0.03]',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ContextMeterBadge({ sessionId, messageCount, onCompactComplete, onClearRequest }: Props) {
|
|
57
|
+
const [status, setStatus] = useState<ContextStatusResponse | null>(null)
|
|
58
|
+
const [open, setOpen] = useState(false)
|
|
59
|
+
const [compacting, setCompacting] = useState(false)
|
|
60
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
61
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
62
|
+
|
|
63
|
+
const loadStatus = useCallback(async () => {
|
|
64
|
+
try {
|
|
65
|
+
const next = await fetchContextStatus(sessionId)
|
|
66
|
+
setStatus(next)
|
|
67
|
+
} catch {
|
|
68
|
+
// silent — badge just won't render
|
|
69
|
+
}
|
|
70
|
+
}, [sessionId])
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
void loadStatus()
|
|
74
|
+
}, [loadStatus, messageCount])
|
|
75
|
+
|
|
76
|
+
useWs(`messages:${sessionId}`, loadStatus)
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open) return
|
|
80
|
+
const handler = (event: MouseEvent) => {
|
|
81
|
+
const target = event.target as Node
|
|
82
|
+
if (anchorRef.current?.contains(target)) return
|
|
83
|
+
if (panelRef.current?.contains(target)) return
|
|
84
|
+
setOpen(false)
|
|
85
|
+
}
|
|
86
|
+
document.addEventListener('mousedown', handler)
|
|
87
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
88
|
+
}, [open])
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!open) return
|
|
92
|
+
const handler = (event: KeyboardEvent) => {
|
|
93
|
+
if (event.key === 'Escape') setOpen(false)
|
|
94
|
+
}
|
|
95
|
+
document.addEventListener('keydown', handler)
|
|
96
|
+
return () => document.removeEventListener('keydown', handler)
|
|
97
|
+
}, [open])
|
|
98
|
+
|
|
99
|
+
const handleCompact = useCallback(async () => {
|
|
100
|
+
if (compacting) return
|
|
101
|
+
setCompacting(true)
|
|
102
|
+
try {
|
|
103
|
+
const result = await compactChat(sessionId)
|
|
104
|
+
if (result.status === 'no_action') {
|
|
105
|
+
toast('Nothing to compact yet.')
|
|
106
|
+
} else {
|
|
107
|
+
toast.success(
|
|
108
|
+
`Compacted ${result.prunedCount ?? 0} message${result.prunedCount === 1 ? '' : 's'}.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
onCompactComplete()
|
|
112
|
+
await loadStatus()
|
|
113
|
+
setOpen(false)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
toast.error(`Compact failed: ${errorMessage(err)}`)
|
|
116
|
+
} finally {
|
|
117
|
+
setCompacting(false)
|
|
118
|
+
}
|
|
119
|
+
}, [compacting, loadStatus, onCompactComplete, sessionId])
|
|
120
|
+
|
|
121
|
+
const handleClearClick = useCallback(() => {
|
|
122
|
+
setOpen(false)
|
|
123
|
+
onClearRequest()
|
|
124
|
+
}, [onClearRequest])
|
|
125
|
+
|
|
126
|
+
if (!status) return null
|
|
127
|
+
const colors = resolveColor(status.strategy)
|
|
128
|
+
const percent = Math.min(100, Math.max(0, status.percentUsed))
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="relative" ref={anchorRef}>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
135
|
+
className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1 text-[10px] font-600 transition-colors shrink-0 cursor-pointer ${colors.bg} ${colors.border} ${colors.text} hover:border-white/[0.15] hover:text-text-2`}
|
|
136
|
+
title={`${status.effectiveTokens.toLocaleString()} of ${status.contextWindow.toLocaleString()} tokens used`}
|
|
137
|
+
aria-expanded={open}
|
|
138
|
+
aria-label={`Context usage ${percent}%. Click for details.`}
|
|
139
|
+
>
|
|
140
|
+
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
|
141
|
+
<span>{percent}%</span>
|
|
142
|
+
<span className="text-text-3/45 font-500">
|
|
143
|
+
{formatTokens(status.effectiveTokens)}
|
|
144
|
+
</span>
|
|
145
|
+
</button>
|
|
146
|
+
{open && (
|
|
147
|
+
<div
|
|
148
|
+
ref={panelRef}
|
|
149
|
+
className="absolute right-0 top-[calc(100%+6px)] z-50 w-[280px] rounded-[14px] border border-white/[0.08] bg-raised/95 p-3 shadow-[0_16px_64px_rgba(0,0,0,0.6)] backdrop-blur-xl"
|
|
150
|
+
style={{ animation: 'fade-in 0.15s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
|
151
|
+
>
|
|
152
|
+
<div className="flex items-baseline justify-between">
|
|
153
|
+
<span className="text-[11px] font-600 uppercase tracking-wider text-text-3/60">Context window</span>
|
|
154
|
+
<span className={`text-[11px] font-600 ${colors.text}`}>{percent}%</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="mt-2 h-1.5 w-full rounded-full bg-white/[0.06] overflow-hidden">
|
|
157
|
+
<div
|
|
158
|
+
className={`h-full rounded-full transition-all ${
|
|
159
|
+
status.strategy === 'critical' ? 'bg-red-400'
|
|
160
|
+
: status.strategy === 'warning' ? 'bg-amber-400'
|
|
161
|
+
: 'bg-emerald-400/80'
|
|
162
|
+
}`}
|
|
163
|
+
style={{ width: `${percent}%` }}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
<dl className="mt-3 space-y-1 text-[11px]">
|
|
167
|
+
<div className="flex justify-between">
|
|
168
|
+
<dt className="text-text-3/60">Used</dt>
|
|
169
|
+
<dd className="text-text-2 font-mono">
|
|
170
|
+
{status.effectiveTokens.toLocaleString()} / {status.contextWindow.toLocaleString()}
|
|
171
|
+
</dd>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex justify-between">
|
|
174
|
+
<dt className="text-text-3/60">Remaining</dt>
|
|
175
|
+
<dd className="text-text-2 font-mono">{status.remainingTokens.toLocaleString()}</dd>
|
|
176
|
+
</div>
|
|
177
|
+
<div className="flex justify-between">
|
|
178
|
+
<dt className="text-text-3/60">Messages</dt>
|
|
179
|
+
<dd className="text-text-2 font-mono">{status.messageCount}</dd>
|
|
180
|
+
</div>
|
|
181
|
+
</dl>
|
|
182
|
+
<div className="mt-3 flex gap-2">
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={handleCompact}
|
|
186
|
+
disabled={compacting || status.messageCount < 3}
|
|
187
|
+
className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-accent-bright/25 bg-accent-soft/40 px-2.5 py-1.5 text-[11px] font-600 text-accent-bright transition-colors hover:border-accent-bright/40 hover:bg-accent-soft/60 disabled:cursor-not-allowed disabled:opacity-50"
|
|
188
|
+
>
|
|
189
|
+
{compacting ? (
|
|
190
|
+
<>
|
|
191
|
+
<span className="w-3 h-3 rounded-full border-2 border-accent-bright/30 border-t-accent-bright animate-spin" />
|
|
192
|
+
Compacting
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<>
|
|
196
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
197
|
+
<path d="M4 14h6v6" />
|
|
198
|
+
<path d="M20 10h-6V4" />
|
|
199
|
+
<path d="M14 10 21 3" />
|
|
200
|
+
<path d="M3 21l7-7" />
|
|
201
|
+
</svg>
|
|
202
|
+
Compact
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={handleClearClick}
|
|
209
|
+
className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-white/[0.08] bg-white/[0.03] px-2.5 py-1.5 text-[11px] font-600 text-text-2 transition-colors hover:border-red-500/25 hover:bg-red-500/10 hover:text-red-300"
|
|
210
|
+
>
|
|
211
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
212
|
+
<path d="M3 6h18" />
|
|
213
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
|
214
|
+
<path d="M10 11v6" />
|
|
215
|
+
<path d="M14 11v6" />
|
|
216
|
+
</svg>
|
|
217
|
+
Clear
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
<p className="mt-2.5 text-[10px] leading-relaxed text-text-3/55">
|
|
221
|
+
Long-term memory, skills, and facts are preserved. Clear only affects this chat transcript.
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
@@ -16,6 +16,7 @@ const transportColors: Record<string, string> = {
|
|
|
16
16
|
|
|
17
17
|
type McpStatus = { ok: boolean; tools?: string[]; error?: string; loading: boolean }
|
|
18
18
|
type McpToolMeta = { name: string; description?: string; inputSchema?: Record<string, unknown> }
|
|
19
|
+
type McpToolsInfo = { totalTokens: number; exposedTokens: number; toolCount: number; loading?: boolean; error?: string }
|
|
19
20
|
type McpInvokeResult = { ok: boolean; text?: string; error?: string; isError?: boolean; result?: unknown }
|
|
20
21
|
type McpConformanceIssue = { level: 'error' | 'warning'; code: string; message: string; toolName?: string }
|
|
21
22
|
type McpConformanceResult = {
|
|
@@ -56,6 +57,7 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
56
57
|
const setMcpServerSheetOpen = useAppStore((s) => s.setMcpServerSheetOpen)
|
|
57
58
|
const setEditingMcpServerId = useAppStore((s) => s.setEditingMcpServerId)
|
|
58
59
|
const [statuses, setStatuses] = useState<Record<string, McpStatus>>({})
|
|
60
|
+
const [toolsInfo, setToolsInfo] = useState<Record<string, McpToolsInfo>>({})
|
|
59
61
|
const [inspectorServerId, setInspectorServerId] = useState<string | null>(null)
|
|
60
62
|
const [toolsByServer, setToolsByServer] = useState<Record<string, McpToolMeta[]>>({})
|
|
61
63
|
const [inspectorLoading, setInspectorLoading] = useState(false)
|
|
@@ -111,6 +113,41 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
111
113
|
timersRef.current.push(timer)
|
|
112
114
|
})
|
|
113
115
|
|
|
116
|
+
// Tools-info — fetch in parallel with tests, staggered to avoid hammering
|
|
117
|
+
// every server at once. Piggybacks on the same connect that /test does,
|
|
118
|
+
// just produces richer data.
|
|
119
|
+
serverList.forEach((server, i) => {
|
|
120
|
+
setToolsInfo((prev) => ({ ...prev, [server.id]: { totalTokens: 0, exposedTokens: 0, toolCount: 0, loading: true } }))
|
|
121
|
+
const timer = setTimeout(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const res = await api<{ tools: Array<{ tokens: number }>; totalTokens: number; exposedTokens: number }>('GET', `/mcp-servers/${server.id}/tools-info`)
|
|
124
|
+
if (cancelled || !mountedRef.current) return
|
|
125
|
+
setToolsInfo((prev) => ({
|
|
126
|
+
...prev,
|
|
127
|
+
[server.id]: {
|
|
128
|
+
totalTokens: res.totalTokens,
|
|
129
|
+
exposedTokens: res.exposedTokens,
|
|
130
|
+
toolCount: res.tools?.length ?? 0,
|
|
131
|
+
loading: false,
|
|
132
|
+
},
|
|
133
|
+
}))
|
|
134
|
+
} catch (err: unknown) {
|
|
135
|
+
if (cancelled || !mountedRef.current) return
|
|
136
|
+
setToolsInfo((prev) => ({
|
|
137
|
+
...prev,
|
|
138
|
+
[server.id]: {
|
|
139
|
+
totalTokens: 0,
|
|
140
|
+
exposedTokens: 0,
|
|
141
|
+
toolCount: 0,
|
|
142
|
+
loading: false,
|
|
143
|
+
error: err instanceof Error ? err.message : 'fetch failed',
|
|
144
|
+
},
|
|
145
|
+
}))
|
|
146
|
+
}
|
|
147
|
+
}, i * 200 + 100)
|
|
148
|
+
timersRef.current.push(timer)
|
|
149
|
+
})
|
|
150
|
+
|
|
114
151
|
return () => {
|
|
115
152
|
cancelled = true
|
|
116
153
|
timersRef.current.forEach(clearTimeout)
|
|
@@ -472,6 +509,25 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
472
509
|
<p className="text-[12px] text-text-3/60 font-mono truncate">
|
|
473
510
|
{server.transport === 'stdio' ? server.command : server.url}
|
|
474
511
|
</p>
|
|
512
|
+
{(() => {
|
|
513
|
+
const info = toolsInfo[server.id]
|
|
514
|
+
if (!info || info.loading || info.error) return null
|
|
515
|
+
if (info.totalTokens === 0) return null
|
|
516
|
+
const savings = info.totalTokens > 0
|
|
517
|
+
? Math.round((1 - info.exposedTokens / info.totalTokens) * 100)
|
|
518
|
+
: 0
|
|
519
|
+
return (
|
|
520
|
+
<p className="mt-1 text-[11px] font-mono text-text-3/75">
|
|
521
|
+
<span className={info.exposedTokens < info.totalTokens ? 'text-emerald-400/80' : 'text-text-3'}>
|
|
522
|
+
{info.exposedTokens.toLocaleString()}
|
|
523
|
+
</span>
|
|
524
|
+
<span className="text-text-3/50"> / {info.totalTokens.toLocaleString()} tokens exposed</span>
|
|
525
|
+
{savings > 0 && (
|
|
526
|
+
<span className="ml-2 text-emerald-400/80">({savings}% saved)</span>
|
|
527
|
+
)}
|
|
528
|
+
</p>
|
|
529
|
+
)
|
|
530
|
+
})()}
|
|
475
531
|
{conformanceByServer[server.id] && (
|
|
476
532
|
<p className={`mt-1 text-[11px] ${conformanceByServer[server.id].ok ? 'text-emerald-300/80' : 'text-amber-300/80'}`}>
|
|
477
533
|
{conformanceByServer[server.id].ok
|