@geminilight/mindos 0.6.18 → 0.6.20

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.
@@ -16,8 +16,8 @@ import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { getFileContent, getMindRoot } from '@/lib/fs';
18
18
  import { getModelConfig } from '@/lib/agent/model';
19
- import { getRequestScopedTools, WRITE_TOOLS, truncate } from '@/lib/agent/tools';
20
- import { AGENT_SYSTEM_PROMPT } from '@/lib/agent/prompt';
19
+ import { getRequestScopedTools, getOrganizeTools, WRITE_TOOLS, truncate } from '@/lib/agent/tools';
20
+ import { AGENT_SYSTEM_PROMPT, ORGANIZE_SYSTEM_PROMPT } from '@/lib/agent/prompt';
21
21
  import { toAgentMessages } from '@/lib/agent/to-agent-messages';
22
22
  import { logAgentOp } from '@/lib/agent/log';
23
23
  import { readSettings } from '@/lib/settings';
@@ -252,6 +252,8 @@ export async function POST(req: NextRequest) {
252
252
  attachedFiles?: string[];
253
253
  uploadedFiles?: Array<{ name: string; content: string }>;
254
254
  maxSteps?: number;
255
+ /** 'organize' = lean prompt for file import organize; default = full prompt */
256
+ mode?: 'organize' | 'default';
255
257
  };
