nothumanallowed 10.9.1 → 11.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "10.9.1",
3
+ "version": "11.0.1",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 53 tools. Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, GitHub, Notion, Slack, voice chat, 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,7 +121,14 @@ function getOrCreateIdentity() {
121
121
  let id = loadIdentity();
122
122
  if (!id) {
123
123
  id = generateKeypair();
124
- id.displayName = `Agent-${id.fingerprint.slice(0, 6)}`;
124
+ // Use profile name from config if available, otherwise generate default
125
+ try {
126
+ const { loadConfig } = await import('../config.mjs');
127
+ const cfg = loadConfig();
128
+ id.displayName = cfg.profile?.name || `Agent-${id.fingerprint.slice(0, 6)}`;
129
+ } catch {
130
+ id.displayName = `Agent-${id.fingerprint.slice(0, 6)}`;
131
+ }
125
132
  id.createdAt = new Date().toISOString();
126
133
  saveIdentity(id);
127
134
  info(`Identity created: ${id.fingerprint}`);
@@ -38,6 +38,11 @@ import {
38
38
  setActiveId,
39
39
  getHistory,
40
40
  addMessages,
41
+ retryMessage,
42
+ addRetryResponse,
43
+ editMessage,
44
+ navigateFork,
45
+ getForkInfo,
41
46
  exportAsMarkdown,
42
47
  exportAsJson,
43
48
  migrateOldHistory,
@@ -1430,6 +1435,61 @@ export async function cmdUI(args) {
1430
1435
  return;
1431
1436
  }
1432
1437
 
1438
+ // ── Fork/Retry/Navigate API ────────────────────────────────────────
1439
+
1440
+ // POST /api/conversations/:id/retry — retry a message (create fork)
1441
+ if (method === 'POST' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/retry$/)) {
1442
+ const convId = pathname.split('/')[3];
1443
+ const body = await parseBody(req);
1444
+ const conv = loadConversation(convId);
1445
+ if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1446
+ const userNodeId = retryMessage(conv, body.assistantNodeId);
1447
+ if (!userNodeId) { sendJSON(res, 400, { error: 'Invalid message' }); logRequest(method, pathname, 400, Date.now() - start); return; }
1448
+ sendJSON(res, 200, { userNodeId, userContent: conv.tree?.[userNodeId]?.content });
1449
+ logRequest(method, pathname, 200, Date.now() - start);
1450
+ return;
1451
+ }
1452
+
1453
+ // POST /api/conversations/:id/retry-response — add the retry result
1454
+ if (method === 'POST' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/retry-response$/)) {
1455
+ const convId = pathname.split('/')[3];
1456
+ const body = await parseBody(req);
1457
+ const conv = loadConversation(convId);
1458
+ if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1459
+ const newId = addRetryResponse(conv, body.userNodeId, body.content);
1460
+ sendJSON(res, 200, { nodeId: newId, messages: getHistory(conv) });
1461
+ logRequest(method, pathname, 200, Date.now() - start);
1462
+ return;
1463
+ }
1464
+
1465
+ // POST /api/conversations/:id/navigate — switch fork direction
1466
+ if (method === 'POST' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/navigate$/)) {
1467
+ const convId = pathname.split('/')[3];
1468
+ const body = await parseBody(req);
1469
+ const conv = loadConversation(convId);
1470
+ if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1471
+ const ok = navigateFork(conv, body.nodeId, body.direction);
1472
+ sendJSON(res, 200, { ok, messages: getHistory(conv) });
1473
+ logRequest(method, pathname, 200, Date.now() - start);
1474
+ return;
1475
+ }
1476
+
1477
+ // GET /api/conversations/:id/forks — get fork info for all messages
1478
+ if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/forks$/)) {
1479
+ const convId = pathname.split('/')[3];
1480
+ const conv = loadConversation(convId);
1481
+ if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1482
+ const messages = getHistory(conv);
1483
+ const forks = {};
1484
+ for (const msg of messages) {
1485
+ const info = getForkInfo(conv, msg.id);
1486
+ if (info) forks[msg.id] = info;
1487
+ }
1488
+ sendJSON(res, 200, { forks });
1489
+ logRequest(method, pathname, 200, Date.now() - start);
1490
+ return;
1491
+ }
1492
+
1433
1493
  // ── Streaming Chat API ─────────────────────────────────────────────
1434
1494
 
