umer-wasim commited on
Commit
59cb23a
·
verified ·
1 Parent(s): 8eec847

Chat: show the detected device and tell the AI about it; download progress inside the message box

Browse files
Files changed (5) hide show
  1. app/renderer/knowledge.js +3 -2
  2. chat/chat.css +46 -1
  3. chat/chat.js +94 -8
  4. chat/device.js +48 -0
  5. index.html +14 -1
app/renderer/knowledge.js CHANGED
@@ -105,9 +105,10 @@ export function auditBrief(evidence, maxGaps = 8) {
105
  }
106
 
107
  // Messages for one chat turn. history: earlier [{role, content}] turns (already answered).
108
- export function chatMessages(question, passages, audit = 'none', history = []) {
 
109
  const context = passages.length ? passages.map((p, n) => `[${n + 1}] ${p.title}: ${p.text}`).join('\n') : '(no matching passages)';
110
- const user = `CONTEXT:\n${context}\n\nMY AUDIT: ${audit}\n\nQUESTION: ${question}`;
111
  return [{ role: 'system', content: CHAT_SYSTEM_PROMPT }, ...history.slice(-4), { role: 'user', content: user }];
112
  }
113
 
 
105
  }
106
 
107
  // Messages for one chat turn. history: earlier [{role, content}] turns (already answered).
108
+ // device: what the browser saw about the device the chat runs on (chat/device.js deviceBrief), or null.
109
+ export function chatMessages(question, passages, audit = 'none', history = [], device = null) {
110
  const context = passages.length ? passages.map((p, n) => `[${n + 1}] ${p.title}: ${p.text}`).join('\n') : '(no matching passages)';
111
+ const user = `CONTEXT:\n${context}\n\nMY AUDIT: ${audit}\n\n${device ? `MY DEVICE: ${device}\n\n` : ''}QUESTION: ${question}`;
112
  return [{ role: 'system', content: CHAT_SYSTEM_PROMPT }, ...history.slice(-4), { role: 'user', content: user }];
113
  }
114
 