256
258
  try {
257
259
  body = await req.json();
@@ -260,6 +262,7 @@ export async function POST(req: NextRequest) {
260
262
  }
261
263
 
262
264
  const { messages, currentFile, attachedFiles, uploadedFiles } = body;
265
+ const isOrganizeMode = body.mode === 'organize';
263
266
 
264
267
  // Read agent config from settings
265
268
  const serverSettings = readSettings();
@@ -271,99 +274,7 @@ export async function POST(req: NextRequest) {
271
274
  const thinkingBudget = agentConfig.thinkingBudget ?? 5000;
272
275
  const contextStrategy = agentConfig.contextStrategy ?? 'auto';
273
276
 
274
- // Auto-load skill + bootstrap context for each request.
275
- // 1. SKILL.md — complete skill with operating rules (always loaded)
276
- // 2. user-skill-rules.md — user's personalized rules from KB root (if exists)
277
- const isZh = serverSettings.disabledSkills?.includes('mindos') ?? false;
278
- const skillDirName = isZh ? 'mindos-zh' : 'mindos';
279
- const appDir = process.env.MINDOS_PROJECT_ROOT
280
- ? path.join(process.env.MINDOS_PROJECT_ROOT, 'app')
281
- : process.cwd();
282
- const skillPath = path.join(appDir, `data/skills/${skillDirName}/SKILL.md`);
283
- const skill = readAbsoluteFile(skillPath);
284
-
285
- const mindRoot = getMindRoot();
286
- const userSkillRules = readKnowledgeFile('user-skill-rules.md');
287
-
288
- const targetDir = dirnameOf(currentFile);
289
- const bootstrap = {
290
- instruction: readKnowledgeFile('INSTRUCTION.md'),
291
- index: readKnowledgeFile('README.md'),
292
- config_json: readKnowledgeFile('CONFIG.json'),
293
- config_md: readKnowledgeFile('CONFIG.md'),
294
- target_readme: targetDir ? readKnowledgeFile(`${targetDir}/README.md`) : null,
295
- target_instruction: targetDir ? readKnowledgeFile(`${targetDir}/INSTRUCTION.md`) : null,
296
- target_config_json: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.json`) : null,
297
- target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
298
- };
299
-
300
- // Only report failures + truncation warnings
301
- const initFailures: string[] = [];
302
- const truncationWarnings: string[] = [];
303
- if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
304
- if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
305
- if (userSkillRules.ok && userSkillRules.truncated) truncationWarnings.push('user-skill-rules.md was truncated');
306
- if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
307
- if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
308
- if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
309
- if (bootstrap.index.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
310
- if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
311
- if (bootstrap.config_json.ok && bootstrap.config_json.truncated) truncationWarnings.push('bootstrap.config_json was truncated');
312
- if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
313
- if (bootstrap.config_md.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
314
- if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
315
- if (bootstrap.target_readme?.ok && bootstrap.target_readme.truncated) truncationWarnings.push('bootstrap.target_readme was truncated');
316
- if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
317
- if (bootstrap.target_instruction?.ok && bootstrap.target_instruction.truncated) truncationWarnings.push('bootstrap.target_instruction was truncated');
318
- if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
319
- if (bootstrap.target_config_json?.ok && bootstrap.target_config_json.truncated) truncationWarnings.push('bootstrap.target_config_json was truncated');
320
- if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
321
- if (bootstrap.target_config_md?.ok && bootstrap.target_config_md.truncated) truncationWarnings.push('bootstrap.target_config_md was truncated');
322
-
323
- const initStatus = initFailures.length === 0
324
- ? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? ` ⚠️ ${truncationWarnings.length} files truncated` : ''}`
325
- : `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? `\n⚠️ Warnings:\n${truncationWarnings.join('\n')}` : ''}`;
326
-
327
- const initContextBlocks: string[] = [];
328
- if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
329
- // User personalization rules (from knowledge base root)
330
- if (userSkillRules.ok && !userSkillRules.truncated && userSkillRules.content.trim()) {
331
- initContextBlocks.push(`## user_skill_rules\n\nUser personalization rules (user-skill-rules.md):\n\n${userSkillRules.content}`);
332
- }
333
- if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
334
- if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
335
- if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
336
- if (bootstrap.config_md.ok) initContextBlocks.push(`## bootstrap_config_md\n\n${bootstrap.config_md.content}`);
337
- if (bootstrap.target_readme?.ok) initContextBlocks.push(`## bootstrap_target_readme\n\n${bootstrap.target_readme.content}`);
338
- if (bootstrap.target_instruction?.ok) initContextBlocks.push(`## bootstrap_target_instruction\n\n${bootstrap.target_instruction.content}`);
339
- if (bootstrap.target_config_json?.ok) initContextBlocks.push(`## bootstrap_target_config_json\n\n${bootstrap.target_config_json.content}`);
340
- if (bootstrap.target_config_md?.ok) initContextBlocks.push(`## bootstrap_target_config_md\n\n${bootstrap.target_config_md.content}`);
341
-
342
- // Build initial context from attached/current files
343
- const contextParts: string[] = [];
344
- const seen = new Set<string>();
345
- const hasAttached = Array.isArray(attachedFiles) && attachedFiles.length > 0;
346
-
347
- if (hasAttached) {
348
- for (const filePath of attachedFiles!) {
349
- if (seen.has(filePath)) continue;
350
- seen.add(filePath);
351
- try {
352
- const content = truncate(getFileContent(filePath));
353
- contextParts.push(`## Attached: ${filePath}\n\n${content}`);
354
- } catch { /* ignore missing files */ }
355
- }
356
- }
357
-
358
- if (currentFile && !seen.has(currentFile)) {
359
- seen.add(currentFile);
360
- try {
361
- const content = truncate(getFileContent(currentFile));
362
- contextParts.push(`## Current file: ${currentFile}\n\n${content}`);
363
- } catch { /* ignore */ }
364
- }
365
-
366
- // Uploaded files
277
+ // Uploaded files shared by both modes
367
278
  const uploadedParts: string[] = [];
368
279
  if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
369
280
  for (const f of uploadedFiles.slice(0, 8)) {
@@ -372,40 +283,154 @@ export async function POST(req: NextRequest) {
372
283
  }
373
284
  }
374
285
 
375
- // Generate current time for the agent's context
376
- const now = new Date();
377
- const timeContext = `## Current Time Context
286
+ // ---------------------------------------------------------------------------
287
+ // Build system prompt — lean path for organize mode, full path otherwise
288
+ // ---------------------------------------------------------------------------
289
+ let systemPrompt: string;
290
+
291
+ if (isOrganizeMode) {
292
+ // Organize mode: minimal prompt — only KB structure + uploaded files
293
+ const promptParts: string[] = [ORGANIZE_SYSTEM_PROMPT];
294
+
295
+ promptParts.push(`---\n\nmind_root=${getMindRoot()}`);
296
+
297
+ // Only load root README.md for KB structure awareness (skip SKILL.md, configs, target dir, time, etc.)
298
+ const bootstrapIndex = readKnowledgeFile('README.md');
299
+ if (bootstrapIndex.ok) {
300
+ promptParts.push(`---\n\n## Knowledge Base Structure\n\n${bootstrapIndex.content}`);
301
+ }
302
+
303
+ if (uploadedParts.length > 0) {
304
+ promptParts.push(
305
+ `---\n\n## ⚠️ USER-UPLOADED FILES\n\n` +
306
+ `Their FULL CONTENT is below. Use this directly — do NOT call read tools on them.\n\n` +
307
+ uploadedParts.join('\n\n---\n\n'),
308
+ );
309
+ }
310
+
311
+ systemPrompt = promptParts.join('\n\n');
312
+ } else {
313
+ // Full mode: original prompt assembly
314
+ // Auto-load skill + bootstrap context for each request.
315
+ const isZh = serverSettings.disabledSkills?.includes('mindos') ?? false;
316
+ const skillDirName = isZh ? 'mindos-zh' : 'mindos';
317
+ const appDir = process.env.MINDOS_PROJECT_ROOT
318
+ ? path.join(process.env.MINDOS_PROJECT_ROOT, 'app')
319
+ : process.cwd();
320
+ const skillPath = path.join(appDir, `data/skills/${skillDirName}/SKILL.md`);
321
+ const skill = readAbsoluteFile(skillPath);
322
+
323
+ const userSkillRules = readKnowledgeFile('user-skill-rules.md');
324
+
325
+ const targetDir = dirnameOf(currentFile);
326
+ const bootstrap = {
327
+ instruction: readKnowledgeFile('INSTRUCTION.md'),
328
+ index: readKnowledgeFile('README.md'),
329
+ config_json: readKnowledgeFile('CONFIG.json'),
330
+ config_md: readKnowledgeFile('CONFIG.md'),
331
+ target_readme: targetDir ? readKnowledgeFile(`${targetDir}/README.md`) : null,
332
+ target_instruction: targetDir ? readKnowledgeFile(`${targetDir}/INSTRUCTION.md`) : null,
333
+ target_config_json: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.json`) : null,
334
+ target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
335
+ };
336
+
337
+ // Only report failures + truncation warnings
338
+ const initFailures: string[] = [];
339
+ const truncationWarnings: string[] = [];
340
+ if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
341
+ if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
342
+ if (userSkillRules.ok && userSkillRules.truncated) truncationWarnings.push('user-skill-rules.md was truncated');
343
+ if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
344
+ if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
345
+ if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
346
+ if (bootstrap.index.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
347
+ if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
348
+ if (bootstrap.config_json.ok && bootstrap.config_json.truncated) truncationWarnings.push('bootstrap.config_json was truncated');
349
+ if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
350
+ if (bootstrap.config_md.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
351
+ if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
352
+ if (bootstrap.target_readme?.ok && bootstrap.target_readme.truncated) truncationWarnings.push('bootstrap.target_readme was truncated');
353
+ if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
354
+ if (bootstrap.target_instruction?.ok && bootstrap.target_instruction.truncated) truncationWarnings.push('bootstrap.target_instruction was truncated');
355
+ if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
356
+ if (bootstrap.target_config_json?.ok && bootstrap.target_config_json.truncated) truncationWarnings.push('bootstrap.target_config_json was truncated');
357
+ if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
358
+ if (bootstrap.target_config_md?.ok && bootstrap.target_config_md.truncated) truncationWarnings.push('bootstrap.target_config_md was truncated');
359
+
360
+ const initStatus = initFailures.length === 0
361
+ ? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? ` ⚠️ ${truncationWarnings.length} files truncated` : ''}`
362
+ : `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? `\n⚠️ Warnings:\n${truncationWarnings.join('\n')}` : ''}`;
363
+
364
+ const initContextBlocks: string[] = [];
365
+ if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
366
+ if (userSkillRules.ok && !userSkillRules.truncated && userSkillRules.content.trim()) {
367
+ initContextBlocks.push(`## user_skill_rules\n\nUser personalization rules (user-skill-rules.md):\n\n${userSkillRules.content}`);
368
+ }
369
+ if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
370
+ if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
371
+ if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
372
+ if (bootstrap.config_md.ok) initContextBlocks.push(`## bootstrap_config_md\n\n${bootstrap.config_md.content}`);
373
+ if (bootstrap.target_readme?.ok) initContextBlocks.push(`## bootstrap_target_readme\n\n${bootstrap.target_readme.content}`);
374
+ if (bootstrap.target_instruction?.ok) initContextBlocks.push(`## bootstrap_target_instruction\n\n${bootstrap.target_instruction.content}`);
375
+ if (bootstrap.target_config_json?.ok) initContextBlocks.push(`## bootstrap_target_config_json\n\n${bootstrap.target_config_json.content}`);
376
+ if (bootstrap.target_config_md?.ok) initContextBlocks.push(`## bootstrap_target_config_md\n\n${bootstrap.target_config_md.content}`);
377
+
378
+ // Build initial context from attached/current files
379
+ const contextParts: string[] = [];
380
+ const seen = new Set<string>();
381
+ const hasAttached = Array.isArray(attachedFiles) && attachedFiles.length > 0;
382
+
383
+ if (hasAttached) {
384
+ for (const filePath of attachedFiles!) {
385
+ if (seen.has(filePath)) continue;
386
+ seen.add(filePath);
387
+ try {
388
+ const content = truncate(getFileContent(filePath));
389
+ contextParts.push(`## Attached: ${filePath}\n\n${content}`);
390
+ } catch { /* ignore missing files */ }
391
+ }
392
+ }
393
+
394
+ if (currentFile && !seen.has(currentFile)) {
395
+ seen.add(currentFile);
396
+ try {
397
+ const content = truncate(getFileContent(currentFile));
398
+ contextParts.push(`## Current file: ${currentFile}\n\n${content}`);
399
+ } catch { /* ignore */ }
400
+ }
401
+
402
+ const now = new Date();
403
+ const timeContext = `## Current Time Context
378
404
  - Current UTC Time: ${now.toISOString()}
379
405
  - System Local Time: ${new Intl.DateTimeFormat('en-US', { dateStyle: 'full', timeStyle: 'long' }).format(now)}
380
406
  - Unix Timestamp: ${Math.floor(now.getTime() / 1000)}
381
407
 
382
408
  *Note: The times listed above represent "NOW". The user may have sent messages hours or days ago in this same conversation thread. Each user message in the history contains its own specific timestamp which you should refer to when understanding historical context.*`;
383
409
 
384
- const promptParts: string[] = [AGENT_SYSTEM_PROMPT];
385
-
386
- promptParts.push(`---\n\n${timeContext}`);
387
-
388
- promptParts.push(`---\n\nInitialization status (auto-loaded at request start):\n\n${initStatus}`);
410
+ const promptParts: string[] = [AGENT_SYSTEM_PROMPT];
411
+ promptParts.push(`---\n\n${timeContext}`);
412
+ promptParts.push(`---\n\nInitialization status (auto-loaded at request start):\n\n${initStatus}`);
389
413
 
390
- if (initContextBlocks.length > 0) {
391
- promptParts.push(`---\n\nInitialization context:\n\n${initContextBlocks.join('\n\n---\n\n')}`);
392
- }
414
+ if (initContextBlocks.length > 0) {
415
+ promptParts.push(`---\n\nInitialization context:\n\n${initContextBlocks.join('\n\n---\n\n')}`);
416
+ }
393
417
 
394
- if (contextParts.length > 0) {
395
- promptParts.push(`---\n\nThe user is currently viewing these files:\n\n${contextParts.join('\n\n---\n\n')}`);
396
- }
418
+ if (contextParts.length > 0) {
419
+ promptParts.push(`---\n\nThe user is currently viewing these files:\n\n${contextParts.join('\n\n---\n\n')}`);
420
+ }
397
421
 
398
- if (uploadedParts.length > 0) {
399
- promptParts.push(
400
- `---\n\n## ⚠️ USER-UPLOADED FILES (ACTIVE ATTACHMENTS)\n\n` +
401
- `The user has uploaded the following file(s) in this conversation. ` +
402
- `Their FULL CONTENT is provided below. You MUST use this content directly when the user refers to these files. ` +
403
- `Do NOT use read_file or search tools to find them — they exist only here, not in the knowledge base.\n\n` +
404
- uploadedParts.join('\n\n---\n\n'),
405
- );
406
- }
422
+ if (uploadedParts.length > 0) {
423
+ promptParts.push(
424
+ `---\n\n## ⚠️ USER-UPLOADED FILES (ACTIVE ATTACHMENTS)\n\n` +
425
+ `The user has uploaded the following file(s) in this conversation. ` +
426
+ `Their FULL CONTENT is provided below. You MUST use this content directly when the user refers to these files. ` +
427
+ `Do NOT use read_file or search tools to find them — they exist only here, not in the knowledge base.\n\n` +
428
+ uploadedParts.join('\n\n---\n\n'),
429
+ );
430
+ }
407
431
 
408
- const systemPrompt = promptParts.join('\n\n');
432
+ systemPrompt = promptParts.join('\n\n');
433
+ }
409
434
 
410
435
  try {
411
436
  const { model, modelName, apiKey, provider } = getModelConfig();
@@ -424,7 +449,7 @@ export async function POST(req: NextRequest) {
424
449
  // Capture API key for this request — safe since each POST creates a new Agent instance.
425
450
  const requestApiKey = apiKey;
426
451
  const projectRoot = process.env.MINDOS_PROJECT_ROOT || path.resolve(process.cwd(), '..');
427
- const requestTools = await getRequestScopedTools();
452
+ const requestTools = isOrganizeMode ? getOrganizeTools() : await getRequestScopedTools();
428
453
  const customTools = toPiCustomToolDefinitions(requestTools);
429
454
 
430
455
  const authStorage = AuthStorage.create();
@@ -492,12 +517,18 @@ export async function POST(req: NextRequest) {
492
517
  }
493
518
  }
494
519
 
520
+ let hasContent = false;
521
+ let lastModelError = '';
522
+
495
523
  session.subscribe((event: AgentEvent) => {
496
524
  if (isTextDeltaEvent(event)) {
525
+ hasContent = true;
497
526
  send({ type: 'text_delta', delta: getTextDelta(event) });
498
527
  } else if (isThinkingDeltaEvent(event)) {
528
+ hasContent = true;
499
529
  send({ type: 'thinking_delta', delta: getThinkingDelta(event) });
500
530
  } else if (isToolExecutionStartEvent(event)) {
531
+ hasContent = true;
501
532
  const { toolCallId, toolName, args } = getToolExecutionStart(event);
502
533
  const safeArgs = sanitizeToolArgs(toolName, args);
503
534
  send({
@@ -555,12 +586,30 @@ export async function POST(req: NextRequest) {
555
586
  }
556
587
 
557
588
  console.log(`[ask] Step ${stepCount}/${stepLimit}`);
589
+ } else if (event.type === 'agent_end') {
590
+ // Capture model errors from the last assistant message.
591
+ // pi-coding-agent resolves prompt() without throwing after retries;
592
+ // the error is only visible in agent_end event messages.
593
+ const msgs = (event as any).messages;
594
+ if (Array.isArray(msgs)) {
595
+ for (let i = msgs.length - 1; i >= 0; i--) {
596
+ const m = msgs[i];
597
+ if (m?.role === 'assistant' && m?.stopReason === 'error' && m?.errorMessage) {
598
+ lastModelError = m.errorMessage;
599
+ break;
600
+ }
601
+ }
602
+ }
558
603
  }
559
604
  });
560
605
 
561
606
  session.prompt(lastUserContent).then(() => {
562
607
  metrics.recordRequest(Date.now() - requestStartTime);
563
- send({ type: 'done' });
608
+ if (!hasContent && lastModelError) {
609
+ send({ type: 'error', message: lastModelError });
610
+ } else {
611
+ send({ type: 'done' });
612
+ }
564
613
  controller.close();
565
614
  }).catch((err) => {
566
615
  metrics.recordRequest(Date.now() - requestStartTime);
@@ -587,3 +636,4 @@ export async function POST(req: NextRequest) {
587
636
  return apiError(ErrorCodes.MODEL_INIT_FAILED, err instanceof Error ? err.message : 'Failed to initialize AI model', 500);
588
637
  }
589
638
  }
639
+
@@ -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
+ }
@@ -58,45 +58,45 @@ async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promi
58
58
  'Content-Type': 'application/json',
59
59
  'Authorization': `Bearer ${apiKey}`,
60
60
  },
61
- body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
61
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: 'hi' }] }),
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, {
69
- method: 'POST',
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- 'Authorization': `Bearer ${apiKey}`,
73
- },
74
- body: JSON.stringify({
75
- 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',
94
- }),
95
- signal: ctrl.signal,
96
- });
97
-
98
- if (toolRes.ok) return { ok: true, latency };
65
+ if (!res.ok) {
66
+ const body = await res.text();
67
+ return { ok: false, ...classifyError(res.status, body) };
68
+ }
99
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
+ messages: [
80
+ { role: 'system', content: 'You are a helpful assistant.' },
81
+ { role: 'user', content: 'hi' },
82
+ ],
83
+ tools: [{
84
+ type: 'function',
85
+ function: {
86
+ name: 'noop',
87
+ description: 'No-op function used for compatibility checks.',
88
+ parameters: {
89
+ type: 'object',
90
+ properties: {},
91
+ additionalProperties: false,
92
+ },
93
+ },
94
+ }],
95
+ tool_choice: 'none',
96
+ }),
97
+ signal: ctrl.signal,
98
+ });
99
+ if (!toolRes.ok) {
100
100
  const toolBody = await toolRes.text();
101
101
  const toolErr = classifyError(toolRes.status, toolBody);
102
102
  return {
@@ -105,8 +105,8 @@ async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promi
105
105
  error: `Model endpoint passes basic test but is incompatible with agent tool calls: ${toolErr.error}`,
106
106
  };
107
107
  }
