@geminilight/mindos 0.6.18 → 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/OrganizeToast.tsx +237 -82
- package/app/components/Panel.tsx +6 -5
- 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' };
|
|
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|
|
5
5
|
import {
|
|
6
6
|
Check, X, Loader2, Sparkles, AlertCircle, Undo2,
|
|
7
7
|
ChevronDown, FilePlus, FileEdit, ExternalLink,
|
|
8
|
+
Maximize2, Minimize2, FileIcon,
|
|
8
9
|
} from 'lucide-react';
|
|
9
10
|
import { useLocale } from '@/lib/LocaleContext';
|
|
10
11
|
import type { useAiOrganize } from '@/hooks/useAiOrganize';
|
|
@@ -84,6 +85,7 @@ export default function OrganizeToast({
|
|
|
84
85
|
const { elapsed, displayHint } = useOrganizeTimer(isOrganizing, aiOrganize.stageHint);
|
|
85
86
|
|
|
86
87
|
const [expanded, setExpanded] = useState(false);
|
|
88
|
+
const [maximized, setMaximized] = useState(false);
|
|
87
89
|
const [undoing, setUndoing] = useState(false);
|
|
88
90
|
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
89
91
|
const historyIdRef = useRef<string | null>(null);
|
|
@@ -197,7 +199,211 @@ export default function OrganizeToast({
|
|
|
197
199
|
|
|
198
200
|
if (!isActive) return null;
|
|
199
201
|
|
|
200
|
-
//
|
|
202
|
+
// ── Shared file-change row renderer ──
|
|
203
|
+
function renderChangeRow(c: typeof aiOrganize.changes[number], idx: number) {
|
|
204
|
+
const wasUndone = c.undone;
|
|
205
|
+
const undoable = aiOrganize.canUndo(c.path);
|
|
206
|
+
const fileName = c.path.split('/').pop() ?? c.path;
|
|
207
|
+
return (
|
|
208
|
+
<div
|
|
209
|
+
key={`${c.path}-${idx}`}
|
|
210
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${wasUndone ? 'bg-muted/30 opacity-50' : 'bg-muted/50'}`}
|
|
211
|
+
>
|
|
212
|
+
{wasUndone ? (
|
|
213
|
+
<Undo2 size={14} className="text-muted-foreground shrink-0" />
|
|
214
|
+
) : c.action === 'create' ? (
|
|
215
|
+
<FilePlus size={14} className="text-success shrink-0" />
|
|
216
|
+
) : (
|
|
217
|
+
<FileEdit size={14} className="text-[var(--amber)] shrink-0" />
|
|
218
|
+
)}
|
|
219
|
+
<span className={`truncate flex-1 ${wasUndone ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
|
|
220
|
+
{fileName}
|
|
221
|
+
</span>
|
|
222
|
+
{wasUndone ? (
|
|
223
|
+
<span className="text-xs text-muted-foreground shrink-0">{fi.organizeUndone as string}</span>
|
|
224
|
+
) : (
|
|
225
|
+
<span className={`text-xs shrink-0 ${c.ok ? 'text-muted-foreground' : 'text-error'}`}>
|
|
226
|
+
{!c.ok ? fi.organizeFailed as string
|
|
227
|
+
: c.action === 'create' ? fi.organizeCreated as string
|
|
228
|
+
: fi.organizeUpdated as string}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
{undoable && (
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => handleUndoOne(c.path)}
|
|
235
|
+
disabled={undoing}
|
|
236
|
+
className="text-2xs text-muted-foreground/60 hover:text-foreground transition-colors shrink-0 px-1 disabled:opacity-40"
|
|
237
|
+
title={fi.organizeUndoOne as string}
|
|
238
|
+
>
|
|
239
|
+
<Undo2 size={12} />
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
{c.ok && !c.undone && (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => handleViewFile(c.path)}
|
|
246
|
+
className="text-2xs text-muted-foreground/60 hover:text-[var(--amber)] transition-colors shrink-0 px-1"
|
|
247
|
+
title={fi.organizeViewFile as string}
|
|
248
|
+
>
|
|
249
|
+
<ExternalLink size={12} />
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Shared footer actions ──
|
|
257
|
+
function renderActions() {
|
|
258
|
+
return (
|
|
259
|
+
<div className="flex items-center justify-end gap-3 px-4 py-3 border-t border-border">
|
|
260
|
+
{isDone && aiOrganize.hasAnyUndoable && (
|
|
261
|
+
<button
|
|
262
|
+
onClick={handleUndoAll}
|
|
263
|
+
disabled={undoing}
|
|
264
|
+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1.5 disabled:opacity-50"
|
|
265
|
+
>
|
|
266
|
+
{undoing ? <Loader2 size={12} className="animate-spin" /> : <Undo2 size={12} />}
|
|
267
|
+
{fi.organizeUndoAll as string}
|
|
268
|
+
</button>
|
|
269
|
+
)}
|
|
270
|
+
<button
|
|
271
|
+
onClick={handleDismiss}
|
|
272
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all"
|
|
273
|
+
>
|
|
274
|
+
<Check size={12} />
|
|
275
|
+
{fi.organizeDone as string}
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
282
|
+
// Maximized Modal — full detail view with source files, summary, changes
|
|
283
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
284
|
+
if (maximized) {
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
className="fixed inset-0 z-50 overlay-backdrop flex items-center justify-center p-4"
|
|
288
|
+
onClick={(e) => { if (e.target === e.currentTarget) setMaximized(false); }}
|
|
289
|
+
>
|
|
290
|
+
<div
|
|
291
|
+
className="w-full max-w-xl max-h-[80vh] flex flex-col bg-card rounded-xl shadow-xl border border-border animate-in fade-in-0 zoom-in-95 duration-200"
|
|
292
|
+
onClick={handleUserAction}
|
|
293
|
+
role="dialog"
|
|
294
|
+
aria-modal="true"
|
|
295
|
+
aria-label={fi.organizeDetailTitle as string}
|
|
296
|
+
>
|
|
297
|
+
{/* Header */}
|
|
298
|
+
<div className="flex items-center justify-between px-5 pt-5 pb-3 shrink-0">
|
|
299
|
+
<div className="flex items-center gap-2">
|
|
300
|
+
{isOrganizing ? (
|
|
301
|
+
<div className="relative shrink-0">
|
|
302
|
+
<Sparkles size={16} className="text-[var(--amber)]" />
|
|
303
|
+
<Loader2 size={10} className="absolute -bottom-0.5 -right-0.5 text-[var(--amber)] animate-spin" />
|
|
304
|
+
</div>
|
|
305
|
+
) : isDone ? (
|
|
306
|
+
<Check size={16} className="text-success" />
|
|
307
|
+
) : (
|
|
308
|
+
<AlertCircle size={16} className="text-error" />
|
|
309
|
+
)}
|
|
310
|
+
<h2 className="text-base font-semibold text-foreground">
|
|
311
|
+
{fi.organizeDetailTitle as string}
|
|
312
|
+
</h2>
|
|
313
|
+
{isOrganizing && (
|
|
314
|
+
<span className="text-xs text-muted-foreground/60 tabular-nums">
|
|
315
|
+
{(fi.organizeElapsed as (s: number) => string)(elapsed)}
|
|
316
|
+
</span>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
<div className="flex items-center gap-1">
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={() => setMaximized(false)}
|
|
323
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
324
|
+
title={fi.organizeMinimizeModal as string}
|
|
325
|
+
>
|
|
326
|
+
<Minimize2 size={14} />
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={handleDismiss}
|
|
331
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
332
|
+
>
|
|
333
|
+
<X size={14} />
|
|
334
|
+
</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* Scrollable body */}
|
|
339
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-5 pb-2 space-y-4">
|
|
340
|
+
{/* Live progress during organizing */}
|
|
341
|
+
{isOrganizing && (
|
|
342
|
+
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-[var(--amber-subtle)] border border-[var(--amber-dim)]">
|
|
343
|
+
<Loader2 size={14} className="text-[var(--amber)] animate-spin shrink-0" />
|
|
344
|
+
<span className="text-xs text-foreground">{stageText(t, displayHint)}</span>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{/* Source files */}
|
|
349
|
+
{aiOrganize.sourceFileNames.length > 0 && (
|
|
350
|
+
<div>
|
|
351
|
+
<h3 className="text-xs font-medium text-muted-foreground mb-1.5">
|
|
352
|
+
{fi.organizeSourceFiles as string}
|
|
353
|
+
</h3>
|
|
354
|
+
<div className="flex flex-wrap gap-1.5">
|
|
355
|
+
{aiOrganize.sourceFileNames.map((name, i) => (
|
|
356
|
+
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-muted/60 text-xs text-foreground">
|
|
357
|
+
<FileIcon size={11} className="text-muted-foreground shrink-0" />
|
|
358
|
+
{name}
|
|
359
|
+
</span>
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{/* AI Summary */}
|
|
366
|
+
{(aiOrganize.summary || isOrganizing) && (
|
|
367
|
+
<div>
|
|
368
|
+
<h3 className="text-xs font-medium text-muted-foreground mb-1.5">
|
|
369
|
+
{fi.organizeSummaryLabel as string}
|
|
370
|
+
</h3>
|
|
371
|
+
<div className="px-3 py-2.5 rounded-lg bg-muted/30 border border-border text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
|
372
|
+
{aiOrganize.summary || (fi.organizeNoSummary as string)}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/* File changes */}
|
|
378
|
+
{aiOrganize.changes.length > 0 && (
|
|
379
|
+
<div>
|
|
380
|
+
<h3 className="text-xs font-medium text-muted-foreground mb-1.5">
|
|
381
|
+
{(fi.organizeChangesLabel as (n: number) => string)(aiOrganize.changes.length)}
|
|
382
|
+
</h3>
|
|
383
|
+
<div className="space-y-0.5">
|
|
384
|
+
{aiOrganize.changes.map((c, idx) => renderChangeRow(c, idx))}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
|
|
389
|
+
{/* Error detail */}
|
|
390
|
+
{isError && (
|
|
391
|
+
<div className="px-3 py-2.5 rounded-lg bg-error/5 border border-error/20 text-xs text-error">
|
|
392
|
+
{aiOrganize.error}
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
{/* Footer actions */}
|
|
398
|
+
{(isDone || isError) && renderActions()}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
405
|
+
// Expanded panel (file list with per-file undo) — bottom toast size
|
|
406
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
201
407
|
if (expanded && (isDone || isError)) {
|
|
202
408
|
return (
|
|
203
409
|
<div
|
|
@@ -212,71 +418,29 @@ export default function OrganizeToast({
|
|
|
212
418
|
{isDone ? fi.organizeReviewTitle as string : fi.organizeErrorTitle as string}
|
|
213
419
|
</span>
|
|
214
420
|
</div>
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
421
|
+
<div className="flex items-center gap-1">
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => { setExpanded(false); setMaximized(true); }}
|
|
425
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
426
|
+
title={fi.organizeDetailTitle as string}
|
|
427
|
+
>
|
|
428
|
+
<Maximize2 size={13} />
|
|
429
|
+
</button>
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={() => setExpanded(false)}
|
|
433
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
434
|
+
>
|
|
435
|
+
<ChevronDown size={14} />
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
222
438
|
</div>
|
|
223
439
|
|
|
224
440
|
{/* File list */}
|
|
225
441
|
{isDone && (
|
|
226
442
|
<div className="max-h-[240px] overflow-y-auto p-2 space-y-0.5">
|
|
227
|
-
{aiOrganize.changes.map((c, idx) =>
|
|
228
|
-
const wasUndone = c.undone;
|
|
229
|
-
const undoable = aiOrganize.canUndo(c.path);
|
|
230
|
-
const fileName = c.path.split('/').pop() ?? c.path;
|
|
231
|
-
|
|
232
|
-
return (
|
|
233
|
-
<div
|
|
234
|
-
key={`${c.path}-${idx}`}
|
|
235
|
-
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${wasUndone ? 'bg-muted/30 opacity-50' : 'bg-muted/50'}`}
|
|
236
|
-
>
|
|
237
|
-
{wasUndone ? (
|
|
238
|
-
<Undo2 size={14} className="text-muted-foreground shrink-0" />
|
|
239
|
-
) : c.action === 'create' ? (
|
|
240
|
-
<FilePlus size={14} className="text-success shrink-0" />
|
|
241
|
-
) : (
|
|
242
|
-
<FileEdit size={14} className="text-[var(--amber)] shrink-0" />
|
|
243
|
-
)}
|
|
244
|
-
<span className={`truncate flex-1 ${wasUndone ? 'line-through text-muted-foreground' : 'text-foreground'}`}>
|
|
245
|
-
{fileName}
|
|
246
|
-
</span>
|
|
247
|
-
{wasUndone ? (
|
|
248
|
-
<span className="text-xs text-muted-foreground shrink-0">{fi.organizeUndone as string}</span>
|
|
249
|
-
) : (
|
|
250
|
-
<span className={`text-xs shrink-0 ${c.ok ? 'text-muted-foreground' : 'text-error'}`}>
|
|
251
|
-
{!c.ok ? fi.organizeFailed as string
|
|
252
|
-
: c.action === 'create' ? fi.organizeCreated as string
|
|
253
|
-
: fi.organizeUpdated as string}
|
|
254
|
-
</span>
|
|
255
|
-
)}
|
|
256
|
-
{undoable && (
|
|
257
|
-
<button
|
|
258
|
-
type="button"
|
|
259
|
-
onClick={() => handleUndoOne(c.path)}
|
|
260
|
-
disabled={undoing}
|
|
261
|
-
className="text-2xs text-muted-foreground/60 hover:text-foreground transition-colors shrink-0 px-1 disabled:opacity-40"
|
|
262
|
-
title={fi.organizeUndoOne as string}
|
|
263
|
-
>
|
|
264
|
-
<Undo2 size={12} />
|
|
265
|
-
</button>
|
|
266
|
-
)}
|
|
267
|
-
{c.ok && !c.undone && (
|
|
268
|
-
<button
|
|
269
|
-
type="button"
|
|
270
|
-
onClick={() => handleViewFile(c.path)}
|
|
271
|
-
className="text-2xs text-muted-foreground/60 hover:text-[var(--amber)] transition-colors shrink-0 px-1"
|
|
272
|
-
title={fi.organizeViewFile as string}
|
|
273
|
-
>
|
|
274
|
-
<ExternalLink size={12} />
|
|
275
|
-
</button>
|
|
276
|
-
)}
|
|
277
|
-
</div>
|
|
278
|
-
);
|
|
279
|
-
})}
|
|
443
|
+
{aiOrganize.changes.map((c, idx) => renderChangeRow(c, idx))}
|
|
280
444
|
</div>
|
|
281
445
|
)}
|
|
282
446
|
|
|
@@ -286,31 +450,14 @@ export default function OrganizeToast({
|
|
|
286
450
|
</div>
|
|
287
451
|
)}
|
|
288
452
|
|
|
289
|
-
{
|
|
290
|
-
<div className="flex items-center justify-end gap-3 px-4 py-3 border-t border-border">
|
|
291
|
-
{isDone && aiOrganize.hasAnyUndoable && (
|
|
292
|
-
<button
|
|
293
|
-
onClick={handleUndoAll}
|
|
294
|
-
disabled={undoing}
|
|
295
|
-
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1.5 disabled:opacity-50"
|
|
296
|
-
>
|
|
297
|
-
{undoing ? <Loader2 size={12} className="animate-spin" /> : <Undo2 size={12} />}
|
|
298
|
-
{fi.organizeUndoAll as string}
|
|
299
|
-
</button>
|
|
300
|
-
)}
|
|
301
|
-
<button
|
|
302
|
-
onClick={handleDismiss}
|
|
303
|
-
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all"
|
|
304
|
-
>
|
|
305
|
-
<Check size={12} />
|
|
306
|
-
{fi.organizeDone as string}
|
|
307
|
-
</button>
|
|
308
|
-
</div>
|
|
453
|
+
{renderActions()}
|
|
309
454
|
</div>
|
|
310
455
|
);
|
|
311
456
|
}
|
|
312
457
|
|
|
313
|
-
//
|
|
458
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
459
|
+
// Compact toast bar
|
|
460
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
314
461
|
return (
|
|
315
462
|
<div
|
|
316
463
|
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-card border border-border rounded-xl shadow-lg px-4 py-3 max-w-md animate-in fade-in-0 slide-in-from-bottom-2 duration-200"
|
|
@@ -372,6 +519,14 @@ export default function OrganizeToast({
|
|
|
372
519
|
<span className="text-xs text-muted-foreground/60 tabular-nums shrink-0">
|
|
373
520
|
{(fi.organizeElapsed as (s: number) => string)(elapsed)}
|
|
374
521
|
</span>
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
onClick={() => { setMaximized(true); handleUserAction(); }}
|
|
525
|
+
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors shrink-0"
|
|
526
|
+
title={fi.organizeDetailTitle as string}
|
|
527
|
+
>
|
|
528
|
+
<Maximize2 size={13} />
|
|
529
|
+
</button>
|
|
375
530
|
<button
|
|
376
531
|
type="button"
|
|
377
532
|
onClick={handleDismiss}
|
package/app/components/Panel.tsx
CHANGED
|
@@ -107,11 +107,12 @@ export default function Panel({
|
|
|
107
107
|
};
|
|
108
108
|
}, [newPopover]);
|
|
109
109
|
|
|
110
|
-
// Double-click hint: show only until user has used it once
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
// Double-click hint: show only until user has used it once.
|
|
111
|
+
// Initialize false to match SSR; hydrate from localStorage in useEffect.
|
|
112
|
+
const [dblHintSeen, setDblHintSeen] = useState(false);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
try { if (localStorage.getItem('mindos-tree-dblclick-hint') === '1') setDblHintSeen(true); } catch { /* ignore */ }
|
|
115
|
+
}, []);
|
|
115
116
|
const markDblHintSeen = useCallback(() => {
|
|
116
117
|
if (!dblHintSeen) {
|
|
117
118
|
setDblHintSeen(true);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
|
-
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
4
|
+
import { AlertCircle, ChevronDown, Loader2 } from 'lucide-react';
|
|
5
5
|
import type { AiSettings, AgentSettings, ProviderConfig, SettingsData, AiTabProps } from './types';
|
|
6
6
|
import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle, SectionLabel } from './Primitives';
|
|
7
7
|
import { useLocale } from '@/lib/LocaleContext';
|
|
@@ -14,6 +14,7 @@ interface TestResult {
|
|
|
14
14
|
latency?: number;
|
|
15
15
|
error?: string;
|
|
16
16
|
code?: ErrorCode;
|
|
17
|
+
streamingSupported?: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function errorMessage(t: AiTabProps['t'], code?: ErrorCode): string {
|
|
@@ -72,12 +73,16 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
72
73
|
const json = await res.json();
|
|
73
74
|
|
|
74
75
|
if (json.ok) {
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const streamingSupported = json.streamingSupported !== false;
|
|
77
|
+
setTestResult(prev => ({ ...prev, [providerName]: { state: 'ok', latency: json.latency, streamingSupported } }));
|
|
78
|
+
// Auto-persist streaming capability so /api/ask uses the right path
|
|
79
|
+
if (providerName === data.ai.provider) {
|
|
80
|
+
updateAgent({ useStreaming: streamingSupported });
|
|
81
|
+
}
|
|
77
82
|
if (okTimerRef.current) clearTimeout(okTimerRef.current);
|
|
78
83
|
okTimerRef.current = setTimeout(() => {
|
|
79
84
|
setTestResult(prev => ({ ...prev, [providerName]: { state: 'idle' } }));
|
|
80
|
-
},
|
|
85
|
+
}, 8000);
|
|
81
86
|
} else {
|
|
82
87
|
setTestResult(prev => ({
|
|
83
88
|
...prev,
|
|
@@ -139,7 +144,12 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
139
144
|
)}
|
|
140
145
|
</button>
|
|
141
146
|
{result.state === 'ok' && result.latency != null && (
|
|
142
|
-
<span className="text-xs text-success">
|
|
147
|
+
<span className="text-xs text-success">
|
|
148
|
+
{t.settings.ai.testKeyOk(result.latency)}
|
|
149
|
+
{result.streamingSupported === false && (
|
|
150
|
+
<span className="text-muted-foreground ml-1.5">{t.settings.ai.streamingFallback}</span>
|
|
151
|
+
)}
|
|
152
|
+
</span>
|
|
143
153
|
)}
|
|
144
154
|
{result.state === 'error' && (
|
|
145
155
|
<span className="text-xs text-error">✗ {errorMessage(t, result.code)}</span>
|
|
@@ -163,10 +173,14 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
163
173
|
{provider === 'anthropic' ? (
|
|
164
174
|
<>
|
|
165
175
|
<Field label={<>{t.settings.ai.model} <EnvBadge overridden={env.ANTHROPIC_MODEL} /></>}>
|
|
166
|
-
<
|
|
176
|
+
<ModelInput
|
|
167
177
|
value={anthropic.model}
|
|
168
|
-
onChange={
|
|
178
|
+
onChange={v => patchProvider('anthropic', { model: v })}
|
|
169
179
|
placeholder={envVal.ANTHROPIC_MODEL || 'claude-sonnet-4-6'}
|
|
180
|
+
provider="anthropic"
|
|
181
|
+
apiKey={anthropic.apiKey}
|
|
182
|
+
envKey={env.ANTHROPIC_API_KEY}
|
|
183
|
+
t={t}
|
|
170
184
|
/>
|
|
171
185
|
</Field>
|
|
172
186
|
<Field
|
|
@@ -183,10 +197,15 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
183
197
|
) : (
|
|
184
198
|
<>
|
|
185
199
|
<Field label={<>{t.settings.ai.model} <EnvBadge overridden={env.OPENAI_MODEL} /></>}>
|
|
186
|
-
<
|
|
200
|
+
<ModelInput
|
|
187
201
|
value={openai.model}
|
|
188
|
-
onChange={
|
|
202
|
+
onChange={v => patchProvider('openai', { model: v })}
|
|
189
203
|
placeholder={envVal.OPENAI_MODEL || 'gpt-5.4'}
|
|
204
|
+
provider="openai"
|
|
205
|
+
apiKey={openai.apiKey}
|
|
206
|
+
envKey={env.OPENAI_API_KEY}
|
|
207
|
+
baseUrl={openai.baseUrl}
|
|
208
|
+
t={t}
|
|
190
209
|
/>
|
|
191
210
|
</Field>
|
|
192
211
|
<Field
|
|
@@ -309,6 +328,111 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
309
328
|
);
|
|
310
329
|
}
|
|
311
330
|
|
|
331
|
+
/* ── Model Input with "List models" picker ── */
|
|
332
|
+
|
|
333
|
+
function ModelInput({
|
|
334
|
+
value, onChange, placeholder, provider, apiKey, envKey, baseUrl, t,
|
|
335
|
+
}: {
|
|
336
|
+
value: string;
|
|
337
|
+
onChange: (v: string) => void;
|
|
338
|
+
placeholder: string;
|
|
339
|
+
provider: 'anthropic' | 'openai';
|
|
340
|
+
apiKey: string;
|
|
341
|
+
envKey?: boolean;
|
|
342
|
+
baseUrl?: string;
|
|
343
|
+
t: AiTabProps['t'];
|
|
344
|
+
}) {
|
|
345
|
+
const [models, setModels] = useState<string[] | null>(null);
|
|
346
|
+
const [loading, setLoading] = useState(false);
|
|
347
|
+
const [open, setOpen] = useState(false);
|
|
348
|
+
const [error, setError] = useState('');
|
|
349
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
350
|
+
|
|
351
|
+
const hasKey = !!apiKey || !!envKey;
|
|
352
|
+
|
|
353
|
+
const fetchModels = useCallback(async () => {
|
|
354
|
+
if (loading) return;
|
|
355
|
+
setLoading(true);
|
|
356
|
+
setError('');
|
|
357
|
+
try {
|
|
358
|
+
const body: Record<string, string> = { provider };
|
|
359
|
+
if (apiKey) body.apiKey = apiKey;
|
|
360
|
+
if (baseUrl) body.baseUrl = baseUrl;
|
|
361
|
+
|
|
362
|
+
const res = await fetch('/api/settings/list-models', {
|
|
363
|
+
method: 'POST',
|
|
364
|
+
headers: { 'Content-Type': 'application/json' },
|
|
365
|
+
body: JSON.stringify(body),
|
|
366
|
+
});
|
|
367
|
+
const json = await res.json();
|
|
368
|
+
if (json.ok && Array.isArray(json.models)) {
|
|
369
|
+
setModels(json.models);
|
|
370
|
+
setOpen(true);
|
|
371
|
+
} else {
|
|
372
|
+
setError(json.error || 'Failed to fetch models');
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
setError('Network error');
|
|
376
|
+
} finally {
|
|
377
|
+
setLoading(false);
|
|
378
|
+
}
|
|
379
|
+
}, [provider, apiKey, baseUrl, loading]);
|
|
380
|
+
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!open) return;
|
|
383
|
+
function handleClickOutside(e: MouseEvent) {
|
|
384
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
385
|
+
setOpen(false);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
389
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
390
|
+
}, [open]);
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<div ref={containerRef} className="relative">
|
|
394
|
+
<div className="flex gap-1.5">
|
|
395
|
+
<Input
|
|
396
|
+
value={value}
|
|
397
|
+
onChange={e => onChange(e.target.value)}
|
|
398
|
+
placeholder={placeholder}
|
|
399
|
+
className="flex-1"
|
|
400
|
+
/>
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
disabled={!hasKey || loading}
|
|
404
|
+
onClick={fetchModels}
|
|
405
|
+
title={t.settings.ai.listModels}
|
|
406
|
+
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
|
407
|
+
>
|
|
408
|
+
{loading ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
|
409
|
+
{t.settings.ai.listModels}
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
{error && <p className="text-xs text-error mt-1">{error}</p>}
|
|
413
|
+
{open && models && models.length > 0 && (
|
|
414
|
+
<div className="absolute z-50 mt-1 w-full max-h-48 overflow-y-auto rounded-lg border border-border bg-popover shadow-lg">
|
|
415
|
+
{models.map(m => (
|
|
416
|
+
<button
|
|
417
|
+
key={m}
|
|
418
|
+
type="button"
|
|
419
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors ${m === value ? 'bg-accent/60 font-medium' : ''}`}
|
|
420
|
+
onClick={() => { onChange(m); setOpen(false); }}
|
|
421
|
+
>
|
|
422
|
+
{m}
|
|
423
|
+
</button>
|
|
424
|
+
))}
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
{open && models && models.length === 0 && (
|
|
428
|
+
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-popover shadow-lg px-3 py-2 text-xs text-muted-foreground">
|
|
429
|
+
{t.settings.ai.noModelsFound}
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
312
436
|
/* ── Ask AI Display Mode (localStorage-based, no server roundtrip) ── */
|
|
313
437
|
|
|
314
438
|
function AskDisplayMode() {
|
package/app/lib/i18n-en.ts
CHANGED
|
@@ -144,7 +144,7 @@ export const en = {
|
|
|
144
144
|
searching: 'Searching knowledge base...',
|
|
145
145
|
generating: 'Generating response...',
|
|
146
146
|
stopped: 'Generation stopped.',
|
|
147
|
-
errorNoResponse: '
|
|
147
|
+
errorNoResponse: 'AI returned no content. Possible causes: model does not support streaming, proxy compatibility issue, or request exceeds context limit.',
|
|
148
148
|
reconnecting: (attempt: number, max: number) => `Connection lost. Reconnecting (${attempt}/${max})...`,
|
|
149
149
|
reconnectFailed: 'Connection failed after multiple attempts.',
|
|
150
150
|
retry: 'Retry',
|
|
@@ -789,6 +789,12 @@ export const en = {
|
|
|
789
789
|
organizeUndone: 'Undone',
|
|
790
790
|
organizeViewFile: 'View file',
|
|
791
791
|
organizeUndoSuccess: (n: number) => `Reverted ${n} file${n !== 1 ? 's' : ''}`,
|
|
792
|
+
organizeDetailTitle: 'AI Organize Details',
|
|
793
|
+
organizeSourceFiles: 'Source Files',
|
|
794
|
+
organizeSummaryLabel: 'AI Summary',
|
|
795
|
+
organizeChangesLabel: (n: number) => `Changes (${n})`,
|
|
796
|
+
organizeNoSummary: 'AI is working...',
|
|
797
|
+
organizeMinimizeModal: 'Minimize',
|
|
792
798
|
},
|
|
793
799
|
importHistory: {
|
|
794
800
|
title: 'Import History',
|
|
@@ -836,6 +842,9 @@ export const en = {
|
|
|
836
842
|
testKeyNetworkError: 'Network error',
|
|
837
843
|
testKeyNoKey: 'No API key configured',
|
|
838
844
|
testKeyUnknown: 'Test failed',
|
|
845
|
+
listModels: 'Browse',
|
|
846
|
+
noModelsFound: 'No models found',
|
|
847
|
+
streamingFallback: '(will use standard mode)',
|
|
839
848
|
},
|
|
840
849
|
agent: {
|
|
841
850
|
title: 'Agent Behavior',
|
package/app/lib/i18n-zh.ts
CHANGED
|
@@ -169,7 +169,7 @@ export const zh = {
|
|
|
169
169
|
searching: '正在搜索知识库...',
|
|
170
170
|
generating: '正在生成回复...',
|
|
171
171
|
stopped: '已停止生成。',
|
|
172
|
-
errorNoResponse: 'AI
|
|
172
|
+
errorNoResponse: 'AI 未返回有效内容。可能原因:模型不支持流式输出、中转站兼容性问题、或请求超出上下文限制。',
|
|
173
173
|
reconnecting: (attempt: number, max: number) => `连接中断,正在重连 (${attempt}/${max})...`,
|
|
174
174
|
reconnectFailed: '多次重连失败,请检查网络后重试。',
|
|
175
175
|
retry: '重试',
|
|
@@ -813,6 +813,12 @@ export const zh = {
|
|
|
813
813
|
organizeUndone: '已撤销',
|
|
814
814
|
organizeViewFile: '查看文件',
|
|
815
815
|
organizeUndoSuccess: (n: number) => `已撤销 ${n} 个文件`,
|
|
816
|
+
organizeDetailTitle: 'AI 整理详情',
|
|
817
|
+
organizeSourceFiles: '源文件',
|
|
818
|
+
organizeSummaryLabel: 'AI 总结',
|
|
819
|
+
organizeChangesLabel: (n: number) => `变更列表 (${n})`,
|
|
820
|
+
organizeNoSummary: 'AI 正在处理中...',
|
|
821
|
+
organizeMinimizeModal: '最小化',
|
|
816
822
|
},
|
|
817
823
|
importHistory: {
|
|
818
824
|
title: '导入历史',
|
|
@@ -860,6 +866,9 @@ export const zh = {
|
|
|
860
866
|
testKeyNetworkError: '网络错误',
|
|
861
867
|
testKeyNoKey: '未配置 API Key',
|
|
862
868
|
testKeyUnknown: '测试失败',
|
|
869
|
+
listModels: '选择模型',
|
|
870
|
+
noModelsFound: '未找到可用模型',
|
|
871
|
+
streamingFallback: '(将使用标准模式)',
|
|
863
872
|
},
|
|
864
873
|
agent: {
|
|
865
874
|
title: 'Agent 行为',
|
package/app/lib/settings.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface AgentConfig {
|
|
|
24
24
|
thinkingBudget?: number; // default 5000
|
|
25
25
|
contextStrategy?: 'auto' | 'off'; // default 'auto'
|
|
26
26
|
reconnectRetries?: number; // default 3, range 0-10 (0 = disabled)
|
|
27
|
+
useStreaming?: boolean; // default true; false = non-streaming fallback for proxy compat
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface GuideState {
|
|
@@ -130,6 +131,7 @@ function parseAgent(raw: unknown): AgentConfig | undefined {
|
|
|
130
131
|
if (typeof obj.thinkingBudget === 'number') result.thinkingBudget = Math.min(50000, Math.max(1000, obj.thinkingBudget));
|
|
131
132
|
if (obj.contextStrategy === 'auto' || obj.contextStrategy === 'off') result.contextStrategy = obj.contextStrategy;
|
|
132
133
|
if (typeof obj.reconnectRetries === 'number') result.reconnectRetries = Math.min(10, Math.max(0, obj.reconnectRetries));
|
|
134
|
+
if (typeof obj.useStreaming === 'boolean') result.useStreaming = obj.useStreaming;
|
|
133
135
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
134
136
|
}
|
|
135
137
|
|
package/app/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED