// Devseis Security Assistant: chat about endpoint security with the Devseis model running on the device (WebLLM), // grounded in the Devseis knowledge base (library/knowledge.json), plus audits: the phone check on phones, the desktop // app (or its report file) on computers. Conversations are stored only in this browser. import { Knowledge, chatMessages, auditBrief, chatProblems, searchWithAudit } from '../app/renderer/knowledge.js'; import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer, ANSWER_SCHEMAS } from '../app/renderer/auditor-core.js'; import { buildReportHtml } from '../app/renderer/report.js'; import { detectDevice } from '../mobile/detect.js'; const WEBLLM_URL = 'https://esm.run/@mlc-ai/web-llm@0.2.85'; const STORE = 'devseis.chats.v1'; const $ = id => document.getElementById(id); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const state = { kb: null, device: null, config: null, engine: null, enginePromise: null, modelLabel: '', busy: false, chats: [], current: null, libData: null }; // ---------- storage ---------- function loadChats() { try { return JSON.parse(localStorage.getItem(STORE)) || []; } catch { return []; } } function saveChats() { try { localStorage.setItem(STORE, JSON.stringify(state.chats.slice(0, 50))); } catch { /* private mode or full */ } } function newChat() { const chat = { id: Date.now().toString(36), title: 'New conversation', messages: [], audit: null }; state.chats.unshift(chat); state.current = chat; saveChats(); render(); return chat; } // ---------- model ---------- // The model never blocks a question: until it is ready, answers come straight from the knowledge base. // It downloads in the background after the user agrees once (about 1 GB), and starts by itself when already stored. const AI_CONSENT = 'devseis.aiConsent'; let webllmPromise = null; const loadWebllm = () => (webllmPromise ||= import(WEBLLM_URL)); async function modelChoice() { if (!('gpu' in navigator)) throw new Error('This browser has no WebGPU'); const adapter = await navigator.gpu.requestAdapter(); if (!adapter) throw new Error('No WebGPU graphics adapter'); const quant = adapter.features.has('shader-f16') ? 'q4f16_1' : 'q4f32_1'; const webllm = await loadWebllm(); const base = webllm.prebuiltAppConfig.model_list.find(m => m.model_id === `Qwen2.5-1.5B-Instruct-${quant}-MLC`); const v = state.config?.chat?.version; const custom = v ? { ...base, model: `https://huggingface.co/Devseis/endpoint-auditor-1.5b-${quant}-MLC/resolve/${v}/`, model_id: `devseis-assistant-1.5b-${v}-${quant}` } : null; const appConfig = { ...webllm.prebuiltAppConfig, model_list: custom ? [...webllm.prebuiltAppConfig.model_list, custom] : webllm.prebuiltAppConfig.model_list }; return { webllm, base, custom, appConfig, v }; } // Is the model already stored on this device (no download needed)? async function modelCached() { try { const { webllm, base, custom, appConfig } = await modelChoice(); return await webllm.hasModelInCache((custom || base).model_id, appConfig); } catch { return false; } } function getEngine() { if (state.engine !== null || state.enginePromise) return state.enginePromise || Promise.resolve(state.engine); state.loading = true; state.progress = 0; aiChip(); render(); state.enginePromise = (async () => { const { webllm, base, custom, appConfig, v } = await modelChoice(); const initProgressCallback = r => { state.progress = r.progress || 0; const mb = /([\d.]+)MB fetched/.exec(r.text || ''); status(state.cachedStart ? `Starting the AI assistant… ${Math.round(state.progress * 100)}%` : `Downloading the AI assistant (one time, about 1 GB): ${Math.round(state.progress * 100)}%${mb ? ` · ${Math.round(+mb[1])} MB` : ''} · you can keep chatting meanwhile`, state.progress); aiChip(); }; let engine, label; if (custom) { try { engine = await webllm.CreateMLCEngine(custom.model_id, { appConfig, initProgressCallback }); label = `Devseis assistant ${v} (1.5B)`; } catch (e) { console.warn('Devseis model failed, using base', e); } } if (!engine) { engine = await webllm.CreateMLCEngine(base.model_id, { appConfig, initProgressCallback }); label = 'Qwen2.5 1.5B (base, until the Devseis model loads)'; } state.modelLabel = label; $('modelName').textContent = label; status(''); return engine; })().then(e => { state.engine = e; state.loading = false; aiChip(); render(); return e; }, err => { state.engine = false; state.loading = false; state.modelLabel = `Not available: ${err.message}`; $('modelName').textContent = 'Knowledge base only'; status(''); aiChip(); render(); return false; }); return state.enginePromise; } function enableAi() { try { localStorage.setItem(AI_CONSENT, 'yes'); } catch { /* ignore */ } state.consent = true; getEngine(); } // Small status chip in the header: what the assistant is running on right now. function aiChip() { const chip = $('aiChip'); if (!chip) return; if (state.engine) { chip.textContent = '✨ AI ready'; chip.title = state.modelLabel; chip.dataset.state = 'ready'; } else if (state.loading) { chip.textContent = `⬇ AI ${Math.round((state.progress || 0) * 100)}%`; chip.title = 'The AI assistant is getting ready'; chip.dataset.state = 'loading'; } else if (state.engine === false) { chip.textContent = '📚 Knowledge base'; chip.title = state.modelLabel; chip.dataset.state = 'off'; } else { chip.textContent = '⚡ Enable AI'; chip.title = 'Download the AI assistant (about 1 GB, one time)'; chip.dataset.state = 'off'; } } // Instant answer straight from the knowledge base, formatted for reading. function quickAnswer(passages) { if (!passages.length) return 'The Devseis knowledge base has nothing on that question. I cover device security, ISO 27001, GDPR and the EU AI Act.'; const top = passages[0]; const body = top.text.startsWith(top.title) ? top.text.slice(top.title.length).replace(/^[.:]\s*/, '') : top.text; const text = body.replace(/\s\((\d+)\)\s/g, '\n$1. ') .replace(/(^|\s)(How to fix on [A-Za-z]+|How to verify|Related controls|Why it matters|Risk if missing|Requirement|An auditor looks for|On endpoints|Where to look):/g, '\n\n**$2:**') .replace(/^\s+/, ''); return `**${top.title}** [1]\n\n${text}`; } function status(text, fraction) { $('statusBar').classList.toggle('hidden', !text); $('statusText').textContent = text; $('statusFill').style.width = `${Math.round((fraction || 0) * 100)}%`; } // ---------- rendering ---------- function markdown(text, passages, msgIndex) { const lines = esc(text).split('\n'); let html = '', list = null; const close = () => { if (list) { html += `${list}>`; list = null; } }; for (const line of lines) { const ol = /^\s*\d+\.\s+(.*)$/.exec(line), ul = /^\s*[-•]\s+(.*)$/.exec(line); if (ol || ul) { const tag = ol ? 'ol' : 'ul'; if (list !== tag) { close(); html += `<${tag}>`; list = tag; } html += `
${line}
`; } } close(); return html.replace(/\*\*(.+?)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
.replace(/\[(\d+)\]/g, (m, n) => passages && passages[n - 1] ? `` : '');
}
function auditCard(audit) {
const s = audit.summary, assessed = s.Compliant + s['Non-Compliant'];
const pct = assessed ? Math.round(100 * s.Compliant / assessed) : 0;
const colours = { Compliant: 'var(--ok)', 'Non-Compliant': 'var(--accent)', Pending: 'var(--warn)', NotApplicable: 'var(--muted)', Error: '#8250df' };
const gaps = audit.results.filter(r => r.status === 'Non-Compliant').slice(0, 5);
return `Top gaps: ${gaps.map(g => esc(g.name)).join(', ')}
` : ''}No conversations yet.
'; } function showSource(msgIndex, n, anchor) { const m = state.current.messages[msgIndex]; const p = m && m.passages && m.passages[n - 1]; if (!p) return; const slot = document.querySelector(`#m${msgIndex} .source-slot`); slot.innerHTML = `The check runs in this browser: the phone answers what it can, you answer the rest. About 5 minutes.
📱 Start the phone checkYour answers and report stay on this phone.` : `A web page can't read a computer's settings, so computers are audited with the free Devseis app. It only reads, changes nothing, and takes about 15 minutes.
💻 Get the desktop app for ${osName}${os === 'windows' ? ' (beta)' : ''}Step-by-step install guide. 📱 Check a phone insteadOpen the phone check on the phone itself.`; $('auditModal').classList.remove('hidden'); const att = $('optAttach'); if (att) att.addEventListener('click', () => { $('auditModal').classList.add('hidden'); $('fileInput').click(); }); } function attachAudit(evidence) { if (!evidence || evidence.schema_version !== '1.0' || !Array.isArray(evidence.results) || !evidence.host) throw new Error('This is not a Devseis audit report file.'); const chat = state.current && !state.current.messages.length && !state.current.audit ? state.current : newChat(); chat.audit = evidence; chat.title = `Audit: ${evidence.host.os_name} ${String(evidence.audit.started_at).slice(0, 10)}`; saveChats(); render(); } // JSON from this site. A request can stall behind the browser's cache lock when the page was reloaded while it was // downloading (the desktop app does that when it opens the chat), so give up after a few seconds and retry past the cache. // The retry has no time limit, so a slow connection still gets there. async function getJson(p, { timeout = 5000, cache = 'default' } = {}) { for (const mode of [cache, 'reload']) { const ctl = new AbortController(); const timer = mode === 'reload' ? null : setTimeout(() => ctl.abort(), timeout); try { const r = await fetch(p, { cache: mode, signal: ctl.signal }); if (!r.ok) throw new Error(`${p}: ${r.status}`); return await r.json(); } catch (error) { if (error.name !== 'AbortError' || mode === 'reload') throw error; } finally { clearTimeout(timer); } } } async function loadLibrary() { if (state.libData) return state.libData; const [catalog, controls, guidance, aiTools] = await Promise.all(['catalog/checks.json', 'library/controls.json', 'library/check_guidance.json', 'library/ai_tools.json'].map(p => getJson(p))); state.libData = new Library({ catalog, controls, guidance, aiTools }); return state.libData; } async function writeReport() { const chat = state.current; if (!chat || !chat.audit || state.busy) return; state.busy = true; updateSend(); const evidence = chat.audit; try { const lib = await loadLibrary(); const engine = state.engine || null; const generate = engine ? async (system, prompt, task) => (await engine.chat.completions.create({ messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }], temperature: 0, max_tokens: task === 'ai_classification' ? 1600 : 1200, response_format: { type: 'json_object', schema: JSON.stringify(ANSWER_SCHEMAS[task]) } })).choices[0].message.content : null; const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status)); const findings = {}, sources = { model: 0, template: 0 }; let i = 0; for (const r of targets) { status(`Writing your report: ${r.name} (${++i} of ${targets.length})`, i / (targets.length + 2)); const out = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), r.section === 'org' ? null : generate); findings[r.id] = out.answer; sources[out.source] += 1; } status('Writing your report: summary', 0.95); const base = summaryTemplate(evidence, lib); const so = await writeAnswer('summary', summaryPrompt(evidence, lib), base, generate); sources[so.source] += 1; const summary = so.source === 'model' ? { ...base, summary: so.answer.summary, next_steps: so.answer.next_steps } : base; const ai = aiPrompt(evidence, lib); let aiResult = null; if (ai) { const o = await writeAnswer('ai_classification', ai, aiTemplate(evidence, lib), generate); aiResult = o.answer; sources[o.source] += 1; } const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256: '(from attached file)', model: state.engine ? state.modelLabel : null, sources }); const url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); chat.messages.push({ role: 'assistant', content: `Your full report is ready: ${sources.model} parts written by the AI, ${sources.template} by the built-in writer. Open it and use your browser's Print › Save as PDF to keep it.`, passages: [] }); saveChats(); render(); const last = $(`m${chat.messages.length - 1}`); last.insertAdjacentHTML('beforeend', ``); } catch (error) { chat.messages.push({ role: 'assistant', content: `The report could not be written: ${error.message}`, passages: [] }); render(); } status(''); state.busy = false; updateSend(); } // ---------- wiring ---------- function updateSend() { $('sendBtn').disabled = state.busy || !$('input').value.trim(); } function closeSidebar() { $('sidebar').classList.remove('open'); $('overlay').classList.add('hidden'); } $('composer').addEventListener('submit', e => { e.preventDefault(); const q = $('input').value.trim(); $('input').value = ''; $('input').style.height = ''; updateSend(); ask(q); }); $('input').addEventListener('input', () => { const t = $('input'); t.style.height = 'auto'; t.style.height = `${Math.min(t.scrollHeight, 180)}px`; updateSend(); }); $('input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); $('composer').requestSubmit(); } }); document.querySelectorAll('.chip').forEach(c => c.addEventListener('click', () => (c.dataset.action === 'audit' ? openAuditModal() : ask(c.dataset.prompt)))); $('auditBtn').addEventListener('click', openAuditModal); $('aiChip').addEventListener('click', () => { if (!state.engine && !state.loading && state.engine !== false) enableAi(); else if (state.engine) status(`AI assistant ready: ${state.modelLabel}`, 1) || setTimeout(() => status(''), 2500); }); $('aiCardYes').addEventListener('click', () => { state.showAiCard = false; enableAi(); }); $('aiCardNo').addEventListener('click', () => { state.showAiCard = false; try { localStorage.setItem(AI_CONSENT, 'later'); } catch { /* ignore */ } render(); }); $('auditClose').addEventListener('click', () => $('auditModal').classList.add('hidden')); $('auditModal').addEventListener('click', e => { if (e.target === $('auditModal')) $('auditModal').classList.add('hidden'); }); $('attachBtn').addEventListener('click', () => $('fileInput').click()); $('fileInput').addEventListener('change', async () => { const f = $('fileInput').files[0]; $('fileInput').value = ''; if (!f) return; try { attachAudit(JSON.parse((await f.text()).replace(/^/, ''))); } catch (error) { const chat = state.current || newChat(); chat.messages.push({ role: 'assistant', content: `I couldn't read that file: ${error.message} Use the …-evidence.json file the Devseis app saves next to the PDF report.`, passages: [] }); render(); } }); $('thread').addEventListener('click', e => { const t = e.target.closest('button'); if (!t) return; if (t.dataset.rewrite) rewriteWithAi(+t.dataset.rewrite); else if (t.dataset.enable) enableAi(); else if (t.dataset.ask) ask(t.dataset.ask); else if (t.dataset.report) writeReport(); else if (t.dataset.msg) showSource(+t.dataset.msg, +t.dataset.n, true); }); $('newChat').addEventListener('click', () => { newChat(); $('input').focus(); }); $('sidebarToggle').addEventListener('click', () => { $('sidebar').classList.add('open'); $('overlay').classList.remove('hidden'); }); $('sidebarClose').addEventListener('click', closeSidebar); $('overlay').addEventListener('click', closeSidebar); $('sidebarList').addEventListener('click', e => { const del = e.target.closest('[data-del]'); if (del) { state.chats = state.chats.filter(c => c.id !== del.dataset.del); if (state.current && state.current.id === del.dataset.del) state.current = state.chats[0] || null; saveChats(); render(); return; } const row = e.target.closest('.convo'); if (row) { state.current = state.chats.find(c => c.id === row.dataset.id); render(); closeSidebar(); } }); $('clearAll').addEventListener('click', () => { state.chats = []; state.current = null; saveChats(); render(); }); $('themeToggle').addEventListener('click', () => { const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.dataset.theme = dark ? 'light' : 'dark'; try { localStorage.setItem('devseis.theme', document.documentElement.dataset.theme); } catch { /* ignore */ } }); async function init() { try { const t = localStorage.getItem('devseis.theme'); if (t) document.documentElement.dataset.theme = t; } catch { /* ignore */ } state.chats = loadChats(); state.current = state.chats[0] || null; const [kb, device, config] = await Promise.all([ getJson('library/knowledge.json'), detectDevice().catch(() => ({})), getJson('app/model-version.json', { cache: 'no-cache' }).catch(() => ({})), ]); state.kb = new Knowledge(kb); state.device = device; state.config = config; // Coming back from the phone check with a fresh report. if (location.hash === '#audit') { try { const ev = JSON.parse(localStorage.getItem('devseis.lastAudit')); if (ev) { attachAudit(ev); localStorage.removeItem('devseis.lastAudit'); } } catch { /* ignore */ } history.replaceState(null, '', location.pathname); } else if (location.hash.startsWith('#audit=')) { // From the old address (apps 0.4.0/0.4.1 via the endpoint-auditor redirect Space). The fragment never reaches a server. try { attachAudit(JSON.parse(decodeURIComponent(location.hash.slice(7)))); } catch { /* ignore */ } history.replaceState(null, '', location.pathname); } render(); aiChip(); if ('gpu' in navigator) { let consent = null; try { consent = localStorage.getItem(AI_CONSENT); } catch { /* ignore */ } modelCached().then(cached => { if (cached || consent === 'yes') { state.cachedStart = cached; getEngine(); } else if (consent !== 'later') { state.showAiCard = true; if (navigator.connection && navigator.connection.type === 'cellular') $('aiCardText').textContent += ' You seem to be on mobile data: Wi-Fi is recommended.'; render(); } }); } else { state.engine = false; state.modelLabel = 'Not available: this browser has no WebGPU'; $('modelName').textContent = 'Knowledge base only'; aiChip(); } if (new URLSearchParams(location.search).has('test')) Object.assign(window, { __chat: state, __ask: ask, __enable: enableAi, __rewrite: rewriteWithAi }); } init();