Chat: show the detected device and tell the AI about it; download progress inside the message box
Browse files- app/renderer/knowledge.js +3 -2
- chat/chat.css +46 -1
- chat/chat.js +94 -8
- chat/device.js +48 -0
- 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 |
-
|
|
|
|
| 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\
|
| 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 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
:
|
| 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>
|