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 +1 -1
- package/src/commands/collab.mjs +8 -1
- package/src/commands/ui.mjs +74 -1
- package/src/constants.mjs +1 -1
- package/src/services/conversations.mjs +238 -89
- package/src/services/web-ui.mjs +72 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "
|
|
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": {
|
package/src/commands/collab.mjs
CHANGED
|
@@ -121,7 +121,14 @@ function getOrCreateIdentity() {
|
|
|
121
121
|
let id = loadIdentity();
|
|
122
122
|
if (!id) {
|
|
123
123
|
id = generateKeypair();
|
|
124
|
-
|
|
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}`);
|
package/src/commands/ui.mjs
CHANGED
|
@@ -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
|
-
|
|
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 = '
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
296
|
+
* Creates tree nodes. Auto-titles from first user message.
|
|
192
297
|
*/
|
|
193
298
|
export function addMessages(conv, userContent, assistantContent) {
|
|
194
|
-
conv
|
|
195
|
-
conv
|
|
299
|
+
const userId = addNode(conv, 'user', userContent);
|
|
300
|
+
const assistantId = addNode(conv, 'assistant', assistantContent, userId);
|
|
196
301
|
|
|
197
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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: ${
|
|
224
|
-
'',
|
|
225
|
-
'---',
|
|
226
|
-
'',
|
|
387
|
+
`*Messages: ${messages.length}*`,
|
|
388
|
+
'', '---', '',
|
|
227
389
|
];
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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 {
|
|
425
|
+
} catch {}
|
|
277
426
|
}
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -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">◀</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">▶</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
|
-
|
|
714
|
-
chatHistory
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
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;
|