umer-wasim commited on
Commit
a5e81ea
·
verified ·
1 Parent(s): fbb1758

Chat: instant answers from the knowledge base, AI download in the background with consent, auto-start when stored, rewrite with AI

Browse files
Files changed (4) hide show
  1. app/renderer/knowledge.js +3 -1
  2. chat/chat.css +8 -0
  3. chat/chat.js +145 -51
  4. 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 disk', bitlocker: 'encryption disk', luks: 'encryption disk', 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,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
- async function getEngine() {
26
- if (state.engine !== null || state.enginePromise) return state.enginePromise || state.engine;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  state.enginePromise = (async () => {
28
- if (!('gpu' in navigator)) throw new Error('This browser has no WebGPU');
29
- const adapter = await navigator.gpu.requestAdapter();
30
- if (!adapter) throw new Error('No WebGPU graphics adapter');
31
- const quant = adapter.features.has('shader-f16') ? 'q4f16_1' : 'q4f32_1';
32
- const webllm = await import(WEBLLM_URL);
33
- const base = webllm.prebuiltAppConfig.model_list.find(m => m.model_id === `Qwen2.5-1.5B-Instruct-${quant}-MLC`);
34
- const v = state.config?.chat?.version;
35
- 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;
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 => (state.engine = e), err => { state.engine = false; state.modelLabel = `Not available: ${err.message}`; $('modelName').textContent = 'Knowledge base only'; status(''); return false; });
 
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>` : ''}<div class="source-slot"></div></div></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
- let engine = await getEngine();
136
- let streamed = false;
137
- if (engine) {
138
- // A GPU can be lost (memory pressure, sleep); restart the model once, then fall back to the knowledge base.
139
- for (let attempt = 0; attempt < 2 && engine; attempt++) {
140
- try {
141
- reply.pending = false; reply.content = '';
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.length
166
- ? `The AI model isn't available right now, but here is what the Devseis knowledge base says:\n\n${passages.slice(0, 2).map((p, i) => `**${p.title}** [${i + 1}]\n${p.text}`).join('\n\n')}`
167
- : 'The AI model isn\'t available right now, and the Devseis knowledge base has nothing on that question. I cover device security, ISO 27001, GDPR and the EU AI Act.';
168
- reply.cited = true; reply.note = 'Shown directly from the knowledge base (the AI model is not available on this device right now).';
 
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'"); }
178
 
@@ -216,7 +294,7 @@ async function writeReport() {
216
  const evidence = chat.audit;
217
  try {
218
  const lib = await loadLibrary();
219
- const engine = await getEngine();
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.ask) ask(t.dataset.ask);
 
 
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 (new URLSearchParams(location.search).has('test')) Object.assign(window, { __chat: state, __ask: ask });
 
 
 
 
 
 
 
 
 
 
 
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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>