1435
1495
  // POST /api/chat/stream — SSE streaming chat with conversation persistence
@@ -1720,7 +1780,20 @@ export async function cmdUI(args) {
1720
1780
  }
1721
1781
  const conv = loadConversation(convId);
1722
1782
  if (conv) {
1723
- addMessages(conv, msg, persistedResponse);
1783
+ if (body.isRetry) {
1784
+ // Retry: find the user node and add a new sibling response (fork)
1785
+ const activePath = getHistory(conv);
1786
+ // Find the last user message that matches
1787
+ const userNodes = activePath.filter(m => m.role === 'user' && m.content === msg);
1788
+ const userNode = userNodes[userNodes.length - 1];
1789
+ if (userNode && userNode.id) {
1790
+ addRetryResponse(conv, userNode.id, persistedResponse);
1791
+ } else {
1792
+ addMessages(conv, msg, persistedResponse);
1793
+ }
1794
+ } else {
1795
+ addMessages(conv, msg, persistedResponse);
1796
+ }
1724
1797
  }
1725
1798
  } catch {}
1726
1799
  }
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '10.9.1';
8
+ export const VERSION = '11.0.1';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -1,8 +1,15 @@
1
1
  /**
2
- * Multi-conversation manager for NHA Chat.
2
+ * Multi-conversation manager for NHA Chat — with fork/branching support.
3
3
  *
4
4
  * Stores conversations in ~/.nha/conversations/ as JSON files.
5
- * Each conversation has an ID, auto-generated title, and message history.
5
+ * Each conversation has an ID, auto-generated title, and message tree.
6
+ *
7
+ * Message tree structure:
8
+ * Each message node: { id, role, content, parentId, children: [id, id, ...], activeChild: 0 }
9
+ * The "active path" follows activeChild at each fork point.
10
+ * Retry creates a new sibling (branch), not a replacement.
11
+ *
12
+ * Backward compatible: old conversations with flat messages[] are auto-upgraded.
6
13
  *
7
14
  * Zero npm dependencies.
8
15
  */
@@ -31,10 +38,10 @@ function generateId() {
31
38
  return crypto.randomUUID().slice(0, 8);
32
39
  }
33
40
 
