nothumanallowed 9.1.1 → 9.2.0

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": "9.1.1",
3
+ "version": "9.2.0",
4
4
  "description": "NotHumanAllowed — 38 AI agents + 50 tools + web search. Streaming chat, multi-conversation, export. Gmail, Calendar, Drive, GitHub, Notion, Slack. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
- import { VERSION, NHA_DIR, AGENTS_DIR, EXTENSIONS_DIR, AGENTS, EXTENSIONS, BASE_URL } from './constants.mjs';
5
+ import { VERSION, NHA_DIR, AGENTS_DIR, EXTENSIONS_DIR, AGENTS, EXTENSIONS, BASE_URL, API_BASE } from './constants.mjs';
6
6
  import { needsBootstrap, bootstrap } from './bootstrap.mjs';
7
7
  import { spawnCore } from './spawn.mjs';
8
8
  import { loadConfig, setConfigValue } from './config.mjs';
@@ -42,6 +42,13 @@ export async function main(argv) {
42
42
  }
43
43
  }).catch(() => {});
44
44
 
45
+ // Anonymous usage ping — fire-and-forget, no user data
46
+ fetch(`${API_BASE}/telemetry/ping`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ platform: 'npm-cli', version: VERSION, command: cmd }),
50
+ }).catch(() => {});
51
+
45
52
  // npm version check (non-blocking)
