Chat: instant answers from the knowledge base, AI download in the background with consent, auto-start when stored, rewrite with AI
Browse files- app/renderer/knowledge.js +3 -1
- chat/chat.css +8 -0
- chat/chat.js +145 -51
- index.html +5 -0
app/renderer/knowledge.js
CHANGED
|
@@ -11,7 +11,7 @@ export const CHAT_SYSTEM_PROMPT = 'You are the Devseis security assistant. You h
|
|
| 11 |
const STOP = new Set('a an and are as at be by can do does for from has have how i if in is it its me my of on or should so that the their them this to was what when where which who why will with you your'.split(' '));
|
| 12 |
// Words people use -> words the knowledge base uses.
|
| 13 |
const SYNONYMS = {
|
| 14 |
-
filevault: 'encryption
|
| 15 |
antivirus: 'malware antivirus', virus: 'malware', defender: 'malware antivirus defender', xprotect: 'malware', clamav: 'malware',
|
| 16 |
passcode: 'passcode screen lock', pin: 'passcode pin screen lock', autolock: 'auto lock screen timeout', lock: 'lock screen',
|
| 17 |
password: 'password', admin: 'administrator', administrator: 'administrator', sudo: 'administrator', root: 'administrator rooted',
|
|
@@ -71,6 +71,7 @@ export class Knowledge {
|
|
| 71 |
// Top-k passages (BM25), preferring the OS the question names, else the device's OS.
|
| 72 |
search(question, { k = 4, os = null } = {}) {
|
| 73 |
const target = osInQuestion(question) || os;
|
|
|
|
| 74 |
const q = tokens(question, true);
|
| 75 |
const N = this.docs.length, k1 = 1.4, b = 0.75;
|
| 76 |
const scores = this.docs.map((d, i) => {
|
|
@@ -82,6 +83,7 @@ export class Knowledge {
|
|
| 82 |
}
|
| 83 |
const c = this.chunks[i];
|
| 84 |
if (target && c.os) s *= c.os === target ? 1.6 : 0.45;
|
|
|
|
| 85 |
return { i, s };
|
| 86 |
});
|
| 87 |
return scores.filter(x => x.s > 0).sort((a, b) => b.s - a.s || a.i - b.i).slice(0, k).map(x => this.chunks[x.i]);
|
|
|
|
| 11 |
const STOP = new Set('a an and are as at be by can do does for from has have how i if in is it its me my of on or should so that the their them this to was what when where which who why will with you your'.split(' '));
|
| 12 |
// Words people use -> words the knowledge base uses.
|
| 13 |
const SYNONYMS = {
|
| 14 |
+
filevault: 'encryption', bitlocker: 'encryption', luks: 'encryption', encrypt: 'encryption', encrypted: 'encryption',
|
| 15 |
antivirus: 'malware antivirus', virus: 'malware', defender: 'malware antivirus defender', xprotect: 'malware', clamav: 'malware',
|
| 16 |
passcode: 'passcode screen lock', pin: 'passcode pin screen lock', autolock: 'auto lock screen timeout', lock: 'lock screen',
|
| 17 |
password: 'password', admin: 'administrator', administrator: 'administrator', sudo: 'administrator', root: 'administrator rooted',
|
|
|
|
| 71 |
// Top-k passages (BM25), preferring the OS the question names, else the device's OS.
|
| 72 |
search(question, { k = 4, os = null } = {}) {
|
| 73 |
const target = osInQuestion(question) || os;
|
| 74 |
+
const wantsOn = /\b(turn on|switch on|enable|activate|set up|setup)\b/i.test(question);
|
| 75 |
const q = tokens(question, true);
|
| 76 |
const N = this.docs.length, k1 = 1.4, b = 0.75;
|
| 77 |
const scores = this.docs.map((d, i) => {
|
|
|
|
| 83 |
}
|
| 84 |
const c = this.chunks[i];
|
| 85 |
if (target && c.os) s *= c.os === target ? 1.6 : 0.45;
|
| 86 |
+
if (wantsOn && c.check && /\.enabled$/.test(c.check)) s *= 1.35; // "turn on X" -> the X-is-on check
|
| 87 |
return { i, s };
|
| 88 |
});
|
| 89 |
return scores.filter(x => x.s > 0).sort((a, b) => b.s - a.s || a.i - b.i).slice(0, k).map(x => this.chunks[x.i]);
|
chat/chat.css
CHANGED
|
@@ -97,3 +97,11 @@ main { flex: 1; overflow-y: auto; padding: 18px 14px 8px; }
|
|
| 97 |
.option.recommended { border: 2px solid var(--accent); }
|
| 98 |
.link-btn { border: 0; background: transparent; color: var(--muted); text-decoration: underline; padding: 6px 0; }
|
| 99 |
@media (max-width: 560px) { .brand span { display: none; } .audit-btn { padding: 8px 12px; } .hero-sub { font-size: 16px; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
.option.recommended { border: 2px solid var(--accent); }
|
| 98 |
.link-btn { border: 0; background: transparent; color: var(--muted); text-decoration: underline; padding: 6px 0; }
|
| 99 |
@media (max-width: 560px) { .brand span { display: none; } .audit-btn { padding: 8px 12px; } .hero-sub { font-size: 16px; } }
|
| 100 |
+
|
| 101 |
+
.ai-chip { border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 999px; padding: 6px 11px; font-size: 13px; white-space: nowrap; }
|
| 102 |
+
.ai-chip[data-state="ready"] { border-color: var(--ok); color: var(--ok); }
|
| 103 |
+
.ai-chip[data-state="loading"] { border-color: var(--accent-border); color: var(--accent); }
|
| 104 |
+
.ai-card { text-align: left; background: var(--panel); border: 2px solid var(--accent-border); border-radius: 16px; padding: 14px 16px; margin: 0 auto 16px; max-width: 600px; font-size: 15px; }
|
| 105 |
+
.ai-card .btn-row { margin-top: 10px; }
|
| 106 |
+
.quick-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
|
| 107 |
+
@media (max-width: 560px) { .ai-chip { padding: 6px 8px; font-size: 12px; } }
|
chat/chat.js
CHANGED
|
@@ -22,19 +22,45 @@ function newChat() {
|
|
| 22 |
}
|
| 23 |
|
| 24 |
// ---------- model ----------
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
state.enginePromise = (async () => {
|
| 28 |
-
|
| 29 |
-
const
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
const appConfig = { ...webllm.prebuiltAppConfig, model_list: custom ? [...webllm.prebuiltAppConfig.model_list, custom] : webllm.prebuiltAppConfig.model_list };
|
| 37 |
-
const initProgressCallback = r => status(`Getting the AI ready on this device (first time only, about 1 GB): ${Math.round((r.progress || 0) * 100)}%`, r.progress || 0);
|
| 38 |
let engine, label;
|
| 39 |
if (custom) {
|
| 40 |
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); }
|
|
@@ -42,10 +68,38 @@ async function getEngine() {
|
|
| 42 |
if (!engine) { engine = await webllm.CreateMLCEngine(base.model_id, { appConfig, initProgressCallback }); label = 'Qwen2.5 1.5B (base, until the Devseis model loads)'; }
|
| 43 |
state.modelLabel = label; $('modelName').textContent = label; status('');
|
| 44 |
return engine;
|
| 45 |
-
})().then(e =>
|
|
|
|
| 46 |
return state.enginePromise;
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
function status(text, fraction) {
|
| 50 |
$('statusBar').classList.toggle('hidden', !text);
|
| 51 |
$('statusText').textContent = text;
|
|
@@ -86,6 +140,7 @@ function auditCard(audit) {
|
|
| 86 |
function render() {
|
| 87 |
const chat = state.current;
|
| 88 |
$('emptyState').classList.toggle('hidden', Boolean(chat && (chat.messages.length || chat.audit)));
|
|
|
|
| 89 |
const thread = $('thread');
|
| 90 |
thread.innerHTML = '';
|
| 91 |
if (!chat) return renderSidebar();
|
|
@@ -96,7 +151,10 @@ function render() {
|
|
| 96 |
thread.insertAdjacentHTML('beforeend', `<div class="msg bot"><img class="avatar" src="app/build/icon.png" alt=""><div class="bubble" id="m${i}">
|
| 97 |
${m.pending ? '<div class="typing"><span></span><span></span><span></span></div>' : markdown(m.content, m.passages, i)}
|
| 98 |
${!m.pending && sources && !m.noSources ? `<div class="sources"><span class="muted tiny">Sources:</span>${sources}</div>` : ''}
|
| 99 |
-
${m.note ? `<div class="note">${esc(m.note)}</div>` : ''}
|
|
|
|
|
|
|
|
|
|
| 100 |
});
|
| 101 |
renderSidebar();
|
| 102 |
$('chatMain').scrollTop = $('chatMain').scrollHeight;
|
|
@@ -132,40 +190,19 @@ async function ask(question) {
|
|
| 132 |
const passages = searchWithAudit(state.kb, question, chat.audit, { k: 4, os });
|
| 133 |
reply.passages = passages;
|
| 134 |
const messages = chatMessages(question, passages, auditBrief(chat.audit), history);
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
const stream = await engine.chat.completions.create({ messages, temperature: 0, max_tokens: 700, stream: true });
|
| 143 |
-
let last = 0;
|
| 144 |
-
for await (const chunk of stream) {
|
| 145 |
-
reply.content += chunk.choices[0]?.delta?.content || '';
|
| 146 |
-
if (Date.now() - last > 120) { last = Date.now(); const el = $(`m${chat.messages.length - 1}`); if (el) el.innerHTML = markdown(clean(reply.content), passages, chat.messages.length - 1); }
|
| 147 |
-
}
|
| 148 |
-
streamed = true;
|
| 149 |
-
break;
|
| 150 |
-
} catch (error) {
|
| 151 |
-
if (!/lost|Instance reference|GPUDevice|disposed/i.test(error.message)) throw error;
|
| 152 |
-
status('The graphics processor was reset; restarting the AI…', 0.5);
|
| 153 |
-
state.engine = null; state.enginePromise = null;
|
| 154 |
-
engine = attempt === 0 ? await getEngine() : null;
|
| 155 |
-
}
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
if (streamed) {
|
| 159 |
-
reply.content = clean(reply.content);
|
| 160 |
-
reply.noSources = !/\[\d+\]/.test(reply.content) && /can only help|don't have|do not have|not able to|can't answer|cannot answer|sorry/i.test(reply.content);
|
| 161 |
-
const problems = chatProblems(messages[messages.length - 1].content, reply.content);
|
| 162 |
-
reply.cited = /\[\d+\]/.test(reply.content);
|
| 163 |
-
if (problems.length) reply.note = 'Some details in this answer could not be matched to the sources. Check them in the sources below before acting.';
|
| 164 |
} else {
|
| 165 |
-
reply.content = passages
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
| 169 |
}
|
| 170 |
} catch (error) {
|
| 171 |
reply.content = `Something went wrong: ${error.message}`;
|
|
@@ -173,6 +210,47 @@ async function ask(question) {
|
|
| 173 |
reply.pending = false; state.busy = false; saveChats(); updateSend(); render();
|
| 174 |
}
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
// Models sometimes emit HTML entities; show plain characters.
|
| 177 |
function clean(text) { return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); }
|
| 178 |
|
|
@@ -216,7 +294,7 @@ async function writeReport() {
|
|
| 216 |
const evidence = chat.audit;
|
| 217 |
try {
|
| 218 |
const lib = await loadLibrary();
|
| 219 |
-
const engine =
|
| 220 |
const generate = engine ? async (system, prompt, task) => (await engine.chat.completions.create({
|
| 221 |
messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }], temperature: 0, max_tokens: task === 'ai_classification' ? 1600 : 1200,
|
| 222 |
response_format: { type: 'json_object', schema: JSON.stringify(ANSWER_SCHEMAS[task]) } })).choices[0].message.content : null;
|
|
@@ -257,6 +335,9 @@ $('input').addEventListener('input', () => { const t = $('input'); t.style.heigh
|
|
| 257 |
$('input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); $('composer').requestSubmit(); } });
|
| 258 |
document.querySelectorAll('.chip').forEach(c => c.addEventListener('click', () => (c.dataset.action === 'audit' ? openAuditModal() : ask(c.dataset.prompt))));
|
| 259 |
$('auditBtn').addEventListener('click', openAuditModal);
|
|
|
|
|
|
|
|
|
|
| 260 |
$('auditClose').addEventListener('click', () => $('auditModal').classList.add('hidden'));
|
| 261 |
$('auditModal').addEventListener('click', e => { if (e.target === $('auditModal')) $('auditModal').classList.add('hidden'); });
|
| 262 |
$('attachBtn').addEventListener('click', () => $('fileInput').click());
|
|
@@ -269,7 +350,9 @@ $('fileInput').addEventListener('change', async () => {
|
|
| 269 |
});
|
| 270 |
$('thread').addEventListener('click', e => {
|
| 271 |
const t = e.target.closest('button'); if (!t) return;
|
| 272 |
-
if (t.dataset.
|
|
|
|
|
|
|
| 273 |
else if (t.dataset.report) writeReport();
|
| 274 |
else if (t.dataset.msg) showSource(+t.dataset.msg, +t.dataset.n, true);
|
| 275 |
});
|
|
@@ -304,7 +387,18 @@ async function init() {
|
|
| 304 |
try { const ev = JSON.parse(localStorage.getItem('devseis.lastAudit')); if (ev) { attachAudit(ev); localStorage.removeItem('devseis.lastAudit'); } } catch { /* ignore */ }
|
| 305 |
history.replaceState(null, '', location.pathname);
|
| 306 |
}
|
| 307 |
-
render();
|
| 308 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
}
|
| 310 |
init();
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
// ---------- model ----------
|
| 25 |
+
// The model never blocks a question: until it is ready, answers come straight from the knowledge base.
|
| 26 |
+
// It downloads in the background after the user agrees once (about 1 GB), and starts by itself when already stored.
|
| 27 |
+
const AI_CONSENT = 'devseis.aiConsent';
|
| 28 |
+
let webllmPromise = null;
|
| 29 |
+
const loadWebllm = () => (webllmPromise ||= import(WEBLLM_URL));
|
| 30 |
+
|
| 31 |
+
async function modelChoice() {
|
| 32 |
+
if (!('gpu' in navigator)) throw new Error('This browser has no WebGPU');
|
| 33 |
+
const adapter = await navigator.gpu.requestAdapter();
|
| 34 |
+
if (!adapter) throw new Error('No WebGPU graphics adapter');
|
| 35 |
+
const quant = adapter.features.has('shader-f16') ? 'q4f16_1' : 'q4f32_1';
|
| 36 |
+
const webllm = await loadWebllm();
|
| 37 |
+
const base = webllm.prebuiltAppConfig.model_list.find(m => m.model_id === `Qwen2.5-1.5B-Instruct-${quant}-MLC`);
|
| 38 |
+
const v = state.config?.chat?.version;
|
| 39 |
+
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;
|
| 40 |
+
const appConfig = { ...webllm.prebuiltAppConfig, model_list: custom ? [...webllm.prebuiltAppConfig.model_list, custom] : webllm.prebuiltAppConfig.model_list };
|
| 41 |
+
return { webllm, base, custom, appConfig, v };
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Is the model already stored on this device (no download needed)?
|
| 45 |
+
async function modelCached() {
|
| 46 |
+
try {
|
| 47 |
+
const { webllm, base, custom, appConfig } = await modelChoice();
|
| 48 |
+
return await webllm.hasModelInCache((custom || base).model_id, appConfig);
|
| 49 |
+
} catch { return false; }
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function getEngine() {
|
| 53 |
+
if (state.engine !== null || state.enginePromise) return state.enginePromise || Promise.resolve(state.engine);
|
| 54 |
+
state.loading = true; state.progress = 0; aiChip(); render();
|
| 55 |
state.enginePromise = (async () => {
|
| 56 |
+
const { webllm, base, custom, appConfig, v } = await modelChoice();
|
| 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` : ''} · you can keep chatting meanwhile`, state.progress);
|
| 62 |
+
aiChip();
|
| 63 |
+
};
|
|
|
|
|
|
|
| 64 |
let engine, label;
|
| 65 |
if (custom) {
|
| 66 |
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); }
|
|
|
|
| 68 |
if (!engine) { engine = await webllm.CreateMLCEngine(base.model_id, { appConfig, initProgressCallback }); label = 'Qwen2.5 1.5B (base, until the Devseis model loads)'; }
|
| 69 |
state.modelLabel = label; $('modelName').textContent = label; status('');
|
| 70 |
return engine;
|
| 71 |
+
})().then(e => { state.engine = e; state.loading = false; aiChip(); render(); return e; },
|
| 72 |
+
err => { state.engine = false; state.loading = false; state.modelLabel = `Not available: ${err.message}`; $('modelName').textContent = 'Knowledge base only'; status(''); aiChip(); render(); return false; });
|
| 73 |
return state.enginePromise;
|
| 74 |
}
|
| 75 |
|
| 76 |
+
function enableAi() {
|
| 77 |
+
try { localStorage.setItem(AI_CONSENT, 'yes'); } catch { /* ignore */ }
|
| 78 |
+
state.consent = true;
|
| 79 |
+
getEngine();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Small status chip in the header: what the assistant is running on right now.
|
| 83 |
+
function aiChip() {
|
| 84 |
+
const chip = $('aiChip');
|
| 85 |
+
if (!chip) return;
|
| 86 |
+
if (state.engine) { chip.textContent = '✨ AI ready'; chip.title = state.modelLabel; chip.dataset.state = 'ready'; }
|
| 87 |
+
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'; }
|
| 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 |
+
}
|
| 91 |
+
|
| 92 |
+
// Instant answer straight from the knowledge base, formatted for reading.
|
| 93 |
+
function quickAnswer(passages) {
|
| 94 |
+
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.';
|
| 95 |
+
const top = passages[0];
|
| 96 |
+
const body = top.text.startsWith(top.title) ? top.text.slice(top.title.length).replace(/^[.:]\s*/, '') : top.text;
|
| 97 |
+
const text = body.replace(/\s\((\d+)\)\s/g, '\n$1. ')
|
| 98 |
+
.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:**')
|
| 99 |
+
.replace(/^\s+/, '');
|
| 100 |
+
return `**${top.title}** [1]\n\n${text}`;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
function status(text, fraction) {
|
| 104 |
$('statusBar').classList.toggle('hidden', !text);
|
| 105 |
$('statusText').textContent = text;
|
|
|
|
| 140 |
function render() {
|
| 141 |
const chat = state.current;
|
| 142 |
$('emptyState').classList.toggle('hidden', Boolean(chat && (chat.messages.length || chat.audit)));
|
| 143 |
+
$('aiCard').classList.toggle('hidden', !state.showAiCard || state.loading || Boolean(state.engine) || state.engine === false);
|
| 144 |
const thread = $('thread');
|
| 145 |
thread.innerHTML = '';
|
| 146 |
if (!chat) return renderSidebar();
|
|
|
|
| 151 |
thread.insertAdjacentHTML('beforeend', `<div class="msg bot"><img class="avatar" src="app/build/icon.png" alt=""><div class="bubble" id="m${i}">
|
| 152 |
${m.pending ? '<div class="typing"><span></span><span></span><span></span></div>' : markdown(m.content, m.passages, i)}
|
| 153 |
${!m.pending && sources && !m.noSources ? `<div class="sources"><span class="muted tiny">Sources:</span>${sources}</div>` : ''}
|
| 154 |
+
${m.note ? `<div class="note">${esc(m.note)}</div>` : ''}
|
| 155 |
+
${m.quick && !m.pending && !m.writing ? (state.engine ? `<div class="quick-actions"><button type="button" class="primary-btn" data-rewrite="${i}">✨ Rewrite with AI</button></div>`
|
| 156 |
+
: (!state.loading && state.engine !== false ? `<div class="quick-actions"><button type="button" class="small-btn" data-enable="1">⚡ Enable the AI assistant (about 1 GB, one time)</button></div>` : '')) : ''}
|
| 157 |
+
<div class="source-slot"></div></div></div>`);
|
| 158 |
});
|
| 159 |
renderSidebar();
|
| 160 |
$('chatMain').scrollTop = $('chatMain').scrollHeight;
|
|
|
|
| 190 |
const passages = searchWithAudit(state.kb, question, chat.audit, { k: 4, os });
|
| 191 |
reply.passages = passages;
|
| 192 |
const messages = chatMessages(question, passages, auditBrief(chat.audit), history);
|
| 193 |
+
reply.question = question; reply.history = history;
|
| 194 |
+
if (state.engine) {
|
| 195 |
+
// Show the knowledge-base answer at once; the AI's answer replaces it when its first words arrive.
|
| 196 |
+
reply.pending = false; reply.content = quickAnswer(passages); reply.cited = true; reply.quick = true; reply.writing = true;
|
| 197 |
+
reply.note = '✨ The AI is writing a fuller answer…';
|
| 198 |
+
render();
|
| 199 |
+
await aiAnswer(chat, reply, messages, passages);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
} else {
|
| 201 |
+
reply.content = quickAnswer(passages);
|
| 202 |
+
reply.cited = true; reply.quick = true;
|
| 203 |
+
reply.note = state.loading ? `Quick answer from the knowledge base. The AI assistant is getting ready (${Math.round((state.progress || 0) * 100)}%); you can ask it to rewrite this when it is ready.`
|
| 204 |
+
: state.engine === false ? 'Quick answer from the knowledge base (the AI is not available in this browser).'
|
| 205 |
+
: 'Quick answer from the knowledge base. Enable the AI assistant for written answers.';
|
| 206 |
}
|
| 207 |
} catch (error) {
|
| 208 |
reply.content = `Something went wrong: ${error.message}`;
|
|
|
|
| 210 |
reply.pending = false; state.busy = false; saveChats(); updateSend(); render();
|
| 211 |
}
|
| 212 |
|
| 213 |
+
// Write (or rewrite) an answer with the model; the GPU can be lost (memory pressure, sleep): restart once, else keep the quick answer.
|
| 214 |
+
async function aiAnswer(chat, reply, messages, passages) {
|
| 215 |
+
const index = chat.messages.indexOf(reply);
|
| 216 |
+
let engine = state.engine;
|
| 217 |
+
for (let attempt = 0; attempt < 2 && engine; attempt++) {
|
| 218 |
+
try {
|
| 219 |
+
reply.pending = false;
|
| 220 |
+
const stream = await engine.chat.completions.create({ messages, temperature: 0, max_tokens: 700, stream: true });
|
| 221 |
+
let last = 0, started = false;
|
| 222 |
+
for await (const chunk of stream) {
|
| 223 |
+
const delta = chunk.choices[0]?.delta?.content || '';
|
| 224 |
+
if (!started && delta) { started = true; reply.content = ''; reply.quick = false; reply.writing = false; reply.note = ''; }
|
| 225 |
+
reply.content += delta;
|
| 226 |
+
if (Date.now() - last > 120) { last = Date.now(); const el = $(`m${index}`); if (el) el.innerHTML = markdown(clean(reply.content), passages, index); }
|
| 227 |
+
}
|
| 228 |
+
if (!started) throw new Error('The AI returned no answer');
|
| 229 |
+
reply.content = clean(reply.content);
|
| 230 |
+
reply.noSources = !/\[\d+\]/.test(reply.content) && /can only help|don't have|do not have|not able to|can't answer|cannot answer|sorry/i.test(reply.content);
|
| 231 |
+
reply.cited = /\[\d+\]/.test(reply.content);
|
| 232 |
+
if (chatProblems(messages[messages.length - 1].content, reply.content).length) reply.note = 'Some details in this answer could not be matched to the sources. Check them in the sources below before acting.';
|
| 233 |
+
return;
|
| 234 |
+
} catch (error) {
|
| 235 |
+
if (!/lost|Instance reference|GPUDevice|disposed/i.test(error.message)) { console.warn('AI answer failed', error); break; } // keep the quick answer
|
| 236 |
+
status('The graphics processor was reset; restarting the AI…', 0.5);
|
| 237 |
+
state.engine = null; state.enginePromise = null;
|
| 238 |
+
engine = attempt === 0 ? await getEngine() : null;
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
reply.content = quickAnswer(passages); reply.cited = true; reply.quick = true; reply.writing = false;
|
| 242 |
+
reply.note = 'Quick answer from the knowledge base (the AI could not run just now).';
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
async function rewriteWithAi(index) {
|
| 246 |
+
const chat = state.current; const reply = chat && chat.messages[index];
|
| 247 |
+
if (!reply || !state.engine || state.busy) return;
|
| 248 |
+
state.busy = true; updateSend();
|
| 249 |
+
const messages = chatMessages(reply.question, reply.passages, auditBrief(chat.audit), reply.history || []);
|
| 250 |
+
try { await aiAnswer(chat, reply, messages, reply.passages); } catch (error) { reply.note = `The AI could not answer: ${error.message}`; }
|
| 251 |
+
state.busy = false; saveChats(); updateSend(); render();
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
// Models sometimes emit HTML entities; show plain characters.
|
| 255 |
function clean(text) { return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); }
|
| 256 |
|
|
|
|
| 294 |
const evidence = chat.audit;
|
| 295 |
try {
|
| 296 |
const lib = await loadLibrary();
|
| 297 |
+
const engine = state.engine || null;
|
| 298 |
const generate = engine ? async (system, prompt, task) => (await engine.chat.completions.create({
|
| 299 |
messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }], temperature: 0, max_tokens: task === 'ai_classification' ? 1600 : 1200,
|
| 300 |
response_format: { type: 'json_object', schema: JSON.stringify(ANSWER_SCHEMAS[task]) } })).choices[0].message.content : null;
|
|
|
|
| 335 |
$('input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); $('composer').requestSubmit(); } });
|
| 336 |
document.querySelectorAll('.chip').forEach(c => c.addEventListener('click', () => (c.dataset.action === 'audit' ? openAuditModal() : ask(c.dataset.prompt))));
|
| 337 |
$('auditBtn').addEventListener('click', openAuditModal);
|
| 338 |
+
$('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); });
|
| 339 |
+
$('aiCardYes').addEventListener('click', () => { state.showAiCard = false; enableAi(); });
|
| 340 |
+
$('aiCardNo').addEventListener('click', () => { state.showAiCard = false; try { localStorage.setItem(AI_CONSENT, 'later'); } catch { /* ignore */ } render(); });
|
| 341 |
$('auditClose').addEventListener('click', () => $('auditModal').classList.add('hidden'));
|
| 342 |
$('auditModal').addEventListener('click', e => { if (e.target === $('auditModal')) $('auditModal').classList.add('hidden'); });
|
| 343 |
$('attachBtn').addEventListener('click', () => $('fileInput').click());
|
|
|
|
| 350 |
});
|
| 351 |
$('thread').addEventListener('click', e => {
|
| 352 |
const t = e.target.closest('button'); if (!t) return;
|
| 353 |
+
if (t.dataset.rewrite) rewriteWithAi(+t.dataset.rewrite);
|
| 354 |
+
else if (t.dataset.enable) enableAi();
|
| 355 |
+
else if (t.dataset.ask) ask(t.dataset.ask);
|
| 356 |
else if (t.dataset.report) writeReport();
|
| 357 |
else if (t.dataset.msg) showSource(+t.dataset.msg, +t.dataset.n, true);
|
| 358 |
});
|
|
|
|
| 387 |
try { const ev = JSON.parse(localStorage.getItem('devseis.lastAudit')); if (ev) { attachAudit(ev); localStorage.removeItem('devseis.lastAudit'); } } catch { /* ignore */ }
|
| 388 |
history.replaceState(null, '', location.pathname);
|
| 389 |
}
|
| 390 |
+
render(); aiChip();
|
| 391 |
+
if ('gpu' in navigator) {
|
| 392 |
+
let consent = null; try { consent = localStorage.getItem(AI_CONSENT); } catch { /* ignore */ }
|
| 393 |
+
modelCached().then(cached => {
|
| 394 |
+
if (cached || consent === 'yes') { state.cachedStart = cached; getEngine(); }
|
| 395 |
+
else if (consent !== 'later') {
|
| 396 |
+
state.showAiCard = true;
|
| 397 |
+
if (navigator.connection && navigator.connection.type === 'cellular') $('aiCardText').textContent += ' You seem to be on mobile data: Wi-Fi is recommended.';
|
| 398 |
+
render();
|
| 399 |
+
}
|
| 400 |
+
});
|
| 401 |
+
} else { state.engine = false; state.modelLabel = 'Not available: this browser has no WebGPU'; $('modelName').textContent = 'Knowledge base only'; aiChip(); }
|
| 402 |
+
if (new URLSearchParams(location.search).has('test')) Object.assign(window, { __chat: state, __ask: ask, __enable: enableAi, __rewrite: rewriteWithAi });
|
| 403 |
}
|
| 404 |
init();
|
index.html
CHANGED
|
@@ -19,6 +19,7 @@
|
|
| 19 |
<a class="brand" href="index.html"><img src="app/build/icon.png" alt=""><span>Devseis <b>Security Assistant</b></span></a>
|
| 20 |
</div>
|
| 21 |
<div class="header-actions">
|
|
|
|
| 22 |
<button type="button" id="newChat" class="icon-btn" aria-label="New chat" title="New chat">
|
| 23 |
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
| 24 |
</button>
|
|
@@ -33,6 +34,10 @@
|
|
| 33 |
<div class="hero-icon"><img src="app/build/icon.png" alt=""></div>
|
| 34 |
<h1>Hi, I'm your Devseis security assistant.</h1>
|
| 35 |
<p class="hero-sub">Ask me how to secure your computer or phone, or about ISO 27001, GDPR and the EU AI Act. I can also <b>audit this device</b> for free. I run on your device: nothing you type is uploaded.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
<div class="chips">
|
| 37 |
<button type="button" class="chip" data-prompt="How do I turn on disk encryption on my computer?">🔒 Turn on disk encryption</button>
|
| 38 |
<button type="button" class="chip" data-prompt="Is ChatGPT OK to use at work under GDPR?">🤖 Is ChatGPT OK at work?</button>
|
|
|
|
| 19 |
<a class="brand" href="index.html"><img src="app/build/icon.png" alt=""><span>Devseis <b>Security Assistant</b></span></a>
|
| 20 |
</div>
|
| 21 |
<div class="header-actions">
|
| 22 |
+
<button type="button" id="aiChip" class="ai-chip" data-state="off">⚡ Enable AI</button>
|
| 23 |
<button type="button" id="newChat" class="icon-btn" aria-label="New chat" title="New chat">
|
| 24 |
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
| 25 |
</button>
|
|
|
|
| 34 |
<div class="hero-icon"><img src="app/build/icon.png" alt=""></div>
|
| 35 |
<h1>Hi, I'm your Devseis security assistant.</h1>
|
| 36 |
<p class="hero-sub">Ask me how to secure your computer or phone, or about ISO 27001, GDPR and the EU AI Act. I can also <b>audit this device</b> for free. I run on your device: nothing you type is uploaded.</p>
|
| 37 |
+
<div class="ai-card hidden" id="aiCard">
|
| 38 |
+
<div><b>⚡ Turn on the AI assistant</b><br><span id="aiCardText">One-time download of about 1 GB; it then runs on this device, even offline. Until then I answer instantly from the Devseis knowledge base.</span></div>
|
| 39 |
+
<div class="btn-row"><button type="button" class="primary-btn" id="aiCardYes">Download now</button><button type="button" class="small-btn" id="aiCardNo">Not now</button></div>
|
| 40 |
+
</div>
|
| 41 |
<div class="chips">
|
| 42 |
<button type="button" class="chip" data-prompt="How do I turn on disk encryption on my computer?">🔒 Turn on disk encryption</button>
|
| 43 |
<button type="button" class="chip" data-prompt="Is ChatGPT OK to use at work under GDPR?">🤖 Is ChatGPT OK at work?</button>
|