nothumanallowed 12.7.0 → 13.2.13
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/LICENSE +21 -0
- package/README.md +185 -707
- package/bin/nha.mjs +35 -1
- package/package.json +2 -2
- package/src/commands/ui.mjs +484 -113
- package/src/config.mjs +0 -46
- package/src/constants.mjs +1 -1
- package/src/services/google-oauth.mjs +3 -8
- package/src/services/llm.mjs +140 -6
- package/src/services/tool-executor.mjs +2 -178
- package/src/services/web-ui.mjs +1452 -474
- package/src/services/imap-email.mjs +0 -428
package/src/commands/ui.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from '../services/task-store.mjs';
|
|
28
28
|
import { runPlanningPipeline } from '../services/ops-pipeline.mjs';
|
|
29
29
|
import { AGENTS, AGENTS_DIR, NHA_DIR, VERSION } from '../constants.mjs';
|
|
30
|
-
import { getHTML } from '../services/web-ui.mjs';
|
|
30
|
+
import { getHTML, getJS } from '../services/web-ui.mjs';
|
|
31
31
|
import { loadChatHistory, saveChatHistory, extractMemory, buildMemoryContext } from '../services/memory.mjs';
|
|
32
32
|
import {
|
|
33
33
|
createConversation,
|
|
@@ -244,6 +244,7 @@ export async function cmdUI(args) {
|
|
|
244
244
|
|
|
245
245
|
const config = loadConfig();
|
|
246
246
|
const htmlPage = getHTML(port);
|
|
247
|
+
const jsBundle = getJS();
|
|
247
248
|
|
|
248
249
|
// Migrate old chat history to multi-conversation format
|
|
249
250
|
migrateOldHistory();
|
|
@@ -285,6 +286,17 @@ export async function cmdUI(args) {
|
|
|
285
286
|
return;
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
// ── JS bundle ───────────────────────────────────────────────────
|
|
290
|
+
if (method === 'GET' && pathname.startsWith('/nha-ui.js')) {
|
|
291
|
+
res.writeHead(200, {
|
|
292
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
293
|
+
'Cache-Control': 'public, max-age=3600',
|
|
294
|
+
});
|
|
295
|
+
res.end(jsBundle);
|
|
296
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
288
300
|
// ── PWA Manifest ────────────────────────────────────────────────
|
|
289
301
|
if (pathname === '/manifest.json') {
|
|
290
302
|
sendJSON(res, 200, {
|
|
@@ -335,40 +347,15 @@ export async function cmdUI(args) {
|
|
|
335
347
|
|
|
336
348
|
// POST /api/google/auth — trigger Google OAuth flow from web UI
|
|
337
349
|
if (method === 'POST' && pathname === '/api/google/auth') {
|
|
338
|
-
// Check if Google credentials are configured first
|
|
339
|
-
const clientId = config.google?.clientId || '';
|
|
340
|
-
const clientSecret = config.google?.clientSecret || '';
|
|
341
|
-
if (!clientId) {
|
|
342
|
-
sendJSON(res, 200, {
|
|
343
|
-
ok: false,
|
|
344
|
-
needsSetup: true,
|
|
345
|
-
message: 'Google OAuth not configured yet.\n\n' +
|
|
346
|
-
'To connect Google services, you need OAuth credentials:\n\n' +
|
|
347
|
-
'1. Go to https://console.cloud.google.com/apis/credentials\n' +
|
|
348
|
-
'2. Create an OAuth 2.0 Client ID (Desktop app type)\n' +
|
|
349
|
-
'3. Enable Gmail API, Calendar API, Drive API, People API, Tasks API\n' +
|
|
350
|
-
'4. Add authorized redirect URIs: http://127.0.0.1:19847/callback through http://127.0.0.1:19851/callback\n' +
|
|
351
|
-
'5. In the NHA terminal, run:\n' +
|
|
352
|
-
' nha config set google-client-id YOUR_CLIENT_ID\n' +
|
|
353
|
-
' nha config set google-client-secret YOUR_CLIENT_SECRET\n' +
|
|
354
|
-
'6. Then click "Connect Google" again.',
|
|
355
|
-
});
|
|
356
|
-
logRequest(method, pathname, 200, Date.now() - start);
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
350
|
try {
|
|
360
351
|
const { runAuthFlow } = await import('../services/google-oauth.mjs');
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
config._googleConnected = true;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
sendJSON(res, 200, { ok: true, message: 'Google connected successfully! You can now use email, calendar, contacts, and Drive.' });
|
|
367
|
-
} else {
|
|
368
|
-
sendJSON(res, 200, { ok: false, message: 'Google OAuth failed. The browser window should have opened at accounts.google.com. If it didn\'t, try running "nha google" from the terminal.' });
|
|
369
|
-
}
|
|
352
|
+
// Run auth flow in background — opens browser
|
|
353
|
+
runAuthFlow(config).then(success => {
|
|
354
|
+
if (success) config._googleConnected = true;
|
|
355
|
+
}).catch(() => {});
|
|
356
|
+
sendJSON(res, 200, { ok: true, message: 'OAuth flow started. Check the browser window that opened.' });
|
|
370
357
|
} catch (e) {
|
|
371
|
-
sendJSON(res, 500, { error:
|
|
358
|
+
sendJSON(res, 500, { error: e.message });
|
|
372
359
|
}
|
|
373
360
|
logRequest(method, pathname, 200, Date.now() - start);
|
|
374
361
|
return;
|
|
@@ -631,14 +618,6 @@ export async function cmdUI(args) {
|
|
|
631
618
|
// GET /api/config — read config values for settings UI
|
|
632
619
|
if (method === 'GET' && pathname === '/api/config') {
|
|
633
620
|
// Return non-sensitive config for the settings form
|
|
634
|
-
// Sanitize email accounts — don't expose passwords to frontend
|
|
635
|
-
const safeAccounts = (config.emailAccounts || []).map(a => ({
|
|
636
|
-
label: a.label,
|
|
637
|
-
address: a.address,
|
|
638
|
-
isDefault: a.isDefault,
|
|
639
|
-
hasImap: !!(a.imap?.host),
|
|
640
|
-
hasSmtp: !!(a.smtp?.host),
|
|
641
|
-
}));
|
|
642
621
|
sendJSON(res, 200, {
|
|
643
622
|
profile: config.profile || {},
|
|
644
623
|
provider: config.llm?.provider || '',
|
|
@@ -649,8 +628,6 @@ export async function cmdUI(args) {
|
|
|
649
628
|
meetingAlert: config.ops?.meetingAlertMinutes || 30,
|
|
650
629
|
hasTelegram: !!config.responder?.telegram?.token,
|
|
651
630
|
hasDiscord: !!config.responder?.discord?.token,
|
|
652
|
-
hasGoogle: !!config._googleConnected,
|
|
653
|
-
emailAccounts: safeAccounts,
|
|
654
631
|
});
|
|
655
632
|
logRequest(method, pathname, 200, Date.now() - start);
|
|
656
633
|
return;
|
|
@@ -1270,7 +1247,7 @@ export async function cmdUI(args) {
|
|
|
1270
1247
|
}
|
|
1271
1248
|
}
|
|
1272
1249
|
|
|
1273
|
-
if (!config.llm.apiKey && config.llm.provider !== 'nha') {
|
|
1250
|
+
if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
|
|
1274
1251
|
config.llm.provider = 'nha'; // Auto-fallback to free tier
|
|
1275
1252
|
}
|
|
1276
1253
|
|
|
@@ -1292,14 +1269,12 @@ export async function cmdUI(args) {
|
|
|
1292
1269
|
if (sLines.length > 0) parts.push(`[CONTEXT — ${sLines.length} earlier exchanges]\n${sLines.join('\n')}\n[END CONTEXT]`);
|
|
1293
1270
|
}
|
|
1294
1271
|
for (const turn of requestHistory.slice(-RECENT)) {
|
|
1295
|
-
|
|
1296
|
-
const turnContent = turn.llmContent || turn.content;
|
|
1297
|
-
parts.push(`${turn.role === 'user' ? '[User]' : '[Assistant]'} ${turnContent.slice(0, 4000)}`);
|
|
1272
|
+
parts.push(`${turn.role === 'user' ? '[User]' : '[Assistant]'} ${turn.content.slice(0, 2000)}`);
|
|
1298
1273
|
}
|
|
1299
1274
|
parts.push(`[User] ${body.message}`);
|
|
1300
1275
|
let userMessage = parts.join('\n\n');
|
|
1301
1276
|
|
|
1302
|
-
// Inject episodic memory
|
|
1277
|
+
// Inject episodic memory context into the system prompt
|
|
1303
1278
|
const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
|
|
1304
1279
|
let enrichedSystemPrompt = basePrompt;
|
|
1305
1280
|
try {
|
|
@@ -1307,37 +1282,6 @@ export async function cmdUI(args) {
|
|
|
1307
1282
|
if (memCtx) enrichedSystemPrompt = basePrompt + memCtx;
|
|
1308
1283
|
} catch { /* memory unavailable */ }
|
|
1309
1284
|
|
|
1310
|
-
// Cross-conversation memory — summaries of recent conversations
|
|
1311
|
-
try {
|
|
1312
|
-
const allConvs = listConversations();
|
|
1313
|
-
if (allConvs.length > 1) {
|
|
1314
|
-
const summaries = [];
|
|
1315
|
-
let totalChars = 0;
|
|
1316
|
-
for (const c of allConvs) {
|
|
1317
|
-
if (c.id === (body.conversationId || activeConvId)) continue;
|
|
1318
|
-
if (summaries.length >= 8 || totalChars > 2000) break;
|
|
1319
|
-
if (!c.messages || c.messages.length === 0) continue;
|
|
1320
|
-
const firstUser = c.messages.find(m => m.role === 'user');
|
|
1321
|
-
const lastAssistant = [...c.messages].reverse().find(m => m.role === 'assistant');
|
|
1322
|
-
if (!firstUser) continue;
|
|
1323
|
-
const date = c.updatedAt?.split('T')[0] || '?';
|
|
1324
|
-
const title = c.title !== 'New Chat' ? c.title : firstUser.content.slice(0, 60);
|
|
1325
|
-
let s = `• [${date}] "${title}" (${c.messages.length} msgs)`;
|
|
1326
|
-
s += `\n User: ${firstUser.content.replace(/\s+/g, ' ').slice(0, 120)}`;
|
|
1327
|
-
if (lastAssistant) {
|
|
1328
|
-
const preview = lastAssistant.content.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/\s+/g, ' ').slice(0, 150);
|
|
1329
|
-
s += `\n Result: ${preview}`;
|
|
1330
|
-
}
|
|
1331
|
-
if (totalChars + s.length > 2000) break;
|
|
1332
|
-
summaries.push(s);
|
|
1333
|
-
totalChars += s.length;
|
|
1334
|
-
}
|
|
1335
|
-
if (summaries.length > 0) {
|
|
1336
|
-
enrichedSystemPrompt += `\n\n--- CONVERSATION MEMORY ---\nYou remember these past conversations:\n\n${summaries.join('\n\n')}\n\nUse this to maintain continuity. Never say "I don't have access to previous conversations".\n--- END MEMORY ---`;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
} catch { /* non-critical */ }
|
|
1340
|
-
|
|
1341
1285
|
// Handle image attachment — vision API
|
|
1342
1286
|
if (body.imageBase64 && body.imageMimeType) {
|
|
1343
1287
|
try {
|
|
@@ -1430,33 +1374,13 @@ export async function cmdUI(args) {
|
|
|
1430
1374
|
const pdfPrompt = body.message || `Read and analyze this PDF document "${body.pdfName}". Extract all text content, summarize key information.`;
|
|
1431
1375
|
let pdfResponse = '';
|
|
1432
1376
|
|
|
1433
|
-
// Step 1: Extract text — try server (pdftotext) first, then local fallback
|
|
1434
|
-
let pdfText = '';
|
|
1435
|
-
try {
|
|
1436
|
-
const extractRes = await fetch('https://nothumanallowed.com/api/v1/tools/extract-pdf', {
|
|
1437
|
-
method: 'POST',
|
|
1438
|
-
headers: { 'Content-Type': 'application/json', 'X-NHA-Client': 'desktop' },
|
|
1439
|
-
body: JSON.stringify({ base64: body.pdfBase64 }),
|
|
1440
|
-
signal: AbortSignal.timeout(30000),
|
|
1441
|
-
});
|
|
1442
|
-
if (extractRes.ok) {
|
|
1443
|
-
const d = await extractRes.json();
|
|
1444
|
-
pdfText = d.text || '';
|
|
1445
|
-
}
|
|
1446
|
-
} catch { /* server unreachable */ }
|
|
1447
|
-
// Local fallback if server extraction failed
|
|
1448
|
-
if (pdfText.length < 20) {
|
|
1449
|
-
const pdfBuffer = Buffer.from(body.pdfBase64, 'base64');
|
|
1450
|
-
pdfText = extractTextFromPdf(pdfBuffer);
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
// Save extracted text as llmContent so it persists across turns
|
|
1454
|
-
const pdfLlmContent = pdfText.length > 20
|
|
1455
|
-
? `[PDF: ${body.pdfName}]\n\n${pdfText.slice(0, 12000)}\n\n---\n\nUser question: ${pdfPrompt}`
|
|
1456
|
-
: '';
|
|
1457
|
-
|
|
1458
1377
|
if (provider === 'nha') {
|
|
1378
|
+
// NHA Free tier: extract text from PDF, then send to Liara chat
|
|
1379
|
+
// Decode PDF base64 and extract text content
|
|
1380
|
+
const pdfBuffer = Buffer.from(body.pdfBase64, 'base64');
|
|
1381
|
+
const pdfText = extractTextFromPdf(pdfBuffer);
|
|
1459
1382
|
if (!pdfText || pdfText.length < 10) {
|
|
1383
|
+
// Fallback: send first page as image to vision model
|
|
1460
1384
|
const r = await fetch('https://nothumanallowed.com/api/v1/liara/vision', {
|
|
1461
1385
|
method: 'POST',
|
|
1462
1386
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1469,6 +1393,7 @@ export async function cmdUI(args) {
|
|
|
1469
1393
|
pdfResponse = 'Could not read this PDF. Try a text-based PDF or use Claude/Gemini for scanned documents.';
|
|
1470
1394
|
}
|
|
1471
1395
|
} else {
|
|
1396
|
+
// Send extracted text to Liara chat
|
|
1472
1397
|
const truncatedText = pdfText.slice(0, 12000);
|
|
1473
1398
|
const r = await fetch('https://nothumanallowed.com/api/v1/liara/chat', {
|
|
1474
1399
|
method: 'POST',
|
|
@@ -1526,8 +1451,7 @@ export async function cmdUI(args) {
|
|
|
1526
1451
|
pdfResponse = `PDF reading requires Anthropic (Claude) or Gemini. Your provider "${provider}" does not support native PDF documents.`;
|
|
1527
1452
|
}
|
|
1528
1453
|
|
|
1529
|
-
|
|
1530
|
-
sendJSON(res, 200, { response: pdfResponse, llmContent: pdfLlmContent || undefined });
|
|
1454
|
+
sendJSON(res, 200, { response: pdfResponse });
|
|
1531
1455
|
logRequest(method, pathname, 200, Date.now() - start);
|
|
1532
1456
|
return;
|
|
1533
1457
|
} catch (e) {
|
|
@@ -1537,14 +1461,12 @@ export async function cmdUI(args) {
|
|
|
1537
1461
|
}
|
|
1538
1462
|
}
|
|
1539
1463
|
|
|
1540
|
-
// Handle text file attachment
|
|
1541
|
-
let fileLlmContent = '';
|
|
1464
|
+
// Handle text file attachment
|
|
1542
1465
|
if (body.fileContent && body.fileName) {
|
|
1543
1466
|
const filePrompt = body.message
|
|
1544
1467
|
? `User asks about file "${body.fileName}": ${body.message}\n\nFile content:\n${body.fileContent.slice(0, 8000)}`
|
|
1545
1468
|
: `Analyze this file "${body.fileName}":\n\n${body.fileContent.slice(0, 8000)}`;
|
|
1546
1469
|
userMessage = filePrompt;
|
|
1547
|
-
fileLlmContent = filePrompt;
|
|
1548
1470
|
}
|
|
1549
1471
|
|
|
1550
1472
|
try {
|
|
@@ -1563,7 +1485,11 @@ export async function cmdUI(args) {
|
|
|
1563
1485
|
screenshotData = result;
|
|
1564
1486
|
toolResults.push({ action, result: 'Screenshot captured. Analyzing with vision...' });
|
|
1565
1487
|
} else {
|
|
1566
|
-
|
|
1488
|
+
let rStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
|
|
1489
|
+
if ((action === 'web_search' || action === 'fetch_url') && rStr.includes('<')) {
|
|
1490
|
+
rStr = rStr.replace(/<style[\s\S]*?<\/style>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'').replace(/<[^>]+>/g,' ').replace(/\s{3,}/g,'\n').replace(/[^\x00-\x7F]/g,'').trim().slice(0,6000);
|
|
1491
|
+
}
|
|
1492
|
+
toolResults.push({ action, result: rStr });
|
|
1567
1493
|
}
|
|
1568
1494
|
} catch (e) {
|
|
1569
1495
|
toolResults.push({ action, result: `Error: ${e.message}` });
|
|
@@ -1641,7 +1567,7 @@ export async function cmdUI(args) {
|
|
|
1641
1567
|
} catch { /* non-critical */ }
|
|
1642
1568
|
try { extractMemory('chat', body.message, fullResponse); } catch { /* non-critical */ }
|
|
1643
1569
|
|
|
1644
|
-
sendJSON(res, 200, { response: fullResponse, toolResults, actions
|
|
1570
|
+
sendJSON(res, 200, { response: fullResponse, toolResults, actions });
|
|
1645
1571
|
} catch (e) {
|
|
1646
1572
|
sendJSON(res, 200, { response: null, error: e.message });
|
|
1647
1573
|
}
|
|
@@ -1783,7 +1709,7 @@ export async function cmdUI(args) {
|
|
|
1783
1709
|
if (method === 'POST' && pathname === '/api/chat/stream') {
|
|
1784
1710
|
const body = await parseBody(req);
|
|
1785
1711
|
if (!body.message) { sendJSON(res, 400, { error: 'message required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
|
|
1786
|
-
if (!config.llm.apiKey && config.llm.provider !== 'nha') { config.llm.provider = 'nha'; }
|
|
1712
|
+
if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) { config.llm.provider = 'nha'; }
|
|
1787
1713
|
|
|
1788
1714
|
const msg = body.message.trim();
|
|
1789
1715
|
const convId = body.conversationId;
|
|
@@ -1989,7 +1915,18 @@ export async function cmdUI(args) {
|
|
|
1989
1915
|
continue;
|
|
1990
1916
|
}
|
|
1991
1917
|
|
|
1992
|
-
|
|
1918
|
+
let resultStr = typeof result === 'object' ? JSON.stringify(result) : String(result);
|
|
1919
|
+
// For web_search/fetch_url: strip raw HTML/CSS so the LLM gets clean text
|
|
1920
|
+
if ((action === 'web_search' || action === 'fetch_url') && resultStr.includes('<')) {
|
|
1921
|
+
resultStr = resultStr
|
|
1922
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
1923
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
1924
|
+
.replace(/<[^>]+>/g, ' ')
|
|
1925
|
+
.replace(/\s{3,}/g, '\n')
|
|
1926
|
+
.replace(/[^\x00-\x7F]/g, '') // strip non-ASCII that causes encoding issues
|
|
1927
|
+
.trim()
|
|
1928
|
+
.slice(0, 6000);
|
|
1929
|
+
}
|
|
1993
1930
|
toolResults.push({ action, result: resultStr });
|
|
1994
1931
|
sendSSE('tool', { action, status: 'done', result: typeof resultStr === 'string' ? resultStr.slice(0, 500) : '' });
|
|
1995
1932
|
|
|
@@ -2280,7 +2217,7 @@ export async function cmdUI(args) {
|
|
|
2280
2217
|
return;
|
|
2281
2218
|
}
|
|
2282
2219
|
|
|
2283
|
-
if (!config.llm.apiKey && config.llm.provider !== 'nha') {
|
|
2220
|
+
if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
|
|
2284
2221
|
// Auto-fallback to NHA free tier if no API key is configured
|
|
2285
2222
|
config.llm.provider = 'nha';
|
|
2286
2223
|
}
|
|
@@ -2351,6 +2288,107 @@ export async function cmdUI(args) {
|
|
|
2351
2288
|
return;
|
|
2352
2289
|
}
|
|
2353
2290
|
|
|
2291
|
+
// POST /api/ask/stream — streaming SSE agent call
|
|
2292
|
+
if (method === 'POST' && pathname === '/api/ask/stream') {
|
|
2293
|
+
const body = await parseBody(req);
|
|
2294
|
+
if (!body.agent || !body.prompt) {
|
|
2295
|
+
sendJSON(res, 400, { error: 'agent and prompt required' });
|
|
2296
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Ensure provider is set — default to 'nha' free tier if no apiKey
|
|
2301
|
+
if (!config.llm.provider || (!config.llm.apiKey && config.llm.provider !== 'nha')) {
|
|
2302
|
+
config.llm.provider = 'nha';
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const agentFileStream = path.join(AGENTS_DIR, `${body.agent}.mjs`);
|
|
2306
|
+
if (!AGENTS.includes(body.agent) && !fs.existsSync(agentFileStream)) {
|
|
2307
|
+
sendJSON(res, 400, { error: `Unknown agent: ${body.agent}` });
|
|
2308
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
res.writeHead(200, {
|
|
2313
|
+
'Content-Type': 'text/event-stream',
|
|
2314
|
+
'Cache-Control': 'no-cache',
|
|
2315
|
+
'Connection': 'keep-alive',
|
|
2316
|
+
'Access-Control-Allow-Origin': '*',
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
const sendEv = (obj) => { try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {} };
|
|
2320
|
+
|
|
2321
|
+
try {
|
|
2322
|
+
let context = '';
|
|
2323
|
+
try {
|
|
2324
|
+
const [emails, events] = await Promise.all([
|
|
2325
|
+
getUnreadImportant(config, 15).catch(() => []),
|
|
2326
|
+
getTodayEvents(config).catch(() => []),
|
|
2327
|
+
]);
|
|
2328
|
+
const tasks = getTasks();
|
|
2329
|
+
if (emails.length > 0) {
|
|
2330
|
+
context += '\n\n[USER EMAIL CONTEXT]\n';
|
|
2331
|
+
emails.slice(0, 10).forEach((e, i) => {
|
|
2332
|
+
context += `${i + 1}. From: ${e.from} | Subject: ${e.subject}\n ${e.snippet.slice(0, 150)}\n`;
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
if (events.length > 0) {
|
|
2336
|
+
context += '\n\n[USER CALENDAR — today]\n';
|
|
2337
|
+
events.forEach(e => {
|
|
2338
|
+
context += `${e.isAllDay ? 'All day' : e.start + ' - ' + e.end}: ${e.summary}\n`;
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
if (tasks.length > 0) {
|
|
2342
|
+
context += '\n\n[USER TASKS]\n';
|
|
2343
|
+
tasks.forEach(t => { context += `#${t.id} [${t.priority}] ${t.description}\n`; });
|
|
2344
|
+
}
|
|
2345
|
+
} catch { /* proceed without context */ }
|
|
2346
|
+
|
|
2347
|
+
let fileContext = '';
|
|
2348
|
+
if (body.fileContent && body.fileName) {
|
|
2349
|
+
fileContext = '\n\n--- Attached: ' + body.fileName + ' ---\n' + String(body.fileContent).slice(0, 100000);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
const enrichedPrompt = body.prompt + fileContext + (context
|
|
2353
|
+
? '\n\nIMPORTANT CONTEXT: The data below is from the user\'s OWN accounts.\n' + context : '');
|
|
2354
|
+
|
|
2355
|
+
if (!fs.existsSync(agentFileStream)) {
|
|
2356
|
+
sendEv({ error: `Agent "${body.agent}" not downloaded. Run: nha update` });
|
|
2357
|
+
sendEv({ done: true });
|
|
2358
|
+
res.end();
|
|
2359
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
const src = fs.readFileSync(agentFileStream, 'utf-8');
|
|
2363
|
+
const { systemPrompt } = parseAgentFile(src, body.agent);
|
|
2364
|
+
let enrichedSystem = systemPrompt;
|
|
2365
|
+
try {
|
|
2366
|
+
const { buildMemoryContext } = await import('../services/memory.mjs');
|
|
2367
|
+
const mc = buildMemoryContext(body.agent, enrichedPrompt);
|
|
2368
|
+
if (mc) enrichedSystem = systemPrompt + mc;
|
|
2369
|
+
} catch { /* proceed */ }
|
|
2370
|
+
|
|
2371
|
+
let fullResponse = '';
|
|
2372
|
+
await callLLMStream(config, enrichedSystem, enrichedPrompt, (token) => {
|
|
2373
|
+
fullResponse += token;
|
|
2374
|
+
sendEv({ token });
|
|
2375
|
+
});
|
|
2376
|
+
sendEv({ done: true });
|
|
2377
|
+
|
|
2378
|
+
try {
|
|
2379
|
+
const { extractMemory } = await import('../services/memory.mjs');
|
|
2380
|
+
extractMemory(body.agent, enrichedPrompt, fullResponse);
|
|
2381
|
+
} catch { /* non-critical */ }
|
|
2382
|
+
|
|
2383
|
+
} catch (e) {
|
|
2384
|
+
sendEv({ error: e.message });
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
res.end();
|
|
2388
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2354
2392
|
// ── GitHub ───────────────────────────────────────────────────────
|
|
2355
2393
|
if (method === 'GET' && pathname === '/api/github') {
|
|
2356
2394
|
try {
|
|
@@ -2499,6 +2537,339 @@ export async function cmdUI(args) {
|
|
|
2499
2537
|
return;
|
|
2500
2538
|
}
|
|
2501
2539
|
|
|
2540
|
+
// ── Studio: plan workflow ────────────────────────────────────────
|
|
2541
|
+
if (pathname === '/api/studio/plan' && method === 'POST') {
|
|
2542
|
+
const body = await parseBody(req);
|
|
2543
|
+
const task = (body.task || '').trim();
|
|
2544
|
+
if (!task) { sendJSON(res, 400, { error: 'task required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
|
|
2545
|
+
|
|
2546
|
+
const planPrompt = `You are a workflow planner for NHA Studio. The user wants to accomplish this task: "${task}"
|
|
2547
|
+
|
|
2548
|
+
Design a sequential workflow of 2-5 steps. RULES:
|
|
2549
|
+
- Use tool-agents (WebSearchAgent, EmailAgent, CalendarAgent, GitHubAgent, NotionAgent, SlackAgent) FIRST when real live data is needed
|
|
2550
|
+
- Use specialist agents (SABER, ATLAS, JARVIS, etc.) for deep domain analysis — they have rich expert system prompts
|
|
2551
|
+
- Use CanvasAgent as the LAST step ONLY when a visual HTML report is requested
|
|
2552
|
+
- The "prompt" field must be a plain language instruction — never JSON or code
|
|
2553
|
+
- Each agent receives the previous step's output as context automatically
|
|
2554
|
+
- Pick the most relevant agents for the task — don't use all of them
|
|
2555
|
+
|
|
2556
|
+
TOOL AGENTS (fetch real live data):
|
|
2557
|
+
- WebSearchAgent: search the web for current information
|
|
2558
|
+
- EmailAgent: read user's real unread emails
|
|
2559
|
+
- CalendarAgent: read user's real calendar events for today
|
|
2560
|
+
- GitHubAgent: read user's GitHub notifications and issues
|
|
2561
|
+
- NotionAgent: search user's Notion workspace
|
|
2562
|
+
- SlackAgent: read user's Slack messages
|
|
2563
|
+
- WriterAgent: write, summarize, synthesize text (no live data)
|
|
2564
|
+
- SummaryAgent: condense and summarize content
|
|
2565
|
+
- DataAnalystAgent: analyze data, find patterns, generate insights
|
|
2566
|
+
- SecurityAgent: security audit, threat analysis
|
|
2567
|
+
- DevOpsAgent: infrastructure, deployment, CI/CD analysis
|
|
2568
|
+
- CanvasAgent: generate a beautiful HTML visual dashboard (LAST step only)
|
|
2569
|
+
|
|
2570
|
+
SPECIALIST AGENTS (deep domain experts with rich system prompts):
|
|
2571
|
+
- SABER: security audits, OWASP, penetration testing, vulnerability analysis
|
|
2572
|
+
- ATLAS: infrastructure-as-code, Terraform, Kubernetes, cloud architecture
|
|
2573
|
+
- JARVIS: full-stack architecture, API design, system design, ADRs
|
|
2574
|
+
- VERITAS: fact-checking, evidence verification, claim validation
|
|
2575
|
+
- CASSANDRA: risk analysis, failure modes, worst-case scenarios
|
|
2576
|
+
- MERCURY: financial analysis, ROI, unit economics, market modeling
|
|
2577
|
+
- HERALD: news analysis, trend detection, executive briefings
|
|
2578
|
+
- ATHENA: tech evaluation, framework comparison, benchmarks
|
|
2579
|
+
- ORACLE: business intelligence, KPIs, OKRs, dashboards
|
|
2580
|
+
- NAVI: data exploration, statistical analysis, pattern detection
|
|
2581
|
+
- MUSE: creative brainstorming, ideation, naming, taglines
|
|
2582
|
+
- QUILL: short-form content, summaries, press releases
|
|
2583
|
+
- SCHEHERAZADE: long-form technical writing, documentation, tutorials
|
|
2584
|
+
- ECHO: content adaptation, cross-platform distribution
|
|
2585
|
+
- POLYGLOT: translation, localization, multilingual content
|
|
2586
|
+
- FORGE: CI/CD pipelines, GitHub Actions, Docker builds
|
|
2587
|
+
- FLUX: deployment strategies, blue/green, canary releases
|
|
2588
|
+
- SHOGUN: Kubernetes, Helm, container orchestration
|
|
2589
|
+
- PIPE: data pipelines, Airflow, dbt, ETL
|
|
2590
|
+
- MACRO: bulk operations, data migration, batch processing
|
|
2591
|
+
- SHELL: shell scripting, CLI tools, automation scripts
|
|
2592
|
+
- CONDUCTOR: workflow orchestration, task decomposition
|
|
2593
|
+
- CRON: scheduling, cron jobs, time-based automation
|
|
2594
|
+
- HERMES: webhooks, event-driven architecture, integrations
|
|
2595
|
+
- BABEL: API design, microservices, OpenAPI specs
|
|
2596
|
+
- CARTOGRAPHER: data mapping, schema inference, knowledge graphs
|
|
2597
|
+
- LOGOS: logical analysis, argument mapping, decision theory
|
|
2598
|
+
- EDI: A/B testing, statistical modeling, hypothesis testing
|
|
2599
|
+
- EPICURE: nutrition, recipes, meal planning
|
|
2600
|
+
- MURASAKI: creative writing, storytelling, narrative craft
|
|
2601
|
+
- LINK: community management, reputation systems
|
|
2602
|
+
- GLITCH: chaos engineering, resilience testing
|
|
2603
|
+
- TEMPEST: performance engineering, load testing
|
|
2604
|
+
- SAURON: observability, monitoring, alerting
|
|
2605
|
+
- PROMETHEUS: capability routing, task decomposition
|
|
2606
|
+
- ADE: agent design, system prompt engineering
|
|
2607
|
+
- ZERO: vulnerability scanning, dependency audit, secret detection
|
|
2608
|
+
|
|
2609
|
+
Icon values must be actual emoji characters — never HTML entities.
|
|
2610
|
+
|
|
2611
|
+
Respond with ONLY valid JSON, no markdown:
|
|
2612
|
+
{"steps":[{"icon":"🔍","agent":"WebSearchAgent","label":"Search AI news","prompt":"Search for the latest AI agent news"},{"icon":"🧠","agent":"ATHENA","label":"Tech analysis","prompt":"Analyze the search results and compare the key technologies mentioned"}]}`;
|
|
2613
|
+
|
|
2614
|
+
try {
|
|
2615
|
+
const planRaw = await callLLM(config, 'You are a JSON workflow planner. Respond only with valid JSON.', planPrompt, { max_tokens: 800 });
|
|
2616
|
+
let steps;
|
|
2617
|
+
try {
|
|
2618
|
+
const jsonMatch = planRaw.match(/\{[\s\S]*\}/);
|
|
2619
|
+
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : planRaw);
|
|
2620
|
+
steps = parsed.steps;
|
|
2621
|
+
} catch {
|
|
2622
|
+
sendJSON(res, 500, { error: 'Failed to parse workflow plan' });
|
|
2623
|
+
logRequest(method, pathname, 500, Date.now() - start);
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
if (!Array.isArray(steps) || !steps.length) {
|
|
2627
|
+
sendJSON(res, 500, { error: 'Empty workflow plan' });
|
|
2628
|
+
logRequest(method, pathname, 500, Date.now() - start);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
sendJSON(res, 200, { steps });
|
|
2632
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2633
|
+
} catch (e) {
|
|
2634
|
+
sendJSON(res, 500, { error: e.message });
|
|
2635
|
+
logRequest(method, pathname, 500, Date.now() - start);
|
|
2636
|
+
}
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// ── Studio: run single step (SSE streaming) ──────────────────────
|
|
2641
|
+
if (pathname === '/api/studio/run' && method === 'POST') {
|
|
2642
|
+
const body = await parseBody(req);
|
|
2643
|
+
const { agent, task, context, stepDef } = body;
|
|
2644
|
+
if (!agent || !task) { sendJSON(res, 400, { error: 'agent and task required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
|
|
2645
|
+
|
|
2646
|
+
res.writeHead(200, {
|
|
2647
|
+
'Content-Type': 'text/event-stream',
|
|
2648
|
+
'Cache-Control': 'no-cache',
|
|
2649
|
+
'Connection': 'keep-alive',
|
|
2650
|
+
'Access-Control-Allow-Origin': '*',
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
const sendEvent = (data) => {
|
|
2654
|
+
try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}
|
|
2655
|
+
};
|
|
2656
|
+
const sendToken = (t) => sendEvent({ token: t });
|
|
2657
|
+
|
|
2658
|
+
// Keepalive: send a comment every 5s so the connection doesn't time out during slow tool calls
|
|
2659
|
+
const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 5000);
|
|
2660
|
+
|
|
2661
|
+
// Timeout wrapper for tool calls — 25s max
|
|
2662
|
+
const withTimeout = (promise, label) => Promise.race([
|
|
2663
|
+
promise,
|
|
2664
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} timed out after 25s`)), 25000)),
|
|
2665
|
+
]);
|
|
2666
|
+
|
|
2667
|
+
try {
|
|
2668
|
+
const stepPrompt = stepDef?.prompt || task;
|
|
2669
|
+
let toolData = '';
|
|
2670
|
+
|
|
2671
|
+
// ── Fetch REAL data for each agent type ──────────────────────
|
|
2672
|
+
if (agent === 'EmailAgent') {
|
|
2673
|
+
sendToken('[Reading emails...] ');
|
|
2674
|
+
try {
|
|
2675
|
+
const emails = await withTimeout(getUnreadImportant(config, 10), 'EmailAgent');
|
|
2676
|
+
toolData = emails && emails.length
|
|
2677
|
+
? emails.map(e => `From: ${e.from}\nSubject: ${e.subject}\nDate: ${e.date}\nSnippet: ${e.snippet}`).join('\n\n---\n\n')
|
|
2678
|
+
: 'No unread emails found.';
|
|
2679
|
+
} catch (e) { toolData = `Email read failed: ${e.message}`; }
|
|
2680
|
+
|
|
2681
|
+
} else if (agent === 'CalendarAgent') {
|
|
2682
|
+
sendToken('[Reading calendar...] ');
|
|
2683
|
+
try {
|
|
2684
|
+
const events = await withTimeout(getTodayEvents(config), 'CalendarAgent');
|
|
2685
|
+
toolData = events && events.length
|
|
2686
|
+
? events.map(e => `${e.summary || e.title} - ${e.start || ''} to ${e.end || ''}`).join('\n')
|
|
2687
|
+
: 'No events found for today.';
|
|
2688
|
+
} catch (e) { toolData = `Calendar read failed: ${e.message}`; }
|
|
2689
|
+
|
|
2690
|
+
} else if (agent === 'WebSearchAgent' || agent === 'ResearchAgent') {
|
|
2691
|
+
sendToken('[Searching the web...] ');
|
|
2692
|
+
try {
|
|
2693
|
+
const searchResult = await withTimeout(executeTool('web_search', { query: stepPrompt }, config), 'WebSearch');
|
|
2694
|
+
toolData = typeof searchResult === 'string' ? searchResult : JSON.stringify(searchResult);
|
|
2695
|
+
} catch (e) { toolData = `Web search failed: ${e.message}`; }
|
|
2696
|
+
|
|
2697
|
+
} else if (agent === 'BrowserAgent') {
|
|
2698
|
+
const urlMatch = stepPrompt.match(/https?:\/\/[^\s"']+/);
|
|
2699
|
+
if (urlMatch) {
|
|
2700
|
+
sendToken(`[Fetching ${urlMatch[0]}...] `);
|
|
2701
|
+
try {
|
|
2702
|
+
const fetchResult = await withTimeout(executeTool('fetch_url', { url: urlMatch[0] }, config), 'BrowserAgent');
|
|
2703
|
+
toolData = typeof fetchResult === 'string' ? fetchResult : JSON.stringify(fetchResult);
|
|
2704
|
+
} catch (e) { toolData = `Fetch failed: ${e.message}`; }
|
|
2705
|
+
} else {
|
|
2706
|
+
sendToken('[Searching web...] ');
|
|
2707
|
+
try {
|
|
2708
|
+
const searchResult = await withTimeout(executeTool('web_search', { query: stepPrompt }, config), 'BrowserSearch');
|
|
2709
|
+
toolData = typeof searchResult === 'string' ? searchResult : JSON.stringify(searchResult);
|
|
2710
|
+
} catch (e) { toolData = `Browser search failed: ${e.message}`; }
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
} else if (agent === 'GitHubAgent') {
|
|
2714
|
+
sendToken('[Reading GitHub...] ');
|
|
2715
|
+
try {
|
|
2716
|
+
const gh = await import('../services/github.mjs');
|
|
2717
|
+
const issues = await withTimeout(gh.listIssues(config, config.githubRepo || '', 10), 'GitHubAgent');
|
|
2718
|
+
toolData = typeof issues === 'string' ? issues : JSON.stringify(issues);
|
|
2719
|
+
} catch (e) { toolData = `GitHub read failed: ${e.message}`; }
|
|
2720
|
+
|
|
2721
|
+
} else if (agent === 'NotionAgent') {
|
|
2722
|
+
sendToken('[Searching Notion...] ');
|
|
2723
|
+
try {
|
|
2724
|
+
const nt = await import('../services/notion.mjs');
|
|
2725
|
+
const results = await withTimeout(nt.search(config, stepPrompt, 10), 'NotionAgent');
|
|
2726
|
+
toolData = typeof results === 'string' ? results : JSON.stringify(results);
|
|
2727
|
+
} catch (e) { toolData = `Notion search failed: ${e.message}`; }
|
|
2728
|
+
|
|
2729
|
+
} else if (agent === 'SlackAgent') {
|
|
2730
|
+
sendToken('[Reading Slack...] ');
|
|
2731
|
+
try {
|
|
2732
|
+
const sl = await import('../services/slack.mjs');
|
|
2733
|
+
const channels = await withTimeout(sl.listChannels(config, 10), 'SlackAgent');
|
|
2734
|
+
toolData = typeof channels === 'string' ? channels : JSON.stringify(channels);
|
|
2735
|
+
} catch (e) { toolData = `Slack read failed: ${e.message}`; }
|
|
2736
|
+
|
|
2737
|
+
} else if (agent === 'DriveAgent') {
|
|
2738
|
+
sendToken('[Reading Drive...] ');
|
|
2739
|
+
try {
|
|
2740
|
+
const gd = await import('../services/google-drive.mjs');
|
|
2741
|
+
const files = await withTimeout(gd.listFiles(config, '', 10), 'DriveAgent');
|
|
2742
|
+
toolData = typeof files === 'string' ? files : JSON.stringify(files);
|
|
2743
|
+
} catch (e) { toolData = `Drive read failed: ${e.message}`; }
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// ── Build system prompt with real tool data ───────────────────
|
|
2747
|
+
const isCanvasAgent = agent === 'CanvasAgent';
|
|
2748
|
+
// Synthesis agents: do NOT wrap with TOOL_DEFINITIONS — use focused prompts only
|
|
2749
|
+
const isSynthesisAgent = ['WriterAgent','SummaryAgent','DataAnalystAgent','SecurityAgent','DevOpsAgent'].includes(agent);
|
|
2750
|
+
const isToolOutputAgent = ['CalendarAgent','EmailAgent','GitHubAgent','NotionAgent','SlackAgent'].includes(agent);
|
|
2751
|
+
const isPureAnalysis = isSynthesisAgent || isToolOutputAgent;
|
|
2752
|
+
|
|
2753
|
+
const canvasSystemPrompt = `You are an HTML report generator. Output a single complete HTML document. No preamble, no explanation.
|
|
2754
|
+
RULES:
|
|
2755
|
+
- First character of your response must be < (start of <!DOCTYPE html>)
|
|
2756
|
+
- Do NOT use markdown code blocks, JSON, or any wrapper
|
|
2757
|
+
- Use clean design: white background, Inter/system-ui font, #6366f1 accent color
|
|
2758
|
+
- Structure: gradient header, then card sections with the content
|
|
2759
|
+
- Make it complete and self-contained`;
|
|
2760
|
+
|
|
2761
|
+
let sysPrompt, userMsg;
|
|
2762
|
+
|
|
2763
|
+
if (isCanvasAgent) {
|
|
2764
|
+
sysPrompt = canvasSystemPrompt;
|
|
2765
|
+
userMsg = `Generate a beautiful HTML dashboard report for this content. Start immediately with <!DOCTYPE html>:\n\n${context.slice(0, 8000)}`;
|
|
2766
|
+
} else if (isSynthesisAgent) {
|
|
2767
|
+
// Focused system prompt — no TOOL_DEFINITIONS bloat
|
|
2768
|
+
const today = new Date().toISOString().split('T')[0];
|
|
2769
|
+
const language = config?.language || 'Italian';
|
|
2770
|
+
sysPrompt = `You are ${agent}, a specialist AI agent inside NHA Studio. Today is ${today}. Respond in ${language}.
|
|
2771
|
+
Task: ${stepPrompt}
|
|
2772
|
+
${toolData ? `\n## LIVE DATA:\n${toolData.slice(0, 4000)}\n` : ''}
|
|
2773
|
+
${context ? `\n## CONTEXT FROM PREVIOUS STEPS:\n${context.slice(0, 5000)}\n` : ''}
|
|
2774
|
+
Write your full response in plain prose. Do NOT output JSON, tool calls, or code blocks unless explicitly asked. Use the context and data above — do not ask for more information.`;
|
|
2775
|
+
userMsg = toolData
|
|
2776
|
+
? `Use the live data and context above to complete: ${stepPrompt}`
|
|
2777
|
+
: context
|
|
2778
|
+
? `Based on the context above, complete: ${stepPrompt}`
|
|
2779
|
+
: stepPrompt;
|
|
2780
|
+
} else {
|
|
2781
|
+
const agentInstruction = `You are ${agent}, a specialist AI agent inside NHA Studio.\nYour task: ${stepPrompt}\n` +
|
|
2782
|
+
(toolData ? `\n## DATA FROM TOOLS:\n${toolData.slice(0, 4000)}\n` : '') +
|
|
2783
|
+
(context ? `\n## OUTPUT FROM PREVIOUS AGENTS:\n${context.slice(0, 3000)}\n` : '') +
|
|
2784
|
+
(isToolOutputAgent
|
|
2785
|
+
? '\nWrite your analysis in plain text. Do NOT output JSON, tool calls, or code blocks. Use the context above.'
|
|
2786
|
+
: '\nOutput your result directly. No preamble.');
|
|
2787
|
+
sysPrompt = buildSystemPrompt(agent, agentInstruction, config);
|
|
2788
|
+
userMsg = toolData
|
|
2789
|
+
? `Use the data above to complete: ${stepPrompt}`
|
|
2790
|
+
: context
|
|
2791
|
+
? `Based on the previous output, complete: ${stepPrompt}`
|
|
2792
|
+
: stepPrompt;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// ── Stream LLM response ───────────────────────────────────────
|
|
2796
|
+
let fullOutput = '';
|
|
2797
|
+
sendToken(isCanvasAgent ? 'Generating visual report...' : '');
|
|
2798
|
+
try {
|
|
2799
|
+
await withTimeout(
|
|
2800
|
+
callLLMStream(config, sysPrompt, userMsg,
|
|
2801
|
+
(token) => { fullOutput += token; if (!isCanvasAgent) sendToken(token); },
|
|
2802
|
+
),
|
|
2803
|
+
isCanvasAgent ? 60000 : 35000
|
|
2804
|
+
);
|
|
2805
|
+
} catch (e) {
|
|
2806
|
+
if (!isCanvasAgent) sendToken(`[Error: ${e.message}]`);
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
if (isCanvasAgent) {
|
|
2810
|
+
let html = fullOutput.trim();
|
|
2811
|
+
// Strip thinking tags if not already filtered
|
|
2812
|
+
html = html.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
2813
|
+
// Extract from markdown code block
|
|
2814
|
+
const mdMatch = html.match(/```html?\s*([\s\S]*?)```/i);
|
|
2815
|
+
if (mdMatch) html = mdMatch[1].trim();
|
|
2816
|
+
// Find <!DOCTYPE or <html start if there's preamble text
|
|
2817
|
+
const doctypeIdx = html.indexOf('<!DOCTYPE');
|
|
2818
|
+
const htmlTagIdx = html.indexOf('<html');
|
|
2819
|
+
const startIdx = doctypeIdx >= 0 ? doctypeIdx : (htmlTagIdx >= 0 ? htmlTagIdx : -1);
|
|
2820
|
+
if (startIdx > 0) html = html.slice(startIdx);
|
|
2821
|
+
// Fallback: build clean HTML from the context directly (no LLM needed)
|
|
2822
|
+
if (!html.trim() || !html.includes('<')) {
|
|
2823
|
+
// Try splitting on markdown headings first, then numbered items, then double newlines
|
|
2824
|
+
let sections = context.split(/\n#{1,3} /).filter(s => s.trim());
|
|
2825
|
+
if (sections.length <= 1) sections = context.split(/\n(?=\*\*\d+[\.\)])|(?=^\d+[\.\)])/).filter(s => s.trim());
|
|
2826
|
+
if (sections.length <= 1) sections = context.split(/\n{2,}/).filter(s => s.trim());
|
|
2827
|
+
const reportTitle = (task.slice(0, 80) || 'NHA Studio Report').replace(/</g,'<').replace(/>/g,'>');
|
|
2828
|
+
const cardsHtml = sections.map(s => {
|
|
2829
|
+
const clean = s.replace(/\*\*/g, '').replace(/\*/g, '').trim();
|
|
2830
|
+
const lines = clean.split('\n').filter(Boolean);
|
|
2831
|
+
const titleLine = lines[0] || '';
|
|
2832
|
+
const bodyLines = lines.slice(1).join('\n').trim();
|
|
2833
|
+
return `<div class="card">${titleLine ? `<h2>${titleLine.replace(/</g,'<')}</h2>` : ''}<p>${(bodyLines || titleLine).replace(/\n/g, '</p><p>').replace(/</g,'<')}</p></div>`;
|
|
2834
|
+
}).join('');
|
|
2835
|
+
const safeContext = context.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'</p><p>');
|
|
2836
|
+
html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Report</title><style>
|
|
2837
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
2838
|
+
body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;color:#1e293b;padding:0}
|
|
2839
|
+
.header{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:48px 40px;margin-bottom:32px}
|
|
2840
|
+
.header h1{font-size:1.8em;font-weight:700;margin-bottom:8px}
|
|
2841
|
+
.header p{opacity:.85;font-size:1em}
|
|
2842
|
+
.content{max-width:900px;margin:0 auto;padding:0 32px 48px}
|
|
2843
|
+
.card{background:#fff;border-radius:12px;padding:28px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);border:1px solid #e2e8f0}
|
|
2844
|
+
.card h2{color:#6366f1;font-size:1.05em;font-weight:700;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid #f1f5f9}
|
|
2845
|
+
.card p{color:#475569;line-height:1.75;margin-bottom:8px}
|
|
2846
|
+
.card p:last-child{margin-bottom:0}
|
|
2847
|
+
</style></head><body>
|
|
2848
|
+
<div class="header"><h1>${reportTitle}</h1><p>Report generated by NHA Studio</p></div>
|
|
2849
|
+
<div class="content">${cardsHtml || '<div class="card"><p>' + safeContext + '</p></div>'}</div>
|
|
2850
|
+
</body></html>`;
|
|
2851
|
+
}
|
|
2852
|
+
sendToken('\n\n[Report generato]');
|
|
2853
|
+
sendEvent({ canvas: html });
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// Estimate token usage (aprox: 1 token ≈ 4 chars)
|
|
2857
|
+
const inTokens = Math.ceil((sysPrompt.length + userMsg.length) / 4);
|
|
2858
|
+
const outTokens = Math.ceil(fullOutput.length / 4);
|
|
2859
|
+
clearInterval(keepalive);
|
|
2860
|
+
sendEvent({ usage: { input: inTokens, output: outTokens } });
|
|
2861
|
+
sendEvent({ done: true });
|
|
2862
|
+
res.write('data: [DONE]\n\n');
|
|
2863
|
+
res.end();
|
|
2864
|
+
} catch (e) {
|
|
2865
|
+
clearInterval(keepalive);
|
|
2866
|
+
sendEvent({ error: e.message });
|
|
2867
|
+
res.end();
|
|
2868
|
+
}
|
|
2869
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2502
2873
|
// ── 404 ──────────────────────────────────────────────────────────
|
|
2503
2874
|
sendJSON(res, 404, { error: 'Not found' });
|
|
2504
2875
|
logRequest(method, pathname, 404, Date.now() - start);
|