46
53
  checkNpmVersion().then(result => {
47
54
  if (result?.updateAvailable) {
@@ -15,7 +15,7 @@ import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { loadConfig } from '../config.mjs';
17
17
  import { detectMailProvider, hasMailProvider, getProviderStatus } from '../services/mail-router.mjs';
18
- import { callLLM, callAgent, parseAgentFile } from '../services/llm.mjs';
18
+ import { callLLM, callLLMStream, callAgent, parseAgentFile } from '../services/llm.mjs';
19
19
  import { getUnreadImportant, getMessage, listMessages, sendEmail, createDraft } from '../services/mail-router.mjs';
20
20
  import { getTodayEvents, getUpcomingEvents, createEvent, updateEvent, getEventsForDate } from '../services/mail-router.mjs';
21
21
  import {
@@ -28,6 +28,20 @@ import { runPlanningPipeline } from '../services/ops-pipeline.mjs';
28
28
  import { AGENTS, AGENTS_DIR, NHA_DIR, VERSION } from '../constants.mjs';
29
29
  import { getHTML } from '../services/web-ui.mjs';
30
30
  import { loadChatHistory, saveChatHistory, extractMemory, buildMemoryContext } from '../services/memory.mjs';
31
+ import {
32
+ createConversation,
33
+ loadConversation,
34
+ saveConversation,
35
+ deleteConversation,
36
+ listConversations,
37
+ getOrCreateActive,
38
+ setActiveId,
39
+ getHistory,
40
+ addMessages,
41
+ exportAsMarkdown,
42
+ exportAsJson,
43
+ migrateOldHistory,
44
+ } from '../services/conversations.mjs';
31
45
  import { info, ok, fail, warn, C, G, D, NC, BOLD } from '../ui.mjs';
32
46
  import {
33
47
  parseActions,
@@ -78,7 +92,7 @@ function sendJSON(res, statusCode, data) {
78
92
  res.writeHead(statusCode, {
79
93
  'Content-Type': 'application/json',
80
94
  'Access-Control-Allow-Origin': '*',
81
- 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
95
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
82
96
  'Access-Control-Allow-Headers': 'Content-Type',
83
97
  'Cache-Control': 'no-cache',
84
98
  });
@@ -151,6 +165,9 @@ export async function cmdUI(args) {
151
165
  const config = loadConfig();
152
166
  const htmlPage = getHTML(port);
153
167
 
168
+ // Migrate old chat history to multi-conversation format
169
+ migrateOldHistory();
170
+
154
171
  // Pre-load agent cards once at startup
155
172
  const agentCards = loadAgentCards();
156
173
 
@@ -173,7 +190,7 @@ export async function cmdUI(args) {
173
190
  if (method === 'OPTIONS') {
174
191
  res.writeHead(204, {
175
192
  'Access-Control-Allow-Origin': '*',
176
- 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
193
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
177
194
  'Access-Control-Allow-Headers': 'Content-Type',
178
195
  });
179
196
  res.end();
@@ -999,6 +1016,201 @@ export async function cmdUI(args) {
999
1016
  return;
1000
1017
  }
1001
1018
 
1019
+ // ── Conversations API ────────────────────────────────────────────
1020
+
1021
+ // GET /api/conversations — list all
1022
+ if (method === 'GET' && pathname === '/api/conversations') {
1023
+ const convs = listConversations();
1024
+ sendJSON(res, 200, { conversations: convs });
1025
+ logRequest(method, pathname, 200, Date.now() - start);
1026
+ return;
1027
+ }
1028
+
1029
+ // POST /api/conversations — create new
1030
+ if (method === 'POST' && pathname === '/api/conversations') {
1031
+ const conv = createConversation();
1032
+ setActiveId(conv.id);
1033
+ sendJSON(res, 201, { conversation: conv });
1034
+ logRequest(method, pathname, 201, Date.now() - start);
1035
+ return;
1036
+ }
1037
+
1038
+ // GET /api/conversations/:id
1039
+ if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1040
+ const id = pathname.split('/')[3];
1041
+ const conv = loadConversation(id);
1042
+ if (!conv) { sendJSON(res, 404, { error: 'Conversation not found' }); }
1043
+ else { sendJSON(res, 200, { conversation: conv }); }
1044
+ logRequest(method, pathname, conv ? 200 : 404, Date.now() - start);
1045
+ return;
1046
+ }
1047
+
1048
+ // DELETE /api/conversations/:id
1049
+ if (method === 'DELETE' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1050
+ const id = pathname.split('/')[3];
1051
+ const ok = deleteConversation(id);
1052
+ sendJSON(res, ok ? 200 : 404, { ok });
1053
+ logRequest(method, pathname, ok ? 200 : 404, Date.now() - start);
1054
+ return;
1055
+ }
1056
+
1057
+ // PATCH /api/conversations/:id — rename
1058
+ if (method === 'PATCH' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+$/)) {
1059
+ const id = pathname.split('/')[3];
1060
+ const body = await parseBody(req);
1061
+ const conv = loadConversation(id);
1062
+ if (!conv) { sendJSON(res, 404, { error: 'Not found' }); }
1063
+ else {
1064
+ if (body.title) conv.title = body.title;
1065
+ saveConversation(conv);
1066
+ sendJSON(res, 200, { conversation: conv });
1067
+ }
1068
+ logRequest(method, pathname, conv ? 200 : 404, Date.now() - start);
1069
+ return;
1070
+ }
1071
+
1072
+ // GET /api/conversations/:id/export?format=md|json
1073
+ if (method === 'GET' && pathname.match(/^\/api\/conversations\/[a-z0-9-]+\/export$/)) {
1074
+ const id = pathname.split('/')[3];
1075
+ const conv = loadConversation(id);
1076
+ if (!conv) { sendJSON(res, 404, { error: 'Not found' }); logRequest(method, pathname, 404, Date.now() - start); return; }
1077
+ const url = new URL(req.url, `http://${req.headers.host}`);
1078
+ const format = url.searchParams.get('format') || 'md';
1079
+ if (format === 'json') {
1080
+ const exported = exportAsJson(conv);
1081
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="nha-chat-${id}.json"` });
1082
+ res.end(exported);
1083
+ } else {
1084
+ const exported = exportAsMarkdown(conv);
1085
+ res.writeHead(200, { 'Content-Type': 'text/markdown', 'Content-Disposition': `attachment; filename="nha-chat-${id}.md"` });
1086
+ res.end(exported);
1087
+ }
1088
+ logRequest(method, pathname, 200, Date.now() - start);
1089
+ return;
1090
+ }
1091
+
1092
+ // ── Streaming Chat API ─────────────────────────────────────────────
1093
+
1094
+ // POST /api/chat/stream — SSE streaming chat with conversation persistence
1095
+ if (method === 'POST' && pathname === '/api/chat/stream') {
1096
+ const body = await parseBody(req);
1097
+ if (!body.message) { sendJSON(res, 400, { error: 'message required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
1098
+ if (!config.llm.apiKey) { sendJSON(res, 200, { error: 'no_api_key' }); logRequest(method, pathname, 200, Date.now() - start); return; }
1099
+
1100
+ const msg = body.message.trim();
1101
+ const convId = body.conversationId;
1102
+
1103
+ // Build system prompt
1104
+ let effectiveSystemPrompt = config._chatAgent?.systemPrompt || null;
1105
+ let effectiveMsg = msg;
1106
+ const atMatch = msg.match(/^@(\w+)\s+([\s\S]*)/);
1107
+ if (atMatch) {
1108
+ const inlineAgent = atMatch[1].toLowerCase();
1109
+ effectiveMsg = atMatch[2];
1110
+ const agentFile = path.join(AGENTS_DIR, `${inlineAgent}.mjs`);
1111
+ if (fs.existsSync(agentFile)) {
1112
+ const src = fs.readFileSync(agentFile, 'utf-8');
1113
+ const parsed = parseAgentFile(src, inlineAgent);
1114
+ if (parsed.systemPrompt) effectiveSystemPrompt = parsed.systemPrompt;
1115
+ }
1116
+ }
1117
+
1118
+ const basePrompt = effectiveSystemPrompt || chatSystemPrompt;
1119
+ let enrichedPrompt = basePrompt;
1120
+ try { const m = buildMemoryContext('chat', effectiveMsg); if (m) enrichedPrompt = basePrompt + m; } catch {}
1121
+
1122
+ // Build message with conversation history
1123
+ const history = body.history || [];
1124
+ const parts = [];
1125
+ for (const turn of history) {
1126
+ const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
1127
+ parts.push(`${prefix} ${turn.content}`);
1128
+ }
1129
+ parts.push(`[User] ${effectiveMsg}`);
1130
+ const userMessage = parts.join('\n\n');
1131
+
1132
+ // Handle file/image/pdf attachments — fall back to non-streaming
1133
+ if (body.imageBase64 || body.pdfBase64 || body.fileContent) {
1134
+ // Redirect to regular /api/chat for attachment handling
1135
+ sendJSON(res, 200, { error: 'attachments_use_regular', redirect: '/api/chat' });
1136
+ logRequest(method, pathname, 200, Date.now() - start);
1137
+ return;
1138
+ }
1139
+
1140
+ // SSE headers
1141
+ res.writeHead(200, {
1142
+ 'Content-Type': 'text/event-stream',
1143
+ 'Cache-Control': 'no-cache',
1144
+ 'Connection': 'keep-alive',
1145
+ 'Access-Control-Allow-Origin': '*',
1146
+ });
1147
+
1148
+ const sendSSE = (event, data) => {
1149
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1150
+ };
1151
+
1152
+ sendSSE('processing', {});
1153
+
1154
+ try {
1155
+ let fullResponse = '';
1156
+ fullResponse = await callLLMStream(config, enrichedPrompt, userMessage, (chunk) => {
1157
+ sendSSE('token', { content: chunk });
1158
+ });
1159
+
1160
+ // Parse and execute tools
1161
+ const { textParts, actions } = parseActions(fullResponse);
1162
+ const toolResults = [];
1163
+
1164
+ for (const { action, params } of actions) {
1165
+ sendSSE('tool', { action, status: 'executing' });
1166
+ try {
1167
+ const result = await executeTool(action, params, config);
1168
+ toolResults.push({ action, result: typeof result === 'object' ? JSON.stringify(result) : String(result) });
1169
+ sendSSE('tool', { action, status: 'done', result: typeof result === 'string' ? result.slice(0, 500) : '' });
1170
+ } catch (e) {
1171
+ toolResults.push({ action, result: `Error: ${e.message}` });
1172
+ sendSSE('tool', { action, status: 'error', error: e.message });
1173
+ }
1174
+ }
1175
+
1176
+ // If tools were executed, make a second LLM call with results
1177
+ let finalResponse = fullResponse;
1178
+ if (toolResults.length > 0) {
1179
+ const toolContext = toolResults.map(t => `[${t.action} result]: ${t.result}`).join('\n\n');
1180
+ const followUp = `The user asked: "${msg}"\n\nI executed these tools and got REAL results:\n\n${toolContext}\n\nNow respond to the user based ONLY on the REAL data above. Present the actual results clearly.`;
1181
+ sendSSE('tool_synthesis', {});
1182
+ try {
1183
+ finalResponse = await callLLMStream(config, enrichedPrompt, followUp, (chunk) => {
1184
+ sendSSE('token', { content: chunk });
1185
+ });
1186
+ } catch {
1187
+ finalResponse = toolResults.map(t => `${t.action}: ${t.result}`).join('\n\n');
1188
+ }
1189
+ }
1190
+
1191
+ // Persist to conversation
1192
+ if (convId) {
1193
+ try {
1194
+ const conv = loadConversation(convId);
1195
+ if (conv) {
1196
+ addMessages(conv, msg, finalResponse);
1197
+ }
1198
+ } catch {}
1199
+ }
1200
+
1201
+ // Extract memory
1202
+ try { extractMemory('chat', msg, finalResponse); } catch {}
1203
+
1204
+ sendSSE('done', { content: finalResponse, toolResults });
1205
+ } catch (e) {
1206
+ sendSSE('error', { message: e.message });
1207
+ }
1208
+
1209
+ res.end();
1210
+ logRequest(method, pathname, 200, Date.now() - start);
1211
+ return;
1212
+ }
1213
+
1002
1214
  // GET /api/agents
1003
1215
  if (method === 'GET' && pathname === '/api/agents') {
1004
1216
  sendJSON(res, 200, { agents: agentCards });
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 = '9.1.1';
8
+ export const VERSION = '9.2.0';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -198,11 +198,17 @@ input:focus,textarea:focus{border-color:var(--green3)}
198
198
  const JS = `
199
199
  var API = '';
200
200
  var currentView = 'dashboard';
201
- var chatHistory = (function(){try{var s=localStorage.getItem('nha_chat_history');return s?JSON.parse(s):[];}catch(e){return [];}})();
201
+ var chatHistory = [];
202
+ var activeConvId = null;
203
+ var convList = [];
202
204
  var dash = {emails:[],events:[],tasks:[],plan:null,status:null};
205
+ var chatStreaming = false;
203
206
 
204
- function saveChatToStorage(){try{localStorage.setItem('nha_chat_history',JSON.stringify(chatHistory.slice(-40)));}catch(e){}}
205
- function clearChatHistory(){chatHistory=[];saveChatToStorage();renderMessages();}
207
+ function loadConvList(){return apiGet('/api/conversations').then(function(r){convList=(r&&r.conversations)||[];renderConvSidebar();})}
208
+ function loadConv(id){return apiGet('/api/conversations/'+id).then(function(r){if(r&&r.conversation){activeConvId=r.conversation.id;chatHistory=r.conversation.messages||[];renderMessages();renderConvSidebar();}})}
209
+ function createNewConv(){return apiPost('/api/conversations',{}).then(function(r){if(r&&r.conversation){activeConvId=r.conversation.id;chatHistory=[];renderMessages();loadConvList();}})}
210
+ function deleteConv(id){return fetch(API+'/api/conversations/'+id,{method:'DELETE'}).then(function(){loadConvList();if(id===activeConvId)createNewConv();})}
211
+ function clearChatHistory(){createNewConv()}
206
212
  var agentsList = [];
207
213
  var selectedAgent = null;
208
214
 
@@ -316,18 +322,55 @@ function renderDash(el){
316
322
  var chatReady=false;
317
323
  function renderChat(el){
318
324
  if(!chatReady||!document.getElementById('chatMessages')){
319
- el.innerHTML='<div class="chat"><div class="chat__messages" id="chatMessages"></div><div id="chatAttachInfo" style="display:none;padding:4px 12px;font-size:11px;color:var(--cyan);background:var(--bg2);border-top:1px solid var(--border)"><span id="chatAttachName"></span> <button onclick="clearChatAttach()" style="background:none;border:none;color:#f44;cursor:pointer;font-size:14px;font-weight:700">&times;</button></div><div class="chat__bar"><button class="chat__mic" id="chatMic" onclick="toggleVoiceInput()" title="Voice input">&#127908;</button><button onclick="document.getElementById(\\x27chatFileInput\\x27).click()" style="background:none;border:none;cursor:pointer;font-size:16px;padding:4px" title="Attach file">&#128206;</button><button onclick="document.getElementById(\\x27chatImageInput\\x27).click()" style="background:none;border:none;cursor:pointer;font-size:16px;padding:4px" title="Attach image">&#128247;</button><input type="file" id="chatFileInput" style="display:none" onchange="handleChatFile(this)"><input type="file" id="chatImageInput" accept="image/*" style="display:none" onchange="handleChatImage(this)"><textarea class="chat__input" id="chatInput" placeholder="Ask anything... (or attach file/image first)" rows="1"></textarea><button class="chat__send" id="chatSend">Send</button><button onclick="clearChatHistory()" style="background:none;color:var(--dim);font-size:10px;padding:4px 8px" title="Clear chat history">Clear</button></div></div>';
325
+ el.innerHTML='<div style="display:flex;height:calc(100vh - 56px)">'+
326
+ '<div id="convSidebar" style="width:220px;border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0;background:var(--bg);display:none">'+
327
+ '<div style="padding:8px"><button onclick="createNewConv()" style="width:100%;padding:8px;border-radius:var(--r);border:1px solid var(--green);background:transparent;color:var(--green);cursor:pointer;font-size:11px">+ New Chat</button></div>'+
328
+ '<div id="convList"></div>'+
329
+ '</div>'+
330
+ '<div style="flex:1;display:flex;flex-direction:column;min-width:0">'+
331
+ '<div style="padding:6px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px">'+
332
+ '<button onclick="toggleConvSidebar()" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--dim)" title="History">&#9776;</button>'+
333
+ '<span id="convTitle" style="flex:1;font-size:12px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">New Chat</span>'+
334
+ '<button onclick="createNewConv()" style="background:none;border:1px solid var(--green);color:var(--green);padding:4px 10px;border-radius:var(--r);cursor:pointer;font-size:10px">+ New</button>'+
335
+ '<button onclick="exportConvMd()" style="background:none;border:1px solid var(--border);color:var(--dim);padding:4px 8px;border-radius:var(--r);cursor:pointer;font-size:10px" title="Export Markdown">Export</button>'+
336
+ '</div>'+
337
+ '<div class="chat"><div class="chat__messages" id="chatMessages"></div>'+
338
+ '<div id="chatAttachInfo" style="display:none;padding:4px 12px;font-size:11px;color:var(--cyan);background:var(--bg2);border-top:1px solid var(--border)"><span id="chatAttachName"></span> <button onclick="clearChatAttach()" style="background:none;border:none;color:#f44;cursor:pointer;font-size:14px;font-weight:700">&times;</button></div>'+
339
+ '<div class="chat__bar"><button class="chat__mic" id="chatMic" onclick="toggleVoiceInput()" title="Voice input">&#127908;</button><button onclick="document.getElementById(\\x27chatFileInput\\x27).click()" style="background:none;border:none;cursor:pointer;font-size:16px;padding:4px" title="Attach file">&#128206;</button><button onclick="document.getElementById(\\x27chatImageInput\\x27).click()" style="background:none;border:none;cursor:pointer;font-size:16px;padding:4px" title="Attach image">&#128247;</button><input type="file" id="chatFileInput" style="display:none" onchange="handleChatFile(this)"><input type="file" id="chatImageInput" accept="image/*" style="display:none" onchange="handleChatImage(this)"><textarea class="chat__input" id="chatInput" placeholder="Ask anything... (or attach file/image first)" rows="1"></textarea><button class="chat__send" id="chatSend">Send</button></div>'+
340
+ '</div>'+
341
+ '</div>'+
342
+ '</div>';
320
343
  chatReady=true;
321
344
  document.getElementById('chatSend').onclick=sendChat;
322
345
  document.getElementById('chatInput').onkeydown=function(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat()}};
323
- renderMessages();
346
+ loadConvList().then(function(){
347
+ if(!activeConvId&&convList.length>0){loadConv(convList[0].id)}
348
+ else if(!activeConvId){createNewConv()}
349
+ else{loadConv(activeConvId)}
350
+ });
324
351
  setTimeout(function(){var i=document.getElementById('chatInput');if(i)i.focus()},100);
325
352
  }
326
353
  }
354
+ function toggleConvSidebar(){var s=document.getElementById('convSidebar');if(!s)return;s.style.display=s.style.display==='none'?'':'none';}
355
+ function renderConvSidebar(){
356
+ var el=document.getElementById('convList');if(!el)return;
357
+ var h='';convList.forEach(function(c){
358
+ var active=c.id===activeConvId;
359
+ var turns=Math.floor(c.messageCount/2);
360
+ h+='<div onclick="loadConv(\\x27'+c.id+'\\x27)" style="padding:8px 12px;cursor:pointer;border-left:3px solid '+(active?'var(--green)':'transparent')+';background:'+(active?'var(--bg2)':'transparent')+'" onmouseover="this.style.background=\\x27var(--bg2)\\x27" onmouseout="this.style.background='+(active?"\\x27var(--bg2)\\x27":"\\x27transparent\\x27")+'">'+
361
+ '<div style="font-size:11px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(c.title)+'</div>'+
362
+ '<div style="font-size:9px;color:var(--dim);display:flex;gap:6px;margin-top:2px"><span>'+turns+' turns</span>'+(active?'':'<span onclick="event.stopPropagation();deleteConv(\\x27'+c.id+'\\x27)" style="color:var(--red);cursor:pointer">del</span>')+'</div>'+
363
+ '</div>';
364
+ });
365
+ el.innerHTML=h;
366
+ var t=document.getElementById('convTitle');
367
+ if(t){var ac=convList.find(function(c){return c.id===activeConvId});t.textContent=ac?ac.title:'New Chat';}
368
+ }
369
+ function exportConvMd(){if(!activeConvId)return;window.open(API+'/api/conversations/'+activeConvId+'/export?format=md','_blank');}
327
370
  function renderMessages(){
328
371
  var el=document.getElementById('chatMessages');if(!el)return;
329
372
  if(chatHistory.length===0){
330
- el.innerHTML='<div class="chat__empty"><div class="chat__empty-title">NHA Chat</div><div>Personal Operations Assistant</div><div class="chat__empty-hint">Try: Show my unread emails / What is on my calendar? / Add a task</div></div>';
373
+ el.innerHTML='<div class="chat__empty"><div class="chat__empty-title">NHA Chat</div><div>Personal Operations Assistant — Streaming + Web Search</div><div class="chat__empty-hint">Try: Show my unread emails / Search the web for React 19 / What is on my calendar?</div></div>';
331
374
  return;
332
375
  }
333
376
  var h='';chatHistory.forEach(function(m){
@@ -389,37 +432,78 @@ function sendChat(){
389
432
  var msg=inp.value.trim();
390
433
  var hasAttach=!!chatAttachedFile||!!chatAttachedImage;
391
434
  if(!msg&&!hasAttach)return;
435
+ if(chatStreaming)return;
392
436
 
393
437
  var displayMsg=msg;
394
438
  if(chatAttachedFile)displayMsg=(msg?msg+' ':'')+'[File: '+chatAttachedFile.name+']';
395
439
  if(chatAttachedImage)displayMsg=(msg?msg+' ':'')+'[Image: '+chatAttachedImage.name+']';
396
440
 
397
441
  chatHistory.push({role:'user',content:displayMsg});
398
- inp.value='';saveChatToStorage();renderMessages();
399
- chatHistory.push({role:'assistant',content:'Thinking...'});renderMessages();
400
-
401
- var payload={message:msg||'Analyze this attachment',history:chatHistory.slice(0,-1)};
402
- if(chatAttachedFile){
403
- if(chatAttachedFile.isPDF&&chatAttachedFile.base64){
404
- payload.pdfBase64=chatAttachedFile.base64;payload.pdfName=chatAttachedFile.name;
405
- }else{
406
- payload.fileContent=chatAttachedFile.content;payload.fileName=chatAttachedFile.name;
442
+ inp.value='';renderMessages();
443
+
444
+ // If attachment, use regular (non-streaming) endpoint
445
+ if(chatAttachedFile||chatAttachedImage){
446
+ chatHistory.push({role:'assistant',content:'Thinking...'});renderMessages();
447
+ var payload={message:msg||'Analyze this attachment',history:chatHistory.slice(0,-1)};
448
+ if(chatAttachedFile){
449
+ if(chatAttachedFile.isPDF&&chatAttachedFile.base64){payload.pdfBase64=chatAttachedFile.base64;payload.pdfName=chatAttachedFile.name;}
450
+ else{payload.fileContent=chatAttachedFile.content;payload.fileName=chatAttachedFile.name;}
407
451
  }
452
+ if(chatAttachedImage){payload.imageBase64=chatAttachedImage.base64;payload.imageMimeType=chatAttachedImage.mimeType;}
453
+ clearChatAttach();
454
+ apiPost('/api/chat',payload).then(function(r){
455
+ chatHistory.pop();
456
+ if(r&&r.response){chatHistory.push({role:'assistant',content:r.response})}
457
+ else if(r&&r.error){chatHistory.push({role:'assistant',content:'Error: '+r.error})}
458
+ else{chatHistory.push({role:'assistant',content:'Error: no response'})}
459
+ renderMessages();loadConvList();
460
+ });
461
+ return;
408
462
  }
409
- if(chatAttachedImage){payload.imageBase64=chatAttachedImage.base64;payload.imageMimeType=chatAttachedImage.mimeType;payload.imageName=chatAttachedImage.name;}
410
463
  clearChatAttach();
411
464
 
412
- apiPost('/api/chat',payload).then(function(r){
413
- chatHistory.pop();
414
- if(r&&r.response){chatHistory.push({role:'assistant',content:r.response})}
415
- else if(r&&r.error){chatHistory.push({role:'assistant',content:'Error: '+r.error})}
416
- else{chatHistory.push({role:'assistant',content:'Error: no response from server'})}
417
- saveChatToStorage();renderMessages();
418
- if(r&&((r.actions&&r.actions.length>0)||(r.toolResults&&r.toolResults.length>0))){
419
- calEventsCache={};contactsData=null;notesData=null;driveData=null;onedriveData=null;mstodoData=null;
420
- loadDash().then(function(){render()}).catch(function(){});
465
+ // Streaming SSE
466
+ chatStreaming=true;
467
+ chatHistory.push({role:'assistant',content:''});
468
+ renderMessages();
469
+ var streamIdx=chatHistory.length-1;
470
+ var payload={message:msg,history:chatHistory.slice(0,-1),conversationId:activeConvId};
471
+
472
+ fetch(API+'/api/chat/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(function(response){
473
+ if(!response.ok||!response.body){chatHistory[streamIdx].content='Error: connection failed';chatStreaming=false;renderMessages();return;}
474
+ var reader=response.body.getReader();var decoder=new TextDecoder();var buffer='';var currentEvent='';
475
+ function pump(){
476
+ reader.read().then(function(result){
477
+ if(result.done){chatStreaming=false;renderMessages();loadConvList();return;}
478
+ buffer+=decoder.decode(result.value,{stream:true});
479
+ var lines=buffer.split('\\n');buffer=lines.pop()||'';
480
+ for(var i=0;i<lines.length;i++){
481
+ var line=lines[i];
482
+ if(line.startsWith('event: ')){currentEvent=line.slice(7).trim();}
483
+ else if(line.startsWith('data: ')){
484
+ try{
485
+ var data=JSON.parse(line.slice(6));
486
+ if(currentEvent==='token'&&data.content){
487
+ chatHistory[streamIdx].content+=data.content;
488
+ var el=document.getElementById('chatMessages');
489
+ if(el){var msgs=el.querySelectorAll('.msg');var last=msgs[msgs.length-1];if(last){var bub=last.querySelector('.msg__bubble');if(bub)bub.textContent=chatHistory[streamIdx].content;}el.scrollTop=el.scrollHeight;}
490
+ }
491
+ if(currentEvent==='tool'){
492
+ var indicator=data.status==='executing'?'['+data.action+' executing...]':'['+data.action+': '+data.status+']';
493
+ chatHistory[streamIdx].content+=indicator+'\\n';
494
+ renderMessages();
495
+ }
496
+ if(currentEvent==='tool_synthesis'){chatHistory[streamIdx].content='';renderMessages();}
497
+ if(currentEvent==='done'){chatStreaming=false;if(data.content)chatHistory[streamIdx].content=data.content;renderMessages();loadConvList();}
498
+ if(currentEvent==='error'){chatStreaming=false;chatHistory[streamIdx].content='Error: '+(data.message||'Unknown');renderMessages();}
499
+ }catch(e){}
500
+ }
501
+ }
502
+ pump();
503
+ }).catch(function(e){chatStreaming=false;chatHistory[streamIdx].content='Error: '+e.message;renderMessages();});
421
504
  }
422
- });
505
+ pump();
506
+ }).catch(function(e){chatStreaming=false;chatHistory[streamIdx].content='Error: '+e.message;renderMessages();});
423
507
  }
424
508
 
425
509
  // ---- TASKS ----