chat/chat.css CHANGED
@@ -67,7 +67,7 @@ main { flex: 1; overflow-y: auto; padding: 18px 14px 8px; }
67
  .primary-btn { border: 0; background: var(--accent); color: #fff; border-radius: 10px; padding: 8px 14px; font-size: 14px; font-weight: 700; }
68
 
69
  #composer { display: flex; align-items: flex-end; gap: 8px; max-width: 812px; width: 100%; margin: 0 auto; padding: 8px 14px 4px; flex-shrink: 0; }
70
- .input-pill { flex: 1; display: flex; background: var(--input-bg); border: 1px solid var(--border); border-radius: 22px; padding: 4px 6px 4px 16px; }
71
  .input-pill:focus-within { box-shadow: 0 0 0 2px var(--accent-border); }
72
  #input { flex: 1; border: 0; outline: 0; resize: none; background: transparent; color: var(--text); font: inherit; max-height: 180px; padding: 9px 0; }
73
  #input:disabled { cursor: not-allowed; }
@@ -107,3 +107,48 @@ main { flex: 1; overflow-y: auto; padding: 18px 14px 8px; }
107
  .ai-card .btn-row { margin-top: 10px; }
108
  .quick-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
109
  @media (max-width: 560px) { .ai-chip { padding: 6px 8px; font-size: 12px; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  .primary-btn { border: 0; background: var(--accent); color: #fff; border-radius: 10px; padding: 8px 14px; font-size: 14px; font-weight: 700; }
68
 
69
  #composer { display: flex; align-items: flex-end; gap: 8px; max-width: 812px; width: 100%; margin: 0 auto; padding: 8px 14px 4px; flex-shrink: 0; }
70
+ .input-pill { position: relative; overflow: hidden; flex: 1; display: flex; background: var(--input-bg); border: 1px solid var(--border); border-radius: 22px; padding: 4px 6px 4px 16px; }
71
  .input-pill:focus-within { box-shadow: 0 0 0 2px var(--accent-border); }
72
  #input { flex: 1; border: 0; outline: 0; resize: none; background: transparent; color: var(--text); font: inherit; max-height: 180px; padding: 9px 0; }
73
  #input:disabled { cursor: not-allowed; }
 
107
  .ai-card .btn-row { margin-top: 10px; }
108
  .quick-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
109
  @media (max-width: 560px) { .ai-chip { padding: 6px 8px; font-size: 12px; } }
110
+
111
+ /* While the AI assistant downloads, the locked message box is the progress bar. */
112
+ .input-pill.loading { background: linear-gradient(90deg, var(--accent-soft) var(--progress, 0%), var(--input-bg) var(--progress, 0%)); border-color: var(--accent-border); }
113
+ .input-pill.loading::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: var(--progress, 0%); background: var(--accent); transition: width .3s; }
114
+ .input-pill.loading #input::placeholder { color: var(--accent); opacity: 1; font-weight: 600; }
115
+
116
+ /* This device: a pill above the message box; details open as a popover (computers) or a bottom sheet (phones). */
117
+ .context-wrap { position: relative; max-width: 812px; width: 100%; margin: 0 auto; padding: 0 14px; flex-shrink: 0; }
118
+ .context-bar { display: flex; align-items: center; gap: 10px; padding-top: 6px; min-width: 0; }
119
+ .device-pill { display: inline-flex; align-items: center; gap: 7px; min-width: 0; max-width: 100%; border: 1px solid var(--border); background: var(--panel);
120
+ color: var(--text); border-radius: 999px; padding: 5px 12px 5px 9px; font-size: 13px; }
121
+ .device-pill:hover { border-color: var(--accent-border); }
122
+ .device-pill[data-kind="chosen"] { border-color: var(--blue); }
123
+ .device-pill[data-kind="audit"] { border-color: var(--ok); }
124
+ .device-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
125
+ .device-ic { flex: none; } .chev { flex: none; color: var(--muted); font-size: 11px; }
126
+ .context-note { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
127
+ .device-panel { position: absolute; left: 14px; bottom: calc(100% + 8px); width: min(400px, calc(100% - 28px)); background: var(--panel); border: 1px solid var(--border);
128
+ border-radius: 16px; padding: 6px 16px 16px; box-shadow: 0 12px 32px rgba(0, 0, 0, .18); z-index: 15; max-height: 70dvh; overflow-y: auto; }
129
+ .device-head { display: flex; justify-content: space-between; align-items: center; }
130
+ .device-head h2 { font-size: 16px; margin: 0; }
131
+ .device-panel dl { margin: 4px 0 10px; }
132
+ .drow { display: grid; grid-template-columns: 112px 1fr; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
133
+ .drow:last-child { border-bottom: 0; }
134
+ .drow dt { color: var(--muted); } .drow dd { margin: 0; overflow-wrap: anywhere; }
135
+ .drow small { display: block; color: var(--muted); font-size: 12px; }
136
+ .device-note { background: var(--bg); border-radius: 10px; padding: 9px 11px; font-size: 13px; color: var(--muted); margin: 0 0 12px; }
137
+ .device-audit { width: 100%; padding: 10px 14px; }
138
+ .device-switch { margin-top: 14px; }
139
+ .seg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
140
+ .seg button { border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: 999px; padding: 5px 10px; font-size: 13px; }
141
+ .seg button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); font-weight: 700; }
142
+ .sheet-backdrop { position: fixed; inset: 0; z-index: 14; background: transparent; }
143
+ @media (max-width: 560px) {
144
+ .context-note { display: none; }
145
+ .device-pill { width: 100%; justify-content: flex-start; }
146
+ .device-pill .chev { margin-left: auto; }
147
+ .sheet-backdrop { background: rgba(0, 0, 0, .4); }
148
+ .device-panel { position: fixed; left: 0; right: 0; bottom: 0; width: 100%; border-radius: 18px 18px 0 0; max-height: 85dvh;
149
+ padding-bottom: calc(16px + env(safe-area-inset-bottom)); animation: sheet-up .2s ease-out; }
150
+ .device-panel::before { content: ""; display: block; width: 40px; height: 4px; border-radius: 99px; background: var(--border); margin: 4px auto 6px; }
151
+ .drow { grid-template-columns: 104px 1fr; }
152
+ }
153
+ @keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }
154
+ @media (prefers-reduced-motion: reduce) { .device-panel { animation: none; } .input-pill.loading::after { transition: none; } }
chat/chat.js CHANGED
@@ -5,6 +5,7 @@ import { Knowledge, chatMessages, auditBrief, chatProblems, searchWithAudit } fr
5
  import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer, ANSWER_SCHEMAS } from '../app/renderer/auditor-core.js';
6
  import { buildReportHtml } from '../app/renderer/report.js';
7
  import { detectDevice } from '../mobile/detect.js';
 
8
 
9
  const WEBLLM_URL = 'https://esm.run/@mlc-ai/web-llm@0.2.85';
10
  const STORE = 'devseis.chats.v1';
@@ -57,8 +58,10 @@ function getEngine() {
57
  const initProgressCallback = r => {
58
  state.progress = r.progress || 0;
59
  const mb = /([\d.]+)MB fetched/.exec(r.text || '');
60
- status(state.cachedStart ? `Starting the AI assistant… ${Math.round(state.progress * 100)}%`
61
- : `Downloading the AI assistant (one time, about 1 GB): ${Math.round(state.progress * 100)}%${mb ? ` · ${Math.round(+mb[1])} MB` : ''} · the chat opens when it is ready`, state.progress);
 
 
62
  aiChip();
63
  };
64
  let engine, label;
@@ -88,11 +91,12 @@ function aiChip() {
88
  else if (state.engine === false) { chip.textContent = '📚 Knowledge base'; chip.title = state.modelLabel; chip.dataset.state = 'off'; }
89
  else { chip.textContent = '⚡ Enable AI'; chip.title = 'Download the AI assistant (about 1 GB, one time)'; chip.dataset.state = 'off'; }
90
  updateSend();
 
91
  }
92
 
93
  // With WebGPU the chat waits for the AI; the knowledge base answers alone only without WebGPU, if the AI fails to load,
94
  // or when the visitor chose "Not now".
95
- function aiPending() { return !state.kbOnly && state.engine !== false && !state.engine; }
96
 
97
  // Instant answer straight from the knowledge base, formatted for reading.
98
  function quickAnswer(passages) {
@@ -146,6 +150,7 @@ function render() {
146
  const chat = state.current;
147
  $('emptyState').classList.toggle('hidden', Boolean(chat && (chat.messages.length || chat.audit)));
148
  $('aiCard').classList.toggle('hidden', !state.showAiCard || state.loading || Boolean(state.engine) || state.engine === false);
 
149
  const thread = $('thread');
150
  thread.innerHTML = '';
151
  if (!chat) return renderSidebar();
@@ -191,10 +196,11 @@ async function ask(question) {
191
  chat.messages.push(reply);
192
  state.busy = true; updateSend(); render();
193
  try {
194
- const os = chat.audit ? chat.audit.host.os_family : (state.device && (state.device.os_family || (state.device.desktop ? desktopOs() : null)));
 
195
  const passages = searchWithAudit(state.kb, question, chat.audit, { k: 4, os });
196
  reply.passages = passages;
197
- const messages = chatMessages(question, passages, auditBrief(chat.audit), history);
198
  reply.question = question; reply.history = history;
199
  if (state.engine) {
200
  // Typing dots until the AI's first words arrive; the knowledge-base answer is only the fallback if the AI fails.
@@ -249,7 +255,7 @@ async function rewriteWithAi(index) {
249
  const chat = state.current; const reply = chat && chat.messages[index];
250
  if (!reply || !state.engine || state.busy) return;
251
  state.busy = true; updateSend();
252
- const messages = chatMessages(reply.question, reply.passages, auditBrief(chat.audit), reply.history || []);
253
  try { await aiAnswer(chat, reply, messages, reply.passages); } catch (error) { reply.note = `The AI could not answer: ${error.message}`; }
254
  state.busy = false; saveChats(); updateSend(); render();
255
  }
@@ -346,12 +352,91 @@ async function writeReport() {
346
  function updateSend() {
347
  const wait = aiPending();
348
  $('input').disabled = wait;
 
 
 
349
  $('input').placeholder = !wait ? 'Ask Arx about security, or add your audit report…'
350
- : state.loading ? `Arx can chat once the AI assistant is ready (${Math.round((state.progress || 0) * 100)}%)…`
351
  : state.showAiCard ? 'Choose "Download now" or "Not now" above to start chatting…' : 'Tap ⚡ Enable AI at the top to start chatting…';
352
  document.querySelectorAll('.chip[data-prompt]').forEach(c => { c.disabled = wait; });
353
  $('sendBtn').disabled = wait || state.busy || !$('input').value.trim();
354
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  function closeSidebar() { $('sidebar').classList.remove('open'); $('overlay').classList.add('hidden'); }
356
 
357
  $('composer').addEventListener('submit', e => { e.preventDefault(); const q = $('input').value.trim(); $('input').value = ''; $('input').style.height = ''; updateSend(); ask(q); });
@@ -405,7 +490,8 @@ async function init() {
405
  detectDevice().catch(() => ({})),
406
  getJson('app/model-version.json', { cache: 'no-cache' }).catch(() => ({})),
407
  ]);
408
- state.kb = new Knowledge(kb); state.device = device; state.config = config;
 
409
  // Coming back from the phone check with a fresh report.
410
  if (location.hash === '#audit') {
411
  try { const ev = JSON.parse(localStorage.getItem('devseis.lastAudit')); if (ev) { attachAudit(ev); localStorage.removeItem('devseis.lastAudit'); } } catch { /* ignore */ }
 
5
  import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer, ANSWER_SCHEMAS } from '../app/renderer/auditor-core.js';
6
  import { buildReportHtml } from '../app/renderer/report.js';
7
  import { detectDevice } from '../mobile/detect.js';
8
+ import { describeComputer, chosenDevice, deviceBrief, deviceIcon, osLabel, OS_CHOICES } from './device.js';
9
 
10
  const WEBLLM_URL = 'https://esm.run/@mlc-ai/web-llm@0.2.85';
11
  const STORE = 'devseis.chats.v1';
 
58
  const initProgressCallback = r => {
59
  state.progress = r.progress || 0;
60
  const mb = /([\d.]+)MB fetched/.exec(r.text || '');
61
+ // Shown inside the (locked) message box, which fills up as the model arrives.
62
+ const pct = `${Math.round(state.progress * 100)}%`, narrow = matchMedia('(max-width: 560px)').matches;
63
+ state.progressText = state.cachedStart ? `Starting the AI… ${pct}` : narrow ? `Downloading AI… ${pct}${mb ? ` · ${Math.round(+mb[1])} MB` : ''}`
64
+ : `Downloading the AI assistant… ${pct}${mb ? ` · ${Math.round(+mb[1])} of about 1,000 MB` : ''}`;
65
  aiChip();
66
  };
67
  let engine, label;
 
91
  else if (state.engine === false) { chip.textContent = '📚 Knowledge base'; chip.title = state.modelLabel; chip.dataset.state = 'off'; }
92
  else { chip.textContent = '⚡ Enable AI'; chip.title = 'Download the AI assistant (about 1 GB, one time)'; chip.dataset.state = 'off'; }
93
  updateSend();
94
+ if (!$('devicePanel').classList.contains('hidden')) renderDevicePanel();
95
  }
96
 
97
  // With WebGPU the chat waits for the AI; the knowledge base answers alone only without WebGPU, if the AI fails to load,
98
  // or when the visitor chose "Not now".
99
+ function aiPending() { return 'gpu' in navigator && !state.kbOnly && state.engine !== false && !state.engine; }
100
 
101
  // Instant answer straight from the knowledge base, formatted for reading.
102
  function quickAnswer(passages) {
 
150
  const chat = state.current;
151
  $('emptyState').classList.toggle('hidden', Boolean(chat && (chat.messages.length || chat.audit)));
152
  $('aiCard').classList.toggle('hidden', !state.showAiCard || state.loading || Boolean(state.engine) || state.engine === false);
153
+ renderDevice();
154
  const thread = $('thread');
155
  thread.innerHTML = '';
156
  if (!chat) return renderSidebar();
 
196
  chat.messages.push(reply);
197
  state.busy = true; updateSend(); render();
198
  try {
199
+ const device = activeDevice();
200
+ const os = chat.audit ? chat.audit.host.os_family : (device.os_family === 'chromeos' ? null : device.os_family);
201
  const passages = searchWithAudit(state.kb, question, chat.audit, { k: 4, os });
202
  reply.passages = passages;
203
+ const messages = chatMessages(question, passages, auditBrief(chat.audit), history, chat.audit ? null : deviceBrief(device));
204
  reply.question = question; reply.history = history;
205
  if (state.engine) {
206
  // Typing dots until the AI's first words arrive; the knowledge-base answer is only the fallback if the AI fails.
 
255
  const chat = state.current; const reply = chat && chat.messages[index];
256
  if (!reply || !state.engine || state.busy) return;
257
  state.busy = true; updateSend();
258
+ const messages = chatMessages(reply.question, reply.passages, auditBrief(chat.audit), reply.history || [], chat.audit ? null : deviceBrief(activeDevice()));
259
  try { await aiAnswer(chat, reply, messages, reply.passages); } catch (error) { reply.note = `The AI could not answer: ${error.message}`; }
260
  state.busy = false; saveChats(); updateSend(); render();
261
  }
 
352
  function updateSend() {
353
  const wait = aiPending();
354
  $('input').disabled = wait;
355
+ const loading = wait && state.loading;
356
+ $('inputPill').classList.toggle('loading', loading);
357
+ $('inputPill').style.setProperty('--progress', `${Math.round((state.progress || 0) * 100)}%`);
358
  $('input').placeholder = !wait ? 'Ask Arx about security, or add your audit report…'
359
+ : loading ? state.progressText || 'Getting the AI assistant ready…'
360
  : state.showAiCard ? 'Choose "Download now" or "Not now" above to start chatting…' : 'Tap ⚡ Enable AI at the top to start chatting…';
361
  document.querySelectorAll('.chip[data-prompt]').forEach(c => { c.disabled = wait; });
362
  $('sendBtn').disabled = wait || state.busy || !$('input').value.trim();
363
  }
364
+ // ---------- this device ----------
365
+ const DEVICE_CHOICE = 'devseis.deviceChoice';
366
+ // The device answers are for: the one the browser detected, or one the visitor picked ("my Windows PC").
367
+ function activeDevice() { return state.deviceChoice ? chosenDevice(state.deviceChoice) : (state.device || {}); }
368
+
369
+ function renderDevice() {
370
+ const audit = state.current && state.current.audit;
371
+ const d = activeDevice();
372
+ const ready = Boolean(state.device);
373
+ let text, note;
374
+ if (audit) {
375
+ const s = audit.summary, assessed = s.Compliant + s['Non-Compliant'];
376
+ text = `Audit · ${audit.host.os_name} ${audit.host.os_version} · ${assessed ? Math.round(s.Compliant / assessed * 100) : 0}% compliant`;
377
+ note = 'Answers use your audit results';
378
+ } else if (!ready) { text = 'Detecting this device…'; note = ''; }
379
+ else if (!d.os_family) { text = 'Unknown device · choose yours'; note = 'Pick a system for tailored steps'; }
380
+ else {
381
+ text = [d.chosen ? `Asking about ${d.device_model}` : d.device_model !== d.os_name ? d.device_model : null, osLabel(d), d.chosen ? null : d.browser].filter(Boolean).join(' · ');
382
+ note = d.chosen ? 'Steps are for the system you chose' : 'Answers are tailored to this device';
383
+ }
384
+ $('deviceIcon').textContent = audit ? '📋' : deviceIcon(d);
385
+ $('deviceText').textContent = text;
386
+ $('contextNote').textContent = note;
387
+ $('deviceBtn').dataset.kind = audit ? 'audit' : d.chosen ? 'chosen' : 'detected';
388
+ if (!$('devicePanel').classList.contains('hidden')) renderDevicePanel();
389
+ }
390
+
391
+ function renderDevicePanel() {
392
+ const audit = state.current && state.current.audit;
393
+ const real = state.device || {};
394
+ const d = activeDevice();
395
+ const row = (label, value, hint = '') => value ? `<div class="drow"><dt>${label}</dt><dd>${esc(value)}${hint ? `<small>${esc(hint)}</small>` : ''}</dd></div>` : '';
396
+ const unlock = real.platform_authenticator === true ? 'Available (fingerprint, face or PIN)' : real.platform_authenticator === false ? 'None found by the browser' : '';
397
+ const ai = state.engine ? `Ready · ${state.modelLabel}` : state.loading ? `Downloading · ${Math.round((state.progress || 0) * 100)}%`
398
+ : 'gpu' in navigator && state.engine !== false ? 'Supported (WebGPU)' : 'Not supported here: answers come from the knowledge base';
399
+ let html;
400
+ if (audit) {
401
+ const h = audit.host, s = audit.summary, assessed = s.Compliant + s['Non-Compliant'];
402
+ $('deviceTitle').textContent = 'Audit in this chat';
403
+ html = `<dl>${row('Device', h.device_model || h.hostname)}${row('System', `${h.os_name} ${h.os_version}`)}${row('Audited', String(audit.audit.started_at).slice(0, 10))}
404
+ ${row('Result', `${s.Compliant} of ${assessed} checks compliant · ${s['Non-Compliant']} gaps`)}</dl>
405
+ <p class="device-note">Arx answers from these results. Start a new chat to ask about this device instead.</p>`;
406
+ } else {
407
+ $('deviceTitle').textContent = d.chosen ? 'Device you are asking about' : 'This device';
408
+ const version = d.os_version && !d.version_precise && !d.chosen && d.os_family !== 'windows' ? 'Approximate: browsers hide the exact version' : '';
409
+ html = `<dl>${row('Device', d.device_model ? `${d.device_model}${d.arch ? ` (${d.arch})` : ''}` : '')}${row('System', d.os_family ? osLabel(d) : 'Not recognised', version)}
410
+ ${d.chosen ? '' : row('Browser', real.browser) + row('Screen unlock', unlock) + row('Connection', real.cellular ? 'Mobile data' : '')}${row('On-device AI', ai)}</dl>
411
+ <p class="device-note">🔒 Read by your browser and never uploaded. Arx uses it to give steps for this system. A web page can't see settings such as encryption or the firewall: an audit checks those.</p>
412
+ <button type="button" class="primary-btn device-audit" id="deviceAudit">🛡 Audit ${d.chosen ? 'a device' : 'this device'}</button>
413
+ <div class="device-switch"><span class="muted tiny">Asking about another device?</span><div class="seg">
414
+ <button type="button" data-os="" aria-pressed="${!state.deviceChoice}">${deviceIcon(real)} This device</button>
415
+ ${OS_CHOICES.map(([os, label]) => `<button type="button" data-os="${os}" aria-pressed="${state.deviceChoice === os}">${label}</button>`).join('')}</div></div>`;
416
+ }
417
+ $('deviceBody').innerHTML = html;
418
+ }
419
+
420
+ function toggleDevicePanel(open) {
421
+ $('devicePanel').classList.toggle('hidden', !open);
422
+ $('deviceBackdrop').classList.toggle('hidden', !open);
423
+ $('deviceBtn').setAttribute('aria-expanded', String(open));
424
+ if (open) { renderDevicePanel(); $('deviceClose').focus(); } else $('deviceBtn').focus();
425
+ }
426
+ $('deviceBtn').addEventListener('click', () => toggleDevicePanel($('devicePanel').classList.contains('hidden')));
427
+ $('deviceClose').addEventListener('click', () => toggleDevicePanel(false));
428
+ $('deviceBackdrop').addEventListener('click', () => toggleDevicePanel(false));
429
+ document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('devicePanel').classList.contains('hidden')) toggleDevicePanel(false); });
430
+ $('deviceBody').addEventListener('click', e => {
431
+ const pick = e.target.closest('[data-os]');
432
+ if (pick) {
433
+ state.deviceChoice = pick.dataset.os || null;
434
+ try { if (state.deviceChoice) localStorage.setItem(DEVICE_CHOICE, state.deviceChoice); else localStorage.removeItem(DEVICE_CHOICE); } catch { /* ignore */ }
435
+ renderDevice(); renderDevicePanel(); return;
436
+ }
437
+ if (e.target.closest('#deviceAudit')) { toggleDevicePanel(false); openAuditModal(); }
438
+ });
439
+
440
  function closeSidebar() { $('sidebar').classList.remove('open'); $('overlay').classList.add('hidden'); }
441
 
442
  $('composer').addEventListener('submit', e => { e.preventDefault(); const q = $('input').value.trim(); $('input').value = ''; $('input').style.height = ''; updateSend(); ask(q); });
 
490
  detectDevice().catch(() => ({})),
491
  getJson('app/model-version.json', { cache: 'no-cache' }).catch(() => ({})),
492
  ]);
493
+ state.kb = new Knowledge(kb); state.device = await describeComputer(device); state.config = config;
494
+ try { state.deviceChoice = localStorage.getItem(DEVICE_CHOICE); } catch { /* ignore */ }
495
  // Coming back from the phone check with a fresh report.
496
  if (location.hash === '#audit') {
497
  try { const ev = JSON.parse(localStorage.getItem('devseis.lastAudit')); if (ev) { attachAudit(ev); localStorage.removeItem('devseis.lastAudit'); } } catch { /* ignore */ }
chat/device.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // What the chat knows about the device it runs on: the phone details from mobile/detect.js, plus the computer's OS,
2
+ // version and chip, which a browser only partly reveals (Chromium client hints are precise; Safari and Firefox freeze
3
+ // the macOS version, and Windows 10 and 11 look the same without hints). Nothing here is uploaded.
4
+
5
+ const OS_NAMES = { macos: 'macOS', windows: 'Windows', linux: 'Linux', chromeos: 'ChromeOS', ios: 'iOS', android: 'Android' };
6
+ const DEVICE_NAMES = { macos: 'Mac', windows: 'Windows PC', linux: 'Linux computer', chromeos: 'Chromebook', ios: 'iPhone', android: 'Android phone' };
7
+ export const OS_CHOICES = [['windows', '🪟 Windows'], ['macos', '💻 macOS'], ['linux', '🐧 Linux'], ['ios', '📱 iPhone / iPad'], ['android', '🤖 Android']];
8
+
9
+ // Fill in a computer's details (phones are already done by detectDevice).
10
+ export async function describeComputer(device) {
11
+ const d = { ...device };
12
+ if (!d.desktop) return d;
13
+ const ua = navigator.userAgent;
14
+ d.os_family = /CrOS/.test(ua) ? 'chromeos' : /Windows/.test(ua) ? 'windows' : /Mac/.test(ua) ? 'macos' : 'linux';
15
+ d.os_name = OS_NAMES[d.os_family];
16
+ d.device_model = DEVICE_NAMES[d.os_family];
17
+ d.os_version = null; d.version_precise = false; d.arch = null;
18
+ const uaData = navigator.userAgentData;
19
+ if (uaData && uaData.getHighEntropyValues) {
20
+ try {
21
+ const hi = await Promise.race([uaData.getHighEntropyValues(['platformVersion', 'architecture', 'bitness']), new Promise(r => setTimeout(() => r({}), 1500))]);
22
+ const v = hi.platformVersion || '';
23
+ if (d.os_family === 'windows' && v) { d.os_version = parseInt(v, 10) >= 13 ? '11' : '10'; d.version_precise = true; }
24
+ if (d.os_family === 'macos' && v) { d.os_version = v.replace(/(\.0)+$/, ''); d.version_precise = true; }
25
+ if (hi.architecture) d.arch = hi.architecture === 'arm' ? (d.os_family === 'macos' ? 'Apple silicon' : 'ARM') : (d.os_family === 'macos' ? 'Intel' : `${hi.architecture === 'x86' ? 'x86' : hi.architecture}-${hi.bitness || '64'}`);
26
+ } catch { /* hints refused */ }
27
+ }
28
+ if (d.os_family === 'windows' && !d.os_version) d.os_version = '10 or 11';
29
+ return d;
30
+ }
31
+
32
+ // "macOS 15.6", "Windows 10 or 11", "iOS 18.6", "Linux".
33
+ export function osLabel(d) { return [d.os_name || OS_NAMES[d.os_family] || 'Unknown system', d.os_version].filter(Boolean).join(' '); }
34
+
35
+ export function deviceIcon(d) { return d.os_family === 'ios' || d.os_family === 'android' ? '📱' : '💻'; }
36
+
37
+ // A device the visitor picked by hand ("I'm asking about my Windows PC").
38
+ export function chosenDevice(family) {
39
+ return { os_family: family, os_name: OS_NAMES[family], os_version: null, device_model: DEVICE_NAMES[family], chosen: true };
40
+ }
41
+
42
+ // The MY DEVICE line in the chat prompt: what the browser saw, and that settings are not checked.
43
+ export function deviceBrief(d) {
44
+ if (!d || !d.os_family) return null;
45
+ if (d.chosen) return `${osLabel(d)} (${d.os_family}), chosen by the user; its security settings have not been checked.`;
46
+ const model = d.device_model && d.device_model !== d.os_name ? ` on ${/^[AEIOU]/.test(d.device_model) ? 'an' : 'a'} ${d.device_model}${d.arch ? ` (${d.arch})` : ''}` : '';
47
+ return `${osLabel(d)} (${d.os_family})${model}, ${d.browser || 'unknown browser'}; detected by the browser, security settings not checked.`;
48
+ }
index.html CHANGED
@@ -48,12 +48,25 @@
48
  <div id="thread" aria-live="polite"></div>
49
  </main>
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  <form id="composer" autocomplete="off">
52
  <button type="button" id="attachBtn" class="icon-btn attach" title="Add an audit report (.json from the Devseis app)" aria-label="Add an audit report">
53
  <svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5.5 5.5 0 01-7.78-7.78l9.2-9.19a3.5 3.5 0 014.95 4.95l-9.2 9.19a1.5 1.5 0 01-2.12-2.12l8.49-8.48"/></svg>
54
  </button>
55
  <input type="file" id="fileInput" accept=".json,application/json" hidden>
56
- <div class="input-pill">
57
  <textarea id="input" rows="1" placeholder="Ask Arx about security, or add your audit report…" aria-label="Message"></textarea>
58
  </div>
59
  <button type="submit" id="sendBtn" class="send-btn" aria-label="Send" disabled>
 
48
  <div id="thread" aria-live="polite"></div>
49
  </main>
50
 
51
+ <div class="context-wrap" id="contextWrap">
52
+ <div class="context-bar">
53
+ <button type="button" id="deviceBtn" class="device-pill" aria-haspopup="dialog" aria-expanded="false" aria-controls="devicePanel">
54
+ <span class="device-ic" id="deviceIcon">💻</span><span class="device-text" id="deviceText">Detecting this device…</span><span class="chev" aria-hidden="true">▾</span>
55
+ </button>
56
+ <span class="context-note" id="contextNote">Answers are tailored to this device</span>
57
+ </div>
58
+ <div class="sheet-backdrop hidden" id="deviceBackdrop"></div>
59
+ <div class="device-panel hidden" id="devicePanel" role="dialog" aria-modal="false" aria-labelledby="deviceTitle">
60
+ <div class="device-head"><h2 id="deviceTitle">This device</h2><button type="button" class="icon-btn" id="deviceClose" aria-label="Close">✕</button></div>
61
+ <div id="deviceBody"></div>
62
+ </div>
63
+ </div>
64
  <form id="composer" autocomplete="off">
65
  <button type="button" id="attachBtn" class="icon-btn attach" title="Add an audit report (.json from the Devseis app)" aria-label="Add an audit report">
66
  <svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5.5 5.5 0 01-7.78-7.78l9.2-9.19a3.5 3.5 0 014.95 4.95l-9.2 9.19a1.5 1.5 0 01-2.12-2.12l8.49-8.48"/></svg>
67
  </button>
68
  <input type="file" id="fileInput" accept=".json,application/json" hidden>
69
+ <div class="input-pill" id="inputPill">
70
  <textarea id="input" rows="1" placeholder="Ask Arx about security, or add your audit report…" aria-label="Message"></textarea>
71
  </div>
72
  <button type="submit" id="sendBtn" class="send-btn" aria-label="Send" disabled>