@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.
@@ -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' };
@@ -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
- // Expanded panel (file list with per-file undo)
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
- <button
216
- type="button"
217
- onClick={() => setExpanded(false)}
218
- className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
219
- >
220
- <ChevronDown size={14} />
221
- </button>
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
- {/* Actions */}
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
- // Compact toast bar
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}
@@ -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
- const [dblHintSeen, setDblHintSeen] = useState(() => {
112
- if (typeof window === 'undefined') return false;
113
- return localStorage.getItem('mindos-tree-dblclick-hint') === '1';
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
- setTestResult(prev => ({ ...prev, [providerName]: { state: 'ok', latency: json.latency } }));
76
- // Auto-clear after 5s
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
- }, 5000);
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">{t.settings.ai.testKeyOk(result.latency)}</span>
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
- <Input
176
+ <ModelInput
167
177
  value={anthropic.model}
168
- onChange={e => patchProvider('anthropic', { model: e.target.value })}
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
- <Input
200
+ <ModelInput
187
201
  value={openai.model}
188
- onChange={e => patchProvider('openai', { model: e.target.value })}
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() {
@@ -21,6 +21,7 @@ export interface AgentSettings {
21
21
  thinkingBudget?: number;
22
22
  contextStrategy?: 'auto' | 'off';
23
23
  reconnectRetries?: number;
24
+ useStreaming?: boolean;
24
25
  }
25
26
 
26
27
  export interface SettingsData {
@@ -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: 'No response from AI. Please check your API key and provider settings.',
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',
@@ -169,7 +169,7 @@ export const zh = {
169
169
  searching: '正在搜索知识库...',
170
170
  generating: '正在生成回复...',
171
171
  stopped: '已停止生成。',
172
- errorNoResponse: 'AI 未返回响应,请检查 API Key 和服务商设置。',
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 行为',
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.6.18",
3
+ "version": "0.6.19",
4
4
  "description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
5
5
  "keywords": [
6
6
  "mindos",