@geminilight/mindos 0.5.12 → 0.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/app/api/ask/route.ts +66 -17
- package/app/app/api/mcp/status/route.ts +10 -15
- package/app/app/api/restart/route.ts +5 -4
- package/app/app/api/settings/route.ts +2 -0
- package/app/app/api/sync/route.ts +6 -7
- package/app/components/AskModal.tsx +1 -12
- package/app/components/SettingsModal.tsx +7 -3
- package/app/components/ask/MessageList.tsx +8 -5
- package/app/components/ask/ThinkingBlock.tsx +55 -0
- package/app/components/ask/ToolCallBlock.tsx +11 -3
- package/app/components/settings/AiTab.tsx +76 -2
- package/app/components/settings/types.ts +8 -0
- package/app/lib/agent/context.ts +317 -0
- package/app/lib/agent/index.ts +4 -0
- package/app/lib/agent/prompt.ts +36 -53
- package/app/lib/agent/stream-consumer.ts +36 -2
- package/app/lib/agent/tools.ts +37 -4
- package/app/lib/i18n.ts +28 -0
- package/app/lib/settings.ts +22 -0
- package/app/lib/types.ts +6 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +0 -1
- package/bin/cli.js +4 -0
- package/bin/lib/build.js +6 -2
- package/bin/lib/sync.js +83 -40
- package/package.json +3 -2
- package/scripts/setup.js +5 -0
- package/skills/mindos/SKILL.md +47 -183
- package/skills/mindos-zh/SKILL.md +47 -183
- package/app/package-lock.json +0 -15615
package/app/app/api/ask/route.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { getFileContent, getMindRoot } from '@/lib/fs';
|
|
7
|
-
import { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT } from '@/lib/agent';
|
|
7
|
+
import { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT, estimateTokens, estimateStringTokens, getContextLimit, needsCompact, truncateToolOutputs, compactMessages, hardPrune } from '@/lib/agent';
|
|
8
|
+
import { effectiveAiConfig, readSettings } from '@/lib/settings';
|
|
8
9
|
import type { Message as FrontendMessage, ToolCallPart as FrontendToolCallPart } from '@/lib/types';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -62,6 +63,7 @@ function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
|
|
|
62
63
|
completedToolCalls.push(part);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
// 'reasoning' parts are display-only; not sent back to model
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
if (assistantContent.length > 0) {
|
|
@@ -124,8 +126,19 @@ export async function POST(req: NextRequest) {
|
|
|
124
126
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
const { messages, currentFile, attachedFiles, uploadedFiles
|
|
128
|
-
|
|
129
|
+
const { messages, currentFile, attachedFiles, uploadedFiles } = body;
|
|
130
|
+
|
|
131
|
+
// Read agent config from settings
|
|
132
|
+
// NOTE: readSettings() is also called inside getModel() → effectiveAiConfig().
|
|
133
|
+
// Acceptable duplication — both are sync fs reads with identical results.
|
|
134
|
+
const serverSettings = readSettings();
|
|
135
|
+
const agentConfig = serverSettings.agent ?? {};
|
|
136
|
+
const stepLimit = Number.isFinite(body.maxSteps)
|
|
137
|
+
? Math.min(30, Math.max(1, Number(body.maxSteps)))
|
|
138
|
+
: Math.min(30, Math.max(1, agentConfig.maxSteps ?? 20));
|
|
139
|
+
const enableThinking = agentConfig.enableThinking ?? false;
|
|
140
|
+
const thinkingBudget = agentConfig.thinkingBudget ?? 5000;
|
|
141
|
+
const contextStrategy = agentConfig.contextStrategy ?? 'auto';
|
|
129
142
|
|
|
130
143
|
// Auto-load skill + bootstrap context for each request.
|
|
131
144
|
const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
|
|
@@ -143,19 +156,21 @@ export async function POST(req: NextRequest) {
|
|
|
143
156
|
target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
|
|
144
157
|
};
|
|
145
158
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
// Only report failures — when everything loads fine, a single summary line suffices.
|
|
160
|
+
const initFailures: string[] = [];
|
|
161
|
+
if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
|
|
162
|
+
if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
|
|
163
|
+
if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
|
|
164
|
+
if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
|
|
165
|
+
if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
|
|
166
|
+
if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
|
|
167
|
+
if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
|
|
168
|
+
if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
|
|
169
|
+
if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
|
|
170
|
+
|
|
171
|
+
const initStatus = initFailures.length === 0
|
|
172
|
+
? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`
|
|
173
|
+
: `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`;
|
|
159
174
|
|
|
160
175
|
const initContextBlocks: string[] = [];
|
|
161
176
|
if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
|
|
@@ -227,7 +242,34 @@ export async function POST(req: NextRequest) {
|
|
|
227
242
|
|
|
228
243
|
try {
|
|
229
244
|
const model = getModel();
|
|
230
|
-
const
|
|
245
|
+
const cfg = effectiveAiConfig();
|
|
246
|
+
const modelName = cfg.provider === 'openai' ? cfg.openaiModel : cfg.anthropicModel;
|
|
247
|
+
let modelMessages = convertToModelMessages(messages);
|
|
248
|
+
|
|
249
|
+
// Phase 3: Context management pipeline
|
|
250
|
+
// 1. Truncate tool outputs in historical messages
|
|
251
|
+
modelMessages = truncateToolOutputs(modelMessages);
|
|
252
|
+
|
|
253
|
+
const preTokens = estimateTokens(modelMessages);
|
|
254
|
+
const sysTokens = estimateStringTokens(systemPrompt);
|
|
255
|
+
const ctxLimit = getContextLimit(modelName);
|
|
256
|
+
console.log(`[ask] Context: ~${preTokens + sysTokens} tokens (messages=${preTokens}, system=${sysTokens}), limit=${ctxLimit}`);
|
|
257
|
+
|
|
258
|
+
// 2. Compact if >70% context limit (skip if user disabled)
|
|
259
|
+
if (contextStrategy === 'auto' && needsCompact(modelMessages, systemPrompt, modelName)) {
|
|
260
|
+
console.log('[ask] Context >70% limit, compacting...');
|
|
261
|
+
const result = await compactMessages(modelMessages, model);
|
|
262
|
+
modelMessages = result.messages;
|
|
263
|
+
if (result.compacted) {
|
|
264
|
+
const postTokens = estimateTokens(modelMessages);
|
|
265
|
+
console.log(`[ask] After compact: ~${postTokens + sysTokens} tokens`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log('[ask] Compact skipped (too few messages), hard prune will handle overflow if needed');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 3. Hard prune if still >90% context limit
|
|
272
|
+
modelMessages = hardPrune(modelMessages, systemPrompt, modelName);
|
|
231
273
|
|
|
232
274
|
// Phase 2: Step monitoring + loop detection
|
|
233
275
|
const stepHistory: Array<{ tool: string; input: string }> = [];
|
|
@@ -240,6 +282,13 @@ export async function POST(req: NextRequest) {
|
|
|
240
282
|
messages: modelMessages,
|
|
241
283
|
tools: knowledgeBaseTools,
|
|
242
284
|
stopWhen: stepCountIs(stepLimit),
|
|
285
|
+
...(enableThinking && cfg.provider === 'anthropic' ? {
|
|
286
|
+
providerOptions: {
|
|
287
|
+
anthropic: {
|
|
288
|
+
thinking: { type: 'enabled', budgetTokens: thinkingBudget },
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
} : {}),
|
|
243
292
|
|
|
244
293
|
onStepFinish: ({ toolCalls, usage }) => {
|
|
245
294
|
if (toolCalls) {
|
|
@@ -6,28 +6,23 @@ export async function GET() {
|
|
|
6
6
|
try {
|
|
7
7
|
const settings = readSettings();
|
|
8
8
|
const port = settings.mcpPort ?? 8781;
|
|
9
|
-
const
|
|
9
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
10
|
+
const endpoint = `${baseUrl}/mcp`;
|
|
10
11
|
const authConfigured = !!settings.authToken;
|
|
11
12
|
|
|
12
|
-
// Check if MCP server is running
|
|
13
13
|
let running = false;
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
try {
|
|
16
|
+
// Use the health endpoint — avoids MCP handshake complexity
|
|
17
|
+
const healthUrl = `${baseUrl}/api/health`;
|
|
16
18
|
const controller = new AbortController();
|
|
17
19
|
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
18
|
-
const res = await fetch(
|
|
19
|
-
method: 'POST',
|
|
20
|
-
headers: { 'Content-Type': 'application/json' },
|
|
21
|
-
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
22
|
-
signal: controller.signal,
|
|
23
|
-
});
|
|
20
|
+
const res = await fetch(healthUrl, { signal: controller.signal, cache: 'no-store' });
|
|
24
21
|
clearTimeout(timeout);
|
|
22
|
+
|
|
25
23
|
if (res.ok) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const data = await res.json();
|
|
29
|
-
if (data?.result?.tools) toolCount = data.result.tools.length;
|
|
30
|
-
} catch { /* non-JSON response — still running */ }
|
|
24
|
+
const data = await res.json() as { ok?: boolean; service?: string };
|
|
25
|
+
running = data.ok === true && data.service === 'mindos';
|
|
31
26
|
}
|
|
32
27
|
} catch {
|
|
33
28
|
// Connection refused or timeout — not running
|
|
@@ -38,7 +33,7 @@ export async function GET() {
|
|
|
38
33
|
transport: 'http',
|
|
39
34
|
endpoint,
|
|
40
35
|
port,
|
|
41
|
-
toolCount,
|
|
36
|
+
toolCount: running ? 20 : 0,
|
|
42
37
|
authConfigured,
|
|
43
38
|
});
|
|
44
39
|
} catch (err) {
|
|
@@ -5,8 +5,8 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
export async function POST() {
|
|
7
7
|
try {
|
|
8
|
-
|
|
9
|
-
const
|
|
8
|
+
const cliPath = process.env.MINDOS_CLI_PATH || resolve(process.cwd(), '..', 'bin', 'cli.js');
|
|
9
|
+
const nodeBin = process.env.MINDOS_NODE_BIN || process.execPath;
|
|
10
10
|
// Use 'restart' (stop all → wait for ports free → start) instead of bare
|
|
11
11
|
// 'start' which would fail assertPortFree because the current process and
|
|
12
12
|
// its MCP child are still holding the ports.
|
|
@@ -28,7 +28,7 @@ export async function POST() {
|
|
|
28
28
|
delete childEnv.WEB_PASSWORD;
|
|
29
29
|
if (oldWebPort) childEnv.MINDOS_OLD_WEB_PORT = oldWebPort;
|
|
30
30
|
if (oldMcpPort) childEnv.MINDOS_OLD_MCP_PORT = oldMcpPort;
|
|
31
|
-
const child = spawn(
|
|
31
|
+
const child = spawn(nodeBin, [cliPath, 'restart'], {
|
|
32
32
|
detached: true,
|
|
33
33
|
stdio: 'ignore',
|
|
34
34
|
env: childEnv,
|
|
@@ -41,6 +41,7 @@ export async function POST() {
|
|
|
41
41
|
setTimeout(() => process.exit(0), 1500);
|
|
42
42
|
return NextResponse.json({ ok: true });
|
|
43
43
|
} catch (err) {
|
|
44
|
-
|
|
44
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
45
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -49,6 +49,7 @@ export async function GET() {
|
|
|
49
49
|
webPassword: settings.webPassword ? '***set***' : '',
|
|
50
50
|
authToken: maskToken(settings.authToken),
|
|
51
51
|
mcpPort: settings.mcpPort ?? 8781,
|
|
52
|
+
agent: settings.agent ?? {},
|
|
52
53
|
envOverrides: {
|
|
53
54
|
AI_PROVIDER: !!process.env.AI_PROVIDER,
|
|
54
55
|
ANTHROPIC_API_KEY: !!process.env.ANTHROPIC_API_KEY,
|
|
@@ -110,6 +111,7 @@ export async function POST(req: NextRequest) {
|
|
|
110
111
|
},
|
|
111
112
|
},
|
|
112
113
|
mindRoot: body.mindRoot ?? current.mindRoot,
|
|
114
|
+
agent: body.agent ?? current.agent,
|
|
113
115
|
webPassword: resolvedWebPassword,
|
|
114
116
|
authToken: resolvedAuthToken,
|
|
115
117
|
port: typeof body.port === 'number' ? body.port : current.port,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const dynamic = 'force-dynamic';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
-
import { execSync,
|
|
3
|
+
import { execSync, execFile } from 'child_process';
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
5
5
|
import { join, resolve } from 'path';
|
|
6
6
|
import { homedir } from 'os';
|
|
@@ -37,18 +37,17 @@ function isGitRepo(dir: string) {
|
|
|
37
37
|
return existsSync(join(dir, '.git'));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Resolve path to bin/cli.js
|
|
40
|
+
/** Resolve path to bin/cli.js — prefer env var set by CLI launcher, fall back to cwd. */
|
|
41
41
|
function getCliPath() {
|
|
42
|
-
return resolve(process.cwd(), '..', 'bin', 'cli' + '.js');
|
|
42
|
+
return process.env.MINDOS_CLI_PATH || resolve(process.cwd(), '..', 'bin', 'cli' + '.js');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** Run CLI command via
|
|
45
|
+
/** Run CLI command via execFile — avoids shell injection by passing args as array */
|
|
46
46
|
function runCli(args: string[], timeoutMs = 30000): Promise<void> {
|
|
47
47
|
const cliPath = getCliPath();
|
|
48
|
-
const
|
|
49
|
-
const cmd = `${process.execPath} ${cliPath} ${escaped}`;
|
|
48
|
+
const nodeBin = process.env.MINDOS_NODE_BIN || process.execPath;
|
|
50
49
|
return new Promise((res, rej) => {
|
|
51
|
-
|
|
50
|
+
execFile(nodeBin, [cliPath, ...args], { timeout: timeoutMs }, (err, _stdout, stderr) => {
|
|
52
51
|
if (err) rej(new Error(stderr?.trim() || err.message));
|
|
53
52
|
else res();
|
|
54
53
|
});
|
|
@@ -28,7 +28,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
28
28
|
const [isLoading, setIsLoading] = useState(false);
|
|
29
29
|
const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
|
|
30
30
|
const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
|
|
31
|
-
const [maxSteps, setMaxSteps] = useState(20);
|
|
32
31
|
const [showHistory, setShowHistory] = useState(false);
|
|
33
32
|
|
|
34
33
|
const session = useAskSession(currentFile);
|
|
@@ -136,7 +135,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
136
135
|
currentFile,
|
|
137
136
|
attachedFiles,
|
|
138
137
|
uploadedFiles: upload.localAttachments,
|
|
139
|
-
maxSteps,
|
|
140
138
|
}),
|
|
141
139
|
signal: controller.signal,
|
|
142
140
|
});
|
|
@@ -208,7 +206,7 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
208
206
|
setIsLoading(false);
|
|
209
207
|
abortRef.current = null;
|
|
210
208
|
}
|
|
211
|
-
}, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery,
|
|
209
|
+
}, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped]);
|
|
212
210
|
|
|
213
211
|
const handleResetSession = useCallback(() => {
|
|
214
212
|
if (isLoading) return;
|
|
@@ -287,7 +285,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
287
285
|
emptyPrompt={t.ask.emptyPrompt}
|
|
288
286
|
suggestions={t.ask.suggestions}
|
|
289
287
|
onSuggestionClick={setInput}
|
|
290
|
-
maxSteps={maxSteps}
|
|
291
288
|
labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
|
|
292
289
|
/>
|
|
293
290
|
|
|
@@ -389,14 +386,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
389
386
|
<div className="hidden md:flex px-4 pb-2 items-center gap-3 text-xs text-muted-foreground/50 shrink-0">
|
|
390
387
|
<span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
|
|
391
388
|
<span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
|
|
392
|
-
<span className="inline-flex items-center gap-1">
|
|
393
|
-
<span>Agent steps</span>
|
|
394
|
-
<select value={maxSteps} onChange={(e) => setMaxSteps(Number(e.target.value))} disabled={isLoading} className="bg-transparent border border-border rounded px-1.5 py-0.5 text-xs text-foreground">
|
|
395
|
-
<option value={10}>10</option>
|
|
396
|
-
<option value={20}>20</option>
|
|
397
|
-
<option value={30}>30</option>
|
|
398
|
-
</select>
|
|
399
|
-
</span>
|
|
400
389
|
<span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>
|
|
401
390
|
</div>
|
|
402
391
|
</div>
|
|
@@ -6,7 +6,7 @@ import { useLocale } from '@/lib/LocaleContext';
|
|
|
6
6
|
import { getAllRenderers, loadDisabledState, isRendererEnabled } from '@/lib/renderers/registry';
|
|
7
7
|
import { apiFetch } from '@/lib/api';
|
|
8
8
|
import '@/lib/renderers/index';
|
|
9
|
-
import type { AiSettings, SettingsData, Tab } from './settings/types';
|
|
9
|
+
import type { AiSettings, AgentSettings, SettingsData, Tab } from './settings/types';
|
|
10
10
|
import { FONTS } from './settings/types';
|
|
11
11
|
import { AiTab } from './settings/AiTab';
|
|
12
12
|
import { AppearanceTab } from './settings/AppearanceTab';
|
|
@@ -88,7 +88,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
88
88
|
await apiFetch('/api/settings', {
|
|
89
89
|
method: 'POST',
|
|
90
90
|
headers: { 'Content-Type': 'application/json' },
|
|
91
|
-
body: JSON.stringify({ ai: data.ai, mindRoot: data.mindRoot, webPassword: data.webPassword, authToken: data.authToken }),
|
|
91
|
+
body: JSON.stringify({ ai: data.ai, agent: data.agent, mindRoot: data.mindRoot, webPassword: data.webPassword, authToken: data.authToken }),
|
|
92
92
|
});
|
|
93
93
|
setStatus('saved');
|
|
94
94
|
setTimeout(() => setStatus('idle'), 2500);
|
|
@@ -104,6 +104,10 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
104
104
|
setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
|
|
105
105
|
}, []);
|
|
106
106
|
|
|
107
|
+
const updateAgent = useCallback((patch: Partial<AgentSettings>) => {
|
|
108
|
+
setData(d => d ? { ...d, agent: { ...(d.agent ?? {}), ...patch } } : d);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
107
111
|
const restoreFromEnv = useCallback(async () => {
|
|
108
112
|
if (!data) return;
|
|
109
113
|
const defaults: AiSettings = {
|
|
@@ -198,7 +202,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
198
202
|
</div>
|
|
199
203
|
) : (
|
|
200
204
|
<>
|
|
201
|
-
{tab === 'ai' && data?.ai && <AiTab data={data} updateAi={updateAi} t={t} />}
|
|
205
|
+
{tab === 'ai' && data?.ai && <AiTab data={data} updateAi={updateAi} updateAgent={updateAgent} t={t} />}
|
|
202
206
|
{tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
|
|
203
207
|
{tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
|
|
204
208
|
{tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
|
|
@@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown';
|
|
|
6
6
|
import remarkGfm from 'remark-gfm';
|
|
7
7
|
import type { Message } from '@/lib/types';
|
|
8
8
|
import ToolCallBlock from './ToolCallBlock';
|
|
9
|
+
import ThinkingBlock from './ThinkingBlock';
|
|
9
10
|
|
|
10
11
|
function AssistantMessage({ content, isStreaming }: { content: string; isStreaming: boolean }) {
|
|
11
12
|
return (
|
|
@@ -45,6 +46,10 @@ function AssistantMessageWithParts({ message, isStreaming }: { message: Message;
|
|
|
45
46
|
return (
|
|
46
47
|
<div>
|
|
47
48
|
{parts.map((part, idx) => {
|
|
49
|
+
if (part.type === 'reasoning') {
|
|
50
|
+
const isLastPart = isStreaming && idx === parts.length - 1;
|
|
51
|
+
return <ThinkingBlock key={`reasoning-${idx}`} text={part.text} isStreaming={isLastPart} />;
|
|
52
|
+
}
|
|
48
53
|
if (part.type === 'text') {
|
|
49
54
|
const isLastTextPart = isStreaming && idx === parts.length - 1;
|
|
50
55
|
return part.text ? (
|
|
@@ -66,7 +71,7 @@ function AssistantMessageWithParts({ message, isStreaming }: { message: Message;
|
|
|
66
71
|
);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
function StepCounter({ parts
|
|
74
|
+
function StepCounter({ parts }: { parts: Message['parts'] }) {
|
|
70
75
|
if (!parts) return null;
|
|
71
76
|
const toolCalls = parts.filter(p => p.type === 'tool-call');
|
|
72
77
|
if (toolCalls.length === 0) return null;
|
|
@@ -75,7 +80,7 @@ function StepCounter({ parts, maxSteps }: { parts: Message['parts']; maxSteps?:
|
|
|
75
80
|
return (
|
|
76
81
|
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground/70">
|
|
77
82
|
<Wrench size={10} />
|
|
78
|
-
<span>Step {toolCalls.length}{
|
|
83
|
+
<span>Step {toolCalls.length}{toolLabel ? ` — ${toolLabel}` : ''}</span>
|
|
79
84
|
</div>
|
|
80
85
|
);
|
|
81
86
|
}
|
|
@@ -87,7 +92,6 @@ interface MessageListProps {
|
|
|
87
92
|
emptyPrompt: string;
|
|
88
93
|
suggestions: readonly string[];
|
|
89
94
|
onSuggestionClick: (text: string) => void;
|
|
90
|
-
maxSteps?: number;
|
|
91
95
|
labels: {
|
|
92
96
|
connecting: string;
|
|
93
97
|
thinking: string;
|
|
@@ -102,7 +106,6 @@ export default function MessageList({
|
|
|
102
106
|
emptyPrompt,
|
|
103
107
|
suggestions,
|
|
104
108
|
onSuggestionClick,
|
|
105
|
-
maxSteps,
|
|
106
109
|
labels,
|
|
107
110
|
}: MessageListProps) {
|
|
108
111
|
const endRef = useRef<HTMLDivElement>(null);
|
|
@@ -160,7 +163,7 @@ export default function MessageList({
|
|
|
160
163
|
<>
|
|
161
164
|
<AssistantMessageWithParts message={m} isStreaming={isLoading && i === messages.length - 1} />
|
|
162
165
|
{isLoading && i === messages.length - 1 && (
|
|
163
|
-
<StepCounter parts={m.parts}
|
|
166
|
+
<StepCounter parts={m.parts} />
|
|
164
167
|
)}
|
|
165
168
|
</>
|
|
166
169
|
) : isLoading && i === messages.length - 1 ? (
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
|
|
5
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
|
+
|
|
7
|
+
interface ThinkingBlockProps {
|
|
8
|
+
text: string;
|
|
9
|
+
isStreaming?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ThinkingBlock({ text, isStreaming }: ThinkingBlockProps) {
|
|
13
|
+
const [expanded, setExpanded] = useState(false);
|
|
14
|
+
const { t } = useLocale();
|
|
15
|
+
|
|
16
|
+
if (!text && !isStreaming) return null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="my-1 rounded-md border border-border/40 bg-muted/20 text-xs">
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
onClick={() => setExpanded(v => !v)}
|
|
23
|
+
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/30 transition-colors rounded-md"
|
|
24
|
+
>
|
|
25
|
+
{expanded ? (
|
|
26
|
+
<ChevronDown size={12} className="shrink-0 text-muted-foreground" />
|
|
27
|
+
) : (
|
|
28
|
+
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
|
29
|
+
)}
|
|
30
|
+
<Brain size={12} className="shrink-0 text-muted-foreground" />
|
|
31
|
+
<span className="text-muted-foreground font-medium">
|
|
32
|
+
{t.ask.thinkingLabel}
|
|
33
|
+
{isStreaming && !expanded && (
|
|
34
|
+
<span className="ml-1 animate-pulse">...</span>
|
|
35
|
+
)}
|
|
36
|
+
</span>
|
|
37
|
+
{!expanded && text && (
|
|
38
|
+
<span className="text-muted-foreground/60 truncate flex-1 ml-1">
|
|
39
|
+
{text.slice(0, 80)}{text.length > 80 ? '...' : ''}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
</button>
|
|
43
|
+
{expanded && (
|
|
44
|
+
<div className="px-2 pb-2 pt-0.5 border-t border-border/30">
|
|
45
|
+
<div className="text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
|
46
|
+
{text}
|
|
47
|
+
{isStreaming && (
|
|
48
|
+
<span className="inline-block w-1 h-3 bg-muted-foreground/40 ml-0.5 align-middle animate-pulse rounded-sm" />
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { ChevronRight, ChevronDown, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
|
4
|
+
import { ChevronRight, ChevronDown, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
|
|
5
5
|
import type { ToolCallPart } from '@/lib/types';
|
|
6
6
|
|
|
7
|
+
const DESTRUCTIVE_TOOLS = new Set(['delete_file', 'move_file', 'rename_file', 'write_file']);
|
|
8
|
+
|
|
7
9
|
const TOOL_ICONS: Record<string, string> = {
|
|
8
10
|
search: '🔍',
|
|
9
11
|
list_files: '📂',
|
|
@@ -48,17 +50,23 @@ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
|
|
|
48
50
|
const [expanded, setExpanded] = useState(false);
|
|
49
51
|
const icon = TOOL_ICONS[part.toolName] ?? '🔧';
|
|
50
52
|
const inputSummary = formatInput(part.input);
|
|
53
|
+
const isDestructive = DESTRUCTIVE_TOOLS.has(part.toolName);
|
|
51
54
|
|
|
52
55
|
return (
|
|
53
|
-
<div className=
|
|
56
|
+
<div className={`my-1 rounded-md border text-xs font-mono ${
|
|
57
|
+
isDestructive
|
|
58
|
+
? 'border-amber-500/30 bg-amber-500/5'
|
|
59
|
+
: 'border-border/50 bg-muted/30'
|
|
60
|
+
}`}>
|
|
54
61
|
<button
|
|
55
62
|
type="button"
|
|
56
63
|
onClick={() => setExpanded(v => !v)}
|
|
57
64
|
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/50 transition-colors rounded-md"
|
|
58
65
|
>
|
|
59
66
|
{expanded ? <ChevronDown size={12} className="shrink-0 text-muted-foreground" /> : <ChevronRight size={12} className="shrink-0 text-muted-foreground" />}
|
|
67
|
+
{isDestructive && <AlertTriangle size={11} className="shrink-0 text-amber-500" />}
|
|
60
68
|
<span>{icon}</span>
|
|
61
|
-
<span className=
|
|
69
|
+
<span className={`font-medium ${isDestructive ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>{part.toolName}</span>
|
|
62
70
|
<span className="text-muted-foreground truncate flex-1">({inputSummary})</span>
|
|
63
71
|
<span className="shrink-0 ml-auto">
|
|
64
72
|
{part.state === 'pending' || part.state === 'running' ? (
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
4
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
5
|
-
import type { AiSettings, ProviderConfig, SettingsData } from './types';
|
|
5
|
+
import type { AiSettings, AgentSettings, ProviderConfig, SettingsData } from './types';
|
|
6
6
|
import { Field, Select, Input, EnvBadge, ApiKeyInput } from './Primitives';
|
|
7
7
|
|
|
8
8
|
type TestState = 'idle' | 'testing' | 'ok' | 'error';
|
|
@@ -28,10 +28,11 @@ function errorMessage(t: any, code?: ErrorCode): string {
|
|
|
28
28
|
interface AiTabProps {
|
|
29
29
|
data: SettingsData;
|
|
30
30
|
updateAi: (patch: Partial<AiSettings>) => void;
|
|
31
|
+
updateAgent: (patch: Partial<AgentSettings>) => void;
|
|
31
32
|
t: any;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
export function AiTab({ data, updateAi, t }: AiTabProps) {
|
|
35
|
+
export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
35
36
|
const env = data.envOverrides ?? {};
|
|
36
37
|
const envVal = data.envValues ?? {};
|
|
37
38
|
const provider = data.ai.provider;
|
|
@@ -224,6 +225,79 @@ export function AiTab({ data, updateAi, t }: AiTabProps) {
|
|
|
224
225
|
<span>{t.settings.ai.envHint}</span>
|
|
225
226
|
</div>
|
|
226
227
|
)}
|
|
228
|
+
|
|
229
|
+
{/* Agent Behavior */}
|
|
230
|
+
<div className="pt-3 border-t border-border">
|
|
231
|
+
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">{t.settings.agent.title}</h3>
|
|
232
|
+
|
|
233
|
+
<div className="space-y-4">
|
|
234
|
+
<Field label={t.settings.agent.maxSteps} hint={t.settings.agent.maxStepsHint}>
|
|
235
|
+
<Select
|
|
236
|
+
value={String(data.agent?.maxSteps ?? 20)}
|
|
237
|
+
onChange={e => updateAgent({ maxSteps: Number(e.target.value) })}
|
|
238
|
+
>
|
|
239
|
+
<option value="5">5</option>
|
|
240
|
+
<option value="10">10</option>
|
|
241
|
+
<option value="15">15</option>
|
|
242
|
+
<option value="20">20</option>
|
|
243
|
+
<option value="25">25</option>
|
|
244
|
+
<option value="30">30</option>
|
|
245
|
+
</Select>
|
|
246
|
+
</Field>
|
|
247
|
+
|
|
248
|
+
<Field label={t.settings.agent.contextStrategy} hint={t.settings.agent.contextStrategyHint}>
|
|
249
|
+
<Select
|
|
250
|
+
value={data.agent?.contextStrategy ?? 'auto'}
|
|
251
|
+
onChange={e => updateAgent({ contextStrategy: e.target.value as 'auto' | 'off' })}
|
|
252
|
+
>
|
|
253
|
+
<option value="auto">{t.settings.agent.contextStrategyAuto}</option>
|
|
254
|
+
<option value="off">{t.settings.agent.contextStrategyOff}</option>
|
|
255
|
+
</Select>
|
|
256
|
+
</Field>
|
|
257
|
+
|
|
258
|
+
{provider === 'anthropic' && (
|
|
259
|
+
<>
|
|
260
|
+
<div className="flex items-center justify-between">
|
|
261
|
+
<div>
|
|
262
|
+
<div className="text-sm text-foreground">{t.settings.agent.thinking}</div>
|
|
263
|
+
<div className="text-xs text-muted-foreground mt-0.5">{t.settings.agent.thinkingHint}</div>
|
|
264
|
+
</div>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
role="switch"
|
|
268
|
+
aria-checked={data.agent?.enableThinking ?? false}
|
|
269
|
+
onClick={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })}
|
|
270
|
+
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
271
|
+
data.agent?.enableThinking ? 'bg-amber-500' : 'bg-muted'
|
|
272
|
+
}`}
|
|
273
|
+
>
|
|
274
|
+
<span
|
|
275
|
+
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
276
|
+
data.agent?.enableThinking ? 'translate-x-4' : 'translate-x-0'
|
|
277
|
+
}`}
|
|
278
|
+
/>
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{data.agent?.enableThinking && (
|
|
283
|
+
<Field label={t.settings.agent.thinkingBudget} hint={t.settings.agent.thinkingBudgetHint}>
|
|
284
|
+
<Input
|
|
285
|
+
type="number"
|
|
286
|
+
value={String(data.agent?.thinkingBudget ?? 5000)}
|
|
287
|
+
onChange={e => {
|
|
288
|
+
const v = parseInt(e.target.value, 10);
|
|
289
|
+
if (!isNaN(v)) updateAgent({ thinkingBudget: Math.max(1000, Math.min(50000, v)) });
|
|
290
|
+
}}
|
|
291
|
+
min={1000}
|
|
292
|
+
max={50000}
|
|
293
|
+
step={1000}
|
|
294
|
+
/>
|
|
295
|
+
</Field>
|
|
296
|
+
)}
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
227
301
|
</div>
|
|
228
302
|
);
|
|
229
303
|
}
|
|
@@ -14,8 +14,16 @@ export interface AiSettings {
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface AgentSettings {
|
|
18
|
+
maxSteps?: number;
|
|
19
|
+
enableThinking?: boolean;
|
|
20
|
+
thinkingBudget?: number;
|
|
21
|
+
contextStrategy?: 'auto' | 'off';
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
export interface SettingsData {
|
|
18
25
|
ai: AiSettings;
|
|
26
|
+
agent?: AgentSettings;
|
|
19
27
|
mindRoot: string;
|
|
20
28
|
webPassword?: string;
|
|
21
29
|
authToken?: string; // masked: first-xxxx-••••-last pattern
|