@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.
- package/app/app/api/ask/route.ts +220 -2
- package/app/app/api/settings/list-models/route.ts +96 -0
- package/app/app/api/settings/test-key/route.ts +79 -35
- package/app/components/ActivityBar.tsx +8 -5
- package/app/components/JsonView.tsx +2 -5
- package/app/components/OrganizeToast.tsx +237 -82
- package/app/components/Panel.tsx +6 -6
- package/app/components/SidebarLayout.tsx +10 -0
- package/app/components/UpdateOverlay.tsx +6 -6
- package/app/components/agents/AgentDetailContent.tsx +2 -2
- package/app/components/agents/AgentsMcpSection.tsx +2 -2
- package/app/components/agents/AgentsOverviewSection.tsx +11 -11
- package/app/components/agents/AgentsPrimitives.tsx +14 -14
- package/app/components/agents/AgentsSkillsSection.tsx +1 -1
- package/app/components/agents/SkillDetailPopover.tsx +1 -1
- package/app/components/ask/MessageList.tsx +1 -1
- package/app/components/panels/EchoPanel.tsx +7 -7
- package/app/components/renderers/summary/SummaryRenderer.tsx +1 -1
- package/app/components/settings/AiTab.tsx +133 -9
- package/app/components/settings/types.ts +1 -0
- package/app/lib/i18n-en.ts +10 -1
- package/app/lib/i18n-zh.ts +10 -1
- package/app/lib/settings.ts +2 -0
- package/app/next-env.d.ts +1 -1
- package/package.json +1 -1
package/app/app/api/ask/route.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
return { ok:
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
}
|