@geminilight/mindos 0.5.10 → 0.5.12
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/README.md +9 -9
- package/README_zh.md +9 -9
- package/app/README.md +2 -2
- package/app/app/api/ask/route.ts +126 -3
- package/app/app/api/mcp/install/route.ts +1 -1
- package/app/app/api/mcp/status/route.ts +1 -1
- package/app/app/api/settings/route.ts +1 -1
- package/app/app/api/settings/test-key/route.ts +111 -0
- package/app/app/api/setup/route.ts +7 -7
- package/app/app/api/sync/route.ts +30 -40
- package/app/components/AskModal.tsx +28 -21
- package/app/components/ask/MessageList.tsx +62 -3
- package/app/components/ask/ToolCallBlock.tsx +89 -0
- package/app/components/settings/AiTab.tsx +120 -2
- package/app/components/setup/StepReview.tsx +31 -25
- package/app/components/setup/index.tsx +6 -3
- package/app/instrumentation.ts +19 -0
- package/app/lib/agent/prompt.ts +32 -0
- package/app/lib/agent/stream-consumer.ts +178 -0
- package/app/lib/agent/tools.ts +122 -0
- package/app/lib/i18n.ts +18 -0
- package/app/lib/types.ts +18 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +49 -22
- package/bin/lib/gateway.js +24 -3
- package/bin/lib/mcp-install.js +2 -2
- package/bin/lib/mcp-spawn.js +3 -3
- package/bin/lib/stop.js +1 -1
- package/bin/lib/sync.js +61 -11
- package/mcp/README.md +5 -5
- package/mcp/src/index.ts +2 -2
- package/package.json +4 -2
- package/scripts/setup.js +12 -12
- package/scripts/upgrade-prompt.md +6 -6
- package/assets/images/demo-flow-dark.png +0 -0
- package/assets/images/demo-flow-light.png +0 -0
- package/assets/images/demo-flow-zh-dark.png +0 -0
- package/assets/images/demo-flow-zh-light.png +0 -0
- package/assets/images/gui-sync-cv.png +0 -0
- package/assets/images/wechat-qr.png +0 -0
- package/mcp/package-lock.json +0 -1717
package/README.md
CHANGED
|
@@ -156,8 +156,8 @@ Or skip the wizard and edit `~/.mindos/config.json` manually (see Config Referen
|
|
|
156
156
|
```json
|
|
157
157
|
{
|
|
158
158
|
"mindRoot": "~/MindOS",
|
|
159
|
-
"port":
|
|
160
|
-
"mcpPort":
|
|
159
|
+
"port": 3456,
|
|
160
|
+
"mcpPort": 8781,
|
|
161
161
|
"authToken": "",
|
|
162
162
|
"webPassword": "",
|
|
163
163
|
"startMode": "daemon",
|
|
@@ -182,8 +182,8 @@ Or skip the wizard and edit `~/.mindos/config.json` manually (see Config Referen
|
|
|
182
182
|
| Field | Default | Description |
|
|
183
183
|
| :--- | :--- | :--- |
|
|
184
184
|
| `mindRoot` | `~/MindOS` | **Required**. Absolute path to the knowledge base root. |
|
|
185
|
-
| `port` | `
|
|
186
|
-
| `mcpPort` | `
|
|
185
|
+
| `port` | `3456` | Optional. Web app port. |
|
|
186
|
+
| `mcpPort` | `8781` | Optional. MCP server port. |
|
|
187
187
|
| `authToken` | — | Optional. Protects App `/api/*` and MCP `/mcp` with bearer token auth. For Agent / MCP clients. Recommended when exposed to a network. |
|
|
188
188
|
| `webPassword` | — | Optional. Protects the web UI with a login page. For browser access. Independent from `authToken`. |
|
|
189
189
|
| `startMode` | `start` | Start mode: `daemon` (background service, auto-starts on boot), `start` (foreground), or `dev`. |
|
|
@@ -257,15 +257,15 @@ mindos mcp install -g -y
|
|
|
257
257
|
Use `http` transport — MindOS must be running (`mindos start`) on the remote machine:
|
|
258
258
|
|
|
259
259
|
```bash
|
|
260
|
-
mindos mcp install--transport http --url http://<server-ip>:
|
|
260
|
+
mindos mcp install--transport http --url http://<server-ip>:8781/mcp --token your-token -g
|
|
261
261
|
```
|
|
262
262
|
|
|
263
263
|
> [!NOTE]
|
|
264
|
-
> For remote access, ensure port `
|
|
264
|
+
> For remote access, ensure port `8781` is open in your firewall/security-group.
|
|
265
265
|
|
|
266
266
|
> Add `-g` to install globally — MCP config is shared across all projects instead of the current directory only.
|
|
267
267
|
|
|
268
|
-
> The MCP port defaults to `
|
|
268
|
+
> The MCP port defaults to `8781`. To use a different port, run `mindos onboard` and set `mcpPort`.
|
|
269
269
|
|
|
270
270
|
<details>
|
|
271
271
|
<summary>Manual config (JSON snippets)</summary>
|
|
@@ -291,7 +291,7 @@ mindos mcp install--transport http --url http://<server-ip>:8787/mcp --token you
|
|
|
291
291
|
{
|
|
292
292
|
"mcpServers": {
|
|
293
293
|
"mindos": {
|
|
294
|
-
"url": "http://localhost:
|
|
294
|
+
"url": "http://localhost:8781/mcp",
|
|
295
295
|
"headers": { "Authorization": "Bearer your-token" }
|
|
296
296
|
}
|
|
297
297
|
}
|
|
@@ -304,7 +304,7 @@ mindos mcp install--transport http --url http://<server-ip>:8787/mcp --token you
|
|
|
304
304
|
{
|
|
305
305
|
"mcpServers": {
|
|
306
306
|
"mindos": {
|
|
307
|
-
"url": "http://<server-ip>:
|
|
307
|
+
"url": "http://<server-ip>:8781/mcp",
|
|
308
308
|
"headers": { "Authorization": "Bearer your-token" }
|
|
309
309
|
}
|
|
310
310
|
}
|
package/README_zh.md
CHANGED
|
@@ -158,8 +158,8 @@ mindos onboard --install-daemon
|
|
|
158
158
|
```json
|
|
159
159
|
{
|
|
160
160
|
"mindRoot": "~/MindOS",
|
|
161
|
-
"port":
|
|
162
|
-
"mcpPort":
|
|
161
|
+
"port": 3456,
|
|
162
|
+
"mcpPort": 8781,
|
|
163
163
|
"authToken": "",
|
|
164
164
|
"webPassword": "",
|
|
165
165
|
"startMode": "daemon",
|
|
@@ -184,8 +184,8 @@ mindos onboard --install-daemon
|
|
|
184
184
|
| 字段 | 默认值 | 说明 |
|
|
185
185
|
| :--- | :--- | :--- |
|
|
186
186
|
| `mindRoot` | `~/MindOS` | **必填**。知识库根目录的绝对路径 |
|
|
187
|
-
| `port` | `
|
|
188
|
-
| `mcpPort` | `
|
|
187
|
+
| `port` | `3456` | 可选。Web 服务端口 |
|
|
188
|
+
| `mcpPort` | `8781` | 可选。MCP 服务端口 |
|
|
189
189
|
| `authToken` | — | 可选。保护 App `/api/*` 和 MCP `/mcp` 的 Bearer Token 认证。供 Agent / MCP 客户端使用,暴露到网络时建议设置 |
|
|
190
190
|
| `webPassword` | — | 可选。为 Web UI 添加登录密码保护。供浏览器访问,与 `authToken` 相互独立 |
|
|
191
191
|
| `startMode` | `start` | 启动模式:`daemon`(后台服务,开机自启)、`start`(前台)或 `dev` |
|
|
@@ -259,15 +259,15 @@ mindos mcp install -g -y
|
|
|
259
259
|
使用 `http` transport — 远程机器上需先运行 `mindos start`:
|
|
260
260
|
|
|
261
261
|
```bash
|
|
262
|
-
mindos mcp install --transport http --url http://<服务器IP>:
|
|
262
|
+
mindos mcp install --transport http --url http://<服务器IP>:8781/mcp --token your-token -g
|
|
263
263
|
```
|
|
264
264
|
|
|
265
265
|
> [!NOTE]
|
|
266
|
-
> 远程访问时,请确保端口 `
|
|
266
|
+
> 远程访问时,请确保端口 `8781` 已在防火墙/安全组中放行。
|
|
267
267
|
|
|
268
268
|
> 加 `-g` 表示全局安装 — MCP 配置写入用户级配置文件,所有项目共享,而非仅当前目录。
|
|
269
269
|
|
|
270
|
-
> MCP 端口默认为 `
|
|
270
|
+
> MCP 端口默认为 `8781`。如需修改,运行 `mindos onboard` 设置 `mcpPort`。
|
|
271
271
|
|
|
272
272
|
<details>
|
|
273
273
|
<summary>手动配置(JSON 片段)</summary>
|
|
@@ -293,7 +293,7 @@ mindos mcp install --transport http --url http://<服务器IP>:8787/mcp --token
|
|
|
293
293
|
{
|
|
294
294
|
"mcpServers": {
|
|
295
295
|
"mindos": {
|
|
296
|
-
"url": "http://localhost:
|
|
296
|
+
"url": "http://localhost:8781/mcp",
|
|
297
297
|
"headers": { "Authorization": "Bearer your-token" }
|
|
298
298
|
}
|
|
299
299
|
}
|
|
@@ -306,7 +306,7 @@ mindos mcp install --transport http --url http://<服务器IP>:8787/mcp --token
|
|
|
306
306
|
{
|
|
307
307
|
"mcpServers": {
|
|
308
308
|
"mindos": {
|
|
309
|
-
"url": "http://<服务器IP>:
|
|
309
|
+
"url": "http://<服务器IP>:8781/mcp",
|
|
310
310
|
"headers": { "Authorization": "Bearer your-token" }
|
|
311
311
|
}
|
|
312
312
|
}
|
package/app/README.md
CHANGED
|
@@ -20,7 +20,7 @@ MIND_ROOT=~/MindOS ANTHROPIC_API_KEY=sk-ant-... npm run dev
|
|
|
20
20
|
# Or copy .env.local.example to app/.env.local and fill in values
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Open [http://localhost:
|
|
23
|
+
Open [http://localhost:3456](http://localhost:3456).
|
|
24
24
|
|
|
25
25
|
## Features
|
|
26
26
|
|
|
@@ -47,7 +47,7 @@ Copy `.env.local.example` to `.env.local`:
|
|
|
47
47
|
| Variable | Default | Description |
|
|
48
48
|
|----------|---------|-------------|
|
|
49
49
|
| `MIND_ROOT` | `./my-mind` | Path to your knowledge base directory |
|
|
50
|
-
| `MINDOS_WEB_PORT` | `
|
|
50
|
+
| `MINDOS_WEB_PORT` | `3456` | Dev/production server port |
|
|
51
51
|
| `AI_PROVIDER` | `anthropic` | `anthropic` or `openai` |
|
|
52
52
|
| `ANTHROPIC_API_KEY` | — | Required when `AI_PROVIDER=anthropic` |
|
|
53
53
|
| `ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Anthropic model ID |
|
package/app/app/api/ask/route.ts
CHANGED
|
@@ -5,6 +5,85 @@ import fs from 'fs';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { getFileContent, getMindRoot } from '@/lib/fs';
|
|
7
7
|
import { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT } from '@/lib/agent';
|
|
8
|
+
import type { Message as FrontendMessage, ToolCallPart as FrontendToolCallPart } from '@/lib/types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert frontend Message[] (with parts containing tool calls + results)
|
|
12
|
+
* into AI SDK ModelMessage[] that streamText expects.
|
|
13
|
+
*
|
|
14
|
+
* Frontend format:
|
|
15
|
+
* { role: 'assistant', content: '...', parts: [TextPart, ToolCallPart(with output/state)] }
|
|
16
|
+
*
|
|
17
|
+
* AI SDK format:
|
|
18
|
+
* { role: 'assistant', content: [TextPart, ToolCallPart(no output)] }
|
|
19
|
+
* { role: 'tool', content: [ToolResultPart] } // one per completed tool call
|
|
20
|
+
*/
|
|
21
|
+
function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
|
|
22
|
+
const result: ModelMessage[] = [];
|
|
23
|
+
|
|
24
|
+
for (const msg of messages) {
|
|
25
|
+
if (msg.role === 'user') {
|
|
26
|
+
result.push({ role: 'user', content: msg.content });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Skip error placeholder messages from frontend
|
|
31
|
+
if (msg.content.startsWith('__error__')) continue;
|
|
32
|
+
|
|
33
|
+
// Assistant message
|
|
34
|
+
if (!msg.parts || msg.parts.length === 0) {
|
|
35
|
+
// Plain text assistant message — no tool calls
|
|
36
|
+
if (msg.content) {
|
|
37
|
+
result.push({ role: 'assistant', content: msg.content });
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build assistant message content array (text parts + tool call parts)
|
|
43
|
+
const assistantContent: Array<
|
|
44
|
+
{ type: 'text'; text: string } |
|
|
45
|
+
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
|
46
|
+
> = [];
|
|
47
|
+
const completedToolCalls: FrontendToolCallPart[] = [];
|
|
48
|
+
|
|
49
|
+
for (const part of msg.parts) {
|
|
50
|
+
if (part.type === 'text') {
|
|
51
|
+
if (part.text) {
|
|
52
|
+
assistantContent.push({ type: 'text', text: part.text });
|
|
53
|
+
}
|
|
54
|
+
} else if (part.type === 'tool-call') {
|
|
55
|
+
assistantContent.push({
|
|
56
|
+
type: 'tool-call',
|
|
57
|
+
toolCallId: part.toolCallId,
|
|
58
|
+
toolName: part.toolName,
|
|
59
|
+
input: part.input,
|
|
60
|
+
});
|
|
61
|
+
if (part.state === 'done' || part.state === 'error') {
|
|
62
|
+
completedToolCalls.push(part);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (assistantContent.length > 0) {
|
|
68
|
+
result.push({ role: 'assistant', content: assistantContent });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Add tool result messages for completed tool calls
|
|
72
|
+
if (completedToolCalls.length > 0) {
|
|
73
|
+
result.push({
|
|
74
|
+
role: 'tool',
|
|
75
|
+
content: completedToolCalls.map(tc => ({
|
|
76
|
+
type: 'tool-result' as const,
|
|
77
|
+
toolCallId: tc.toolCallId,
|
|
78
|
+
toolName: tc.toolName,
|
|
79
|
+
output: { type: 'text' as const, value: tc.output ?? '' },
|
|
80
|
+
})),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
8
87
|
|
|
9
88
|
function readKnowledgeFile(filePath: string): { ok: boolean; content: string; error?: string } {
|
|
10
89
|
try {
|
|
@@ -33,7 +112,7 @@ function dirnameOf(filePath?: string): string | null {
|
|
|
33
112
|
|
|
34
113
|
export async function POST(req: NextRequest) {
|
|
35
114
|
let body: {
|
|
36
|
-
messages:
|
|
115
|
+
messages: FrontendMessage[];
|
|
37
116
|
currentFile?: string;
|
|
38
117
|
attachedFiles?: string[];
|
|
39
118
|
uploadedFiles?: Array<{ name: string; content: string }>;
|
|
@@ -148,18 +227,62 @@ export async function POST(req: NextRequest) {
|
|
|
148
227
|
|
|
149
228
|
try {
|
|
150
229
|
const model = getModel();
|
|
230
|
+
const modelMessages = convertToModelMessages(messages);
|
|
231
|
+
|
|
232
|
+
// Phase 2: Step monitoring + loop detection
|
|
233
|
+
const stepHistory: Array<{ tool: string; input: string }> = [];
|
|
234
|
+
let loopDetected = false;
|
|
235
|
+
let loopCooldown = 0; // skip detection for N steps after warning
|
|
236
|
+
|
|
151
237
|
const result = streamText({
|
|
152
238
|
model,
|
|
153
239
|
system: systemPrompt,
|
|
154
|
-
messages,
|
|
240
|
+
messages: modelMessages,
|
|
155
241
|
tools: knowledgeBaseTools,
|
|
156
242
|
stopWhen: stepCountIs(stepLimit),
|
|
243
|
+
|
|
244
|
+
onStepFinish: ({ toolCalls, usage }) => {
|
|
245
|
+
if (toolCalls) {
|
|
246
|
+
for (const tc of toolCalls) {
|
|
247
|
+
stepHistory.push({ tool: tc.toolName, input: JSON.stringify(tc.input) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Loop detection: same tool + same args 3 times in a row
|
|
251
|
+
// Skip detection during cooldown to avoid repeated warnings
|
|
252
|
+
if (loopCooldown > 0) {
|
|
253
|
+
loopCooldown--;
|
|
254
|
+
} else if (stepHistory.length >= 3) {
|
|
255
|
+
const last3 = stepHistory.slice(-3);
|
|
256
|
+
if (last3.every(s => s.tool === last3[0].tool && s.input === last3[0].input)) {
|
|
257
|
+
loopDetected = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
console.log(`[ask] Step ${stepHistory.length}/${stepLimit}, tokens=${usage?.totalTokens ?? '?'}`);
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
prepareStep: ({ messages: stepMessages }) => {
|
|
264
|
+
if (loopDetected) {
|
|
265
|
+
loopDetected = false;
|
|
266
|
+
loopCooldown = 3; // suppress re-detection for 3 steps
|
|
267
|
+
return {
|
|
268
|
+
messages: [
|
|
269
|
+
...stepMessages,
|
|
270
|
+
{
|
|
271
|
+
role: 'user' as const,
|
|
272
|
+
content: '[SYSTEM WARNING] You have called the same tool with identical arguments 3 times in a row. This appears to be a loop. Try a completely different approach or ask the user for clarification.',
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {}; // no modification
|
|
278
|
+
},
|
|
279
|
+
|
|
157
280
|
onError: ({ error }) => {
|
|
158
281
|
console.error('[ask] Stream error:', error);
|
|
159
282
|
},
|
|
160
283
|
});
|
|
161
284
|
|
|
162
|
-
return result.
|
|
285
|
+
return result.toUIMessageStreamResponse();
|
|
163
286
|
} catch (err) {
|
|
164
287
|
console.error('[ask] Failed to initialize model:', err);
|
|
165
288
|
return NextResponse.json(
|
|
@@ -21,7 +21,7 @@ function buildEntry(transport: string, url?: string, token?: string) {
|
|
|
21
21
|
if (transport === 'stdio') {
|
|
22
22
|
return { type: 'stdio', command: 'mindos', args: ['mcp'], env: { MCP_TRANSPORT: 'stdio' } };
|
|
23
23
|
}
|
|
24
|
-
const entry: Record<string, unknown> = { url: url || 'http://localhost:
|
|
24
|
+
const entry: Record<string, unknown> = { url: url || 'http://localhost:8781/mcp' };
|
|
25
25
|
if (token) entry.headers = { Authorization: `Bearer ${token}` };
|
|
26
26
|
return entry;
|
|
27
27
|
}
|
|
@@ -5,7 +5,7 @@ import { readSettings } from '@/lib/settings';
|
|
|
5
5
|
export async function GET() {
|
|
6
6
|
try {
|
|
7
7
|
const settings = readSettings();
|
|
8
|
-
const port = settings.mcpPort ??
|
|
8
|
+
const port = settings.mcpPort ?? 8781;
|
|
9
9
|
const endpoint = `http://127.0.0.1:${port}/mcp`;
|
|
10
10
|
const authConfigured = !!settings.authToken;
|
|
11
11
|
|
|
@@ -48,7 +48,7 @@ export async function GET() {
|
|
|
48
48
|
mindRoot: settings.mindRoot,
|
|
49
49
|
webPassword: settings.webPassword ? '***set***' : '',
|
|
50
50
|
authToken: maskToken(settings.authToken),
|
|
51
|
-
mcpPort: settings.mcpPort ??
|
|
51
|
+
mcpPort: settings.mcpPort ?? 8781,
|
|
52
52
|
envOverrides: {
|
|
53
53
|
AI_PROVIDER: !!process.env.AI_PROVIDER,
|
|
54
54
|
ANTHROPIC_API_KEY: !!process.env.ANTHROPIC_API_KEY,
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
|
|
8
|
+
|
|
9
|
+
function classifyError(status: number, body: string): { code: ErrorCode; error: string } {
|
|
10
|
+
if (status === 401 || status === 403) return { code: 'auth_error', error: 'Invalid API key' };
|
|
11
|
+
if (status === 404) return { code: 'model_not_found', error: 'Model not found' };
|
|
12
|
+
if (status === 429) return { code: 'rate_limited', error: 'Rate limited' };
|
|
13
|
+
// Try to extract error message from response body
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(body);
|
|
16
|
+
const msg = parsed?.error?.message || parsed?.error || '';
|
|
17
|
+
if (typeof msg === 'string' && msg.length > 0) return { code: 'unknown', error: msg.slice(0, 200) };
|
|
18
|
+
} catch { /* not JSON */ }
|
|
19
|
+
return { code: 'unknown', error: `HTTP ${status}` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function testAnthropic(apiKey: string, model: string): Promise<{ ok: boolean; latency?: number; code?: ErrorCode; error?: string }> {
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
const ctrl = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
'x-api-key': apiKey,
|
|
32
|
+
'anthropic-version': '2023-06-01',
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
35
|
+
signal: ctrl.signal,
|
|
36
|
+
});
|
|
37
|
+
const latency = Date.now() - start;
|
|
38
|
+
if (res.ok) return { ok: true, latency };
|
|
39
|
+
const body = await res.text();
|
|
40
|
+
return { ok: false, ...classifyError(res.status, body) };
|
|
41
|
+
} catch (e: unknown) {
|
|
42
|
+
if (e instanceof Error && e.name === 'AbortError') return { ok: false, code: 'network_error', error: 'Request timed out' };
|
|
43
|
+
return { ok: false, code: 'network_error', error: e instanceof Error ? e.message : 'Network error' };
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promise<{ ok: boolean; latency?: number; code?: ErrorCode; error?: string }> {
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
const ctrl = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
|
|
53
|
+
const url = (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '') + '/chat/completions';
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
62
|
+
signal: ctrl.signal,
|
|
63
|
+
});
|
|
64
|
+
const latency = Date.now() - start;
|
|
65
|
+
if (res.ok) return { ok: true, latency };
|
|
66
|
+
const body = await res.text();
|
|
67
|
+
return { ok: false, ...classifyError(res.status, body) };
|
|
68
|
+
} catch (e: unknown) {
|
|
69
|
+
if (e instanceof Error && e.name === 'AbortError') return { ok: false, code: 'network_error', error: 'Request timed out' };
|
|
70
|
+
return { ok: false, code: 'network_error', error: e instanceof Error ? e.message : 'Network error' };
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function POST(req: NextRequest) {
|
|
77
|
+
try {
|
|
78
|
+
const body = await req.json();
|
|
79
|
+
const { provider, apiKey, model, baseUrl } = body as {
|
|
80
|
+
provider?: string;
|
|
81
|
+
apiKey?: string;
|
|
82
|
+
model?: string;
|
|
83
|
+
baseUrl?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
87
|
+
return NextResponse.json({ ok: false, code: 'unknown', error: 'Invalid provider' }, { status: 400 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolve actual API key: use provided key, fallback to config/env for masked or missing
|
|
91
|
+
const cfg = effectiveAiConfig();
|
|
92
|
+
let resolvedKey = apiKey || '';
|
|
93
|
+
if (!resolvedKey || resolvedKey === '***set***') {
|
|
94
|
+
resolvedKey = provider === 'anthropic' ? cfg.anthropicApiKey : cfg.openaiApiKey;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!resolvedKey) {
|
|
98
|
+
return NextResponse.json({ ok: false, code: 'auth_error', error: 'No API key configured' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const resolvedModel = model || (provider === 'anthropic' ? cfg.anthropicModel : cfg.openaiModel);
|
|
102
|
+
|
|
103
|
+
const result = provider === 'anthropic'
|
|
104
|
+
? await testAnthropic(resolvedKey, resolvedModel)
|
|
105
|
+
: await testOpenAI(resolvedKey, resolvedModel, baseUrl || cfg.openaiBaseUrl);
|
|
106
|
+
|
|
107
|
+
return NextResponse.json(result);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return NextResponse.json({ ok: false, code: 'unknown', error: String(err) }, { status: 500 });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -20,8 +20,8 @@ export async function GET() {
|
|
|
20
20
|
mindRoot: defaultMindRoot,
|
|
21
21
|
homeDir: home,
|
|
22
22
|
platform: process.platform,
|
|
23
|
-
port: s.port ??
|
|
24
|
-
mcpPort: s.mcpPort ??
|
|
23
|
+
port: s.port ?? 3456,
|
|
24
|
+
mcpPort: s.mcpPort ?? 8781,
|
|
25
25
|
authToken: s.authToken ?? '',
|
|
26
26
|
webPassword: s.webPassword ?? '',
|
|
27
27
|
provider: s.ai.provider,
|
|
@@ -58,8 +58,8 @@ export async function POST(req: NextRequest) {
|
|
|
58
58
|
const resolvedRoot = expandHome(mindRoot.trim());
|
|
59
59
|
|
|
60
60
|
// Validate ports
|
|
61
|
-
const webPort = typeof port === 'number' ? port :
|
|
62
|
-
const mcpPortNum = typeof mcpPort === 'number' ? mcpPort :
|
|
61
|
+
const webPort = typeof port === 'number' ? port : 3456;
|
|
62
|
+
const mcpPortNum = typeof mcpPort === 'number' ? mcpPort : 8781;
|
|
63
63
|
if (webPort < 1024 || webPort > 65535) {
|
|
64
64
|
return NextResponse.json({ error: `Invalid web port: ${webPort}` }, { status: 400 });
|
|
65
65
|
}
|
|
@@ -78,7 +78,7 @@ export async function POST(req: NextRequest) {
|
|
|
78
78
|
|
|
79
79
|
// Read current running port for portChanged detection
|
|
80
80
|
const current = readSettings();
|
|
81
|
-
const currentPort = current.port ??
|
|
81
|
+
const currentPort = current.port ?? 3456;
|
|
82
82
|
|
|
83
83
|
// Use the same resolved values that will actually be written to config
|
|
84
84
|
const resolvedAuthToken = authToken ?? current.authToken ?? '';
|
|
@@ -87,8 +87,8 @@ export async function POST(req: NextRequest) {
|
|
|
87
87
|
// Re-onboard only needs restart if port/path/auth/password actually changed.
|
|
88
88
|
const isFirstTime = current.setupPending === true || !current.mindRoot;
|
|
89
89
|
const needsRestart = isFirstTime || (
|
|
90
|
-
webPort !== (current.port ??
|
|
91
|
-
mcpPortNum !== (current.mcpPort ??
|
|
90
|
+
webPort !== (current.port ?? 3456) ||
|
|
91
|
+
mcpPortNum !== (current.mcpPort ?? 8781) ||
|
|
92
92
|
resolvedRoot !== (current.mindRoot || '') ||
|
|
93
93
|
resolvedAuthToken !== (current.authToken ?? '') ||
|
|
94
94
|
resolvedWebPassword !== (current.webPassword ?? '')
|
|
@@ -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, exec } 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,6 +37,24 @@ function isGitRepo(dir: string) {
|
|
|
37
37
|
return existsSync(join(dir, '.git'));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Resolve path to bin/cli.js at runtime. */
|
|
41
|
+
function getCliPath() {
|
|
42
|
+
return resolve(process.cwd(), '..', 'bin', 'cli' + '.js');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run CLI command via shell string — avoids Turbopack static analysis of file paths */
|
|
46
|
+
function runCli(args: string[], timeoutMs = 30000): Promise<void> {
|
|
47
|
+
const cliPath = getCliPath();
|
|
48
|
+
const escaped = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
|
|
49
|
+
const cmd = `${process.execPath} ${cliPath} ${escaped}`;
|
|
50
|
+
return new Promise((res, rej) => {
|
|
51
|
+
exec(cmd, { timeout: timeoutMs }, (err, _stdout, stderr) => {
|
|
52
|
+
if (err) rej(new Error(stderr?.trim() || err.message));
|
|
53
|
+
else res();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
export async function GET() {
|
|
41
59
|
const config = loadConfig();
|
|
42
60
|
const syncConfig = config.sync || {};
|
|
@@ -95,32 +113,13 @@ export async function POST(req: NextRequest) {
|
|
|
95
113
|
return NextResponse.json({ error: 'Sync already configured' }, { status: 400 });
|
|
96
114
|
}
|
|
97
115
|
|
|
98
|
-
// Build the effective remote URL (inject token for HTTPS)
|
|
99
|
-
let effectiveRemote = remote;
|
|
100
|
-
if (isHTTPS && body.token) {
|
|
101
|
-
try {
|
|
102
|
-
const urlObj = new URL(remote);
|
|
103
|
-
urlObj.username = 'oauth2';
|
|
104
|
-
urlObj.password = body.token;
|
|
105
|
-
effectiveRemote = urlObj.toString();
|
|
106
|
-
} catch {
|
|
107
|
-
return NextResponse.json({ error: 'Invalid remote URL' }, { status: 400 });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
116
|
const branch = body.branch?.trim() || 'main';
|
|
112
117
|
|
|
113
|
-
// Call CLI's sync init
|
|
118
|
+
// Call CLI's sync init — pass clean remote + token separately (never embed token in URL)
|
|
114
119
|
try {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await new Promise<void>((res, rej) => {
|
|
119
|
-
execFile('node', [cliPath, ...args], { timeout: 30000 }, (err, stdout, stderr) => {
|
|
120
|
-
if (err) rej(new Error(stderr?.trim() || err.message));
|
|
121
|
-
else res();
|
|
122
|
-
});
|
|
123
|
-
});
|
|
120
|
+
const args = ['sync', 'init', '--non-interactive', '--remote', remote, '--branch', branch];
|
|
121
|
+
if (body.token) args.push('--token', body.token);
|
|
122
|
+
await runCli(args, 30000);
|
|
124
123
|
return NextResponse.json({ success: true, message: 'Sync initialized' });
|
|
125
124
|
} catch (err: unknown) {
|
|
126
125
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -132,23 +131,14 @@ export async function POST(req: NextRequest) {
|
|
|
132
131
|
if (!isGitRepo(mindRoot)) {
|
|
133
132
|
return NextResponse.json({ error: 'Not a git repository' }, { status: 400 });
|
|
134
133
|
}
|
|
135
|
-
//
|
|
136
|
-
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const status = execSync('git status --porcelain', { cwd: mindRoot, encoding: 'utf-8' }).trim();
|
|
143
|
-
if (status) {
|
|
144
|
-
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
145
|
-
execSync(`git commit -m "auto-sync: ${timestamp}"`, { cwd: mindRoot, stdio: 'pipe' });
|
|
146
|
-
execSync('git push', { cwd: mindRoot, stdio: 'pipe' });
|
|
134
|
+
// Delegate to CLI for unified conflict handling
|
|
135
|
+
try {
|
|
136
|
+
await runCli(['sync', 'now'], 60000);
|
|
137
|
+
return NextResponse.json({ ok: true });
|
|
138
|
+
} catch (err: unknown) {
|
|
139
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
return NextResponse.json({ error: errMsg }, { status: 500 });
|
|
147
141
|
}
|
|
148
|
-
const state = loadSyncState();
|
|
149
|
-
state.lastSync = new Date().toISOString();
|
|
150
|
-
writeFileSync(SYNC_STATE_PATH, JSON.stringify(state, null, 2) + '\n');
|
|
151
|
-
return NextResponse.json({ ok: true });
|
|
152
142
|
}
|
|
153
143
|
|
|
154
144
|
case 'on': {
|
|
@@ -11,6 +11,7 @@ import MessageList from '@/components/ask/MessageList';
|
|
|
11
11
|
import MentionPopover from '@/components/ask/MentionPopover';
|
|
12
12
|
import SessionHistory from '@/components/ask/SessionHistory';
|
|
13
13
|
import FileChip from '@/components/ask/FileChip';
|
|
14
|
+
import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
|
|
14
15
|
|
|
15
16
|
interface AskModalProps {
|
|
16
17
|
open: boolean;
|
|
@@ -151,25 +152,22 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
151
152
|
|
|
152
153
|
if (!res.body) throw new Error('No response body');
|
|
153
154
|
|
|
154
|
-
const reader = res.body.getReader();
|
|
155
|
-
const decoder = new TextDecoder();
|
|
156
|
-
let assistantContent = '';
|
|
157
155
|
setLoadingPhase('thinking');
|
|
158
156
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
157
|
+
const finalMessage = await consumeUIMessageStream(
|
|
158
|
+
res.body,
|
|
159
|
+
(msg) => {
|
|
160
|
+
setLoadingPhase('streaming');
|
|
161
|
+
session.setMessages(prev => {
|
|
162
|
+
const updated = [...prev];
|
|
163
|
+
updated[updated.length - 1] = msg;
|
|
164
|
+
return updated;
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
controller.signal,
|
|
168
|
+
);
|
|
171
169
|
|
|
172
|
-
if (!
|
|
170
|
+
if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
|
|
173
171
|
session.setMessages(prev => {
|
|
174
172
|
const updated = [...prev];
|
|
175
173
|
updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
|
|
@@ -181,8 +179,12 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
181
179
|
session.setMessages(prev => {
|
|
182
180
|
const updated = [...prev];
|
|
183
181
|
const lastIdx = updated.length - 1;
|
|
184
|
-
if (lastIdx >= 0 && updated[lastIdx].role === 'assistant'
|
|
185
|
-
updated[lastIdx]
|
|
182
|
+
if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
|
|
183
|
+
const last = updated[lastIdx];
|
|
184
|
+
const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
|
|
185
|
+
if (!hasContent) {
|
|
186
|
+
updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
|
|
187
|
+
}
|
|
186
188
|
}
|
|
187
189
|
return updated;
|
|
188
190
|
});
|
|
@@ -191,9 +193,13 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
191
193
|
session.setMessages(prev => {
|
|
192
194
|
const updated = [...prev];
|
|
193
195
|
const lastIdx = updated.length - 1;
|
|
194
|
-
if (lastIdx >= 0 && updated[lastIdx].role === 'assistant'
|
|
195
|
-
updated[lastIdx]
|
|
196
|
-
|
|
196
|
+
if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
|
|
197
|
+
const last = updated[lastIdx];
|
|
198
|
+
const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
|
|
199
|
+
if (!hasContent) {
|
|
200
|
+
updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
|
|
201
|
+
return updated;
|
|
202
|
+
}
|
|
197
203
|
}
|
|
198
204
|
return [...updated, { role: 'assistant', content: `__error__${errMsg}` }];
|
|
199
205
|
});
|
|
@@ -281,6 +287,7 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
281
287
|
emptyPrompt={t.ask.emptyPrompt}
|
|
282
288
|
suggestions={t.ask.suggestions}
|
|
283
289
|
onSuggestionClick={setInput}
|
|
290
|
+
maxSteps={maxSteps}
|
|
284
291
|
labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
|
|
285
292
|
/>
|
|
286
293
|
|