34
- /**
35
- * Auto-generate a title from the first user message.
36
- * Takes the first ~50 chars, trims to last word boundary.
37
- */
41
+ function msgId() {
42
+ return crypto.randomUUID().slice(0, 12);
43
+ }
44
+
38
45
  function autoTitle(firstMessage) {
39
46
  if (!firstMessage) return 'New Chat';
40
47
  let title = firstMessage.replace(/\s+/g, ' ').trim();
@@ -47,12 +54,139 @@ function autoTitle(firstMessage) {
47
54
  return title;
48
55
  }
49
56
 
50
- // ── CRUD ─────────────────────────────────────────────────────────────────────
57
+ // ── Tree Helpers ─────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Upgrade a flat messages array to tree format.
61
+ * Each message gets an id, parentId, children[].
62
+ */
63
+ function upgradeToTree(conv) {
64
+ if (conv.tree) return; // already upgraded
65
+
66
+ const nodes = {};
67
+ const messages = conv.messages || [];
68
+ let prevId = null;
69
+
70
+ for (let i = 0; i < messages.length; i++) {
71
+ const msg = messages[i];
72
+ const id = msg.id || msgId();
73
+ const node = {
74
+ id,
75
+ role: msg.role,
76
+ content: msg.content,
77
+ parentId: prevId,
78
+ children: [],
79
+ activeChild: 0,
80
+ };
81
+ nodes[id] = node;
82
+ if (prevId && nodes[prevId]) {
83
+ nodes[prevId].children.push(id);
84
+ }
85
+ prevId = id;
86
+ }
87
+
88
+ conv.tree = nodes;
89
+ conv.rootId = messages.length > 0 ? Object.keys(nodes)[0] : null;
90
+ }
91
+
92
+ /**
93
+ * Get the active message path (following activeChild at each fork).
94
+ * Returns flat array of {role, content, id} for the LLM.
95
+ */
96
+ function getActivePath(conv) {
97
+ upgradeToTree(conv);
98
+ if (!conv.tree || !conv.rootId) return [];
99
+
100
+ const path = [];
101
+ let currentId = conv.rootId;
102
+
103
+ while (currentId) {
104
+ const node = conv.tree[currentId];
105
+ if (!node) break;
106
+ path.push({ id: node.id, role: node.role, content: node.content });
107
+ if (node.children.length === 0) break;
108
+ const nextIdx = Math.min(node.activeChild || 0, node.children.length - 1);
109
+ currentId = node.children[nextIdx];
110
+ }
111
+
112
+ return path;
113
+ }
114
+
115
+ /**
116
+ * Add a message to the tree. Returns the new node ID.
117
+ * parentId = null means append to the end of the active path.
118
+ */
119
+ function addNode(conv, role, content, parentId = null) {
120
+ upgradeToTree(conv);
121
+ if (!conv.tree) conv.tree = {};
122
+
123
+ const id = msgId();
124
+
125
+ // Find parent: if not specified, use the last node in active path
126
+ if (!parentId) {
127
+ const activePath = getActivePath(conv);
128
+ if (activePath.length > 0) {
129
+ parentId = activePath[activePath.length - 1].id;
130
+ }
131
+ }
132
+
133
+ const node = { id, role, content, parentId, children: [], activeChild: 0 };
134
+ conv.tree[id] = node;
135
+
136
+ if (parentId && conv.tree[parentId]) {
137
+ conv.tree[parentId].children.push(id);
138
+ // Set this as the active child
139
+ conv.tree[parentId].activeChild = conv.tree[parentId].children.length - 1;
140
+ }
141
+
142
+ if (!conv.rootId) conv.rootId = id;
143
+
144
+ return id;
145
+ }
146
+
147
+ /**
148
+ * Get fork info for a specific node: how many siblings and which is active.
149
+ * Returns { total, current, siblingIds } or null if no fork.
150
+ */
151
+ function getForkInfo(conv, nodeId) {
152
+ upgradeToTree(conv);
153
+ const node = conv.tree?.[nodeId];
154
+ if (!node || !node.parentId) return null;
155
+
156
+ const parent = conv.tree[node.parentId];
157
+ if (!parent || parent.children.length <= 1) return null;
158
+
159
+ const current = parent.children.indexOf(nodeId);
160
+ return {
161
+ total: parent.children.length,
162
+ current: current,
163
+ siblingIds: parent.children,
164
+ parentId: parent.id,
165
+ };
166
+ }
51
167
 
52
168
  /**
53
- * Create a new conversation and set it as active.
54
- * @returns {{ id: string, title: string, messages: Array, createdAt: string, updatedAt: string }}
169
+ * Switch to a different sibling at a fork point.
170
+ * direction: -1 (previous) or +1 (next)
55
171
  */
172
+ function switchFork(conv, nodeId, direction) {
173
+ upgradeToTree(conv);
174
+ const node = conv.tree?.[nodeId];
175
+ if (!node || !node.parentId) return false;
176
+
177
+ const parent = conv.tree[node.parentId];
178
+ if (!parent || parent.children.length <= 1) return false;
179
+
180
+ const currentIdx = parent.children.indexOf(nodeId);
181
+ const newIdx = currentIdx + direction;
182
+ if (newIdx < 0 || newIdx >= parent.children.length) return false;
183
+
184
+ parent.activeChild = newIdx;
185
+ return true;
186
+ }
187
+
188
+ // ── CRUD ─────────────────────────────────────────────────────────────────────
189
+
56
190
  export function createConversation(title = '') {
57
191
  ensureDir();
58
192
  const id = generateId();
@@ -61,6 +195,8 @@ export function createConversation(title = '') {
61
195
  id,
62
196
  title: title || 'New Chat',
63
197
  messages: [],
198
+ tree: {},
199
+ rootId: null,
64
200
  createdAt: now,
65
201
  updatedAt: now,
66
202
  };
@@ -69,48 +205,37 @@ export function createConversation(title = '') {
69
205
  return conv;
70
206
  }
71
207
 
72
- /**
73
- * Load a conversation by ID.
74
- * @returns {object|null}
75
- */
76
208
  export function loadConversation(id) {
77
209
  try {
78
210
  const data = fs.readFileSync(convPath(id), 'utf-8');
79
- return JSON.parse(data);
211
+ const conv = JSON.parse(data);
212
+ // Auto-upgrade old format
213
+ if (!conv.tree && conv.messages && conv.messages.length > 0) {
214
+ upgradeToTree(conv);
215
+ saveConversation(conv);
216
+ }
217
+ return conv;
80
218
  } catch {
81
219
  return null;
82
220
  }
83
221
  }
84
222
 
85
- /**
86
- * Save a conversation (full overwrite).
87
- */
88
223
  export function saveConversation(conv) {
89
224
  ensureDir();
90
225
  conv.updatedAt = new Date().toISOString();
226
+ // Keep messages[] in sync with active path for backward compat
227
+ conv.messages = getActivePath(conv);
91
228
  fs.writeFileSync(convPath(conv.id), JSON.stringify(conv, null, 2) + '\n', 'utf-8');
92
229
  }
93
230
 
94
- /**
95
- * Delete a conversation by ID.
96
- * If it was active, clears active state.
97
- * @returns {boolean} true if deleted
98
- */
99
231
  export function deleteConversation(id) {
100
232
  const filePath = convPath(id);
101
233
  if (!fs.existsSync(filePath)) return false;
102
234
  fs.unlinkSync(filePath);
103
- if (getActiveId() === id) {
104
- clearActiveId();
105
- }
235
+ if (getActiveId() === id) clearActiveId();
106
236
  return true;
107
237
  }
108
238
 
109
- /**
110
- * List all conversations, sorted by updatedAt (newest first).
111
- * Returns summary objects (no messages).
112
- * @returns {Array<{ id: string, title: string, messageCount: number, createdAt: string, updatedAt: string }>}
113
- */
114
239
  export function listConversations() {
115
240
  ensureDir();
116
241
  const files = fs.readdirSync(CONVERSATIONS_DIR)
@@ -127,12 +252,11 @@ export function listConversations() {
127
252
  createdAt: data.createdAt,
128
253
  updatedAt: data.updatedAt,
129
254
  });
130
- } catch { /* skip corrupt files */ }
255
+ } catch {}
131
256
  }
