yasserrmd commited on
Commit
3f042c1
Β·
verified Β·
1 Parent(s): 26b1c63

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +330 -960
index.html CHANGED
@@ -78,9 +78,7 @@
78
  letter-spacing: 0.05em;
79
  margin-bottom: 10px;
80
  }
81
- .frm-field {
82
- margin-bottom: 12px;
83
- }
84
  .frm-label {
85
  display: block;
86
  font-size: 0.8rem;
@@ -88,10 +86,7 @@
88
  color: #8b949e;
89
  margin-bottom: 4px;
90
  }
91
- .frm-req {
92
- color: #f85149;
93
- margin-left: 2px;
94
- }
95
  .frm-input {
96
  width: 100%;
97
  background: #0d1117;
@@ -115,10 +110,7 @@
115
  border-color: #f85149;
116
  box-shadow: 0 0 0 3px rgba(248, 81, 73, 0.15);
117
  }
118
- .frm-textarea {
119
- resize: vertical;
120
- min-height: 80px;
121
- }
122
  .frm-select {
123
  cursor: pointer;
124
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
@@ -126,105 +118,39 @@
126
  background-position: right 10px center;
127
  padding-right: 30px;
128
  }
129
- .frm-radio-group {
130
- display: flex;
131
- flex-direction: column;
132
- gap: 6px;
133
- margin-top: 4px;
134
- }
135
- .frm-radio-label {
136
- display: flex;
137
- align-items: center;
138
- gap: 8px;
139
- cursor: pointer;
140
- font-size: 0.875rem;
141
- color: #adbac7;
142
- }
143
- .frm-radio-label input[type="radio"] {
144
- accent-color: #388bfd;
145
- width: 14px;
146
- height: 14px;
147
- cursor: pointer;
148
- flex-shrink: 0;
149
- }
150
- .frm-checkbox-label {
151
- display: flex;
152
- align-items: center;
153
- gap: 8px;
154
- cursor: pointer;
155
- font-size: 0.875rem;
156
- color: #adbac7;
157
- }
158
- .frm-checkbox-label input[type="checkbox"] {
159
- accent-color: #388bfd;
160
- width: 14px;
161
- height: 14px;
162
- cursor: pointer;
163
- flex-shrink: 0;
164
- }
165
- .frm-error-msg {
166
- font-size: 0.75rem;
167
- color: #f85149;
168
- margin-top: 3px;
169
- }
170
- .frm-actions,
171
- .frm-nav {
172
- display: flex;
173
- gap: 8px;
174
- margin-top: 16px;
175
- justify-content: flex-end;
176
- }
177
- .frm-btn-submit,
178
- .frm-btn-next {
179
  background: linear-gradient(135deg, #1f6feb, #388bfd);
180
- color: #fff;
181
- border: none;
182
- border-radius: 6px;
183
- padding: 7px 16px;
184
- font-size: 0.875rem;
185
- font-weight: 500;
186
- font-family: inherit;
187
- cursor: pointer;
188
- transition: opacity 0.15s;
189
- }
190
- .frm-btn-submit:hover,
191
- .frm-btn-next:hover {
192
- opacity: 0.88;
193
  }
 
194
  .frm-btn-back {
195
- background: transparent;
196
- color: #8b949e;
197
- border: 1px solid #30363d;
198
- border-radius: 6px;
199
- padding: 7px 14px;
200
- font-size: 0.875rem;
201
- font-family: inherit;
202
- cursor: pointer;
203
- transition: border-color 0.15s, color 0.15s;
204
- }
205
- .frm-btn-back:hover {
206
- border-color: #58a6ff;
207
- color: #58a6ff;
208
  }
 
209
  .frm-success {
210
- font-size: 0.875rem;
211
- color: #3fb950;
212
  background: rgba(63, 185, 80, 0.08);
213
  border: 1px solid rgba(63, 185, 80, 0.2);
214
- border-radius: 6px;
215
- padding: 10px 14px;
216
- margin-top: 8px;
217
- }
218
- .frm-select option {
219
- background: #1c2128;
220
- color: #e6edf3;
221
  }
 
222
 
223
  /* ── App styles ── */
224
  * { box-sizing: border-box; }
225
  body { margin: 0; background: #0d1117; color: #adbac7; font-family: 'Inter', system-ui, sans-serif; }
226
  html, body, #root { height: 100%; }
227
-
228
  ::-webkit-scrollbar { width: 6px; }
229
  ::-webkit-scrollbar-track { background: transparent; }
230
  ::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
@@ -239,7 +165,6 @@
239
  0%,60%,100% { transform: translateY(0); opacity: 0.4; }
240
  30% { transform: translateY(-4px); opacity: 1; }
241
  }
242
-
243
  .streaming-cursor::after {
244
  content: 'β–‹'; display: inline-block;
245
  animation: blink 0.8s step-end infinite; color: #388bfd;
@@ -262,7 +187,6 @@
262
  border-radius: 20px; padding: 4px 12px; font-size: 0.75rem;
263
  color: #58a6ff; cursor: default; user-select: none;
264
  }
265
-
266
  #model-progress-bar { transition: width 0.4s ease; }
267
 
268
  /* Model switcher pills */
@@ -285,7 +209,6 @@
285
  white-space: nowrap;
286
  }
