@geminilight/mindos 0.5.11 → 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/setup/route.ts +7 -7
- package/app/app/api/sync/route.ts +21 -16
- 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/setup/StepReview.tsx +31 -25
- package/app/components/setup/index.tsx +6 -3
- 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/types.ts +18 -0
- package/app/next-env.d.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +41 -21
- 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/mcp/README.md +5 -5
- package/mcp/src/index.ts +2 -2
- package/package.json +1 -1
- package/scripts/setup.js +12 -12
- package/scripts/upgrade-prompt.md +6 -6
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,
|
|
@@ -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 || {};
|
|
@@ -99,16 +117,9 @@ export async function POST(req: NextRequest) {
|
|
|
99
117
|
|
|
100
118
|
// Call CLI's sync init — pass clean remote + token separately (never embed token in URL)
|
|
101
119
|
try {
|
|
102
|
-
const cliPath = resolve(process.cwd(), '..', 'bin', 'cli.js');
|
|
103
120
|
const args = ['sync', 'init', '--non-interactive', '--remote', remote, '--branch', branch];
|
|
104
121
|
if (body.token) args.push('--token', body.token);
|
|
105
|
-
|
|
106
|
-
await new Promise<void>((res, rej) => {
|
|
107
|
-
execFile('node', [cliPath, ...args], { timeout: 30000 }, (err, stdout, stderr) => {
|
|
108
|
-
if (err) rej(new Error(stderr?.trim() || err.message));
|
|
109
|
-
else res();
|
|
110
|
-
});
|
|
111
|
-
});
|
|
122
|
+
await runCli(args, 30000);
|
|
112
123
|
return NextResponse.json({ success: true, message: 'Sync initialized' });
|
|
113
124
|
} catch (err: unknown) {
|
|
114
125
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -122,13 +133,7 @@ export async function POST(req: NextRequest) {
|
|
|
122
133
|
}
|
|
123
134
|
// Delegate to CLI for unified conflict handling
|
|
124
135
|
try {
|
|
125
|
-
|
|
126
|
-
await new Promise<void>((res, rej) => {
|
|
127
|
-
execFile('node', [cliPath, 'sync', 'now'], { timeout: 60000 }, (err, stdout, stderr) => {
|
|
128
|
-
if (err) rej(new Error(stderr?.trim() || err.message));
|
|
129
|
-
else res();
|
|
130
|
-
});
|
|
131
|
-
});
|
|
136
|
+
await runCli(['sync', 'now'], 60000);
|
|
132
137
|
return NextResponse.json({ ok: true });
|
|
133
138
|
} catch (err: unknown) {
|
|
134
139
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -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
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useRef, useEffect } from 'react';
|
|
4
|
-
import { Sparkles, Loader2, AlertCircle } from 'lucide-react';
|
|
4
|
+
import { Sparkles, Loader2, AlertCircle, Wrench } from 'lucide-react';
|
|
5
5
|
import ReactMarkdown from 'react-markdown';
|
|
6
6
|
import remarkGfm from 'remark-gfm';
|
|
7
7
|
import type { Message } from '@/lib/types';
|
|
8
|
+
import ToolCallBlock from './ToolCallBlock';
|
|
8
9
|
|
|
9
10
|
function AssistantMessage({ content, isStreaming }: { content: string; isStreaming: boolean }) {
|
|
10
11
|
return (
|
|
@@ -28,6 +29,57 @@ function AssistantMessage({ content, isStreaming }: { content: string; isStreami
|
|
|
28
29
|
);
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function AssistantMessageWithParts({ message, isStreaming }: { message: Message; isStreaming: boolean }) {
|
|
33
|
+
const parts = message.parts;
|
|
34
|
+
if (!parts || parts.length === 0) {
|
|
35
|
+
// Fallback to plain text rendering
|
|
36
|
+
return message.content ? (
|
|
37
|
+
<AssistantMessage content={message.content} isStreaming={isStreaming} />
|
|
38
|
+
) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if the last part is a running tool call — show a spinner after it
|
|
42
|
+
const lastPart = parts[parts.length - 1];
|
|
43
|
+
const showTrailingSpinner = isStreaming && lastPart.type === 'tool-call' && (lastPart.state === 'running' || lastPart.state === 'pending');
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div>
|
|
47
|
+
{parts.map((part, idx) => {
|
|
48
|
+
if (part.type === 'text') {
|
|
49
|
+
const isLastTextPart = isStreaming && idx === parts.length - 1;
|
|
50
|
+
return part.text ? (
|
|
51
|
+
<AssistantMessage key={idx} content={part.text} isStreaming={isLastTextPart} />
|
|
52
|
+
) : null;
|
|
53
|
+
}
|
|
54
|
+
if (part.type === 'tool-call') {
|
|
55
|
+
return <ToolCallBlock key={part.toolCallId} part={part} />;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
})}
|
|
59
|
+
{showTrailingSpinner && (
|
|
60
|
+
<div className="flex items-center gap-2 py-1 mt-1">
|
|
61
|
+
<Loader2 size={12} className="animate-spin" style={{ color: 'var(--amber)' }} />
|
|
62
|
+
<span className="text-xs text-muted-foreground animate-pulse">Executing tool…</span>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function StepCounter({ parts, maxSteps }: { parts: Message['parts']; maxSteps?: number }) {
|
|
70
|
+
if (!parts) return null;
|
|
71
|
+
const toolCalls = parts.filter(p => p.type === 'tool-call');
|
|
72
|
+
if (toolCalls.length === 0) return null;
|
|
73
|
+
const lastToolCall = toolCalls[toolCalls.length - 1];
|
|
74
|
+
const toolLabel = lastToolCall.type === 'tool-call' ? lastToolCall.toolName : '';
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground/70">
|
|
77
|
+
<Wrench size={10} />
|
|
78
|
+
<span>Step {toolCalls.length}{maxSteps ? `/${maxSteps}` : ''}{toolLabel ? ` — ${toolLabel}` : ''}</span>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
31
83
|
interface MessageListProps {
|
|
32
84
|
messages: Message[];
|
|
33
85
|
isLoading: boolean;
|
|
@@ -35,6 +87,7 @@ interface MessageListProps {
|
|
|
35
87
|
emptyPrompt: string;
|
|
36
88
|
suggestions: readonly string[];
|
|
37
89
|
onSuggestionClick: (text: string) => void;
|
|
90
|
+
maxSteps?: number;
|
|
38
91
|
labels: {
|
|
39
92
|
connecting: string;
|
|
40
93
|
thinking: string;
|
|
@@ -49,6 +102,7 @@ export default function MessageList({
|
|
|
49
102
|
emptyPrompt,
|
|
50
103
|
suggestions,
|
|
51
104
|
onSuggestionClick,
|
|
105
|
+
maxSteps,
|
|
52
106
|
labels,
|
|
53
107
|
}: MessageListProps) {
|
|
54
108
|
const endRef = useRef<HTMLDivElement>(null);
|
|
@@ -102,8 +156,13 @@ export default function MessageList({
|
|
|
102
156
|
</div>
|
|
103
157
|
) : (
|
|
104
158
|
<div className="max-w-[85%] px-3 py-2 rounded-xl rounded-bl-sm bg-muted text-foreground text-sm">
|
|
105
|
-
{m.content ? (
|
|
106
|
-
|
|
159
|
+
{(m.parts && m.parts.length > 0) || m.content ? (
|
|
160
|
+
<>
|
|
161
|
+
<AssistantMessageWithParts message={m} isStreaming={isLoading && i === messages.length - 1} />
|
|
162
|
+
{isLoading && i === messages.length - 1 && (
|
|
163
|
+
<StepCounter parts={m.parts} maxSteps={maxSteps} />
|
|
164
|
+
)}
|
|
165
|
+
</>
|
|
107
166
|
) : isLoading && i === messages.length - 1 ? (
|
|
108
167
|
<div className="flex items-center gap-2 py-1">
|
|
109
168
|
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--amber)' }} />
|