@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 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": 3000,
160
- "mcpPort": 8787,
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` | `3000` | Optional. Web app port. |
186
- | `mcpPort` | `8787` | Optional. MCP server port. |
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>:8787/mcp --token your-token -g
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 `8787` is open in your firewall/security-group.
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 `8787`. To use a different port, run `mindos onboard` and set `mcpPort`.
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:8787/mcp",
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>:8787/mcp",
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": 3000,
162
- "mcpPort": 8787,
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` | `3000` | 可选。Web 服务端口 |
188
- | `mcpPort` | `8787` | 可选。MCP 服务端口 |
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>:8787/mcp --token your-token -g
262
+ mindos mcp install --transport http --url http://<服务器IP>:8781/mcp --token your-token -g
263
263
  ```
264
264
 
265
265
  > [!NOTE]
266
- > 远程访问时,请确保端口 `8787` 已在防火墙/安全组中放行。
266
+ > 远程访问时,请确保端口 `8781` 已在防火墙/安全组中放行。
267
267
 
268
268
  > 加 `-g` 表示全局安装 — MCP 配置写入用户级配置文件,所有项目共享,而非仅当前目录。
269
269
 
270
- > MCP 端口默认为 `8787`。如需修改,运行 `mindos onboard` 设置 `mcpPort`。
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:8787/mcp",
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>:8787/mcp",
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:3000](http://localhost:3000).
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` | `3000` | Dev/production server 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 |
@@ -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: ModelMessage[];
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.toTextStreamResponse();
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:8787/mcp' };
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 ?? 8787;
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 ?? 8787,
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 ?? 3000,
24
- mcpPort: s.mcpPort ?? 8787,
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 : 3000;
62
- const mcpPortNum = typeof mcpPort === 'number' ? mcpPort : 8787;
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 ?? 3000;
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 ?? 3000) ||
91
- mcpPortNum !== (current.mcpPort ?? 8787) ||
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, execFile } from 'child_process';
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
- const cliPath = resolve(process.cwd(), '..', 'bin', 'cli.js');
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
- while (true) {
160
- const { done, value } = await reader.read();
161
- if (done) break;
162
- const chunk = decoder.decode(value, { stream: true });
163
- if (chunk) setLoadingPhase('streaming');
164
- assistantContent += chunk;
165
- session.setMessages(prev => {
166
- const updated = [...prev];
167
- updated[updated.length - 1] = { role: 'assistant', content: assistantContent };
168
- return updated;
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 (!assistantContent.trim()) {
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' && !updated[lastIdx].content.trim()) {
185
- updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
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' && !updated[lastIdx].content.trim()) {
195
- updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
196
- return updated;
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
- <AssistantMessage content={m.content} isStreaming={isLoading && i === messages.length - 1} />
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)' }} />