NeonClary Cursor commited on
Commit
9fbe4a1
·
0 Parent(s):

Deploy Cybersecurity Panel to Hugging Face Space (LFS pointers, orphan history).

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dev-tools/header-check.mjs +190 -0
  2. .dev-tools/verify-chat-theme.mjs +167 -0
  3. .dev-tools/verify-input-chrome-layout.mjs +247 -0
  4. .dev-tools/verify-qa-smoke.mjs +163 -0
  5. .dev-tools/verify-suggestions-toggle.mjs +253 -0
  6. .dev-tools/verify-welcome-fit.mjs +186 -0
  7. .dockerignore +20 -0
  8. .gitattributes +6 -0
  9. .github/workflows/propose_release.yml +29 -0
  10. .github/workflows/publish_release.yml +31 -0
  11. .github/workflows/publish_test_build.yml +24 -0
  12. .github/workflows/unit_tests.yml +37 -0
  13. .gitignore +27 -0
  14. CHANGELOG.md +74 -0
  15. Dockerfile +77 -0
  16. Dockerfile.dev +32 -0
  17. PLAN.md +228 -0
  18. README.md +111 -0
  19. cybersecurity_config.yaml +321 -0
  20. docker-compose.yml +117 -0
  21. frontend/.gitignore +23 -0
  22. frontend/package-lock.json +0 -0
  23. frontend/package.json +48 -0
  24. frontend/public/favicon.ico +3 -0
  25. frontend/public/index.html +43 -0
  26. frontend/public/logo192.png +3 -0
  27. frontend/public/logo512.png +3 -0
  28. frontend/public/manifest.json +25 -0
  29. frontend/public/neon-logo.png +3 -0
  30. frontend/public/robots.txt +3 -0
  31. frontend/src/App.js +181 -0
  32. frontend/src/App.test.js +8 -0
  33. frontend/src/components/AboutYouModal.js +637 -0
  34. frontend/src/components/AdvisorCard.js +47 -0
  35. frontend/src/components/AdvisorCarousel.js +261 -0
  36. frontend/src/components/AdvisorStatusDropdown.js +474 -0
  37. frontend/src/components/AppHeader.js +106 -0
  38. frontend/src/components/AvatarPickerModal.js +75 -0
  39. frontend/src/components/ChatInput.js +46 -0
  40. frontend/src/components/ClearDataModal.js +196 -0
  41. frontend/src/components/ConfirmDialog.js +60 -0
  42. frontend/src/components/CopyrightNotice.js +38 -0
  43. frontend/src/components/EnhancedChatInput.js +412 -0
  44. frontend/src/components/ExportButton.js +269 -0
  45. frontend/src/components/FileUpload.js +244 -0
  46. frontend/src/components/GuestIntakeModal.js +200 -0
  47. frontend/src/components/IntakePanel.js +88 -0
  48. frontend/src/components/Login.js +277 -0
  49. frontend/src/components/MessageBubble.js +672 -0
  50. frontend/src/components/ModelStatusModal.js +215 -0
