@swarmclawai/swarmclaw 1.5.63 → 1.5.65
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 +28 -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-registry/[slug]/route.ts +31 -0
- package/src/app/api/mcp-registry/route.ts +36 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +12 -1
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/app/api/mcp-servers/route.test.ts +10 -0
- package/src/cli/index.js +13 -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 +57 -1
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +219 -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 +127 -0
- package/src/lib/server/mcp-connection-pool.ts +150 -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 +114 -15
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/types/agent.ts +1 -0
- package/src/types/misc.ts +7 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { loadMcpServers } from '@/lib/server/storage'
|
|
3
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
|
+
import { connectMcpServer, disconnectMcpServer } from '@/lib/server/mcp-client'
|
|
5
|
+
|
|
6
|
+
// Tokenizer-free estimate — same formula as @swarmclawai/mcp-gateway's
|
|
7
|
+
// tokens.ts so the two numbers line up when users compare side-by-side.
|
|
8
|
+
const CHARS_PER_TOKEN = 3.5
|
|
9
|
+
|
|
10
|
+
function estimateToolTokens(tool: {
|
|
11
|
+
name: string
|
|
12
|
+
description?: string
|
|
13
|
+
inputSchema?: unknown
|
|
14
|
+
}): number {
|
|
15
|
+
const json = JSON.stringify({
|
|
16
|
+
name: tool.name,
|
|
17
|
+
description: tool.description ?? '',
|
|
18
|
+
inputSchema: tool.inputSchema ?? {},
|
|
19
|
+
})
|
|
20
|
+
return Math.ceil(json.length / CHARS_PER_TOKEN)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Discovery + token-cost endpoint for an MCP server. Connects, lists tools,
|
|
25
|
+
* estimates per-tool schema tokens, and returns aggregate totals — including
|
|
26
|
+
* how many tokens would actually be bound given the server's current
|
|
27
|
+
* alwaysExpose policy. The MCP servers UI uses this to render the token-cost
|
|
28
|
+
* badge on each card and the per-tool checklist in the allow-list editor.
|
|
29
|
+
*/
|
|
30
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
31
|
+
const { id } = await params
|
|
32
|
+
const servers = loadMcpServers()
|
|
33
|
+
const config = servers[id]
|
|
34
|
+
if (!config) return notFound()
|
|
35
|
+
|
|
36
|
+
let client: Awaited<ReturnType<typeof connectMcpServer>>['client'] | null = null
|
|
37
|
+
let transport: Awaited<ReturnType<typeof connectMcpServer>>['transport'] | null = null
|
|
38
|
+
try {
|
|
39
|
+
const conn = await connectMcpServer(config)
|
|
40
|
+
client = conn.client
|
|
41
|
+
transport = conn.transport
|
|
42
|
+
const { tools } = await client.listTools()
|
|
43
|
+
const detailed = tools.map((t: { name: string; description?: string; inputSchema?: unknown }) => ({
|
|
44
|
+
name: t.name,
|
|
45
|
+
description: t.description ?? '',
|
|
46
|
+
inputSchema: t.inputSchema ?? {},
|
|
47
|
+
tokens: estimateToolTokens(t),
|
|
48
|
+
}))
|
|
49
|
+
const totalTokens = detailed.reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
|
|
50
|
+
const mode = config.alwaysExpose === undefined ? true : config.alwaysExpose
|
|
51
|
+
const exposedTokens =
|
|
52
|
+
mode === true
|
|
53
|
+
? totalTokens
|
|
54
|
+
: mode === false
|
|
55
|
+
? 0
|
|
56
|
+
: detailed
|
|
57
|
+
.filter((t: { name: string }) => (mode as string[]).includes(t.name))
|
|
58
|
+
.reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
|
|
59
|
+
return NextResponse.json({
|
|
60
|
+
tools: detailed,
|
|
61
|
+
totalTokens,
|
|
62
|
+
exposedTokens,
|
|
63
|
+
alwaysExpose: mode,
|
|
64
|
+
})
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
return NextResponse.json(
|
|
67
|
+
{ error: err instanceof Error ? err.message : 'MCP connection failed' },
|
|
68
|
+
{ status: 502 },
|
|
69
|
+
)
|
|
70
|
+
} finally {
|
|
71
|
+
if (client && transport) {
|
|
72
|
+
await disconnectMcpServer(client, transport)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -61,6 +61,16 @@ test('MCP server routes exercise a live stdio server end to end', async () => {
|
|
|
61
61
|
assert.equal(health.ok, true)
|
|
62
62
|
assert.deepEqual(health.tools, ['mcp_smoke_ping', 'mcp_smoke_echo', 'mcp_smoke_cwd_check'])
|
|
63
63
|
|
|
64
|
+
// `reset=1` still works and succeeds — used by the explicit "Re-test" button
|
|
65
|
+
// to force pool eviction. Default (no query) path skips eviction so
|
|
66
|
+
// auto-probes don't disrupt in-flight agent MCP calls.
|
|
67
|
+
const resetHealthResponse = await testMcpServer(new Request(`http://local/api/mcp-servers/${serverId}/test?reset=1`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
}), routeParams(serverId))
|
|
70
|
+
assert.equal(resetHealthResponse.status, 200)
|
|
71
|
+
const resetHealth = await resetHealthResponse.json() as Record<string, unknown>
|
|
72
|
+
assert.equal(resetHealth.ok, true)
|
|
73
|
+
|
|
64
74
|
const toolsResponse = await listMcpTools(new Request(`http://local/api/mcp-servers/${serverId}/tools`), routeParams(serverId))
|
|
65
75
|
assert.equal(toolsResponse.status, 200)
|
|
66
76
|
const tools = await toolsResponse.json() as Array<Record<string, unknown>>
|
package/src/cli/index.js
CHANGED
|
@@ -367,10 +367,19 @@ const COMMAND_GROUPS = [
|
|
|
367
367
|
cmd('delete', 'DELETE', '/mcp-servers/:id', 'Delete MCP server'),
|
|
368
368
|
cmd('test', 'POST', '/mcp-servers/:id/test', 'Test MCP server connection'),
|
|
369
369
|
cmd('tools', 'GET', '/mcp-servers/:id/tools', 'List tools available on an MCP server'),
|
|
370
|
+
cmd('tools-info', 'GET', '/mcp-servers/:id/tools-info', 'List tools with token-cost estimates and exposure status'),
|
|
370
371
|
cmd('conformance', 'POST', '/mcp-servers/:id/conformance', 'Run MCP conformance checks for a server', { expectsJsonBody: true }),
|
|
371
372
|
cmd('invoke', 'POST', '/mcp-servers/:id/invoke', 'Invoke an MCP tool on a server', { expectsJsonBody: true }),
|
|
372
373
|
],
|
|
373
374
|
},
|
|
375
|
+
{
|
|
376
|
+
name: 'mcp-registry',
|
|
377
|
+
description: 'Browse the public SwarmDock MCP Registry',
|
|
378
|
+
commands: [
|
|
379
|
+
cmd('search', 'GET', '/mcp-registry', 'Search registry servers (supports --query q=postgres,limit=20)'),
|
|
380
|
+
cmd('get', 'GET', '/mcp-registry/:slug', 'Get registry server detail by slug'),
|
|
381
|
+
],
|
|
382
|
+
},
|
|
374
383
|
{
|
|
375
384
|
name: 'memories',
|
|
376
385
|
description: 'Alias of memory command group',
|
|
@@ -585,7 +594,10 @@ const COMMAND_GROUPS = [
|
|
|
585
594
|
responseType: 'sse',
|
|
586
595
|
}),
|
|
587
596
|
cmd('stop', 'POST', '/chats/:id/stop', 'Stop chat run(s)'),
|
|
588
|
-
cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages'),
|
|
597
|
+
cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages (returns undoToken with a 30s TTL)'),
|
|
598
|
+
cmd('clear-undo', 'POST', '/chats/:id/clear/undo', 'Restore a cleared chat via its undoToken', { expectsJsonBody: true }),
|
|
599
|
+
cmd('compact', 'POST', '/chats/:id/compact', 'Summarize and compact chat history (accepts optional keepLastN)', { expectsJsonBody: true }),
|
|
600
|
+
cmd('context-status', 'GET', '/chats/:id/context-status', 'Report token usage and context-window status for a chat'),
|
|
589
601
|
cmd('browser-status', 'GET', '/chats/:id/browser', 'Check browser status'),
|
|
590
602
|
cmd('browser-close', 'DELETE', '/chats/:id/browser', 'Close browser'),
|
|
591
603
|
cmd('mailbox', 'GET', '/chats/:id/mailbox', 'List chat mailbox envelopes'),
|
package/src/cli/spec.js
CHANGED
|
@@ -435,7 +435,10 @@ const COMMAND_GROUPS = {
|
|
|
435
435
|
'edit-resend': { description: 'Edit and resend from a specific message index', method: 'POST', path: '/chats/:id/edit-resend', params: ['id'] },
|
|
436
436
|
chat: { description: 'Send chat message (SSE stream)', method: 'POST', path: '/chats/:id/chat', params: ['id'], stream: true, waitable: true },
|
|
437
437
|
stop: { description: 'Cancel active/running chat work', method: 'POST', path: '/chats/:id/stop', params: ['id'] },
|
|
438
|
-
clear: { description: 'Clear chat history', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
|
|
438
|
+
clear: { description: 'Clear chat history (returns undoToken with 30s TTL)', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
|
|
439
|
+
'clear-undo': { description: 'Restore a cleared chat via its undoToken', method: 'POST', path: '/chats/:id/clear/undo', params: ['id'] },
|
|
440
|
+
compact: { description: 'Summarize and compact chat history', method: 'POST', path: '/chats/:id/compact', params: ['id'] },
|
|
441
|
+
'context-status': { description: 'Report token usage and context-window status', method: 'GET', path: '/chats/:id/context-status', params: ['id'] },
|
|
439
442
|
mailbox: { description: 'List mailbox envelopes for a chat', method: 'GET', path: '/chats/:id/mailbox', params: ['id'] },
|
|
440
443
|
'mailbox-action': { description: 'Send/ack/clear mailbox envelopes', method: 'POST', path: '/chats/:id/mailbox', params: ['id'] },
|
|
441
444
|
queue: { description: 'List queued follow-up turns for a chat', method: 'GET', path: '/chats/:id/queue', params: ['id'] },
|
|
@@ -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)
|
|
@@ -151,7 +188,7 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
151
188
|
e.stopPropagation()
|
|
152
189
|
setStatuses((prev) => ({ ...prev, [id]: { ok: false, loading: true } }))
|
|
153
190
|
try {
|
|
154
|
-
const res = await api<{ ok: boolean; tools?: string[]; error?: string }>('POST', `/mcp-servers/${id}/test`)
|
|
191
|
+
const res = await api<{ ok: boolean; tools?: string[]; error?: string }>('POST', `/mcp-servers/${id}/test?reset=1`)
|
|
155
192
|
if (!mountedRef.current) return
|
|
156
193
|
setStatuses((prev) => ({ ...prev, [id]: { ok: res.ok, tools: res.tools, error: res.error, loading: false } }))
|
|
157
194
|
if (res.ok) toast.success('Connection test passed')
|
|
@@ -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
|