@geminilight/mindos 0.5.21 → 0.5.22

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.
@@ -13,7 +13,10 @@ import {
13
13
  createTransformContext,
14
14
  } from '@/lib/agent/context';
15
15
  import { logAgentOp } from '@/lib/agent/log';
16
+ import { loadSkillRules } from '@/lib/agent/skill-rules';
16
17
  import { readSettings } from '@/lib/settings';
18
+ import { MindOSError, apiError, ErrorCodes } from '@/lib/errors';
19
+ import { metrics } from '@/lib/metrics';
17
20
  import { assertNotProtected } from '@/lib/core';
18
21
  import type { Message as FrontendMessage } from '@/lib/types';
19
22
 
@@ -160,7 +163,7 @@ export async function POST(req: NextRequest) {
160
163
  try {
161
164
  body = await req.json();
162
165
  } catch {
163
- return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
166
+ return apiError(ErrorCodes.INVALID_REQUEST, 'Invalid JSON body', 400);
164
167
  }
165
168
 
166
169
  const { messages, currentFile, attachedFiles, uploadedFiles } = body;
@@ -176,12 +179,18 @@ export async function POST(req: NextRequest) {
176
179
  const contextStrategy = agentConfig.contextStrategy ?? 'auto';
177
180
 
178
181
  // Auto-load skill + bootstrap context for each request.
179
- // TODO (optimization): Consider caching bootstrap files with TTL to reduce per-request IO overhead.
180
- // Current behavior: 8 synchronous file reads per request. For users with large knowledge bases,
181
- // this adds ~10-50ms latency. Mitigation: Cache with 5min TTL or lazy-load on-demand.
182
- const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
182
+ // 1. SKILL.md static trigger + protocol (always loaded)
183
+ // 2. skill-rules.md user's knowledge base operating rules (if exists)
184
+ // 3. user-rules.md user's personalized rules (if exists)
185
+ const isZh = serverSettings.disabledSkills?.includes('mindos') ?? false;
186
+ const skillDirName = isZh ? 'mindos-zh' : 'mindos';
187
+ const skillPath = path.resolve(process.cwd(), `data/skills/${skillDirName}/SKILL.md`);
183
188
  const skill = readAbsoluteFile(skillPath);
184
189
 
190
+ // Progressive skill loading: read skill-rules + user-rules from knowledge base
191
+ const mindRoot = getMindRoot();
192
+ const { skillRules, userRules } = loadSkillRules(mindRoot, skillDirName);
193
+
185
194
  const targetDir = dirnameOf(currentFile);
186
195
  const bootstrap = {
187
196
  instruction: readKnowledgeFile('INSTRUCTION.md'),
@@ -199,6 +208,8 @@ export async function POST(req: NextRequest) {
199
208
  const truncationWarnings: string[] = [];
200
209
  if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
201
210
  if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
211
+ if (skillRules.ok && skillRules.truncated) truncationWarnings.push('skill-rules.md was truncated');
212
+ if (userRules.ok && userRules.truncated) truncationWarnings.push('user-rules.md was truncated');
202
213
  if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
203
214
  if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
204
215
  if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
@@ -222,6 +233,13 @@ export async function POST(req: NextRequest) {
222
233
 
223
234
  const initContextBlocks: string[] = [];
224
235
  if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
236
+ // Progressive skill loading: inject skill-rules and user-rules after SKILL.md
237
+ if (skillRules.ok && !skillRules.empty) {
238
+ initContextBlocks.push(`## skill_rules\n\nOperating rules loaded from knowledge base (.agents/skills/${skillDirName}/skill-rules.md):\n\n${skillRules.content}`);
239
+ }
240
+ if (userRules.ok && !userRules.empty) {
241
+ initContextBlocks.push(`## user_rules\n\nUser personalization rules (.agents/skills/${skillDirName}/user-rules.md):\n\n${userRules.content}`);
242
+ }
225
243
  if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
226
244
  if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
227
245
  if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
@@ -381,6 +399,7 @@ export async function POST(req: NextRequest) {
381
399
 
382
400
  // ── SSE Stream ──
383
401
  const encoder = new TextEncoder();
402
+ const requestStartTime = Date.now();
384
403
  const stream = new ReadableStream({
385
404
  start(controller) {
386
405
  function send(event: MindOSSSEvent) {
@@ -404,6 +423,7 @@ export async function POST(req: NextRequest) {
404
423
  });
405
424
  } else if (isToolExecutionEndEvent(event)) {
406
425
  const { toolCallId, output, isError } = getToolExecutionEnd(event);
426
+ metrics.recordToolExecution();
407
427
  send({
408
428
  type: 'tool_end',
409
429
  toolCallId,
@@ -413,6 +433,12 @@ export async function POST(req: NextRequest) {
413
433
  } else if (isTurnEndEvent(event)) {
414
434
  stepCount++;
415
435
 
436
+ // Record token usage if available from the turn
437
+ const turnUsage = (event as any).usage;
438
+ if (turnUsage && typeof turnUsage.inputTokens === 'number') {
439
+ metrics.recordTokens(turnUsage.inputTokens, turnUsage.outputTokens ?? 0);
440
+ }
441
+
416
442
  // Track tool calls for loop detection (lock-free batch update).
417
443
  // Deterministic JSON.stringify ensures consistent input comparison.
418
444
  const { toolResults } = getTurnEndData(event);
@@ -452,9 +478,12 @@ export async function POST(req: NextRequest) {
452
478
  });
453
479
 
454
480
  agent.prompt(lastUserContent).then(() => {
481
+ metrics.recordRequest(Date.now() - requestStartTime);
455
482
  send({ type: 'done' });
456
483
  controller.close();
457
484
  }).catch((err) => {
485
+ metrics.recordRequest(Date.now() - requestStartTime);
486
+ metrics.recordError();
458
487
  send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
459
488
  controller.close();
460
489
  });
@@ -471,9 +500,9 @@ export async function POST(req: NextRequest) {
471
500
  });
472
501
  } catch (err) {
473
502
  console.error('[ask] Failed to initialize model:', err);
474
- return NextResponse.json(
475
- { error: err instanceof Error ? err.message : 'Failed to initialize AI model' },
476
- { status: 500 },
477
- );
503
+ if (err instanceof MindOSError) {
504
+ return apiError(err.code, err.message);
505
+ }
506
+ return apiError(ErrorCodes.MODEL_INIT_FAILED, err instanceof Error ? err.message : 'Failed to initialize AI model', 500);
478
507
  }
479
508
  }
@@ -0,0 +1,95 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { getMindRoot } from '@/lib/fs';
6
+ import { metrics } from '@/lib/metrics';
7
+
8
+ // Aligned with IGNORED_DIRS in lib/fs.ts and lib/core/tree.ts
9
+ const IGNORED_DIRS = new Set(['.git', 'node_modules', 'app', '.next', '.DS_Store', 'mcp']);
10
+
11
+ /**
12
+ * Recursively count files and sum their sizes under a directory.
13
+ * Skips directories in IGNORED_DIRS (aligned with the rest of the codebase).
14
+ */
15
+ function walkStats(dir: string): { fileCount: number; totalSizeBytes: number } {
16
+ let fileCount = 0;
17
+ let totalSizeBytes = 0;
18
+
19
+ function walk(current: string) {
20
+ let entries: fs.Dirent[];
21
+ try {
22
+ entries = fs.readdirSync(current, { withFileTypes: true });
23
+ } catch {
24
+ return;
25
+ }
26
+ for (const entry of entries) {
27
+ if (IGNORED_DIRS.has(entry.name)) continue;
28
+ const fullPath = path.join(current, entry.name);
29
+ if (entry.isDirectory()) {
30
+ walk(fullPath);
31
+ } else if (entry.isFile()) {
32
+ try {
33
+ const stat = fs.statSync(fullPath);
34
+ fileCount++;
35
+ totalSizeBytes += stat.size;
36
+ } catch { /* skip unreadable files */ }
37
+ }
38
+ }
39
+ }
40
+
41
+ walk(dir);
42
+ return { fileCount, totalSizeBytes };
43
+ }
44
+
45
+ // ── TTL cache for walkStats (avoid blocking event loop every 5s poll) ──
46
+ let cachedKbStats: { fileCount: number; totalSizeBytes: number } | null = null;
47
+ let cachedKbStatsTs = 0;
48
+ const KB_STATS_TTL = 30_000; // 30s
49
+
50
+ function getCachedKbStats(mindRoot: string): { fileCount: number; totalSizeBytes: number } {
51
+ const now = Date.now();
52
+ if (cachedKbStats && now - cachedKbStatsTs < KB_STATS_TTL) return cachedKbStats;
53
+ cachedKbStats = walkStats(mindRoot);
54
+ cachedKbStatsTs = now;
55
+ return cachedKbStats;
56
+ }
57
+
58
+ export async function GET() {
59
+ const snap = metrics.getSnapshot();
60
+ const mem = process.memoryUsage();
61
+ const mindRoot = getMindRoot();
62
+
63
+ const kbStats = getCachedKbStats(mindRoot);
64
+
65
+ // Detect MCP status from environment / config
66
+ const mcpPort = Number(process.env.MCP_PORT) || 3457;
67
+
68
+ return NextResponse.json({
69
+ system: {
70
+ uptimeMs: Date.now() - snap.processStartTime,
71
+ memory: {
72
+ heapUsed: mem.heapUsed,
73
+ heapTotal: mem.heapTotal,
74
+ rss: mem.rss,
75
+ },
76
+ nodeVersion: process.version,
77
+ },
78
+ application: {
79
+ agentRequests: snap.agentRequests,
80
+ toolExecutions: snap.toolExecutions,
81
+ totalTokens: snap.totalTokens,
82
+ avgResponseTimeMs: snap.avgResponseTimeMs,
83
+ errors: snap.errors,
84
+ },
85
+ knowledgeBase: {
86
+ root: mindRoot,
87
+ fileCount: kbStats.fileCount,
88
+ totalSizeBytes: kbStats.totalSizeBytes,
89
+ },
90
+ mcp: {
91
+ running: true, // If this endpoint responds, the server is running
92
+ port: mcpPort,
93
+ },
94
+ });
95
+ }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState, useCallback, useRef } from 'react';
4
- import { X, Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Puzzle } from 'lucide-react';
4
+ import { X, Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Puzzle, Activity, Users } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import { getAllRenderers, loadDisabledState, isRendererEnabled } from '@/lib/renderers/registry';
7
7
  import { apiFetch } from '@/lib/api';
@@ -14,6 +14,8 @@ import { KnowledgeTab } from './settings/KnowledgeTab';
14
14
  import { PluginsTab } from './settings/PluginsTab';
15
15
  import { SyncTab } from './settings/SyncTab';
16
16
  import { McpTab } from './settings/McpTab';
17
+ import { MonitoringTab } from './settings/MonitoringTab';
18
+ import { AgentsTab } from './settings/AgentsTab';
17
19
 
18
20
  interface SettingsModalProps {
19
21
  open: boolean;
@@ -147,6 +149,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
147
149
  { id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={13} /> },
148
150
  { id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP', icon: <Plug size={13} /> },
149
151
  { id: 'plugins', label: t.settings.tabs.plugins, icon: <Puzzle size={13} /> },
152
+ { id: 'monitoring', label: t.settings.tabs.monitoring, icon: <Activity size={13} /> },
153
+ { id: 'agents', label: t.settings.tabs.agents ?? 'Agents', icon: <Users size={13} /> },
150
154
  ];
151
155
 
152
156
  return (
@@ -197,7 +201,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
197
201
  <p className="text-sm text-destructive font-medium">Failed to load settings</p>
198
202
  <p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>
199
203
  </div>
200
- ) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
204
+ ) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' && tab !== 'agents' ? (
201
205
  <div className="flex justify-center py-8">
202
206
  <Loader2 size={18} className="animate-spin text-muted-foreground" />
203
207
  </div>
@@ -209,6 +213,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
209
213
  {tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
210
214
  {tab === 'sync' && <SyncTab t={t} />}
211
215
  {tab === 'mcp' && <McpTab t={t} />}
216
+ {tab === 'monitoring' && <MonitoringTab t={t} />}
217
+ {tab === 'agents' && <AgentsTab t={t} />}
212
218
  </>
213
219
  )}
214
220
  </div>
@@ -0,0 +1,240 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { Loader2, RefreshCw, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
3
+ import { apiFetch } from '@/lib/api';
4
+ import type { McpStatus, AgentInfo } from './types';
5
+ import type { Messages } from '@/lib/i18n';
6
+
7
+ interface AgentsTabProps {
8
+ t: Messages;
9
+ }
10
+
11
+ export function AgentsTab({ t }: AgentsTabProps) {
12
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
13
+ const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
14
+ const [loading, setLoading] = useState(true);
15
+ const [error, setError] = useState(false);
16
+ const [refreshing, setRefreshing] = useState(false);
17
+ const [showNotDetected, setShowNotDetected] = useState(false);
18
+ const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
19
+
20
+ const a = t.settings?.agents as Record<string, unknown> | undefined;
21
+
22
+ // i18n helpers with fallbacks
23
+ const txt = (key: string, fallback: string) => (a?.[key] as string) ?? fallback;
24
+ const txtFn = <T,>(key: string, fallback: (v: T) => string) =>
25
+ (a?.[key] as ((v: T) => string) | undefined) ?? fallback;
26
+
27
+ const fetchAll = useCallback(async (silent = false) => {
28
+ if (!silent) setError(false);
29
+ try {
30
+ const [statusData, agentsData] = await Promise.all([
31
+ apiFetch<McpStatus>('/api/mcp/status'),
32
+ apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
33
+ ]);
34
+ setMcpStatus(statusData);
35
+ setAgents(agentsData.agents);
36
+ setError(false);
37
+ } catch {
38
+ if (!silent) setError(true);
39
+ }
40
+ setLoading(false);
41
+ setRefreshing(false);
42
+ }, []);
43
+
44
+ // Initial fetch + 30s auto-refresh
45
+ useEffect(() => {
46
+ fetchAll();
47
+ intervalRef.current = setInterval(() => fetchAll(true), 30_000);
48
+ return () => clearInterval(intervalRef.current);
49
+ }, [fetchAll]);
50
+
51
+ const handleRefresh = () => {
52
+ setRefreshing(true);
53
+ fetchAll();
54
+ };
55
+
56
+ if (loading) {
57
+ return (
58
+ <div className="flex justify-center py-8">
59
+ <Loader2 size={18} className="animate-spin text-muted-foreground" />
60
+ </div>
61
+ );
62
+ }
63
+
64
+ if (error && agents.length === 0) {
65
+ return (
66
+ <div className="flex flex-col items-center gap-3 py-8 text-center">
67
+ <p className="text-sm text-destructive">{txt('fetchError', 'Failed to load agent data')}</p>
68
+ <button
69
+ onClick={handleRefresh}
70
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
71
+ >
72
+ <RefreshCw size={12} />
73
+ {txt('refresh', 'Refresh')}
74
+ </button>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // Group agents
80
+ const connected = agents.filter(a => a.present && a.installed);
81
+ const detected = agents.filter(a => a.present && !a.installed);
82
+ const notFound = agents.filter(a => !a.present);
83
+
84
+ return (
85
+ <div className="space-y-5">
86
+ {/* MCP Server Status */}
87
+ <div className="rounded-xl border border-border bg-card p-4 flex items-center justify-between">
88
+ <div className="flex items-center gap-3">
89
+ <span className="text-sm font-medium text-foreground">{txt('mcpServer', 'MCP Server')}</span>
90
+ {mcpStatus?.running ? (
91
+ <span className="flex items-center gap-1.5 text-xs">
92
+ <span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" />
93
+ <span className="text-emerald-600 dark:text-emerald-400">
94
+ {txt('running', 'Running')} {txtFn<number>('onPort', (p) => `on :${p}`)(mcpStatus.port)}
95
+ </span>
96
+ </span>
97
+ ) : (
98
+ <span className="flex items-center gap-1.5 text-xs">
99
+ <span className="w-2 h-2 rounded-full bg-zinc-400 inline-block" />
100
+ <span className="text-muted-foreground">{txt('stopped', 'Not running')}</span>
101
+ </span>
102
+ )}
103
+ </div>
104
+ <button
105
+ onClick={handleRefresh}
106
+ disabled={refreshing}
107
+ 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 disabled:opacity-50 transition-colors"
108
+ >
109
+ <RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
110
+ {refreshing ? txt('refreshing', 'Refreshing...') : txt('refresh', 'Refresh')}
111
+ </button>
112
+ </div>
113
+
114
+ {/* Connected Agents */}
115
+ {connected.length > 0 && (
116
+ <section>
117
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
118
+ {txtFn<number>('connectedCount', (n) => `Connected (${n})`)(connected.length)}
119
+ </h3>
120
+ <div className="space-y-2">
121
+ {connected.map(agent => (
122
+ <AgentCard key={agent.key} agent={agent} status="connected" />
123
+ ))}
124
+ </div>
125
+ </section>
126
+ )}
127
+
128
+ {/* Detected but not configured */}
129
+ {detected.length > 0 && (
130
+ <section>
131
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
132
+ {txtFn<number>('detectedCount', (n) => `Detected but not configured (${n})`)(detected.length)}
133
+ </h3>
134
+ <div className="space-y-2">
135
+ {detected.map(agent => (
136
+ <AgentCard
137
+ key={agent.key}
138
+ agent={agent}
139
+ status="detected"
140
+ connectLabel={txt('connect', 'Connect')}
141
+ />
142
+ ))}
143
+ </div>
144
+ </section>
145
+ )}
146
+
147
+ {/* Not Detected */}
148
+ {notFound.length > 0 && (
149
+ <section>
150
+ <button
151
+ onClick={() => setShowNotDetected(!showNotDetected)}
152
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 hover:text-foreground transition-colors"
153
+ >
154
+ {showNotDetected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
155
+ {txtFn<number>('notDetectedCount', (n) => `Not Detected (${n})`)(notFound.length)}
156
+ </button>
157
+ {showNotDetected && (
158
+ <div className="space-y-2">
159
+ {notFound.map(agent => (
160
+ <AgentCard key={agent.key} agent={agent} status="notFound" />
161
+ ))}
162
+ </div>
163
+ )}
164
+ </section>
165
+ )}
166
+
167
+ {/* Empty state */}
168
+ {agents.length === 0 && (
169
+ <p className="text-sm text-muted-foreground text-center py-4">
170
+ {txt('noAgents', 'No agents detected on this machine.')}
171
+ </p>
172
+ )}
173
+
174
+ {/* Auto-refresh hint */}
175
+ <p className="text-[10px] text-muted-foreground/60 text-center">
176
+ {txt('autoRefresh', 'Auto-refresh every 30s')}
177
+ </p>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ /* ── Agent Card ──────────────────────────────────────────────── */
183
+
184
+ function AgentCard({
185
+ agent,
186
+ status,
187
+ connectLabel,
188
+ }: {
189
+ agent: AgentInfo;
190
+ status: 'connected' | 'detected' | 'notFound';
191
+ connectLabel?: string;
192
+ }) {
193
+ const dot =
194
+ status === 'connected' ? 'bg-emerald-500' :
195
+ status === 'detected' ? 'bg-amber-500' :
196
+ 'bg-zinc-400';
197
+
198
+ return (
199
+ <div className="rounded-lg border border-border bg-card/50 px-4 py-3 flex items-center justify-between gap-3">
200
+ <div className="flex items-center gap-3 min-w-0">
201
+ <span className={`w-2 h-2 rounded-full shrink-0 ${dot}`} />
202
+ <span className="text-sm font-medium text-foreground truncate">{agent.name}</span>
203
+ {status === 'connected' && (
204
+ <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
205
+ <span className="px-1.5 py-0.5 rounded bg-muted">{agent.transport}</span>
206
+ <span className="px-1.5 py-0.5 rounded bg-muted">{agent.scope}</span>
207
+ {agent.configPath && (
208
+ <span className="truncate max-w-[200px]" title={agent.configPath}>
209
+ {agent.configPath.replace(/^.*[/\\]/, '')}
210
+ </span>
211
+ )}
212
+ </div>
213
+ )}
214
+ </div>
215
+ {status === 'detected' && (
216
+ <a
217
+ href="#"
218
+ onClick={(e) => {
219
+ e.preventDefault();
220
+ // Navigate to MCP tab by dispatching a custom event
221
+ const settingsModal = document.querySelector('[role="dialog"][aria-label="Settings"]');
222
+ if (settingsModal) {
223
+ const mcpBtn = settingsModal.querySelectorAll('button');
224
+ for (const btn of mcpBtn) {
225
+ if (btn.textContent?.trim() === 'MCP') {
226
+ btn.click();
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ }}
232
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-colors shrink-0"
233
+ >
234
+ {connectLabel ?? 'Connect'}
235
+ <ExternalLink size={11} />
236
+ </a>
237
+ )}
238
+ </div>
239
+ );
240
+ }