@geminilight/mindos 0.6.17 → 0.6.19

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.
@@ -27,6 +27,24 @@ import { assertNotProtected } from '@/lib/core';
27
27
  import { scanExtensionPaths } from '@/lib/pi-integration/extensions';
28
28
  import type { Message as FrontendMessage } from '@/lib/types';
29
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Streaming blacklist — caches provider+model combos that don't support SSE.
32
+ // Auto-populated when streaming fails; entries expire after 10 minutes
33
+ // so transient proxy issues don't permanently lock out streaming.
34
+ // ---------------------------------------------------------------------------
35
+ const streamingBlacklist = new Map<string, number>();
36
+ const STREAMING_BLACKLIST_TTL = 10 * 60 * 1000;
37
+
38
+ function isStreamingBlacklisted(key: string): boolean {
39
+ const ts = streamingBlacklist.get(key);
40
+ if (ts === undefined) return false;
41
+ if (Date.now() - ts > STREAMING_BLACKLIST_TTL) {
42
+ streamingBlacklist.delete(key);
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+
30
48
  // ---------------------------------------------------------------------------
31
49
  // MindOS SSE format — 6 event types (front-back contract)
32
50
  // ---------------------------------------------------------------------------
@@ -407,9 +425,25 @@ export async function POST(req: NextRequest) {
407
425
 
408
426
  const systemPrompt = promptParts.join('\n\n');
409
427
 
428
+ const useStreaming = agentConfig.useStreaming !== false;
429
+
410
430
  try {
411
431
  const { model, modelName, apiKey, provider } = getModelConfig();
412
432
 
433
+ // ── Non-streaming path (auto-detected or cached) ──
434
+ // When test-key detected streaming incompatibility, or a previous request
435
+ // failed and cached the result, go directly to non-streaming.
436
+ const cacheKey = `${provider}:${model.id}:${model.baseUrl ?? ''}`;
437
+ if (!useStreaming || isStreamingBlacklisted(cacheKey)) {
438
+ if (isStreamingBlacklisted(cacheKey)) {
439
+ console.log(`[ask] Using non-streaming mode (cached failure for ${cacheKey})`);
440
+ }
441
+ return await handleNonStreaming({
442
+ provider, apiKey, model, systemPrompt, messages, modelName,
443
+ });
444
+ }
445
+
446
+ // ── Streaming path (default) ──
413
447
  // Convert frontend messages to AgentMessage[]
414
448
  const agentMessages = toAgentMessages(messages);
415
449
 
@@ -492,12 +526,18 @@ export async function POST(req: NextRequest) {
492
526
  }
493
527
  }
494
528
 
529
+ let hasContent = false;
530
+ let lastModelError = '';
531
+
495
532
  session.subscribe((event: AgentEvent) => {
496
533
  if (isTextDeltaEvent(event)) {
534
+ hasContent = true;
497
535
  send({ type: 'text_delta', delta: getTextDelta(event) });
498
536
  } else if (isThinkingDeltaEvent(event)) {
537
+ hasContent = true;
499
538
  send({ type: 'thinking_delta', delta: getThinkingDelta(event) });
500
539
  } else if (isToolExecutionStartEvent(event)) {
540
+ hasContent = true;
501
541
  const { toolCallId, toolName, args } = getToolExecutionStart(event);
502
542
  const safeArgs = sanitizeToolArgs(toolName, args);
503
543
  send({
@@ -555,12 +595,47 @@ export async function POST(req: NextRequest) {
555
595
  }
556
596
 
557
597
  console.log(`[ask] Step ${stepCount}/${stepLimit}`);
598
+ } else if (event.type === 'agent_end') {
599
+ // Capture model errors from the last assistant message.
600
+ // pi-coding-agent resolves prompt() without throwing after retries;
601
+ // the error is only visible in agent_end event messages.
602
+ const msgs = (event as any).messages;
603
+ if (Array.isArray(msgs)) {
604
+ for (let i = msgs.length - 1; i >= 0; i--) {
605
+ const m = msgs[i];
606
+ if (m?.role === 'assistant' && m?.stopReason === 'error' && m?.errorMessage) {
607
+ lastModelError = m.errorMessage;
608
+ break;
609
+ }
610
+ }
611
+ }
558
612
  }
559
613
  });
560
614
 
561
- session.prompt(lastUserContent).then(() => {
615
+ session.prompt(lastUserContent).then(async () => {
562
616
  metrics.recordRequest(Date.now() - requestStartTime);
563
- send({ type: 'done' });
617
+ if (!hasContent && lastModelError) {
618
+ // Streaming failed — auto-retry with non-streaming fallback.
619
+ // Cache the failure so subsequent requests skip streaming entirely.
620
+ console.warn(`[ask] Streaming failed for ${modelName}, retrying non-streaming: ${lastModelError}`);
621
+ streamingBlacklist.set(cacheKey, Date.now());
622
+ // No visible hint needed — the fallback is transparent to the user
623
+ try {
624
+ const fallbackResult = await directNonStreamingCall({
625
+ provider, apiKey, model, systemPrompt, messages, modelName,
626
+ });
627
+ if (fallbackResult) {
628
+ send({ type: 'text_delta', delta: fallbackResult });
629
+ send({ type: 'done' });
630
+ } else {
631
+ send({ type: 'error', message: lastModelError });
632
+ }
633
+ } catch (fallbackErr) {
634
+ send({ type: 'error', message: lastModelError });
635
+ }
636
+ } else {
637
+ send({ type: 'done' });
638
+ }
564
639
  controller.close();
565
640
  }).catch((err) => {
566
641
  metrics.recordRequest(Date.now() - requestStartTime);
@@ -587,3 +662,146 @@ export async function POST(req: NextRequest) {
587
662
  return apiError(ErrorCodes.MODEL_INIT_FAILED, err instanceof Error ? err.message : 'Failed to initialize AI model', 500);
588
663
  }
589
664
  }
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // Non-streaming — direct /chat/completions call (no SSE, no tools)
668
+ // ---------------------------------------------------------------------------
669
+
670
+ interface NonStreamingOpts {
671
+ provider: 'anthropic' | 'openai';
672
+ apiKey: string;
673
+ model: { id: string; baseUrl?: string; maxTokens?: number };
674
+ systemPrompt: string;
675
+ messages: FrontendMessage[];
676
+ modelName: string;
677
+ }
678
+
679
+ /**
680
+ * Core non-streaming API call. Returns the response text or throws.
681
+ * Used by both the direct non-streaming path and the auto-fallback.
682
+ */
683
+ async function directNonStreamingCall(opts: NonStreamingOpts): Promise<string> {
684
+ const { provider, apiKey, model, systemPrompt, messages, modelName } = opts;
685
+ const ctrl = new AbortController();
686
+ const timeout = setTimeout(() => ctrl.abort(), 120_000);
687
+
688
+ try {
689
+ if (provider === 'openai') {
690
+ const baseUrl = (model.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
691
+ const url = `${baseUrl}/chat/completions`;
692
+ const apiMessages = [
693
+ { role: 'system', content: systemPrompt },
694
+ ...messages.map(m => ({ role: m.role, content: m.content })),
695
+ ];
696
+
697
+ const res = await fetch(url, {
698
+ method: 'POST',
699
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
700
+ body: JSON.stringify({
701
+ model: model.id,
702
+ messages: apiMessages,
703
+ stream: false,
704
+ max_tokens: model.maxTokens ?? 16_384,
705
+ }),
706
+ signal: ctrl.signal,
707
+ });
708
+
709
+ if (!res.ok) {
710
+ const body = await res.text().catch(() => '');
711
+ throw new Error(`API returned ${res.status}: ${body.slice(0, 500)}`);
712
+ }
713
+
714
+ const json = await res.json();
715
+ return json?.choices?.[0]?.message?.content ?? '';
716
+ }
717
+
718
+ // Anthropic
719
+ const url = 'https://api.anthropic.com/v1/messages';
720
+ const apiMessages = messages.map(m => ({ role: m.role, content: m.content }));
721
+
722
+ const res = await fetch(url, {
723
+ method: 'POST',
724
+ headers: {
725
+ 'Content-Type': 'application/json',
726
+ 'x-api-key': apiKey,
727
+ 'anthropic-version': '2023-06-01',
728
+ },
729
+ body: JSON.stringify({
730
+ model: model.id,
731
+ system: systemPrompt,
732
+ messages: apiMessages,
733
+ max_tokens: model.maxTokens ?? 8_192,
734
+ }),
735
+ signal: ctrl.signal,
736
+ });
737
+
738
+ if (!res.ok) {
739
+ const body = await res.text().catch(() => '');
740
+ throw new Error(`API returned ${res.status}: ${body.slice(0, 500)}`);
741
+ }
742
+
743
+ const json = await res.json();
744
+ const blocks = json?.content;
745
+ if (Array.isArray(blocks)) {
746
+ return blocks.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('');
747
+ }
748
+ return '';
749
+ } finally {
750
+ clearTimeout(timeout);
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Full non-streaming response handler — wraps directNonStreamingCall
756
+ * and returns an SSE-formatted Response for the client.
757
+ */
758
+ async function handleNonStreaming(opts: NonStreamingOpts): Promise<Response> {
759
+ const { modelName } = opts;
760
+ const requestStartTime = Date.now();
761
+ const encoder = new TextEncoder();
762
+
763
+ try {
764
+ const text = await directNonStreamingCall(opts);
765
+ metrics.recordRequest(Date.now() - requestStartTime);
766
+
767
+ if (!text) {
768
+ metrics.recordError();
769
+ return sseResponse(encoder, { type: 'error', message: `[non-streaming] ${modelName} returned empty response` });
770
+ }
771
+
772
+ console.log(`[ask] Non-streaming response from ${modelName}: ${text.length} chars`);
773
+ const stream = new ReadableStream({
774
+ start(controller) {
775
+ controller.enqueue(encoder.encode(`data:${JSON.stringify({ type: 'text_delta', delta: text })}\n\n`));
776
+ controller.enqueue(encoder.encode(`data:${JSON.stringify({ type: 'done' })}\n\n`));
777
+ controller.close();
778
+ },
779
+ });
780
+ return new Response(stream, { headers: sseHeaders() });
781
+ } catch (err) {
782
+ metrics.recordRequest(Date.now() - requestStartTime);
783
+ metrics.recordError();
784
+ const message = err instanceof Error ? err.message : String(err);
785
+ console.error(`[ask] Non-streaming request failed:`, message);
786
+ return sseResponse(encoder, { type: 'error', message });
787
+ }
788
+ }
789
+
790
+ function sseResponse(encoder: TextEncoder, event: MindOSSSEvent): Response {
791
+ const stream = new ReadableStream({
792
+ start(controller) {
793
+ controller.enqueue(encoder.encode(`data:${JSON.stringify(event)}\n\n`));
794
+ controller.close();
795
+ },
796
+ });
797
+ return new Response(stream, { headers: sseHeaders() });
798
+ }
799
+
800
+ function sseHeaders(): HeadersInit {
801
+ return {
802
+ 'Content-Type': 'text/event-stream',
803
+ 'Cache-Control': 'no-cache, no-transform',
804
+ 'Connection': 'keep-alive',
805
+ 'X-Accel-Buffering': 'no',
806
+ };
807
+ }
@@ -0,0 +1,96 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import { effectiveAiConfig } from '@/lib/settings';
4
+
5
+ const TIMEOUT = 10_000;
6
+
7
+ export async function POST(req: NextRequest) {
8
+ try {
9
+ const body = await req.json();
10
+ const { provider, apiKey, baseUrl } = body as {
11
+ provider?: string;
12
+ apiKey?: string;
13
+ baseUrl?: string;
14
+ };
15
+
16
+ if (provider !== 'anthropic' && provider !== 'openai') {
17
+ return NextResponse.json({ ok: false, error: 'Invalid provider' }, { status: 400 });
18
+ }
19
+
20
+ const cfg = effectiveAiConfig();
21
+ let resolvedKey = apiKey || '';
22
+ if (!resolvedKey || resolvedKey === '***set***') {
23
+ resolvedKey = provider === 'anthropic' ? cfg.anthropicApiKey : cfg.openaiApiKey;
24
+ }
25
+
26
+ if (!resolvedKey) {
27
+ return NextResponse.json({ ok: false, error: 'No API key configured' });
28
+ }
29
+
30
+ const ctrl = new AbortController();
31
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
32
+
33
+ try {
34
+ let models: string[] = [];
35
+
36
+ if (provider === 'openai') {
37
+ const resolvedBaseUrl = (baseUrl || cfg.openaiBaseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
38
+ const res = await fetch(`${resolvedBaseUrl}/models`, {
39
+ headers: { Authorization: `Bearer ${resolvedKey}` },
40
+ signal: ctrl.signal,
41
+ });
42
+
43
+ if (!res.ok) {
44
+ const errBody = await res.text().catch(() => '');
45
+ return NextResponse.json({
46
+ ok: false,
47
+ error: `Failed to list models: HTTP ${res.status} ${errBody.slice(0, 200)}`,
48
+ });
49
+ }
50
+
51
+ const json = await res.json();
52
+ if (Array.isArray(json?.data)) {
53
+ models = json.data
54
+ .map((m: any) => m.id as string)
55
+ .filter(Boolean)
56
+ .sort((a: string, b: string) => a.localeCompare(b));
57
+ }
58
+ } else {
59
+ const res = await fetch('https://api.anthropic.com/v1/models', {
60
+ headers: {
61
+ 'x-api-key': resolvedKey,
62
+ 'anthropic-version': '2023-06-01',
63
+ },
64
+ signal: ctrl.signal,
65
+ });
66
+
67
+ if (!res.ok) {
68
+ const errBody = await res.text().catch(() => '');
69
+ return NextResponse.json({
70
+ ok: false,
71
+ error: `Failed to list models: HTTP ${res.status} ${errBody.slice(0, 200)}`,
72
+ });
73
+ }
74
+
75
+ const json = await res.json();
76
+ if (Array.isArray(json?.data)) {
77
+ models = json.data
78
+ .map((m: any) => m.id as string)
79
+ .filter(Boolean)
80
+ .sort((a: string, b: string) => a.localeCompare(b));
81
+ }
82
+ }
83
+
84
+ return NextResponse.json({ ok: true, models });
85
+ } catch (e: unknown) {
86
+ if (e instanceof Error && e.name === 'AbortError') {
87
+ return NextResponse.json({ ok: false, error: 'Request timed out' });
88
+ }
89
+ return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : 'Network error' });
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ } catch (err) {
94
+ return NextResponse.json({ ok: false, error: String(err) }, { status: 500 });
95
+ }
96
+ }
@@ -46,7 +46,7 @@ async function testAnthropic(apiKey: string, model: string): Promise<{ ok: boole
46
46
  }
47
47
  }
48
48
 
49
- async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promise<{ ok: boolean; latency?: number; code?: ErrorCode; error?: string }> {
49
+ async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promise<{ ok: boolean; latency?: number; code?: ErrorCode; error?: string; streamingSupported?: boolean }> {
50
50
  const start = Date.now();
51
51
  const ctrl = new AbortController();
52
52
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
@@ -62,10 +62,57 @@ async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promi
62
62
  signal: ctrl.signal,
63
63
  });
64
64
  const latency = Date.now() - start;
65
- if (res.ok) {
66
- // `/api/ask` always sends tool definitions, so key test should verify this
67
- // compatibility as well (not just plain chat completion).
68
- const toolRes = await fetch(url, {
65
+ if (!res.ok) {
66
+ const body = await res.text();
67
+ return { ok: false, ...classifyError(res.status, body) };
68
+ }
69
+
70
+ // Tool compatibility test — `/api/ask` always sends tool definitions.
71
+ const toolRes = await fetch(url, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'Authorization': `Bearer ${apiKey}`,
76
+ },
77
+ body: JSON.stringify({
78
+ model,
79
+ max_tokens: 1,
80
+ messages: [
81
+ { role: 'system', content: 'You are a helpful assistant.' },
82
+ { role: 'user', content: 'hi' },
83
+ ],
84
+ tools: [{
85
+ type: 'function',
86
+ function: {
87
+ name: 'noop',
88
+ description: 'No-op function used for compatibility checks.',
89
+ parameters: {
90
+ type: 'object',
91
+ properties: {},
92
+ additionalProperties: false,
93
+ },
94
+ },
95
+ }],
96
+ tool_choice: 'none',
97
+ }),
98
+ signal: ctrl.signal,
99
+ });
100
+ if (!toolRes.ok) {
101
+ const toolBody = await toolRes.text();
102
+ const toolErr = classifyError(toolRes.status, toolBody);
103
+ return {
104
+ ok: false,
105
+ code: toolErr.code,
106
+ error: `Model endpoint passes basic test but is incompatible with agent tool calls: ${toolErr.error}`,
107
+ };
108
+ }
109
+
110
+ // Streaming compatibility test — `/api/ask` uses SSE streaming by default.
111
+ // Many proxies pass non-streaming tests but fail at streaming.
112
+ // If streaming fails, we still report ok: true (basic chat works via non-streaming fallback).
113
+ let streamingSupported = true;
114
+ try {
115
+ const streamRes = await fetch(url, {
69
116
  method: 'POST',
70
117
  headers: {
71
118
  'Content-Type': 'application/json',
@@ -73,40 +120,37 @@ async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promi
73
120
  },
74
121
  body: JSON.stringify({
75
122
  model,
76
- max_tokens: 1,
77
- messages: [
78
- { role: 'system', content: 'You are a helpful assistant.' },
79
- { role: 'user', content: 'hi' },
80
- ],
81
- tools: [{
82
- type: 'function',
83
- function: {
84
- name: 'noop',
85
- description: 'No-op function used for compatibility checks.',
86
- parameters: {
87
- type: 'object',
88
- properties: {},
89
- additionalProperties: false,
90
- },
91
- },
92
- }],
93
- tool_choice: 'none',
123
+ max_tokens: 5,
124
+ stream: true,
125
+ messages: [{ role: 'user', content: 'Say OK' }],
94
126
  }),
95
127
  signal: ctrl.signal,
96
128
  });
97
-
98
- if (toolRes.ok) return { ok: true, latency };
99
-
100
- const toolBody = await toolRes.text();
101
- const toolErr = classifyError(toolRes.status, toolBody);
102
- return {
103
- ok: false,
104
- code: toolErr.code,
105
- error: `Model endpoint passes basic test but is incompatible with agent tool calls: ${toolErr.error}`,
106
- };
129
+ if (!streamRes.ok) {
130
+ streamingSupported = false;
131
+ } else {
132
+ const reader = streamRes.body?.getReader();
133
+ if (reader) {
134
+ const decoder = new TextDecoder();
135
+ let gotData = false;
136
+ try {
137
+ while (true) {
138
+ const { done, value } = await reader.read();
139
+ if (done) break;
140
+ const text = decoder.decode(value, { stream: true });
141
+ if (text.includes('data:')) { gotData = true; break; }
142
+ }
143
+ } finally {
144
+ reader.releaseLock();
145
+ }
146
+ if (!gotData) streamingSupported = false;
147
+ }
148
+ }
149
+ } catch {
150
+ streamingSupported = false;
107
151
  }
108
- const body = await res.text();
109
- return { ok: false, ...classifyError(res.status, body) };
152
+
153
+ return { ok: true, latency, streamingSupported };
110
154
  } catch (e: unknown) {
111
155
  if (e instanceof Error && e.name === 'AbortError') return { ok: false, code: 'network_error', error: 'Request timed out' };
112
156
  return { ok: false, code: 'network_error', error: e instanceof Error ? e.message : 'Network error' };
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { useRef, useCallback, useState, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
- import { FolderTree, Search, Settings, RefreshCw, Bot, Compass, HelpCircle, ChevronLeft, ChevronRight, Radio, History } from 'lucide-react';
5
+ import { FolderTree, Search, Settings, RefreshCw, Bot, Compass, HelpCircle, ChevronLeft, ChevronRight, Radio } from 'lucide-react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
8
8
  import type { SyncStatus } from './settings/SyncTab';
9
9
  import Logo from './Logo';
10
10
 
11
- export type PanelId = 'files' | 'search' | 'echo' | 'agents' | 'discover' | 'history';
11
+ export type PanelId = 'files' | 'search' | 'echo' | 'agents' | 'discover';
12
12
 
13
13
  export const RAIL_WIDTH_COLLAPSED = 48;
14
14
  export const RAIL_WIDTH_EXPANDED = 180;
@@ -16,7 +16,9 @@ export const RAIL_WIDTH_EXPANDED = 180;
16
16
  interface ActivityBarProps {
17
17
  activePanel: PanelId | null;
18
18
  onPanelChange: (id: PanelId | null) => void;
19
+ onEchoClick?: () => void;
19
20
  onAgentsClick?: () => void;
21
+ onDiscoverClick?: () => void;
20
22
  syncStatus: SyncStatus | null;
21
23
  expanded: boolean;
22
24
  onExpandedChange: (expanded: boolean) => void;
@@ -77,7 +79,9 @@ function RailButton({ icon, label, shortcut, active = false, expanded, onClick,
77
79
  export default function ActivityBar({
78
80
  activePanel,
79
81
  onPanelChange,
82
+ onEchoClick,
80
83
  onAgentsClick,
84
+ onDiscoverClick,
81
85
  syncStatus,
82
86
  expanded,
83
87
  onExpandedChange,
@@ -188,7 +192,7 @@ export default function ActivityBar({
188
192
  <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
189
193
  <RailButton icon={<FolderTree size={18} />} label={t.sidebar.files} active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} walkthroughId="files-panel" />
190
194
  <RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} />
191
- <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => toggle('echo')} walkthroughId="echo-panel" />
195
+ <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => onEchoClick ? debounced(onEchoClick) : toggle('echo')} walkthroughId="echo-panel" />
192
196
  <RailButton
193
197
  icon={<Bot size={18} />}
194
198
  label={t.sidebar.agents}
@@ -197,8 +201,7 @@ export default function ActivityBar({
197
201
  onClick={() => onAgentsClick ? debounced(onAgentsClick) : toggle('agents')}
198
202
  walkthroughId="agents-panel"
199
203
  />
200
- <RailButton icon={<Compass size={18} />} label={t.sidebar.discover} active={activePanel === 'discover'} expanded={expanded} onClick={() => toggle('discover')} />
201
- <RailButton icon={<History size={18} />} label={t.sidebar.history} active={activePanel === 'history'} expanded={expanded} onClick={() => toggle('history')} />
204
+ <RailButton icon={<Compass size={18} />} label={t.sidebar.discover} active={activePanel === 'discover'} expanded={expanded} onClick={() => onDiscoverClick ? debounced(onDiscoverClick) : toggle('discover')} />
202
205
  </div>
203
206
 
204
207
  {/* ── Spacer ── */}
@@ -16,11 +16,8 @@ export default function JsonView({ content }: JsonViewProps) {
16
16
  }, [content]);
17
17
 
18
18
  return (
19
- <pre
20
- className="rounded-xl border border-border bg-card px-4 py-3 overflow-x-auto text-sm leading-relaxed font-display"
21
- suppressHydrationWarning
22
- >
23
- <code>{pretty}</code>
19
+ <pre className="rounded-xl border border-border bg-card px-4 py-3 overflow-x-auto text-sm leading-relaxed font-display">
20
+ <code suppressHydrationWarning>{pretty}</code>
24
21
  </pre>
25
22
  );
26
23
  }