108
- const body = await res.text();
109
- return { ok: false, ...classifyError(res.status, body) };
108
+
109
+ return { ok: true, latency };
110
110
  } catch (e: unknown) {
111
111
  if (e instanceof Error && e.name === 'AbortError') return { ok: false, code: 'network_error', error: 'Request timed out' };
112
112
  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';
@@ -73,11 +73,10 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
73
73
 
74
74
  if (json.ok) {
75
75
  setTestResult(prev => ({ ...prev, [providerName]: { state: 'ok', latency: json.latency } }));
76
- // Auto-clear after 5s
77
76
  if (okTimerRef.current) clearTimeout(okTimerRef.current);
78
77
  okTimerRef.current = setTimeout(() => {
79
78
  setTestResult(prev => ({ ...prev, [providerName]: { state: 'idle' } }));
80
- }, 5000);
79
+ }, 8000);
81
80
  } else {
82
81
  setTestResult(prev => ({
83
82
  ...prev,
@@ -139,7 +138,9 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
139
138
  )}
140
139
  </button>
141
140
  {result.state === 'ok' && result.latency != null && (
142
- <span className="text-xs text-success">{t.settings.ai.testKeyOk(result.latency)}</span>
141
+ <span className="text-xs text-success">
142
+ {t.settings.ai.testKeyOk(result.latency)}
143
+ </span>
143
144
  )}
144
145
  {result.state === 'error' && (
145
146
  <span className="text-xs text-error">✗ {errorMessage(t, result.code)}</span>
@@ -163,10 +164,14 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
163
164
  {provider === 'anthropic' ? (
164
165
  <>
165
166
  <Field label={<>{t.settings.ai.model} <EnvBadge overridden={env.ANTHROPIC_MODEL} /></>}>
166
- <Input
167
+ <ModelInput
167
168
  value={anthropic.model}
168
- onChange={e => patchProvider('anthropic', { model: e.target.value })}
169
+ onChange={v => patchProvider('anthropic', { model: v })}
169
170
  placeholder={envVal.ANTHROPIC_MODEL || 'claude-sonnet-4-6'}
171
+ provider="anthropic"
172
+ apiKey={anthropic.apiKey}
173
+ envKey={env.ANTHROPIC_API_KEY}
174
+ t={t}
170
175
  />
171
176
  </Field>
172
177
  <Field
@@ -183,10 +188,15 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
183
188
  ) : (
184
189
  <>
185
190
  <Field label={<>{t.settings.ai.model} <EnvBadge overridden={env.OPENAI_MODEL} /></>}>
186
- <Input
191
+ <ModelInput
187
192
  value={openai.model}
188
- onChange={e => patchProvider('openai', { model: e.target.value })}
193
+ onChange={v => patchProvider('openai', { model: v })}
189
194
  placeholder={envVal.OPENAI_MODEL || 'gpt-5.4'}
195
+ provider="openai"
196
+ apiKey={openai.apiKey}
197
+ envKey={env.OPENAI_API_KEY}
198
+ baseUrl={openai.baseUrl}
199
+ t={t}
190
200
  />
191
201
  </Field>
192
202
  <Field
@@ -309,6 +319,111 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
309
319
  );
310
320
  }
311
321
 
322
+ /* ── Model Input with "List models" picker ── */
323
+
324
+ function ModelInput({
325
+ value, onChange, placeholder, provider, apiKey, envKey, baseUrl, t,
326
+ }: {
327
+ value: string;
328
+ onChange: (v: string) => void;
329
+ placeholder: string;
330
+ provider: 'anthropic' | 'openai';
331
+ apiKey: string;
332
+ envKey?: boolean;
333
+ baseUrl?: string;
334
+ t: AiTabProps['t'];
335
+ }) {
336
+ const [models, setModels] = useState<string[] | null>(null);
337
+ const [loading, setLoading] = useState(false);
338
+ const [open, setOpen] = useState(false);
339
+ const [error, setError] = useState('');
340
+ const containerRef = useRef<HTMLDivElement>(null);
341
+
342
+ const hasKey = !!apiKey || !!envKey;
343
+
344
+ const fetchModels = useCallback(async () => {
345
+ if (loading) return;
346
+ setLoading(true);
347
+ setError('');
348
+ try {
349
+ const body: Record<string, string> = { provider };
350
+ if (apiKey) body.apiKey = apiKey;
351
+ if (baseUrl) body.baseUrl = baseUrl;
352
+
353
+ const res = await fetch('/api/settings/list-models', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify(body),
357
+ });
358
+ const json = await res.json();
359
+ if (json.ok && Array.isArray(json.models)) {
360
+ setModels(json.models);
361
+ setOpen(true);
362
+ } else {
363
+ setError(json.error || 'Failed to fetch models');
364
+ }
365
+ } catch {
366
+ setError('Network error');
367
+ } finally {
368
+ setLoading(false);
369
+ }
370
+ }, [provider, apiKey, baseUrl, loading]);
371
+
372
+ useEffect(() => {
373
+ if (!open) return;
374
+ function handleClickOutside(e: MouseEvent) {
375
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
376
+ setOpen(false);
377
+ }
378
+ }
379
+ document.addEventListener('mousedown', handleClickOutside);
380
+ return () => document.removeEventListener('mousedown', handleClickOutside);
381
+ }, [open]);
382
+
383
+ return (
384
+ <div ref={containerRef} className="relative">
385
+ <div className="flex gap-1.5">
386
+ <Input
387
+ value={value}
388
+ onChange={e => onChange(e.target.value)}
389
+ placeholder={placeholder}
390
+ className="flex-1"
391
+ />
392
+ <button
393
+ type="button"
394
+ disabled={!hasKey || loading}
395
+ onClick={fetchModels}
396
+ title={t.settings.ai.listModels}
397
+ 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"
398
+ >
399
+ {loading ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
400
+ {t.settings.ai.listModels}
401
+ </button>
402
+ </div>
403
+ {error && <p className="text-xs text-error mt-1">{error}</p>}
404
+ {open && models && models.length > 0 && (
405
+ <div className="absolute z-50 mt-1 w-full max-h-48 overflow-y-auto rounded-lg border border-border bg-popover shadow-lg">
406
+ {models.map(m => (
407
+ <button
408
+ key={m}
409
+ type="button"
410
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors ${m === value ? 'bg-accent/60 font-medium' : ''}`}
411
+ onClick={() => { onChange(m); setOpen(false); }}
412
+ >
413
+ {m}
414
+ </button>
415
+ ))}
416
+ </div>
417
+ )}
418
+ {open && models && models.length === 0 && (
419
+ <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">
420
+ {t.settings.ai.noModelsFound}
421
+ </div>
422
+ )}
423
+ </div>
424
+ );
425
+ }
426
+
312
427
  /* ── Ask AI Display Mode (localStorage-based, no server roundtrip) ── */
313
428
 
314
429
  function AskDisplayMode() {
@@ -281,6 +281,7 @@ export function useAiOrganize() {
281
281
  messages,
282
282
  uploadedFiles: truncatedFiles,
283
283
  maxSteps: 15,
284
+ mode: 'organize',
284
285
  }),
285
286
  signal: controller.signal,
286
287
  });
@@ -57,6 +57,8 @@ export function getModelConfig(): {
57
57
  // For custom proxy endpoints, set conservative compat flags.
58
58
  // Most proxies (Azure, Bedrock relays, corporate gateways) only support
59
59
  // a subset of OpenAI's features. These defaults prevent silent failures.
60
+ // NOTE: maxTokensField is NOT overridden — pi-ai auto-detects the correct
61
+ // field based on URL (defaults to max_completion_tokens for modern APIs).
60
62
  if (hasCustomBase) {
61
63
  model = {
62
64
  ...model,
@@ -68,7 +70,6 @@ export function getModelConfig(): {
68
70
  supportsReasoningEffort: false,
69
71
  supportsUsageInStreaming: false,
70
72
  supportsStrictMode: false,
71
- maxTokensField: 'max_tokens' as const,
72
73
  },
73
74
  };
74
75
  if (customApiVariant) {
@@ -35,3 +35,26 @@ Persona: Methodical, strictly objective, execution-oriented. Zero fluff. Never u
35
35
  - Reply in the user's language.
36
36
  - Use clean Markdown (tables, lists, bold).
37
37
  - End with concrete next actions if the task is incomplete.`;
38
+
39
+ /**
40
+ * Lean system prompt for "organize uploaded files" mode.
41
+ *
42
+ * Design goal: ~200 tokens (vs ~600 for general). Strips everything the
43
+ * organize task doesn't need: anti-hallucination (no KB Q&A), cite sources,
44
+ * smart recovery, skills/MCP discovery, output formatting.
45
+ *
46
+ * The full SKILL.md is NOT loaded in organize mode — only the bootstrap
47
+ * README.md (for KB structure awareness) is injected by route.ts.
48
+ */
49
+ export const ORGANIZE_SYSTEM_PROMPT = `You are MindOS Agent — an expert at organizing information into a local Markdown knowledge base.
50
+
51
+ Your ONLY job: read the user's uploaded files, extract key information, and save well-structured Markdown notes into the knowledge base using file tools.
52
+
53
+ Rules:
54
+ 1. Read uploaded file content from the "USER-UPLOADED FILES" section below — do NOT call read tools on them.
55
+ 2. Use \`list_files\` to understand the existing KB structure before deciding where to place notes.
56
+ 3. Create new files or update existing ones. Prefer \`create_file\` for new content, \`update_section\` / \`append_to_file\` for additions to existing files.
57
+ 4. Match the language of the source files when writing notes.
58
+ 5. Batch parallel tool calls in a single turn for efficiency.
59
+ 6. Do NOT write to the KB root directory — place files under the most fitting subdirectory.
60
+ 7. After writing, provide a brief summary of what you created/updated.`;
@@ -173,6 +173,18 @@ export const WRITE_TOOLS = new Set([
173
173
  'update_section', 'edit_lines', 'delete_file', 'rename_file', 'move_file', 'append_csv',
174
174
  ]);
175
175
 
176
+ /** Tool names sufficient for the "organize uploaded files" task. */
177
+ const ORGANIZE_TOOL_NAMES = new Set([
178
+ 'list_files', 'read_file', 'search',
179
+ 'create_file', 'batch_create_files', 'write_file',
180
+ 'append_to_file', 'insert_after_heading', 'update_section',
181
+ ]);
182
+
183
+ /** Lean tool set for organize mode — skips MCP discovery, history, backlinks, etc. */
184
+ export function getOrganizeTools(): AgentTool<any>[] {
185
+ return knowledgeBaseTools.filter(t => ORGANIZE_TOOL_NAMES.has(t.name));
186
+ }
187
+
176
188
  export async function getRequestScopedTools(): Promise<AgentTool<any>[]> {
177
189
  try {
178
190
  const result = await listMcporterServers();
@@ -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,8 @@ 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',
839
847
  },
840
848
  agent: {
841
849
  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,8 @@ export const zh = {
860
866
  testKeyNetworkError: '网络错误',
861
867
  testKeyNoKey: '未配置 API Key',
862
868
  testKeyUnknown: '测试失败',
869
+ listModels: '选择模型',
870
+ noModelsFound: '未找到可用模型',
863
871
  },
864
872
  agent: {
865
873
  title: 'Agent 行为',
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.20",
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",