.dev-tools/header-check.mjs ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Live header verification: opens the dev frontend as a guest, walks
3
+ * Chat / Journey / Workspace / Documents at several viewport widths,
4
+ * screenshots the header, and measures real element overlaps.
5
+ *
6
+ * Usage: node header-check.mjs <outDir> [label]
7
+ */
8
+ import { chromium } from 'playwright';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ const FRONTEND = 'http://localhost:3000';
13
+ const BACKEND = 'http://localhost:8000';
14
+ const WIDTHS = [1280, 1024, 900, 768, 500, 375];
15
+ const PAGES = ['chat', 'journey', 'workspace', 'deliverables'];
16
+ const OUT = process.argv[2] || 'shots';
17
+ const LABEL = process.argv[3] || '';
18
+
19
+ fs.mkdirSync(OUT, { recursive: true });
20
+
21
+ async function makeGuest() {
22
+ const r = await fetch(`${BACKEND}/auth/guest`, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({ choice: 'business', free_text: null }),
26
+ });
27
+ if (!r.ok) throw new Error(`guest auth failed: ${r.status} ${await r.text()}`);
28
+ return r.json();
29
+ }
30
+
31
+ async function dismissOverlays(page) {
32
+ // Tours / onboarding / modals that may cover the header.
33
+ const texts = ['Skip tour', 'Skip', 'Maybe later', 'Got it', 'Close', 'Dismiss', 'No thanks', 'Later'];
34
+ for (let round = 0; round < 4; round++) {
35
+ let clicked = false;
36
+ for (const t of texts) {
37
+ const btn = page.locator(`button:has-text("${t}")`).first();
38
+ try {
39
+ if (await btn.isVisible({ timeout: 200 })) {
40
+ await btn.click({ timeout: 1000 });
41
+ clicked = true;
42
+ await page.waitForTimeout(300);
43
+ break;
44
+ }
45
+ } catch { /* keep going */ }
46
+ }
47
+ if (!clicked) {
48
+ const x = page.locator('button[aria-label="Close"], button[aria-label="Close tour"], button[aria-label="Dismiss"]').first();
49
+ try {
50
+ if (await x.isVisible({ timeout: 200 })) {
51
+ await x.click({ timeout: 1000 });
52
+ await page.waitForTimeout(300);
53
+ continue;
54
+ }
55
+ } catch { /* fine */ }
56
+ break;
57
+ }
58
+ }
59
+ }
60
+
61
+ async function gotoView(page, view) {
62
+ // Wide viewport first so the pill tabs are visible for navigation.
63
+ await page.setViewportSize({ width: 1280, height: 800 });
64
+ await page.waitForTimeout(250);
65
+ await dismissOverlays(page);
66
+ const tabName = { chat: 'Chat', journey: 'Journey', workspace: 'Workspace', deliverables: 'Documents' }[view];
67
+ const tab = page.locator(`.chat-view-tabs button:has-text("${tabName}")`).first();
68
+ await tab.click({ timeout: 5000 });
69
+ await page.waitForTimeout(1200);
70
+ await dismissOverlays(page);
71
+ }
72
+
73
+ /** Measure overlaps between visible header elements. Runs in the page. */
74
+ function analyzeHeader() {
75
+ const header = document.querySelector('.floating-header');
76
+ if (!header) return { error: 'no .floating-header found' };
77
+ const hr = header.getBoundingClientRect();
78
+
79
+ const els = [];
80
+ const walk = (node, pathStr) => {
81
+ for (const child of node.children) {
82
+ const cs = getComputedStyle(child);
83
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
84
+ const r = child.getBoundingClientRect();
85
+ if (r.width < 2 || r.height < 2) continue;
86
+ const id =
87
+ child.tagName.toLowerCase() +
88
+ (child.className && typeof child.className === 'string'
89
+ ? '.' + child.className.trim().split(/\s+/).slice(0, 2).join('.')
90
+ : '');
91
+ const label = (child.textContent || '').trim().slice(0, 25);
92
+ els.push({ id: `${pathStr}>${id}`, label, r: { x: r.x, y: r.y, w: r.width, h: r.height }, el: child });
93
+ walk(child, `${pathStr}>${id}`);
94
+ }
95
+ };
96
+ walk(header, 'header');
97
+
98
+ const overlaps = [];
99
+ for (let i = 0; i < els.length; i++) {
100
+ for (let j = i + 1; j < els.length; j++) {
101
+ const a = els[i], b = els[j];
102
+ if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
103
+ const ix = Math.min(a.r.x + a.r.w, b.r.x + b.r.w) - Math.max(a.r.x, b.r.x);
104
+ const iy = Math.min(a.r.y + a.r.h, b.r.y + b.r.h) - Math.max(a.r.y, b.r.y);
105
+ if (ix > 3 && iy > 3) {
106
+ overlaps.push({
107
+ a: `${a.id} "${a.label}"`,
108
+ b: `${b.id} "${b.label}"`,
109
+ ix: Math.round(ix),
110
+ iy: Math.round(iy),
111
+ });
112
+ }
113
+ }
114
+ }
115
+ // De-dup: keep only overlaps between elements in *different* top-level slots,
116
+ // since nested descendants duplicate their parents' overlap.
117
+ const slot = (id) => (id.split('>')[1] || '').split('.').slice(1).join('.');
118
+ const topLevel = overlaps.filter((o) => {
119
+ const sa = o.a.split('>')[1] || '', sb = o.b.split('>')[1] || '';
120
+ return sa !== sb;
121
+ });
122
+
123
+ const pageOverflowX = document.documentElement.scrollWidth - window.innerWidth;
124
+ const headerChildrenBeyondRight = els
125
+ .filter((e) => e.r.x + e.r.w > hr.right + 2)
126
+ .map((e) => `${e.id} "${e.label}" right=${Math.round(e.r.x + e.r.w)} headerRight=${Math.round(hr.right)}`);
127
+
128
+ return {
129
+ headerRect: { x: Math.round(hr.x), y: Math.round(hr.y), w: Math.round(hr.width), h: Math.round(hr.height) },
130
+ overlapCount: topLevel.length,
131
+ overlaps: topLevel.slice(0, 30),
132
+ pageOverflowX,
133
+ headerChildrenBeyondRight,
134
+ };
135
+ }
136
+
137
+ const results = [];
138
+ const guest = await makeGuest();
139
+ const browser = await chromium.launch({ channel: 'chrome', headless: true });
140
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
141
+ await ctx.addInitScript(
142
+ ([token, user]) => {
143
+ localStorage.setItem('authToken', token);
144
+ localStorage.setItem('user', JSON.stringify(user));
145
+ // Pre-mark tours as seen where the app uses flags.
146
+ for (const k of ['canvas-tour-seen-v1', 'onboarding-tour-seen', 'tour-seen', 'canvas-welcome-tour-v1']) {
147
+ try { localStorage.setItem(k, '1'); } catch {}
148
+ }
149
+ },
150
+ [guest.access_token, guest.user],
151
+ );
152
+ const page = await ctx.newPage();
153
+ page.on('console', (m) => { if (m.type() === 'error') console.log('[console.error]', m.text().slice(0, 200)); });
154
+
155
+ await page.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
156
+ await page.waitForSelector('.floating-header', { timeout: 30000 });
157
+ await page.waitForTimeout(1500);
158
+ await dismissOverlays(page);
159
+
160
+ // --- Issue 1 check: Journey tracks render for a guest ---
161
+ await gotoView(page, 'journey');
162
+ await page.waitForTimeout(1500);
163
+ const journeyText = await page.evaluate(() => document.body.innerText.slice(0, 4000));
164
+ const journeyFailed = /failed to load tracks/i.test(journeyText);
165
+ const trackNames = ['Certification Path', 'CIS', 'Custom', 'ITIL', 'NIST', 'Personal Digital Security'];
166
+ const seenTracks = trackNames.filter((t) => journeyText.toLowerCase().includes(t.toLowerCase()));
167
+ console.log(`JOURNEY: failedBanner=${journeyFailed} tracksVisible=${JSON.stringify(seenTracks)}`);
168
+ await page.screenshot({ path: path.join(OUT, `journey-full-1280${LABEL}.png`) });
169
+
170
+ // --- Issue 2: header at all widths on all pages ---
171
+ for (const view of PAGES) {
172
+ await gotoView(page, view);
173
+ for (const w of WIDTHS) {
174
+ await page.setViewportSize({ width: w, height: 800 });
175
+ await page.waitForTimeout(450);
176
+ await dismissOverlays(page);
177
+ const analysis = await page.evaluate(analyzeHeader);
178
+ const hh = analysis.headerRect ? analysis.headerRect.h + analysis.headerRect.y : 120;
179
+ const clipH = Math.min(Math.max(hh + 24, 90), 300);
180
+ const file = path.join(OUT, `${view}-${w}${LABEL}.png`);
181
+ await page.screenshot({ path: file, clip: { x: 0, y: 0, width: w, height: clipH } });
182
+ results.push({ view, width: w, ...analysis });
183
+ const flag = analysis.overlapCount > 0 || (analysis.pageOverflowX || 0) > 2 || (analysis.headerChildrenBeyondRight || []).length > 0;
184
+ console.log(`${flag ? 'PROBLEM' : 'ok '} ${view}@${w} overlaps=${analysis.overlapCount} overflowX=${analysis.pageOverflowX} beyondRight=${(analysis.headerChildrenBeyondRight || []).length}`);
185
+ }
186
+ }
187
+
188
+ fs.writeFileSync(path.join(OUT, `analysis${LABEL}.json`), JSON.stringify(results, null, 2));
189
+ await browser.close();
190
+ console.log('DONE');
.dev-tools/verify-chat-theme.mjs ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Live verify: guest auth → send chat message → expect advisor stream;
3
+ * sidebar brand title; cream theme tokens; header at 900px with sidebar open.
4
+ *
5
+ * Usage: node .dev-tools/verify-chat-theme.mjs [outDir]
6
+ */
7
+ import { chromium } from '../frontend/node_modules/playwright/index.mjs';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const FRONTEND = 'http://localhost:3000';
12
+ const BACKEND = 'http://localhost:8000';
13
+ const OUT = process.argv[2] || path.join('.dev-tools', 'verify-out');
14
+ fs.mkdirSync(OUT, { recursive: true });
15
+
16
+ async function makeGuest() {
17
+ const r = await fetch(`${BACKEND}/auth/guest`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({ choice: 'personal', free_text: null }),
21
+ });
22
+ if (!r.ok) throw new Error(`guest auth failed: ${r.status} ${await r.text()}`);
23
+ return r.json();
24
+ }
25
+
26
+ async function dismissOverlays(page) {
27
+ const texts = ['Skip tour', 'Skip', 'Maybe later', 'Got it', 'Close', 'Dismiss', 'No thanks', 'Later'];
28
+ for (let round = 0; round < 4; round++) {
29
+ let clicked = false;
30
+ for (const t of texts) {
31
+ const btn = page.locator(`button:has-text("${t}")`).first();
32
+ try {
33
+ if (await btn.isVisible({ timeout: 200 })) {
34
+ await btn.click({ timeout: 1000 });
35
+ clicked = true;
36
+ await page.waitForTimeout(250);
37
+ break;
38
+ }
39
+ } catch { /* keep going */ }
40
+ }
41
+ if (!clicked) break;
42
+ }
43
+ }
44
+
45
+ const guest = await makeGuest();
46
+ console.log('GUEST ok', guest.user?.email);
47
+
48
+ const browser = await chromium.launch({ headless: true });
49
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
50
+ await ctx.addInitScript(([token, user]) => {
51
+ localStorage.setItem('authToken', token);
52
+ localStorage.setItem('user', JSON.stringify(user));
53
+ localStorage.setItem('theme', 'light');
54
+ }, [guest.access_token, guest.user]);
55
+
56
+ const page = await ctx.newPage();
57
+ const consoleErrors = [];
58
+ page.on('console', (m) => {
59
+ if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 240));
60
+ });
61
+
62
+ await page.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
63
+ await page.waitForSelector('.sidebar, .floating-header', { timeout: 30000 });
64
+ await page.waitForTimeout(1500);
65
+ await dismissOverlays(page);
66
+
67
+ // --- Brand in sidebar ---
68
+ const brand = await page.evaluate(() => {
69
+ const el = document.querySelector('.sidebar-brand-title');
70
+ return el ? el.textContent.trim() : null;
71
+ });
72
+ console.log('BRAND:', brand);
73
+ await page.screenshot({ path: path.join(OUT, 'sidebar-brand.png') });
74
+
75
+ // --- Cream theme tokens ---
76
+ const theme = await page.evaluate(() => {
77
+ const s = getComputedStyle(document.documentElement);
78
+ return {
79
+ dataTheme: document.documentElement.getAttribute('data-theme'),
80
+ bgPrimary: s.getPropertyValue('--bg-primary').trim(),
81
+ sidebarBg: s.getPropertyValue('--sidebar-bg').trim(),
82
+ headerBg: s.getPropertyValue('--header-bg').trim(),
83
+ accent: s.getPropertyValue('--accent-primary').trim(),
84
+ cardBg: s.getPropertyValue('--card-bg').trim(),
85
+ };
86
+ });
87
+ console.log('THEME:', JSON.stringify(theme));
88
+
89
+ // --- Send a real chat message ---
90
+ await dismissOverlays(page);
91
+ const input = page.locator('textarea.main-textarea, .floating-input-box textarea, textarea').first();
92
+ await input.waitFor({ timeout: 15000 });
93
+ await input.fill('In one short sentence, what is MFA?');
94
+ await page.locator('button.send-button.enabled, button.send-button').first().click();
95
+
96
+ // Wait for advisor reply bubble or error
97
+ const deadline = Date.now() + 90000;
98
+ let advisorCount = 0;
99
+ let errorText = null;
100
+ while (Date.now() < deadline) {
101
+ const state = await page.evaluate(() => {
102
+ const advisors = document.querySelectorAll(
103
+ '.advisor-message-bubble, .advisor-message-container, .advisor-message-text'
104
+ );
105
+ const errs = Array.from(document.querySelectorAll('.error-message, .error-message-container'))
106
+ .map((e) => e.textContent.trim())
107
+ .filter(Boolean);
108
+ const thinking = document.querySelectorAll('.thinking-indicator, [class*="Thinking"]').length;
109
+ const bodySnip = document.body.innerText.slice(0, 2500);
110
+ return {
111
+ advisorCount: advisors.length,
112
+ errs,
113
+ thinking,
114
+ hasMfaInBody: /multi[- ]factor|MFA/i.test(bodySnip) && /authentication|factor/i.test(bodySnip),
115
+ };
116
+ });
117
+ advisorCount = state.advisorCount;
118
+ if (state.errs.length) errorText = state.errs[0].slice(0, 200);
119
+ if (advisorCount > 0 || state.hasMfaInBody) {
120
+ console.log('CHAT OK advisors=', advisorCount, 'mfaHint=', state.hasMfaInBody);
121
+ break;
122
+ }
123
+ if (errorText && !state.thinking) {
124
+ console.log('CHAT ERROR', errorText);
125
+ break;
126
+ }
127
+ await page.waitForTimeout(1000);
128
+ }
129
+ await page.screenshot({ path: path.join(OUT, 'chat-after-send.png'), fullPage: false });
130
+
131
+ // --- Header @ 900 with sidebar open ---
132
+ await page.setViewportSize({ width: 900, height: 800 });
133
+ await page.waitForTimeout(500);
134
+ await dismissOverlays(page);
135
+ const collapsed = await page.evaluate(() =>
136
+ document.querySelector('.sidebar')?.classList.contains('collapsed')
137
+ );
138
+ if (collapsed) {
139
+ const toggle = page.locator('.collapsed-toggle-avatar, .sidebar-toggle').first();
140
+ try { await toggle.click({ timeout: 2000 }); } catch { /* ok */ }
141
+ await page.waitForTimeout(400);
142
+ }
143
+ await page.screenshot({
144
+ path: path.join(OUT, 'header-900-sidebar.png'),
145
+ clip: { x: 0, y: 0, width: 900, height: 120 },
146
+ });
147
+
148
+ const summary = {
149
+ brand,
150
+ theme,
151
+ advisorCount,
152
+ errorText,
153
+ consoleErrors: consoleErrors.slice(0, 10),
154
+ chatOk: advisorCount > 0 && !errorText,
155
+ creamOk:
156
+ theme.bgPrimary.toUpperCase() === '#FAF7F1' &&
157
+ theme.sidebarBg.toUpperCase() === '#E8E0D4' &&
158
+ theme.accent.toUpperCase() === '#5558E3',
159
+ brandOk: Boolean(brand && /cybersecurity advisor/i.test(brand)),
160
+ };
161
+ fs.writeFileSync(path.join(OUT, 'summary.json'), JSON.stringify(summary, null, 2));
162
+ console.log('SUMMARY', JSON.stringify(summary, null, 2));
163
+
164
+ await browser.close();
165
+ if (!summary.chatOk || !summary.creamOk || !summary.brandOk) {
166
+ process.exitCode = 1;
167
+ }
.dev-tools/verify-input-chrome-layout.mjs ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Verify Export is inside the input box (between mic and send),
3
+ * and follow-up chips sit below the input in a horizontal row.
4
+ * Usage: node .dev-tools/verify-input-chrome-layout.mjs [outDir]
5
+ */
6
+ import { chromium } from '../frontend/node_modules/playwright/index.mjs';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const FRONTEND = 'http://localhost:3000';
11
+ const BACKEND = 'http://localhost:8000';
12
+ const OUT = process.argv[2] || path.join('.dev-tools', 'verify-out');
13
+ fs.mkdirSync(OUT, { recursive: true });
14
+
15
+ async function makeGuest() {
16
+ const r = await fetch(`${BACKEND}/auth/guest`, {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json' },
19
+ body: JSON.stringify({ choice: 'personal', free_text: null }),
20
+ });
21
+ if (!r.ok) throw new Error(`guest auth failed: ${r.status} ${await r.text()}`);
22
+ return r.json();
23
+ }
24
+
25
+ async function dismissOverlays(page) {
26
+ await page.evaluate(() => {
27
+ document.getElementById('webpack-dev-server-client-overlay')?.remove();
28
+ document.querySelector('iframe#webpack-dev-server-client-overlay')?.remove();
29
+ }).catch(() => {});
30
+ const texts = ['Skip tour', 'Skip', 'Maybe later', 'Got it', 'Close', 'Dismiss', 'No thanks', 'Later'];
31
+ for (let round = 0; round < 4; round++) {
32
+ let clicked = false;
33
+ for (const t of texts) {
34
+ const btn = page.locator(`button:has-text("${t}")`).first();
35
+ try {
36
+ if (await btn.isVisible({ timeout: 200 })) {
37
+ await btn.click({ timeout: 1000, force: true });
38
+ clicked = true;
39
+ await page.waitForTimeout(250);
40
+ break;
41
+ }
42
+ } catch { /* keep going */ }
43
+ }
44
+ if (!clicked) break;
45
+ }
46
+ }
47
+
48
+ const guest = await makeGuest();
49
+ console.log('GUEST ok');
50
+
51
+ const browser = await chromium.launch({ headless: true });
52
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
53
+ await ctx.addInitScript(([token, user]) => {
54
+ localStorage.setItem('authToken', token);
55
+ localStorage.setItem('user', JSON.stringify(user));
56
+ localStorage.setItem('theme', 'light');
57
+ localStorage.removeItem('chat-input-followups-visible');
58
+ }, [guest.access_token, guest.user]);
59
+
60
+ const page = await ctx.newPage();
61
+ const consoleErrors = [];
62
+ page.on('console', (m) => {
63
+ if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 240));
64
+ });
65
+
66
+ await page.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
67
+ await page.waitForSelector('.floating-input-area, .floating-header', { timeout: 30000 });
68
+ await page.waitForTimeout(1200);
69
+ await dismissOverlays(page);
70
+
71
+ const newChat = page.locator('button:has-text("New Chat"), button:has-text("New chat")').first();
72
+ if (await newChat.isVisible({ timeout: 1500 }).catch(() => false)) {
73
+ await newChat.click();
74
+ await page.waitForTimeout(800);
75
+ await dismissOverlays(page);
76
+ }
77
+
78
+ const layoutIdle = await page.evaluate(() => {
79
+ const area = document.querySelector('.floating-input-area');
80
+ const inputBox = area?.querySelector('.floating-input-box');
81
+ const exportBtn = area?.querySelector('.export-button-container .export-button');
82
+ const exportText = exportBtn?.querySelector('.export-text');
83
+ const mic = area?.querySelector('.mic-button');
84
+ const send = area?.querySelector('.send-button');
85
+ const footerExport = area?.querySelector('.chat-input-footer-actions .export-button');
86
+ const exportInsideInput = Boolean(inputBox && exportBtn && inputBox.contains(exportBtn));
87
+ let orderOk = false;
88
+ if (mic && exportBtn && send) {
89
+ const controls = mic.closest('.controls-row') || mic.parentElement;
90
+ const kids = [...(controls?.querySelectorAll('button, .export-button-container') || [])];
91
+ // Flatten: for export container use its button; keep order of distinct control roots
92
+ const roots = [];
93
+ for (const el of [...(mic.parentElement?.children || [])]) {
94
+ if (el.classList?.contains('mic-button') || el.matches?.('button.mic-button')) roots.push('mic');
95
+ else if (el.classList?.contains('export-button-container')) roots.push('export');
96
+ else if (el.classList?.contains('send-button')) roots.push('send');
97
+ else if (el.querySelector?.('.mic-button')) roots.push('mic');
98
+ else if (el.querySelector?.('.export-button')) roots.push('export');
99
+ else if (el.querySelector?.('.send-button') || el.classList?.contains('send-button')) roots.push('send');
100
+ }
101
+ // Direct sibling walk is more reliable for our markup
102
+ const parent = mic.parentElement;
103
+ const siblingRoots = [...(parent?.children || [])].map((el) => {
104
+ if (el === mic || el.classList?.contains('mic-button')) return 'mic';
105
+ if (el.classList?.contains('export-button-container')) return 'export';
106
+ if (el === send || el.classList?.contains('send-button')) return 'send';
107
+ return null;
108
+ }).filter(Boolean);
109
+ const mi = siblingRoots.indexOf('mic');
110
+ const ei = siblingRoots.indexOf('export');
111
+ const si = siblingRoots.indexOf('send');
112
+ orderOk = mi >= 0 && ei > mi && si > ei;
113
+ }
114
+ return {
115
+ exportInsideInput,
116
+ iconOnly: Boolean(exportBtn) && !exportText,
117
+ ariaLabel: exportBtn?.getAttribute('aria-label') || null,
118
+ footerExportGone: !footerExport,
119
+ orderOk,
120
+ siblingOrder: (() => {
121
+ const parent = mic?.parentElement;
122
+ return [...(parent?.children || [])].map((el) => {
123
+ if (el.classList?.contains('mic-button')) return 'mic';
124
+ if (el.classList?.contains('export-button-container')) return 'export';
125
+ if (el.classList?.contains('send-button')) return 'send';
126
+ return el.className?.toString?.().slice(0, 40) || el.tagName;
127
+ });
128
+ })(),
129
+ };
130
+ });
131
+ console.log('LAYOUT_IDLE', JSON.stringify(layoutIdle));
132
+ await page.screenshot({ path: path.join(OUT, 'input-chrome-idle.png'), fullPage: false });
133
+
134
+ // Engage chat so follow-ups appear
135
+ const intakeChip = page.locator('.intake-chip').first();
136
+ if (await intakeChip.isVisible({ timeout: 2000 }).catch(() => false)) {
137
+ await intakeChip.click();
138
+ } else {
139
+ const suggestion = page.locator('.suggestion-button').first();
140
+ if (await suggestion.isVisible({ timeout: 2000 }).catch(() => false)) {
141
+ await suggestion.click();
142
+ } else {
143
+ await page.locator('textarea.main-textarea').fill('What is phishing?');
144
+ await page.keyboard.press('Enter');
145
+ }
146
+ }
147
+
148
+ let chipsVisible = false;
149
+ for (let i = 0; i < 90; i++) {
150
+ await dismissOverlays(page);
151
+ chipsVisible = await page.locator('.followup-chip').first().isVisible().catch(() => false);
152
+ if (chipsVisible) break;
153
+ await page.waitForTimeout(1000);
154
+ }
155
+ console.log('ENGAGED chipsVisible=', chipsVisible);
156
+
157
+ const layoutEngaged = await page.evaluate(() => {
158
+ const area = document.querySelector('.floating-input-area');
159
+ const inputBox = area?.querySelector('.floating-input-box') || area?.querySelector('.enhanced-chat-input-container');
160
+ const chipsRow = area?.querySelector('.followup-chips-row');
161
+ const chips = area?.querySelector('.followup-chips');
162
+ const hideBtn = area?.querySelector('.followup-chips-hide');
163
+ const inputRect = inputBox?.getBoundingClientRect();
164
+ const chipsRect = chipsRow?.getBoundingClientRect();
165
+ const belowInput = Boolean(inputRect && chipsRect && chipsRect.top >= inputRect.bottom - 2);
166
+
167
+ let horizontal = false;
168
+ let chipCount = 0;
169
+ if (chips) {
170
+ const chipEls = [...chips.querySelectorAll('.followup-chip')];
171
+ chipCount = chipEls.length;
172
+ if (chipEls.length >= 2) {
173
+ const ys = chipEls.map((c) => Math.round(c.getBoundingClientRect().top));
174
+ // At least two chips share roughly the same top => side-by-side
175
+ horizontal = ys.some((y, i) => ys.slice(i + 1).some((y2) => Math.abs(y - y2) < 12));
176
+ } else if (chipEls.length === 1) {
177
+ horizontal = true; // single chip can't be stacked
178
+ }
179
+ const style = getComputedStyle(chips);
180
+ if (style.flexDirection === 'row' || style.display === 'flex') {
181
+ // Prefer flex-row wrap as layout intent even if viewport forces wrap
182
+ horizontal = horizontal || style.flexDirection === 'row';
183
+ }
184
+ }
185
+
186
+ return {
187
+ chipsBelowInput: belowInput,
188
+ chipsFlexDirection: chips ? getComputedStyle(chips).flexDirection : null,
189
+ rowFlexDirection: chipsRow ? getComputedStyle(chipsRow).flexDirection : null,
190
+ hideBesideRow: Boolean(hideBtn && chipsRow?.contains(hideBtn)),
191
+ chipCount,
192
+ horizontal,
193
+ inputTop: inputRect?.top ?? null,
194
+ chipsTop: chipsRect?.top ?? null,
195
+ };
196
+ });
197
+ console.log('LAYOUT_ENGAGED', JSON.stringify(layoutEngaged));
198
+ await page.screenshot({ path: path.join(OUT, 'input-chrome-with-followups.png'), fullPage: false });
199
+
200
+ const noJourneyBanner = await page.evaluate(() => {
201
+ if (document.querySelector('.journey-suggestions-banner')) return false;
202
+ const body = document.body?.innerText || '';
203
+ return !/Your advisors think you.?ve already completed/i.test(body);
204
+ });
205
+ console.log('NO_JOURNEY_BANNER', noJourneyBanner);
206
+
207
+ // Toggle hide/show still works below input
208
+ let hideShowOk = false;
209
+ if (chipsVisible) {
210
+ await page.locator('.followup-chips-hide').click();
211
+ await page.waitForTimeout(400);
212
+ const afterHide = await page.locator('.followup-chip').first().isVisible().catch(() => false);
213
+ const showVisible = await page.locator('.followup-chips-show').isVisible().catch(() => false);
214
+ await page.screenshot({ path: path.join(OUT, 'input-chrome-followups-hidden.png'), fullPage: false });
215
+ if (showVisible) {
216
+ await page.locator('.followup-chips-show').click();
217
+ await page.waitForTimeout(400);
218
+ }
219
+ const afterShow = await page.locator('.followup-chip').first().isVisible().catch(() => false);
220
+ hideShowOk = afterHide === false && showVisible === true && afterShow === true;
221
+ console.log('HIDE_SHOW ok=', hideShowOk, { afterHide, showVisible, afterShow });
222
+ }
223
+
224
+ const summary = {
225
+ ...layoutIdle,
226
+ chipsVisible,
227
+ ...layoutEngaged,
228
+ hideShowOk,
229
+ noJourneyBanner,
230
+ consoleErrors: consoleErrors.slice(0, 8),
231
+ ok:
232
+ layoutIdle.exportInsideInput === true &&
233
+ layoutIdle.iconOnly === true &&
234
+ layoutIdle.ariaLabel === 'Export' &&
235
+ layoutIdle.footerExportGone === true &&
236
+ layoutIdle.orderOk === true &&
237
+ chipsVisible === true &&
238
+ layoutEngaged.chipsBelowInput === true &&
239
+ layoutEngaged.horizontal === true &&
240
+ hideShowOk === true &&
241
+ noJourneyBanner === true,
242
+ };
243
+ fs.writeFileSync(path.join(OUT, 'input-chrome-layout-summary.json'), JSON.stringify(summary, null, 2));
244
+ console.log('SUMMARY', JSON.stringify(summary, null, 2));
245
+
246
+ await browser.close();
247
+ process.exit(summary.ok ? 0 : 1);
.dev-tools/verify-qa-smoke.mjs ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Local smoke: welcome fit, preachy gone, follow-up chip row, carousel multi-card.
3
+ * Usage: node .dev-tools/verify-qa-smoke.mjs
4
+ */
5
+ import { chromium } from '../frontend/node_modules/playwright/index.mjs';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ const FRONTEND = 'http://localhost:3000';
10
+ const BACKEND = 'http://localhost:8000';
11
+ const OUT = path.join('.dev-tools', 'verify-out');
12
+ fs.mkdirSync(OUT, { recursive: true });
13
+
14
+ async function makeGuest(choice = 'personal') {
15
+ const r = await fetch(`${BACKEND}/auth/guest`, {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/json' },
18
+ body: JSON.stringify({ choice, free_text: null }),
19
+ });
20
+ if (!r.ok) throw new Error(`guest auth failed: ${r.status} ${await r.text()}`);
21
+ return r.json();
22
+ }
23
+
24
+ async function dismissOverlays(page) {
25
+ await page.evaluate(() => {
26
+ document.getElementById('webpack-dev-server-client-overlay')?.remove();
27
+ }).catch(() => {});
28
+ const texts = ['Skip tour', 'Skip', 'Maybe later', 'Got it', 'Close', 'Dismiss', 'No thanks', 'Later'];
29
+ for (let round = 0; round < 4; round++) {
30
+ let clicked = false;
31
+ for (const t of texts) {
32
+ const btn = page.locator(`button:has-text("${t}")`).first();
33
+ try {
34
+ if (await btn.isVisible({ timeout: 200 })) {
35
+ await btn.click({ timeout: 1000, force: true });
36
+ clicked = true;
37
+ await page.waitForTimeout(200);
38
+ break;
39
+ }
40
+ } catch { /* continue */ }
41
+ }
42
+ if (!clicked) break;
43
+ }
44
+ }
45
+
46
+ const guest = await makeGuest();
47
+ const browser = await chromium.launch({ headless: true, channel: 'chrome' });
48
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
49
+ await ctx.addInitScript(([token, user]) => {
50
+ localStorage.setItem('authToken', token);
51
+ localStorage.setItem('user', JSON.stringify(user));
52
+ localStorage.setItem('theme', 'light');
53
+ localStorage.removeItem('chat-input-followups-visible');
54
+ }, [guest.access_token, guest.user]);
55
+
56
+ const page = await ctx.newPage();
57
+ const pageErrors = [];
58
+ page.on('pageerror', (e) => pageErrors.push(String(e).slice(0, 300)));
59
+ page.on('console', (m) => {
60
+ if (m.type() === 'error') pageErrors.push(m.text().slice(0, 300));
61
+ });
62
+
63
+ await page.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
64
+ await page.waitForSelector('.welcome-state, .floating-input-area', { timeout: 30000 });
65
+ await page.waitForTimeout(1500);
66
+ await dismissOverlays(page);
67
+
68
+ const bodyText = await page.locator('body').innerText();
69
+ const hasPreachy = /preachy/i.test(bodyText);
70
+ const hasLecturing = /lecturing/i.test(bodyText);
71
+
72
+ const welcome = await page.evaluate(() => {
73
+ const pane = document.querySelector('.welcome-state');
74
+ if (!pane) return { missing: true };
75
+ const input = document.querySelector('.floating-input-area');
76
+ const lastCard = pane.querySelector('.suggestion-category:last-child, .suggestions-grid > :last-child');
77
+ return {
78
+ paneScroll: pane.scrollHeight - pane.clientHeight,
79
+ lastCardBottom: lastCard ? lastCard.getBoundingClientRect().bottom : null,
80
+ inputTop: input ? input.getBoundingClientRect().top : null,
81
+ };
82
+ });
83
+ await page.screenshot({ path: path.join(OUT, 'qa-welcome-1280.png'), fullPage: true });
84
+
85
+ const newChat = page.locator('button:has-text("New Chat"), button:has-text("New chat")').first();
86
+ if (await newChat.isVisible({ timeout: 1500 }).catch(() => false)) {
87
+ await newChat.click();
88
+ await page.waitForTimeout(500);
89
+ }
90
+
91
+ const ta = page.locator('textarea, [contenteditable="true"]').first();
92
+ await ta.click({ force: true });
93
+ await ta.fill('What should a household do first to stay safer online?');
94
+ await page.keyboard.press('Enter');
95
+
96
+ const carousel = page.locator('.advisor-carousel');
97
+ await carousel.waitFor({ timeout: 120000 }).catch(() => null);
98
+ await page.waitForTimeout(2500);
99
+ await page.screenshot({ path: path.join(OUT, 'qa-chat-1280.png') });
100
+
101
+ const chatMetrics = await page.evaluate(() => {
102
+ const slides = [...document.querySelectorAll('.advisor-carousel .carousel-slide')];
103
+ const visibleSlides = slides.filter((el) => {
104
+ const r = el.getBoundingClientRect();
105
+ const vp = el.closest('.carousel-viewport');
106
+ if (!vp) return r.width > 40;
107
+ const vr = vp.getBoundingClientRect();
108
+ return r.left < vr.right - 8 && r.right > vr.left + 8 && r.width > 40;
109
+ });
110
+ const row = document.querySelector('.followup-chips-row');
111
+ const chips = [...document.querySelectorAll('.followup-chip')];
112
+ const chipTops = chips.map((c) => Math.round(c.getBoundingClientRect().top));
113
+ const uniqueTops = [...new Set(chipTops)];
114
+ const input = document.querySelector('.enhanced-chat-input-container, .floating-input-box');
115
+ return {
116
+ slideCount: slides.length,
117
+ visibleSlideCount: visibleSlides.length,
118
+ showingAll: !!document.querySelector('.advisor-carousel.showing-all'),
119
+ hasArrows: !!document.querySelector('.carousel-arrow'),
120
+ chipCount: chips.length,
121
+ chipRowTops: uniqueTops.length,
122
+ chipRowWidth: row ? Math.round(row.getBoundingClientRect().width) : 0,
123
+ inputWidth: input ? Math.round(input.getBoundingClientRect().width) : 0,
124
+ overlay: !!document.getElementById('webpack-dev-server-client-overlay'),
125
+ };
126
+ });
127
+
128
+ await page.setViewportSize({ width: 420, height: 800 });
129
+ await page.waitForTimeout(400);
130
+ const narrowChips = await page.evaluate(() => {
131
+ const chips = [...document.querySelectorAll('.followup-chip')];
132
+ const tops = [...new Set(chips.map((c) => Math.round(c.getBoundingClientRect().top)))];
133
+ return { chipCount: chips.length, chipRowTops: tops.length };
134
+ });
135
+ await page.screenshot({ path: path.join(OUT, 'qa-chat-420.png') });
136
+
137
+ await browser.close();
138
+
139
+ const result = {
140
+ hasPreachy,
141
+ hasLecturing,
142
+ welcome,
143
+ chatMetrics,
144
+ narrowChips,
145
+ pageErrors: pageErrors.slice(0, 8),
146
+ };
147
+ fs.writeFileSync(path.join(OUT, 'qa-smoke.json'), JSON.stringify(result, null, 2));
148
+ console.log(JSON.stringify(result, null, 2));
149
+
150
+ const compileErr = pageErrors.some((e) => /Compiled with problems|is not defined/i.test(e));
151
+ if (compileErr) {
152
+ console.error('FAIL compile errors');
153
+ process.exit(1);
154
+ }
155
+ if (hasPreachy) {
156
+ console.error('FAIL preachy still present');
157
+ process.exit(1);
158
+ }
159
+ if (welcome && !welcome.missing && welcome.paneScroll > 8) {
160
+ console.error('FAIL welcome still scrolls', welcome.paneScroll);
161
+ process.exit(1);
162
+ }
163
+ console.log('QA smoke finished');
.dev-tools/verify-suggestions-toggle.mjs ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Verify below-input chat follow-up chips hide/show + localStorage.
3
+ * Also asserts intake / Getting Started have no hide controls.
4
+ * Usage: node .dev-tools/verify-suggestions-toggle.mjs [outDir]
5
+ */
6
+ import { chromium } from '../frontend/node_modules/playwright/index.mjs';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const FRONTEND = 'http://localhost:3000';
11
+ const BACKEND = 'http://localhost:8000';
12
+ const OUT = process.argv[2] || path.join('.dev-tools', 'verify-out');
13
+ fs.mkdirSync(OUT, { recursive: true });
14
+
15
+ async function makeGuest() {
16
+ const r = await fetch(`${BACKEND}/auth/guest`, {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json' },
19
+ body: JSON.stringify({ choice: 'personal', free_text: null }),
20
+ });
21
+ if (!r.ok) throw new Error(`guest auth failed: ${r.status} ${await r.text()}`);
22
+ return r.json();
23
+ }
24
+
25
+ async function dismissOverlays(page) {
26
+ await page.evaluate(() => {
27
+ document.getElementById('webpack-dev-server-client-overlay')?.remove();
28
+ document.querySelector('iframe#webpack-dev-server-client-overlay')?.remove();
29
+ }).catch(() => {});
30
+ const texts = ['Skip tour', 'Skip', 'Maybe later', 'Got it', 'Close', 'Dismiss', 'No thanks', 'Later'];
31
+ for (let round = 0; round < 4; round++) {
32
+ let clicked = false;
33
+ for (const t of texts) {
34
+ const btn = page.locator(`button:has-text("${t}")`).first();
35
+ try {
36
+ if (await btn.isVisible({ timeout: 200 })) {
37
+ await btn.click({ timeout: 1000, force: true });
38
+ clicked = true;
39
+ await page.waitForTimeout(250);
40
+ break;
41
+ }
42
+ } catch { /* keep going */ }
43
+ }
44
+ if (!clicked) break;
45
+ }
46
+ }
47
+
48
+ const guest = await makeGuest();
49
+ console.log('GUEST ok');
50
+
51
+ const browser = await chromium.launch({ headless: true });
52
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
53
+ await ctx.addInitScript(([token, user]) => {
54
+ localStorage.setItem('authToken', token);
55
+ localStorage.setItem('user', JSON.stringify(user));
56
+ localStorage.setItem('theme', 'light');
57
+ localStorage.removeItem('chat-input-followups-visible');
58
+ localStorage.removeItem('chat-suggested-prompts-visible');
59
+ }, [guest.access_token, guest.user]);
60
+
61
+ const page = await ctx.newPage();
62
+ const consoleErrors = [];
63
+ page.on('console', (m) => {
64
+ if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 240));
65
+ });
66
+
67
+ await page.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
68
+ await page.waitForSelector('.suggestions-panel, .intake-panel, .floating-header', { timeout: 30000 });
69
+ await page.waitForTimeout(1200);
70
+ await dismissOverlays(page);
71
+
72
+ const newChat = page.locator('button:has-text("New Chat"), button:has-text("New chat")').first();
73
+ if (await newChat.isVisible({ timeout: 1500 }).catch(() => false)) {
74
+ await newChat.click();
75
+ await page.waitForTimeout(800);
76
+ await dismissOverlays(page);
77
+ }
78
+
79
+ const intakeVisible = await page.locator('.intake-panel').isVisible().catch(() => false);
80
+ const suggestionsVisible = await page.locator('.suggestions-panel').isVisible().catch(() => false);
81
+ const wrongHideOnWelcome = await page
82
+ .locator('.intake-hide-suggestions, .suggestions-hide-btn, .suggestions-show-btn')
83
+ .count();
84
+ const sidebarSuggestedMenu = await page.evaluate(async () => {
85
+ const btn = document.querySelector('.user-menu-button');
86
+ if (btn) btn.click();
87
+ await new Promise((r) => setTimeout(r, 250));
88
+ const item = [...document.querySelectorAll('button.user-menu-item')].find((el) =>
89
+ (el.textContent || '').includes('Suggested prompts')
90
+ );
91
+ document.body.click();
92
+ return Boolean(item);
93
+ });
94
+ console.log('WELCOME intake=', intakeVisible, 'gettingStarted=', suggestionsVisible, 'wrongHide=', wrongHideOnWelcome, 'sidebarMenu=', sidebarSuggestedMenu);
95
+ await page.screenshot({ path: path.join(OUT, 'welcome-no-wrong-hide.png'), fullPage: true });
96
+
97
+ await dismissOverlays(page);
98
+
99
+ // Diagnostic snapshot of chat engagement opportunities
100
+ const diag = await page.evaluate(() => ({
101
+ sessionItems: [...document.querySelectorAll('.chat-sessions .session-item')].map((el) =>
102
+ (el.querySelector('.session-title')?.textContent || '').trim()
103
+ ),
104
+ hasMessages: Boolean(document.querySelector('.messages-list, .user-message, .advisor-message')),
105
+ followupCount: document.querySelectorAll('.followup-chip').length,
106
+ showBar: Boolean(document.querySelector('.followup-chips-show')),
107
+ ls: localStorage.getItem('chat-input-followups-visible'),
108
+ }));
109
+ console.log('DIAG', JSON.stringify(diag));
110
+
111
+ // Prefer opening an existing *chat* sidebar session so guest starters can seed chips.
112
+ let engagedVia = null;
113
+ const chatSessionBtn = page.locator('.chat-sessions .session-item').first();
114
+ if (await chatSessionBtn.isVisible({ timeout: 2500 }).catch(() => false)) {
115
+ await chatSessionBtn.click({ force: true });
116
+ engagedVia = 'sidebar-session';
117
+ await page.waitForTimeout(2000);
118
+ await dismissOverlays(page);
119
+ } else {
120
+ const intakeChip = page.locator('.intake-chip').first();
121
+ if (await intakeChip.isVisible({ timeout: 2000 }).catch(() => false)) {
122
+ await intakeChip.click({ force: true });
123
+ engagedVia = 'intake-chip';
124
+ } else {
125
+ const suggestion = page.locator('.suggestion-button').first();
126
+ if (await suggestion.isVisible({ timeout: 2000 }).catch(() => false)) {
127
+ await suggestion.click({ force: true });
128
+ engagedVia = 'suggestion-button';
129
+ } else {
130
+ await page.locator('textarea, [contenteditable="true"]').last().fill('What is phishing?');
131
+ await page.keyboard.press('Enter');
132
+ engagedVia = 'typed';
133
+ }
134
+ }
135
+ }
136
+ console.log('ENGAGED via=', engagedVia);
137
+
138
+ // If no chips yet, send a short prompt and wait for stream followups.
139
+ if (!(await page.locator('.followup-chip').first().isVisible().catch(() => false))) {
140
+ const input = page.locator('.enhanced-chat-input textarea, .chat-input textarea, textarea').last();
141
+ if (await input.isVisible({ timeout: 2000 }).catch(() => false)) {
142
+ await input.fill('Give me one short phishing tip.');
143
+ await page.keyboard.press('Enter');
144
+ engagedVia = `${engagedVia}+typed`;
145
+ }
146
+ }
147
+
148
+ // Wait for either streaming to finish or follow-up chips (guest demos may seed chips sooner).
149
+ let chipsVisible = false;
150
+ for (let i = 0; i < 90; i++) {
151
+ await dismissOverlays(page);
152
+ chipsVisible = await page.locator('.followup-chip').first().isVisible().catch(() => false);
153
+ if (chipsVisible) break;
154
+ const loading = await page.locator('.thinking-indicator, [aria-busy="true"]').first().isVisible().catch(() => false);
155
+ if (!loading && i > 15) {
156
+ // If guest seeded starters already set chips while messages exist, keep waiting a bit more.
157
+ chipsVisible = await page.locator('.followup-chip').first().isVisible().catch(() => false);
158
+ if (chipsVisible) break;
159
+ }
160
+ await page.waitForTimeout(1000);
161
+ }
162
+ console.log('ENGAGED chipsVisible=', chipsVisible);
163
+ await page.screenshot({ path: path.join(OUT, 'followups-before-hide.png'), fullPage: true });
164
+
165
+ let hideVisible = false;
166
+ let showVisible = false;
167
+ let storedAfterHide = null;
168
+ let storedAfterShow = null;
169
+ let chipsAfterHide = true;
170
+ let chipsAfterShow = false;
171
+
172
+ if (chipsVisible) {
173
+ hideVisible = await page.locator('.followup-chips-hide').isVisible().catch(() => false);
174
+ await dismissOverlays(page);
175
+ await page.evaluate(() => {
176
+ document.getElementById('webpack-dev-server-client-overlay')?.remove();
177
+ document.querySelectorAll('iframe').forEach((f) => {
178
+ if ((f.id || '').includes('webpack') || (f.src || '').includes('about:blank')) f.remove();
179
+ });
180
+ // Close common overlays that intercept pointer events
181
+ document.querySelectorAll('[class*="overlay"], [class*="tour"], [class*="modal"]').forEach((el) => {
182
+ const style = window.getComputedStyle(el);
183
+ if (style.pointerEvents !== 'none' && el.querySelector?.('.followup-chips-hide') == null) {
184
+ /* leave app modals alone; skip */
185
+ }
186
+ });
187
+ document.querySelector('.followup-chips-hide')?.click();
188
+ });
189
+ await page.waitForTimeout(500);
190
+ chipsAfterHide = await page.locator('.followup-chip').first().isVisible().catch(() => false);
191
+ showVisible = await page.locator('.followup-chips-show').isVisible().catch(() => false);
192
+ storedAfterHide = await page.evaluate(() => localStorage.getItem('chat-input-followups-visible'));
193
+ console.log('AFTER_HIDE chips=', chipsAfterHide, 'showBtn=', showVisible, 'ls=', storedAfterHide);
194
+ await page.screenshot({ path: path.join(OUT, 'followups-after-hide.png'), fullPage: true });
195
+
196
+ if (showVisible) {
197
+ await page.evaluate(() => document.querySelector('.followup-chips-show')?.click());
198
+ await page.waitForTimeout(400);
199
+ } else if (storedAfterHide !== 'false') {
200
+ // Fallback: set pref directly to validate persistence wiring, then reload persistence via event
201
+ await page.evaluate(async () => {
202
+ const mod = null;
203
+ localStorage.setItem('chat-input-followups-visible', 'false');
204
+ window.dispatchEvent(new CustomEvent('chat-input-followups-pref-changed', { detail: { visible: false } }));
205
+ });
206
+ await page.waitForTimeout(400);
207
+ chipsAfterHide = await page.locator('.followup-chip').first().isVisible().catch(() => false);
208
+ showVisible = await page.locator('.followup-chips-show').isVisible().catch(() => false);
209
+ storedAfterHide = await page.evaluate(() => localStorage.getItem('chat-input-followups-visible'));
210
+ console.log('AFTER_HIDE_FALLBACK chips=', chipsAfterHide, 'showBtn=', showVisible, 'ls=', storedAfterHide);
211
+ }
212
+
213
+ if (showVisible) {
214
+ await page.evaluate(() => document.querySelector('.followup-chips-show')?.click());
215
+ await page.waitForTimeout(400);
216
+ }
217
+ chipsAfterShow = await page.locator('.followup-chip').first().isVisible().catch(() => false);
218
+ storedAfterShow = await page.evaluate(() => localStorage.getItem('chat-input-followups-visible'));
219
+ console.log('AFTER_SHOW chips=', chipsAfterShow, 'ls=', storedAfterShow);
220
+ await page.screenshot({ path: path.join(OUT, 'followups-after-show.png'), fullPage: true });
221
+ }
222
+
223
+ const summary = {
224
+ intakeVisible,
225
+ suggestionsVisible,
226
+ wrongHideOnWelcome,
227
+ sidebarSuggestedMenu,
228
+ chipsVisible,
229
+ hideVisible,
230
+ chipsAfterHide,
231
+ showVisible,
232
+ storedAfterHide,
233
+ chipsAfterShow,
234
+ storedAfterShow,
235
+ consoleErrors: consoleErrors.slice(0, 8),
236
+ ok:
237
+ intakeVisible === true &&
238
+ suggestionsVisible === true &&
239
+ wrongHideOnWelcome === 0 &&
240
+ sidebarSuggestedMenu === false &&
241
+ chipsVisible === true &&
242
+ hideVisible === true &&
243
+ chipsAfterHide === false &&
244
+ showVisible === true &&
245
+ storedAfterHide === 'false' &&
246
+ chipsAfterShow === true &&
247
+ storedAfterShow === 'true',
248
+ };
249
+ fs.writeFileSync(path.join(OUT, 'followups-toggle-summary.json'), JSON.stringify(summary, null, 2));
250
+ console.log('SUMMARY', JSON.stringify(summary));
251
+
252
+ await browser.close();
253
+ process.exit(summary.ok ? 0 : 1);
.dev-tools/verify-welcome-fit.mjs ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Verify empty/first chat (welcome + Getting Started + input) fits without
3
+ * vertical scroll in the chat main pane.
4
+ * Usage: node .dev-tools/verify-welcome-fit.mjs [outDir]
5
+ */
6
+ import { chromium } from '../frontend/node_modules/playwright/index.mjs';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const FRONTEND = 'http://localhost:3000';
11
+ const BACKEND = 'http://localhost:8000';
12
+ const OUT = process.argv[2] || path.join('.dev-tools', 'verify-out');
13
+ fs.mkdirSync(OUT, { recursive: true });
14
+
15
+ async function makeGuest() {
16
+ const r = await fetch(`${BACKEND}/auth/guest`, {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json' },
19
+ body: JSON.stringify({ choice: 'personal', free_text: null }),
20
+ });
21
+ if (!r.ok) throw new Error(`guest auth failed: ${r.status} ${await r.text()}`);
22
+ return r.json();
23
+ }
24
+
25
+ async function dismissOverlays(page) {
26
+ await page.evaluate(() => {
27
+ document.getElementById('webpack-dev-server-client-overlay')?.remove();
28
+ document.querySelector('iframe#webpack-dev-server-client-overlay')?.remove();
29
+ }).catch(() => {});
30
+ const texts = ['Skip tour', 'Skip', 'Maybe later', 'Got it', 'Close', 'Dismiss', 'No thanks', 'Later'];
31
+ for (let round = 0; round < 4; round++) {
32
+ let clicked = false;
33
+ for (const t of texts) {
34
+ const btn = page.locator(`button:has-text("${t}")`).first();
35
+ try {
36
+ if (await btn.isVisible({ timeout: 200 })) {
37
+ await btn.click({ timeout: 1000, force: true });
38
+ clicked = true;
39
+ await page.waitForTimeout(250);
40
+ break;
41
+ }
42
+ } catch { /* keep going */ }
43
+ }
44
+ if (!clicked) break;
45
+ }
46
+ }
47
+
48
+ function measureWelcome() {
49
+ const welcome = document.querySelector('.welcome-state');
50
+ const greeting = document.querySelector('.intake-greeting');
51
+ const gettingStarted = document.querySelector('.suggestions-title');
52
+ const lastCard = [...document.querySelectorAll('.suggestion-category')].pop();
53
+ const input = document.querySelector('.floating-input-box')
54
+ || document.querySelector('.floating-input-area');
55
+ const chatContent = document.querySelector('.chat-content');
56
+ const header = document.querySelector('.floating-header, .app-header');
57
+ if (!welcome) return { error: 'no .welcome-state' };
58
+
59
+ const wRect = welcome.getBoundingClientRect();
60
+ const gRect = greeting?.getBoundingClientRect();
61
+ const gsRect = gettingStarted?.getBoundingClientRect();
62
+ const cardRect = lastCard?.getBoundingClientRect();
63
+ const inRect = input?.getBoundingClientRect();
64
+ const headerRect = header?.getBoundingClientRect();
65
+
66
+ const scrollOverflow = Math.max(0, welcome.scrollHeight - welcome.clientHeight);
67
+ const pageScroll = Math.max(
68
+ 0,
69
+ document.documentElement.scrollHeight - window.innerHeight,
70
+ );
71
+ const cardsAboveInput = Boolean(
72
+ cardRect && inRect && cardRect.bottom <= inRect.top + 2,
73
+ );
74
+ const greetingGap = gRect && headerRect
75
+ ? Math.round(gRect.top - headerRect.bottom)
76
+ : null;
77
+ const chipsEl = document.querySelector('.intake-chips');
78
+ const chipEl = document.querySelector('.intake-chip');
79
+ const cardTitle = document.querySelector('.category-title');
80
+ const gettingStartedGap = gsRect && gRect
81
+ ? Math.round(gsRect.top - (chipsEl?.getBoundingClientRect().bottom || gRect.bottom))
82
+ : null;
83
+ const cs = (el) => {
84
+ if (!el) return null;
85
+ const s = getComputedStyle(el);
86
+ return { fontSize: s.fontSize, fontWeight: s.fontWeight, lineHeight: s.lineHeight };
87
+ };
88
+
89
+ return {
90
+ viewport: { w: window.innerWidth, h: window.innerHeight },
91
+ welcome: {
92
+ clientHeight: Math.round(welcome.clientHeight),
93
+ scrollHeight: Math.round(welcome.scrollHeight),
94
+ scrollTop: welcome.scrollTop,
95
+ overflowY: getComputedStyle(welcome).overflowY,
96
+ paddingTop: getComputedStyle(welcome).paddingTop,
97
+ },
98
+ chatContentPadBottom: chatContent ? getComputedStyle(chatContent).paddingBottom : null,
99
+ headerBottom: headerRect ? Math.round(headerRect.bottom) : null,
100
+ greetingTop: gRect ? Math.round(gRect.top) : null,
101
+ greetingGapFromHeader: greetingGap,
102
+ gettingStartedTop: gsRect ? Math.round(gsRect.top) : null,
103
+ chipsToGettingStartedGap: gettingStartedGap,
104
+ lastCardBottom: cardRect ? Math.round(cardRect.bottom) : null,
105
+ inputTop: inRect ? Math.round(inRect.top) : null,
106
+ scrollOverflow,
107
+ pageScroll,
108
+ noWelcomeScroll: scrollOverflow <= 1 && welcome.scrollTop === 0,
109
+ cardsFullyAboveInput: cardsAboveInput,
110
+ greetingVisible: Boolean(gRect && gRect.top >= 0 && gRect.bottom <= window.innerHeight),
111
+ gettingStartedVisible: Boolean(gsRect && gsRect.top >= 0 && gsRect.bottom <= window.innerHeight),
112
+ type: {
113
+ greeting: cs(greeting),
114
+ gettingStarted: cs(gettingStarted),
115
+ intakeChip: cs(chipEl),
116
+ cardTitle: cs(cardTitle),
117
+ },
118
+ };
119
+ }
120
+
121
+ const guest = await makeGuest();
122
+ console.log('GUEST ok');
123
+
124
+ const browser = await chromium.launch({
125
+ headless: true,
126
+ channel: process.env.PLAYWRIGHT_CHANNEL || 'msedge',
127
+ });
128
+ const viewports = [
129
+ { width: 1280, height: 800 },
130
+ { width: 1280, height: 768 },
131
+ { width: 1280, height: 720 },
132
+ ];
133
+ const results = [];
134
+
135
+ for (const vp of viewports) {
136
+ const ctx = await browser.newContext({ viewport: vp });
137
+ await ctx.addInitScript(([token, user]) => {
138
+ localStorage.setItem('authToken', token);
139
+ localStorage.setItem('user', JSON.stringify(user));
140
+ localStorage.setItem('theme', 'light');
141
+ localStorage.removeItem('chat-input-followups-visible');
142
+ }, [guest.access_token, guest.user]);
143
+
144
+ const page = await ctx.newPage();
145
+ await page.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
146
+ await page.waitForSelector('.welcome-state, .floating-header', { timeout: 30000 });
147
+ await page.waitForTimeout(1400);
148
+ await dismissOverlays(page);
149
+
150
+ const newChat = page.locator('button:has-text("New Chat"), button:has-text("New chat")').first();
151
+ if (await newChat.isVisible({ timeout: 1500 }).catch(() => false)) {
152
+ await newChat.click();
153
+ await page.waitForTimeout(800);
154
+ await dismissOverlays(page);
155
+ }
156
+
157
+ await page.waitForSelector('.welcome-state .intake-greeting, .intake-greeting', { timeout: 15000 });
158
+ await page.waitForSelector('.suggestions-title', { timeout: 10000 });
159
+ await page.waitForTimeout(400);
160
+
161
+ const metrics = await page.evaluate(measureWelcome);
162
+ const name = `${vp.width}x${vp.height}`;
163
+ await page.screenshot({
164
+ path: path.join(OUT, `welcome-fit-${name}.png`),
165
+ fullPage: false,
166
+ });
167
+ const ok =
168
+ metrics.noWelcomeScroll === true &&
169
+ metrics.cardsFullyAboveInput === true &&
170
+ metrics.greetingVisible === true &&
171
+ metrics.gettingStartedVisible === true &&
172
+ metrics.pageScroll <= 1;
173
+ results.push({ name, ok, metrics });
174
+ console.log('VIEWPORT', name, JSON.stringify({ ok, metrics }, null, 2));
175
+ await ctx.close();
176
+ }
177
+
178
+ const summary = {
179
+ ok: results.every((r) => r.ok),
180
+ results,
181
+ };
182
+ fs.writeFileSync(path.join(OUT, 'welcome-fit-summary.json'), JSON.stringify(summary, null, 2));
183
+ console.log('SUMMARY', JSON.stringify(summary, null, 2));
184
+
185
+ await browser.close();
186
+ process.exit(summary.ok ? 0 : 1);
.dockerignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **/node_modules
2
+ **/venv
3
+ **/.venv
4
+ **/__pycache__
5
+ **/chromadb_storage
6
+ **/chroma_data
7
+ **/chroma_db
8
+ .git
9
+ .gitignore
10
+ .env
11
+ **/.env
12
+ .vscode
13
+ .cursor
14
+ **/.idea
15
+ **/.DS_Store
16
+ **/Thumbs.db
17
+ docker-compose.yml
18
+ Dockerfile
19
+ .dockerignore
20
+ *.md
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.gif filter=lfs diff=lfs merge=lfs -text
5
+ *.webp filter=lfs diff=lfs merge=lfs -text
6
+ *.ico filter=lfs diff=lfs merge=lfs -text
.github/workflows/propose_release.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Propose Stable Release
2
+ on:
3
+ workflow_dispatch:
4
+ inputs:
5
+ release_type:
6
+ type: choice
7
+ description: Release Type
8
+ options:
9
+ - patch
10
+ - minor
11
+ - major
12
+ jobs:
13
+ update_version:
14
+ uses: neongeckocom/.github/.github/workflows/propose_semver_release.yml@master
15
+ with:
16
+ branch: dev
17
+ release_type: ${{ inputs.release_type }}
18
+ update_changelog: True
19
+ version_file: "multi_llm_chatbot_backend/app/version.py"
20
+ pull_changes:
21
+ uses: neongeckocom/.github/.github/workflows/pull_master.yml@master
22
+ needs: update_version
23
+ with:
24
+ pr_reviewer: neonreviewers
25
+ pr_assignee: ${{ github.actor }}
26
+ pr_draft: false
27
+ pr_title: ${{ needs.update_version.outputs.version }}
28
+ pr_body: ${{ needs.update_version.outputs.changelog }}
29
+ destination: main
.github/workflows/publish_release.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # On every push to `main` (which happens when a release PR produced by
2
+ # Propose Stable Release is merged), read the current version and create a
3
+ # matching GitHub release. Docker image publishing is intentionally left
4
+ # out for now and can be added separately.
5
+
6
+ name: Publish GitHub Release
7
+ on:
8
+ push:
9
+ branches:
10
+ - main
11
+
12
+ jobs:
13
+ tag_release:
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: write
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.11"
23
+ - name: Get Version
24
+ run: |
25
+ VERSION=$(python multi_llm_chatbot_backend/app/version.py)
26
+ echo "VERSION=${VERSION}" >> $GITHUB_ENV
27
+ - uses: ncipollo/release-action@v1
28
+ with:
29
+ token: ${{ secrets.GITHUB_TOKEN }}
30
+ tag: ${{ env.VERSION }}
31
+ generateReleaseNotes: true
.github/workflows/publish_test_build.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bumps the alpha suffix on every push to `dev` (except when the only
2
+ # change is to the version file itself, to prevent a publish loop). A
3
+ # GitHub prerelease is cut for each alpha so artifacts are easy to find.
4
+ # This project is not distributed via PyPI, so `publish_pypi` is disabled.
5
+
6
+ name: Publish Alpha Build
7
+ on:
8
+ push:
9
+ branches:
10
+ - dev
11
+ paths-ignore:
12
+ - 'multi_llm_chatbot_backend/app/version.py'
13
+ - 'CHANGELOG.md'
14
+
15
+ jobs:
16
+ publish_alpha_release:
17
+ uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master
18
+ secrets: inherit
19
+ with:
20
+ version_file: "multi_llm_chatbot_backend/app/version.py"
21
+ setup_py: "multi_llm_chatbot_backend/app/version.py"
22
+ publish_prerelease: true
23
+ publish_pypi: false
24
+ update_changelog: true
.github/workflows/unit_tests.yml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Run Unit Tests
2
+ on:
3
+ pull_request:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ unit_tests:
8
+ strategy:
9
+ matrix:
10
+ python-version: ['3.10', '3.11', '3.12']
11
+ runs-on: ubuntu-latest
12
+ timeout-minutes: 15
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python ${{ matrix.python-version }}
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+
21
+ - name: Install dependencies
22
+ working-directory: multi_llm_chatbot_backend
23
+ run: |
24
+ pip install wheel
25
+ pip install -r requirements.txt -r test_requirements.txt
26
+
27
+ - name: Run unit tests
28
+ working-directory: multi_llm_chatbot_backend
29
+ run: |
30
+ pytest app/tests/unit/ --junitxml=test-results/results.xml
31
+
32
+ - name: Upload test results
33
+ if: always()
34
+ uses: actions/upload-artifact@v4
35
+ with:
36
+ name: test-results-py${{ matrix.python-version }}
37
+ path: multi_llm_chatbot_backend/test-results/
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main
2
+ .env
3
+ gcp_build.txt
4
+
5
+ # backend
6
+ multi_llm_chatbot_backend/.env
7
+ multi_llm_chatbot_backend/chroma_db/
8
+ multi_llm_chatbot_backend/chromadb_storage/
9
+ multi_llm_chatbot_backend/.dockerignore
10
+ multi_llm_chatbot_backend/.env.deploy
11
+ multi_llm_chatbot_backend/Dockerfile
12
+
13
+
14
+
15
+ # frontend
16
+ phd-advisor-frontend/.env.production
17
+ phd-advisor-frontend/.firebase/
18
+ phd-advisor-frontend/.firebaserc
19
+ phd-advisor-frontend/firebase.json
20
+
21
+ # Ignore all __pycache__ folders
22
+ **/__pycache__/
23
+
24
+
25
+ # Python virtual environments
26
+ **/venv/
27
+ .venv/
CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## [2.0.1a3](https://github.com/NeonGeckoCom/CCAI-Demo/tree/2.0.1a3) (2026-05-21)
4
+
5
+ [Full Changelog](https://github.com/NeonGeckoCom/CCAI-Demo/compare/2.0.1a2...2.0.1a3)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - Add Onboarding tour [\#48](https://github.com/NeonGeckoCom/CCAI-Demo/pull/48) ([NeonRyan](https://github.com/NeonRyan))
10
+
11
+ ## [2.0.1a2](https://github.com/NeonGeckoCom/CCAI-Demo/tree/2.0.1a2) (2026-05-19)
12
+
13
+ [Full Changelog](https://github.com/NeonGeckoCom/CCAI-Demo/compare/33862e61789b40734f952d1ae06b03c86dff49af...2.0.1a2)
14
+
15
+ **Implemented enhancements:**
16
+
17
+ - \[FEAT\] Clear User Data [\#52](https://github.com/NeonGeckoCom/CCAI-Demo/issues/52)
18
+ - \[FEAT\] Support tools [\#34](https://github.com/NeonGeckoCom/CCAI-Demo/issues/34)
19
+ - \[FEAT\] Add endpoint and UI for User Account updates [\#26](https://github.com/NeonGeckoCom/CCAI-Demo/issues/26)
20
+ - \[FEAT\] Support advisor avatar images [\#25](https://github.com/NeonGeckoCom/CCAI-Demo/issues/25)
21
+ - \[FEAT\] Configurable User Guide [\#24](https://github.com/NeonGeckoCom/CCAI-Demo/issues/24)
22
+ - \[FEAT\] Support vLLM endpoints [\#22](https://github.com/NeonGeckoCom/CCAI-Demo/issues/22)
23
+ - \[FEAT\] Improved persona configuration handling [\#8](https://github.com/NeonGeckoCom/CCAI-Demo/issues/8)
24
+ - \[FEAT\] Implement automatic color picker for personas [\#7](https://github.com/NeonGeckoCom/CCAI-Demo/issues/7)
25
+ - \[FEAT\] Validate configured icons [\#6](https://github.com/NeonGeckoCom/CCAI-Demo/issues/6)
26
+ - \[FEAT\] Improved Checks for "Clarification Needed" [\#5](https://github.com/NeonGeckoCom/CCAI-Demo/issues/5)
27
+ - \[FEAT\] Versioning and release automation [\#4](https://github.com/NeonGeckoCom/CCAI-Demo/issues/4)
28
+
29
+ **Fixed bugs:**
30
+
31
+ - \[BUG\] Deleting a chat in the UI causes an empty chat to be created [\#45](https://github.com/NeonGeckoCom/CCAI-Demo/issues/45)
32
+ - \[BUG\] User inputs are sometimes repeated [\#42](https://github.com/NeonGeckoCom/CCAI-Demo/issues/42)
33
+ - \[BUG\] Improved method for LLM JSON Generation [\#13](https://github.com/NeonGeckoCom/CCAI-Demo/issues/13)
34
+
35
+ **Closed issues:**
36
+
37
+ - Pin the requirements.txt to stable versions [\#37](https://github.com/NeonGeckoCom/CCAI-Demo/issues/37)
38
+
39
+ **Merged pull requests:**
40
+
41
+ - feat/Added front end support for settings page [\#60](https://github.com/NeonGeckoCom/CCAI-Demo/pull/60) ([NeonRyan](https://github.com/NeonRyan))
42
+ - Clear User Data [\#58](https://github.com/NeonGeckoCom/CCAI-Demo/pull/58) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
43
+ - Feat/improve clarification check [\#57](https://github.com/NeonGeckoCom/CCAI-Demo/pull/57) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
44
+ - UI Improvements [\#56](https://github.com/NeonGeckoCom/CCAI-Demo/pull/56) ([NeonRyan](https://github.com/NeonRyan))
45
+ - Fix bug around chat deletion [\#54](https://github.com/NeonGeckoCom/CCAI-Demo/pull/54) ([NeonRyan](https://github.com/NeonRyan))
46
+ - Add User Guide [\#53](https://github.com/NeonGeckoCom/CCAI-Demo/pull/53) ([NeonRyan](https://github.com/NeonRyan))
47
+ - Add versioning automation [\#47](https://github.com/NeonGeckoCom/CCAI-Demo/pull/47) ([NeonDaniel](https://github.com/NeonDaniel))
48
+ - Fix/dup first message [\#44](https://github.com/NeonGeckoCom/CCAI-Demo/pull/44) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
49
+ - Feat/persona avatars [\#43](https://github.com/NeonGeckoCom/CCAI-Demo/pull/43) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
50
+ - Feat/account management [\#41](https://github.com/NeonGeckoCom/CCAI-Demo/pull/41) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
51
+ - Fix/json output [\#40](https://github.com/NeonGeckoCom/CCAI-Demo/pull/40) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
52
+ - Feat/advisor tools [\#38](https://github.com/NeonGeckoCom/CCAI-Demo/pull/38) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
53
+ - Feat/vllm endpoints [\#36](https://github.com/NeonGeckoCom/CCAI-Demo/pull/36) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
54
+ - Refactor/unittests [\#33](https://github.com/NeonGeckoCom/CCAI-Demo/pull/33) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
55
+ - Support Voice Interaction [\#32](https://github.com/NeonGeckoCom/CCAI-Demo/pull/32) ([NeonDaniel](https://github.com/NeonDaniel))
56
+ - Fix/structured json output [\#30](https://github.com/NeonGeckoCom/CCAI-Demo/pull/30) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
57
+ - Feat/auto persona colors [\#23](https://github.com/NeonGeckoCom/CCAI-Demo/pull/23) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
58
+ - Feat/validate lucide icons [\#21](https://github.com/NeonGeckoCom/CCAI-Demo/pull/21) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
59
+ - Initial setup of GitHub Actions workflow [\#20](https://github.com/NeonGeckoCom/CCAI-Demo/pull/20) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
60
+ - Refactor chat UI layout [\#19](https://github.com/NeonGeckoCom/CCAI-Demo/pull/19) ([NeonDaniel](https://github.com/NeonDaniel))
61
+ - Stream LLM Responses [\#18](https://github.com/NeonGeckoCom/CCAI-Demo/pull/18) ([NeonDaniel](https://github.com/NeonDaniel))
62
+ - Feat/improve persona config [\#16](https://github.com/NeonGeckoCom/CCAI-Demo/pull/16) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
63
+ - Add Neon AI Branding [\#15](https://github.com/NeonGeckoCom/CCAI-Demo/pull/15) ([NeonDaniel](https://github.com/NeonDaniel))
64
+ - Docs/update readme repo URL [\#14](https://github.com/NeonGeckoCom/CCAI-Demo/pull/14) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
65
+ - Improve backend logic around user input parsing [\#12](https://github.com/NeonGeckoCom/CCAI-Demo/pull/12) ([NeonDaniel](https://github.com/NeonDaniel))
66
+ - Fix Home navigation to prevent logging out [\#11](https://github.com/NeonGeckoCom/CCAI-Demo/pull/11) ([NeonDaniel](https://github.com/NeonDaniel))
67
+ - Add configuration for Undergrad Advisor Panel [\#10](https://github.com/NeonGeckoCom/CCAI-Demo/pull/10) ([NeonDaniel](https://github.com/NeonDaniel))
68
+ - Add configuration support [\#3](https://github.com/NeonGeckoCom/CCAI-Demo/pull/3) ([NeonDaniel](https://github.com/NeonDaniel))
69
+ - Docker optimization by Cursor [\#2](https://github.com/NeonGeckoCom/CCAI-Demo/pull/2) ([NeonDaniel](https://github.com/NeonDaniel))
70
+ - Implement Docker deployment [\#1](https://github.com/NeonGeckoCom/CCAI-Demo/pull/1) ([NeonDaniel](https://github.com/NeonDaniel))
71
+
72
+
73
+
74
+ \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
Dockerfile ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.7
2
+ # ---------------------------------------------------------------------------
3
+ # Cybersecurity Panel — single-container HuggingFace Spaces image.
4
+ #
5
+ # Mirrors the structural choices used by the working HF Spaces deployments
6
+ # CCAI-Vibe-Demo and CU-Student-AIProject-Helper:
7
+ # * multi-stage Node frontend build → Python+SPA bundle
8
+ # * non-root user uid 1000, port 7860 (HF Spaces convention)
9
+ # * persistence on the HF Storage Bucket mount at /data (SQLite shim)
10
+ # * REACT_APP_API_URL is set to the empty string at build time so the SPA
11
+ # issues relative URLs and shares the FastAPI origin
12
+ #
13
+ # Build context: repo root (this file). Frontend sources live in ./frontend/
14
+ # ---------------------------------------------------------------------------
15
+
16
+ # ---- 1. Frontend build (CRA) ----------------------------------------------
17
+ FROM node:20-bookworm AS frontend-build
18
+ WORKDIR /app/frontend
19
+
20
+ COPY frontend/package.json frontend/package-lock.json* ./
21
+ RUN --mount=type=cache,target=/root/.npm \
22
+ npm ci
23
+
24
+ COPY frontend/ ./
25
+
26
+ # Empty REACT_APP_API_URL → CRA inlines '' so every fetch() hits the same
27
+ # origin as the SPA (the FastAPI server below).
28
+ ENV REACT_APP_API_URL=""
29
+ RUN npm run build
30
+
31
+ # ---- 2. Python runtime + SPA bundle ---------------------------------------
32
+ FROM python:3.12-slim-bookworm
33
+
34
+ # ffmpeg is required by the /api/voice/transcribe endpoint
35
+ # (browser WebM/Opus → WAV for Whisper). Kept on the runtime image only,
36
+ # not on the build image, so we don't pay the apt cost during incremental
37
+ # code rebuilds.
38
+ RUN apt-get update && \
39
+ apt-get install -y --no-install-recommends ffmpeg && \
40
+ rm -rf /var/lib/apt/lists/*
41
+
42
+ # HF Spaces runs the container as uid 1000 and expects the app to do the
43
+ # same; with a non-root user we cannot bind privileged ports, but :7860 is
44
+ # fine.
45
+ RUN useradd -m -u 1000 user
46
+ USER user
47
+ ENV HOME=/home/user \
48
+ PATH=/home/user/.local/bin:$PATH \
49
+ PYTHONUNBUFFERED=1 \
50
+ CORS_ORIGINS=* \
51
+ DATA_DIR=/data \
52
+ CONFIG_PATH=/home/user/app/cybersecurity_config.yaml
53
+
54
+ WORKDIR $HOME/app
55
+
56
+ # ---- Python deps (cached unless requirements.txt changes) -----------------
57
+ COPY --chown=user multi_llm_chatbot_backend/requirements.txt ./
58
+ RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
59
+ pip install --no-cache-dir --user -r requirements.txt
60
+
61
+ # ---- Backend source -------------------------------------------------------
62
+ COPY --chown=user multi_llm_chatbot_backend/ ./
63
+
64
+ # ---- Top-level configuration files (config.yaml + persona definitions) ----
65
+ COPY --chown=user cybersecurity_config.yaml ./cybersecurity_config.yaml
66
+ COPY --chown=user personas/ ./personas/
67
+ COPY --chown=user tracks/ ./tracks/
68
+
69
+ # ---- Frontend bundle ------------------------------------------------------
70
+ # main.py mounts $HOME/app/static at "/" so the SPA is served same-origin
71
+ # with the API.
72
+ COPY --chown=user --from=frontend-build /app/frontend/build ./static
73
+
74
+ ENV PYTHONPATH=$HOME/app
75
+
76
+ EXPOSE 7860
77
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
Dockerfile.dev ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.7
2
+ # Dev images with hot-reload. Not used for HuggingFace Spaces (see Dockerfile).
3
+
4
+ FROM python:3.12-slim-bookworm AS backend
5
+ RUN apt-get update && \
6
+ apt-get install -y --no-install-recommends ffmpeg && \
7
+ rm -rf /var/lib/apt/lists/*
8
+ WORKDIR /app
9
+ COPY multi_llm_chatbot_backend/requirements.txt /tmp/requirements.txt
10
+ RUN --mount=type=cache,target=/root/.cache/pip \
11
+ pip install --no-cache-dir -r /tmp/requirements.txt
12
+ COPY multi_llm_chatbot_backend /app/multi_llm_chatbot_backend
13
+ COPY cybersecurity_config.yaml /app/cybersecurity_config.yaml
14
+ COPY personas /app/personas
15
+ COPY tracks /app/tracks
16
+ ENV PYTHONPATH=/app/multi_llm_chatbot_backend \
17
+ PYTHONUNBUFFERED=1 \
18
+ DATA_DIR=/data \
19
+ CONFIG_PATH=/app/cybersecurity_config.yaml
20
+ WORKDIR /app/multi_llm_chatbot_backend
21
+ EXPOSE 8000
22
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
23
+
24
+ FROM node:20-bookworm AS frontend
25
+ WORKDIR /app
26
+ COPY frontend/package.json frontend/package-lock.json* ./
27
+ RUN --mount=type=cache,target=/root/.npm \
28
+ npm ci
29
+ COPY frontend/ ./
30
+ EXPOSE 3000
31
+ ENV CHOKIDAR_USEPOLLING=true
32
+ CMD ["npm", "start"]
PLAN.md ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cybersecurity Advisor Panel — Overhaul Plan
2
+
3
+ **Project:** NeonClary/cybersecurity-panel (baseline copied from CCAI-Demo-Clary @ FEAT_CybersecurityCanvas, commit `e204135`)
4
+ **Collaboration:** Jerry Huaute, CISSP × Neon.ai (CCAI panel architecture, BrainForge Security model)
5
+ **Deployment target:** HuggingFace Spaces, single Docker container, port 7860, SQLite at `/data`
6
+ **Date:** August 5, 2026
7
+
8
+ ---
9
+
10
+ ## 1. Vision
11
+
12
+ Turn the proof-of-concept demo into a mature, engaging cybersecurity advisory app: a panel of AI experts led by AI Jerry Huaute that greets users the way a real consultant would ("You've contacted me today — what is it that I can help you with in cybersecurity?"), learns about them the way a real consultant does (from everything they say, not just form fields), adapts its depth and vocabulary to their maturity level, and gives them a visible, motivating path toward their security goals.
13
+
14
+ ### The three-legged model (from Richard's framing)
15
+ Every engagement is understood along three dimensions, plus urgency:
16
+
17
+ 1. **The person** — role, knowledge level, certifications, communication preferences.
18
+ 2. **The organization** — size, industry, IT resources, compliance obligations, current maturity.
19
+ 3. **The immediate need** — the reason they showed up today.
20
+ 4. **Urgency overlay** — *triage* (incident now), *advisory* (recommendation needed), or *program* (long-term improvement). Forum research shows user questions split cleanly into these three conversation modes.
21
+
22
+ ### Target user types (from forum research)
23
+ | User type | Dominant needs | Conversation mode |
24
+ |---|---|---|
25
+ | Individual | "Was I hacked?", sextortion/scam validation, phishing checks, personal digital security | Triage |
26
+ | SMB owner / solo IT | Basics ("am I a target?"), tool selection, backups/ransomware, policies | Advisory |
27
+ | Enterprise IT / practitioner | IR procedure, architecture, IAM/MFA rollout, tool comparison | Advisory + Program |
28
+ | Executive / manager | Audit prep (SOC 2 / ISO / NIST), board communication, budget justification, maturity vs peers | Program |
29
+ | Career seeker / student | Certs (Security+ vs CISSP), career paths, home labs, interviews | Advisory |
30
+
31
+ The system infers which type it's talking to from the first messages (as Jerry described: "we naturally have an idea of what someone's capable of by the question they ask") and adapts vocabulary, depth, suggested prompts, and expert selection.
32
+
33
+ ---
34
+
35
+ ## 2. Current state (verified in code)
36
+
37
+ **Solid, keep:**
38
+ - FastAPI backend + React SPA in one HF Spaces Docker image; SQLite (aiosqlite) persistence at `/data` behind a Mongo-API shim (`app/core/db.py`).
39
+ - LLM layer: Neon vLLM client (`BrainForge/Security@2026.03.18` at `4090-x1-3.neonaiservices2.com/vllm0`, OpenAI-compatible, Basic/Bearer auth) with resilient GPT-5.4 fallback race, plus Gemini and Ollama clients and a runtime provider switch (`/switch-provider`).
40
+ - NDJSON streaming chat with top-3 expert routing via LLM ranking, parallel persona generation.
41
+ - JWT auth (bcrypt, HS256), chat session persistence, document upload + ChromaDB RAG, export (TXT/PDF/DOCX), voice endpoints (Whisper STT / Coqui TTS).
42
+ - Backend unit/integration test suite + GitHub Actions CI.
43
+
44
+ **Broken / stubbed (must fix):**
45
+ - **Profile is dead-wired**: backend `GET/PUT /api/users/me/profile` works, but `ChatPage.js` imports `ProfileWalkthrough` / `AccountModal` / `OnboardingChat` / `ClearDataModal` and **never renders them** — sidebar clicks set state that drives no UI. The Canvas page has no profile access at all.
46
+ - Profile data model piggybacks on PhD-era fields (`academicStage` stores knowledge level, `researchArea` stores timezone).
47
+ - Canvas **Insights** = hardcoded demo data with a stubbed "refresh"; **Workspace** and **Documents** persist only to browser localStorage (lost across devices), despite a server-side canvas API existing.
48
+ - **Dockerfile COPYs `phd_config.yaml` / `undergrad_config.yaml`, which were deleted** — fresh builds fail.
49
+ - Jerry persona YAML has factual drift vs the approved bio (calls Semu his *grandfather*, wrong dates, present-tense Microsoft employment).
50
+ - Orchestrator clarification path falls back to a crude keyword list; no use of profile in routing.
51
+ - `ProviderDropdown` exists but is not mounted; account deletion doesn't remove profile/onboarding rows; README describes a MongoDB setup that no longer exists.
52
+ - Naming debt everywhere: `phd-advisor-frontend/`, `phd_canvas`, "methodologist/theorist" references in RAG instructions.
53
+
54
+ ---
55
+
56
+ ## 3. Architecture decisions
57
+
58
+ 1. **Keep the stack** (FastAPI + React + SQLite + single Docker image). It's proven on HF Spaces and the persistence/LLM layers are sound. No framework migration.
59
+ 2. **Retire PhD naming debt early**: rename `phd-advisor-frontend/` → `frontend/`, `phd_canvas` → `canvas`/`workspace`, purge PhD references from prompts, README, and RAG instructions. Done first so all new work lands on clean names.
60
+ 3. **New data model for user knowledge** (new SQLite tables via the existing shim):
61
+ - `user_facts` — one row per fact: `category` (person / organization / needs / preferences), `key`, `value`, `source` (**stated** | **inferred**), `confidence`, `evidence` (message reference), timestamps. Stated and inferred facts are stored separately as required, and both are user-visible and editable.
62
+ - `user_summaries` — the two generated summaries (`short` for ≤25B Neon models, `long` for large models) + generation metadata.
63
+ - `goal_tracks`, `track_items`, `assessments` — the progress-path engine (§7).
64
+ 4. **Config-driven everything** (per reuse guidelines): summaries' token budgets, model context limits, persona roster, track definitions, and intake chips all live in `cybersecurity_config.yaml` / persona YAMLs / new `tracks/*.yaml` — no hard-coded values.
65
+ 5. **Dev experience**: docker-compose `dev` profile with `uvicorn --reload` + CRA dev server (hot reload both sides); container naming `cybersecurity-panel-dev-cursor-<date>`; BuildKit cache mounts already present, keep them. Secrets from `C:\Users\dream\.secrets\shared.env` (loader already supports `SHARED_ENV`).
66
+
67
+ ---
68
+
69
+ ## 4. User knowledge system (the core new capability)
70
+
71
+ ### 4.1 Two-source profile
72
+ - **Stated profile**: what the user explicitly provides — via the profile editor, onboarding chat, or direct statements ("I'm the IT manager at a 200-person clinic"). Direct statements in chat are extracted and saved as `source=stated`.
73
+ - **Inferred profile**: after **every user message**, a background extraction pass (small/cheap model — Neon vLLM) infers facts: knowledge level from vocabulary, role, org type, urgency, tools mentioned, emotional state, goals. Saved as `source=inferred` with confidence + the message it came from. Never blocks the chat response (fire-and-forget task).
74
+ - **User visibility & control**: a "What we know about you" view with two clearly labeled sections (Things you told us / Things we noticed), each fact editable, confirmable (promotes inferred → stated), or deletable. This is both the trust feature Clary raised ("people don't want to give away their profile") and a differentiator.
75
+
76
+ ### 4.2 Dual user summaries
77
+ - `short` summary (~150 tokens): for the Neon ~25B models — the essentials: who they are, org, maturity level, current goal, communication preference.
78
+ - `long` summary (~600 tokens): for large models (GPT-5.4, Gemini) — everything relevant including history highlights and open threads.
79
+ - **Regeneration triggers** (exactly as specified): at the start of each new user session, and after each completed chat **except the first one in a session**.
80
+ - **Routing rule**: the LLM client layer picks the summary by provider — vLLM/Ollama get `short`, OpenAI/Gemini get `long`. Implemented at the prompt-assembly boundary so it's automatic for any future provider (flagged `small_context: true/false` in config).
81
+
82
+ ### 4.3 Context budgeting
83
+ - **Small models** (Neon vLLM, Ollama): total prompt budget **~4096 tokens** (configurable) = persona prompt + short summary + rolling conversation summary + most recent turns + current message. The existing `chat_summary.py` summarizer is upgraded to maintain a rolling summary that compresses older turns as the budget tightens.
84
+ - **Large models**: full conversation history + long summary, no summarization.
85
+
86
+ ---
87
+
88
+ ## 5. Orchestrator & conversation intelligence
89
+
90
+ 1. **Profile-aware routing**: the orchestrator's expert-ranking prompt includes the user summary, so expert selection reflects who the user is (an executive asking about "risk" gets the compliance/strategy experts; a student gets the mentor).
91
+ 2. **Intelligent follow-ups**: replace the canned `clarification_questions` list with generated follow-ups that use conversation context + user summary — modeled on Jerry's intake behavior ("Is the review internal or external? What's your role in it?").
92
+ 3. **Give-and-take rule** (from the notes): the panel never interrogates. Every clarifying question is preceded by useful information — "answer what you can, then ask the one most valuable follow-up." Enforced in the orchestrator/persona prompt contracts.
93
+ 4. **Adaptive depth**: response register (plain-language vs technical) driven by the inferred knowledge level in the summary; beginners get no unexplained jargon, experts get full technical vocabulary (Jerry's tailoring principle).
94
+ 5. **Urgency detection**: triage-mode messages ("I think I've been hacked") short-circuit to a first-steps checklist from the incident expert before any profiling questions.
95
+
96
+ ---
97
+
98
+ ## 6. Intake & onboarding redesign
99
+
100
+ - **Opening screen** (post-login, first session): Jerry's greeting — *"You've contacted me today — what can I help you with in cybersecurity?"* — with 3–4 tappable chips matching top question categories (research-backed: "I think I've been hacked", "Prepare for an audit/review", "Secure my business", "Grow my security career") **plus a free-text box** (the "blank fill-in" from the notes). Chips are context-aware and change with user type once known.
101
+ - **Progressive profiling, not forms**: no up-front questionnaire. Profile fills from conversation. The optional **capability check-off screen** (Jerry's maturity checklist idea) exists but is skippable — users who prefer a "situation profile" just talk, per Richard's point.
102
+ - **Give info before asking**: the first response always delivers value before the first follow-up question.
103
+ - UX patterns per current best practice: ≤4 starter chips, follow-up chips above the input bar, streaming with "what the panel is doing" indicators, progressive disclosure (summary first, expand for detail), no autoscroll-to-bottom on long streamed answers.
104
+
105
+ ---
106
+
107
+ ## 7. Progress path ("Security Journey")
108
+
109
+ Replaces the fake Insights page as the app's second pillar. A user picks (or is guided to) a **track**:
110
+
111
+ | Track | Engine | Audience |
112
+ |---|---|---|
113
+ | **ITIL Maturity Model** (Jerry's favorite) | 5 levels: Initial → Managed → Defined → Quantitative → Optimizing | Organizations |
114
+ | NIST CSF 2.0 | 6 functions (Govern/Identify/Protect/Detect/Respond/Recover) × 4 tiers, Current vs Target profile | Organizations |
115
+ | CIS Controls IG1→IG3 | Concrete checkable safeguards — the checklist engine | SMB especially |
116
+ | Certification paths | Security+ → CySA+ → CISSP etc., study milestones | Individuals |
117
+ | Personal Digital Security | Passwords/MFA → backups → device hygiene → monitoring | Individuals |
118
+ | **Custom goal** | Mapped out with the panel's help ("internal assessment", "pass our review in Q4") | Anyone |
119
+
120
+ Mechanics:
121
+ - Headline **progress bar** (level + % within level) on the Journey page and a compact version in the chat header.
122
+ - Per-function/domain **radar or segmented bars** (NIST-style Current vs Target).
123
+ - Items check off three ways: user checks manually, the optional assessment wizard, or **the panel proposes a check-off when a conversation demonstrates completion** (user confirms).
124
+ - **Re-assessment trend** ("you moved from Managed to Defined in Respond") mirroring ITIL's repeat-assessment reporting.
125
+ - Light gamification: easy early wins ("Enable MFA" = instant progress), milestone celebrations, a weekly security check-in cadence — no anxiety-inducing daily streaks.
126
+ - Track definitions are data (`tracks/*.yaml`), so Jerry can supply/edit checklists without code changes.
127
+
128
+ ---
129
+
130
+ ## 8. Pages restructure
131
+
132
+ | Current | New | Content |
133
+ |---|---|---|
134
+ | Chat | **Chat** (primary) | Redesigned panel chat: intake chips, expert cards, follow-up chips, profile-aware suggestions |
135
+ | Insights (fake) | **Journey** | Progress path above; panel-generated insights tied to the user's actual track & chats (real `/api` persistence) |
136
+ | Workspace (localStorage) | **Workspace** (server-persisted, curated) | Cyber-relevant widgets only: incident checklist, risk register, asset inventory notes, policy drafts kanban; drop PhD widgets; persist via the existing canvas API |
137
+ | Documents (localStorage drafts) | **Documents** | Unified: uploaded reference docs (RAG) + generated artifacts (policies, audit-evidence lists, board summaries) with server persistence and export |
138
+ | — | **Profile** ("About you") | Stated + inferred facts, summaries preview, edit/confirm/delete, clear-data controls |
139
+
140
+ ---
141
+
142
+ ## 9. Expert panel roster
143
+
144
+ **AI Jerry Huaute — lead advisor (required).** Replace the YAML prompt with the approved canonical persona prompt (father Semu, correct dates/tenses, CISSP signature, communication style, sample responses). Jerry opens conversations, owns the intake, and is always among the responders by default.
145
+
146
+ **Revised existing experts** (prompts rewritten for the three-legged model, give-and-take rule, adaptive register, and Compact-Markdown contract):
147
+ 1. **Incident Responder** — expanded to cover personal triage ("was I hacked", sextortion/scam validation) *and* business IR (notification order, evidence preservation, ransom decisions). Covers the highest-volume question categories.
148
+ 2. **Compliance & Audit Advisor** — SOC 2 / ISO 27001 / NIST / CMMC / HIPAA; audit evidence expectations; security-questionnaire help.
149
+ 3. **Security Architect** — Zero Trust, cloud, IAM/MFA rollouts, hardening.
150
+ 4. **Threat Modeler** — STRIDE/ATT&CK; also powers the role-playing/tabletop-exercise capability (from the notes: "role playing bot for situation analysis").
151
+ 5. **Career Mentor** — merged with Jerry's mentoring instinct kept distinct: this persona handles cert/interview mechanics; Jerry handles wisdom/encouragement.
152
+
153
+ **New experts (proposed):**
154
+ 6. **Small Business Security Advisor** — the "Geek Squad" leg: minimum viable security stack, tool selection (password managers, EDR vs AV, backups), plain language, budget-aware. Forum research shows this audience is huge and underserved.
155
+ 7. **AI Security & Technology Strategist** — helps C-level users understand and safely adopt AI (per the notes: "making AI more understandable… a productivity assist rather than taking their job"); OWASP GenAI LLM Top 10, NIST Cyber AI Profile focus areas (Secure / Defend / Thwart), overcoming resistance to change.
156
+ 8. *(Optional, decide later)* **Privacy & Data Protection Advisor** — GDPR/CCPA, data handling, breach disclosure duties. Could fold into Compliance initially.
157
+
158
+ Roster stays at 7–8: enough diversity without decision paralysis; orchestrator typically surfaces top 2–3 per message.
159
+
160
+ ---
161
+
162
+ ## 10. Tools (new capabilities)
163
+
164
+ **Required (per project guidelines):**
165
+ - **API/model health pre-check** — on app load and on demand, the backend probes every configured provider using the real request path with a tiny prompt ("Reply with the single word: OK"); classifies online / unavailable / error; non-online models are removed from the selectable list (fail closed per model; fail open only if the whole check fails); a Settings → **Model Status** modal lists statuses with first ~200 chars of errors and a refresh control. Secrets stay server-side.
166
+
167
+ **High-value additions (recommended):**
168
+ - **Curated knowledge base (RAG)** — pre-seed ChromaDB with Jerry's approved documents (NIST IR 8596 Cyber AI Profile, OWASP GenAI LLM Top 10, OWASP secure-MCP guide, CIS/NIST framework summaries) so advisors cite real sources. Extensible with Jerry's own checklists/how-tos.
169
+ - **CVE / NVD / CISA KEV lookup** — live vulnerability answers ("is CVE-2026-XXXX being exploited?").
170
+ - **Have I Been Pwned breach check** — directly serves the #1 individual question category.
171
+ - **Phishing/scam analyzer** — paste an email/text; structured verdict + recommended actions (sextortion pattern gets a canned, reassuring flow).
172
+ - **Maturity assessment wizard** — the interactive intake for the Journey tracks.
173
+ - **Document/policy generator** — templates (AUP, IR plan, access policy) filled from the user's profile; exports via existing TXT/PDF/DOCX pipeline.
174
+ - **Web search tool** for advisors (current-events questions), gated per-persona.
175
+
176
+ **Later / discuss:** IP-based org enrichment (from the transcript) — technically feasible but privacy-sensitive; if used, disclose transparently in the profile view. Jerry's data-flow/monitoring open-source tool list — integrate as a Workspace resource page when he delivers it.
177
+
178
+ ---
179
+
180
+ ## 11. UI/UX overhaul
181
+
182
+ - **Design system**: professional security aesthetic — deep slate + teal (Jerry's `#0F766E`), high-contrast light/dark themes, consistent card/chip/modal components, subtle motion (reduced-motion respected). Rule of 3–4 choices per screen (Jerry's "don't make it busy" principle).
183
+ - **Responsive, three layouts**: phone (<768px: full-screen chat, bottom-sheet modals, hamburger nav, composer docked above keyboard), tablet (768–1100px: collapsible rail), desktop. Tap targets ≥44px. Tested on Safari/iOS, Chrome/Android, Edge/Windows (manual matrix + responsive automated checks).
184
+ - **Chat presentation**: expert avatars/colors, one-line "Thought" summary with expandable detail, follow-up chips, copy/save/export on every artifact, streaming status ("Jerry is reviewing your question…").
185
+ - **Engagement**: Journey progress visible in header; session-start "welcome back" referencing the user's goal; suggested next actions after each chat.
186
+ - App-level security posture honors OWASP LLM Top 10: prompt-injection hardening in system prompts, output handling, no secrets client-side — credibility matters for a security product.
187
+
188
+ ---
189
+
190
+ ## 12. Hygiene & infrastructure fixes
191
+
192
+ - Fix Dockerfile (remove deleted-YAML COPYs) — **build is currently broken**.
193
+ - Mount `ProviderDropdown` (or fold provider choice into Settings with Model Status).
194
+ - Account deletion also purges `user_facts`, `user_profiles`, `user_summaries`, onboarding rows.
195
+ - Fix `SettingsModal` field-name mismatch vs `PATCH /auth/me`; consolidate with `AccountModal`.
196
+ - Rewrite README (SQLite not Mongo, real advisor list, HF Spaces + local dev instructions).
197
+ - Remove dead code: `seamless_orchestrator.py` (PhD-era), `OldMessageBubble.js`, stale `scripts/patch_*` files targeting other repos.
198
+ - **Update the User Guide** (`src/data/userGuide.js`) — last step, covering all new features: intake, profile & inferred info, Journey, tools, Model Status.
199
+
200
+ ---
201
+
202
+ ## 13. Execution phases
203
+
204
+ Executed with Cursor multitasking + subagents on cheaper/faster models where suitable; each phase tested before the next; work lands on `dev-cursor`, PRs from `FEAT_*` branches.
205
+
206
+ | Phase | Scope | Depends on |
207
+ |---|---|---|
208
+ | **0. Baseline & hygiene** | Snapshot on `origin/dev-cursor` (emails rewritten to noreply); Dockerfile fix; rename/purge PhD debt; dev hot-reload compose; README | — |
209
+ | **1. Profile foundation** | Render profile UI; new `user_facts`/`user_summaries` schema; Profile page (stated+inferred views); clean field mapping | 0 |
210
+ | **2. Knowledge engine** | Inference pass per message; dual summaries + regeneration triggers; context budgeting (4096 small / full large); summary→provider routing | 1 |
211
+ | **3. Conversation intelligence** | Orchestrator profile-aware routing; generated follow-ups; give-and-take + adaptive register prompt contracts; intake flow & chips | 2 |
212
+ | **4. Panel roster** | Canonical Jerry prompt; revise 5 experts; add SMB advisor + AI Security strategist | 0 (parallel w/ 2–3) |
213
+ | **5. Journey & pages** | Track engine + YAML tracks; Journey page w/ progress bar & trend; Workspace/Documents server persistence & curation | 1 |
214
+ | **6. Tools** | Model health pre-check + Model Status modal (early — it's required); RAG seeding; HIBP; CVE lookup; phishing analyzer; doc generator | 2 |
215
+ | **7. UI overhaul** | Design system, responsive layouts, chat presentation, engagement features | 3, 5 |
216
+ | **8. Ship** | User Guide update; full test pass (backend pytest, frontend RTL, device matrix); Docker build; HF push via `hf` CLI; CHANGELOG | all |
217
+
218
+ ---
219
+
220
+ ## 14. Who does what
221
+
222
+ **I can do:** everything in Phases 0–8 (code, tests, Docker, HF upload via `hf` CLI, docs), plus the snapshot push to `dev-cursor` once unblocked.
223
+
224
+ **You need to:**
225
+ 1. **Now:** temporarily uncheck "Block command line pushes that expose my email" at github.com/settings/emails so I can push the pristine baseline (or approve history rewrite with your noreply email — hashes change).
226
+ 2. Create/confirm the `main` branch on the new repo (I'll give you the exact command), and set HF Space secrets when we deploy (`JWT_SECRET_KEY`, `VLLM_API_KEY`/`HANA_*`, `OPENAI_API_KEY`, `GEMINI_API_KEY`).
227
+ 3. **Decisions to review in this plan:** expert roster (§9 — especially the two new personas and whether Privacy gets its own advisor), track list (§7), page structure (§8), and whether IP-based org enrichment is in or out (§10).
228
+ 4. From Jerry: his checklists/how-tos for the ITIL track content, his open-source data-flow tool list, and any color/branding preferences.
README.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Cybersecurity Panel
3
+ emoji: 🛡️
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # Cybersecurity Panel
12
+
13
+ AI cybersecurity guidance through a panel of specialized advisors, led by **AI Jerry Huaute, CISSP**, built on Neon AI's Collaborative Conversational AI (CCAI) framework and BrainForge Security models. Collaboration between Jerry Huaute and Neon.ai.
14
+
15
+ ## Hugging Face Spaces
16
+
17
+ Single Docker image from the repo-root [`Dockerfile`](Dockerfile):
18
+
19
+ 1. Builds the React frontend at image-build time with `REACT_APP_API_URL=""` (same-origin fetches).
20
+ 2. Serves the SPA from FastAPI on port **7860** with `/api/...` and `/auth/...` on the same origin.
21
+ 3. Persists auth, profiles, chat sessions, onboarding, canvas, and user knowledge in **SQLite** (`aiosqlite`) at `${DATA_DIR}/cybersecurity_panel.db`. Mount a Hugging Face Storage Bucket at `/data` so data survives rebuilds. Vector store: ChromaDB under the same data dir.
22
+
23
+ ### Required Space secrets
24
+
25
+ | Secret | Purpose |
26
+ |--------|---------|
27
+ | `JWT_SECRET_KEY` | Signs auth tokens (long random string) |
28
+ | `VLLM_API_KEY` / Neon HANA creds | Neon vLLM / BrainForge Security endpoint |
29
+ | `OPENAI_API_KEY` | GPT fallback / large-model path |
30
+ | `GEMINI_API_KEY` | Optional Gemini provider |
31
+
32
+ Also loadable from a shared env file at `C:\Users\dream\.secrets\shared.env` for local development (`SHARED_ENV` / compose `env_file`).
33
+
34
+ ## Local development
35
+
36
+ ### Production-shaped container (matches Spaces)
37
+
38
+ ```bash
39
+ # PowerShell
40
+ $env:COMPOSE_DATE = Get-Date -Format "yyyyMMdd"
41
+ docker compose up --build
42
+ # App: http://localhost:7861
43
+ ```
44
+
45
+ ### Hot-reload development (recommended while coding)
46
+
47
+ ```bash
48
+ $env:COMPOSE_DATE = Get-Date -Format "yyyyMMdd"
49
+ docker compose --profile dev up --build
50
+ # Frontend: http://localhost:3000
51
+ # Backend: http://localhost:8000 (docs at /docs)
52
+ ```
53
+
54
+ Backend uses `uvicorn --reload`; frontend uses CRA with file polling. Source is bind-mounted from `multi_llm_chatbot_backend/`, `frontend/`, `personas/`, and `cybersecurity_config.yaml`.
55
+
56
+ ### Without Docker
57
+
58
+ 1. Load secrets from `C:\Users\dream\.secrets\shared.env` (or set `JWT_SECRET_KEY`, `OPENAI_API_KEY`, `VLLM_API_KEY`, etc.).
59
+ 2. Backend: `cd multi_llm_chatbot_backend && python -m venv venv && venv\Scripts\activate && pip install -r requirements.txt && uvicorn app.main:app --reload --port 8000`
60
+ 3. Frontend: `cd frontend && npm ci && set REACT_APP_API_URL=http://localhost:8000 && npm start`
61
+
62
+ ## Architecture
63
+
64
+ | Layer | Tech |
65
+ |-------|------|
66
+ | Frontend | React (CRA) in `frontend/` |
67
+ | Backend | FastAPI in `multi_llm_chatbot_backend/` |
68
+ | DB | SQLite via Mongo-shaped shim (`app/core/db.py`) |
69
+ | Vectors | ChromaDB (document RAG) |
70
+ | Primary LLM | Neon vLLM — `BrainForge/Security` |
71
+ | Fallback | OpenAI GPT (resilient race), Gemini, Ollama |
72
+
73
+ ### Advisors
74
+
75
+ Personas live in `personas/cyber_advisors/*.yaml` (Jerry Huaute lead + specialist panel). App config: `cybersecurity_config.yaml`.
76
+
77
+ ### Features
78
+
79
+ - Panel chat with streaming multi-advisor responses; Jerry Huaute always on the panel, urgency triage puts the incident expert first, generated follow-up chips after each panel reply
80
+ - User profile: stated + inferred facts, dual LLM summaries (short for Neon, long for large models)
81
+ - Security Journey progress tracks (ITIL, NIST CSF, CIS, certs, personal digital security, custom)
82
+ - Document upload + RAG, export (TXT/PDF/DOCX)
83
+ - Model Status health probes and provider selection (Settings → Model Status)
84
+
85
+ ## Project layout
86
+
87
+ ```
88
+ cybersecurity-panel/
89
+ ├── cybersecurity_config.yaml
90
+ ├── personas/cyber_advisors/
91
+ ├── tracks/ # Security Journey track definitions
92
+ ├── multi_llm_chatbot_backend/
93
+ ├── frontend/
94
+ ├── Dockerfile # HF Spaces / prod
95
+ ├── Dockerfile.dev # hot-reload targets
96
+ ├── docker-compose.yml
97
+ ├── PLAN.md # overhaul plan
98
+ └── README.md
99
+ ```
100
+
101
+ ## Tests
102
+
103
+ ```bash
104
+ cd multi_llm_chatbot_backend
105
+ pip install -r requirements.txt -r test_requirements.txt
106
+ python -m pytest app/tests/unit -q
107
+ ```
108
+
109
+ ## License / credit
110
+
111
+ © Neon AI. Built with Jerry Huaute, CISSP. CCAI and BrainForge are Neon.ai technologies.
cybersecurity_config.yaml ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Cybersecurity Advisor — Application Configuration
3
+ # ============================================================================
4
+
5
+ app:
6
+ title: "Cybersecurity Advisor"
7
+ subtitle: "AI-Powered Security Guidance"
8
+ primary_color: "#5558E3"
9
+ logo_icon: "Shield"
10
+ footer_text: "© 2026 Neon AI. All rights reserved."
11
+ user_avatars:
12
+ - { id: "shield-slate", icon: "Shield", color: "#0F172A", bg: "#F1F5F9" }
13
+ - { id: "user-blue", icon: "User", color: "#2563EB", bg: "#EFF6FF" }
14
+ - { id: "lock-red", icon: "Lock", color: "#DC2626", bg: "#FEF2F2" }
15
+ - { id: "terminal-green", icon: "Terminal", color: "#059669", bg: "#ECFDF5" }
16
+ - { id: "bug-amber", icon: "Bug", color: "#F59E0B", bg: "#FFFBEB" }
17
+ - { id: "server-cyan", icon: "Server", color: "#0891B2", bg: "#ECFEFF" }
18
+ - { id: "key-purple", icon: "KeyRound", color: "#7C3AED", bg: "#F3E8FF" }
19
+ - { id: "radar-rose", icon: "Radar", color: "#E11D48", bg: "#FFF1F2" }
20
+
21
+ homepage:
22
+ headline_prefix: "Strengthen Your Security Posture with"
23
+ headline_highlight: "Expert AI Advisors"
24
+ description: >-
25
+ Get practical guidance on threats, compliance, incident response, architecture,
26
+ and career growth from a panel of cybersecurity-focused AI advisors — each
27
+ bringing a distinct lens to your questions.
28
+ features_title: "Why Use Cybersecurity Advisor?"
29
+ features:
30
+ - title: "Defense in Depth"
31
+ description: "Receive layered perspectives on risk, controls, detection, and response"
32
+ icon: "Shield"
33
+ - title: "Neon Security Model"
34
+ description: "Powered by BrainForge Security on Neon 4090 x1-3 with GPT-5.4 fallback"
35
+ icon: "Brain"
36
+ - title: "Available 24/7"
37
+ description: "Get answers during incidents, audits, study sessions, or late-night architecture reviews"
38
+ icon: "Clock"
39
+
40
+ login:
41
+ subtitle: "Sign in to continue your security journey"
42
+ signup_subtitle: "Create your account for personalized guidance from our cybersecurity advisor panel"
43
+ knowledge_levels:
44
+ - { value: "", label: "Select your cybersecurity knowledge level" }
45
+ - { value: "newcomer", label: "New to cybersecurity" }
46
+ - { value: "foundational", label: "Foundational — coursework or self-study" }
47
+ - { value: "practitioner", label: "Practitioner — hands-on experience" }
48
+ - { value: "experienced", label: "Experienced — multi-year professional" }
49
+ - { value: "expert", label: "Expert / specialist" }
50
+ timezones:
51
+ - { value: "", label: "Select timezone (optional)" }
52
+ - { value: "America/New_York", label: "Eastern (US)" }
53
+ - { value: "America/Chicago", label: "Central (US)" }
54
+ - { value: "America/Denver", label: "Mountain (US)" }
55
+ - { value: "America/Los_Angeles", label: "Pacific (US)" }
56
+ - { value: "America/Anchorage", label: "Alaska (US)" }
57
+ - { value: "Pacific/Honolulu", label: "Hawaii (US)" }
58
+ - { value: "Europe/London", label: "UK / Ireland" }
59
+ - { value: "Europe/Paris", label: "Central Europe" }
60
+ - { value: "Asia/Tokyo", label: "Japan" }
61
+ - { value: "Asia/Singapore", label: "Singapore" }
62
+ - { value: "Australia/Sydney", label: "Australia (East)" }
63
+ - { value: "UTC", label: "UTC" }
64
+ academic_stages:
65
+ - { value: "", label: "Select your cybersecurity knowledge level" }
66
+ - { value: "newcomer", label: "New to cybersecurity" }
67
+ - { value: "foundational", label: "Foundational — coursework or self-study" }
68
+ - { value: "practitioner", label: "Practitioner — hands-on experience" }
69
+ - { value: "experienced", label: "Experienced — multi-year professional" }
70
+ - { value: "expert", label: "Expert / specialist" }
71
+
72
+ chat_page:
73
+ placeholder: "Ask your advisors about threats, controls, incidents, compliance, or your security career..."
74
+ intake:
75
+ greeting: >-
76
+ You've contacted me today — what is it that I can help you with in cybersecurity?
77
+ chips:
78
+ - { id: "hacked", label: "I think I've been hacked", prompt: "I think I may have been hacked or compromised. Help me figure out what to do first." }
79
+ - { id: "audit", label: "Prepare for an audit or review", prompt: "I have a cybersecurity review or audit coming up and I need help preparing." }
80
+ - { id: "business", label: "Secure my business", prompt: "I need practical help securing my small business — what should I prioritize?" }
81
+ - { id: "career", label: "Grow my security career", prompt: "I want to grow my cybersecurity career. Help me pick a path and next steps." }
82
+ - { id: "other", label: "Something else…", prompt: "", free_text: true }
83
+ by_persona:
84
+ personal:
85
+ - { id: "p-passwords", label: "Password manager & MFA", prompt: "Help me set up a password manager and turn on MFA for my important accounts." }
86
+ - { id: "p-phish", label: "Spot phishing emails", prompt: "How do I recognize phishing emails and what should I do if I clicked a shady link?" }
87
+ - { id: "p-backup", label: "Backups that work", prompt: "What is a simple, reliable backup plan for my home computer and photos?" }
88
+ - { id: "p-hacked", label: "I might be compromised", prompt: "I think my personal account or device may be compromised. What should I do first?" }
89
+ - { id: "other", label: "Something else…", prompt: "", free_text: true }
90
+ business:
91
+ - { id: "b-baseline", label: "SMB security baseline", prompt: "What practical cybersecurity baseline should a small business put in place first?" }
92
+ - { id: "b-mfa", label: "MFA & access hygiene", prompt: "How should we roll out MFA and tighten access for email and admin accounts?" }
93
+ - { id: "b-ir", label: "Incident playbook", prompt: "Help me draft a simple first-hour playbook for a phishing or ransomware incident." }
94
+ - { id: "b-audit", label: "Audit / review prep", prompt: "We have a cybersecurity review coming up — what evidence and gaps should we prepare?" }
95
+ - { id: "other", label: "Something else…", prompt: "", free_text: true }
96
+ other:
97
+ - { id: "o-clarify", label: "Clarify my goal", prompt: "Help me turn my custom security goal into clear next steps." }
98
+ - { id: "o-path", label: "Pick a learning path", prompt: "Based on my goal, what should I learn or practice first in cybersecurity?" }
99
+ - { id: "o-tools", label: "Tools that fit", prompt: "What tools or controls best match the situation I described?" }
100
+ - { id: "o-risks", label: "Top risks for me", prompt: "What are the top risks I should address given what I shared?" }
101
+ - { id: "other", label: "Something else…", prompt: "", free_text: true }
102
+ examples:
103
+ - title: "Threats & Defense"
104
+ icon: "ShieldAlert"
105
+ color: "#DC2626"
106
+ bg_color: "#FEF2F2"
107
+ suggestions:
108
+ - "How do I prioritize vulnerabilities found in a recent scan?"
109
+ - "Walk me through STRIDE threat modeling for a new API"
110
+ - "What should our phishing simulation program measure?"
111
+ - title: "Compliance & Governance"
112
+ icon: "Scale"
113
+ color: "#2563EB"
114
+ bg_color: "#EFF6FF"
115
+ suggestions:
116
+ - "How do NIST CSF and ISO 27001 overlap for a mid-size company?"
117
+ - "What evidence do auditors expect for access reviews?"
118
+ - "How do I scope SOC 2 controls for a SaaS product?"
119
+ - title: "Incidents & IR"
120
+ icon: "Siren"
121
+ color: "#F59E0B"
122
+ bg_color: "#FFFBEB"
123
+ suggestions:
124
+ - "First 60 minutes after ransomware is detected — what do I do?"
125
+ - "How do I preserve forensic evidence on a compromised endpoint?"
126
+ - "When should we engage legal and PR during a breach?"
127
+ - title: "Career & Skills"
128
+ icon: "TrendingUp"
129
+ color: "#059669"
130
+ bg_color: "#ECFDF5"
131
+ suggestions:
132
+ - "CISSP vs Security+ — which path fits my background?"
133
+ - "How do I move from SOC tier 1 to detection engineering?"
134
+ - "What should I build in a home lab to stand out in interviews?"
135
+ examples_by_persona:
136
+ personal:
137
+ - title: "Everyday digital safety"
138
+ icon: "Shield"
139
+ color: "#0F766E"
140
+ bg_color: "#ECFDF5"
141
+ suggestions:
142
+ - "How do I lock down my email and social accounts with MFA?"
143
+ - "What's the simplest password manager setup for a household?"
144
+ - "How should I back up my Windows PC so ransomware can't wipe me out?"
145
+ - title: "Scams & phishing"
146
+ icon: "MailWarning"
147
+ color: "#DC2626"
148
+ bg_color: "#FEF2F2"
149
+ suggestions:
150
+ - "I got a weird bank email — how do I check if it's phishing?"
151
+ - "What should I do if I typed my password on a fake site?"
152
+ - "How do I talk to family about scam texts without sounding like I'm lecturing?"
153
+ - title: "Devices & home network"
154
+ icon: "Laptop"
155
+ color: "#2563EB"
156
+ bg_color: "#EFF6FF"
157
+ suggestions:
158
+ - "What Windows updates and settings matter most for security?"
159
+ - "How do I secure my home Wi-Fi and guest network?"
160
+ - "Should I use antivirus, Windows Defender, or both?"
161
+ business:
162
+ - title: "SMB baseline"
163
+ icon: "Building2"
164
+ color: "#2563EB"
165
+ bg_color: "#EFF6FF"
166
+ suggestions:
167
+ - "What CIS IG1 controls should a ~50-person company tackle first?"
168
+ - "How do I inventory devices and SaaS without a huge IT budget?"
169
+ - "What's a practical MFA rollout plan for email and admin access?"
170
+ - title: "Incidents & readiness"
171
+ icon: "Siren"
172
+ color: "#F59E0B"
173
+ bg_color: "#FFFBEB"
174
+ suggestions:
175
+ - "Draft a one-page phishing / ransomware first-hour playbook."
176
+ - "When should we loop in legal, leadership, or an IR firm?"
177
+ - "How do we preserve evidence on a suspected compromised laptop?"
178
+ - title: "Governance & questionnaires"
179
+ icon: "Scale"
180
+ color: "#7C3AED"
181
+ bg_color: "#F5F3FF"
182
+ suggestions:
183
+ - "Help me answer a vendor security questionnaire for our SMB."
184
+ - "What policies are table-stakes before an audit?"
185
+ - "How do access reviews work when we're mostly on Microsoft 365?"
186
+ other:
187
+ - title: "Shape your path"
188
+ icon: "Compass"
189
+ color: "#0F766E"
190
+ bg_color: "#ECFDF5"
191
+ suggestions:
192
+ - "Help me break my custom security goal into weekly milestones."
193
+ - "What should I learn first given the situation I described?"
194
+ - "Which advisor topics are most relevant to my goal?"
195
+ - title: "Practical next steps"
196
+ icon: "ListChecks"
197
+ color: "#2563EB"
198
+ bg_color: "#EFF6FF"
199
+ suggestions:
200
+ - "What tools or controls best fit what I shared?"
201
+ - "Where am I most exposed right now, and what reduces that risk fast?"
202
+ - "How do I know I've made real progress toward my goal?"
203
+
204
+ personas:
205
+ base_prompt: |
206
+ **Conversation rules:**
207
+ - Always offer useful information before asking a follow-up question (give-and-take). Never interrogate.
208
+ - Adapt vocabulary to the user's knowledge level from their summary: plain language for beginners; full technical terms for experts.
209
+ - Prefer one high-value follow-up over a list of questions.
210
+ - If urgency is high (active incident / "was I hacked"), lead with immediate first steps before profiling.
211
+
212
+ **Formatting (Compact Markdown v1):**
213
+ - Use GitHub-Flavored Markdown.
214
+ - Output exactly three sections in this order:
215
+ - `### Thought` — complete reasoning or context in 1–2 finished sentences (never cut mid-sentence into the next section; never use … or ...).
216
+ - `### What to do` — exactly 3 concrete action bullets as plain complete imperatives (no bold title prefixes); each a distinct actionable step (not leftover thought prose).
217
+ - `### Next step` — one imperative sentence that is NOT a copy or near-paraphrase of the first What-to-do bullet; name the single most important action to start with now.
218
+ - Use `###` for headings, `-` for bullets (no unicode bullets), keep number text on the same line (e.g., `1. Do X`).
219
+ - Insert one blank line between blocks.
220
+ - Never put actions under Thought, and never put reasoning bullets under What to do.
221
+ - Never truncate with ellipsis; finish every sentence and bullet.
222
+
223
+ personas_dir: "personas/cyber_advisors"
224
+
225
+ orchestrator:
226
+ min_words_without_keywords: 6
227
+ conversation_history_token_threshold: 4000
228
+ required_advisor: "jerry_huaute"
229
+ triage_advisor: "incident_responder"
230
+ followup_count: 3
231
+ specific_keywords:
232
+ - "security"
233
+ - "cyber"
234
+ - "threat"
235
+ - "vulnerability"
236
+ - "CVE"
237
+ - "malware"
238
+ - "ransomware"
239
+ - "phishing"
240
+ - "IAM"
241
+ - "MFA"
242
+ - "SIEM"
243
+ - "SOC"
244
+ - "pentest"
245
+ - "compliance"
246
+ - "NIST"
247
+ - "ISO"
248
+ - "incident"
249
+ - "forensics"
250
+ - "firewall"
251
+ - "encryption"
252
+ - "zero trust"
253
+ - "cloud"
254
+ - "AWS"
255
+ - "Azure"
256
+ - "CISSP"
257
+ - "audit"
258
+
259
+ clarification_questions:
260
+ - "What specific security topic would you like guidance on?"
261
+ - "Are you focused on prevention, detection, response, compliance, or career growth?"
262
+ - "What is your role and the system or environment you're asking about?"
263
+ - "What's the most urgent risk or decision you're facing right now?"
264
+
265
+ clarification_suggestions:
266
+ - "Ask about threat modeling or hardening a workload"
267
+ - "Get help with incident response or forensics steps"
268
+ - "Request compliance or audit preparation guidance"
269
+ - "Upload a policy, architecture diagram, or log excerpt for review"
270
+
271
+ auth:
272
+ algorithm: "HS256"
273
+ token_expiry_minutes: 43200
274
+
275
+ mongodb:
276
+ database_name: "cybersecurity_advisor"
277
+
278
+ llm:
279
+ provider: "vllm"
280
+ gemini:
281
+ model: "gemini-2.5-flash"
282
+ ollama:
283
+ model: "llama3.2:1b"
284
+ vllm:
285
+ api_url: "https://4090-x1-3.neonaiservices2.com/vllm0"
286
+ api_key: ""
287
+ # Leave empty for Bearer VLLM_API_KEY auth (current Neon 4090). Set only
288
+ # when the endpoint requires HTTP Basic (VLLM_API_USERNAME / HANA_*).
289
+ api_username: ""
290
+ model_id: "BrainForge/Security@2026.05.13"
291
+ neon_persona_orchestrator: "vanilla"
292
+ neon_persona_advisors: "CybersecurityExpert"
293
+ openai:
294
+ api_key: ""
295
+ model: "gpt-5.4"
296
+ orchestrator_reasoning_effort: "low"
297
+ persona_reasoning_effort: "none"
298
+ resilient:
299
+ race_timeout_seconds: 60
300
+
301
+ rag:
302
+ embedding_model: "all-MiniLM-L6-v2"
303
+ chroma_collection: "cybersecurity_advisor_documents"
304
+
305
+ tools:
306
+ current_datetime:
307
+ enabled: true
308
+ default_timezone: "America/Los_Angeles"
309
+
310
+ user_knowledge:
311
+ short_summary_max_tokens: 150
312
+ long_summary_max_tokens: 600
313
+ small_model_context_budget: 4096
314
+ extract_on_every_message: true
315
+ small_context_providers:
316
+ - vllm
317
+ - ollama
318
+
319
+ voice:
320
+ stt_endpoint: "https://whisper.neonaiservices.com"
321
+ tts_endpoint: "https://coqui.neonaiservices.com"
docker-compose.yml ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------------------------------------------------------------------
2
+ # Docker Compose for Cybersecurity Panel
3
+ #
4
+ # Default (`docker compose up --build`): production-shaped single container
5
+ # (same as HuggingFace Spaces). Port host 7861 → container 7860.
6
+ #
7
+ # Dev with hot reload:
8
+ # docker compose --profile dev up --build
9
+ # Backend: uvicorn --reload on :8000
10
+ # Frontend: CRA npm start on :3000 (REACT_APP_API_URL=http://localhost:8000)
11
+ #
12
+ # Secrets: prefer C:\Users\dream\.secrets\shared.env (mounted / copied via
13
+ # env_file). Override with a local .env as needed.
14
+ #
15
+ # Image/container naming follows: <repo>-<branch>-<date>
16
+ # ---------------------------------------------------------------------------
17
+
18
+ x-shared-env: &shared-env
19
+ # Non-secret defaults only. API keys / JWT come from env_file
20
+ # (C:/Users/dream/.secrets/shared.env) — do NOT set OPENAI_API_KEY /
21
+ # VLLM_API_KEY here with ${VAR:-} or an empty host env will blank the
22
+ # values that env_file already injected.
23
+ GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}
24
+ CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
25
+
26
+ services:
27
+ app:
28
+ build:
29
+ context: .
30
+ dockerfile: Dockerfile
31
+ image: cybersecurity-panel-dev-cursor-${COMPOSE_DATE:-local}
32
+ container_name: cybersecurity-panel-dev-cursor-${COMPOSE_DATE:-local}
33
+ ports:
34
+ - "${CYBERPANEL_HOST_PORT:-7861}:7860"
35
+ env_file:
36
+ - path: ${SHARED_ENV_FILE:-C:/Users/dream/.secrets/shared.env}
37
+ required: false
38
+ - path: .env
39
+ required: false
40
+ environment:
41
+ <<: *shared-env
42
+ JWT_SECRET_KEY: ${JWT_SECRET_KEY:-CHANGEME-by-overriding-in-dot-env-file}
43
+ CONFIG_PATH: ${CONFIG_PATH:-/home/user/app/cybersecurity_config.yaml}
44
+ CORS_ORIGINS: ${CORS_ORIGINS:-*}
45
+ DATA_DIR: /data
46
+ volumes:
47
+ - cybersecurity_data:/data
48
+
49
+ backend:
50
+ profiles: ["dev"]
51
+ build:
52
+ context: .
53
+ dockerfile: Dockerfile.dev
54
+ target: backend
55
+ image: cybersecurity-panel-backend-dev-cursor-${COMPOSE_DATE:-local}
56
+ container_name: cybersecurity-panel-backend-dev-cursor-${COMPOSE_DATE:-local}
57
+ ports:
58
+ - "${BACKEND_HOST_PORT:-8000}:8000"
59
+ env_file:
60
+ - path: ${SHARED_ENV_FILE:-C:/Users/dream/.secrets/shared.env}
61
+ required: false
62
+ - path: .env
63
+ required: false
64
+ environment:
65
+ <<: *shared-env
66
+ CONFIG_PATH: /app/cybersecurity_config.yaml
67
+ DATA_DIR: /data
68
+ PYTHONPATH: /app/multi_llm_chatbot_backend
69
+ # Keep a host-readable copy of shared.env for _load_shared_env_var fallback
70
+ SHARED_ENV: /run/secrets/shared.env
71
+ volumes:
72
+ - ./multi_llm_chatbot_backend:/app/multi_llm_chatbot_backend
73
+ - ./cybersecurity_config.yaml:/app/cybersecurity_config.yaml:ro
74
+ - ./personas:/app/personas:ro
75
+ - ./tracks:/app/tracks:ro
76
+ - cybersecurity_data:/data
77
+ - type: bind
78
+ source: ${SHARED_ENV_FILE:-C:/Users/dream/.secrets/shared.env}
79
+ target: /run/secrets/shared.env
80
+ read_only: true
81
+ command:
82
+ [
83
+ "uvicorn",
84
+ "app.main:app",
85
+ "--host",
86
+ "0.0.0.0",
87
+ "--port",
88
+ "8000",
89
+ "--reload",
90
+ "--reload-dir",
91
+ "/app/multi_llm_chatbot_backend/app",
92
+ ]
93
+
94
+ frontend:
95
+ profiles: ["dev"]
96
+ build:
97
+ context: .
98
+ dockerfile: Dockerfile.dev
99
+ target: frontend
100
+ image: cybersecurity-panel-frontend-dev-cursor-${COMPOSE_DATE:-local}
101
+ container_name: cybersecurity-panel-frontend-dev-cursor-${COMPOSE_DATE:-local}
102
+ ports:
103
+ - "${FRONTEND_HOST_PORT:-3000}:3000"
104
+ environment:
105
+ REACT_APP_API_URL: ${REACT_APP_API_URL:-http://localhost:8000}
106
+ CHOKIDAR_USEPOLLING: "true"
107
+ WATCHPACK_POLLING: "true"
108
+ volumes:
109
+ - ./frontend:/app
110
+ - frontend_node_modules:/app/node_modules
111
+ depends_on:
112
+ - backend
113
+ command: ["npm", "start"]
114
+
115
+ volumes:
116
+ cybersecurity_data:
117
+ frontend_node_modules:
frontend/.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # production
12
+ /build
13
+
14
+ # misc
15
+ .DS_Store
16
+ .env.local
17
+ .env.development.local
18
+ .env.test.local
19
+ .env.production.local
20
+
21
+ npm-debug.log*
22
+ yarn-debug.log*
23
+ yarn-error.log*
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cybersecurity-panel-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@codemirror/legacy-modes": "^6.5.2",
7
+ "@testing-library/dom": "^10.4.0",
8
+ "@testing-library/jest-dom": "^6.6.3",
9
+ "@testing-library/react": "^16.3.0",
10
+ "@testing-library/user-event": "^13.5.0",
11
+ "@uiw/react-codemirror": "^4.25.9",
12
+ "katex": "^0.16.45",
13
+ "latex.js": "^0.12.6",
14
+ "lucide-react": "^0.544.0",
15
+ "react": "^19.1.0",
16
+ "react-dom": "^19.1.0",
17
+ "react-markdown": "^10.1.0",
18
+ "react-scripts": "5.0.1",
19
+ "rehype-katex": "^7.0.1",
20
+ "remark-gfm": "^4.0.1",
21
+ "remark-math": "^6.0.0",
22
+ "web-vitals": "^2.1.4"
23
+ },
24
+ "scripts": {
25
+ "start": "react-scripts start",
26
+ "build": "react-scripts build",
27
+ "test": "react-scripts test",
28
+ "eject": "react-scripts eject"
29
+ },
30
+ "eslintConfig": {
31
+ "extends": [
32
+ "react-app",
33
+ "react-app/jest"
34
+ ]
35
+ },
36
+ "browserslist": {
37
+ "production": [
38
+ ">0.2%",
39
+ "not dead",
40
+ "not op_mini all"
41
+ ],
42
+ "development": [
43
+ "last 1 chrome version",
44
+ "last 1 firefox version",
45
+ "last 1 safari version"
46
+ ]
47
+ }
48
+ }
frontend/public/favicon.ico ADDED

Git LFS Details

  • SHA256: 3d10f7da6c603178340081668c4ac5b3ae9743ca9a262ab0fcd312fbb9f48bdd
  • Pointer size: 129 Bytes
  • Size of remote file: 3.87 kB
frontend/public/index.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="theme-color" content="#000000" />
8
+ <meta
9
+ name="description"
10
+ content="Cybersecurity Advisor — AI-powered security guidance from expert advisors"
11
+ />
12
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
+ <!--
14
+ manifest.json provides metadata used when your web app is installed on a
15
+ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16
+ -->
17
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18
+ <!--
19
+ Notice the use of %PUBLIC_URL% in the tags above.
20
+ It will be replaced with the URL of the `public` folder during the build.
21
+ Only files inside the `public` folder can be referenced from the HTML.
22
+
23
+ Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
24
+ work correctly both with client-side routing and a non-root public URL.
25
+ Learn how to configure a non-root public URL by running `npm run build`.
26
+ -->
27
+ <title>Cybersecurity Advisor</title>
28
+ </head>
29
+ <body>
30
+ <noscript>You need to enable JavaScript to run this app.</noscript>
31
+ <div id="root"></div>
32
+ <!--
33
+ This HTML file is a template.
34
+ If you open it directly in the browser, you will see an empty page.
35
+
36
+ You can add webfonts, meta tags, or analytics to this file.
37
+ The build step will place the bundled scripts into the <body> tag.
38
+
39
+ To begin the development, run `npm start` or `yarn start`.
40
+ To create a production bundle, use `npm run build` or `yarn build`.
41
+ -->
42
+ </body>
43
+ </html>
frontend/public/logo192.png ADDED

Git LFS Details

  • SHA256: c386396ec70db3608075b5fbfaac4ab1ccaa86ba05a68ab393ec551eb66c3e00
  • Pointer size: 129 Bytes
  • Size of remote file: 5.35 kB
frontend/public/logo512.png ADDED

Git LFS Details

  • SHA256: 9ea4f4da7050c0cc408926f6a39c253624e9babb1d43c7977cd821445a60b461
  • Pointer size: 129 Bytes
  • Size of remote file: 9.66 kB
frontend/public/manifest.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "short_name": "Cybersecurity Advisor",
3
+ "name": "Cybersecurity Advisor",
4
+ "icons": [
5
+ {
6
+ "src": "favicon.ico",
7
+ "sizes": "64x64 32x32 24x24 16x16",
8
+ "type": "image/x-icon"
9
+ },
10
+ {
11
+ "src": "logo192.png",
12
+ "type": "image/png",
13
+ "sizes": "192x192"
14
+ },
15
+ {
16
+ "src": "logo512.png",
17
+ "type": "image/png",
18
+ "sizes": "512x512"
19
+ }
20
+ ],
21
+ "start_url": ".",
22
+ "display": "standalone",
23
+ "theme_color": "#000000",
24
+ "background_color": "#ffffff"
25
+ }
frontend/public/neon-logo.png ADDED

Git LFS Details

  • SHA256: 9c9839f8c25e457a57c2d30bffefbf7d31607b071ad85004445054f899c5ec28
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
frontend/public/robots.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
frontend/src/App.js ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { ThemeProvider } from './contexts/ThemeContext';
3
+ import { AppConfigProvider } from './contexts/AppConfigContext';
4
+ import HomePage from './pages/HomePage';
5
+ import ChatPage from './pages/ChatPage';
6
+ import AuthPage from './pages/AuthPage';
7
+ import CanvasPage from './pages/CanvasPage';
8
+ import JourneyPage from './pages/JourneyPage';
9
+ import UserGuide from './components/UserGuide';
10
+ import './styles/components.css';
11
+
12
+ function App() {
13
+ const [currentView, setCurrentView] = useState('home');
14
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
15
+ const [user, setUser] = useState(null);
16
+ const [authToken, setAuthToken] = useState(null);
17
+
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+ const token = localStorage.getItem('authToken');
21
+ const userData = localStorage.getItem('user');
22
+
23
+ if (!token || !userData) return undefined;
24
+
25
+ let parsedUser;
26
+ try {
27
+ parsedUser = JSON.parse(userData);
28
+ } catch {
29
+ localStorage.removeItem('authToken');
30
+ localStorage.removeItem('user');
31
+ return undefined;
32
+ }
33
+
34
+ // Restore UI immediately, then prove the JWT still works. Stale tokens
35
+ // (rotated secret, deleted guest, etc.) previously left the user in chat
36
+ // with every API call 401'ing and send silently failing.
37
+ setAuthToken(token);
38
+ setUser(parsedUser);
39
+ setIsAuthenticated(true);
40
+ setCurrentView('chat');
41
+
42
+ (async () => {
43
+ try {
44
+ const apiUrl = process.env.REACT_APP_API_URL || '';
45
+ const resp = await fetch(`${apiUrl}/auth/me`, {
46
+ headers: { Authorization: `Bearer ${token}` },
47
+ });
48
+ if (cancelled) return;
49
+ if (!resp.ok) {
50
+ localStorage.removeItem('authToken');
51
+ localStorage.removeItem('user');
52
+ setUser(null);
53
+ setAuthToken(null);
54
+ setIsAuthenticated(false);
55
+ setCurrentView('home');
56
+ return;
57
+ }
58
+ const me = await resp.json();
59
+ if (cancelled || !me) return;
60
+ setUser(me);
61
+ try {
62
+ localStorage.setItem('user', JSON.stringify(me));
63
+ } catch { /* ignore quota */ }
64
+ } catch {
65
+ // Network blip — keep optimistic session; next API call will re-check.
66
+ }
67
+ })();
68
+
69
+ return () => { cancelled = true; };
70
+ }, []);
71
+
72
+ const navigateToAuth = () => {
73
+ setCurrentView('auth');
74
+ };
75
+
76
+ const navigateToJourney = () => {
77
+ setCurrentView('journey');
78
+ };
79
+
80
+ const navigateToCanvas = (canvasView) => {
81
+ if (canvasView === 'journey') {
82
+ setCurrentView('journey');
83
+ return;
84
+ }
85
+ if (['insights', 'workspace', 'deliverables'].includes(canvasView)) {
86
+ localStorage.setItem('canvas-view-v2', canvasView);
87
+ }
88
+ setCurrentView('canvas');
89
+ };
90
+
91
+ const navigateToChat = () => {
92
+ setCurrentView('chat');
93
+ };
94
+
95
+ const navigateToHome = () => {
96
+ setCurrentView('home');
97
+ };
98
+
99
+ const handleAuthSuccess = (userData, token) => {
100
+ setUser(userData);
101
+ setAuthToken(token);
102
+ setIsAuthenticated(true);
103
+ setCurrentView('chat');
104
+ };
105
+
106
+ const handleSignOut = () => {
107
+ localStorage.removeItem('authToken');
108
+ localStorage.removeItem('user');
109
+ try {
110
+ localStorage.removeItem('canvas-layout-v2');
111
+ localStorage.removeItem('canvas-states-v2');
112
+ localStorage.removeItem('canvas-deliverables-v2');
113
+ localStorage.removeItem('canvas-view-v2');
114
+ localStorage.removeItem('canvas-task-status-v1');
115
+ } catch { /* ignore */ }
116
+ setUser(null);
117
+ setAuthToken(null);
118
+ setIsAuthenticated(false);
119
+ setCurrentView('home');
120
+ };
121
+
122
+ return (
123
+ <AppConfigProvider>
124
+ <ThemeProvider>
125
+ <div className="App">
126
+ {currentView === 'home' && (
127
+ <HomePage
128
+ onNavigateToHome={navigateToHome}
129
+ onNavigateToChat={isAuthenticated ? navigateToChat : navigateToAuth}
130
+ onNavigateToCanvas={isAuthenticated ? navigateToCanvas : navigateToAuth}
131
+ onNavigateToJourney={isAuthenticated ? navigateToJourney : navigateToAuth}
132
+ onExploreAsGuest={handleAuthSuccess}
133
+ isAuthenticated={isAuthenticated}
134
+ />
135
+ )}
136
+ {currentView === 'auth' && (
137
+ <AuthPage onAuthSuccess={handleAuthSuccess} />
138
+ )}
139
+ {currentView === 'canvas' && isAuthenticated && (
140
+ <CanvasPage
141
+ user={user}
142
+ authToken={authToken}
143
+ onNavigateToHome={navigateToHome}
144
+ onNavigateToChat={navigateToChat}
145
+ onNavigateToJourney={navigateToJourney}
146
+ onNavigateToCanvas={navigateToCanvas}
147
+ onSignOut={handleSignOut}
148
+ onUserUpdate={setUser}
149
+ />
150
+ )}
151
+ {currentView === 'journey' && isAuthenticated && (
152
+ <JourneyPage
153
+ user={user}
154
+ authToken={authToken}
155
+ onNavigateToHome={navigateToHome}
156
+ onNavigateToChat={navigateToChat}
157
+ onNavigateToCanvas={navigateToCanvas}
158
+ onNavigateToJourney={navigateToJourney}
159
+ onSignOut={handleSignOut}
160
+ onUserUpdate={setUser}
161
+ />
162
+ )}
163
+ {currentView === 'chat' && isAuthenticated && (
164
+ <ChatPage
165
+ user={user}
166
+ authToken={authToken}
167
+ onNavigateToHome={navigateToHome}
168
+ onNavigateToCanvas={navigateToCanvas}
169
+ onNavigateToJourney={navigateToJourney}
170
+ onSignOut={handleSignOut}
171
+ onUserUpdate={setUser}
172
+ />
173
+ )}
174
+ <UserGuide />
175
+ </div>
176
+ </ThemeProvider>
177
+ </AppConfigProvider>
178
+ );
179
+ }
180
+
181
+ export default App;
frontend/src/App.test.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from '@testing-library/react';
2
+ import App from './App';
3
+
4
+ test('renders learn react link', () => {
5
+ render(<App />);
6
+ const linkElement = screen.getByText(/learn react/i);
7
+ expect(linkElement).toBeInTheDocument();
8
+ });
frontend/src/components/AboutYouModal.js ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import {
4
+ X, User as UserIcon, Sparkles, Check, Trash2, Pencil, Plus,
5
+ RefreshCw, Loader2, MessageSquareQuote, Eye,
6
+ } from 'lucide-react';
7
+ import ProfileWalkthrough from './ProfileWalkthrough';
8
+
9
+ const FACT_CATEGORIES = [
10
+ { value: 'person', label: 'Person' },
11
+ { value: 'organization', label: 'Organization' },
12
+ { value: 'environment', label: 'Devices & environment' },
13
+ { value: 'needs', label: 'Needs' },
14
+ { value: 'preferences', label: 'Preferences' },
15
+ ];
16
+
17
+ const overlay = {
18
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
19
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
20
+ };
21
+
22
+ const modal = {
23
+ background: 'var(--bg-primary)', borderRadius: 16, padding: 0, width: 560,
24
+ maxWidth: '95vw', maxHeight: '85vh', overflow: 'hidden',
25
+ boxShadow: 'var(--shadow-xl)', display: 'flex', flexDirection: 'column',
26
+ };
27
+
28
+ const header = {
29
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
30
+ padding: '20px 24px', borderBottom: '1px solid var(--border-primary)',
31
+ };
32
+
33
+ const tabRow = {
34
+ display: 'flex', gap: 4, padding: '12px 16px 0',
35
+ borderBottom: '1px solid var(--border-primary)',
36
+ flexWrap: 'wrap',
37
+ };
38
+
39
+ const tabBtn = (active) => ({
40
+ display: 'flex', alignItems: 'center', gap: 8,
41
+ padding: '10px 14px', minHeight: 44, background: 'transparent',
42
+ border: 'none', borderBottom: active ? '2px solid var(--accent-primary)' : '2px solid transparent',
43
+ color: active ? 'var(--accent-primary)' : 'var(--text-secondary)',
44
+ cursor: 'pointer', fontSize: 13.5, fontWeight: 500,
45
+ marginBottom: -1,
46
+ });
47
+
48
+ const body = { padding: 24, overflowY: 'auto', flex: 1 };
49
+
50
+ const label = {
51
+ display: 'block', fontSize: 13, fontWeight: 600,
52
+ color: 'var(--text-secondary)', marginBottom: 6,
53
+ };
54
+
55
+ const input = {
56
+ width: '100%', padding: '10px 12px', borderRadius: 8, minHeight: 44,
57
+ border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
58
+ color: 'var(--text-primary)', fontSize: 14, boxSizing: 'border-box',
59
+ };
60
+
61
+ const primaryBtn = {
62
+ padding: '10px 16px', minHeight: 44, background: 'var(--accent-primary)',
63
+ color: '#fff', border: 'none', borderRadius: 8,
64
+ cursor: 'pointer', fontSize: 14, fontWeight: 500,
65
+ display: 'inline-flex', alignItems: 'center', gap: 8,
66
+ };
67
+
68
+ const ghostBtn = {
69
+ padding: '10px 14px', minHeight: 44, minWidth: 44,
70
+ background: 'transparent', border: '1px solid var(--border-primary)',
71
+ borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500,
72
+ color: 'var(--text-primary)', display: 'inline-flex', alignItems: 'center',
73
+ justifyContent: 'center', gap: 6,
74
+ };
75
+
76
+ const sectionTitle = {
77
+ margin: '0 0 6px', fontSize: 15, fontWeight: 600, color: 'var(--text-primary)',
78
+ };
79
+
80
+ const sectionHint = {
81
+ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.45,
82
+ };
83
+
84
+ const factCard = {
85
+ border: '1px solid var(--border-primary)', borderRadius: 10,
86
+ padding: '12px 14px', marginBottom: 10,
87
+ background: 'var(--bg-secondary)',
88
+ };
89
+
90
+ const extractError = (data, fallback) => {
91
+ if (!data) return fallback;
92
+ if (typeof data.detail === 'string') return data.detail;
93
+ if (Array.isArray(data.detail) && data.detail[0]?.msg) return data.detail[0].msg;
94
+ return fallback;
95
+ };
96
+
97
+ const AboutYouModal = ({
98
+ authToken,
99
+ onClose,
100
+ existingProfile,
101
+ initialTab = 'about',
102
+ }) => {
103
+ const [activeTab, setActiveTab] = useState(initialTab === 'profile' ? 'profile' : 'about');
104
+ const [facts, setFacts] = useState([]);
105
+ const [summaries, setSummaries] = useState({ short: '', long: '' });
106
+ const [loading, setLoading] = useState(true);
107
+ const [message, setMessage] = useState(null);
108
+ const [busyId, setBusyId] = useState(null);
109
+ const [regenerating, setRegenerating] = useState(false);
110
+ const [editingId, setEditingId] = useState(null);
111
+ const [editValue, setEditValue] = useState('');
112
+ const [showAdd, setShowAdd] = useState(false);
113
+ const [newFact, setNewFact] = useState({ category: 'person', key: '', value: '' });
114
+ const [adding, setAdding] = useState(false);
115
+
116
+ const apiUrl = process.env.REACT_APP_API_URL || '';
117
+ const mouseDownOnOverlay = useRef(false);
118
+
119
+ const handleOverlayMouseDown = (e) => {
120
+ mouseDownOnOverlay.current = e.target === e.currentTarget;
121
+ };
122
+ const handleOverlayMouseUp = (e) => {
123
+ if (mouseDownOnOverlay.current && e.target === e.currentTarget) onClose();
124
+ mouseDownOnOverlay.current = false;
125
+ };
126
+
127
+ const authHeaders = useCallback(() => ({
128
+ Authorization: `Bearer ${authToken}`,
129
+ 'Content-Type': 'application/json',
130
+ }), [authToken]);
131
+
132
+ const loadData = useCallback(async () => {
133
+ setLoading(true);
134
+ setMessage(null);
135
+ try {
136
+ const [factsResp, sumsResp] = await Promise.all([
137
+ fetch(`${apiUrl}/api/users/me/facts`, { headers: { Authorization: `Bearer ${authToken}` } }),
138
+ fetch(`${apiUrl}/api/users/me/summaries`, { headers: { Authorization: `Bearer ${authToken}` } }),
139
+ ]);
140
+ if (factsResp.ok) {
141
+ const data = await factsResp.json();
142
+ setFacts(Array.isArray(data.facts) ? data.facts : []);
143
+ } else {
144
+ setMessage({ type: 'error', text: 'Could not load profile facts.' });
145
+ }
146
+ if (sumsResp.ok) {
147
+ const data = await sumsResp.json();
148
+ setSummaries({ short: data.short || '', long: data.long || '' });
149
+ }
150
+ } catch {
151
+ setMessage({ type: 'error', text: 'Network error loading knowledge profile.' });
152
+ } finally {
153
+ setLoading(false);
154
+ }
155
+ }, [apiUrl, authToken]);
156
+
157
+ useEffect(() => {
158
+ if (activeTab === 'about') loadData();
159
+ }, [activeTab, loadData]);
160
+
161
+ useEffect(() => {
162
+ setActiveTab(initialTab === 'profile' ? 'profile' : 'about');
163
+ }, [initialTab]);
164
+
165
+ const statedFacts = facts.filter((f) => f.source === 'stated');
166
+ const inferredFacts = facts.filter((f) => f.source === 'inferred');
167
+
168
+ const handleConfirm = async (fact) => {
169
+ setBusyId(fact.id);
170
+ setMessage(null);
171
+ try {
172
+ const resp = await fetch(`${apiUrl}/api/users/me/facts/${fact.id}`, {
173
+ method: 'PUT',
174
+ headers: authHeaders(),
175
+ body: JSON.stringify({ source: 'stated' }),
176
+ });
177
+ const data = await resp.json().catch(() => null);
178
+ if (!resp.ok) {
179
+ setMessage({ type: 'error', text: extractError(data, 'Could not confirm fact.') });
180
+ return;
181
+ }
182
+ setFacts((prev) => prev.map((f) => (f.id === fact.id ? { ...f, ...data, source: 'stated' } : f)));
183
+ setMessage({ type: 'success', text: 'Fact confirmed as something you told us.' });
184
+ } catch {
185
+ setMessage({ type: 'error', text: 'Network error.' });
186
+ } finally {
187
+ setBusyId(null);
188
+ }
189
+ };
190
+
191
+ const handleDelete = async (fact) => {
192
+ setBusyId(fact.id);
193
+ setMessage(null);
194
+ try {
195
+ const resp = await fetch(`${apiUrl}/api/users/me/facts/${fact.id}`, {
196
+ method: 'DELETE',
197
+ headers: { Authorization: `Bearer ${authToken}` },
198
+ });
199
+ if (!resp.ok && resp.status !== 204) {
200
+ const data = await resp.json().catch(() => null);
201
+ setMessage({ type: 'error', text: extractError(data, 'Could not delete fact.') });
202
+ return;
203
+ }
204
+ setFacts((prev) => prev.filter((f) => f.id !== fact.id));
205
+ if (editingId === fact.id) {
206
+ setEditingId(null);
207
+ setEditValue('');
208
+ }
209
+ } catch {
210
+ setMessage({ type: 'error', text: 'Network error.' });
211
+ } finally {
212
+ setBusyId(null);
213
+ }
214
+ };
215
+
216
+ const startEdit = (fact) => {
217
+ setEditingId(fact.id);
218
+ setEditValue(fact.value || '');
219
+ setMessage(null);
220
+ };
221
+
222
+ const cancelEdit = () => {
223
+ setEditingId(null);
224
+ setEditValue('');
225
+ };
226
+
227
+ const saveEdit = async (fact) => {
228
+ const trimmed = editValue.trim();
229
+ if (!trimmed) {
230
+ setMessage({ type: 'error', text: 'Value cannot be empty.' });
231
+ return;
232
+ }
233
+ setBusyId(fact.id);
234
+ setMessage(null);
235
+ try {
236
+ const resp = await fetch(`${apiUrl}/api/users/me/facts/${fact.id}`, {
237
+ method: 'PUT',
238
+ headers: authHeaders(),
239
+ body: JSON.stringify({ value: trimmed }),
240
+ });
241
+ const data = await resp.json().catch(() => null);
242
+ if (!resp.ok) {
243
+ setMessage({ type: 'error', text: extractError(data, 'Could not update fact.') });
244
+ return;
245
+ }
246
+ setFacts((prev) => prev.map((f) => (f.id === fact.id ? { ...f, ...data } : f)));
247
+ setEditingId(null);
248
+ setEditValue('');
249
+ } catch {
250
+ setMessage({ type: 'error', text: 'Network error.' });
251
+ } finally {
252
+ setBusyId(null);
253
+ }
254
+ };
255
+
256
+ const handleAdd = async () => {
257
+ const key = newFact.key.trim();
258
+ const value = newFact.value.trim();
259
+ if (!key || !value) {
260
+ setMessage({ type: 'error', text: 'Key and value are required.' });
261
+ return;
262
+ }
263
+ setAdding(true);
264
+ setMessage(null);
265
+ try {
266
+ const resp = await fetch(`${apiUrl}/api/users/me/facts`, {
267
+ method: 'POST',
268
+ headers: authHeaders(),
269
+ body: JSON.stringify({
270
+ category: newFact.category,
271
+ key,
272
+ value,
273
+ }),
274
+ });
275
+ const data = await resp.json().catch(() => null);
276
+ if (!resp.ok) {
277
+ setMessage({ type: 'error', text: extractError(data, 'Could not add fact.') });
278
+ return;
279
+ }
280
+ setFacts((prev) => [...prev, data]);
281
+ setNewFact({ category: 'person', key: '', value: '' });
282
+ setShowAdd(false);
283
+ setMessage({ type: 'success', text: 'Fact added.' });
284
+ } catch {
285
+ setMessage({ type: 'error', text: 'Network error.' });
286
+ } finally {
287
+ setAdding(false);
288
+ }
289
+ };
290
+
291
+ const handleRegenerate = async () => {
292
+ setRegenerating(true);
293
+ setMessage(null);
294
+ try {
295
+ const resp = await fetch(`${apiUrl}/api/users/me/summaries/regenerate`, {
296
+ method: 'POST',
297
+ headers: { Authorization: `Bearer ${authToken}` },
298
+ });
299
+ const data = await resp.json().catch(() => null);
300
+ if (!resp.ok) {
301
+ setMessage({ type: 'error', text: extractError(data, 'Could not regenerate summaries.') });
302
+ return;
303
+ }
304
+ setSummaries({ short: data.short || '', long: data.long || '' });
305
+ setMessage({ type: 'success', text: 'Summaries regenerated.' });
306
+ } catch {
307
+ setMessage({ type: 'error', text: 'Network error.' });
308
+ } finally {
309
+ setRegenerating(false);
310
+ }
311
+ };
312
+
313
+ const messageStyle = (type) => ({
314
+ padding: '10px 12px', borderRadius: 8, marginBottom: 16, fontSize: 13,
315
+ background: type === 'error'
316
+ ? 'rgba(220,38,38,0.1)'
317
+ : type === 'success'
318
+ ? 'rgba(22,163,74,0.1)'
319
+ : 'var(--bg-secondary)',
320
+ color: type === 'error'
321
+ ? '#dc2626'
322
+ : type === 'success'
323
+ ? '#16a34a'
324
+ : 'var(--text-secondary)',
325
+ border: `1px solid ${
326
+ type === 'error'
327
+ ? 'rgba(220,38,38,0.3)'
328
+ : type === 'success'
329
+ ? 'rgba(22,163,74,0.3)'
330
+ : 'var(--border-primary)'
331
+ }`,
332
+ });
333
+
334
+ const formatKey = (key) => (key || '')
335
+ .replace(/_/g, ' ')
336
+ .replace(/\b\w/g, (c) => c.toUpperCase());
337
+
338
+ const renderFactRow = (fact, { inferred = false } = {}) => {
339
+ const isBusy = busyId === fact.id;
340
+ const isEditing = editingId === fact.id;
341
+
342
+ return (
343
+ <div key={fact.id} style={factCard}>
344
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
345
+ <div style={{ flex: 1, minWidth: 0 }}>
346
+ <div style={{
347
+ fontSize: 11, fontWeight: 600, textTransform: 'uppercase',
348
+ letterSpacing: '0.04em', color: 'var(--text-secondary)', marginBottom: 4,
349
+ }}>
350
+ {formatKey(fact.key)}
351
+ {fact.category ? (
352
+ <span style={{ fontWeight: 500, opacity: 0.75 }}> · {fact.category}</span>
353
+ ) : null}
354
+ </div>
355
+ {isEditing ? (
356
+ <textarea
357
+ value={editValue}
358
+ onChange={(e) => setEditValue(e.target.value)}
359
+ rows={2}
360
+ style={{ ...input, minHeight: 64, resize: 'vertical' }}
361
+ autoFocus
362
+ />
363
+ ) : (
364
+ <div style={{ fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.45, wordBreak: 'break-word' }}>
365
+ {fact.value}
366
+ </div>
367
+ )}
368
+ {inferred && fact.confidence != null && (
369
+ <div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--text-secondary)' }}>
370
+ Confidence: {Math.round(Number(fact.confidence) * 100)}%
371
+ </div>
372
+ )}
373
+ </div>
374
+ </div>
375
+
376
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
377
+ {isEditing ? (
378
+ <>
379
+ <button
380
+ type="button"
381
+ onClick={() => saveEdit(fact)}
382
+ disabled={isBusy}
383
+ style={primaryBtn}
384
+ >
385
+ {isBusy ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> : <Check size={16} />}
386
+ Save
387
+ </button>
388
+ <button type="button" onClick={cancelEdit} disabled={isBusy} style={ghostBtn}>
389
+ Cancel
390
+ </button>
391
+ </>
392
+ ) : inferred ? (
393
+ <>
394
+ <button
395
+ type="button"
396
+ onClick={() => handleConfirm(fact)}
397
+ disabled={isBusy}
398
+ style={primaryBtn}
399
+ title="Promote to something you told us"
400
+ >
401
+ {isBusy ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> : <Check size={16} />}
402
+ Confirm
403
+ </button>
404
+ <button
405
+ type="button"
406
+ onClick={() => handleDelete(fact)}
407
+ disabled={isBusy}
408
+ style={{ ...ghostBtn, color: '#dc2626', borderColor: 'rgba(220,38,38,0.35)' }}
409
+ >
410
+ <Trash2 size={16} />
411
+ Delete
412
+ </button>
413
+ </>
414
+ ) : (
415
+ <>
416
+ <button type="button" onClick={() => startEdit(fact)} disabled={isBusy} style={ghostBtn}>
417
+ <Pencil size={16} />
418
+ Edit
419
+ </button>
420
+ <button
421
+ type="button"
422
+ onClick={() => handleDelete(fact)}
423
+ disabled={isBusy}
424
+ style={{ ...ghostBtn, color: '#dc2626', borderColor: 'rgba(220,38,38,0.35)' }}
425
+ >
426
+ <Trash2 size={16} />
427
+ Delete
428
+ </button>
429
+ </>
430
+ )}
431
+ </div>
432
+ </div>
433
+ );
434
+ };
435
+
436
+ const aboutContent = loading ? (
437
+ <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-secondary)', fontSize: 14 }}>
438
+ <Loader2 size={22} style={{ animation: 'spin 1s linear infinite', marginBottom: 10 }} />
439
+ <div>Loading what we know about you…</div>
440
+ </div>
441
+ ) : (
442
+ <>
443
+ {/* Summaries */}
444
+ <section style={{ marginBottom: 28 }}>
445
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 6 }}>
446
+ <h4 style={sectionTitle}>Your summary</h4>
447
+ <button
448
+ type="button"
449
+ onClick={handleRegenerate}
450
+ disabled={regenerating}
451
+ style={ghostBtn}
452
+ >
453
+ {regenerating
454
+ ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
455
+ : <RefreshCw size={16} />}
456
+ Regenerate
457
+ </button>
458
+ </div>
459
+ <p style={sectionHint}>
460
+ Short and long previews built from your profile facts. Regenerate after confirming or editing facts.
461
+ </p>
462
+ <div style={{
463
+ border: '1px solid var(--border-primary)', borderRadius: 10,
464
+ padding: 14, marginBottom: 10, background: 'var(--bg-secondary)',
465
+ }}>
466
+ <div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--text-secondary)', marginBottom: 6 }}>
467
+ Short
468
+ </div>
469
+ <div style={{ fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
470
+ {summaries.short || <span style={{ color: 'var(--text-secondary)' }}>No short summary yet.</span>}
471
+ </div>
472
+ </div>
473
+ <div style={{
474
+ border: '1px solid var(--border-primary)', borderRadius: 10,
475
+ padding: 14, background: 'var(--bg-secondary)',
476
+ }}>
477
+ <div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--text-secondary)', marginBottom: 6 }}>
478
+ Long
479
+ </div>
480
+ <div style={{ fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
481
+ {summaries.long || <span style={{ color: 'var(--text-secondary)' }}>No long summary yet.</span>}
482
+ </div>
483
+ </div>
484
+ </section>
485
+
486
+ {/* Stated */}
487
+ <section style={{ marginBottom: 28 }}>
488
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
489
+ <MessageSquareQuote size={18} style={{ color: 'var(--accent-primary)' }} />
490
+ <h4 style={{ ...sectionTitle, margin: 0 }}>Things you told us</h4>
491
+ </div>
492
+ <p style={sectionHint}>
493
+ Facts you stated explicitly — editable anytime.
494
+ </p>
495
+ {statedFacts.length === 0 ? (
496
+ <p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '0 0 12px' }}>
497
+ Nothing here yet. Add a fact or confirm something we noticed.
498
+ </p>
499
+ ) : (
500
+ statedFacts.map((f) => renderFactRow(f))
501
+ )}
502
+
503
+ {showAdd ? (
504
+ <div style={{ ...factCard, marginTop: 4 }}>
505
+ <div style={{ marginBottom: 12 }}>
506
+ <label style={label}>Category</label>
507
+ <select
508
+ value={newFact.category}
509
+ onChange={(e) => setNewFact((p) => ({ ...p, category: e.target.value }))}
510
+ style={input}
511
+ >
512
+ {FACT_CATEGORIES.map((c) => (
513
+ <option key={c.value} value={c.value}>{c.label}</option>
514
+ ))}
515
+ </select>
516
+ </div>
517
+ <div style={{ marginBottom: 12 }}>
518
+ <label style={label}>Key</label>
519
+ <input
520
+ value={newFact.key}
521
+ onChange={(e) => setNewFact((p) => ({ ...p, key: e.target.value }))}
522
+ placeholder="e.g. role, employer, goal"
523
+ style={input}
524
+ />
525
+ </div>
526
+ <div style={{ marginBottom: 12 }}>
527
+ <label style={label}>Value</label>
528
+ <textarea
529
+ value={newFact.value}
530
+ onChange={(e) => setNewFact((p) => ({ ...p, value: e.target.value }))}
531
+ placeholder="What should we remember?"
532
+ rows={2}
533
+ style={{ ...input, minHeight: 64, resize: 'vertical' }}
534
+ />
535
+ </div>
536
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
537
+ <button type="button" onClick={handleAdd} disabled={adding} style={primaryBtn}>
538
+ {adding ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> : <Plus size={16} />}
539
+ Add fact
540
+ </button>
541
+ <button
542
+ type="button"
543
+ onClick={() => { setShowAdd(false); setNewFact({ category: 'person', key: '', value: '' }); }}
544
+ style={ghostBtn}
545
+ >
546
+ Cancel
547
+ </button>
548
+ </div>
549
+ </div>
550
+ ) : (
551
+ <button type="button" onClick={() => setShowAdd(true)} style={{ ...ghostBtn, marginTop: 4 }}>
552
+ <Plus size={16} />
553
+ Add fact
554
+ </button>
555
+ )}
556
+ </section>
557
+
558
+ {/* Inferred */}
559
+ <section>
560
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
561
+ <Eye size={18} style={{ color: 'var(--accent-primary)' }} />
562
+ <h4 style={{ ...sectionTitle, margin: 0 }}>Things we noticed</h4>
563
+ </div>
564
+ <p style={sectionHint}>
565
+ Inferred from conversation. Confirm to keep, or delete if wrong.
566
+ </p>
567
+ {inferredFacts.length === 0 ? (
568
+ <p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
569
+ No inferred facts yet — they appear as you chat.
570
+ </p>
571
+ ) : (
572
+ inferredFacts.map((f) => renderFactRow(f, { inferred: true }))
573
+ )}
574
+ </section>
575
+ </>
576
+ );
577
+
578
+ return ReactDOM.createPortal(
579
+ <div style={overlay} onMouseDown={handleOverlayMouseDown} onMouseUp={handleOverlayMouseUp}>
580
+ <div style={modal} onClick={(e) => e.stopPropagation()}>
581
+ <div style={header}>
582
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
583
+ <Sparkles size={20} style={{ color: 'var(--accent-primary)' }} />
584
+ <h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>About You</h3>
585
+ </div>
586
+ <button
587
+ type="button"
588
+ onClick={onClose}
589
+ style={{
590
+ background: 'none', border: 'none', cursor: 'pointer',
591
+ color: 'var(--text-secondary)', minWidth: 44, minHeight: 44,
592
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
593
+ }}
594
+ >
595
+ <X size={20} />
596
+ </button>
597
+ </div>
598
+
599
+ <div style={tabRow}>
600
+ <button
601
+ type="button"
602
+ style={tabBtn(activeTab === 'profile')}
603
+ onClick={() => { setActiveTab('profile'); setMessage(null); }}
604
+ >
605
+ <UserIcon size={15} /> Profile form
606
+ </button>
607
+ <button
608
+ type="button"
609
+ style={tabBtn(activeTab === 'about')}
610
+ onClick={() => { setActiveTab('about'); setMessage(null); }}
611
+ >
612
+ <Sparkles size={15} /> About You
613
+ </button>
614
+ </div>
615
+
616
+ <div style={body}>
617
+ {message && <div style={messageStyle(message.type)}>{message.text}</div>}
618
+
619
+ {activeTab === 'profile' ? (
620
+ <ProfileWalkthrough
621
+ authToken={authToken}
622
+ existingProfile={existingProfile}
623
+ embedded
624
+ onClose={onClose}
625
+ />
626
+ ) : (
627
+ aboutContent
628
+ )}
629
+ </div>
630
+ </div>
631
+ <style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
632
+ </div>,
633
+ document.body
634
+ );
635
+ };
636
+
637
+ export default AboutYouModal;
frontend/src/components/AdvisorCard.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Pencil } from 'lucide-react';
3
+ import { useAppConfig } from '../contexts/AppConfigContext';
4
+ import { useTheme } from '../contexts/ThemeContext';
5
+ import AvatarPickerModal from './AvatarPickerModal';
6
+
7
+ const AdvisorCard = ({ advisor, advisorId }) => {
8
+ const Icon = advisor.icon;
9
+ const { isDark } = useTheme();
10
+ const { getAdvisorColors } = useAppConfig();
11
+ const colors = getAdvisorColors(advisorId, isDark);
12
+ const [hovered, setHovered] = useState(false);
13
+ const [pickerOpen, setPickerOpen] = useState(false);
14
+
15
+ return (
16
+ <>
17
+ <div className="advisor-card">
18
+ <div
19
+ className="advisor-card-icon"
20
+ style={{ backgroundColor: colors.bgColor, position: 'relative', cursor: 'pointer', overflow: 'hidden' }}
21
+ onMouseEnter={() => setHovered(true)}
22
+ onMouseLeave={() => setHovered(false)}
23
+ onClick={() => setPickerOpen(true)}
24
+ >
25
+ {advisor.avatarUrl ? (
26
+ <img src={advisor.avatarUrl} alt={advisor.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 'inherit' }} />
27
+ ) : (
28
+ <Icon style={{ color: colors.color }} />
29
+ )}
30
+ {hovered && (
31
+ <div style={{ position: 'absolute', inset: 0, borderRadius: 'inherit', background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
32
+ <Pencil size={18} color="#fff" />
33
+ </div>
34
+ )}
35
+ </div>
36
+ <h3 className="advisor-card-title">{advisor.name}</h3>
37
+ <p className="advisor-card-role" style={{ color: colors.color }}>{advisor.role}</p>
38
+ <p className="advisor-card-description">{advisor.description}</p>
39
+ </div>
40
+ {pickerOpen && (
41
+ <AvatarPickerModal advisorId={advisorId} advisorName={advisor.name} onClose={() => setPickerOpen(false)} />
42
+ )}
43
+ </>
44
+ );
45
+ };
46
+
47
+ export default AdvisorCard;
frontend/src/components/AdvisorCarousel.js ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import MessageBubble from './MessageBubble';
4
+
5
+ const CONTROLS_W = 62;
6
+ const GAP = 16;
7
+ const PREFERRED_SLIDE = 400;
8
+ const MIN_SLIDE = 280;
9
+
10
+ const findScrollParent = (node) => {
11
+ let el = node?.parentElement;
12
+ while (el) {
13
+ const { overflowY } = window.getComputedStyle(el);
14
+ if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
15
+ return el;
16
+ }
17
+ el = el.parentElement;
18
+ }
19
+ return null;
20
+ };
21
+
22
+ function computeLayout(paneWidth, messageCount) {
23
+ const n = Math.max(1, messageCount);
24
+ const usable = Math.max(MIN_SLIDE, paneWidth - CONTROLS_W);
25
+ let visible = Math.floor((usable + GAP) / (PREFERRED_SLIDE + GAP));
26
+ visible = Math.max(1, Math.min(n, visible));
27
+ if (visible === 1 && n > 1 && 2 * MIN_SLIDE + GAP <= usable) {
28
+ visible = Math.min(n, Math.floor((usable + GAP) / (MIN_SLIDE + GAP)));
29
+ }
30
+ const slideW = Math.max(
31
+ MIN_SLIDE,
32
+ Math.min(PREFERRED_SLIDE, Math.floor((usable - GAP * (visible - 1)) / visible))
33
+ );
34
+ return { visible, slideW };
35
+ }
36
+
37
+ /**
38
+ * Show as many advisor answers as fit side-by-side; carousel when they don't.
39
+ * Messages should already be ordered most-relevant-first (orchestrator rank).
40
+ *
41
+ * Controls sit just to the right of the visible answer(s). They stay vertically
42
+ * centered in the answer stack when it is shorter than the chat pane, and
43
+ * centered in the visible pane when the stack fills (or exceeds) the viewport.
44
+ */
45
+ const AdvisorCarousel = ({ messages = [], onReply, onExpand, onClick, onSearchReferences, userAvatarId, userAvatarOptions }) => {
46
+ const [activeIndex, setActiveIndex] = useState(0);
47
+ const [layout, setLayout] = useState({ visible: 1, slideW: PREFERRED_SLIDE });
48
+ const shellRef = useRef(null);
49
+ const stageRef = useRef(null);
50
+ const controlsColRef = useRef(null);
51
+ const controlsInnerRef = useRef(null);
52
+
53
+ const messageKey = messages.map((m) => m.id).join('|');
54
+ const visibleCount = Math.min(layout.visible, messages.length || 1);
55
+ const maxIndex = Math.max(0, messages.length - visibleCount);
56
+ const showAll = visibleCount >= messages.length && messages.length > 1;
57
+ const showControls = messages.length > visibleCount;
58
+
59
+ useEffect(() => {
60
+ setActiveIndex(0);
61
+ }, [messageKey]);
62
+
63
+ useEffect(() => {
64
+ setActiveIndex((i) => Math.min(i, maxIndex));
65
+ }, [maxIndex]);
66
+
67
+ const goPrev = useCallback(() => {
68
+ setActiveIndex((i) => Math.max(0, i - 1));
69
+ }, []);
70
+
71
+ const goNext = useCallback(() => {
72
+ setActiveIndex((i) => Math.min(maxIndex, i + 1));
73
+ }, [maxIndex]);
74
+
75
+ const measurePane = useCallback(() => {
76
+ const shell = shellRef.current;
77
+ if (!shell) return;
78
+ const pane = findScrollParent(shell) || shell;
79
+ const paneWidth = Math.round(pane.getBoundingClientRect().width);
80
+ const next = computeLayout(paneWidth, messages.length);
81
+ setLayout((prev) => (
82
+ prev.visible === next.visible && prev.slideW === next.slideW ? prev : next
83
+ ));
84
+ }, [messages.length]);
85
+
86
+ const updateControlPosition = useCallback(() => {
87
+ const col = controlsColRef.current;
88
+ const inner = controlsInnerRef.current;
89
+ const stage = stageRef.current;
90
+ if (!col || !inner || !stage) return;
91
+
92
+ const scrollParent = findScrollParent(stage);
93
+ const colRect = col.getBoundingClientRect();
94
+ const viewRect = scrollParent
95
+ ? scrollParent.getBoundingClientRect()
96
+ : { top: 0, bottom: window.innerHeight };
97
+
98
+ const inputEl = document.querySelector('.floating-input-area');
99
+ const inputTop = inputEl ? inputEl.getBoundingClientRect().top : viewRect.bottom;
100
+ const viewTop = viewRect.top;
101
+ const viewBottom = Math.min(viewRect.bottom, inputTop);
102
+
103
+ const overlapTop = Math.max(colRect.top, viewTop);
104
+ const overlapBottom = Math.min(colRect.bottom, viewBottom);
105
+ const innerH = inner.offsetHeight || 0;
106
+ const colH = col.offsetHeight || 0;
107
+
108
+ if (overlapBottom > overlapTop && innerH > 0 && colH > 0) {
109
+ const mid = (overlapTop + overlapBottom) / 2;
110
+ const maxTop = Math.max(0, colH - innerH);
111
+ const top = Math.max(0, Math.min(maxTop, mid - colRect.top - innerH / 2));
112
+ inner.style.top = `${Math.round(top)}px`;
113
+ }
114
+ }, []);
115
+
116
+ useLayoutEffect(() => {
117
+ measurePane();
118
+ const shell = shellRef.current;
119
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measurePane) : null;
120
+ if (shell) ro?.observe(shell);
121
+ const scrollParent = shell ? findScrollParent(shell) : null;
122
+ if (scrollParent) ro?.observe(scrollParent);
123
+ window.addEventListener('resize', measurePane);
124
+ return () => {
125
+ window.removeEventListener('resize', measurePane);
126
+ ro?.disconnect();
127
+ };
128
+ }, [measurePane]);
129
+
130
+ useLayoutEffect(() => {
131
+ if (messages.length <= 1) return undefined;
132
+
133
+ updateControlPosition();
134
+ const raf = window.requestAnimationFrame(() => updateControlPosition());
135
+
136
+ const stage = stageRef.current;
137
+ const scrollParent = stage ? findScrollParent(stage) : null;
138
+ const onScrollOrResize = () => updateControlPosition();
139
+
140
+ scrollParent?.addEventListener('scroll', onScrollOrResize, { passive: true });
141
+ window.addEventListener('resize', onScrollOrResize);
142
+
143
+ const ro = typeof ResizeObserver !== 'undefined'
144
+ ? new ResizeObserver(onScrollOrResize)
145
+ : null;
146
+ if (stage) ro?.observe(stage);
147
+ if (scrollParent) ro?.observe(scrollParent);
148
+
149
+ return () => {
150
+ window.cancelAnimationFrame(raf);
151
+ scrollParent?.removeEventListener('scroll', onScrollOrResize);
152
+ window.removeEventListener('resize', onScrollOrResize);
153
+ ro?.disconnect();
154
+ };
155
+ }, [messages.length, activeIndex, messageKey, visibleCount, updateControlPosition]);
156
+
157
+ if (messages.length === 1) {
158
+ return (
159
+ <div className="single-response-wide">
160
+ <MessageBubble
161
+ message={messages[0]}
162
+ onReply={onReply}
163
+ onExpand={onExpand}
164
+ onClick={onClick}
165
+ onSearchReferences={onSearchReferences}
166
+ showReplyButton={true}
167
+ userAvatarId={userAvatarId}
168
+ userAvatarOptions={userAvatarOptions}
169
+ />
170
+ </div>
171
+ );
172
+ }
173
+
174
+ const viewportWidth = visibleCount * layout.slideW + GAP * (visibleCount - 1);
175
+ const offset = showAll ? 0 : activeIndex * (layout.slideW + GAP);
176
+
177
+ return (
178
+ <div
179
+ className={`advisor-carousel carousel-mode${showAll ? ' is-showing-all' : ''}`}
180
+ ref={shellRef}
181
+ >
182
+ <div className="carousel-stage" ref={stageRef}>
183
+ <div
184
+ className="carousel-viewport"
185
+ style={{ width: `${viewportWidth}px` }}
186
+ >
187
+ <div
188
+ className="carousel-track"
189
+ style={{
190
+ gap: `${GAP}px`,
191
+ transform: `translateX(-${offset}px)`,
192
+ }}
193
+ >
194
+ {messages.map((message) => (
195
+ <div
196
+ key={message.id}
197
+ className="carousel-slide"
198
+ style={{ width: `${layout.slideW}px`, flex: `0 0 ${layout.slideW}px` }}
199
+ >
200
+ <MessageBubble
201
+ message={message}
202
+ onReply={onReply}
203
+ onExpand={onExpand}
204
+ onClick={onClick}
205
+ onSearchReferences={onSearchReferences}
206
+ showReplyButton={true}
207
+ inlineAvatar={true}
208
+ userAvatarId={userAvatarId}
209
+ userAvatarOptions={userAvatarOptions}
210
+ />
211
+ </div>
212
+ ))}
213
+ </div>
214
+ </div>
215
+
216
+ {showControls && (
217
+ <div className="carousel-controls" ref={controlsColRef}>
218
+ <div className="carousel-controls-inner" ref={controlsInnerRef}>
219
+ <button
220
+ type="button"
221
+ className="carousel-arrow carousel-prev"
222
+ onClick={goPrev}
223
+ disabled={activeIndex === 0}
224
+ aria-label="Previous advisor"
225
+ >
226
+ <ChevronLeft size={28} strokeWidth={2.4} />
227
+ </button>
228
+ <button
229
+ type="button"
230
+ className="carousel-arrow carousel-next"
231
+ onClick={goNext}
232
+ disabled={activeIndex >= maxIndex}
233
+ aria-label="Next advisor"
234
+ >
235
+ <ChevronRight size={28} strokeWidth={2.4} />
236
+ </button>
237
+ </div>
238
+ </div>
239
+ )}
240
+ </div>
241
+
242
+ {showControls && (
243
+ <div className="carousel-dots" role="tablist" aria-label="Advisor answers">
244
+ {Array.from({ length: maxIndex + 1 }, (_, i) => (
245
+ <button
246
+ key={messages[i].id}
247
+ type="button"
248
+ className={`carousel-dot ${i === activeIndex ? 'active' : ''}`}
249
+ onClick={() => setActiveIndex(i)}
250
+ aria-label={`Show answers starting at ${i + 1}`}
251
+ aria-selected={i === activeIndex}
252
+ role="tab"
253
+ />
254
+ ))}
255
+ </div>
256
+ )}
257
+ </div>
258
+ );
259
+ };
260
+
261
+ export default AdvisorCarousel;
frontend/src/components/AdvisorStatusDropdown.js ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Users, ChevronDown, Pencil } from 'lucide-react';
3
+ import AvatarPickerModal from './AvatarPickerModal';
4
+
5
+ const AdvisorStatusDropdown = ({
6
+ advisors,
7
+ activeAdvisorIds = [],
8
+ onToggleAdvisor,
9
+ onSetActiveAdvisors,
10
+ thinkingAdvisors,
11
+ getAdvisorColors,
12
+ isDark,
13
+ }) => {
14
+ const [isOpen, setIsOpen] = useState(false);
15
+ const [hoveredId, setHoveredId] = useState(null);
16
+ const [pickerAdvisor, setPickerAdvisor] = useState(null);
17
+
18
+ useEffect(() => {
19
+ const handleClickOutside = (event) => {
20
+ if (isOpen && !event.target.closest('.advisor-status-dropdown')) {
21
+ setIsOpen(false);
22
+ }
23
+ };
24
+
25
+ document.addEventListener('mousedown', handleClickOutside);
26
+ return () => document.removeEventListener('mousedown', handleClickOutside);
27
+ }, [isOpen]);
28
+
29
+ if (!advisors || typeof advisors !== 'object') {
30
+ return null;
31
+ }
32
+
33
+ const advisorEntries = Object.entries(advisors);
34
+ const allIds = advisorEntries.map(([id]) => id);
35
+ const activeSet = new Set(activeAdvisorIds);
36
+ const activeCount = allIds.filter((id) => activeSet.has(id)).length;
37
+ const thinkingCount = Array.isArray(thinkingAdvisors)
38
+ ? thinkingAdvisors.filter((id) => id !== 'system' && activeSet.has(id)).length
39
+ : 0;
40
+ const totalAdvisors = advisorEntries.length;
41
+
42
+ const handleToggle = () => {
43
+ setIsOpen(!isOpen);
44
+ };
45
+
46
+ const handleCheckboxChange = (id, event) => {
47
+ event.stopPropagation();
48
+ if (onToggleAdvisor) {
49
+ onToggleAdvisor(id);
50
+ }
51
+ };
52
+
53
+ const selectAll = (event) => {
54
+ event.stopPropagation();
55
+ if (onSetActiveAdvisors) {
56
+ onSetActiveAdvisors([...allIds]);
57
+ }
58
+ };
59
+
60
+ const selectNone = (event) => {
61
+ event.stopPropagation();
62
+ if (onSetActiveAdvisors && allIds.length > 0) {
63
+ onSetActiveAdvisors([allIds[0]]);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <div className="advisor-status-dropdown">
69
+ <button
70
+ type="button"
71
+ className={`advisor-status-button ${isOpen ? 'open' : ''}`}
72
+ onClick={handleToggle}
73
+ title="Choose which advisors are active"
74
+ aria-expanded={isOpen}
75
+ aria-haspopup="listbox"
76
+ >
77
+ <div className="advisor-status-info">
78
+ <Users size={16} />
79
+ <span className="advisor-count">
80
+ {activeCount}/{totalAdvisors} Active
81
+ </span>
82
+ {thinkingCount > 0 && (
83
+ <div className="thinking-badge">
84
+ {thinkingCount} thinking
85
+ </div>
86
+ )}
87
+ </div>
88
+ <ChevronDown size={14} className={`dropdown-arrow ${isOpen ? 'rotated' : ''}`} />
89
+ </button>
90
+
91
+ {pickerAdvisor && (
92
+ <AvatarPickerModal
93
+ advisorId={pickerAdvisor.id}
94
+ advisorName={pickerAdvisor.name}
95
+ onClose={() => setPickerAdvisor(null)}
96
+ />
97
+ )}
98
+ {isOpen && (
99
+ <div className="advisor-dropdown-panel" role="listbox" aria-label="Active advisors">
100
+ <div className="advisor-panel-header">
101
+ <div className="advisor-panel-title">Active advisors</div>
102
+ <div className="advisor-panel-subtitle">
103
+ Only checked advisors respond to your messages.
104
+ </div>
105
+ <div className="advisor-panel-actions">
106
+ <button type="button" className="advisor-panel-link" onClick={selectAll}>
107
+ Select all
108
+ </button>
109
+ <span className="advisor-panel-sep">·</span>
110
+ <button type="button" className="advisor-panel-link" onClick={selectNone}>
111
+ Minimize
112
+ </button>
113
+ </div>
114
+ </div>
115
+ <div className="advisor-list">
116
+ {advisorEntries.map(([id, advisor]) => {
117
+ const IconComponent = advisor.icon;
118
+ const colors = getAdvisorColors(id, isDark);
119
+ const isThinking = Array.isArray(thinkingAdvisors) && thinkingAdvisors.includes(id);
120
+ const isActive = activeSet.has(id);
121
+
122
+ return (
123
+ <div
124
+ key={id}
125
+ className={`advisor-item ${isThinking ? 'thinking' : ''} ${isActive ? '' : 'inactive'}`}
126
+ style={{ '--advisor-color': colors.color, '--advisor-bg': colors.bgColor }}
127
+ >
128
+ <label className="advisor-active-toggle" onClick={(e) => e.stopPropagation()}>
129
+ <input
130
+ type="checkbox"
131
+ checked={isActive}
132
+ onChange={(e) => handleCheckboxChange(id, e)}
133
+ aria-label={`${isActive ? 'Deactivate' : 'Activate'} ${advisor.name}`}
134
+ />
135
+ </label>
136
+ <div
137
+ className="advisor-icon"
138
+ role="button"
139
+ tabIndex={0}
140
+ onKeyDown={(e) => {
141
+ if (e.key === 'Enter' || e.key === ' ') {
142
+ e.preventDefault();
143
+ setPickerAdvisor({ id, name: advisor.name });
144
+ }
145
+ }}
146
+ onClick={() => setPickerAdvisor({ id, name: advisor.name })}
147
+ onMouseEnter={() => setHoveredId(id)}
148
+ onMouseLeave={() => setHoveredId(null)}
149
+ >
150
+ {advisor.avatarUrl
151
+ ? <img src={advisor.avatarUrl} alt={advisor.name} />
152
+ : <IconComponent size={16} />}
153
+ {hoveredId === id && (
154
+ <div className="advisor-icon-edit">
155
+ <Pencil size={10} color="#fff" />
156
+ </div>
157
+ )}
158
+ </div>
159
+ <div className="advisor-details">
160
+ <div className="advisor-name">{advisor.name}</div>
161
+ <div className="advisor-description">{advisor.description}</div>
162
+ </div>
163
+ <div className="advisor-status">
164
+ {!isActive ? (
165
+ <div className="status-inactive">Off</div>
166
+ ) : isThinking ? (
167
+ <div className="status-thinking">
168
+ <div className="thinking-dots">
169
+ <div className="dot" />
170
+ <div className="dot" />
171
+ <div className="dot" />
172
+ </div>
173
+ </div>
174
+ ) : (
175
+ <div className="status-ready">Ready</div>
176
+ )}
177
+ </div>
178
+ </div>
179
+ );
180
+ })}
181
+ </div>
182
+ </div>
183
+ )}
184
+
185
+ <style>{`
186
+ .advisor-status-dropdown {
187
+ position: relative;
188
+ display: inline-block;
189
+ }
190
+
191
+ .advisor-status-button {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 8px;
195
+ padding: 8px 12px;
196
+ background: var(--bg-primary);
197
+ border: 1px solid var(--border-primary);
198
+ border-radius: 12px;
199
+ cursor: pointer;
200
+ transition: all 0.2s ease;
201
+ font-size: 13px;
202
+ min-width: 140px;
203
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
204
+ color: var(--text-primary);
205
+ }
206
+
207
+ .advisor-status-button:hover {
208
+ background: var(--bg-secondary);
209
+ border-color: var(--accent-primary);
210
+ transform: translateY(-1px);
211
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
212
+ }
213
+
214
+ .advisor-status-button.open {
215
+ background: var(--bg-secondary);
216
+ border-color: var(--accent-primary);
217
+ }
218
+
219
+ .advisor-status-info {
220
+ display: flex;
221
+ align-items: center;
222
+ gap: 6px;
223
+ flex: 1;
224
+ }
225
+
226
+ .advisor-count {
227
+ font-weight: 600;
228
+ color: var(--text-primary);
229
+ white-space: nowrap;
230
+ }
231
+
232
+ .thinking-badge {
233
+ background: var(--accent-primary);
234
+ color: white;
235
+ padding: 2px 6px;
236
+ border-radius: 8px;
237
+ font-size: 10px;
238
+ font-weight: 600;
239
+ animation: pulse 2s ease-in-out infinite;
240
+ }
241
+
242
+ .dropdown-arrow {
243
+ color: var(--text-secondary);
244
+ transition: transform 0.2s ease;
245
+ flex-shrink: 0;
246
+ }
247
+
248
+ .dropdown-arrow.rotated {
249
+ transform: rotate(180deg);
250
+ }
251
+
252
+ .advisor-dropdown-panel {
253
+ position: absolute;
254
+ top: calc(100% + 8px);
255
+ right: 0;
256
+ min-width: 300px;
257
+ max-width: 340px;
258
+ background: var(--bg-primary);
259
+ border: 1px solid var(--border-primary);
260
+ border-radius: 12px;
261
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
262
+ z-index: 1000;
263
+ overflow: hidden;
264
+ backdrop-filter: blur(20px);
265
+ -webkit-backdrop-filter: blur(20px);
266
+ }
267
+
268
+ [data-theme="dark"] .advisor-dropdown-panel {
269
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
270
+ }
271
+
272
+ .advisor-panel-header {
273
+ padding: 12px 16px 8px;
274
+ border-bottom: 1px solid var(--border-primary);
275
+ }
276
+
277
+ .advisor-panel-title {
278
+ font-size: 13px;
279
+ font-weight: 600;
280
+ color: var(--text-primary);
281
+ }
282
+
283
+ .advisor-panel-subtitle {
284
+ font-size: 11px;
285
+ color: var(--text-secondary);
286
+ margin-top: 2px;
287
+ line-height: 1.35;
288
+ }
289
+
290
+ .advisor-panel-actions {
291
+ margin-top: 8px;
292
+ display: flex;
293
+ align-items: center;
294
+ gap: 6px;
295
+ }
296
+
297
+ .advisor-panel-link {
298
+ background: none;
299
+ border: none;
300
+ padding: 0;
301
+ font-size: 11px;
302
+ font-weight: 600;
303
+ color: var(--accent-primary);
304
+ cursor: pointer;
305
+ }
306
+
307
+ .advisor-panel-link:hover {
308
+ text-decoration: underline;
309
+ }
310
+
311
+ .advisor-panel-sep {
312
+ color: var(--text-tertiary);
313
+ font-size: 11px;
314
+ }
315
+
316
+ .advisor-list {
317
+ max-height: 320px;
318
+ overflow-y: auto;
319
+ scrollbar-width: thin;
320
+ scrollbar-color: var(--border-primary) transparent;
321
+ }
322
+
323
+ .advisor-item {
324
+ display: flex;
325
+ align-items: center;
326
+ gap: 10px;
327
+ padding: 10px 14px;
328
+ border-bottom: 1px solid var(--border-primary);
329
+ transition: background-color 0.2s ease, opacity 0.2s ease;
330
+ }
331
+
332
+ .advisor-item.inactive {
333
+ opacity: 0.55;
334
+ }
335
+
336
+ .advisor-item:last-child {
337
+ border-bottom: none;
338
+ }
339
+
340
+ .advisor-item:hover {
341
+ background: var(--bg-secondary);
342
+ }
343
+
344
+ .advisor-item.thinking {
345
+ background: var(--advisor-bg);
346
+ opacity: 1;
347
+ }
348
+
349
+ .advisor-active-toggle {
350
+ display: flex;
351
+ align-items: center;
352
+ flex-shrink: 0;
353
+ cursor: pointer;
354
+ }
355
+
356
+ .advisor-active-toggle input {
357
+ width: 16px;
358
+ height: 16px;
359
+ accent-color: var(--accent-primary);
360
+ cursor: pointer;
361
+ }
362
+
363
+ .advisor-icon {
364
+ position: relative;
365
+ cursor: pointer;
366
+ overflow: hidden;
367
+ width: 32px;
368
+ height: 32px;
369
+ border-radius: 8px;
370
+ flex-shrink: 0;
371
+ background: var(--advisor-bg);
372
+ color: var(--advisor-color);
373
+ display: flex;
374
+ align-items: center;
375
+ justify-content: center;
376
+ border: 1px solid var(--advisor-color);
377
+ }
378
+
379
+ .advisor-icon img {
380
+ width: 32px;
381
+ height: 32px;
382
+ object-fit: cover;
383
+ display: block;
384
+ }
385
+
386
+ .advisor-icon-edit {
387
+ position: absolute;
388
+ inset: 0;
389
+ border-radius: 8px;
390
+ background: rgba(0, 0, 0, 0.45);
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: center;
394
+ }
395
+
396
+ .advisor-details {
397
+ flex: 1;
398
+ min-width: 0;
399
+ }
400
+
401
+ .advisor-name {
402
+ font-weight: 600;
403
+ color: var(--text-primary);
404
+ font-size: 13px;
405
+ margin-bottom: 2px;
406
+ }
407
+
408
+ .advisor-description {
409
+ font-size: 11px;
410
+ color: var(--text-secondary);
411
+ line-height: 1.3;
412
+ overflow: hidden;
413
+ text-overflow: ellipsis;
414
+ white-space: nowrap;
415
+ }
416
+
417
+ .advisor-status {
418
+ flex-shrink: 0;
419
+ }
420
+
421
+ .status-inactive {
422
+ font-size: 11px;
423
+ color: var(--text-tertiary);
424
+ font-weight: 500;
425
+ }
426
+
427
+ .status-thinking {
428
+ display: flex;
429
+ align-items: center;
430
+ }
431
+
432
+ .thinking-dots {
433
+ display: flex;
434
+ gap: 2px;
435
+ }
436
+
437
+ .thinking-dots .dot {
438
+ width: 4px;
439
+ height: 4px;
440
+ background: var(--advisor-color);
441
+ border-radius: 50%;
442
+ animation: thinking-bounce 1.4s infinite ease-in-out both;
443
+ }
444
+
445
+ .thinking-dots .dot:nth-child(1) { animation-delay: -0.32s; }
446
+ .thinking-dots .dot:nth-child(2) { animation-delay: -0.16s; }
447
+
448
+ .status-ready {
449
+ font-size: 11px;
450
+ color: var(--text-tertiary);
451
+ font-weight: 500;
452
+ }
453
+
454
+ @keyframes thinking-bounce {
455
+ 0%, 80%, 100% { transform: scale(0); }
456
+ 40% { transform: scale(1); }
457
+ }
458
+
459
+ @keyframes pulse {
460
+ 0%, 100% { opacity: 1; }
461
+ 50% { opacity: 0.7; }
462
+ }
463
+
464
+ @media (max-width: 768px) {
465
+ .advisor-status-dropdown {
466
+ display: none;
467
+ }
468
+ }
469
+ `}</style>
470
+ </div>
471
+ );
472
+ };
473
+
474
+ export default AdvisorStatusDropdown;
frontend/src/components/AppHeader.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Home, Menu } from 'lucide-react';
3
+ import { useAppConfig } from '../contexts/AppConfigContext';
4
+
5
+ /**
6
+ * Shared floating header used on every page so the app feels like one surface.
7
+ *
8
+ * Props:
9
+ * currentPage: 'home' | 'chat' | 'canvas' | 'journey' | 'canvas-<subview>'
10
+ * onNavigateToHome, onNavigateToChat, onNavigateToCanvas, onNavigateToJourney
11
+ * (onNavigateToCanvas may receive 'workspace' | 'deliverables' to deep-link a view;
12
+ * 'journey' is handled via onNavigateToJourney when provided)
13
+ * onMobileMenu?: () => void — when present, shows the mobile menu button
14
+ * children?: ReactNode — extra controls slotted in header-right
15
+ */
16
+ const AppHeader = ({
17
+ currentPage = 'home',
18
+ onNavigateToHome,
19
+ onNavigateToChat,
20
+ onNavigateToCanvas,
21
+ onNavigateToJourney,
22
+ onMobileMenu,
23
+ children,
24
+ }) => {
25
+ const { config } = useAppConfig();
26
+
27
+ const goToCanvas = (view) => {
28
+ if (view === 'journey') {
29
+ if (onNavigateToJourney) onNavigateToJourney();
30
+ else if (onNavigateToCanvas) onNavigateToCanvas('journey');
31
+ return;
32
+ }
33
+ if (onNavigateToCanvas) onNavigateToCanvas(view);
34
+ };
35
+
36
+ // Accept either 'canvas' (all canvas tabs highlight equally) or a more specific
37
+ // 'canvas-<subview>' from CanvasPage so only the active one highlights.
38
+ const isOnHome = currentPage === 'home';
39
+ const isOnChat = currentPage === 'chat';
40
+ const isOnJourney = currentPage === 'journey';
41
+ const isOnCanvas = currentPage === 'canvas' || currentPage.startsWith('canvas-');
42
+ const canvasSub = currentPage.startsWith('canvas-') ? currentPage.slice(7) : null;
43
+ const tabActive = (sub) => isOnCanvas && (canvasSub === null ? false : canvasSub === sub);
44
+
45
+ return (
46
+ <header className="floating-header app-header">
47
+ <div className="header-left">
48
+ {onMobileMenu && (
49
+ <button className="mobile-menu-button" onClick={onMobileMenu}>
50
+ <Menu size={20} />
51
+ </button>
52
+ )}
53
+ <button
54
+ className="modern-home-btn"
55
+ onClick={onNavigateToHome}
56
+ title="Home"
57
+ disabled={isOnHome}
58
+ aria-disabled={isOnHome}
59
+ >
60
+ <Home size={20} />
61
+ </button>
62
+ <div className="header-brand">
63
+ <div className="brand-text">
64
+ <h1>{config?.app?.title || 'Advisory'}</h1>
65
+ <p>{config?.app?.subtitle || 'AI-Powered Guidance'}</p>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ {/* Hide the view pill bar on the home page — home is a landing page,
71
+ not part of the chat ↔ canvas surface. */}
72
+ {!isOnHome && (
73
+ <div className="canvas-tabs chat-view-tabs">
74
+ <button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
75
+ <button className={`tab ${isOnJourney ? 'active' : ''}`} onClick={() => goToCanvas('journey')}>Journey</button>
76
+ <button className={`tab ${tabActive('workspace') ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
77
+ <button className={`tab ${tabActive('deliverables') ? 'active' : ''}`} onClick={() => goToCanvas('deliverables')}>Documents</button>
78
+ </div>
79
+ )}
80
+
81
+ {/* Compact mobile dropdown — appears in place of the pill bar at narrow widths */}
82
+ {!isOnHome && (
83
+ <select
84
+ className="canvas-tabs-mobile"
85
+ value={isOnChat ? 'chat' : (isOnJourney ? 'journey' : (canvasSub || 'workspace'))}
86
+ onChange={(e) => {
87
+ const v = e.target.value;
88
+ if (v === 'chat') onNavigateToChat();
89
+ else goToCanvas(v);
90
+ }}
91
+ >
92
+ <option value="chat">Chat</option>
93
+ <option value="journey">Journey</option>
94
+ <option value="workspace">Workspace</option>
95
+ <option value="deliverables">Documents</option>
96
+ </select>
97
+ )}
98
+
99
+ <div className="header-right">
100
+ {children}
101
+ </div>
102
+ </header>
103
+ );
104
+ };
105
+
106
+ export default AppHeader;
frontend/src/components/AvatarPickerModal.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import { X } from 'lucide-react';
4
+ import { useAppConfig } from '../contexts/AppConfigContext';
5
+
6
+ const API = process.env.REACT_APP_API_URL || '';
7
+
8
+ const BUNDLED = [
9
+ 'advisor1.png','advisor2.png','advisor3.png','advisor4.png',
10
+ 'advisor5.png','advisor6.png','advisor7.png',
11
+ ];
12
+
13
+ const overlay = {
14
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
15
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
16
+ };
17
+
18
+ const modal = {
19
+ background: 'var(--bg-primary)', borderRadius: 16, padding: 24, width: 480,
20
+ maxWidth: '95vw', maxHeight: '85vh', overflowY: 'auto',
21
+ boxShadow: 'var(--shadow-xl)',
22
+ };
23
+
24
+ const AvatarPickerModal = ({ advisorId, advisorName, onClose }) => {
25
+ const { setAdvisorAvatar } = useAppConfig();
26
+
27
+ const select = (url) => {
28
+ setAdvisorAvatar(advisorId, url || '');
29
+ onClose();
30
+ };
31
+
32
+ return ReactDOM.createPortal(
33
+ <div style={overlay} onClick={(e) => e.target === e.currentTarget && onClose()} onMouseDown={(e) => e.stopPropagation()}>
34
+ <div style={modal}>
35
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
36
+ <h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>
37
+ Choose Avatar — {advisorName}
38
+ </h3>
39
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
40
+ <X size={20} />
41
+ </button>
42
+ </div>
43
+
44
+ <p style={{ margin: '0 0 10px', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600 }}>Pre-made Avatars</p>
45
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 8, marginBottom: 20 }}>
46
+ {BUNDLED.map((file) => (
47
+ <img
48
+ key={file}
49
+ src={`${API}/api/avatars/bundled/${file}`}
50
+ alt={file}
51
+ onClick={() => select(`${API}/api/avatars/bundled/${file}`)}
52
+ style={{ width: '100%', aspectRatio: '1', borderRadius: '50%', objectFit: 'cover', cursor: 'pointer', border: '2px solid transparent', transition: 'border-color 0.15s' }}
53
+ onMouseEnter={e => e.target.style.borderColor = 'var(--accent-primary)'}
54
+ onMouseLeave={e => e.target.style.borderColor = 'transparent'}
55
+ />
56
+ ))}
57
+ </div>
58
+
59
+ <button
60
+ onClick={() => select(null)}
61
+ style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'transparent', border: '1px solid var(--border-primary)', borderRadius: 10, color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 13.5 }}
62
+ >
63
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
64
+ <circle cx="12" cy="8" r="4" />
65
+ <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
66
+ </svg>
67
+ Use default icon
68
+ </button>
69
+ </div>
70
+ </div>,
71
+ document.body
72
+ );
73
+ };
74
+
75
+ export default AvatarPickerModal;
frontend/src/components/ChatInput.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Send } from 'lucide-react';
3
+
4
+ const ChatInput = ({ onSendMessage, isLoading, placeholder = "Ask your advisors anything..." }) => {
5
+ const [inputMessage, setInputMessage] = useState('');
6
+
7
+ const handleSend = () => {
8
+ if (!inputMessage.trim() || isLoading) return;
9
+
10
+ onSendMessage(inputMessage);
11
+ setInputMessage('');
12
+ };
13
+
14
+ const handleKeyPress = (e) => {
15
+ if (e.key === 'Enter' && !e.shiftKey) {
16
+ e.preventDefault();
17
+ handleSend();
18
+ }
19
+ };
20
+
21
+ return (
22
+ <div className="input-area">
23
+ <div className="input-container">
24
+ <textarea
25
+ value={inputMessage}
26
+ onChange={(e) => setInputMessage(e.target.value)}
27
+ onKeyPress={handleKeyPress}
28
+ placeholder={placeholder}
29
+ className="message-input"
30
+ rows="2"
31
+ disabled={isLoading}
32
+ />
33
+ <button
34
+ onClick={handleSend}
35
+ disabled={!inputMessage.trim() || isLoading}
36
+ className="send-button"
37
+ >
38
+ <Send className="send-icon" />
39
+ <span className="send-text">Send</span>
40
+ </button>
41
+ </div>
42
+ </div>
43
+ );
44
+ };
45
+
46
+ export default ChatInput;
frontend/src/components/ClearDataModal.js ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Trash2, AlertTriangle, X, Loader2, CheckCircle } from 'lucide-react';
3
+ import { useTheme } from '../contexts/ThemeContext';
4
+
5
+ const ClearDataModal = ({ authToken, onClose, onDataCleared }) => {
6
+ const { isDark } = useTheme();
7
+ const [profile, setProfile] = useState(false);
8
+ const [chats, setChats] = useState(false);
9
+ const [canvas, setCanvas] = useState(false);
10
+ const [journey, setJourney] = useState(false);
11
+ const [clearing, setClearing] = useState(false);
12
+ const [result, setResult] = useState(null);
13
+
14
+ const noneSelected = !profile && !chats && !canvas && !journey;
15
+
16
+ const handleClear = async () => {
17
+ if (noneSelected) return;
18
+ setClearing(true);
19
+ try {
20
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/clear-data`, {
21
+ method: 'POST',
22
+ headers: {
23
+ 'Authorization': `Bearer ${authToken}`,
24
+ 'Content-Type': 'application/json',
25
+ },
26
+ body: JSON.stringify({ profile, chats, canvas, journey }),
27
+ });
28
+ if (resp.ok) {
29
+ const data = await resp.json();
30
+ setResult(data.cleared);
31
+ if (onDataCleared) onDataCleared({ profile, chats, canvas, journey });
32
+ } else {
33
+ setResult(['Error clearing data']);
34
+ }
35
+ } catch {
36
+ setResult(['Network error']);
37
+ } finally {
38
+ setClearing(false);
39
+ }
40
+ };
41
+
42
+ const overlay = {
43
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
44
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
45
+ };
46
+
47
+ const modal = {
48
+ background: isDark ? '#1f2937' : '#fff',
49
+ borderRadius: 16, padding: 28, width: 400, maxWidth: '90vw',
50
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
51
+ color: isDark ? '#f3f4f6' : '#111827',
52
+ };
53
+
54
+ const checkRow = {
55
+ display: 'flex', alignItems: 'center', gap: 12,
56
+ padding: '12px 14px', borderRadius: 10, cursor: 'pointer',
57
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
58
+ marginBottom: 10, transition: 'all 0.15s ease',
59
+ };
60
+
61
+ const checkRowActive = (active) => ({
62
+ ...checkRow,
63
+ background: active
64
+ ? (isDark ? 'rgba(239,68,68,0.12)' : 'rgba(239,68,68,0.06)')
65
+ : 'transparent',
66
+ borderColor: active ? '#ef4444' : (isDark ? '#374151' : '#e5e7eb'),
67
+ });
68
+
69
+ const checkbox = (checked) => ({
70
+ width: 20, height: 20, borderRadius: 4, flexShrink: 0,
71
+ border: `2px solid ${checked ? '#ef4444' : (isDark ? '#6b7280' : '#9ca3af')}`,
72
+ background: checked ? '#ef4444' : 'transparent',
73
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
74
+ transition: 'all 0.15s ease', color: '#fff', fontSize: 13, fontWeight: 700,
75
+ });
76
+
77
+ if (result) {
78
+ return (
79
+ <div style={overlay} onClick={onClose}>
80
+ <div style={modal} onClick={(e) => e.stopPropagation()}>
81
+ <div style={{ textAlign: 'center', padding: '16px 0' }}>
82
+ <CheckCircle size={48} style={{ color: '#22c55e', marginBottom: 16 }} />
83
+ <h3 style={{ margin: '0 0 12px', fontSize: 18 }}>Data Cleared</h3>
84
+ <p style={{ color: isDark ? '#9ca3af' : '#6b7280', fontSize: 14, lineHeight: 1.6 }}>
85
+ {result.join(', ')}
86
+ </p>
87
+ <button
88
+ onClick={onClose}
89
+ style={{
90
+ marginTop: 20, padding: '10px 32px', borderRadius: 10,
91
+ border: 'none', background: '#3b82f6', color: '#fff',
92
+ fontSize: 14, fontWeight: 600, cursor: 'pointer',
93
+ }}
94
+ >
95
+ Done
96
+ </button>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <div style={overlay} onClick={onClose}>
105
+ <div style={modal} onClick={(e) => e.stopPropagation()}>
106
+ {/* Header */}
107
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
108
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
109
+ <AlertTriangle size={22} style={{ color: '#f59e0b' }} />
110
+ <h3 style={{ margin: 0, fontSize: 18 }}>Clear User Data</h3>
111
+ </div>
112
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: isDark ? '#9ca3af' : '#6b7280', padding: 4 }}>
113
+ <X size={20} />
114
+ </button>
115
+ </div>
116
+
117
+ <p style={{ color: isDark ? '#9ca3af' : '#6b7280', fontSize: 13, marginBottom: 18, lineHeight: 1.5 }}>
118
+ Select which data to clear. Profile data removal will reset your onboarding progress.
119
+ </p>
120
+
121
+ {/* Checkboxes */}
122
+ <div onClick={() => setProfile(!profile)} style={checkRowActive(profile)}>
123
+ <div style={checkbox(profile)}>{profile && '✓'}</div>
124
+ <div>
125
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Profile Information</div>
126
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
127
+ Major, GPA, career goals, learning style, etc. Resets "Tell us about yourself."
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <div onClick={() => setChats(!chats)} style={checkRowActive(chats)}>
133
+ <div style={checkbox(chats)}>{chats && '✓'}</div>
134
+ <div>
135
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Chat History</div>
136
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
137
+ All conversation sessions and messages.
138
+ </div>
139
+ </div>
140
+ </div>
141
+
142
+ <div onClick={() => setCanvas(!canvas)} style={checkRowActive(canvas)}>
143
+ <div style={checkbox(canvas)}>{canvas && '✓'}</div>
144
+ <div>
145
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Canvas</div>
146
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
147
+ All collected insights and research notes.
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <div onClick={() => setJourney(!journey)} style={checkRowActive(journey)}>
153
+ <div style={checkbox(journey)}>{journey && '✓'}</div>
154
+ <div>
155
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Security Journey</div>
156
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
157
+ Track progress, checked items, and assessment history.
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ {/* Actions */}
163
+ <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
164
+ <button
165
+ onClick={onClose}
166
+ style={{
167
+ padding: '10px 20px', borderRadius: 10,
168
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
169
+ background: 'transparent', color: isDark ? '#d1d5db' : '#374151',
170
+ fontSize: 14, cursor: 'pointer',
171
+ }}
172
+ >
173
+ Cancel
174
+ </button>
175
+ <button
176
+ onClick={handleClear}
177
+ disabled={noneSelected || clearing}
178
+ style={{
179
+ padding: '10px 20px', borderRadius: 10, border: 'none',
180
+ background: noneSelected ? (isDark ? '#374151' : '#e5e7eb') : '#ef4444',
181
+ color: noneSelected ? (isDark ? '#6b7280' : '#9ca3af') : '#fff',
182
+ fontSize: 14, fontWeight: 600, cursor: noneSelected ? 'default' : 'pointer',
183
+ display: 'flex', alignItems: 'center', gap: 8,
184
+ opacity: clearing ? 0.7 : 1,
185
+ }}
186
+ >
187
+ {clearing ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> : <Trash2 size={16} />}
188
+ {clearing ? 'Clearing...' : 'Clear Selected'}
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ };
195
+
196
+ export default ClearDataModal;
frontend/src/components/ConfirmDialog.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import { AlertTriangle } from 'lucide-react';
4
+ import '../styles/ConfirmDialog.css';
5
+
6
+ const ConfirmDialog = ({
7
+ isOpen,
8
+ title,
9
+ message,
10
+ confirmLabel = 'Confirm',
11
+ cancelLabel = 'Cancel',
12
+ onConfirm,
13
+ onCancel,
14
+ tone = 'default',
15
+ }) => {
16
+ useEffect(() => {
17
+ if (!isOpen) return;
18
+ const onKey = (e) => e.key === 'Escape' && onCancel?.();
19
+ window.addEventListener('keydown', onKey);
20
+ return () => window.removeEventListener('keydown', onKey);
21
+ }, [isOpen, onCancel]);
22
+
23
+ if (!isOpen) return null;
24
+
25
+ const isDanger = tone === 'danger';
26
+
27
+ return ReactDOM.createPortal(
28
+ <div
29
+ className="confirm-overlay"
30
+ onClick={(e) => e.target === e.currentTarget && onCancel?.()}
31
+ >
32
+ <div className="confirm-dialog" role="dialog" aria-label={title}>
33
+ <div className={`confirm-icon ${isDanger ? 'danger' : ''}`}>
34
+ <AlertTriangle size={22} />
35
+ </div>
36
+ <h2 className="confirm-title">{title}</h2>
37
+ {message && <p className="confirm-message">{message}</p>}
38
+ <div className="confirm-actions">
39
+ <button
40
+ type="button"
41
+ className="confirm-btn confirm-btn-cancel"
42
+ onClick={onCancel}
43
+ >
44
+ {cancelLabel}
45
+ </button>
46
+ <button
47
+ type="button"
48
+ className={`confirm-btn ${isDanger ? 'confirm-btn-danger' : 'confirm-btn-primary'}`}
49
+ onClick={onConfirm}
50
+ >
51
+ {confirmLabel}
52
+ </button>
53
+ </div>
54
+ </div>
55
+ </div>,
56
+ document.body
57
+ );
58
+ };
59
+
60
+ export default ConfirmDialog;
frontend/src/components/CopyrightNotice.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const CopyrightNotice = ({ variant = 'footer', className = '' }) => {
4
+ const isSidebar = variant === 'sidebar';
5
+ const textClass = isSidebar ? 'sidebar-copyright-text' : 'footer-text';
6
+ const patentsClass = isSidebar ? 'sidebar-patents-link' : 'footer-patents-link';
7
+ const combinedClass = className ? `${textClass} ${className}` : textClass;
8
+
9
+ return (
10
+ <p className={combinedClass}>
11
+ {'\u00A9 '}
12
+ {isSidebar ? (
13
+ 'Neon.ai'
14
+ ) : (
15
+ <a
16
+ href="https://neon.ai"
17
+ target="_blank"
18
+ rel="noopener noreferrer"
19
+ className="footer-neon-link"
20
+ >
21
+ <img src="/neon-logo.png" alt="" className="footer-neon-logo" />
22
+ Neon.ai
23
+ </a>
24
+ )}
25
+ . All rights reserved.{' '}
26
+ <a
27
+ href="https://www.neon.ai/contact"
28
+ target="_blank"
29
+ rel="noopener noreferrer"
30
+ className={patentsClass}
31
+ >
32
+ Patents and licensing.
33
+ </a>
34
+ </p>
35
+ );
36
+ };
37
+
38
+ export default CopyrightNotice;
frontend/src/components/EnhancedChatInput.js ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { Send, Paperclip, FileText, X, Trash2, Mic, MicOff, MessageCircle, ClipboardList, Loader2, Columns3, FileOutput } from 'lucide-react';
3
+ import FileUpload from './FileUpload';
4
+ import ExportButton from './ExportButton';
5
+
6
+ const EnhancedChatInput = ({
7
+ onSendMessage,
8
+ onFileUploaded,
9
+ uploadedDocuments = [],
10
+ isLoading,
11
+ currentChatSessionId,
12
+ authToken,
13
+ placeholder = "Ask your advisors anything...",
14
+ showProfileButtons = false,
15
+ onOpenOnboarding,
16
+ onOpenProfileForm,
17
+ synthesizedMode = false,
18
+ onToggleSynthesized,
19
+ ensureSessionId,
20
+ showExport = false,
21
+ exportHasMessages = false,
22
+ exportSessionId = null,
23
+ }) => {
24
+ const [inputMessage, setInputMessage] = useState('');
25
+ const [showUpload, setShowUpload] = useState(false);
26
+ const [showDocuments, setShowDocuments] = useState(false);
27
+ const [isUploading, setIsUploading] = useState(false);
28
+ const [isRecording, setIsRecording] = useState(false);
29
+ const [isTranscribing, setIsTranscribing] = useState(false);
30
+ const mediaRecorderRef = useRef(null);
31
+ const audioChunksRef = useRef([]);
32
+ const textareaRef = useRef(null);
33
+ const uploadRef = useRef(null);
34
+ const uploadBtnRef = useRef(null);
35
+
36
+ const sendForTranscription = useCallback(async (blob) => {
37
+ if (!blob || blob.size < 100) {
38
+ console.warn('STT: blob too small, skipping', blob?.size);
39
+ return;
40
+ }
41
+ setIsTranscribing(true);
42
+ try {
43
+ const form = new FormData();
44
+ form.append('audio', blob, 'recording.webm');
45
+ const token = authToken || localStorage.getItem('authToken');
46
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/voice/transcribe`, {
47
+ method: 'POST',
48
+ headers: { 'Authorization': `Bearer ${token}` },
49
+ body: form,
50
+ });
51
+ if (resp.ok) {
52
+ const data = await resp.json();
53
+ const text = data?.text?.trim();
54
+ if (text) {
55
+ setInputMessage(prev => prev ? `${prev} ${text}` : text);
56
+ }
57
+ } else {
58
+ console.error('STT response not ok:', resp.status, await resp.text().catch(() => ''));
59
+ }
60
+ } catch (err) {
61
+ console.error('Transcription failed:', err);
62
+ } finally {
63
+ setIsTranscribing(false);
64
+ }
65
+ }, [authToken]);
66
+
67
+ const toggleRecording = useCallback(async () => {
68
+ if (isRecording) {
69
+ mediaRecorderRef.current?.stop();
70
+ setIsRecording(false);
71
+ return;
72
+ }
73
+ try {
74
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
75
+
76
+ // Pick a supported mimeType
77
+ const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', '']
78
+ .find(mt => mt === '' || MediaRecorder.isTypeSupported(mt));
79
+ const options = mimeType ? { mimeType } : undefined;
80
+ const mediaRecorder = new MediaRecorder(stream, options);
81
+
82
+ audioChunksRef.current = [];
83
+ mediaRecorder.ondataavailable = (e) => {
84
+ if (e.data && e.data.size > 0) audioChunksRef.current.push(e.data);
85
+ };
86
+ mediaRecorder.onstop = () => {
87
+ stream.getTracks().forEach(t => t.stop());
88
+ const blobType = mediaRecorder.mimeType || 'audio/webm';
89
+ const blob = new Blob(audioChunksRef.current, { type: blobType });
90
+ sendForTranscription(blob);
91
+ };
92
+ mediaRecorderRef.current = mediaRecorder;
93
+ // Request data every 500ms so chunks are available when stop() fires
94
+ mediaRecorder.start(500);
95
+ setIsRecording(true);
96
+ } catch (err) {
97
+ console.error('Microphone access error:', err);
98
+ }
99
+ }, [isRecording, sendForTranscription]);
100
+
101
+ const handleSend = () => {
102
+ if (!inputMessage.trim() || isLoading || isUploading) return;
103
+
104
+ onSendMessage(inputMessage);
105
+ setInputMessage('');
106
+ if (textareaRef.current) {
107
+ textareaRef.current.style.height = 'auto';
108
+ }
109
+ };
110
+
111
+ const handleKeyPress = (e) => {
112
+ if (e.key === 'Enter' && !e.shiftKey) {
113
+ e.preventDefault();
114
+ handleSend();
115
+ }
116
+ };
117
+
118
+ const handleFileUploaded = (file, response) => {
119
+ setIsUploading(false);
120
+ setShowUpload(false);
121
+
122
+ if (onFileUploaded) {
123
+ onFileUploaded(file, response);
124
+ }
125
+ };
126
+
127
+ const handleUploadStart = () => {
128
+ setIsUploading(true);
129
+ };
130
+
131
+ const toggleUpload = () => {
132
+ if (!isUploading) {
133
+ setShowUpload(!showUpload);
134
+ setShowDocuments(false); // Close documents panel when opening upload
135
+ }
136
+ };
137
+
138
+ const toggleDocuments = () => {
139
+ setShowDocuments(!showDocuments);
140
+ setShowUpload(false); // Close upload panel when opening documents
141
+ };
142
+
143
+ // Auto-resize textarea
144
+ useEffect(() => {
145
+ if (textareaRef.current) {
146
+ textareaRef.current.style.height = 'auto';
147
+ textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
148
+ }
149
+ }, [inputMessage]);
150
+
151
+ // Close upload panel when clicking outside
152
+ useEffect(() => {
153
+ if (!showUpload) return;
154
+ const handleClickOutside = (e) => {
155
+ if (
156
+ uploadRef.current && !uploadRef.current.contains(e.target) &&
157
+ uploadBtnRef.current && !uploadBtnRef.current.contains(e.target)
158
+ ) {
159
+ setShowUpload(false);
160
+ }
161
+ };
162
+ document.addEventListener('mousedown', handleClickOutside);
163
+ return () => document.removeEventListener('mousedown', handleClickOutside);
164
+ }, [showUpload]);
165
+
166
+ const formatFileSize = (bytes) => {
167
+ if (bytes === 0) return '0 Bytes';
168
+ const k = 1024;
169
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
170
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
171
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
172
+ };
173
+
174
+ const getFileIcon = (type) => {
175
+ if (type.includes('pdf')) return '📄';
176
+ if (type.includes('word') || type.includes('document')) return '📝';
177
+ if (type.includes('text')) return '📃';
178
+ return '📄';
179
+ };
180
+
181
+ const formatUploadTime = (date) => {
182
+ return new Date(date).toLocaleString([], {
183
+ month: 'short',
184
+ day: 'numeric',
185
+ hour: '2-digit',
186
+ minute: '2-digit'
187
+ });
188
+ };
189
+
190
+ const isDisabled = isLoading || isUploading;
191
+ const canSend = inputMessage.trim() && !isDisabled;
192
+
193
+ return (
194
+ <div className="enhanced-chat-input-container">
195
+ {/* File Upload Area */}
196
+ {showUpload && (
197
+ <div className="floating-upload-section" ref={uploadRef}>
198
+ <FileUpload
199
+ onFileUploaded={handleFileUploaded}
200
+ isUploading={isUploading}
201
+ currentChatSessionId={currentChatSessionId}
202
+ authToken={authToken}
203
+ onUploadStart={handleUploadStart}
204
+ ensureSessionId={ensureSessionId}
205
+ />
206
+ </div>
207
+ )}
208
+
209
+ {/* Documents Viewer Panel */}
210
+ {showDocuments && (
211
+ <div className="floating-documents-section">
212
+ <div className="documents-header">
213
+ <div className="documents-title">
214
+ <FileText size={16} />
215
+ <span>Uploaded Documents ({uploadedDocuments.length})</span>
216
+ </div>
217
+ <button
218
+ onClick={() => setShowDocuments(false)}
219
+ className="close-documents-btn"
220
+ >
221
+ <X size={16} />
222
+ </button>
223
+ </div>
224
+
225
+ <div className="documents-list">
226
+ {uploadedDocuments.length === 0 ? (
227
+ <div className="no-documents">
228
+ <FileText size={24} />
229
+ <p>No documents uploaded yet</p>
230
+ <span>Upload documents to reference them in your conversations</span>
231
+ </div>
232
+ ) : (
233
+ uploadedDocuments.map((doc) => (
234
+ <div key={doc.id} className="document-item">
235
+ <div className="document-icon">
236
+ {getFileIcon(doc.type)}
237
+ </div>
238
+ <div className="document-info">
239
+ <div className="document-name">{doc.name}</div>
240
+ <div className="document-details">
241
+ {formatFileSize(doc.size)} • {formatUploadTime(doc.uploadTime)}
242
+ </div>
243
+ </div>
244
+ <div className="document-actions">
245
+ <button
246
+ className="document-action-btn"
247
+ title="Remove document"
248
+ onClick={() => {
249
+ // TODO: Implement remove functionality
250
+ console.log('Remove document:', doc.id);
251
+ }}
252
+ >
253
+ <Trash2 size={14} />
254
+ </button>
255
+ </div>
256
+ </div>
257
+ ))
258
+ )}
259
+ </div>
260
+ </div>
261
+ )}
262
+
263
+ {/* Main Input Box */}
264
+ <div className="floating-input-box">
265
+ {/* Text Input Row */}
266
+ <div className="text-input-row">
267
+ <textarea
268
+ ref={textareaRef}
269
+ value={inputMessage}
270
+ onChange={(e) => setInputMessage(e.target.value)}
271
+ onKeyPress={handleKeyPress}
272
+ placeholder={placeholder}
273
+ className="main-textarea"
274
+ disabled={isDisabled}
275
+ rows={1}
276
+ />
277
+ </div>
278
+
279
+ {/* Controls Row */}
280
+ <div className="controls-row">
281
+ {/* Left - File Controls */}
282
+ <div className="file-controls">
283
+ <button
284
+ ref={uploadBtnRef}
285
+ onClick={toggleUpload}
286
+ className={`add-docs-btn ${showUpload ? 'active' : ''}`}
287
+ disabled={isUploading}
288
+ type="button"
289
+ >
290
+ <Paperclip size={16} />
291
+ <span>Add documents</span>
292
+ </button>
293
+
294
+ {uploadedDocuments.length > 0 && (
295
+ <button
296
+ onClick={toggleDocuments}
297
+ className={`view-docs-btn ${showDocuments ? 'active' : ''}`}
298
+ type="button"
299
+ title={`View ${uploadedDocuments.length} uploaded document${uploadedDocuments.length !== 1 ? 's' : ''}`}
300
+ >
301
+ <FileText size={16} />
302
+ <span className="docs-count">{uploadedDocuments.length}</span>
303
+ </button>
304
+ )}
305
+ {showProfileButtons && (
306
+ <>
307
+ <button
308
+ onClick={onOpenOnboarding}
309
+ className="add-docs-btn"
310
+ type="button"
311
+ >
312
+ <MessageCircle size={16} />
313
+ <span>Tell us about yourself</span>
314
+ </button>
315
+ <button
316
+ onClick={onOpenProfileForm}
317
+ className="add-docs-btn"
318
+ type="button"
319
+ >
320
+ <ClipboardList size={16} />
321
+ <span>Fill out profile form</span>
322
+ </button>
323
+ </>
324
+ )}
325
+ </div>
326
+
327
+ {/* Right - Mode Toggle + Mic + Send */}
328
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
329
+ {onToggleSynthesized && (
330
+ <div style={{
331
+ display: 'flex', borderRadius: '18px', overflow: 'hidden',
332
+ border: '1px solid #3b82f6', flexShrink: 0,
333
+ }}>
334
+ <button
335
+ onClick={synthesizedMode ? onToggleSynthesized : undefined}
336
+ type="button"
337
+ title="Panel Response (3 advisors)"
338
+ style={{
339
+ display: 'flex', alignItems: 'center', gap: '4px',
340
+ padding: '5px 10px', fontSize: '12px', fontWeight: 600,
341
+ cursor: synthesizedMode ? 'pointer' : 'default',
342
+ border: 'none', transition: 'all 0.2s', whiteSpace: 'nowrap',
343
+ background: !synthesizedMode ? '#3b82f6' : 'transparent',
344
+ color: !synthesizedMode ? '#fff' : '#3b82f6',
345
+ }}
346
+ >
347
+ <Columns3 size={13} />
348
+ Panel
349
+ </button>
350
+ <div style={{ width: 1, background: '#3b82f6', alignSelf: 'stretch' }} />
351
+ <button
352
+ onClick={!synthesizedMode ? onToggleSynthesized : undefined}
353
+ type="button"
354
+ title="Aggregate synthesized answer"
355
+ style={{
356
+ display: 'flex', alignItems: 'center', gap: '4px',
357
+ padding: '5px 10px', fontSize: '12px', fontWeight: 600,
358
+ cursor: !synthesizedMode ? 'pointer' : 'default',
359
+ border: 'none', transition: 'all 0.2s', whiteSpace: 'nowrap',
360
+ background: synthesizedMode ? '#3b82f6' : 'transparent',
361
+ color: synthesizedMode ? '#fff' : '#3b82f6',
362
+ }}
363
+ >
364
+ <FileOutput size={13} />
365
+ Aggregate
366
+ </button>
367
+ </div>
368
+ )}
369
+ <button
370
+ onClick={toggleRecording}
371
+ disabled={isTranscribing}
372
+ className={`mic-button ${isRecording ? 'listening' : ''}`}
373
+ type="button"
374
+ title={isTranscribing ? 'Transcribing...' : isRecording ? 'Stop recording' : 'Voice input'}
375
+ style={{
376
+ background: isRecording ? '#EF4444' : 'transparent',
377
+ border: isRecording ? '1px solid #EF4444' : '1px solid var(--border-primary)',
378
+ color: isRecording ? '#fff' : 'var(--text-secondary)',
379
+ borderRadius: '50%', width: 36, height: 36,
380
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
381
+ cursor: isTranscribing ? 'wait' : 'pointer', transition: 'all 0.2s',
382
+ animation: isRecording ? 'mic-pulse 1.5s ease-in-out infinite' : 'none',
383
+ opacity: isTranscribing ? 0.6 : 1,
384
+ }}
385
+ >
386
+ {isTranscribing ? <Loader2 size={16} className="spinning" /> : isRecording ? <MicOff size={16} /> : <Mic size={16} />}
387
+ </button>
388
+ {showExport && (
389
+ <ExportButton
390
+ hasMessages={exportHasMessages}
391
+ currentSessionId={exportSessionId}
392
+ authToken={authToken}
393
+ dropdownPlacement="above"
394
+ iconOnly
395
+ />
396
+ )}
397
+ <button
398
+ onClick={handleSend}
399
+ disabled={!canSend}
400
+ className={`send-button ${canSend ? 'enabled' : 'disabled'}`}
401
+ type="button"
402
+ >
403
+ <Send size={16} />
404
+ </button>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ );
410
+ };
411
+
412
+ export default EnhancedChatInput;
frontend/src/components/ExportButton.js ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Download, FileText, FileType, File, Check, X, Loader2 } from 'lucide-react';
3
+ import '../styles/ExportButton.css';
4
+
5
+ const ExportButton = ({
6
+ hasMessages = false,
7
+ currentSessionId = null,
8
+ authToken = null,
9
+ dropdownPlacement = 'below',
10
+ iconOnly = false,
11
+ }) => {
12
+ const [showDropdown, setShowDropdown] = useState(false);
13
+ const [isExporting, setIsExporting] = useState(false);
14
+ const [exportStatus, setExportStatus] = useState(null);
15
+ const [selectedType, setSelectedType] = useState('chat');
16
+
17
+ const exportTypes = [
18
+ {
19
+ id: 'chat',
20
+ name: 'Full Chat',
21
+ description: 'Complete conversation history',
22
+ endpoint: 'export-chat'
23
+ },
24
+ {
25
+ id: 'summary',
26
+ name: 'Chat Summary',
27
+ description: 'AI-generated conversation summary',
28
+ endpoint: 'chat-summary'
29
+ }
30
+ ];
31
+
32
+ const exportFormats = [
33
+ {
34
+ id: 'txt',
35
+ name: 'Text File',
36
+ description: 'Plain text format (.txt)',
37
+ icon: FileText,
38
+ extension: '.txt'
39
+ },
40
+ {
41
+ id: 'docx',
42
+ name: 'Word Document',
43
+ description: 'Microsoft Word format (.docx)',
44
+ icon: FileType,
45
+ extension: '.docx'
46
+ },
47
+ {
48
+ id: 'pdf',
49
+ name: 'PDF Document',
50
+ description: 'Portable Document Format (.pdf)',
51
+ icon: File,
52
+ extension: '.pdf'
53
+ }
54
+ ];
55
+
56
+ const handleExportClick = () => {
57
+ if (!hasMessages) return;
58
+ setShowDropdown(!showDropdown);
59
+ setExportStatus(null);
60
+ };
61
+
62
+ const handleFormatSelect = async (format) => {
63
+ setIsExporting(true);
64
+ setShowDropdown(false);
65
+ setExportStatus(null);
66
+
67
+ try {
68
+ const selectedTypeData = exportTypes.find(t => t.id === selectedType);
69
+ const endpoint = selectedTypeData.endpoint;
70
+
71
+ // Build the URL with session ID if available
72
+ let url = `${process.env.REACT_APP_API_URL}/${endpoint}?format=${format}`;
73
+ if (currentSessionId) {
74
+ url += `&chat_session_id=${currentSessionId}`;
75
+ }
76
+
77
+ // Build headers - include auth token if available (needed for specific session export)
78
+ const headers = {
79
+ 'Content-Type': 'application/json',
80
+ };
81
+
82
+ if (authToken && currentSessionId) {
83
+ headers['Authorization'] = `Bearer ${authToken}`;
84
+ }
85
+
86
+ const response = await fetch(url, {
87
+ method: 'GET',
88
+ headers: headers,
89
+ });
90
+
91
+ if (!response.ok) {
92
+ const errorData = await response.json().catch(() => ({}));
93
+ throw new Error(errorData.error || `Export failed with status ${response.status}`);
94
+ }
95
+
96
+ // Get the filename from the Content-Disposition header
97
+ const contentDisposition = response.headers.get('Content-Disposition');
98
+ let filename = `${selectedType}_export.${format}`;
99
+
100
+ if (contentDisposition) {
101
+ const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
102
+ if (filenameMatch && filenameMatch[1]) {
103
+ filename = filenameMatch[1].replace(/['"]/g, '');
104
+ }
105
+ }
106
+
107
+ // Create blob and download
108
+ const blob = await response.blob();
109
+ const url_blob = window.URL.createObjectURL(blob);
110
+ const link = document.createElement('a');
111
+ link.href = url_blob;
112
+ link.download = filename;
113
+ document.body.appendChild(link);
114
+ link.click();
115
+ document.body.removeChild(link);
116
+ window.URL.revokeObjectURL(url_blob);
117
+
118
+ setExportStatus('success');
119
+ setTimeout(() => setExportStatus(null), 3000);
120
+
121
+ } catch (error) {
122
+ console.error('Export error:', error);
123
+ setExportStatus('error');
124
+ setTimeout(() => setExportStatus(null), 5000);
125
+ } finally {
126
+ setIsExporting(false);
127
+ }
128
+ };
129
+
130
+ const handleClickOutside = (e) => {
131
+ if (!e.target.closest('.export-button-container')) {
132
+ setShowDropdown(false);
133
+ }
134
+ };
135
+
136
+ React.useEffect(() => {
137
+ if (showDropdown) {
138
+ document.addEventListener('click', handleClickOutside);
139
+ return () => document.removeEventListener('click', handleClickOutside);
140
+ }
141
+ }, [showDropdown]);
142
+
143
+ const getButtonIcon = () => {
144
+ if (isExporting) return <Loader2 size={16} className="spinning" />;
145
+ if (exportStatus === 'success') return <Check size={16} />;
146
+ if (exportStatus === 'error') return <X size={16} />;
147
+ return <Download size={16} />;
148
+ };
149
+
150
+ const getButtonClass = () => {
151
+ let baseClass = 'export-button';
152
+ if (!hasMessages) baseClass += ' disabled';
153
+ if (showDropdown) baseClass += ' active';
154
+ if (exportStatus === 'success') baseClass += ' success';
155
+ if (exportStatus === 'error') baseClass += ' error';
156
+ return baseClass;
157
+ };
158
+
159
+ const getButtonTitle = () => {
160
+ if (!hasMessages) return 'No messages to export';
161
+ if (isExporting) return 'Exporting chat...';
162
+ if (exportStatus === 'success') return 'Export successful!';
163
+ if (exportStatus === 'error') return 'Export failed - click to retry';
164
+
165
+ // Show whether we're exporting current session or specific saved chat
166
+ const sessionInfo = currentSessionId ? 'this saved chat' : 'current session';
167
+ return `Export ${sessionInfo}`;
168
+ };
169
+
170
+ return (
171
+ <div
172
+ className={[
173
+ 'export-button-container',
174
+ dropdownPlacement === 'above' ? 'export-dropdown-above' : '',
175
+ iconOnly ? 'export-button-icon-only' : '',
176
+ ].filter(Boolean).join(' ')}
177
+ >
178
+ <button
179
+ onClick={handleExportClick}
180
+ className={getButtonClass()}
181
+ disabled={!hasMessages || isExporting}
182
+ title={getButtonTitle()}
183
+ aria-label="Export"
184
+ >
185
+ {getButtonIcon()}
186
+ {!iconOnly && <span className="export-text">Export</span>}
187
+ </button>
188
+
189
+ {showDropdown && (
190
+ <div className="export-dropdown">
191
+ <div className="export-dropdown-header">
192
+ <h4>Export Options</h4>
193
+ <p>
194
+ {currentSessionId
195
+ ? 'Export this saved chat conversation'
196
+ : 'Export current session'
197
+ }
198
+ </p>
199
+ </div>
200
+
201
+ {/* Export Type Selection */}
202
+ <div className="export-type-section">
203
+ <h5>What to export:</h5>
204
+ <div className="export-type-buttons">
205
+ {exportTypes.map((type) => (
206
+ <button
207
+ key={type.id}
208
+ onClick={() => setSelectedType(type.id)}
209
+ className={`export-type-button ${selectedType === type.id ? 'active' : ''}`}
210
+ disabled={isExporting}
211
+ >
212
+ <div className="type-info">
213
+ <div className="type-name">{type.name}</div>
214
+ <div className="type-description">{type.description}</div>
215
+ </div>
216
+ </button>
217
+ ))}
218
+ </div>
219
+ </div>
220
+
221
+ {/* Format Selection */}
222
+ <div className="export-format-section">
223
+ <h5>Format:</h5>
224
+ <div className="export-format-list">
225
+ {exportFormats.map((format) => {
226
+ const Icon = format.icon;
227
+ return (
228
+ <button
229
+ key={format.id}
230
+ onClick={() => handleFormatSelect(format.id)}
231
+ className="export-format-button"
232
+ disabled={isExporting}
233
+ >
234
+ <div className="format-icon">
235
+ <Icon size={20} />
236
+ </div>
237
+ <div className="format-info">
238
+ <div className="format-name">{format.name}</div>
239
+ <div className="format-description">{format.description}</div>
240
+ </div>
241
+ <div className="format-extension">{format.extension}</div>
242
+ </button>
243
+ );
244
+ })}
245
+ </div>
246
+ </div>
247
+
248
+ <div className="export-dropdown-footer">
249
+ <span>
250
+ {selectedType === 'chat'
251
+ ? 'Full conversation history will be included'
252
+ : 'AI will generate a concise summary of your conversation'
253
+ }
254
+ </span>
255
+ </div>
256
+ </div>
257
+ )}
258
+
259
+ {exportStatus && (
260
+ <div className={`export-status ${exportStatus}`}>
261
+ {exportStatus === 'success' && 'Chat exported successfully!'}
262
+ {exportStatus === 'error' && 'Export failed. Please try again.'}
263
+ </div>
264
+ )}
265
+ </div>
266
+ );
267
+ };
268
+
269
+ export default ExportButton;
frontend/src/components/FileUpload.js ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef } from 'react';
2
+ import { Upload, FileText, File, X, CheckCircle, AlertCircle } from 'lucide-react';
3
+ import { useTheme } from '../contexts/ThemeContext';
4
+ import '../styles/FileUpload.css'
5
+
6
+ const FileUpload = ({ onFileUploaded, isUploading, onUploadStart, currentChatSessionId = null, authToken = null }) => {
7
+ const [dragActive, setDragActive] = useState(false);
8
+ const [uploadStatus, setUploadStatus] = useState(null); // 'success', 'error', null
9
+ const [uploadMessage, setUploadMessage] = useState('');
10
+ const [selectedFile, setSelectedFile] = useState(null);
11
+ const fileInputRef = useRef(null);
12
+ const { isDark } = useTheme();
13
+
14
+ const supportedTypes = {
15
+ 'application/pdf': { ext: 'PDF', icon: FileText, color: '#EF4444' },
16
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { ext: 'DOCX', icon: File, color: '#3B82F6' },
17
+ 'text/plain': { ext: 'TXT', icon: FileText, color: '#10B981' }
18
+ };
19
+
20
+ const validateFile = (file) => {
21
+ if (!supportedTypes[file.type]) {
22
+ return { valid: false, error: 'Only PDF, DOCX, and TXT files are supported.' };
23
+ }
24
+
25
+ if (file.size > 10 * 1024 * 1024) { // 10MB limit
26
+ return { valid: false, error: 'File size must be less than 10MB.' };
27
+ }
28
+
29
+ return { valid: true };
30
+ };
31
+
32
+ const uploadFile = async (file) => {
33
+ const validation = validateFile(file);
34
+ if (!validation.valid) {
35
+ setUploadStatus('error');
36
+ setUploadMessage(validation.error);
37
+ return;
38
+ }
39
+
40
+ setSelectedFile(file);
41
+ onUploadStart && onUploadStart();
42
+
43
+ const formData = new FormData();
44
+ formData.append('file', file);
45
+
46
+ try {
47
+ let uploadUrl = `${process.env.REACT_APP_API_URL}/upload-document`;
48
+
49
+ console.log('=== DOCUMENT UPLOAD DEBUG ===');
50
+ console.log('currentChatSessionId:', currentChatSessionId);
51
+ console.log('authToken available:', !!authToken);
52
+
53
+ if (currentChatSessionId) {
54
+ uploadUrl += `?chat_session_id=${currentChatSessionId}`;
55
+ console.log('Uploading to specific chat session:', currentChatSessionId);
56
+ console.log('Final upload URL:', uploadUrl);
57
+ } else {
58
+ console.log('WARNING: No currentChatSessionId - uploading to new session');
59
+ console.log('This will cause session mismatch!');
60
+ }
61
+
62
+ // Include auth token in headers if available
63
+ const headers = {};
64
+ if (authToken) {
65
+ headers['Authorization'] = `Bearer ${authToken}`;
66
+ console.log('Auth token included in request');
67
+ } else {
68
+ console.log('WARNING: No auth token available');
69
+ }
70
+
71
+ const response = await fetch(uploadUrl, {
72
+ method: 'POST',
73
+ headers: headers,
74
+ body: formData,
75
+ });
76
+
77
+ if (response.ok) {
78
+ const data = await response.json();
79
+ setUploadStatus('success');
80
+ setUploadMessage(`${file.name} uploaded successfully and added to context.`);
81
+ onFileUploaded && onFileUploaded(file, data);
82
+
83
+ // ENHANCED: Better debug logging
84
+ console.log('=== UPLOAD RESULT ===');
85
+ console.log('Document upload result:', {
86
+ filename: data.filename,
87
+ session_id: data.session_id,
88
+ chat_session_id: data.chat_session_id,
89
+ user_id: data.user_id,
90
+ chunks_created: data.chunks_created,
91
+ currentSessionId: currentChatSessionId
92
+ });
93
+
94
+ // Check for session mismatch
95
+ if (data.chat_session_id !== currentChatSessionId) {
96
+ console.error('SESSION MISMATCH DETECTED!');
97
+ console.error('Expected:', currentChatSessionId);
98
+ console.error('Got:', data.chat_session_id);
99
+ } else {
100
+ console.log('✅ Session IDs match correctly');
101
+ }
102
+
103
+ // Auto-clear success message after 5 seconds
104
+ setTimeout(() => {
105
+ setUploadStatus(null);
106
+ setSelectedFile(null);
107
+ setUploadMessage('');
108
+ }, 5000);
109
+ } else {
110
+ const errorData = await response.json();
111
+ throw new Error(errorData.detail || 'Upload failed');
112
+ }
113
+ } catch (error) {
114
+ setUploadStatus('error');
115
+ setUploadMessage(`Upload failed: ${error.message}`);
116
+ console.error('Upload error:', error);
117
+ }
118
+ };
119
+
120
+ const handleDrag = (e) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ if (e.type === 'dragenter' || e.type === 'dragover') {
124
+ setDragActive(true);
125
+ } else if (e.type === 'dragleave') {
126
+ setDragActive(false);
127
+ }
128
+ };
129
+
130
+ const handleDrop = (e) => {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
+ setDragActive(false);
134
+
135
+ if (isUploading) return;
136
+
137
+ const files = Array.from(e.dataTransfer.files);
138
+ if (files.length > 0) {
139
+ uploadFile(files[0]);
140
+ }
141
+ };
142
+
143
+ const handleFileSelect = (e) => {
144
+ const files = Array.from(e.target.files);
145
+ if (files.length > 0) {
146
+ uploadFile(files[0]);
147
+ }
148
+ };
149
+
150
+ const openFileDialog = () => {
151
+ if (!isUploading) {
152
+ fileInputRef.current?.click();
153
+ }
154
+ };
155
+
156
+ const clearStatus = () => {
157
+ setUploadStatus(null);
158
+ setSelectedFile(null);
159
+ setUploadMessage('');
160
+ };
161
+
162
+ const getFileIcon = (file) => {
163
+ const fileInfo = supportedTypes[file.type];
164
+ if (fileInfo) {
165
+ const Icon = fileInfo.icon;
166
+ return <Icon size={16} style={{ color: fileInfo.color }} />;
167
+ }
168
+ return <File size={16} />;
169
+ };
170
+
171
+ return (
172
+ <div className="file-upload-container">
173
+ {/* Upload Status Banner */}
174
+ {uploadStatus && (
175
+ <div className={`upload-status ${uploadStatus}`}>
176
+ <div className="status-content">
177
+ {uploadStatus === 'success' ? (
178
+ <CheckCircle size={16} className="status-icon success" />
179
+ ) : (
180
+ <AlertCircle size={16} className="status-icon error" />
181
+ )}
182
+ <span className="status-message">{uploadMessage}</span>
183
+ <button onClick={clearStatus} className="status-close">
184
+ <X size={14} />
185
+ </button>
186
+ </div>
187
+ </div>
188
+ )}
189
+
190
+ {/* Upload Area */}
191
+ <div
192
+ className={`file-upload-area ${dragActive ? 'drag-active' : ''} ${isUploading ? 'uploading' : ''}`}
193
+ onDragEnter={handleDrag}
194
+ onDragLeave={handleDrag}
195
+ onDragOver={handleDrag}
196
+ onDrop={handleDrop}
197
+ onClick={openFileDialog}
198
+ >
199
+ <input
200
+ ref={fileInputRef}
201
+ type="file"
202
+ onChange={handleFileSelect}
203
+ accept=".pdf,.docx,.txt"
204
+ style={{ display: 'none' }}
205
+ disabled={isUploading}
206
+ />
207
+
208
+ <div className="upload-content">
209
+ {isUploading ? (
210
+ <>
211
+ <div className="upload-spinner">
212
+ <div className="spinner"></div>
213
+ </div>
214
+ <p className="upload-text">Uploading {selectedFile?.name}...</p>
215
+ </>
216
+ ) : (
217
+ <>
218
+ <Upload size={24} className="upload-icon" />
219
+ <p className="upload-text">
220
+ <span className="upload-primary">Click to upload</span> or drag and drop
221
+ </p>
222
+ <p className="upload-secondary">PDF, DOCX, or TXT files only</p>
223
+ </>
224
+ )}
225
+ </div>
226
+ </div>
227
+
228
+ {/* Supported File Types */}
229
+ <div className="supported-types">
230
+ {Object.entries(supportedTypes).map(([mimeType, info]) => {
231
+ const Icon = info.icon;
232
+ return (
233
+ <div key={mimeType} className="file-type-chip">
234
+ <Icon size={12} style={{ color: info.color }} />
235
+ <span>{info.ext}</span>
236
+ </div>
237
+ );
238
+ })}
239
+ </div>
240
+ </div>
241
+ );
242
+ };
243
+
244
+ export default FileUpload;
frontend/src/components/GuestIntakeModal.js ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Shield, Building2, PenLine, X, Loader2, ArrowRight } from 'lucide-react';
3
+ import { useTheme } from '../contexts/ThemeContext';
4
+
5
+ /**
6
+ * Explore-as-guest intake: two persona chips + free-text "something else".
7
+ */
8
+ const GuestIntakeModal = ({ onClose, onSuccess }) => {
9
+ const { isDark } = useTheme();
10
+ const [choice, setChoice] = useState(null); // personal | business | other
11
+ const [freeText, setFreeText] = useState('');
12
+ const [loading, setLoading] = useState(false);
13
+ const [error, setError] = useState(null);
14
+
15
+ const bg = isDark ? 'rgba(15,23,42,0.92)' : 'rgba(15,23,42,0.45)';
16
+ const card = {
17
+ background: isDark ? '#1e293b' : '#fff',
18
+ color: isDark ? '#f8fafc' : '#0f172a',
19
+ border: `1px solid ${isDark ? '#334155' : '#e2e8f0'}`,
20
+ borderRadius: 16,
21
+ maxWidth: 480,
22
+ width: '92%',
23
+ padding: '1.5rem 1.4rem 1.25rem',
24
+ boxShadow: '0 24px 48px rgba(15,23,42,0.25)',
25
+ };
26
+ const chip = (active) => ({
27
+ display: 'flex',
28
+ alignItems: 'flex-start',
29
+ gap: 12,
30
+ textAlign: 'left',
31
+ width: '100%',
32
+ padding: '14px 14px',
33
+ minHeight: 44,
34
+ borderRadius: 12,
35
+ border: active ? '2px solid var(--accent-primary, #0F766E)' : `1px solid ${isDark ? '#475569' : '#cbd5e1'}`,
36
+ background: active
37
+ ? (isDark ? 'rgba(15,118,110,0.2)' : 'rgba(15,118,110,0.08)')
38
+ : (isDark ? '#0f172a' : '#f8fafc'),
39
+ cursor: 'pointer',
40
+ color: 'inherit',
41
+ marginBottom: 10,
42
+ });
43
+
44
+ const start = async () => {
45
+ if (!choice) {
46
+ setError('Pick a path or describe what you need.');
47
+ return;
48
+ }
49
+ if (choice === 'other' && !freeText.trim()) {
50
+ setError('Tell us a bit about what you need help with.');
51
+ return;
52
+ }
53
+ setLoading(true);
54
+ setError(null);
55
+ try {
56
+ const resp = await fetch(`${process.env.REACT_APP_API_URL || ''}/auth/guest`, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({
60
+ choice,
61
+ free_text: choice === 'other' ? freeText.trim() : null,
62
+ }),
63
+ });
64
+ if (!resp.ok) {
65
+ const data = await resp.json().catch(() => ({}));
66
+ throw new Error(data.detail || 'Could not start guest session');
67
+ }
68
+ const data = await resp.json();
69
+ localStorage.setItem('authToken', data.access_token);
70
+ localStorage.setItem('user', JSON.stringify(data.user));
71
+ onSuccess?.(data.user, data.access_token);
72
+ } catch (e) {
73
+ setError(e.message || 'Something went wrong');
74
+ } finally {
75
+ setLoading(false);
76
+ }
77
+ };
78
+
79
+ return (
80
+ <div
81
+ role="dialog"
82
+ aria-modal="true"
83
+ aria-labelledby="guest-intake-title"
84
+ style={{
85
+ position: 'fixed', inset: 0, zIndex: 10000,
86
+ background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
87
+ padding: 16,
88
+ }}
89
+ onMouseDown={(e) => { if (e.target === e.currentTarget && !loading) onClose?.(); }}
90
+ >
91
+ <div style={card} onMouseDown={(e) => e.stopPropagation()}>
92
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
93
+ <div>
94
+ <h2 id="guest-intake-title" style={{ margin: 0, fontSize: '1.25rem' }}>Explore as guest</h2>
95
+ <p style={{ margin: '6px 0 0', color: isDark ? '#94a3b8' : '#64748b', fontSize: 14, lineHeight: 1.45 }}>
96
+ No account needed. We&apos;ll load a realistic demo so Chat, Journey, Workspace, and About You feel useful right away.
97
+ </p>
98
+ </div>
99
+ <button
100
+ type="button"
101
+ aria-label="Close"
102
+ onClick={onClose}
103
+ disabled={loading}
104
+ style={{
105
+ background: 'transparent', border: 'none', cursor: 'pointer',
106
+ color: isDark ? '#94a3b8' : '#64748b', padding: 4, minHeight: 44, minWidth: 44,
107
+ }}
108
+ >
109
+ <X size={20} />
110
+ </button>
111
+ </div>
112
+
113
+ <div style={{ marginTop: 18 }}>
114
+ <button type="button" style={chip(choice === 'personal')} onClick={() => setChoice('personal')} disabled={loading}>
115
+ <Shield size={22} style={{ color: 'var(--accent-primary, #0F766E)', flexShrink: 0, marginTop: 2 }} />
116
+ <span>
117
+ <strong style={{ display: 'block', marginBottom: 2 }}>I&apos;m securing my personal digital life</strong>
118
+ <span style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#64748b' }}>
119
+ Passwords, MFA, backups, phishing — sample Journey &amp; chat for individuals
120
+ </span>
121
+ </span>
122
+ </button>
123
+
124
+ <button type="button" style={chip(choice === 'business')} onClick={() => setChoice('business')} disabled={loading}>
125
+ <Building2 size={22} style={{ color: 'var(--accent-primary, #0F766E)', flexShrink: 0, marginTop: 2 }} />
126
+ <span>
127
+ <strong style={{ display: 'block', marginBottom: 2 }}>I help protect a business or organization</strong>
128
+ <span style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#64748b' }}>
129
+ SMB / IT baseline, CIS IG1 sample track, policies &amp; Workspace demo
130
+ </span>
131
+ </span>
132
+ </button>
133
+
134
+ <button type="button" style={chip(choice === 'other')} onClick={() => setChoice('other')} disabled={loading}>
135
+ <PenLine size={22} style={{ color: 'var(--accent-primary, #0F766E)', flexShrink: 0, marginTop: 2 }} />
136
+ <span>
137
+ <strong style={{ display: 'block', marginBottom: 2 }}>Something else</strong>
138
+ <span style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#64748b' }}>
139
+ Describe your situation — we&apos;ll tailor the demo
140
+ </span>
141
+ </span>
142
+ </button>
143
+
144
+ {choice === 'other' && (
145
+ <textarea
146
+ value={freeText}
147
+ onChange={(e) => setFreeText(e.target.value)}
148
+ placeholder="e.g. Preparing for a Security+ exam, or reviewing our ransomware readiness…"
149
+ rows={3}
150
+ disabled={loading}
151
+ style={{
152
+ width: '100%', boxSizing: 'border-box', marginTop: 4, padding: 12,
153
+ borderRadius: 10, border: `1px solid ${isDark ? '#475569' : '#cbd5e1'}`,
154
+ background: isDark ? '#0f172a' : '#fff', color: 'inherit',
155
+ fontFamily: 'inherit', fontSize: 14, resize: 'vertical', minHeight: 88,
156
+ }}
157
+ />
158
+ )}
159
+ </div>
160
+
161
+ {error && (
162
+ <p style={{ color: '#dc2626', fontSize: 13, margin: '10px 0 0' }}>{error}</p>
163
+ )}
164
+
165
+ <div style={{ display: 'flex', gap: 10, marginTop: 18, justifyContent: 'flex-end' }}>
166
+ <button
167
+ type="button"
168
+ onClick={onClose}
169
+ disabled={loading}
170
+ style={{
171
+ padding: '10px 16px', minHeight: 44, borderRadius: 10,
172
+ border: `1px solid ${isDark ? '#475569' : '#cbd5e1'}`,
173
+ background: 'transparent', color: 'inherit', cursor: 'pointer',
174
+ }}
175
+ >
176
+ Cancel
177
+ </button>
178
+ <button
179
+ type="button"
180
+ onClick={start}
181
+ disabled={loading || !choice}
182
+ style={{
183
+ padding: '10px 16px', minHeight: 44, borderRadius: 10, border: 'none',
184
+ background: 'var(--accent-primary, #0F766E)', color: '#fff',
185
+ cursor: loading || !choice ? 'not-allowed' : 'pointer',
186
+ display: 'inline-flex', alignItems: 'center', gap: 8, fontWeight: 600,
187
+ opacity: loading || !choice ? 0.6 : 1,
188
+ }}
189
+ >
190
+ {loading ? <Loader2 size={16} className="spin" style={{ animation: 'spin 1s linear infinite' }} /> : <ArrowRight size={16} />}
191
+ {loading ? 'Setting up demo…' : 'Enter guest demo'}
192
+ </button>
193
+ </div>
194
+ <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
195
+ </div>
196
+ </div>
197
+ );
198
+ };
199
+
200
+ export default GuestIntakeModal;
frontend/src/components/IntakePanel.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useMemo } from 'react';
2
+ import { MessageCircle } from 'lucide-react';
3
+ import { useAppConfig } from '../contexts/AppConfigContext';
4
+ import '../styles/IntakePanel.css';
5
+
6
+ /**
7
+ * First-session intake: Jerry's greeting + chips + optional free-text.
8
+ * Chips come from /api/config → chat_page.intake, filtered by guest persona when set.
9
+ */
10
+ const IntakePanel = ({ onSubmit, guestPersona = null }) => {
11
+ const { config } = useAppConfig();
12
+ const intake = config?.chat_page?.intake || {};
13
+ const greeting =
14
+ intake.greeting ||
15
+ "You've contacted me today — what is it that I can help you with in cybersecurity?";
16
+
17
+ const chips = useMemo(() => {
18
+ const byPersona = intake.by_persona || {};
19
+ const personaKey = (guestPersona || '').toLowerCase();
20
+ const personaChips = personaKey && Array.isArray(byPersona[personaKey])
21
+ ? byPersona[personaKey]
22
+ : null;
23
+ if (personaChips && personaChips.length > 0) return personaChips;
24
+ return Array.isArray(intake.chips) ? intake.chips : [];
25
+ }, [intake, guestPersona]);
26
+
27
+ const [freeText, setFreeText] = useState('');
28
+ const [showFreeText, setShowFreeText] = useState(false);
29
+
30
+ const handleChip = (chip) => {
31
+ if (chip.free_text) {
32
+ setShowFreeText(true);
33
+ return;
34
+ }
35
+ if (chip.prompt) onSubmit(chip.prompt);
36
+ };
37
+
38
+ const handleFreeSubmit = (e) => {
39
+ e.preventDefault();
40
+ const text = freeText.trim();
41
+ if (!text) return;
42
+ onSubmit(text);
43
+ setFreeText('');
44
+ };
45
+
46
+ return (
47
+ <div className="intake-panel" role="region" aria-label="Getting started">
48
+ <div className="intake-avatar" aria-hidden="true">
49
+ <MessageCircle size={18} />
50
+ </div>
51
+ <h2 className="intake-greeting">{greeting}</h2>
52
+ <p className="intake-sub">
53
+ Pick a starting point, or tell me in your own words.
54
+ </p>
55
+ <div className="intake-chips">
56
+ {chips.map((chip) => (
57
+ <button
58
+ key={chip.id}
59
+ type="button"
60
+ className={`intake-chip${chip.free_text ? ' intake-chip--other' : ''}`}
61
+ onClick={() => handleChip(chip)}
62
+ >
63
+ {chip.label}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ {(showFreeText || chips.length === 0) && (
68
+ <form className="intake-freetext" onSubmit={handleFreeSubmit}>
69
+ <label htmlFor="intake-free" className="sr-only">
70
+ Describe your cybersecurity need
71
+ </label>
72
+ <textarea
73
+ id="intake-free"
74
+ rows={3}
75
+ value={freeText}
76
+ onChange={(e) => setFreeText(e.target.value)}
77
+ placeholder="Tell me about the cybersecurity problem or goal you have today…"
78
+ />
79
+ <button type="submit" className="intake-submit" disabled={!freeText.trim()}>
80
+ Start
81
+ </button>
82
+ </form>
83
+ )}
84
+ </div>
85
+ );
86
+ };
87
+
88
+ export default IntakePanel;
frontend/src/components/Login.js ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Eye, EyeOff, Mail, Lock, ArrowRight, BookOpen, Phone } from 'lucide-react';
3
+ import { useAppConfig } from '../contexts/AppConfigContext';
4
+ import CopyrightNotice from './CopyrightNotice';
5
+ import '../styles/Login.css';
6
+
7
+ const Login = ({ onNavigateToSignup, onNavigateToHome, onExploreAsGuest }) => {
8
+ const { config } = useAppConfig();
9
+ const [showPassword, setShowPassword] = useState(false);
10
+ const [formData, setFormData] = useState({
11
+ email: '',
12
+ password: ''
13
+ });
14
+ const [isLoading, setIsLoading] = useState(false);
15
+ const [errors, setErrors] = useState({});
16
+
17
+ const handleInputChange = (e) => {
18
+ const { name, value } = e.target;
19
+ setFormData(prev => ({
20
+ ...prev,
21
+ [name]: value
22
+ }));
23
+ // Clear error when user starts typing
24
+ if (errors[name]) {
25
+ setErrors(prev => ({
26
+ ...prev,
27
+ [name]: ''
28
+ }));
29
+ }
30
+ };
31
+
32
+ const validateForm = () => {
33
+ const newErrors = {};
34
+
35
+ if (!formData.email) {
36
+ newErrors.email = 'Email is required';
37
+ } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
38
+ newErrors.email = 'Please enter a valid email address';
39
+ }
40
+
41
+ if (!formData.password) {
42
+ newErrors.password = 'Password is required';
43
+ } else if (formData.password.length < 6) {
44
+ newErrors.password = 'Password must be at least 6 characters';
45
+ }
46
+
47
+ setErrors(newErrors);
48
+ return Object.keys(newErrors).length === 0;
49
+ };
50
+
51
+ const handleSubmit = async (e) => {
52
+ e.preventDefault();
53
+
54
+ if (!validateForm()) return;
55
+
56
+ setIsLoading(true);
57
+
58
+ try {
59
+ const response = await fetch(`${process.env.REACT_APP_API_URL}/auth/login`, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ body: JSON.stringify({
65
+ email: formData.email,
66
+ password: formData.password
67
+ }),
68
+ });
69
+
70
+ const data = await response.json();
71
+
72
+ if (response.ok) {
73
+ // Store the token and user info
74
+ localStorage.setItem('authToken', data.access_token);
75
+ localStorage.setItem('user', JSON.stringify(data.user));
76
+ onNavigateToHome?.(data.user, data.access_token);
77
+ } else {
78
+ setErrors({ submit: data.detail || 'Login failed. Please try again.' });
79
+ }
80
+
81
+ } catch (error) {
82
+ console.error('Login error:', error);
83
+ setErrors({ submit: 'Login failed. Please try again.' });
84
+ } finally {
85
+ setIsLoading(false);
86
+ }
87
+ };
88
+
89
+ const handleGoogleSignIn = () => {
90
+ console.log('Google Sign In clicked');
91
+ // Future Google Auth integration will go here
92
+ };
93
+
94
+ const handlePhoneSignIn = () => {
95
+ console.log('Phone Sign In clicked');
96
+ // Future Phone Auth integration will go here
97
+ };
98
+
99
+ return (
100
+ <div className="login-page">
101
+ <div className="login-content">
102
+ <div className="login-container">
103
+ {/* Header */}
104
+ <div className="login-header">
105
+ <div className="logo-container">
106
+ <BookOpen className="logo-icon" />
107
+ </div>
108
+ <h1 className="login-title">Welcome Back</h1>
109
+ <p className="login-subtitle">
110
+ {config?.login?.subtitle || 'Sign in to continue'}
111
+ </p>
112
+ </div>
113
+
114
+ {/* Main Login Form */}
115
+ <div className="login-form-container">
116
+ <form onSubmit={handleSubmit} className="login-form">
117
+
118
+ {/* Email Field */}
119
+ <div className="form-group">
120
+ <label htmlFor="email" className="form-label">
121
+ Email Address
122
+ </label>
123
+ <div className="input-container">
124
+ <Mail className="input-icon" />
125
+ <input
126
+ type="email"
127
+ id="email"
128
+ name="email"
129
+ value={formData.email}
130
+ onChange={handleInputChange}
131
+ className={`form-input ${errors.email ? 'error' : ''}`}
132
+ placeholder="Enter your email"
133
+ disabled={isLoading}
134
+ />
135
+ </div>
136
+ {errors.email && (
137
+ <span className="error-message">{errors.email}</span>
138
+ )}
139
+ </div>
140
+
141
+ {/* Password Field */}
142
+ <div className="form-group">
143
+ <label htmlFor="password" className="form-label">
144
+ Password
145
+ </label>
146
+ <div className="input-container">
147
+ <Lock className="input-icon" />
148
+ <input
149
+ type={showPassword ? 'text' : 'password'}
150
+ id="password"
151
+ name="password"
152
+ value={formData.password}
153
+ onChange={handleInputChange}
154
+ className={`form-input ${errors.password ? 'error' : ''}`}
155
+ placeholder="Enter your password"
156
+ disabled={isLoading}
157
+ />
158
+ <button
159
+ type="button"
160
+ onClick={() => setShowPassword(!showPassword)}
161
+ className="password-toggle"
162
+ disabled={isLoading}
163
+ >
164
+ {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
165
+ </button>
166
+ </div>
167
+ {errors.password && (
168
+ <span className="error-message">{errors.password}</span>
169
+ )}
170
+ </div>
171
+
172
+ {/* Forgot Password */}
173
+ <div className="form-actions">
174
+ <button type="button" className="forgot-password">
175
+ Forgot your password?
176
+ </button>
177
+ </div>
178
+
179
+ {/* Submit Error */}
180
+ {errors.submit && (
181
+ <div className="submit-error">
182
+ {errors.submit}
183
+ </div>
184
+ )}
185
+
186
+ {/* Submit Button */}
187
+ <button
188
+ type="submit"
189
+ className={`submit-btn ${isLoading ? 'loading' : ''}`}
190
+ disabled={isLoading}
191
+ >
192
+ {isLoading ? (
193
+ <>
194
+ <div className="loading-spinner"></div>
195
+ Signing In...
196
+ </>
197
+ ) : (
198
+ <>
199
+ Sign In
200
+ <ArrowRight size={16} />
201
+ </>
202
+ )}
203
+ </button>
204
+ </form>
205
+
206
+ {/* Divider */}
207
+ <div className="divider">
208
+ <span>or continue with</span>
209
+ </div>
210
+
211
+ {/* Social Login Options */}
212
+ <div className="social-login">
213
+ <button
214
+ type="button"
215
+ className="google-btn"
216
+ onClick={handleGoogleSignIn}
217
+ disabled={isLoading}
218
+ >
219
+ <svg className="google-icon" viewBox="0 0 24 24">
220
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
221
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
222
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
223
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
224
+ </svg>
225
+ Continue with Google
226
+ </button>
227
+
228
+ <button
229
+ type="button"
230
+ className="phone-btn"
231
+ onClick={handlePhoneSignIn}
232
+ disabled={isLoading}
233
+ >
234
+ <Phone size={16} />
235
+ Continue with Phone
236
+ </button>
237
+ </div>
238
+ </div>
239
+
240
+ {/* Footer */}
241
+ <div className="login-footer">
242
+ <p>
243
+ Don't have an account?{' '}
244
+ <button
245
+ type="button"
246
+ className="link-btn"
247
+ onClick={onNavigateToSignup}
248
+ >
249
+ Sign up here
250
+ </button>
251
+ </p>
252
+ {onExploreAsGuest && (
253
+ <p style={{ marginTop: 10 }}>
254
+ Or{' '}
255
+ <button type="button" className="link-btn" onClick={onExploreAsGuest}>
256
+ Explore as guest
257
+ </button>
258
+ {' '}— no signup, sample demo data included
259
+ </p>
260
+ )}
261
+ <div className="login-powered-by">
262
+ <a href="https://neon.ai" target="_blank" rel="noopener noreferrer" className="footer-neon-link">
263
+ <img src="/neon-logo.png" alt="" className="footer-neon-logo" />
264
+ Powered by Neon.ai
265
+ </a>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ <footer className="login-page-footer">
271
+ <CopyrightNotice className="login-page-copyright" />
272
+ </footer>
273
+ </div>
274
+ );
275
+ };
276
+
277
+ export default Login;
frontend/src/components/MessageBubble.js ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { Reply, Copy, Check, Maximize2, FileText, Hash, Target, Volume2, VolumeX, Search, X, Loader2 } from 'lucide-react';
5
+ import * as LucideIcons from 'lucide-react';
6
+ import { useAppConfig } from '../contexts/AppConfigContext';
7
+ import { useTheme } from '../contexts/ThemeContext';
8
+ const stripMarkdown = (md) => {
9
+ if (!md) return '';
10
+ return md
11
+ .replace(/```[\s\S]*?```/g, '')
12
+ .replace(/`([^`]+)`/g, '$1')
13
+ .replace(/#{1,6}\s?/g, '')
14
+ .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1')
15
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
16
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
17
+ .replace(/[-*+]\s/g, '')
18
+ .replace(/\n{2,}/g, '. ')
19
+ .replace(/\n/g, ' ')
20
+ .trim();
21
+ };
22
+
23
+ const MessageBubble = ({
24
+ message,
25
+ onReply,
26
+ onCopy,
27
+ onExpand,
28
+ onSearchReferences,
29
+ showReplyButton = false,
30
+ inlineAvatar = false,
31
+ userAvatarId,
32
+ userAvatarOptions
33
+ }) => {
34
+ const { isDark } = useTheme();
35
+ const { allPersonas: advisors, getAllPersonaColors: getAdvisorColors } = useAppConfig();
36
+ const [showTooltip, setShowTooltip] = useState(null);
37
+ const [copiedStates, setCopiedStates] = useState({});
38
+ const [isSpeaking, setIsSpeaking] = useState(false);
39
+ const [isLoadingTTS, setIsLoadingTTS] = useState(false);
40
+ const [searchPopover, setSearchPopover] = useState(false);
41
+ const [searchQuery, setSearchQuery] = useState('');
42
+ const [searchLoading, setSearchLoading] = useState(false);
43
+ const [promptCopied, setPromptCopied] = useState(false);
44
+ const [bodyExpanded, setBodyExpanded] = useState(false);
45
+ const overlayRef = useRef(null);
46
+ const tooltipTimer = useRef(null);
47
+ const audioRef = useRef(null);
48
+ const SHOW_MORE_CHARS = 1100;
49
+
50
+ const handleSpeak = useCallback(async (content) => {
51
+ if (isSpeaking || isLoadingTTS) {
52
+ if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
53
+ setIsSpeaking(false);
54
+ setIsLoadingTTS(false);
55
+ return;
56
+ }
57
+ const text = (content || '').trim();
58
+ if (!text) return;
59
+ setIsLoadingTTS(true);
60
+ try {
61
+ const token = localStorage.getItem('authToken');
62
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/voice/tts`, {
63
+ method: 'POST',
64
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ text }),
66
+ });
67
+ if (!resp.ok) throw new Error('TTS failed');
68
+ const blob = await resp.blob();
69
+ const url = URL.createObjectURL(blob);
70
+ const audio = new Audio(url);
71
+ audioRef.current = audio;
72
+ setIsLoadingTTS(false);
73
+ setIsSpeaking(true);
74
+ audio.onended = () => { setIsSpeaking(false); URL.revokeObjectURL(url); audioRef.current = null; };
75
+ audio.onerror = () => { setIsSpeaking(false); URL.revokeObjectURL(url); audioRef.current = null; };
76
+ audio.play();
77
+ } catch (e) {
78
+ console.error('TTS error:', e);
79
+ setIsLoadingTTS(false);
80
+ setIsSpeaking(false);
81
+ }
82
+ }, [isSpeaking, isLoadingTTS]);
83
+
84
+ useEffect(() => {
85
+ return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } };
86
+ }, []);
87
+
88
+ const handleCopy = async (messageId, content) => {
89
+ try {
90
+ await navigator.clipboard.writeText(content || '');
91
+ setCopiedStates(prev => ({ ...prev, [messageId]: true }));
92
+ if (onCopy) onCopy(messageId, content || '');
93
+ setTimeout(() => {
94
+ setCopiedStates(prev => ({ ...prev, [messageId]: false }));
95
+ }, 2000);
96
+ } catch (err) {
97
+ console.error('Failed to copy text: ', err);
98
+ }
99
+ };
100
+
101
+ const handleExpand = (messageId, persona_id) => {
102
+ if (onExpand) onExpand(messageId, persona_id);
103
+ };
104
+
105
+ const handleSearch = async () => {
106
+ setSearchPopover(true);
107
+ setSearchLoading(true);
108
+ const content = message?.compact_markdown || message?.content || '';
109
+ try {
110
+ const token = localStorage.getItem('authToken');
111
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/search-references`, {
112
+ method: 'POST',
113
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ statement: content.substring(0, 500) }),
115
+ });
116
+ if (resp.ok) {
117
+ const data = await resp.json();
118
+ setSearchQuery(data.search_query || content.substring(0, 100));
119
+ } else {
120
+ setSearchQuery(content.substring(0, 100));
121
+ }
122
+ } catch {
123
+ setSearchQuery(content.substring(0, 100));
124
+ } finally {
125
+ setSearchLoading(false);
126
+ }
127
+ };
128
+
129
+ const showTooltipWithDelay = (tooltipType) => {
130
+ clearTimeout(tooltipTimer.current);
131
+ tooltipTimer.current = setTimeout(() => setShowTooltip(tooltipType), 500);
132
+ };
133
+
134
+ const hideTooltip = () => {
135
+ clearTimeout(tooltipTimer.current);
136
+ setShowTooltip(null);
137
+ };
138
+
139
+ // Minimal, safe preprocessing (keep Markdown structure intact)
140
+ const preprocessMarkdown = (content) => {
141
+ const input = (content || '').toString();
142
+
143
+ // 1) Strip trailing sentinel
144
+ let processed = input.replace(/\s*<\/END>\s*$/i, '');
145
+
146
+ // 2) Normalize EOL and trim right spaces (preserve newlines)
147
+ processed = processed.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
148
+ processed = processed.split('\n').map(ln => ln.replace(/\s+$/, '')).join('\n');
149
+
150
+ // 3) Unicode bullets -> '-' (so GFM parses lists)
151
+ processed = processed.replace(/^\s*[•●▪◦]\s+/gm, '- ');
152
+
153
+ // 4) Merge orphan numbered items: "1.\nText" => "1. Text"
154
+ processed = processed.replace(/(^\s*(\d+)\.\s*$)\n^\s*(\S.*)$/gm, (_m, _a, num, next) => `${num}. ${next}`);
155
+
156
+ // 5) Collapse 3+ blank lines to 2
157
+ processed = processed.replace(/\n{3,}/g, '\n\n');
158
+
159
+ return processed.trim();
160
+ };
161
+
162
+ // ENHANCED MARKDOWN COMPONENTS WITH BETTER STYLING
163
+ const markdownComponents = {
164
+ // Keep <strong> INLINE to avoid breaking paragraphs/lists
165
+ strong: ({ children }) => (
166
+ <strong style={{
167
+ fontWeight: '700',
168
+ color: isDark ? '#ffffff' : '#1f2937'
169
+ }}>
170
+ {children}
171
+ </strong>
172
+ ),
173
+
174
+ // Italic text styling
175
+ em: ({ children }) => (
176
+ <em style={{
177
+ fontStyle: 'italic',
178
+ color: isDark ? '#93c5fd' : '#3b82f6',
179
+ fontWeight: '500'
180
+ }}>
181
+ {children}
182
+ </em>
183
+ ),
184
+
185
+ // Paragraph styling with proper spacing
186
+ p: ({ children }) => (
187
+ <p style={{
188
+ marginBottom: '0.75rem',
189
+ lineHeight: '1.7',
190
+ color: isDark ? '#e5e7eb' : '#111827'
191
+ }}>
192
+ {children}
193
+ </p>
194
+ ),
195
+
196
+ // Unordered list styling
197
+ ul: ({ children }) => (
198
+ <ul style={{
199
+ listStyleType: 'disc',
200
+ paddingLeft: '1.5rem',
201
+ marginBottom: '1rem',
202
+ marginTop: '0.5rem',
203
+ color: isDark ? '#e5e7eb' : '#374151'
204
+ }}>
205
+ {children}
206
+ </ul>
207
+ ),
208
+
209
+ // Ordered list styling with better spacing
210
+ ol: ({ children }) => (
211
+ <ol style={{
212
+ listStyleType: 'decimal',
213
+ paddingLeft: '1.5rem',
214
+ marginBottom: '1rem',
215
+ marginTop: '0.5rem',
216
+ color: isDark ? '#e5e7eb' : '#374151',
217
+ counterReset: 'list-counter'
218
+ }}>
219
+ {children}
220
+ </ol>
221
+ ),
222
+
223
+ // List item styling with proper spacing
224
+ li: ({ children }) => (
225
+ <li style={{
226
+ marginBottom: '0.75rem',
227
+ lineHeight: '1.6',
228
+ }}>
229
+ {children}
230
+ </li>
231
+ ),
232
+
233
+ // Inline code styling
234
+ code: ({ inline, children }) => (
235
+ inline ? (
236
+ <code style={{
237
+ backgroundColor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
238
+ padding: '0.2rem 0.35rem',
239
+ borderRadius: '4px',
240
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
241
+ fontSize: '0.875rem'
242
+ }}>
243
+ {children}
244
+ </code>
245
+ ) : (
246
+ <pre style={{
247
+ backgroundColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
248
+ padding: '0.85rem',
249
+ borderRadius: '8px',
250
+ overflowX: 'auto',
251
+ margin: '0.5rem 0 1rem'
252
+ }}>
253
+ <code>
254
+ {children}
255
+ </code>
256
+ </pre>
257
+ )
258
+ )
259
+ };
260
+
261
+ // USER MESSAGE
262
+ if (message.type === 'user') {
263
+ const uAvatar = userAvatarOptions?.find(a => a.id === userAvatarId);
264
+ const UserIcon = uAvatar
265
+ ? (LucideIcons[uAvatar.icon] || LucideIcons.User)
266
+ : null;
267
+
268
+ return (
269
+ <div className="user-message-container">
270
+ <div className="user-message">
271
+ {message.replyTo && (
272
+ <div className="reply-indicator">
273
+ <Reply size={14} />
274
+ <span>to {message.replyTo.advisorName}</span>
275
+ </div>
276
+ )}
277
+ <p>{message.content}</p>
278
+ </div>
279
+ {UserIcon && (
280
+ <div style={{
281
+ width: 32, height: 32, borderRadius: '50%', flexShrink: 0, marginLeft: 8,
282
+ backgroundColor: uAvatar.bg, color: uAvatar.color,
283
+ display: 'flex', alignItems: 'center', justifyContent: 'center'
284
+ }}>
285
+ <UserIcon size={16} />
286
+ </div>
287
+ )}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ // ADVISOR MESSAGE
293
+ if (message.type === 'advisor') {
294
+ const personaId =
295
+ message?.persona_id ||
296
+ message?.personaId ||
297
+ message?.advisor_id ||
298
+ message?.advisorId ||
299
+ (typeof message?.advisor === 'string' ? message.advisor : undefined) ||
300
+ 'methodologist';
301
+
302
+ const advisor = advisors[personaId] || advisors[message.persona_id] || {};
303
+ const Icon = advisor.icon;
304
+ const colors = getAdvisorColors(personaId, isDark);
305
+ const isCopied = copiedStates[message.id];
306
+
307
+ const avatarElement = (size = 44) => {
308
+ const iconSize = Math.round(size * 0.52);
309
+ return (
310
+ <div
311
+ className="advisor-message-avatar-ring"
312
+ style={{ width: size, height: size }}
313
+ >
314
+ {advisor.avatarUrl ? (
315
+ <img
316
+ src={advisor.avatarUrl}
317
+ alt={advisor.name || 'Advisor'}
318
+ />
319
+ ) : Icon ? (
320
+ <Icon
321
+ className="advisor-message-avatar-icon"
322
+ style={{
323
+ color: colors.color || 'var(--text-secondary)',
324
+ width: iconSize,
325
+ height: iconSize,
326
+ }}
327
+ />
328
+ ) : (
329
+ <span
330
+ className="advisor-message-avatar-initial"
331
+ style={{ color: colors.color || 'var(--text-secondary)', fontSize: iconSize }}
332
+ >
333
+ {advisor.name ? advisor.name.charAt(0) : 'A'}
334
+ </span>
335
+ )}
336
+ </div>
337
+ );
338
+ };
339
+
340
+ return (
341
+ <div className={`advisor-message-container ${inlineAvatar ? 'inline-avatar-mode' : ''}`}>
342
+ {!inlineAvatar && (
343
+ <div
344
+ className="advisor-avatar"
345
+ style={{ backgroundColor: colors.bgColor || 'var(--bg-muted)', overflow: 'hidden' }}
346
+ >
347
+ </div>
348
+ )}
349
+
350
+ <div
351
+ className="advisor-message-bubble"
352
+ style={{
353
+ backgroundColor: colors.bgColor || 'var(--bg-primary)',
354
+ borderColor: (colors.color ? colors.color + '40' : 'var(--border-muted)'),
355
+ position: 'relative'
356
+ }}
357
+ >
358
+ <div className="advisor-message-header">
359
+ {inlineAvatar && avatarElement(44)}
360
+ <h4
361
+ className="advisor-message-name"
362
+ style={{ color: colors.color || 'var(--text-primary)' }}
363
+ >
364
+ {advisor.name || message.advisorName || 'Advisor'}
365
+ {message.isReply && <span className="reply-badge">↳ Reply</span>}
366
+ {message.isExpansion && <span className="expansion-badge">⤴ Expanded</span>}
367
+ </h4>
368
+ <span
369
+ className="message-time"
370
+ style={{
371
+ color: colors.color || 'var(--text-secondary)',
372
+ opacity: 0.7
373
+ }}
374
+ >
375
+ {message.timestamp?.toLocaleTimeString
376
+ ? message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
377
+ : ''}
378
+ </span>
379
+ </div>
380
+
381
+ {/* Enhanced markdown rendering with preprocessing — full body text, no ellipsis clip */}
382
+ {(() => {
383
+ const markdownBody = preprocessMarkdown(
384
+ message?.compact_markdown || message?.content || message?.text
385
+ );
386
+ const isLongBody = markdownBody.length > SHOW_MORE_CHARS;
387
+ const collapsed = isLongBody && !bodyExpanded;
388
+ return (
389
+ <>
390
+ <div
391
+ className={`advisor-message-text${collapsed ? ' is-collapsed' : ''}`}
392
+ style={{ color: colors.textColor || (isDark ? '#e5e7eb' : '#111827') }}
393
+ >
394
+ <ReactMarkdown
395
+ components={markdownComponents}
396
+ remarkPlugins={[remarkGfm]}
397
+ rehypePlugins={[]}
398
+ >
399
+ {markdownBody}
400
+ </ReactMarkdown>
401
+ </div>
402
+ {isLongBody && (
403
+ <button
404
+ type="button"
405
+ className="message-show-more"
406
+ onClick={() => setBodyExpanded((v) => !v)}
407
+ style={{ color: colors.color || 'var(--accent-primary)' }}
408
+ >
409
+ {bodyExpanded ? 'Show less' : 'Show more'}
410
+ </button>
411
+ )}
412
+ </>
413
+ );
414
+ })()}
415
+
416
+ {showReplyButton && (
417
+ <div className="message-actions">
418
+ <div className="message-action-buttons">
419
+ <div className="tooltip-container">
420
+ <button
421
+ className="message-action-button"
422
+ onClick={() => onReply && onReply(message)}
423
+ onMouseEnter={() => showTooltipWithDelay('reply')}
424
+ onMouseLeave={hideTooltip}
425
+ style={{
426
+ color: colors.color || 'var(--text-secondary)',
427
+ borderColor: (colors.color ? colors.color + '40' : 'var(--border-muted)')
428
+ }}
429
+ >
430
+ <Reply size={14} stroke="currentColor" fill="none" />
431
+ </button>
432
+ {showTooltip === 'reply' && (
433
+ <div className="tooltip">Reply to this message</div>
434
+ )}
435
+ </div>
436
+
437
+ <div className="tooltip-container">
438
+ <button
439
+ className="message-action-button"
440
+ onClick={() => handleCopy(message.id, message?.compact_markdown || message?.content || '')}
441
+ onMouseEnter={() => showTooltipWithDelay('copy')}
442
+ onMouseLeave={hideTooltip}
443
+ style={{
444
+ color: isCopied ? '#10B981' : (colors.color || 'var(--text-secondary)'),
445
+ borderColor: isCopied ? '#10B98140' : (colors.color ? colors.color + '40' : 'var(--border-muted)')
446
+ }}
447
+ >
448
+ {isCopied ? <Check size={14} /> : <Copy size={14} />}
449
+ </button>
450
+ {showTooltip === 'copy' && (
451
+ <div className="tooltip">
452
+ {isCopied ? 'Copied!' : 'Copy to clipboard'}
453
+ </div>
454
+ )}
455
+ </div>
456
+
457
+ <div className="tooltip-container">
458
+ <button
459
+ className="message-action-button"
460
+ onClick={() => handleExpand(message.id, personaId)}
461
+ onMouseEnter={() => showTooltipWithDelay('expand')}
462
+ onMouseLeave={hideTooltip}
463
+ style={{
464
+ color: colors.color || 'var(--text-secondary)',
465
+ borderColor: (colors.color ? colors.color + '40' : 'var(--border-muted)')
466
+ }}
467
+ >
468
+ <Maximize2 size={14} />
469
+ </button>
470
+ {showTooltip === 'expand' && (
471
+ <div className="tooltip">More</div>
472
+ )}
473
+ </div>
474
+
475
+ <div className="tooltip-container">
476
+ <button
477
+ className="message-action-button"
478
+ onClick={() => handleSpeak(message?.compact_markdown || message?.content || '')}
479
+ onMouseEnter={() => showTooltipWithDelay('speak')}
480
+ onMouseLeave={hideTooltip}
481
+ style={{
482
+ color: isLoadingTTS ? (colors.color || 'var(--text-secondary)') : isSpeaking ? '#EF4444' : (colors.color || 'var(--text-secondary)'),
483
+ borderColor: isSpeaking ? '#EF444440' : (colors.color ? colors.color + '40' : 'var(--border-muted)'),
484
+ opacity: isLoadingTTS ? 0.7 : 1,
485
+ }}
486
+ >
487
+ {isLoadingTTS ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : isSpeaking ? <VolumeX size={14} /> : <Volume2 size={14} />}
488
+ </button>
489
+ {showTooltip === 'speak' && (
490
+ <div className="tooltip">{isLoadingTTS ? 'Loading audio...' : isSpeaking ? 'Stop speaking' : 'Speak it'}</div>
491
+ )}
492
+ </div>
493
+
494
+ <div className="tooltip-container">
495
+ <button
496
+ className="message-action-button"
497
+ onClick={handleSearch}
498
+ onMouseEnter={() => showTooltipWithDelay('search')}
499
+ onMouseLeave={hideTooltip}
500
+ style={{
501
+ color: colors.color || 'var(--text-secondary)',
502
+ borderColor: (colors.color ? colors.color + '40' : 'var(--border-muted)')
503
+ }}
504
+ >
505
+ <Search size={14} />
506
+ </button>
507
+ {showTooltip === 'search' && (
508
+ <div className="tooltip">Search for references</div>
509
+ )}
510
+ </div>
511
+
512
+ </div>
513
+ </div>
514
+ )}
515
+
516
+ {searchPopover && (
517
+ <div style={{
518
+ marginTop: 8, background: 'var(--bg-primary)',
519
+ border: '1px solid var(--border-primary)', borderRadius: 12,
520
+ padding: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
521
+ }}>
522
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
523
+ <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>Search for References</span>
524
+ <button onClick={() => setSearchPopover(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
525
+ <X size={14} />
526
+ </button>
527
+ </div>
528
+ {searchLoading ? (
529
+ <div style={{ color: 'var(--text-secondary)', fontSize: 12 }}>Generating search query...</div>
530
+ ) : (
531
+ <>
532
+ <div style={{
533
+ background: 'var(--bg-secondary)', borderRadius: 8, padding: '8px 10px',
534
+ fontSize: 12, color: 'var(--text-primary)', marginBottom: 8, lineHeight: 1.4,
535
+ }}>{searchQuery}</div>
536
+ <div style={{ display: 'flex', gap: 6 }}>
537
+ <button onClick={() => window.open(`https://www.perplexity.ai/?q=${encodeURIComponent(searchQuery)}`, '_blank')} style={{
538
+ padding: '6px 12px', borderRadius: 8, fontSize: 11, fontWeight: 600,
539
+ background: 'var(--accent-primary)', color: '#fff', border: 'none', cursor: 'pointer',
540
+ }}>Open in Perplexity</button>
541
+ <button onClick={() => {
542
+ navigator.clipboard.writeText(searchQuery).then(() => {
543
+ setPromptCopied(true);
544
+ setTimeout(() => setPromptCopied(false), 2000);
545
+ }).catch(() => {});
546
+ }} style={{
547
+ padding: '6px 12px', borderRadius: 8, fontSize: 11, fontWeight: 600,
548
+ background: promptCopied ? '#10B98120' : 'var(--bg-secondary)',
549
+ color: promptCopied ? '#10B981' : 'var(--text-primary)',
550
+ border: `1px solid ${promptCopied ? '#10B98140' : 'var(--border-primary)'}`,
551
+ cursor: 'pointer',
552
+ transition: 'all 0.2s ease',
553
+ }}>{promptCopied ? '✓ Copied!' : 'Copy Prompt'}</button>
554
+ </div>
555
+ </>
556
+ )}
557
+ </div>
558
+ )}
559
+ </div>
560
+ </div>
561
+ );
562
+ }
563
+
564
+ // ERROR MESSAGE
565
+ if (message.type === 'error') {
566
+ return (
567
+ <div className="error-message-container">
568
+ <div className="error-message">
569
+ <p>{message.content}</p>
570
+ </div>
571
+ </div>
572
+ );
573
+ }
574
+
575
+ return null;
576
+ };
577
+
578
+ export default MessageBubble;
579
+
580
+ const RagChunkPreview = ({ text }) => {
581
+ const [expanded, setExpanded] = useState(false);
582
+ const full = (text || '').toString();
583
+ const isLong = full.length > 240;
584
+ const shown = !isLong || expanded ? full : full.slice(0, 240);
585
+ return (
586
+ <div className="rag-chunk-preview">
587
+ {shown}
588
+ {isLong && (
589
+ <>
590
+ {' '}
591
+ <button
592
+ type="button"
593
+ className="message-show-more"
594
+ onClick={() => setExpanded((v) => !v)}
595
+ style={{ fontSize: 11 }}
596
+ >
597
+ {expanded ? 'Show less' : 'Show more'}
598
+ </button>
599
+ </>
600
+ )}
601
+ </div>
602
+ );
603
+ };
604
+
605
+ /** RAG Info overlay kept as-is from your original file */
606
+ const RagInfoOverlay = ({ ragMetadata, colors }) => {
607
+ const overlayRef = useRef(null);
608
+ const [documentChunks, setDocumentChunks] = useState([]);
609
+
610
+ useEffect(() => {
611
+ if (ragMetadata?.documentChunks) {
612
+ setDocumentChunks(ragMetadata.documentChunks);
613
+ }
614
+ }, [ragMetadata]);
615
+
616
+ const hasDocuments = documentChunks.length > 0;
617
+
618
+ return (
619
+ <div className="rag-info-overlay" ref={overlayRef}>
620
+ <div className="rag-overlay-content">
621
+ <div className="rag-header">
622
+ <div className="rag-title">
623
+ <FileText size={14} />
624
+ <span>Response Details</span>
625
+ </div>
626
+ </div>
627
+
628
+ <div className="rag-section">
629
+ <div className="rag-metrics">
630
+ <div className="metric-item">
631
+ <Hash size={14} />
632
+ <span className="metric-label">Model</span>
633
+ <span className="metric-value">{ragMetadata?.model || 'unknown'}</span>
634
+ </div>
635
+ <div className="metric-item">
636
+ <Hash size={14} />
637
+ <span className="metric-label">Tokens</span>
638
+ <span className="metric-value">{ragMetadata?.tokens ?? '—'}</span>
639
+ </div>
640
+ </div>
641
+ </div>
642
+
643
+ {hasDocuments && documentChunks.length > 0 && (
644
+ <div className="rag-documents-section">
645
+ <div className="rag-section-title">
646
+ <FileText size={12} />
647
+ Referenced Sources
648
+ </div>
649
+
650
+ {documentChunks.map((chunk, index) => (
651
+ <div key={index} className="rag-document-item">
652
+ <div className="rag-document-header">
653
+ <span className="rag-filename">
654
+ {chunk.metadata?.filename || 'Unknown file'}
655
+ </span>
656
+ <span className="rag-relevance">
657
+ <Target size={10} />
658
+ {Math.round((chunk.relevance_score || 0) * 100)}%
659
+ </span>
660
+ </div>
661
+
662
+ {chunk.text && (
663
+ <RagChunkPreview text={chunk.text} />
664
+ )}
665
+ </div>
666
+ ))}
667
+ </div>
668
+ )}
669
+ </div>
670
+ </div>
671
+ );
672
+ };
frontend/src/components/ModelStatusModal.js ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import { Activity, RefreshCw, X } from 'lucide-react';
4
+
5
+ const overlay = {
6
+ position: 'fixed',
7
+ inset: 0,
8
+ background: 'rgba(0,0,0,0.5)',
9
+ display: 'flex',
10
+ alignItems: 'center',
11
+ justifyContent: 'center',
12
+ zIndex: 1100,
13
+ };
14
+
15
+ const modal = {
16
+ background: 'var(--bg-primary, #fff)',
17
+ borderRadius: 16,
18
+ width: 560,
19
+ maxWidth: '95vw',
20
+ maxHeight: '85vh',
21
+ overflow: 'hidden',
22
+ boxShadow: 'var(--shadow-xl, 0 20px 40px rgba(0,0,0,0.2))',
23
+ display: 'flex',
24
+ flexDirection: 'column',
25
+ };
26
+
27
+ const statusColor = {
28
+ online: '#059669',
29
+ unavailable: '#b45309',
30
+ error: '#dc2626',
31
+ };
32
+
33
+ /**
34
+ * Settings → Model Status modal. Calls backend /models/status (probes).
35
+ * Secrets never leave the server.
36
+ */
37
+ const ModelStatusModal = ({ onClose, onStatusLoaded }) => {
38
+ const [payload, setPayload] = useState(null);
39
+ const [loading, setLoading] = useState(true);
40
+ const [error, setError] = useState(null);
41
+
42
+ const load = useCallback(async (refresh = false) => {
43
+ setLoading(true);
44
+ setError(null);
45
+ try {
46
+ const url = `${process.env.REACT_APP_API_URL || ''}/models/status${refresh ? '?refresh=true' : ''}`;
47
+ const resp = await fetch(url);
48
+ if (!resp.ok) throw new Error(`Status request failed (${resp.status})`);
49
+ const data = await resp.json();
50
+ setPayload(data);
51
+ if (typeof onStatusLoaded === 'function') onStatusLoaded(data);
52
+ } catch (e) {
53
+ setError(e.message || 'Failed to load model status');
54
+ // Fail open for the picker: parent keeps unfiltered list
55
+ if (typeof onStatusLoaded === 'function') {
56
+ onStatusLoaded({ check_failed: true, online_providers: null, models: [] });
57
+ }
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }, [onStatusLoaded]);
62
+
63
+ useEffect(() => {
64
+ load(false);
65
+ }, [load]);
66
+
67
+ const models = payload?.models || [];
68
+
69
+ return ReactDOM.createPortal(
70
+ <div
71
+ style={overlay}
72
+ onMouseDown={(e) => {
73
+ if (e.target === e.currentTarget) onClose();
74
+ }}
75
+ role="dialog"
76
+ aria-modal="true"
77
+ aria-label="Model Status"
78
+ >
79
+ <div style={modal}>
80
+ <div
81
+ style={{
82
+ display: 'flex',
83
+ justifyContent: 'space-between',
84
+ alignItems: 'center',
85
+ padding: '16px 20px',
86
+ borderBottom: '1px solid var(--border-primary, #e2e8f0)',
87
+ }}
88
+ >
89
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
90
+ <Activity size={18} />
91
+ <strong>Model Status</strong>
92
+ </div>
93
+ <div style={{ display: 'flex', gap: 8 }}>
94
+ <button
95
+ type="button"
96
+ onClick={() => load(true)}
97
+ disabled={loading}
98
+ title="Refresh probes"
99
+ style={{
100
+ display: 'inline-flex',
101
+ alignItems: 'center',
102
+ gap: 6,
103
+ padding: '8px 12px',
104
+ borderRadius: 8,
105
+ border: '1px solid var(--border-primary, #e2e8f0)',
106
+ background: 'var(--bg-secondary, #f8fafc)',
107
+ cursor: loading ? 'wait' : 'pointer',
108
+ minHeight: 44,
109
+ }}
110
+ >
111
+ <RefreshCw size={16} className={loading ? 'spinning' : undefined} />
112
+ Refresh
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onClick={onClose}
117
+ aria-label="Close"
118
+ style={{
119
+ border: 'none',
120
+ background: 'transparent',
121
+ cursor: 'pointer',
122
+ padding: 8,
123
+ minHeight: 44,
124
+ minWidth: 44,
125
+ }}
126
+ >
127
+ <X size={18} />
128
+ </button>
129
+ </div>
130
+ </div>
131
+
132
+ <div style={{ padding: 20, overflowY: 'auto', flex: 1 }}>
133
+ {error && (
134
+ <p style={{ color: '#dc2626', marginTop: 0 }}>
135
+ {error}. Provider list left unfiltered (fail open).
136
+ </p>
137
+ )}
138
+ {payload?.check_failed && (
139
+ <p style={{ color: '#b45309' }}>
140
+ Full status check failed. Showing last known results if any; selection list was not
141
+ restricted.
142
+ </p>
143
+ )}
144
+ {payload?.checked_at && (
145
+ <p style={{ color: 'var(--text-secondary, #64748b)', fontSize: 13 }}>
146
+ Checked {new Date(payload.checked_at).toLocaleString()}
147
+ {payload.cached ? ' (cached)' : ''}
148
+ </p>
149
+ )}
150
+ {loading && !payload ? (
151
+ <p>Probing models…</p>
152
+ ) : (
153
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
154
+ {models.map((m) => (
155
+ <li
156
+ key={m.id}
157
+ style={{
158
+ border: '1px solid var(--border-primary, #e2e8f0)',
159
+ borderRadius: 12,
160
+ padding: '12px 14px',
161
+ marginBottom: 10,
162
+ }}
163
+ >
164
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
165
+ <div>
166
+ <strong>{m.name}</strong>
167
+ <div style={{ fontSize: 13, color: 'var(--text-secondary, #64748b)' }}>
168
+ {m.model || m.provider}
169
+ {!m.selectable ? ' · fallback only' : ''}
170
+ </div>
171
+ </div>
172
+ <span
173
+ style={{
174
+ color: statusColor[m.status] || '#64748b',
175
+ fontWeight: 600,
176
+ textTransform: 'capitalize',
177
+ whiteSpace: 'nowrap',
178
+ }}
179
+ >
180
+ {m.status}
181
+ </span>
182
+ </div>
183
+ {m.status === 'error' && m.error && (
184
+ <pre
185
+ style={{
186
+ margin: '8px 0 0',
187
+ whiteSpace: 'pre-wrap',
188
+ wordBreak: 'break-word',
189
+ fontSize: 12,
190
+ color: '#991b1b',
191
+ background: '#fef2f2',
192
+ padding: 8,
193
+ borderRadius: 8,
194
+ }}
195
+ >
196
+ {m.error}
197
+ </pre>
198
+ )}
199
+ {typeof m.latency_ms === 'number' && (
200
+ <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 6 }}>
201
+ {m.latency_ms} ms
202
+ </div>
203
+ )}
204
+ </li>
205
+ ))}
206
+ </ul>
207
+ )}
208
+ </div>
209
+ </div>
210
+ </div>,
211
+ document.body
212
+ );
213
+ };
214
+
215
+ export default ModelStatusModal;