287
  .chip:hover { border-color: #388bfd; color: #58a6ff; }
288
-
289
  .inc-pill {
290
  font-size: 0.7rem; color: #3fb950;
291
  background: rgba(63,185,80,0.08); border: 1px solid rgba(63,185,80,0.2);
@@ -318,7 +241,6 @@
318
  data-model-id="LiquidAI/LFM2.5-350M-ONNX"
319
  data-model-label="LFM2.5-350M">LFM2.5</button>
320
  </div>
321
-
322
  <div id="model-status-wrap" class="ml-auto flex items-center gap-3">
323
  <div id="model-progress-wrap" class="w-36 h-1 rounded-full hidden" style="background:#21262d">
324
  <div id="model-progress-bar" class="h-1 rounded-full" style="width:0%;background:#388bfd"></div>
@@ -329,8 +251,6 @@
329
 
330
  <!-- ── Chat area ─────────────────────────────────────────────────────────── -->
331
  <main id="messages" class="flex-1 overflow-y-auto px-4 py-6 flex flex-col gap-4">
332
-
333
- <!-- Empty state -->
334
  <div id="empty-state" class="flex flex-col items-center justify-center flex-1 gap-6 py-12">
335
  <div class="w-16 h-16 rounded-2xl flex items-center justify-center" style="background:linear-gradient(135deg,#1f6feb,#388bfd)">
336
  <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -351,7 +271,6 @@
351
  <button class="chip" data-msg="I'm a new joiner, need setup">πŸ‘‹ New joiner setup</button>
352
  </div>
353
  </div>
354
-
355
  </main>
356
 
357
  <!-- ── Input bar ─────────────────────────────────────────────────────────── -->
@@ -378,131 +297,61 @@
378
 
379
  <!-- ── formora.js (inlined) ──────────────────────────────────────────────── -->
380
  <script>
381
- /**
382
- * formora.js β€” JavaScript port of the formora form builder.
383
- */
384
-
385
  const FORMORA_PREFIX = '__formora__';
386
 
387
- // ─── Validation Rules ────────────────────────────────────────────────────── //
388
-
389
  class Rule {
390
- static required(msg = 'This field is required') {
391
- return { type: 'required', message: msg };
392
- }
393
- static minLength(n, msg) {
394
- return { type: 'minLength', value: n, message: msg || `Minimum ${n} characters required` };
395
- }
396
- static maxLength(n, msg) {
397
- return { type: 'maxLength', value: n, message: msg || `Maximum ${n} characters allowed` };
398
- }
399
- static min(n, msg) {
400
- return { type: 'min', value: n, message: msg || `Must be at least ${n}` };
401
- }
402
- static max(n, msg) {
403
- return { type: 'max', value: n, message: msg || `Must be at most ${n}` };
404
- }
405
- static email(msg = 'Enter a valid email address') {
406
- return { type: 'email', message: msg };
407
- }
408
- static regex(pattern, msg = 'Invalid format') {
409
- return { type: 'regex', value: pattern, message: msg };
410
- }
411
  }
412
 
413
- // ─── Condition for conditional field visibility ─────────────────────────── //
414
-
415
  class Condition {
416
- constructor(fieldId, operator, value) {
417
- this.fieldId = fieldId;
418
- this.operator = operator;
419
- this.value = value;
420
- }
421
  }
422
 
423
- // ─── Form Builder ────────────────────────────────────────────────────────── //
424
-
425
  class Form {
426
  constructor(formId) {
427
- this._id = formId;
428
- this._title = null;
429
- this._description = null;
430
- this._submitLabel = 'Submit';
431
- this._successMsg = 'Thank you! Your request has been received.';
432
- this._fields = [];
433
- this._steps = [];
434
- this._multiStep = false;
435
- this._currentStep = null;
436
- }
437
-
438
- title(t) { this._title = t; return this; }
439
- description(d) { this._description = d; return this; }
440
- submitLabel(l) { this._submitLabel = l; return this; }
441
- successMessage(m) { this._successMsg = m; return this; }
442
-
443
  step(title = null) {
444
- this._multiStep = true;
445
- this._steps.push({ title });
446
- this._currentStep = this._steps.length - 1;
447
- return this;
448
- }
449
-
450
- _add(field) {
451
- field.stepIndex = this._multiStep ? this._currentStep : null;
452
- this._fields.push(field);
453
- return this;
454
- }
455
-
456
- text(id, label, { required = false, placeholder = '', rules = [], condition = null } = {}) {
457
- return this._add({ type: 'text', id, label, required, placeholder, rules, condition });
458
- }
459
- email(id, label, { required = false, placeholder = '', rules = [], condition = null } = {}) {
460
- return this._add({ type: 'email', id, label, required, placeholder, rules, condition });
461
- }
462
- number(id, label, { required = false, min = null, max = null, rules = [], condition = null } = {}) {
463
- return this._add({ type: 'number', id, label, required, min, max, rules, condition });
464
- }
465
- textarea(id, label, { required = false, rows = 4, placeholder = '', rules = [], condition = null } = {}) {
466
- return this._add({ type: 'textarea', id, label, required, rows, placeholder, rules, condition });
467
- }
468
- select(id, label, options, { required = false, rules = [], condition = null } = {}) {
469
- const opts = options.map(o => Array.isArray(o) ? { label: o[0], value: o[1] } : { label: o, value: o });
470
- return this._add({ type: 'select', id, label, required, options: opts, rules, condition });
471
- }
472
- radio(id, label, options, { required = false, condition = null } = {}) {
473
- const opts = options.map(o => Array.isArray(o) ? { label: o[0], value: o[1] } : { label: o, value: o });
474
- return this._add({ type: 'radio', id, label, required, options: opts, condition });
475
- }
476
- checkbox(id, label, { condition = null } = {}) {
477
- return this._add({ type: 'checkbox', id, label, condition });
478
- }
479
- hidden(id, value) {
480
- return this._add({ type: 'hidden', id, value });
481
- }
482
-
483
- build() {
484
- return renderForm(this);
485
- }
486
  }
487
 
488
- // ─── HTML Renderer ─────────────────────��─────────────────────────────────── //
489
-
490
  function _fEsc(s) {
491
- return String(s)
492
- .replace(/&/g, '&amp;')
493
- .replace(/</g, '&lt;')
494
- .replace(/>/g, '&gt;')
495
- .replace(/"/g, '&quot;');
496
- }
497
-
498
- function renderRules(rules) {
499
- return JSON.stringify(rules || []);
500
- }
501
-
502
- function renderCondition(cond) {
503
- if (!cond) return '';
504
- return `data-condition='${JSON.stringify(cond)}'`;
505
  }
 
 
506
 
507
  function renderField(f) {
508
  const condAttr = renderCondition(f.condition);
@@ -510,100 +359,60 @@ function renderField(f) {
510
  const req = f.required ? 'required' : '';
511
  const reqMark = f.required ? '<span class="frm-req">*</span>' : '';
512
  const wrapStyle = f.condition ? ' style="display:none"' : '';
513
-
514
- const wrap = (inner) =>
515
- `<div class="frm-field" data-field-id="${_fEsc(f.id)}" ${condAttr}${wrapStyle}>${inner}</div>`;
516
-
517
  const label = `<label class="frm-label" for="${_fEsc(f.id)}">${_fEsc(f.label)}${reqMark}</label>`;
518
-
519
  switch (f.type) {
520
  case 'hidden':
521
  return `<input type="hidden" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}" value="${_fEsc(f.value)}" />`;
522
- case 'text':
523
- case 'email':
524
- return wrap(`${label}
525
- <input class="frm-input" type="${f.type}" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}"
526
- placeholder="${_fEsc(f.placeholder || '')}" ${req} ${rulesAttr} />`);
527
  case 'number':
528
- return wrap(`${label}
529
- <input class="frm-input" type="number" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}"
530
- ${f.min != null ? `min="${f.min}"` : ''} ${f.max != null ? `max="${f.max}"` : ''}
531
- ${req} ${rulesAttr} />`);
532
  case 'textarea':
533
- return wrap(`${label}
534
- <textarea class="frm-input frm-textarea" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}"
535
- rows="${f.rows || 4}" placeholder="${_fEsc(f.placeholder || '')}" ${req} ${rulesAttr}></textarea>`);
536
  case 'select':
537
- return wrap(`${label}
538
- <select class="frm-input frm-select" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}" ${req} ${rulesAttr}>
539
- <option value="">Select…</option>
540
- ${f.options.map(o => `<option value="${_fEsc(o.value)}">${_fEsc(o.label)}</option>`).join('')}
541
  </select>`);
542
  case 'radio':
543
- return wrap(`${label}
544
- <div class="frm-radio-group">
545
- ${f.options.map(o => `
546
- <label class="frm-radio-label">
547
- <input type="radio" name="${_fEsc(f.id)}" value="${_fEsc(o.value)}" ${req} />
548
- <span>${_fEsc(o.label)}</span>
549
- </label>`).join('')}
550
  </div>`);
551
  case 'checkbox':
552
- return wrap(`<label class="frm-checkbox-label">
553
- <input type="checkbox" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}" />
554
- <span>${_fEsc(f.label)}</span>
555
- </label>`);
556
- default:
557
- return '';
558
  }
559
  }
560
 
561
  function renderForm(form) {
 
562
  const id = form._id;
563
- const fields = form._fields;
564
-
565
- if (form._multiStep && form._steps.length > 0) {
566
- return renderMultiStep(form);
567
- }
568
-
569
- const fieldHtml = fields.map(renderField).join('\n');
570
-
571
- return `
572
- <div class="frm-wrapper" data-formora-id="${_fEsc(id)}">
573
- ${form._title ? `<div class="frm-title">${_fEsc(form._title)}</div>` : ''}
574
- ${form._description ? `<div class="frm-desc">${_fEsc(form._description)}</div>` : ''}
575
  <form class="frm-form" data-formora-id="${_fEsc(id)}" novalidate>
576
- ${fieldHtml}
577
- <div class="frm-actions">
578
- <button type="submit" class="frm-btn-submit">${_fEsc(form._submitLabel)}</button>
579
- </div>
580
  <div class="frm-success" style="display:none">${_fEsc(form._successMsg)}</div>
581
  <div class="frm-errors" style="display:none"></div>
582
- </form>
583
- </div>`;
584
  }
585
 
586
  function renderMultiStep(form) {
587
- const id = form._id;
588
- const steps = form._steps;
589
- const fields = form._fields;
590
- const total = steps.length;
591
-
592
- const stepGroups = steps.map((_, i) => ({
593
- title: steps[i].title,
594
- fields: fields.filter(f => f.stepIndex === i),
595
- }));
596
-
597
- const stepsHtml = stepGroups.map((step, i) => `
598
- <div class="frm-step" data-step="${i}" ${i > 0 ? 'style="display:none"' : ''}>
599
- ${step.title ? `<div class="frm-step-title">${_fEsc(step.title)}</div>` : ''}
600
  ${step.fields.map(renderField).join('\n')}
601
  </div>`).join('\n');
602
-
603
- return `
604
- <div class="frm-wrapper" data-formora-id="${_fEsc(id)}">
605
- ${form._title ? `<div class="frm-title">${_fEsc(form._title)}</div>` : ''}
606
- ${form._description ? `<div class="frm-desc">${_fEsc(form._description)}</div>` : ''}
607
  <div class="frm-progress-label">Step 1 of ${total}</div>
608
  <div class="frm-progress-bar"><div class="frm-progress-fill" id="${_fEsc(id)}-progress" style="width:${Math.round(100/total)}%"></div></div>
609
  <form class="frm-form" data-formora-id="${_fEsc(id)}" novalidate>
@@ -615,881 +424,457 @@ function renderMultiStep(form) {
615
  </div>
616
  <div class="frm-success" style="display:none">${_fEsc(form._successMsg)}</div>
617
  <div class="frm-errors" style="display:none"></div>
618
- </form>
619
- </div>`;
620
  }
621
 
622
- // ─── Runtime: validation + submission ────────────────────────────────────── //
623
-
624
  function attachForms(container) {
625
  container.querySelectorAll('form[data-formora-id]').forEach(form => {
626
- _attachConditions(form);
627
- _attachMultiStep(form);
628
  form.addEventListener('submit', e => {
629
  e.preventDefault();
630
  if (!_validate(form)) return;
631
  const result = _collect(form);
632
  _showSuccess(form);
633
- window.dispatchEvent(new CustomEvent('formora:submit', {
634
- detail: { raw: result.toMessage(), parsed: result },
635
- }));
636
  });
637
  });
638
  }
639
 
640
  function _attachConditions(form) {
641
- const condFields = form.querySelectorAll('[data-condition]');
642
- condFields.forEach(el => {
643
  const cond = JSON.parse(el.dataset.condition);
644
  const watch = form.querySelector(`[name="${CSS.escape(cond.fieldId)}"]`);
645
  if (!watch) return;
646
  const update = () => {
647
- const val = watch.value;
648
- const show = _evalCondition(cond, val);
649
  el.style.display = show ? '' : 'none';
650
- if (!show) {
651
- el.querySelectorAll('input,select,textarea').forEach(i => i.value = '');
652
- }
653
  };
654
- watch.addEventListener('change', update);
655
- watch.addEventListener('input', update);
656
- update();
657
  });
658
  }
659
 
660
  function _evalCondition(cond, val) {
661
  switch (cond.operator) {
662
- case 'eq': return val === cond.value;
663
- case 'neq': return val !== cond.value;
664
  case 'contains': return val.includes(cond.value);
665
- case 'gt': return parseFloat(val) > parseFloat(cond.value);
666
- case 'lt': return parseFloat(val) < parseFloat(cond.value);
667
- default: return true;
668
  }
669
  }
670
 
671
  function _attachMultiStep(form) {
672
  const stepEls = form.querySelectorAll('.frm-step');
673
  if (!stepEls.length) return;
674
-
675
- const total = stepEls.length;
676
- let current = 0;
677
  const formId = form.getAttribute('data-formora-id') || form.closest('[data-formora-id]')?.getAttribute('data-formora-id') || '';
678
  const progress = document.getElementById(`${formId}-progress`);
679
  const label = form.closest('.frm-wrapper')?.querySelector('.frm-progress-label');
680
  const btnBack = form.querySelector('.frm-btn-back');
681
  const btnNext = form.querySelector('.frm-btn-next');
682
  const btnSub = form.querySelector('.frm-btn-submit');
683
-
684
  const goTo = (i) => {
685
- stepEls[current].style.display = 'none';
686
- current = i;
687
- stepEls[current].style.display = '';
688
- if (label) label.textContent = `Step ${current + 1} of ${total}`;
689
- if (progress) progress.style.width = `${Math.round((current + 1) * 100 / total)}%`;
690
- if (btnBack) btnBack.style.display = current > 0 ? '' : 'none';
691
- if (btnNext) btnNext.style.display = current < total - 1 ? '' : 'none';
692
- if (btnSub) btnSub.style.display = current === total - 1 ? '' : 'none';
693
  };
694
-
695
- if (btnNext) btnNext.addEventListener('click', () => {
696
- if (!_validate(form, stepEls[current])) return;
697
- if (current < total - 1) goTo(current + 1);
698
- });
699
- if (btnBack) btnBack.addEventListener('click', () => {
700
- if (current > 0) goTo(current - 1);
701
- });
702
-
703
  goTo(0);
704
  }
705
 
706
- function _validate(form, scope = form) {
707
- let valid = true;
708
  scope.querySelectorAll('input,select,textarea').forEach(el => {
709
  _clearError(el);
710
- const rules = JSON.parse(el.dataset.rules || '[]');
711
- const val = el.value.trim();
712
-
713
  for (const rule of rules) {
714
- let err = null;
715
- if (rule.type === 'required' && !val) err = rule.message;
716
- if (rule.type === 'minLength' && val && val.length < rule.value) err = rule.message;
717
- if (rule.type === 'maxLength' && val && val.length > rule.value) err = rule.message;
718
- if (rule.type === 'min' && val && parseFloat(val) < rule.value) err = rule.message;
719
- if (rule.type === 'max' && val && parseFloat(val) > rule.value) err = rule.message;
720
- if (rule.type === 'email' && val && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) err = rule.message;
721
- if (rule.type === 'regex' && val && !new RegExp(rule.value).test(val)) err = rule.message;
722
- if (err) { _showError(el, err); valid = false; break; }
723
- }
724
-
725
- if (el.required && !val) {
726
- _showError(el, 'This field is required');
727
- valid = false;
728
- }
729
  });
730
  return valid;
731
  }
732
-
733
- function _showError(el, msg) {
734
  el.classList.add('frm-invalid');
735
- const err = document.createElement('div');
736
- err.className = 'frm-error-msg';
737
- err.textContent = msg;
738
  el.parentNode.appendChild(err);
739
  }
740
-
741
  function _clearError(el) {
742
  el.classList.remove('frm-invalid');
743
- el.parentNode?.querySelectorAll('.frm-error-msg').forEach(e => e.remove());
744
  }
745
-
746
  function _collect(form) {
747
- const formId = form.getAttribute('data-formora-id') || form.closest('[data-formora-id]')?.getAttribute('data-formora-id') || '';
748
- const data = {};
749
- const typed = {};
750
-
751
  form.querySelectorAll('input,select,textarea').forEach(el => {
752
  if (!el.name) return;
753
- if (el.type === 'radio' && !el.checked) return;
754
- if (el.type === 'checkbox') {
755
- data[el.name] = el.checked ? 'true' : 'false';
756
- typed[el.name] = el.checked;
757
- return;
758
- }
759
- data[el.name] = el.value;
760
- const num = Number(el.value);
761
- typed[el.name] = el.type === 'number' && !isNaN(num) ? num : el.value;
762
  });
763
-
764
- return new FormResult(formId, data, typed);
765
  }
766
-
767
  function _showSuccess(form) {
768
- form.querySelectorAll('.frm-field,.frm-nav,.frm-step').forEach(el => el.style.display = 'none');
769
- form.querySelector('.frm-actions')?.style && (form.querySelector('.frm-actions').style.display = 'none');
770
- const suc = form.querySelector('.frm-success');
771
- if (suc) suc.style.display = '';
772
  }
773
 
774
- // ─── FormResult ──────────────────────────────────────────────────────────── //
775
-
776
  class FormResult {
777
- constructor(formId, data, typedData) {
778
- this.formId = formId;
779
- this.data = data;
780
- this.typedData = typedData;
781
- }
782
-
783
- toMessage() {
784
- return FORMORA_PREFIX + JSON.stringify({
785
- form_id: this.formId,
786
- data: this.data,
787
- typed_data: this.typedData,
788
- });
789
- }
790
-
791
- asText() {
792
- return Object.entries(this.typedData)
793
- .filter(([, v]) => v !== '' && v !== false)
794
- .map(([k, v]) => `${k.replace(/_/g, ' ')}: ${v}`)
795
- .join(', ');
796
- }
797
  }
798
-
799
- function isFormoraMessage(msg) {
800
- return typeof msg === 'string' && msg.startsWith(FORMORA_PREFIX);
801
- }
802
-
803
  function parseFormoraMessage(msg) {
804
  if (!isFormoraMessage(msg)) return null;
805
- try {
806
- const payload = JSON.parse(msg.slice(FORMORA_PREFIX.length));
807
- return new FormResult(payload.form_id, payload.data, payload.typed_data);
808
- } catch {
809
- return null;
810
- }
811
  }
812
  </script>
813
 
814
  <!-- ── forms.js (inlined) ────────────────────────────────────────────────── -->
815
  <script>
816
  function buildVpnForm() {
817
- return new Form('vpn_issue')
818
- .title('VPN Issue')
819
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required(), Rule.minLength(2)] })
820
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
821
- .select('os', 'Operating System', [['Windows 10/11', 'windows'], ['macOS', 'macos'], ['Linux', 'linux']], { required: true })
822
- .text('vpn_client', 'VPN Client', { placeholder: 'e.g. Cisco AnyConnect, GlobalProtect' })
823
- .textarea('issue', 'Describe the Issue', { required: true, rows: 3, rules: [Rule.required()] })
824
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
825
- .submitLabel('Submit')
826
- .successMessage('Your VPN issue has been logged. Check your inbox for next steps.')
827
- .build();
828
  }
829
-
830
  function buildAccountForm() {
831
- return new Form('account_issue')
832
- .title('Account / Login Issue')
833
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
834
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
835
- .select('issue_type', 'Issue Type', [
836
- ['Account locked', 'locked'],
837
- ['Forgot password', 'forgot_password'],
838
- ['MFA not working', 'mfa'],
839
- ['Access denied', 'access_denied'],
840
- ['Other', 'other'],
841
- ], { required: true })
842
- .textarea('details', 'Additional Details', { rows: 3 })
843
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
844
- .submitLabel('Submit')
845
- .successMessage('Account issue logged. Our team will be in touch shortly.')
846
- .build();
847
  }
848
-
849
  function buildHardwareForm() {
850
- return new Form('hardware_issue')
851
- .title('Hardware Issue')
852
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
853
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
854
- .select('device_type', 'Device Type', [
855
- ['Laptop', 'laptop'], ['Desktop', 'desktop'], ['Monitor', 'monitor'],
856
- ['Keyboard / Mouse', 'peripheral'], ['Printer', 'printer'],
857
- ['Headset / Webcam', 'audio_video'], ['Other', 'other'],
858
- ], { required: true })
859
- .text('device_model', 'Device Model / Asset Tag', { placeholder: 'e.g. Dell XPS 15, Asset #12345' })
860
- .textarea('issue', 'Describe the Problem', { required: true, rows: 3, rules: [Rule.required()] })
861
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
862
- .submitLabel('Submit')
863
- .successMessage('Hardware issue logged. IT will assess and follow up.')
864
- .build();
865
  }
866
-
867
  function buildSoftwareForm() {
868
- return new Form('software_issue')
869
- .title('Software Issue')
870
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
871
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
872
- .text('application', 'Application Name', { required: true, placeholder: 'e.g. Microsoft Teams, Slack', rules: [Rule.required()] })
873
- .select('issue_type', 'Issue Type', [
874
- ['App crashing', 'crash'], ['Cannot install', 'install'],
875
- ['Licence / activation', 'licence'], ['Performance issues', 'perf'],
876
- ['Error message', 'error'], ['Other', 'other'],
877
- ], { required: true })
878
- .text('error_code', 'Error Code (if any)', { placeholder: 'Optional' })
879
- .textarea('details', 'Describe the Issue', { required: true, rows: 3, rules: [Rule.required()] })
880
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
881
- .submitLabel('Submit')
882
- .successMessage("Software issue logged. We'll be in touch.")
883
- .build();
884
  }
885
-
886
  function buildNetworkForm() {
887
- return new Form('network_issue')
888
- .title('Network / Connectivity Issue')
889
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
890
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
891
- .select('connection_type', 'Connection Type', [
892
- ['Wi-Fi', 'wifi'], ['Ethernet', 'ethernet'], ['VPN-only', 'vpn'], ['Both Wi-Fi and Ethernet', 'both'],
893
- ], { required: true })
894
- .select('issue_type', 'Issue Type', [
895
- ['No internet', 'no_internet'], ['Slow connection', 'slow'],
896
- ['Cannot reach internal systems', 'internal'], ['DNS issues', 'dns'],
897
- ['Packet loss / drops', 'drops'], ['Other', 'other'],
898
- ], { required: true })
899
- .textarea('details', 'Additional Details', { rows: 3 })
900
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
901
- .submitLabel('Submit')
902
- .successMessage('Network issue logged. Our team will investigate.')
903
- .build();
904
  }
905
-
906
  function buildEmailForm() {
907
- return new Form('email_issue')
908
- .title('Email / M365 Issue')
909
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
910
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
911
- .select('issue_type', 'Issue Type', [
912
- ['Outlook not working', 'outlook'], ['Cannot send/receive email', 'send_recv'],
913
- ['Calendar issues', 'calendar'], ['Teams issue', 'teams'],
914
- ['Shared mailbox', 'shared_mailbox'], ['Other', 'other'],
915
- ], { required: true })
916
- .textarea('details', 'Describe the Issue', { required: true, rows: 3, rules: [Rule.required()] })
917
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
918
- .submitLabel('Submit')
919
- .successMessage("Email issue logged. We'll get this sorted.")
920
- .build();
921
  }
922
-
923
  function buildAccessForm() {
924
- return new Form('access_request')
925
- .title('Access Request')
926
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
927
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
928
- .email('manager_email', "Manager's Email", { required: true, rules: [Rule.required(), Rule.email()] })
929
- .text('system_resource', 'System / Resource Requested', { required: true, placeholder: 'e.g. Salesforce, HR Drive, Azure portal', rules: [Rule.required()] })
930
- .textarea('business_justification', 'Business Justification', { required: true, rows: 3, rules: [Rule.required()] })
931
- .select('access_level', 'Access Level', [['Read Only', 'read'], ['Read/Write', 'readwrite'], ['Admin', 'admin']], { required: true })
932
- .submitLabel('Submit Request')
933
- .successMessage('Access request submitted. Your manager will receive an approval email.')
934
- .build();
935
  }
936
-
937
  function buildProcurementForm() {
938
- return new Form('procurement_request')
939
- .title('IT Procurement Request')
940
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
941
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
942
- .email('manager_email', 'Approving Manager Email', { required: true, rules: [Rule.required(), Rule.email()] })
943
- .text('item_description', 'Item / Equipment Required', { required: true, placeholder: 'e.g. MacBook Pro 14", USB-C hub', rules: [Rule.required()] })
944
- .number('quantity', 'Quantity', { required: true, min: 1, rules: [Rule.required(), Rule.min(1)] })
945
- .textarea('business_justification', 'Business Justification', { required: true, rows: 3, rules: [Rule.required()] })
946
- .submitLabel('Submit Request')
947
- .successMessage('Purchase request submitted. Approval email sent to your manager.')
948
- .build();
949
  }
950
-
951
  function buildOnboardingForm() {
952
- return new Form('onboarding_setup')
953
- .title('New Employee Onboarding')
954
  .step('Personal Details')
955
- .text('employee_name', 'Full Name', { required: true, rules: [Rule.required()] })
956
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
957
- .text('department', 'Department', { required: true, rules: [Rule.required()] })
958
- .text('job_title', 'Job Title', { required: true, rules: [Rule.required()] })
959
  .step('Setup Preferences')
960
- .select('device_preference', 'Device Preference', [
961
- ['MacBook Pro', 'macbook_pro'], ['MacBook Air', 'macbook_air'],
962
- ['Windows Laptop', 'windows_laptop'], ['Desktop', 'desktop'],
963
- ], { required: true })
964
- .select('start_date_readiness', 'Start Date', [
965
- ['Within 1 week', '1week'], ['1–2 weeks', '2weeks'],
966
- ['2–4 weeks', '4weeks'], ['More than a month', 'month_plus'],
967
- ])
968
- .textarea('additional_requirements', 'Any Additional Requirements?', { rows: 3 })
969
- .submitLabel('Submit Onboarding Request')
970
- .successMessage('Onboarding request received! IT will prepare your setup before day one.')
971
- .build();
972
  }
973
-
974
  function buildGenericForm() {
975
- return new Form('generic_incident')
976
- .title('IT Support Request')
977
- .text('employee_name', 'Your Name', { required: true, rules: [Rule.required()] })
978
- .email('email', 'Work Email', { required: true, rules: [Rule.required(), Rule.email()] })
979
- .textarea('issue', 'Describe Your Issue', { required: true, rows: 4, rules: [Rule.required(), Rule.minLength(10)] })
980
- .select('urgency', 'Urgency', [['Low', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Critical', 'critical']], { required: true })
981
- .submitLabel('Submit')
982
- .successMessage('Issue logged. Our team will be in touch shortly.')
983
- .build();
984
  }
985
-
986
  const FORM_MAP = {
987
- vpn: buildVpnForm,
988
- account: buildAccountForm,
989
- hardware: buildHardwareForm,
990
- software: buildSoftwareForm,
991
- network: buildNetworkForm,
992
- email: buildEmailForm,
993
- access: buildAccessForm,
994
- procurement: buildProcurementForm,
995
- onboarding: buildOnboardingForm,
996
- generic_incident: buildGenericForm,
997
  };
998
-
999
- function dispatchForm(intent) {
1000
- const builder = FORM_MAP[intent];
1001
- return builder ? builder() : null;
1002
- }
1003
  </script>
1004
 
1005
  <!-- ── intent.js (inlined) ───────────────────────────────────────────────── -->
1006
  <script>
1007
  const GREETING_RE = /^\s*(hi+|hey+|hello+|howdy|yo+|sup|good (morning|afternoon|evening)|thanks?|thank you|cheers|bye|goodbye|ok|okay|cool|great|sure|got it|noted)\s*[!?.]*\s*$/i;
1008
-
1009
  const INTENT_KEYWORDS = {
1010
- vpn: ['vpn', 'remote', 'tunnel', 'connect remotely', 'cisco anyconnect', 'wireguard', 'openvpn', 'remote access'],
1011
- account: ['password', 'locked', 'login', 'reset', 'access denied', 'sign in', 'forgot', 'credentials', 'mfa', '2fa', 'account'],
1012
- hardware: ['laptop', 'keyboard', 'mouse', 'monitor', 'printer', 'screen', 'device', 'battery', 'charger', 'headset', 'webcam'],
1013
- software: ['install', 'app', 'software', 'crash', 'update', 'license', 'application', 'error code', 'not opening', 'uninstall'],
1014
- network: ['internet', 'wifi', 'slow', 'network', 'connection', 'no internet', 'packet loss', 'latency', 'dns', 'ethernet'],
1015
- email: ['email', 'outlook', 'calendar', 'teams', 'mailbox', 'meeting', 'distribution list', 'signature', 'spam'],
1016
- access: ['permission', 'folder', 'drive', 'share', 'access', 'shared drive', 'read only', 'write access', 'file server'],
1017
- procurement: ['order', 'request', 'new laptop', 'equipment', 'purchase', 'budget', 'quote', 'hardware request'],
1018
- onboarding: ['new joiner', 'joined', 'first day', 'setup', 'onboard', 'new employee', 'getting started'],
1019
  };
1020
-
1021
  function detectIntent(message) {
1022
- const stripped = message.trim();
1023
  if (GREETING_RE.test(stripped)) return 'greeting';
1024
-
1025
- const lower = stripped.toLowerCase();
1026
- const scores = {};
1027
-
1028
- for (const [intent, keywords] of Object.entries(INTENT_KEYWORDS)) {
1029
- scores[intent] = keywords.filter(kw => lower.includes(kw)).length;
1030
- }
1031
-
1032
- const best = Object.keys(scores).reduce((a, b) => scores[a] >= scores[b] ? a : b);
1033
- const bestScore = scores[best];
1034
-
1035
- if (bestScore === 0) {
1036
- if (stripped.split(/\s+/).length <= 4) return 'greeting';
1037
- return 'generic_incident';
1038
- }
1039
-
1040
  return best;
1041
  }
1042
-
1043
  const INTRO = {
1044
- greeting: "Hey! I'm DeskFlow, your IT support assistant. What can I help you with today?",
1045
- vpn: "VPN troubles β€” not fun! Let me grab a few details so I can get you sorted.",
1046
- account: "Account issues are stressful, but we'll get you sorted quickly.",
1047
- hardware: "Hardware gremlins! Let me pull up a quick form to get the specifics.",
1048
- software: "Software being awkward? Let me ask you a few things so I can give you the right fix.",
1049
- network: "Connection problems are the worst. Let me get some details to help diagnose this.",
1050
- email: "Email issues β€” always at the worst time! Let me get some details.",
1051
- access: "Access requests sorted quickly β€” fill in the details below.",
1052
- procurement: "Happy to help with your purchase request.",
1053
- onboarding: "Welcome! Let's get everything set up properly.",
1054
- generic_incident: "I'm here to help! Let me take the details so I can find the best solution.",
1055
  };
1056
-
1057
- function getIntroMessage(intent) {
1058
- return INTRO[intent] || INTRO.generic_incident;
1059
- }
1060
-
1061
  const RUNBOOKS = {
1062
- vpn_issue: `VPN Runbook:
1063
- - Likely causes: credential expiry, MFA drift, firewall blocking UDP 1194/TCP 443, client misconfiguration.
1064
- - Steps: verify internet; restart VPN client from tray; check credentials; re-sync MFA app; reboot; flush DNS (ipconfig /flushdns on Windows); disable conflicting firewall/AV; reinstall VPN client.
1065
- - Escalate if: unresolved after reinstall, multiple users affected, or Critical urgency.`,
1066
-
1067
- account_issue: `Account Runbook:
1068
- - Likely causes: too many failed logins (auto-lockout), expired password, MFA device time drift.
1069
- - Steps: wait 15 min for auto-unlock; try self-service password reset; re-sync MFA app; contact IT to manually unlock; request Temporary Access Pass (TAP) for MFA bypass.
1070
- - Escalate: High/Critical urgency or if account is admin-level.`,
1071
-
1072
- hardware_issue: `Hardware Runbook:
1073
- - Likely causes: driver fault, physical damage, hardware component failure.
1074
- - Steps: power cycle device; test peripheral on another port/machine; check Device Manager for errors; update driver; run built-in diagnostics (Dell SupportAssist, Apple Diagnostics).
1075
- - Escalate: physical damage or persistent failure β€” request hardware assessment/replacement.`,
1076
-
1077
- software_issue: `Software Runbook:
1078
- - Likely causes: corrupt install, missing dependencies, licence expiry, insufficient permissions.
1079
- - Steps: close fully (check tray); run as Administrator; check for updates; uninstall, delete AppData, reinstall; verify disk space (>10 GB free); check licence validity.
1080
- - Escalate: business-critical application, High/Critical urgency.`,
1081
-
1082
- network_issue: `Network Runbook:
1083
- - Likely causes: DHCP lease failure, DNS misconfiguration, physical link issue.
1084
- - Steps: check other devices; restart network adapter; ipconfig /release + /renew + /flushdns; try Ethernet if on Wi-Fi; nslookup to test DNS; reboot router/switch (home).
1085
- - Escalate: office-wide outage, infrastructure suspected.`,
1086
-
1087
- email_issue: `Email/M365 Runbook:
1088
- - Likely causes: Outlook profile corruption, OST file corruption, M365 service outage.
1089
- - Steps: check M365 Service Health for outages; test Outlook Web Access (OWA); restart Outlook (close from tray); run in Safe Mode (outlook.exe /safe); create new Outlook profile; clear Teams cache + reinstall.
1090
- - Escalate: if OWA also failing, likely tenant-wide issue β€” contact Microsoft.`,
1091
-
1092
- access_request: `Access Request Runbook:
1093
- - Process: manager approval required within 48 h; IT provisions within 1 business day after approval.
1094
- - Provisioning: role-based access following least-privilege; audit log entry created.
1095
- - Escalation: urgent access β€” provide business justification; emergency access via IT helpdesk.`,
1096
-
1097
- procurement_request: `Procurement Runbook:
1098
- - Process: manager approval β†’ PO raised β†’ procurement team orders β†’ delivery 5–10 business days (in-stock).
1099
- - Status updates emailed at: Approved β†’ PO Raised β†’ Dispatched β†’ Delivered.
1100
- - Escalation: urgent procurement requires budget-holder sign-off and IT director approval.`,
1101
-
1102
- onboarding_setup: `Onboarding Runbook:
1103
- - Timeline: M365 account created 1–2 days before start; device imaged and configured before day one.
1104
- - Day one: IT walkthrough β€” login, MFA setup, VPN, key applications.
1105
- - Access provisioned based on manager approvals (role-based).
1106
- - IT checks in on day 3 for outstanding issues.`,
1107
-
1108
- generic_incident: `General IT Runbook:
1109
- - Start: restart affected app; if persisting, reboot device; note error messages/codes.
1110
- - Check IT status page for known incidents; check if other users affected.
1111
- - Collect: device model, OS, application version, steps to reproduce, error screenshots.
1112
- - Escalate: High/Critical urgency or if multiple users impacted.`,
1113
  };
1114
-
1115
- function getRunbookContext(formId) {
1116
- return RUNBOOKS[formId] || RUNBOOKS.generic_incident;
1117
- }
1118
-
1119
  function buildChatSystemPrompt() {
1120
- return (
1121
- 'You are DeskFlow, a friendly IT support assistant. ' +
1122
- 'Reply conversationally in 1–3 short sentences. ' +
1123
- 'Do NOT provide technical steps yet β€” just respond naturally to what the user said.'
1124
- );
1125
  }
1126
-
1127
- function buildResolutionSystemPrompt(formId, requestNumber) {
1128
- const context = getRunbookContext(formId);
1129
- const ticketLine = requestNumber
1130
- ? `The user's request has been logged as **${requestNumber}**. Open your reply by mentioning this number warmly. `
1131
- : '';
1132
-
1133
- return (
1134
- `You are DeskFlow, a friendly IT support assistant. ${ticketLine}Be concise, warm, and practical.\n\n` +
1135
- `Reply with: 1) Likely cause 2) Numbered resolution steps 3) Escalation note if urgency is High or Critical.\n\n` +
1136
- `Runbook context:\n${context}`
1137
- );
1138
  }
1139
-
1140
  function formResultToPrompt(formResult) {
1141
- const title = (formResult.formId || 'IT Support Request').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1142
- const lines = [`IT Support Request: ${title}`];
1143
- for (const [k, v] of Object.entries(formResult.typedData || {})) {
1144
- if (v !== '' && v !== false && v != null) {
1145
- lines.push(`- ${k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}: ${v}`);
1146
- }
1147
- }
1148
  return lines.join('\n');
1149
  }
1150
-
1151
  const TEMPLATES = {
1152
- vpn_issue: `## VPN Issue β€” Suggested Resolution
1153
-
1154
- **Likely cause:** VPN client configuration issue, credential expiry, or network port blocking.
1155
-
1156
- **Steps to resolve:**
1157
- 1. Verify your internet connection is working.
1158
- 2. Restart the VPN client completely (close from system tray).
1159
- 3. Ensure your username and password are correct and not expired.
1160
- 4. Re-sync your MFA authenticator app β€” check device clock is accurate.
1161
- 5. Reboot your device and try again.
1162
- 6. On Windows: run \`ipconfig /flushdns\` in an elevated command prompt.
1163
- 7. Check that your firewall is not blocking the VPN port (UDP 1194 / TCP 443).
1164
- 8. Reinstall the VPN client from the IT software portal.`,
1165
-
1166
- account_issue: `## Account Issue β€” Suggested Resolution
1167
-
1168
- **Likely cause:** Account lockout, expired password, or MFA misconfiguration.
1169
-
1170
- **Steps to resolve:**
1171
- 1. Wait 15 minutes β€” most accounts auto-unlock after a lockout period.
1172
- 2. Verify Caps Lock is off and you are using the correct username format.
1173
- 3. Attempt a self-service password reset at the IT portal.
1174
- 4. Re-sync your MFA authenticator app.
1175
- 5. Contact IT to manually unlock if auto-unlock has not worked.
1176
- 6. If MFA is the issue, IT can provide a Temporary Access Pass (TAP).`,
1177
-
1178
- hardware_issue: `## Hardware Issue β€” Suggested Resolution
1179
-
1180
- **Likely cause:** Driver issue, physical damage, or hardware component failure.
1181
-
1182
- **Steps to resolve:**
1183
- 1. Power cycle the device completely (hold power for 10 seconds, then restart).
1184
- 2. For peripherals: try a different USB port and test on another machine.
1185
- 3. Check Device Manager for driver errors (yellow exclamation marks).
1186
- 4. Update the device driver from the manufacturer's website.
1187
- 5. Run built-in diagnostics (Dell SupportAssist, HP Support Assistant, or Apple Diagnostics).
1188
- 6. If the issue persists, contact IT for a hardware assessment.`,
1189
-
1190
- software_issue: `## Software Issue β€” Suggested Resolution
1191
-
1192
- **Likely cause:** Corrupt installation, missing dependencies, licence expiry, or permissions issue.
1193
-
1194
- **Steps to resolve:**
1195
- 1. Close the application completely (check the system tray) and reopen.
1196
- 2. Run the application as Administrator.
1197
- 3. Check for available updates and install them.
1198
- 4. Reinstall: uninstall, delete leftover app data, then reinstall.
1199
- 5. Verify you have enough disk space (minimum 10 GB free).
1200
- 6. Check that your software licence is still valid.`,
1201
-
1202
- network_issue: `## Network Issue β€” Suggested Resolution
1203
-
1204
- **Likely cause:** DHCP lease failure, DNS misconfiguration, or physical connectivity issue.
1205
-
1206
- **Steps to resolve:**
1207
- 1. Check if other devices on the same network are affected.
1208
- 2. Restart your network adapter.
1209
- 3. On Windows: run \`ipconfig /release\`, then \`/renew\`, then \`/flushdns\`.
1210
- 4. Try connecting via Ethernet if you are on Wi-Fi.
1211
- 5. Test DNS: run \`nslookup google.com\` in Command Prompt.
1212
- 6. Reboot your router/switch if you are at home.`,
1213
-
1214
- email_issue: `## Email / Communications Issue β€” Suggested Resolution
1215
-
1216
- **Likely cause:** Outlook profile corruption, OST file corruption, or M365 service issue.
1217
-
1218
- **Steps to resolve:**
1219
- 1. Check the Microsoft 365 service health page for known outages.
1220
- 2. Test Outlook Web Access (OWA) β€” if OWA works, the issue is with the desktop client.
1221
- 3. Restart Outlook from the system tray.
1222
- 4. Run Outlook in Safe Mode: Win+R β†’ \`outlook.exe /safe\`.
1223
- 5. Create a new Outlook profile: Control Panel β†’ Mail β†’ Show Profiles β†’ Add.
1224
- 6. For Teams issues: clear the Teams cache and reinstall.`,
1225
-
1226
- access_request: `## Access Request β€” Next Steps
1227
-
1228
- **Your access request has been received.**
1229
-
1230
- 1. An approval email will be sent to your manager.
1231
- 2. Your manager has **48 hours** to approve or decline.
1232
- 3. Once approved, IT will provision access within **1 business day**.
1233
- 4. You will receive a confirmation email when access has been granted.`,
1234
-
1235
- procurement_request: `## Procurement Request β€” Next Steps
1236
-
1237
- **Your purchase request has been received.**
1238
-
1239
- 1. An approval request will be sent to your approving manager.
1240
- 2. Upon approval, IT Procurement will raise a Purchase Order (PO).
1241
- 3. Standard delivery: **5–10 business days** for in-stock items.
1242
- 4. Status updates emailed at each stage: Approved β†’ PO Raised β†’ Dispatched β†’ Delivered.`,
1243
-
1244
- onboarding_setup: `## Onboarding Setup β€” What to Expect
1245
-
1246
- **Your onboarding request has been received.**
1247
-
1248
- 1. **Account creation**: Your M365 account and email will be created 1–2 days before start date.
1249
- 2. **Device provisioning**: Your laptop/desktop will be imaged and configured before day one.
1250
- 3. **Day-one**: IT will walk you through login, MFA setup, and VPN connection.
1251
- 4. **Access**: All requested system access provisioned based on your manager's approvals.
1252
- 5. **Follow-up**: IT will check in on day 3 to address any outstanding issues.`,
1253
-
1254
- generic_incident: `## IT Incident β€” Suggested Resolution
1255
-
1256
- **Likely cause:** Based on your description, this may be a software, network, or configuration issue.
1257
-
1258
- **Steps to resolve:**
1259
- 1. Restart the affected application or service.
1260
- 2. Reboot your device if the issue persists.
1261
- 3. Check if other users are affected.
1262
- 4. Note any error messages or codes.
1263
- 5. Check the IT status page for known incidents.`,
1264
  };
1265
-
1266
  const URGENCY_NOTE = {
1267
- high: '\n\n> **Escalation**: Marked **High** urgency. If unresolved within 1 hour, contact the IT helpdesk directly.',
1268
- critical: '\n\n> **Escalation**: Marked **CRITICAL**. Contact the IT helpdesk **immediately** or call the emergency support line.',
1269
  };
1270
-
1271
- function generateTemplateResponse(formResult, requestNumber = '') {
1272
- const formId = formResult.formId || 'generic_incident';
1273
- const urgency = String((formResult.typedData || {}).urgency || '').toLowerCase();
1274
- let response = TEMPLATES[formId] || TEMPLATES.generic_incident;
1275
-
1276
- if (URGENCY_NOTE[urgency]) response += URGENCY_NOTE[urgency];
1277
- if (requestNumber) {
1278
- response = `I've logged your request as **${requestNumber}**. Here's what I recommend:\n\n` + response;
1279
- }
1280
  return response;
1281
  }
1282
  </script>
1283
 
1284
  <!-- ── Main app logic ─────────────────────────────────────────────────────── -->
1285
  <script>
1286
- // ── DOM refs ──────────────────────────────────────────────────────────────── //
1287
  const messagesEl = document.getElementById('messages');
1288
  const emptyState = document.getElementById('empty-state');
1289
  const msgInput = document.getElementById('msg-input');
1290
  const sendBtn = document.getElementById('send-btn');
1291
- const statusEl = document.getElementById('model-status');
1292
- const progressWrap = document.getElementById('model-progress-wrap');
1293
- const progressBar = document.getElementById('model-progress-bar');
1294
- const statusWrap = document.getElementById('model-status-wrap');
1295
 
1296
- // ── Ticket counter ────────────────────────────────────────────────────────── //
1297
  let _ticketSeq = Math.floor(Math.random() * 9000) + 1000;
1298
  function nextTicket() { return `INC-${++_ticketSeq}`; }
1299
 
1300
- // ── LLM reference (set by WebGPU module) ─────────────────────────────────── //
1301
- window._llm = { status: 'idle', generator: null };
1302
  window._llmGenerate = null;
1303
 
1304
- // ── Helpers ───────────────────────────────────────────────────────────────── //
1305
  function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1306
-
1307
- function scrollBottom() {
1308
- requestAnimationFrame(() => messagesEl.scrollTop = messagesEl.scrollHeight);
1309
- }
1310
-
1311
- function hideEmptyState() {
1312
- emptyState.style.display = 'none';
1313
- }
1314
-
1315
  function setLoading(on) {
1316
- sendBtn.disabled = on;
1317
- msgInput.disabled = on;
1318
  sendBtn.style.opacity = on ? '0.5' : '1';
1319
  }
1320
 
1321
- // ── Bot row factory ───────────────────────────────────────────────────────── //
1322
  function addBotRow(innerHTML) {
1323
- const wrap = document.createElement('div');
1324
- wrap.className = 'flex justify-start';
1325
-
1326
- const row = document.createElement('div');
1327
- row.className = 'flex items-start gap-3 max-w-3xl';
1328
-
1329
  const avatar = document.createElement('div');
1330
  avatar.className = 'w-7 h-7 rounded-lg flex-shrink-0 flex items-center justify-center mt-0.5';
1331
  avatar.style.cssText = 'background:linear-gradient(135deg,#1f6feb,#388bfd)';
1332
  avatar.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3"/><path d="M13 21l2-2 4 4 2-2-4-4 2-2-6-1z"/></svg>`;
1333
-
1334
  const bubble = document.createElement('div');
1335
  bubble.className = 'rounded-2xl rounded-tl-sm px-4 py-3 text-sm leading-relaxed';
1336
  bubble.style.cssText = 'background:#1c2128;border:1px solid #30363d;color:#adbac7;max-width:44rem;flex:1';
1337
  bubble.innerHTML = innerHTML;
1338
-
1339
- row.appendChild(avatar);
1340
- row.appendChild(bubble);
1341
- wrap.appendChild(row);
1342
- messagesEl.appendChild(wrap);
1343
- scrollBottom();
1344
- return bubble;
1345
  }
1346
-
1347
  function addUserBubble(text) {
1348
- const wrap = document.createElement('div');
1349
- wrap.className = 'flex justify-end';
1350
  const bubble = document.createElement('div');
1351
  bubble.className = 'rounded-2xl rounded-tr-sm px-4 py-3 text-sm leading-relaxed';
1352
  bubble.style.cssText = 'background:linear-gradient(135deg,#1f6feb,#388bfd);color:white;max-width:32rem';
1353
- bubble.textContent = text;
1354
- wrap.appendChild(bubble);
1355
- messagesEl.appendChild(wrap);
1356
- scrollBottom();
1357
- }
1358
-
1359
- function addTextBubble(text) {
1360
- return addBotRow(`<span style="color:#adbac7">${esc(text)}</span>`);
1361
- }
1362
-
1363
- function addMarkdownBubble(md) {
1364
- return addBotRow(`<div class="md-body">${marked.parse(md)}</div>`);
1365
  }
 
 
 
1366
 
1367
- function addTyping() {
1368
- return addBotRow('<div class="flex items-center gap-1.5 py-1"><span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></div>');
1369
- }
1370
-
1371
- // ── Form injection ────────────────────────────────────────────────��───────── //
1372
  function injectForm(formHtml, intent) {
1373
  const bubble = addBotRow('');
1374
  bubble.style.padding = '12px';
1375
  bubble.innerHTML = formHtml;
1376
-
1377
  const formEl = bubble.querySelector('form[data-formora-id]');
1378
  const formId = formEl?.getAttribute('data-formora-id');
1379
-
1380
  attachForms(bubble);
1381
-
1382
  function onFormoraSubmit(e) {
1383
  const { parsed } = e.detail;
1384
  if (parsed.formId !== formId) return;
1385
-
1386
  window.removeEventListener('formora:submit', onFormoraSubmit);
1387
-
1388
  const requestNumber = nextTicket();
1389
-
1390
  const pill = document.createElement('div');
1391
- pill.className = 'inc-pill';
1392
- pill.textContent = `βœ“ ${requestNumber}`;
1393
  bubble.insertBefore(pill, bubble.firstChild);
1394
-
1395
  handleFormSubmit(parsed, requestNumber, intent);
1396
  }
1397
-
1398
  window.addEventListener('formora:submit', onFormoraSubmit);
1399
  scrollBottom();
1400
  return bubble;
1401
  }
1402
 
1403
- // ── Form submit β†’ LLM resolution ─────────────────────────────────────────── //
1404
  function handleFormSubmit(formResult, requestNumber, intent) {
1405
  const systemPrompt = buildResolutionSystemPrompt(formResult.formId, requestNumber);
1406
  const userPrompt = formResultToPrompt(formResult);
1407
  const templateResponse = generateTemplateResponse(formResult, requestNumber);
1408
-
1409
  const typing = addTyping();
1410
-
1411
- function showTemplate() {
1412
- typing.closest('.flex.justify-start')?.remove();
1413
- addMarkdownBubble(templateResponse);
1414
- }
1415
-
1416
  const llmGenerate = window._llmGenerate;
1417
- if (typeof llmGenerate !== 'function' || window._llm?.status !== 'ready') {
1418
- showTemplate();
1419
- return;
1420
- }
1421
-
1422
  typing.closest('.flex.justify-start')?.remove();
1423
  const chatBubble = addBotRow('<div class="md-body streaming-cursor"></div>');
1424
  const innerEl = chatBubble.querySelector('.md-body');
1425
-
1426
  llmGenerate(
1427
  systemPrompt, userPrompt, templateResponse,
1428
- (acc) => { innerEl.className = 'md-body streaming-cursor'; innerEl.innerHTML = marked.parse(acc); scrollBottom(); },
1429
- (final) => { innerEl.className = 'md-body'; innerEl.innerHTML = marked.parse(final || templateResponse); scrollBottom(); },
1430
  (_t) => { chatBubble.remove(); addMarkdownBubble(templateResponse); },
1431
  );
1432
  }
1433
 
1434
- // ── Send message ──────────────────────────────────────────────────────────── //
1435
  async function sendMessage() {
1436
  const text = msgInput.value.trim();
1437
  if (!text) return;
1438
-
1439
  hideEmptyState();
1440
  addUserBubble(text);
1441
- msgInput.value = '';
1442
- msgInput.style.height = 'auto';
1443
  setLoading(true);
1444
-
1445
  const typingBubble = addTyping();
1446
  const intent = detectIntent(text);
1447
  const templateChat = getIntroMessage(intent);
1448
  const formHtml = dispatchForm(intent);
1449
-
1450
  await new Promise(r => setTimeout(r, 180));
1451
  typingBubble.closest('.flex.justify-start')?.remove();
1452
-
1453
  const chatBubble = addTextBubble(templateChat);
1454
-
1455
  const injectedBubble = formHtml ? injectForm(formHtml, intent) : null;
1456
  setLoading(false);
1457
-
1458
- // Focus: first visible field in the form, or back to chat input
1459
  if (injectedBubble) {
1460
- const firstField = injectedBubble.querySelector(
1461
- 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])'
1462
- );
1463
- firstField?.focus();
1464
  } else {
1465
  msgInput.focus();
1466
  }
1467
-
1468
  const llmGenerate = window._llmGenerate;
1469
  if (typeof llmGenerate !== 'function' || window._llm?.status !== 'ready') return;
1470
-
1471
  chatBubble.innerHTML = '<div class="md-body streaming-cursor"></div>';
1472
  const innerEl = chatBubble.querySelector('.md-body');
1473
-
1474
  llmGenerate(
1475
  buildChatSystemPrompt(), text, templateChat,
1476
- (acc) => { innerEl.className = 'md-body streaming-cursor'; innerEl.innerHTML = marked.parse(acc); scrollBottom(); },
1477
- (final) => { innerEl.className = 'md-body'; innerEl.innerHTML = marked.parse(final || templateChat); scrollBottom(); },
1478
- (_t) => { chatBubble.textContent = templateChat; },
1479
  );
1480
  }
1481
 
1482
- // ── Event listeners ───────────────────────────────────────────────────────── //
1483
- msgInput.addEventListener('keydown', e => {
1484
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1485
- });
1486
  sendBtn.addEventListener('click', sendMessage);
1487
-
1488
  document.querySelectorAll('.chip').forEach(chip => {
1489
- chip.addEventListener('click', () => {
1490
- msgInput.value = chip.dataset.msg;
1491
- sendMessage();
1492
- });
1493
  });
1494
 
1495
  // ── Model switcher ────────────────────────────────────────────────────────── //
@@ -1497,17 +882,11 @@ document.querySelectorAll('.model-pill').forEach(pill => {
1497
  pill.addEventListener('click', () => {
1498
  if (pill.classList.contains('active')) return;
1499
  if (typeof window._llmSwitchModel !== 'function') return;
1500
-
1501
- document.querySelectorAll('.model-pill').forEach(p => {
1502
- p.classList.remove('active');
1503
- p.disabled = true;
1504
- });
1505
  pill.classList.add('active');
1506
-
1507
  window._llmSwitchModel(
1508
- pill.dataset.modelId,
1509
- pill.dataset.modelLabel,
1510
- () => document.querySelectorAll('.model-pill').forEach(p => { p.disabled = false; }),
1511
  );
1512
  });
1513
  });
@@ -1530,14 +909,9 @@ const progressBar = document.getElementById('model-progress-bar');
1530
  const statusWrap = document.getElementById('model-status-wrap');
1531
 
1532
  function setStatusUI(text, color, pct) {
1533
- statusEl.textContent = text;
1534
- statusEl.style.color = color;
1535
- if (pct != null) {
1536
- progressWrap.classList.remove('hidden');
1537
- progressBar.style.width = Math.round(pct) + '%';
1538
- } else {
1539
- progressWrap.classList.add('hidden');
1540
- }
1541
  }
1542
 
1543
  function bindGenerate() {
@@ -1545,15 +919,15 @@ function bindGenerate() {
1545
  if (!window._llm.generator) { onFallback(templateFallback); return; }
1546
  try {
1547
  const messages = [
1548
- { role: 'system', content: systemPrompt },
1549
- { role: 'user', content: userPrompt },
1550
  ];
1551
  let accumulated = '';
1552
  const streamer = new TextStreamer(window._llm.generator.tokenizer, {
1553
  skip_prompt: true,
1554
  callback_function: (token) => { accumulated += token; onToken(accumulated); },
1555
  });
1556
- // LFM2.5 requires num_logits_to_keep=1 as a mandatory ONNX input
1557
  const isLFM = (window._llm.modelId || '').includes('LFM');
1558
  const result = await window._llm.generator(messages, {
1559
  max_new_tokens: 512,
@@ -1574,23 +948,20 @@ function bindGenerate() {
1574
  async function loadModel(modelId, label, onComplete) {
1575
  window._llmGenerate = null;
1576
  window._llm.status = 'loading';
1577
-
1578
  if (window._llm.generator) {
1579
  try { await window._llm.generator.dispose(); } catch {}
1580
  window._llm.generator = null;
1581
  }
1582
-
1583
  statusWrap.classList.remove('hidden');
1584
  setStatusUI(`⏳ Loading ${label}…`, '#d29922', 0);
1585
  progressWrap.classList.remove('hidden');
1586
-
1587
  try {
1588
  window._llm.generator = await pipeline('text-generation', modelId, {
1589
  dtype: 'q4',
1590
  device: 'webgpu',
1591
  progress_callback: (p) => {
1592
  if (p.status !== 'progress') return;
1593
- setStatusUI(`⏳ ${label} ${Math.round(p.progress ?? 0)}%`, '#d29922', p.progress ?? 0);
1594
  },
1595
  });
1596
  window._llm.status = 'ready';
@@ -1604,7 +975,6 @@ async function loadModel(modelId, label, onComplete) {
1604
  setStatusUI(`⚠️ ${label} unavailable β€” using templates`, '#d29922', null);
1605
  setTimeout(() => statusWrap.classList.add('hidden'), 5000);
1606
  }
1607
-
1608
  onComplete?.();
1609
  }
1610
 
 
78
  letter-spacing: 0.05em;
79
  margin-bottom: 10px;
80
  }
81
+ .frm-field { margin-bottom: 12px; }
 
 
82
  .frm-label {
83
  display: block;
84
  font-size: 0.8rem;
 
86
  color: #8b949e;
87
  margin-bottom: 4px;
88
  }
89
+ .frm-req { color: #f85149; margin-left: 2px; }
 
 
 
90
  .frm-input {
91
  width: 100%;
92
  background: #0d1117;
 
110
  border-color: #f85149;
111
  box-shadow: 0 0 0 3px rgba(248, 81, 73, 0.15);
112
  }
113
+ .frm-textarea { resize: vertical; min-height: 80px; }
 
 
 
114
  .frm-select {
115
  cursor: pointer;
116
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
 
118
  background-position: right 10px center;
119
  padding-right: 30px;
120
  }
121
+ .frm-radio-group { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
122
+ .frm-radio-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.875rem; color: #adbac7; }
123
+ .frm-radio-label input[type="radio"] { accent-color: #388bfd; width: 14px; height: 14px; cursor: pointer; flex-shrink: 0; }
124
+ .frm-checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.875rem; color: #adbac7; }
125
+ .frm-checkbox-label input[type="checkbox"] { accent-color: #388bfd; width: 14px; height: 14px; cursor: pointer; flex-shrink: 0; }
126
+ .frm-error-msg { font-size: 0.75rem; color: #f85149; margin-top: 3px; }
127
+ .frm-actions, .frm-nav { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
128
+ .frm-btn-submit, .frm-btn-next {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  background: linear-gradient(135deg, #1f6feb, #388bfd);
130
+ color: #fff; border: none; border-radius: 6px;
131
+ padding: 7px 16px; font-size: 0.875rem; font-weight: 500;
132
+ font-family: inherit; cursor: pointer; transition: opacity 0.15s;
 
 
 
 
 
 
 
 
 
 
133
  }
134
+ .frm-btn-submit:hover, .frm-btn-next:hover { opacity: 0.88; }
135
  .frm-btn-back {
136
+ background: transparent; color: #8b949e;
137
+ border: 1px solid #30363d; border-radius: 6px;
138
+ padding: 7px 14px; font-size: 0.875rem; font-family: inherit;
139
+ cursor: pointer; transition: border-color 0.15s, color 0.15s;
 
 
 
 
 
 
 
 
 
140
  }
141
+ .frm-btn-back:hover { border-color: #58a6ff; color: #58a6ff; }
142
  .frm-success {
143
+ font-size: 0.875rem; color: #3fb950;
 
144
  background: rgba(63, 185, 80, 0.08);
145
  border: 1px solid rgba(63, 185, 80, 0.2);
146
+ border-radius: 6px; padding: 10px 14px; margin-top: 8px;
 
 
 
 
 
 
147
  }
148
+ .frm-select option { background: #1c2128; color: #e6edf3; }
149
 
150
  /* ── App styles ── */
151
  * { box-sizing: border-box; }
152
  body { margin: 0; background: #0d1117; color: #adbac7; font-family: 'Inter', system-ui, sans-serif; }
153
  html, body, #root { height: 100%; }
 
154
  ::-webkit-scrollbar { width: 6px; }
155
  ::-webkit-scrollbar-track { background: transparent; }
156
  ::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
 
165
  0%,60%,100% { transform: translateY(0); opacity: 0.4; }
166
  30% { transform: translateY(-4px); opacity: 1; }
167
  }
 
168
  .streaming-cursor::after {
169
  content: 'β–‹'; display: inline-block;
170
  animation: blink 0.8s step-end infinite; color: #388bfd;
 
187
  border-radius: 20px; padding: 4px 12px; font-size: 0.75rem;
188
  color: #58a6ff; cursor: default; user-select: none;
189
  }
 
190
  #model-progress-bar { transition: width 0.4s ease; }
191
 
192
  /* Model switcher pills */
 
209
  white-space: nowrap;
210
  }
211
  .chip:hover { border-color: #388bfd; color: #58a6ff; }
 
212
  .inc-pill {
213
  font-size: 0.7rem; color: #3fb950;
214
  background: rgba(63,185,80,0.08); border: 1px solid rgba(63,185,80,0.2);
 
241
  data-model-id="LiquidAI/LFM2.5-350M-ONNX"
242
  data-model-label="LFM2.5-350M">LFM2.5</button>
243
  </div>
 
244
  <div id="model-status-wrap" class="ml-auto flex items-center gap-3">
245
  <div id="model-progress-wrap" class="w-36 h-1 rounded-full hidden" style="background:#21262d">
246
  <div id="model-progress-bar" class="h-1 rounded-full" style="width:0%;background:#388bfd"></div>
 
251
 
252
  <!-- ── Chat area ─────────────────────────────────────────────────────────── -->
253
  <main id="messages" class="flex-1 overflow-y-auto px-4 py-6 flex flex-col gap-4">
 
 
254
  <div id="empty-state" class="flex flex-col items-center justify-center flex-1 gap-6 py-12">
255
  <div class="w-16 h-16 rounded-2xl flex items-center justify-center" style="background:linear-gradient(135deg,#1f6feb,#388bfd)">
256
  <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 
271
  <button class="chip" data-msg="I'm a new joiner, need setup">πŸ‘‹ New joiner setup</button>
272
  </div>
273
  </div>
 
274
  </main>
275
 
276
  <!-- ── Input bar ─────────────────────────────────────────────────────────── -->
 
297
 
298
  <!-- ── formora.js (inlined) ──────────────────────────────────────────────── -->
299
  <script>
 
 
 
 
300
  const FORMORA_PREFIX = '__formora__';
301
 
 
 
302
  class Rule {
303
+ static required(msg = 'This field is required') { return { type: 'required', message: msg }; }
304
+ static minLength(n, msg) { return { type: 'minLength', value: n, message: msg || `Minimum ${n} characters required` }; }
305
+ static maxLength(n, msg) { return { type: 'maxLength', value: n, message: msg || `Maximum ${n} characters allowed` }; }
306
+ static min(n, msg) { return { type: 'min', value: n, message: msg || `Must be at least ${n}` }; }
307
+ static max(n, msg) { return { type: 'max', value: n, message: msg || `Must be at most ${n}` }; }
308
+ static email(msg = 'Enter a valid email address') { return { type: 'email', message: msg }; }
309
+ static regex(pattern, msg = 'Invalid format') { return { type: 'regex', value: pattern, message: msg }; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  }
311
 
 
 
312
  class Condition {
313
+ constructor(fieldId, operator, value) { this.fieldId = fieldId; this.operator = operator; this.value = value; }
 
 
 
 
314
  }
315
 
 
 
316
  class Form {
317
  constructor(formId) {
318
+ this._id = formId; this._title = null; this._description = null;
319
+ this._submitLabel = 'Submit'; this._successMsg = 'Thank you! Your request has been received.';
320
+ this._fields = []; this._steps = []; this._multiStep = false; this._currentStep = null;
321
+ }
322
+ title(t) { this._title = t; return this; }
323
+ description(d) { this._description = d; return this; }
324
+ submitLabel(l) { this._submitLabel = l; return this; }
325
+ successMessage(m) { this._successMsg = m; return this; }
 
 
 
 
 
 
 
 
326
  step(title = null) {
327
+ this._multiStep = true; this._steps.push({ title });
328
+ this._currentStep = this._steps.length - 1; return this;
329
+ }
330
+ _add(field) { field.stepIndex = this._multiStep ? this._currentStep : null; this._fields.push(field); return this; }
331
+ text(id, label, { required=false, placeholder='', rules=[], condition=null }={}) {
332
+ return this._add({ type:'text', id, label, required, placeholder, rules, condition }); }
333
+ email(id, label, { required=false, placeholder='', rules=[], condition=null }={}) {
334
+ return this._add({ type:'email', id, label, required, placeholder, rules, condition }); }
335
+ number(id, label, { required=false, min=null, max=null, rules=[], condition=null }={}) {
336
+ return this._add({ type:'number', id, label, required, min, max, rules, condition }); }
337
+ textarea(id, label, { required=false, rows=4, placeholder='', rules=[], condition=null }={}) {
338
+ return this._add({ type:'textarea', id, label, required, rows, placeholder, rules, condition }); }
339
+ select(id, label, options, { required=false, rules=[], condition=null }={}) {
340
+ const opts = options.map(o => Array.isArray(o) ? { label:o[0], value:o[1] } : { label:o, value:o });
341
+ return this._add({ type:'select', id, label, required, options:opts, rules, condition }); }
342
+ radio(id, label, options, { required=false, condition=null }={}) {
343
+ const opts = options.map(o => Array.isArray(o) ? { label:o[0], value:o[1] } : { label:o, value:o });
344
+ return this._add({ type:'radio', id, label, required, options:opts, condition }); }
345
+ checkbox(id, label, { condition=null }={}) { return this._add({ type:'checkbox', id, label, condition }); }
346
+ hidden(id, value) { return this._add({ type:'hidden', id, value }); }
347
+ build() { return renderForm(this); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  }
349
 
 
 
350
  function _fEsc(s) {
351
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  }
353
+ function renderRules(rules) { return JSON.stringify(rules || []); }
354
+ function renderCondition(cond) { return cond ? `data-condition='${JSON.stringify(cond)}'` : ''; }
355
 
356
  function renderField(f) {
357
  const condAttr = renderCondition(f.condition);
 
359
  const req = f.required ? 'required' : '';
360
  const reqMark = f.required ? '<span class="frm-req">*</span>' : '';
361
  const wrapStyle = f.condition ? ' style="display:none"' : '';
362
+ const wrap = (inner) => `<div class="frm-field" data-field-id="${_fEsc(f.id)}" ${condAttr}${wrapStyle}>${inner}</div>`;
 
 
 
363
  const label = `<label class="frm-label" for="${_fEsc(f.id)}">${_fEsc(f.label)}${reqMark}</label>`;
 
364
  switch (f.type) {
365
  case 'hidden':
366
  return `<input type="hidden" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}" value="${_fEsc(f.value)}" />`;
367
+ case 'text': case 'email':
368
+ return wrap(`${label}<input class="frm-input" type="${f.type}" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}"
369
+ placeholder="${_fEsc(f.placeholder||'')}" ${req} ${rulesAttr} />`);
 
 
370
  case 'number':
371
+ return wrap(`${label}<input class="frm-input" type="number" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}"
372
+ ${f.min!=null?`min="${f.min}"`:''}${f.max!=null?` max="${f.max}"`:''}
373
+ ${req} ${rulesAttr} />`);
 
374
  case 'textarea':
375
+ return wrap(`${label}<textarea class="frm-input frm-textarea" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}"
376
+ rows="${f.rows||4}" placeholder="${_fEsc(f.placeholder||'')}" ${req} ${rulesAttr}></textarea>`);
 
377
  case 'select':
378
+ return wrap(`${label}<select class="frm-input frm-select" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}" ${req} ${rulesAttr}>
379
+ <option value="">Select…</option>
380
+ ${f.options.map(o=>`<option value="${_fEsc(o.value)}">${_fEsc(o.label)}</option>`).join('')}
 
381
  </select>`);
382
  case 'radio':
383
+ return wrap(`${label}<div class="frm-radio-group">
384
+ ${f.options.map(o=>`<label class="frm-radio-label"><input type="radio" name="${_fEsc(f.id)}" value="${_fEsc(o.value)}" ${req} /><span>${_fEsc(o.label)}</span></label>`).join('')}
 
 
 
 
 
385
  </div>`);
386
  case 'checkbox':
387
+ return wrap(`<label class="frm-checkbox-label"><input type="checkbox" id="${_fEsc(f.id)}" name="${_fEsc(f.id)}" /><span>${_fEsc(f.label)}</span></label>`);
388
+ default: return '';
 
 
 
 
389
  }
390
  }
391
 
392
  function renderForm(form) {
393
+ if (form._multiStep && form._steps.length > 0) return renderMultiStep(form);
394
  const id = form._id;
395
+ return `<div class="frm-wrapper" data-formora-id="${_fEsc(id)}">
396
+ ${form._title?`<div class="frm-title">${_fEsc(form._title)}</div>`:''}
397
+ ${form._description?`<div class="frm-desc">${_fEsc(form._description)}</div>`:''}
 
 
 
 
 
 
 
 
 
398
  <form class="frm-form" data-formora-id="${_fEsc(id)}" novalidate>
399
+ ${form._fields.map(renderField).join('\n')}
400
+ <div class="frm-actions"><button type="submit" class="frm-btn-submit">${_fEsc(form._submitLabel)}</button></div>
 
 
401
  <div class="frm-success" style="display:none">${_fEsc(form._successMsg)}</div>
402
  <div class="frm-errors" style="display:none"></div>
403
+ </form></div>`;
 
404
  }
405
 
406
  function renderMultiStep(form) {
407
+ const id = form._id; const steps = form._steps; const total = steps.length;
408
+ const stepGroups = steps.map((_,i) => ({ title:steps[i].title, fields:form._fields.filter(f=>f.stepIndex===i) }));
409
+ const stepsHtml = stepGroups.map((step,i) => `
410
+ <div class="frm-step" data-step="${i}" ${i>0?'style="display:none"':''}>
411
+ ${step.title?`<div class="frm-step-title">${_fEsc(step.title)}</div>`:''}
 
 
 
 
 
 
 
 
412
  ${step.fields.map(renderField).join('\n')}
413
  </div>`).join('\n');
414
+ return `<div class="frm-wrapper" data-formora-id="${_fEsc(id)}">
415
+ ${form._title?`<div class="frm-title">${_fEsc(form._title)}</div>`:''}
 
 
 
416
  <div class="frm-progress-label">Step 1 of ${total}</div>
417
  <div class="frm-progress-bar"><div class="frm-progress-fill" id="${_fEsc(id)}-progress" style="width:${Math.round(100/total)}%"></div></div>
418
  <form class="frm-form" data-formora-id="${_fEsc(id)}" novalidate>
 
424
  </div>
425
  <div class="frm-success" style="display:none">${_fEsc(form._successMsg)}</div>
426
  <div class="frm-errors" style="display:none"></div>
427
+ </form></div>`;
 
428
  }
429
 
 
 
430
  function attachForms(container) {
431
  container.querySelectorAll('form[data-formora-id]').forEach(form => {
432
+ _attachConditions(form); _attachMultiStep(form);
 
433
  form.addEventListener('submit', e => {
434
  e.preventDefault();
435
  if (!_validate(form)) return;
436
  const result = _collect(form);
437
  _showSuccess(form);
438
+ window.dispatchEvent(new CustomEvent('formora:submit', { detail:{ raw:result.toMessage(), parsed:result } }));
 
 
439
  });
440
  });
441
  }
442
 
443
  function _attachConditions(form) {
444
+ form.querySelectorAll('[data-condition]').forEach(el => {
 
445
  const cond = JSON.parse(el.dataset.condition);
446
  const watch = form.querySelector(`[name="${CSS.escape(cond.fieldId)}"]`);
447
  if (!watch) return;
448
  const update = () => {
449
+ const show = _evalCondition(cond, watch.value);
 
450
  el.style.display = show ? '' : 'none';
451
+ if (!show) el.querySelectorAll('input,select,textarea').forEach(i => i.value='');
 
 
452
  };
453
+ watch.addEventListener('change', update); watch.addEventListener('input', update); update();
 
 
454
  });
455
  }
456
 
457
  function _evalCondition(cond, val) {
458
  switch (cond.operator) {
459
+ case 'eq': return val===cond.value; case 'neq': return val!==cond.value;
 
460
  case 'contains': return val.includes(cond.value);
461
+ case 'gt': return parseFloat(val)>parseFloat(cond.value);
462
+ case 'lt': return parseFloat(val)<parseFloat(cond.value);
463
+ default: return true;
464
  }
465
  }
466
 
467
  function _attachMultiStep(form) {
468
  const stepEls = form.querySelectorAll('.frm-step');
469
  if (!stepEls.length) return;
470
+ const total = stepEls.length; let current = 0;
 
 
471
  const formId = form.getAttribute('data-formora-id') || form.closest('[data-formora-id]')?.getAttribute('data-formora-id') || '';
472
  const progress = document.getElementById(`${formId}-progress`);
473
  const label = form.closest('.frm-wrapper')?.querySelector('.frm-progress-label');
474
  const btnBack = form.querySelector('.frm-btn-back');
475
  const btnNext = form.querySelector('.frm-btn-next');
476
  const btnSub = form.querySelector('.frm-btn-submit');
 
477
  const goTo = (i) => {
478
+ stepEls[current].style.display='none'; current=i; stepEls[current].style.display='';
479
+ if (label) label.textContent = `Step ${current+1} of ${total}`;
480
+ if (progress) progress.style.width = `${Math.round((current+1)*100/total)}%`;
481
+ if (btnBack) btnBack.style.display = current>0?'':'none';
482
+ if (btnNext) btnNext.style.display = current<total-1?'':'none';
483
+ if (btnSub) btnSub.style.display = current===total-1?'':'none';
 
 
484
  };
485
+ if (btnNext) btnNext.addEventListener('click', () => { if (!_validate(form,stepEls[current])) return; if (current<total-1) goTo(current+1); });
486
+ if (btnBack) btnBack.addEventListener('click', () => { if (current>0) goTo(current-1); });
 
 
 
 
 
 
 
487
  goTo(0);
488
  }
489
 
490
+ function _validate(form, scope=form) {
491
+ let valid=true;
492
  scope.querySelectorAll('input,select,textarea').forEach(el => {
493
  _clearError(el);
494
+ const rules=JSON.parse(el.dataset.rules||'[]'); const val=el.value.trim();
 
 
495
  for (const rule of rules) {
496
+ let err=null;
497
+ if (rule.type==='required'&&!val) err=rule.message;
498
+ if (rule.type==='minLength'&&val&&val.length<rule.value) err=rule.message;
499
+ if (rule.type==='maxLength'&&val&&val.length>rule.value) err=rule.message;
500
+ if (rule.type==='min'&&val&&parseFloat(val)<rule.value) err=rule.message;
501
+ if (rule.type==='max'&&val&&parseFloat(val)>rule.value) err=rule.message;
502
+ if (rule.type==='email'&&val&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) err=rule.message;
503
+ if (rule.type==='regex'&&val&&!new RegExp(rule.value).test(val)) err=rule.message;
504
+ if (err) { _showError(el,err); valid=false; break; }
505
+ }
506
+ if (el.required&&!val) { _showError(el,'This field is required'); valid=false; }
 
 
 
 
507
  });
508
  return valid;
509
  }
510
+ function _showError(el,msg) {
 
511
  el.classList.add('frm-invalid');
512
+ const err=document.createElement('div'); err.className='frm-error-msg'; err.textContent=msg;
 
 
513
  el.parentNode.appendChild(err);
514
  }
 
515
  function _clearError(el) {
516
  el.classList.remove('frm-invalid');
517
+ el.parentNode?.querySelectorAll('.frm-error-msg').forEach(e=>e.remove());
518
  }
 
519
  function _collect(form) {
520
+ const formId=form.getAttribute('data-formora-id')||form.closest('[data-formora-id]')?.getAttribute('data-formora-id')||'';
521
+ const data={}, typed={};
 
 
522
  form.querySelectorAll('input,select,textarea').forEach(el => {
523
  if (!el.name) return;
524
+ if (el.type==='radio'&&!el.checked) return;
525
+ if (el.type==='checkbox') { data[el.name]=el.checked?'true':'false'; typed[el.name]=el.checked; return; }
526
+ data[el.name]=el.value;
527
+ const num=Number(el.value); typed[el.name]=el.type==='number'&&!isNaN(num)?num:el.value;
 
 
 
 
 
528
  });
529
+ return new FormResult(formId,data,typed);
 
530
  }
 
531
  function _showSuccess(form) {
532
+ form.querySelectorAll('.frm-field,.frm-nav,.frm-step').forEach(el=>el.style.display='none');
533
+ form.querySelector('.frm-actions')?.style&&(form.querySelector('.frm-actions').style.display='none');
534
+ const suc=form.querySelector('.frm-success'); if (suc) suc.style.display='';
 
535
  }
536
 
 
 
537
  class FormResult {
538
+ constructor(formId,data,typedData) { this.formId=formId; this.data=data; this.typedData=typedData; }
539
+ toMessage() { return FORMORA_PREFIX+JSON.stringify({ form_id:this.formId, data:this.data, typed_data:this.typedData }); }
540
+ asText() { return Object.entries(this.typedData).filter(([,v])=>v!==''&&v!==false).map(([k,v])=>`${k.replace(/_/g,' ')}: ${v}`).join(', '); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  }
542
+ function isFormoraMessage(msg) { return typeof msg==='string'&&msg.startsWith(FORMORA_PREFIX); }
 
 
 
 
543
  function parseFormoraMessage(msg) {
544
  if (!isFormoraMessage(msg)) return null;
545
+ try { const p=JSON.parse(msg.slice(FORMORA_PREFIX.length)); return new FormResult(p.form_id,p.data,p.typed_data); } catch { return null; }
 
 
 
 
 
546
  }
547
  </script>
548
 
549
  <!-- ── forms.js (inlined) ────────────────────────────────────────────────── -->
550
  <script>
551
  function buildVpnForm() {
552
+ return new Form('vpn_issue').title('VPN Issue')
553
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required(),Rule.minLength(2)]})
554
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
555
+ .select('os','Operating System',[['Windows 10/11','windows'],['macOS','macos'],['Linux','linux']],{required:true})
556
+ .text('vpn_client','VPN Client',{placeholder:'e.g. Cisco AnyConnect, GlobalProtect'})
557
+ .textarea('issue','Describe the Issue',{required:true,rows:3,rules:[Rule.required()]})
558
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
559
+ .submitLabel('Submit').successMessage('Your VPN issue has been logged. Check your inbox for next steps.').build();
 
 
 
560
  }
 
561
  function buildAccountForm() {
562
+ return new Form('account_issue').title('Account / Login Issue')
563
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
564
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
565
+ .select('issue_type','Issue Type',[['Account locked','locked'],['Forgot password','forgot_password'],['MFA not working','mfa'],['Access denied','access_denied'],['Other','other']],{required:true})
566
+ .textarea('details','Additional Details',{rows:3})
567
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
568
+ .submitLabel('Submit').successMessage('Account issue logged. Our team will be in touch shortly.').build();
 
 
 
 
 
 
 
 
 
569
  }
 
570
  function buildHardwareForm() {
571
+ return new Form('hardware_issue').title('Hardware Issue')
572
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
573
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
574
+ .select('device_type','Device Type',[['Laptop','laptop'],['Desktop','desktop'],['Monitor','monitor'],['Keyboard / Mouse','peripheral'],['Printer','printer'],['Headset / Webcam','audio_video'],['Other','other']],{required:true})
575
+ .text('device_model','Device Model / Asset Tag',{placeholder:'e.g. Dell XPS 15, Asset #12345'})
576
+ .textarea('issue','Describe the Problem',{required:true,rows:3,rules:[Rule.required()]})
577
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
578
+ .submitLabel('Submit').successMessage('Hardware issue logged. IT will assess and follow up.').build();
 
 
 
 
 
 
 
579
  }
 
580
  function buildSoftwareForm() {
581
+ return new Form('software_issue').title('Software Issue')
582
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
583
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
584
+ .text('application','Application Name',{required:true,placeholder:'e.g. Microsoft Teams, Slack',rules:[Rule.required()]})
585
+ .select('issue_type','Issue Type',[['App crashing','crash'],['Cannot install','install'],['Licence / activation','licence'],['Performance issues','perf'],['Error message','error'],['Other','other']],{required:true})
586
+ .text('error_code','Error Code (if any)',{placeholder:'Optional'})
587
+ .textarea('details','Describe the Issue',{required:true,rows:3,rules:[Rule.required()]})
588
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
589
+ .submitLabel('Submit').successMessage("Software issue logged. We'll be in touch.").build();
 
 
 
 
 
 
 
590
  }
 
591
  function buildNetworkForm() {
592
+ return new Form('network_issue').title('Network / Connectivity Issue')
593
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
594
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
595
+ .select('connection_type','Connection Type',[['Wi-Fi','wifi'],['Ethernet','ethernet'],['VPN-only','vpn'],['Both Wi-Fi and Ethernet','both']],{required:true})
596
+ .select('issue_type','Issue Type',[['No internet','no_internet'],['Slow connection','slow'],['Cannot reach internal systems','internal'],['DNS issues','dns'],['Packet loss / drops','drops'],['Other','other']],{required:true})
597
+ .textarea('details','Additional Details',{rows:3})
598
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
599
+ .submitLabel('Submit').successMessage('Network issue logged. Our team will investigate.').build();
 
 
 
 
 
 
 
 
 
600
  }
 
601
  function buildEmailForm() {
602
+ return new Form('email_issue').title('Email / M365 Issue')
603
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
604
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
605
+ .select('issue_type','Issue Type',[['Outlook not working','outlook'],['Cannot send/receive email','send_recv'],['Calendar issues','calendar'],['Teams issue','teams'],['Shared mailbox','shared_mailbox'],['Other','other']],{required:true})
606
+ .textarea('details','Describe the Issue',{required:true,rows:3,rules:[Rule.required()]})
607
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
608
+ .submitLabel('Submit').successMessage("Email issue logged. We'll get this sorted.").build();
 
 
 
 
 
 
 
609
  }
 
610
  function buildAccessForm() {
611
+ return new Form('access_request').title('Access Request')
612
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
613
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
614
+ .email('manager_email',"Manager's Email",{required:true,rules:[Rule.required(),Rule.email()]})
615
+ .text('system_resource','System / Resource Requested',{required:true,placeholder:'e.g. Salesforce, HR Drive, Azure portal',rules:[Rule.required()]})
616
+ .textarea('business_justification','Business Justification',{required:true,rows:3,rules:[Rule.required()]})
617
+ .select('access_level','Access Level',[['Read Only','read'],['Read/Write','readwrite'],['Admin','admin']],{required:true})
618
+ .submitLabel('Submit Request').successMessage('Access request submitted. Your manager will receive an approval email.').build();
 
 
 
619
  }
 
620
  function buildProcurementForm() {
621
+ return new Form('procurement_request').title('IT Procurement Request')
622
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
623
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
624
+ .email('manager_email','Approving Manager Email',{required:true,rules:[Rule.required(),Rule.email()]})
625
+ .text('item_description','Item / Equipment Required',{required:true,placeholder:'e.g. MacBook Pro 14", USB-C hub',rules:[Rule.required()]})
626
+ .number('quantity','Quantity',{required:true,min:1,rules:[Rule.required(),Rule.min(1)]})
627
+ .textarea('business_justification','Business Justification',{required:true,rows:3,rules:[Rule.required()]})
628
+ .submitLabel('Submit Request').successMessage('Purchase request submitted. Approval email sent to your manager.').build();
 
 
 
629
  }
 
630
  function buildOnboardingForm() {
631
+ return new Form('onboarding_setup').title('New Employee Onboarding')
 
632
  .step('Personal Details')
633
+ .text('employee_name','Full Name',{required:true,rules:[Rule.required()]})
634
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
635
+ .text('department','Department',{required:true,rules:[Rule.required()]})
636
+ .text('job_title','Job Title',{required:true,rules:[Rule.required()]})
637
  .step('Setup Preferences')
638
+ .select('device_preference','Device Preference',[['MacBook Pro','macbook_pro'],['MacBook Air','macbook_air'],['Windows Laptop','windows_laptop'],['Desktop','desktop']],{required:true})
639
+ .select('start_date_readiness','Start Date',[['Within 1 week','1week'],['1–2 weeks','2weeks'],['2–4 weeks','4weeks'],['More than a month','month_plus']])
640
+ .textarea('additional_requirements','Any Additional Requirements?',{rows:3})
641
+ .submitLabel('Submit Onboarding Request').successMessage('Onboarding request received! IT will prepare your setup before day one.').build();
 
 
 
 
 
 
 
 
642
  }
 
643
  function buildGenericForm() {
644
+ return new Form('generic_incident').title('IT Support Request')
645
+ .text('employee_name','Your Name',{required:true,rules:[Rule.required()]})
646
+ .email('email','Work Email',{required:true,rules:[Rule.required(),Rule.email()]})
647
+ .textarea('issue','Describe Your Issue',{required:true,rows:4,rules:[Rule.required(),Rule.minLength(10)]})
648
+ .select('urgency','Urgency',[['Low','low'],['Medium','medium'],['High','high'],['Critical','critical']],{required:true})
649
+ .submitLabel('Submit').successMessage('Issue logged. Our team will be in touch shortly.').build();
 
 
 
650
  }
 
651
  const FORM_MAP = {
652
+ vpn:buildVpnForm, account:buildAccountForm, hardware:buildHardwareForm,
653
+ software:buildSoftwareForm, network:buildNetworkForm, email:buildEmailForm,
654
+ access:buildAccessForm, procurement:buildProcurementForm,
655
+ onboarding:buildOnboardingForm, generic_incident:buildGenericForm,
 
 
 
 
 
 
656
  };
657
+ function dispatchForm(intent) { const b=FORM_MAP[intent]; return b?b():null; }
 
 
 
 
658
  </script>
659
 
660
  <!-- ── intent.js (inlined) ───────────────────────────────────────────────── -->
661
  <script>
662
  const GREETING_RE = /^\s*(hi+|hey+|hello+|howdy|yo+|sup|good (morning|afternoon|evening)|thanks?|thank you|cheers|bye|goodbye|ok|okay|cool|great|sure|got it|noted)\s*[!?.]*\s*$/i;
 
663
  const INTENT_KEYWORDS = {
664
+ vpn: ['vpn','remote','tunnel','connect remotely','cisco anyconnect','wireguard','openvpn','remote access'],
665
+ account: ['password','locked','login','reset','access denied','sign in','forgot','credentials','mfa','2fa','account'],
666
+ hardware: ['laptop','keyboard','mouse','monitor','printer','screen','device','battery','charger','headset','webcam'],
667
+ software: ['install','app','software','crash','update','license','application','error code','not opening','uninstall'],
668
+ network: ['internet','wifi','slow','network','connection','no internet','packet loss','latency','dns','ethernet'],
669
+ email: ['email','outlook','calendar','teams','mailbox','meeting','distribution list','signature','spam'],
670
+ access: ['permission','folder','drive','share','access','shared drive','read only','write access','file server'],
671
+ procurement: ['order','request','new laptop','equipment','purchase','budget','quote','hardware request'],
672
+ onboarding: ['new joiner','joined','first day','setup','onboard','new employee','getting started'],
673
  };
 
674
  function detectIntent(message) {
675
+ const stripped=message.trim();
676
  if (GREETING_RE.test(stripped)) return 'greeting';
677
+ const lower=stripped.toLowerCase(); const scores={};
678
+ for (const [intent,keywords] of Object.entries(INTENT_KEYWORDS))
679
+ scores[intent]=keywords.filter(kw=>lower.includes(kw)).length;
680
+ const best=Object.keys(scores).reduce((a,b)=>scores[a]>=scores[b]?a:b);
681
+ if (scores[best]===0) return stripped.split(/\s+/).length<=4?'greeting':'generic_incident';
 
 
 
 
 
 
 
 
 
 
 
682
  return best;
683
  }
 
684
  const INTRO = {
685
+ greeting:"Hey! I'm DeskFlow, your IT support assistant. What can I help you with today?",
686
+ vpn:"VPN troubles β€” not fun! Let me grab a few details so I can get you sorted.",
687
+ account:"Account issues are stressful, but we'll get you sorted quickly.",
688
+ hardware:"Hardware gremlins! Let me pull up a quick form to get the specifics.",
689
+ software:"Software being awkward? Let me ask you a few things so I can give you the right fix.",
690
+ network:"Connection problems are the worst. Let me get some details to help diagnose this.",
691
+ email:"Email issues β€” always at the worst time! Let me get some details.",
692
+ access:"Access requests sorted quickly β€” fill in the details below.",
693
+ procurement:"Happy to help with your purchase request.",
694
+ onboarding:"Welcome! Let's get everything set up properly.",
695
+ generic_incident:"I'm here to help! Let me take the details so I can find the best solution.",
696
  };
697
+ function getIntroMessage(intent) { return INTRO[intent]||INTRO.generic_incident; }
 
 
 
 
698
  const RUNBOOKS = {
699
+ vpn_issue:`VPN Runbook:\n- Likely causes: credential expiry, MFA drift, firewall blocking UDP 1194/TCP 443, client misconfiguration.\n- Steps: verify internet; restart VPN client from tray; check credentials; re-sync MFA app; reboot; flush DNS (ipconfig /flushdns on Windows); disable conflicting firewall/AV; reinstall VPN client.\n- Escalate if: unresolved after reinstall, multiple users affected, or Critical urgency.`,
700
+ account_issue:`Account Runbook:\n- Likely causes: too many failed logins (auto-lockout), expired password, MFA device time drift.\n- Steps: wait 15 min for auto-unlock; try self-service password reset; re-sync MFA app; contact IT to manually unlock; request Temporary Access Pass (TAP) for MFA bypass.\n- Escalate: High/Critical urgency or if account is admin-level.`,
701
+ hardware_issue:`Hardware Runbook:\n- Likely causes: driver fault, physical damage, hardware component failure.\n- Steps: power cycle device; test peripheral on another port/machine; check Device Manager for errors; update driver; run built-in diagnostics (Dell SupportAssist, Apple Diagnostics).\n- Escalate: physical damage or persistent failure β€” request hardware assessment/replacement.`,
702
+ software_issue:`Software Runbook:\n- Likely causes: corrupt install, missing dependencies, licence expiry, insufficient permissions.\n- Steps: close fully (check tray); run as Administrator; check for updates; uninstall, delete AppData, reinstall; verify disk space (>10 GB free); check licence validity.\n- Escalate: business-critical application, High/Critical urgency.`,
703
+ network_issue:`Network Runbook:\n- Likely causes: DHCP lease failure, DNS misconfiguration, physical link issue.\n- Steps: check other devices; restart network adapter; ipconfig /release + /renew + /flushdns; try Ethernet if on Wi-Fi; nslookup to test DNS; reboot router/switch (home).\n- Escalate: office-wide outage, infrastructure suspected.`,
704
+ email_issue:`Email/M365 Runbook:\n- Likely causes: Outlook profile corruption, OST file corruption, M365 service outage.\n- Steps: check M365 Service Health for outages; test Outlook Web Access (OWA); restart Outlook (close from tray); run in Safe Mode (outlook.exe /safe); create new Outlook profile; clear Teams cache + reinstall.\n- Escalate: if OWA also failing, likely tenant-wide issue β€” contact Microsoft.`,
705
+ access_request:`Access Request Runbook:\n- Process: manager approval required within 48 h; IT provisions within 1 business day after approval.\n- Provisioning: role-based access following least-privilege; audit log entry created.\n- Escalation: urgent access β€” provide business justification; emergency access via IT helpdesk.`,
706
+ procurement_request:`Procurement Runbook:\n- Process: manager approval β†’ PO raised β†’ procurement team orders β†’ delivery 5–10 business days (in-stock).\n- Status updates emailed at: Approved β†’ PO Raised β†’ Dispatched β†’ Delivered.\n- Escalation: urgent procurement requires budget-holder sign-off and IT director approval.`,
707
+ onboarding_setup:`Onboarding Runbook:\n- Timeline: M365 account created 1–2 days before start; device imaged and configured before day one.\n- Day one: IT walkthrough β€” login, MFA setup, VPN, key applications.\n- Access provisioned based on manager approvals (role-based).\n- IT checks in on day 3 for outstanding issues.`,
708
+ generic_incident:`General IT Runbook:\n- Start: restart affected app; if persisting, reboot device; note error messages/codes.\n- Check IT status page for known incidents; check if other users affected.\n- Collect: device model, OS, application version, steps to reproduce, error screenshots.\n- Escalate: High/Critical urgency or if multiple users impacted.`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  };
710
+ function getRunbookContext(formId) { return RUNBOOKS[formId]||RUNBOOKS.generic_incident; }
 
 
 
 
711
  function buildChatSystemPrompt() {
712
+ return 'You are DeskFlow, a friendly IT support assistant. Reply conversationally in 1–3 short sentences. Do NOT provide technical steps yet β€” just respond naturally to what the user said.';
 
 
 
 
713
  }
714
+ function buildResolutionSystemPrompt(formId,requestNumber) {
715
+ const context=getRunbookContext(formId);
716
+ const ticketLine=requestNumber?`The user's request has been logged as **${requestNumber}**. Open your reply by mentioning this number warmly. `:'';
717
+ return `You are DeskFlow, a friendly IT support assistant. ${ticketLine}Be concise, warm, and practical.\n\nReply with: 1) Likely cause 2) Numbered resolution steps 3) Escalation note if urgency is High or Critical.\n\nRunbook context:\n${context}`;
 
 
 
 
 
 
 
 
718
  }
 
719
  function formResultToPrompt(formResult) {
720
+ const title=(formResult.formId||'IT Support Request').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
721
+ const lines=[`IT Support Request: ${title}`];
722
+ for (const [k,v] of Object.entries(formResult.typedData||{}))
723
+ if (v!==''&&v!==false&&v!=null) lines.push(`- ${k.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase())}: ${v}`);
 
 
 
724
  return lines.join('\n');
725
  }
 
726
  const TEMPLATES = {
727
+ vpn_issue:`## VPN Issue β€” Suggested Resolution\n\n**Likely cause:** VPN client configuration issue, credential expiry, or network port blocking.\n\n**Steps to resolve:**\n1. Verify your internet connection is working.\n2. Restart the VPN client completely (close from system tray).\n3. Ensure your username and password are correct and not expired.\n4. Re-sync your MFA authenticator app β€” check device clock is accurate.\n5. Reboot your device and try again.\n6. On Windows: run \`ipconfig /flushdns\` in an elevated command prompt.\n7. Check that your firewall is not blocking the VPN port (UDP 1194 / TCP 443).\n8. Reinstall the VPN client from the IT software portal.`,
728
+ account_issue:`## Account Issue β€” Suggested Resolution\n\n**Likely cause:** Account lockout, expired password, or MFA misconfiguration.\n\n**Steps to resolve:**\n1. Wait 15 minutes β€” most accounts auto-unlock after a lockout period.\n2. Verify Caps Lock is off and you are using the correct username format.\n3. Attempt a self-service password reset at the IT portal.\n4. Re-sync your MFA authenticator app.\n5. Contact IT to manually unlock if auto-unlock has not worked.\n6. If MFA is the issue, IT can provide a Temporary Access Pass (TAP).`,
729
+ hardware_issue:`## Hardware Issue β€” Suggested Resolution\n\n**Likely cause:** Driver issue, physical damage, or hardware component failure.\n\n**Steps to resolve:**\n1. Power cycle the device completely (hold power for 10 seconds, then restart).\n2. For peripherals: try a different USB port and test on another machine.\n3. Check Device Manager for driver errors (yellow exclamation marks).\n4. Update the device driver from the manufacturer's website.\n5. Run built-in diagnostics (Dell SupportAssist, HP Support Assistant, or Apple Diagnostics).\n6. If the issue persists, contact IT for a hardware assessment.`,
730
+ software_issue:`## Software Issue β€” Suggested Resolution\n\n**Likely cause:** Corrupt installation, missing dependencies, licence expiry, or permissions issue.\n\n**Steps to resolve:**\n1. Close the application completely (check the system tray) and reopen.\n2. Run the application as Administrator.\n3. Check for available updates and install them.\n4. Reinstall: uninstall, delete leftover app data, then reinstall.\n5. Verify you have enough disk space (minimum 10 GB free).\n6. Check that your software licence is still valid.`,
731
+ network_issue:`## Network Issue β€” Suggested Resolution\n\n**Likely cause:** DHCP lease failure, DNS misconfiguration, or physical connectivity issue.\n\n**Steps to resolve:**\n1. Check if other devices on the same network are affected.\n2. Restart your network adapter.\n3. On Windows: run \`ipconfig /release\`, then \`/renew\`, then \`/flushdns\`.\n4. Try connecting via Ethernet if you are on Wi-Fi.\n5. Test DNS: run \`nslookup google.com\` in Command Prompt.\n6. Reboot your router/switch if you are at home.`,
732
+ email_issue:`## Email / Communications Issue β€” Suggested Resolution\n\n**Likely cause:** Outlook profile corruption, OST file corruption, or M365 service issue.\n\n**Steps to resolve:**\n1. Check the Microsoft 365 service health page for known outages.\n2. Test Outlook Web Access (OWA) β€” if OWA works, the issue is with the desktop client.\n3. Restart Outlook from the system tray.\n4. Run Outlook in Safe Mode: Win+R β†’ \`outlook.exe /safe\`.\n5. Create a new Outlook profile: Control Panel β†’ Mail β†’ Show Profiles β†’ Add.\n6. For Teams issues: clear the Teams cache and reinstall.`,
733
+ access_request:`## Access Request β€” Next Steps\n\n**Your access request has been received.**\n\n1. An approval email will be sent to your manager.\n2. Your manager has **48 hours** to approve or decline.\n3. Once approved, IT will provision access within **1 business day**.\n4. You will receive a confirmation email when access has been granted.`,
734
+ procurement_request:`## Procurement Request β€” Next Steps\n\n**Your purchase request has been received.**\n\n1. An approval request will be sent to your approving manager.\n2. Upon approval, IT Procurement will raise a Purchase Order (PO).\n3. Standard delivery: **5–10 business days** for in-stock items.\n4. Status updates emailed at each stage: Approved β†’ PO Raised β†’ Dispatched β†’ Delivered.`,
735
+ onboarding_setup:`## Onboarding Setup β€” What to Expect\n\n**Your onboarding request has been received.**\n\n1. **Account creation**: Your M365 account and email will be created 1–2 days before start date.\n2. **Device provisioning**: Your laptop/desktop will be imaged and configured before day one.\n3. **Day-one**: IT will walk you through login, MFA setup, and VPN connection.\n4. **Access**: All requested system access provisioned based on your manager's approvals.\n5. **Follow-up**: IT will check in on day 3 to address any outstanding issues.`,
736
+ generic_incident:`## IT Incident β€” Suggested Resolution\n\n**Likely cause:** Based on your description, this may be a software, network, or configuration issue.\n\n**Steps to resolve:**\n1. Restart the affected application or service.\n2. Reboot your device if the issue persists.\n3. Check if other users are affected.\n4. Note any error messages or codes.\n5. Check the IT status page for known incidents.`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  };
 
738
  const URGENCY_NOTE = {
739
+ high:'\n\n> **Escalation**: Marked **High** urgency. If unresolved within 1 hour, contact the IT helpdesk directly.',
740
+ critical:'\n\n> **Escalation**: Marked **CRITICAL**. Contact the IT helpdesk **immediately** or call the emergency support line.',
741
  };
742
+ function generateTemplateResponse(formResult,requestNumber='') {
743
+ const formId=formResult.formId||'generic_incident';
744
+ const urgency=String((formResult.typedData||{}).urgency||'').toLowerCase();
745
+ let response=TEMPLATES[formId]||TEMPLATES.generic_incident;
746
+ if (URGENCY_NOTE[urgency]) response+=URGENCY_NOTE[urgency];
747
+ if (requestNumber) response=`I've logged your request as **${requestNumber}**. Here's what I recommend:\n\n`+response;
 
 
 
 
748
  return response;
749
  }
750
  </script>
751
 
752
  <!-- ── Main app logic ─────────────────────────────────────────────────────── -->
753
  <script>
 
754
  const messagesEl = document.getElementById('messages');
755
  const emptyState = document.getElementById('empty-state');
756
  const msgInput = document.getElementById('msg-input');
757
  const sendBtn = document.getElementById('send-btn');
 
 
 
 
758
 
 
759
  let _ticketSeq = Math.floor(Math.random() * 9000) + 1000;
760
  function nextTicket() { return `INC-${++_ticketSeq}`; }
761
 
762
+ window._llm = { status: 'idle', generator: null, modelId: null };
 
763
  window._llmGenerate = null;
764
 
 
765
  function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
766
+ function scrollBottom() { requestAnimationFrame(() => messagesEl.scrollTop = messagesEl.scrollHeight); }
767
+ function hideEmptyState() { emptyState.style.display = 'none'; }
 
 
 
 
 
 
 
768
  function setLoading(on) {
769
+ sendBtn.disabled = on; msgInput.disabled = on;
 
770
  sendBtn.style.opacity = on ? '0.5' : '1';
771
  }
772
 
 
773
  function addBotRow(innerHTML) {
774
+ const wrap = document.createElement('div'); wrap.className = 'flex justify-start';
775
+ const row = document.createElement('div'); row.className = 'flex items-start gap-3 max-w-3xl';
 
 
 
 
776
  const avatar = document.createElement('div');
777
  avatar.className = 'w-7 h-7 rounded-lg flex-shrink-0 flex items-center justify-center mt-0.5';
778
  avatar.style.cssText = 'background:linear-gradient(135deg,#1f6feb,#388bfd)';
779
  avatar.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3"/><path d="M13 21l2-2 4 4 2-2-4-4 2-2-6-1z"/></svg>`;
 
780
  const bubble = document.createElement('div');
781
  bubble.className = 'rounded-2xl rounded-tl-sm px-4 py-3 text-sm leading-relaxed';
782
  bubble.style.cssText = 'background:#1c2128;border:1px solid #30363d;color:#adbac7;max-width:44rem;flex:1';
783
  bubble.innerHTML = innerHTML;
784
+ row.appendChild(avatar); row.appendChild(bubble); wrap.appendChild(row); messagesEl.appendChild(wrap);
785
+ scrollBottom(); return bubble;
 
 
 
 
 
786
  }
 
787
  function addUserBubble(text) {
788
+ const wrap = document.createElement('div'); wrap.className = 'flex justify-end';
 
789
  const bubble = document.createElement('div');
790
  bubble.className = 'rounded-2xl rounded-tr-sm px-4 py-3 text-sm leading-relaxed';
791
  bubble.style.cssText = 'background:linear-gradient(135deg,#1f6feb,#388bfd);color:white;max-width:32rem';
792
+ bubble.textContent = text; wrap.appendChild(bubble); messagesEl.appendChild(wrap); scrollBottom();
 
 
 
 
 
 
 
 
 
 
 
793
  }
794
+ function addTextBubble(text) { return addBotRow(`<span style="color:#adbac7">${esc(text)}</span>`); }
795
+ function addMarkdownBubble(md) { return addBotRow(`<div class="md-body">${marked.parse(md)}</div>`); }
796
+ function addTyping() { return addBotRow('<div class="flex items-center gap-1.5 py-1"><span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></div>'); }
797
 
 
 
 
 
 
798
  function injectForm(formHtml, intent) {
799
  const bubble = addBotRow('');
800
  bubble.style.padding = '12px';
801
  bubble.innerHTML = formHtml;
 
802
  const formEl = bubble.querySelector('form[data-formora-id]');
803
  const formId = formEl?.getAttribute('data-formora-id');
 
804
  attachForms(bubble);
 
805
  function onFormoraSubmit(e) {
806
  const { parsed } = e.detail;
807
  if (parsed.formId !== formId) return;
 
808
  window.removeEventListener('formora:submit', onFormoraSubmit);
 
809
  const requestNumber = nextTicket();
 
810
  const pill = document.createElement('div');
811
+ pill.className = 'inc-pill'; pill.textContent = `βœ“ ${requestNumber}`;
 
812
  bubble.insertBefore(pill, bubble.firstChild);
 
813
  handleFormSubmit(parsed, requestNumber, intent);
814
  }
 
815
  window.addEventListener('formora:submit', onFormoraSubmit);
816
  scrollBottom();
817
  return bubble;
818
  }
819
 
 
820
  function handleFormSubmit(formResult, requestNumber, intent) {
821
  const systemPrompt = buildResolutionSystemPrompt(formResult.formId, requestNumber);
822
  const userPrompt = formResultToPrompt(formResult);
823
  const templateResponse = generateTemplateResponse(formResult, requestNumber);
 
824
  const typing = addTyping();
825
+ function showTemplate() { typing.closest('.flex.justify-start')?.remove(); addMarkdownBubble(templateResponse); }
 
 
 
 
 
826
  const llmGenerate = window._llmGenerate;
827
+ if (typeof llmGenerate !== 'function' || window._llm?.status !== 'ready') { showTemplate(); return; }
 
 
 
 
828
  typing.closest('.flex.justify-start')?.remove();
829
  const chatBubble = addBotRow('<div class="md-body streaming-cursor"></div>');
830
  const innerEl = chatBubble.querySelector('.md-body');
 
831
  llmGenerate(
832
  systemPrompt, userPrompt, templateResponse,
833
+ (acc) => { innerEl.className='md-body streaming-cursor'; innerEl.innerHTML=marked.parse(acc); scrollBottom(); },
834
+ (final) => { innerEl.className='md-body'; innerEl.innerHTML=marked.parse(final||templateResponse); scrollBottom(); },
835
  (_t) => { chatBubble.remove(); addMarkdownBubble(templateResponse); },
836
  );
837
  }
838
 
 
839
  async function sendMessage() {
840
  const text = msgInput.value.trim();
841
  if (!text) return;
 
842
  hideEmptyState();
843
  addUserBubble(text);
844
+ msgInput.value = ''; msgInput.style.height = 'auto';
 
845
  setLoading(true);
 
846
  const typingBubble = addTyping();
847
  const intent = detectIntent(text);
848
  const templateChat = getIntroMessage(intent);
849
  const formHtml = dispatchForm(intent);
 
850
  await new Promise(r => setTimeout(r, 180));
851
  typingBubble.closest('.flex.justify-start')?.remove();
 
852
  const chatBubble = addTextBubble(templateChat);
 
853
  const injectedBubble = formHtml ? injectForm(formHtml, intent) : null;
854
  setLoading(false);
855
+ // Focus: first field in form, or back to chat input
 
856
  if (injectedBubble) {
857
+ injectedBubble.querySelector('input:not([type="hidden"]):not([disabled]),select:not([disabled]),textarea:not([disabled])')?.focus();
 
 
 
858
  } else {
859
  msgInput.focus();
860
  }
861
+ // Enhance chat bubble with LLM in background
862
  const llmGenerate = window._llmGenerate;
863
  if (typeof llmGenerate !== 'function' || window._llm?.status !== 'ready') return;
 
864
  chatBubble.innerHTML = '<div class="md-body streaming-cursor"></div>';
865
  const innerEl = chatBubble.querySelector('.md-body');
 
866
  llmGenerate(
867
  buildChatSystemPrompt(), text, templateChat,
868
+ (acc) => { innerEl.className='md-body streaming-cursor'; innerEl.innerHTML=marked.parse(acc); scrollBottom(); },
869
+ (final) => { innerEl.className='md-body'; innerEl.innerHTML=marked.parse(final||templateChat); scrollBottom(); },
870
+ (_t) => { chatBubble.textContent=templateChat; },
871
  );
872
  }
873
 
874
+ msgInput.addEventListener('keydown', e => { if (e.key==='Enter'&&!e.shiftKey) { e.preventDefault(); sendMessage(); } });
 
 
 
875
  sendBtn.addEventListener('click', sendMessage);
 
876
  document.querySelectorAll('.chip').forEach(chip => {
877
+ chip.addEventListener('click', () => { msgInput.value=chip.dataset.msg; sendMessage(); });
 
 
 
878
  });
879
 
880
  // ── Model switcher ────────────────────────────────────────────────────────── //
 
882
  pill.addEventListener('click', () => {
883
  if (pill.classList.contains('active')) return;
884
  if (typeof window._llmSwitchModel !== 'function') return;
885
+ document.querySelectorAll('.model-pill').forEach(p => { p.classList.remove('active'); p.disabled=true; });
 
 
 
 
886
  pill.classList.add('active');
 
887
  window._llmSwitchModel(
888
+ pill.dataset.modelId, pill.dataset.modelLabel,
889
+ () => document.querySelectorAll('.model-pill').forEach(p => { p.disabled=false; }),
 
890
  );
891
  });
892
  });
 
909
  const statusWrap = document.getElementById('model-status-wrap');
910
 
911
  function setStatusUI(text, color, pct) {
912
+ statusEl.textContent = text; statusEl.style.color = color;
913
+ if (pct != null) { progressWrap.classList.remove('hidden'); progressBar.style.width = Math.round(pct)+'%'; }
914
+ else { progressWrap.classList.add('hidden'); }
 
 
 
 
 
915
  }
916
 
917
  function bindGenerate() {
 
919
  if (!window._llm.generator) { onFallback(templateFallback); return; }
920
  try {
921
  const messages = [
922
+ { role:'system', content:systemPrompt },
923
+ { role:'user', content:userPrompt },
924
  ];
925
  let accumulated = '';
926
  const streamer = new TextStreamer(window._llm.generator.tokenizer, {
927
  skip_prompt: true,
928
  callback_function: (token) => { accumulated += token; onToken(accumulated); },
929
  });
930
+ // LFM2.5 requires num_logits_to_keep=1 as a mandatory ONNX graph input
931
  const isLFM = (window._llm.modelId || '').includes('LFM');
932
  const result = await window._llm.generator(messages, {
933
  max_new_tokens: 512,
 
948
  async function loadModel(modelId, label, onComplete) {
949
  window._llmGenerate = null;
950
  window._llm.status = 'loading';
 
951
  if (window._llm.generator) {
952
  try { await window._llm.generator.dispose(); } catch {}
953
  window._llm.generator = null;
954
  }
 
955
  statusWrap.classList.remove('hidden');
956
  setStatusUI(`⏳ Loading ${label}…`, '#d29922', 0);
957
  progressWrap.classList.remove('hidden');
 
958
  try {
959
  window._llm.generator = await pipeline('text-generation', modelId, {
960
  dtype: 'q4',
961
  device: 'webgpu',
962
  progress_callback: (p) => {
963
  if (p.status !== 'progress') return;
964
+ setStatusUI(`⏳ ${label} ${Math.round(p.progress??0)}%`, '#d29922', p.progress??0);
965
  },
966
  });
967
  window._llm.status = 'ready';
 
975
  setStatusUI(`⚠️ ${label} unavailable β€” using templates`, '#d29922', null);
976
  setTimeout(() => statusWrap.classList.add('hidden'), 5000);
977
  }
 
978
  onComplete?.();
979
  }
980