132
257
 
133
258
  convs.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
134
259
 
135
- // Auto-prune old conversations beyond limit
136
260
  if (convs.length > MAX_CONVERSATIONS) {
137
261
  for (const old of convs.slice(MAX_CONVERSATIONS)) {
138
262
  try { fs.unlinkSync(convPath(old.id)); } catch {}
@@ -145,38 +269,19 @@ export function listConversations() {
145
269
 
146
270
  // ── Active Conversation ──────────────────────────────────────────────────────
147
271
 
148
- /**
149
- * Get the active conversation ID.
150
- * @returns {string|null}
151
- */
152
272
  export function getActiveId() {
153
- try {
154
- return fs.readFileSync(ACTIVE_FILE, 'utf-8').trim() || null;
155
- } catch {
156
- return null;
157
- }
273
+ try { return fs.readFileSync(ACTIVE_FILE, 'utf-8').trim() || null; } catch { return null; }
158
274
  }
159
275
 
160
- /**
161
- * Set the active conversation ID.
162
- */
163
276
  export function setActiveId(id) {
164
277
  ensureDir();
165
278
  fs.writeFileSync(ACTIVE_FILE, id, 'utf-8');
166
279
  }
167
280
 
168
- /**
169
- * Clear the active conversation.
170
- */
171
- export function clearActiveId() {
281
+ function clearActiveId() {
172
282
  try { fs.unlinkSync(ACTIVE_FILE); } catch {}
173
283
  }
174
284
 
175
- /**
176
- * Get or create the active conversation.
177
- * If none exists, creates a new one.
178
- * @returns {object} conversation object
179
- */
180
285
  export function getOrCreateActive() {
181
286
  const activeId = getActiveId();
182
287
  if (activeId) {
@@ -188,77 +293,121 @@ export function getOrCreateActive() {
188
293
 
189
294
  /**
190
295
  * Add a message pair (user + assistant) to a conversation.
191
- * Auto-titles the conversation from the first user message.
296
+ * Creates tree nodes. Auto-titles from first user message.
192
297
  */
193
298
  export function addMessages(conv, userContent, assistantContent) {
194
- conv.messages.push({ role: 'user', content: userContent });
195
- conv.messages.push({ role: 'assistant', content: assistantContent });
299
+ const userId = addNode(conv, 'user', userContent);
300
+ const assistantId = addNode(conv, 'assistant', assistantContent, userId);
196
301
 
197
- // Auto-title from first user message
198
- if (conv.title === 'New Chat' && conv.messages.length === 2) {
302
+ if (conv.title === 'New Chat' && Object.keys(conv.tree || {}).length <= 2) {
199
303
  conv.title = autoTitle(userContent);
200
304
  }
201
305
 
202
306
  saveConversation(conv);
307
+ return { userId, assistantId };
203
308
  }
204
309
 
205
310
  /**
206
- * Get the message history from a conversation, capped at maxTurns pairs.
207
- * @returns {Array<{role: string, content: string}>}
311
+ * Retry: add a NEW assistant response as a sibling of the existing one.
312
+ * This creates a fork — the user can navigate between variants.
313
+ * Returns the ID of the user message (parent) so the caller knows which message to re-send.
208
314
  */
209
- export function getHistory(conv, maxTurns = 20) {
210
- const messages = conv.messages || [];
211
- return messages.slice(-(maxTurns * 2));
315
+ export function retryMessage(conv, assistantNodeId) {
316
+ upgradeToTree(conv);
317
+ const node = conv.tree?.[assistantNodeId];
318
+ if (!node || node.role !== 'assistant') return null;
319
+
320
+ // The parent is the user message
321
+ const parentId = node.parentId;
322
+ if (!parentId) return null;
323
+
324
+ // Return the parent (user) ID — the caller will generate a new response
325
+ // and call addRetryResponse() with the result
326
+ return parentId;
212
327
  }
213
328
 
214
- // ── Export ────────────────────────────────────────────────────────────────────
329
+ /**
330
+ * Add a retry response as a new sibling branch.
331
+ * @param {object} conv - conversation
332
+ * @param {string} userNodeId - the user message ID (parent)
333
+ * @param {string} newContent - the new assistant response
334
+ * @returns {string} the new assistant node ID
335
+ */
336
+ export function addRetryResponse(conv, userNodeId, newContent) {
337
+ const newId = addNode(conv, 'assistant', newContent, userNodeId);
338
+ saveConversation(conv);
339
+ return newId;
340
+ }
215
341
 
216
342
  /**
217
- * Export conversation as Markdown.
343
+ * Edit a user message: creates a new branch from the parent of the edited message.
344
+ * Returns the new user node ID.
218
345
  */
346
+ export function editMessage(conv, userNodeId, newContent) {
347
+ upgradeToTree(conv);
348
+ const node = conv.tree?.[userNodeId];
349
+ if (!node || node.role !== 'user') return null;
350
+
351
+ const parentId = node.parentId; // could be null (first message) or previous assistant
352
+
353
+ const newId = addNode(conv, 'user', newContent, parentId);
354
+ saveConversation(conv);
355
+ return newId;
356
+ }
357
+
358
+ /**
359
+ * Navigate fork: switch to previous/next sibling at a fork point.
360
+ */
361
+ export function navigateFork(conv, nodeId, direction) {
362
+ const result = switchFork(conv, nodeId, direction);
363
+ if (result) saveConversation(conv);
364
+ return result;
365
+ }
366
+
367
+ /**
368
+ * Get fork info for a node (for UI rendering).
369
+ */
370
+ export { getForkInfo };
371
+
372
+ /**
373
+ * Get the active path as flat messages array.
374
+ */
375
+ export function getHistory(conv, maxTurns = 20) {
376
+ const activePath = getActivePath(conv);
377
+ return activePath.slice(-(maxTurns * 2));
378
+ }
379
+
380
+ // ── Export ────────────────────────────────────────────────────────────────────
381
+
219
382
  export function exportAsMarkdown(conv) {
383
+ const messages = getActivePath(conv);
220
384
  const lines = [
221
385
  `# ${conv.title}`,
222
386
  `*Created: ${new Date(conv.createdAt).toLocaleString()}*`,
223
- `*Messages: ${conv.messages.length}*`,
224
- '',
225
- '---',
226
- '',
387
+ `*Messages: ${messages.length}*`,
388
+ '', '---', '',
227
389
  ];
228
-
229
- for (const msg of conv.messages) {
230
- if (msg.role === 'user') {
231
- lines.push(`### You`);
232
- lines.push(msg.content);
233
- } else {
234
- lines.push(`### NHA`);
235
- lines.push(msg.content);
236
- }
390
+ for (const msg of messages) {
391
+ lines.push(msg.role === 'user' ? '### You' : '### NHA');
392
+ lines.push(msg.content);
237
393
  lines.push('');
238
394
  }
239
-
240
395
  return lines.join('\n');
241
396
  }
242
397
 
243
- /**
244
- * Export conversation as JSON.
245
- */
246
398
  export function exportAsJson(conv) {
247
399
  return JSON.stringify({
248
400
  id: conv.id,
249
401
  title: conv.title,
250
402
  createdAt: conv.createdAt,
251
403
  updatedAt: conv.updatedAt,
252
- messages: conv.messages,
404
+ messages: getActivePath(conv),
405
+ tree: conv.tree,
253
406
  }, null, 2);
254
407
  }
255
408
 
256
409
  // ── Migration ────────────────────────────────────────────────────────────────
257
410
 
258
- /**
259
- * Migrate old single-file chat history to multi-conversation format.
260
- * Called once on first run if old history exists.
261
- */
262
411
  export function migrateOldHistory() {
263
412
  const oldFile = path.join(NHA_DIR, 'memory', 'chat-history.json');
264
413
  if (!fs.existsSync(oldFile)) return;
@@ -269,9 +418,9 @@ export function migrateOldHistory() {
269
418
 
270
419
  const conv = createConversation('Previous Chat');
271
420
  conv.messages = messages;
421
+ upgradeToTree(conv);
272
422
  saveConversation(conv);
273
423
 
274
- // Rename old file to avoid re-migration
275
424
  fs.renameSync(oldFile, oldFile + '.migrated');
276
- } catch { /* migration failed — non-critical */ }
425
+ } catch {}
277
426
  }
@@ -474,15 +474,31 @@ function renderMessages(){
474
474
  var safe=raw.replace(/!\\[([^\\]]*)\\]\\((\\/api\\/screenshots\\/[a-zA-Z0-9._-]+)\\)/g,function(_,alt,src){var ph='__IMG'+idx+'__';imgs.push({ph:ph,alt:alt,src:src});idx++;return ph;});
475
475
  var content=esc(safe);
476
476
  for(var i=0;i<imgs.length;i++){content=content.replace(imgs[i].ph,'<img class="screenshot-preview" alt="'+esc(imgs[i].alt)+'" src="'+imgs[i].src+'">');}
477
- // Action buttons
477
+ // Action buttons + fork navigation
478
478
  var acts='<div class="msg__actions">';
479
479
  acts+='<button onclick="copyMsg('+mi+')">Copy</button>';
480
480
  if(isA){acts+='<button onclick="retryMsg('+mi+')">Retry</button>';}
481
481
  else{acts+='<button onclick="editMsg('+mi+')">Edit</button>';}
482
+ // Fork navigation placeholder (filled after render by loadForkInfo)
483
+ if(m.id){acts+='<span class="msg__fork" data-node-id="'+m.id+'"></span>';}
482
484
  acts+='</div>';
483
485
  h+='<div class="msg msg--'+esc(m.role)+'"><div class="msg__label">'+esc(m.role==='user'?'You':'NHA')+'</div><div class="msg__bubble">'+content+'</div>'+acts+'</div>';
484
486
  });
485
487
  el.innerHTML=h;el.scrollTop=el.scrollHeight;
488
+ // Load fork info for messages that have IDs
489
+ if(activeConvId){
490
+ apiGet('/api/conversations/'+activeConvId+'/forks').then(function(r){
491
+ if(!r||!r.forks)return;
492
+ var forkEls=document.querySelectorAll('.msg__fork');
493
+ for(var fi=0;fi<forkEls.length;fi++){
494
+ var nodeId=forkEls[fi].getAttribute('data-node-id');
495
+ var forkInfo=r.forks[nodeId];
496
+ if(forkInfo&&forkInfo.total>1){
497
+ forkEls[fi].innerHTML='<button onclick="navigateFork(\\x27'+nodeId+'\\x27,-1)" style="background:none;border:none;color:var(--dim);cursor:pointer;font-size:11px">&#x25C0;</button><span style="font-size:9px;color:var(--dim);margin:0 2px">'+(forkInfo.current+1)+'/'+forkInfo.total+'</span><button onclick="navigateFork(\\x27'+nodeId+'\\x27,1)" style="background:none;border:none;color:var(--dim);cursor:pointer;font-size:11px">&#x25B6;</button>';
498
+ }
499
+ }
500
+ }).catch(function(){});
501
+ }
486
502
  }
487
503
  var chatAttachedFile=null;
488
504
  var chatAttachedImage=null;
@@ -710,16 +726,66 @@ function copyMsg(i){
710
726
  }
711
727
  function retryMsg(i){
712
728
  if(i<1||chatHistory[i].role!=='assistant')return;
713
- var userMsg=chatHistory[i-1];if(!userMsg||userMsg.role!=='user')return;
714
- chatHistory.splice(i,1);renderMessages();
715
- var inp=document.getElementById('chatInput');if(inp){inp.value=userMsg.content;}
716
- sendChat();
729
+ if(chatStreaming)return;
730
+ var userMsg=chatHistory[i-1];
731
+ if(!userMsg||userMsg.role!=='user')return;
732
+
733
+ // Keep the old response — just re-stream a new one
734
+ // The old response stays in the tree as a sibling branch
735
+ chatHistory[i]={role:'assistant',content:'Generating alternative...'};
736
+ renderMessages();
737
+
738
+ // Stream a new response for the same user message
739
+ chatStreaming=true;
740
+ chatAbortController=new AbortController();
741
+ var streamIdx=i;
742
+ var allHistory=chatHistory.slice(0,i-1).map(function(m){return{role:m.role,content:(m.content||'')};});
743
+ var payload={message:userMsg.content,history:allHistory,conversationId:activeConvId,isRetry:true};
744
+
745
+ fetch(API+'/api/chat/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:chatAbortController.signal}).then(function(response){
746
+ if(!response.ok||!response.body){chatHistory[streamIdx].content='Error: retry failed';endStreaming();renderMessages();return;}
747
+ var reader=response.body.getReader();var decoder=new TextDecoder();var buffer='';var currentEvent='';
748
+ function pump(){
749
+ reader.read().then(function(result){
750
+ if(result.done){endStreaming();renderMessages();return;}
751
+ buffer+=decoder.decode(result.value,{stream:true});
752
+ var lines=buffer.split('\\n');buffer=lines.pop()||'';
753
+ for(var li=0;li<lines.length;li++){
754
+ var line=lines[li];
755
+ if(line.startsWith('event: ')){currentEvent=line.slice(7).trim();continue;}
756
+ if(!line.startsWith('data: '))continue;
757
+ try{
758
+ var data=JSON.parse(line.slice(6));
759
+ if(currentEvent==='token'&&data.content){
760
+ if(chatHistory[streamIdx].content==='Generating alternative...')chatHistory[streamIdx].content='';
761
+ chatHistory[streamIdx].content+=data.content;
762
+ var el=document.getElementById('chatMessages');
763
+ if(el){var msgs=el.querySelectorAll('.msg');var last=msgs[streamIdx];if(last){var bub=last.querySelector('.msg__bubble');if(bub)bub.textContent=chatHistory[streamIdx].content;}el.scrollTop=el.scrollHeight;}
764
+ }
765
+ if(currentEvent==='tool_synthesis'){chatHistory[streamIdx].content='';renderMessages();}
766
+ if(currentEvent==='done'){endStreaming();if(data.content)chatHistory[streamIdx].content=data.content;renderMessages();loadConvList();}
767
+ if(currentEvent==='error'){endStreaming();chatHistory[streamIdx].content='Error: '+(data.message||'Unknown');renderMessages();}
768
+ }catch(e){}
769
+ }
770
+ pump();
771
+ }).catch(function(e){endStreaming();if(e.name!=='AbortError'){chatHistory[streamIdx].content='Error: '+e.message;renderMessages();}});
772
+ }
773
+ pump();
774
+ }).catch(function(e){endStreaming();chatHistory[streamIdx].content='Error: '+e.message;renderMessages();});
717
775
  }
718
776
  function editMsg(i){
719
777
  if(chatHistory[i].role!=='user')return;
720
778
  var inp=document.getElementById('chatInput');if(!inp)return;
721
779
  inp.value=chatHistory[i].content;inp.focus();
722
- chatHistory.splice(i);renderMessages();
780
+ // Truncate to before this message — new message creates a branch
781
+ chatHistory=chatHistory.slice(0,i);
782
+ renderMessages();
783
+ }
784
+ function navigateFork(nodeId,dir){
785
+ if(!activeConvId)return;
786
+ apiPost('/api/conversations/'+activeConvId+'/navigate',{nodeId:nodeId,direction:dir}).then(function(r){
787
+ if(r&&r.ok&&r.messages){chatHistory=r.messages;renderMessages();}
788
+ });
723
789
  }
724
790
  function sendChat(){
725
791
  var inp=document.getElementById('chatInput');if(!inp)return;