NeonClary commited on
Commit
ffcad5d
·
verified ·
1 Parent(s): 9fbe4a1

Deploy brand mark, logo palette, goal-aware starters, and carousel fit.

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 -190
  2. .dev-tools/verify-carousel.mjs +342 -0
  3. .dev-tools/verify-chat-theme.mjs +167 -167
  4. .dev-tools/verify-input-chrome-layout.mjs +247 -247
  5. .dev-tools/verify-qa-smoke.mjs +163 -163
  6. .dev-tools/verify-suggestions-toggle.mjs +253 -253
  7. .dev-tools/verify-welcome-fit.mjs +186 -186
  8. .dockerignore +19 -19
  9. .gitattributes +1 -1
  10. .github/workflows/propose_release.yml +29 -29
  11. .github/workflows/publish_release.yml +31 -31
  12. .github/workflows/publish_test_build.yml +24 -24
  13. .github/workflows/unit_tests.yml +37 -37
  14. .gitignore +26 -26
  15. CHANGELOG.md +74 -74
  16. Dockerfile +77 -77
  17. Dockerfile.dev +32 -32
  18. PLAN.md +228 -228
  19. README.md +111 -111
  20. cybersecurity_config.yaml +322 -321
  21. docker-compose.yml +117 -117
  22. frontend/.gitignore +23 -23
  23. frontend/package-lock.json +0 -0
  24. frontend/package.json +48 -48
  25. frontend/public/brand/cybersecurity-advisor-mark.png +3 -0
  26. frontend/public/favicon.ico +2 -2
  27. frontend/public/favicon.png +3 -0
  28. frontend/public/index.html +44 -43
  29. frontend/public/logo192.png +2 -2
  30. frontend/public/logo512.png +2 -2
  31. frontend/public/manifest.json +30 -25
  32. frontend/public/robots.txt +3 -3
  33. frontend/src/App.js +181 -181
  34. frontend/src/components/AboutYouModal.js +637 -637
  35. frontend/src/components/AdvisorCard.js +47 -47
  36. frontend/src/components/AdvisorCarousel.js +280 -261
  37. frontend/src/components/AdvisorStatusDropdown.js +474 -474
  38. frontend/src/components/AppHeader.js +111 -106
  39. frontend/src/components/AvatarPickerModal.js +75 -75
  40. frontend/src/components/ChatInput.js +45 -45
  41. frontend/src/components/ClearDataModal.js +196 -196
  42. frontend/src/components/ConfirmDialog.js +60 -60
  43. frontend/src/components/CopyrightNotice.js +38 -38
  44. frontend/src/components/EnhancedChatInput.js +412 -412
  45. frontend/src/components/ExportButton.js +268 -268
  46. frontend/src/components/FileUpload.js +243 -243
  47. frontend/src/components/GuestIntakeModal.js +200 -200
  48. frontend/src/components/IntakePanel.js +88 -88
  49. frontend/src/components/Login.js +244 -277
  50. frontend/src/components/MessageBubble.js +672 -672
.dev-tools/header-check.mjs CHANGED
@@ -1,190 +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');
 
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-carousel.mjs ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Verify advisor carousel: full cards only, arrows inside the message pane.
3
+ * Usage: node .dev-tools/verify-carousel.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
+ import { pathToFileURL } from 'node:url';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ const ROOT = process.cwd();
12
+ const FRONTEND = 'http://localhost:3000';
13
+ const BACKEND = 'http://localhost:8000';
14
+ const OUT = path.join('.dev-tools', 'verify-out');
15
+ fs.mkdirSync(OUT, { recursive: true });
16
+
17
+ function loadLayoutModule() {
18
+ const srcPath = path.join(ROOT, 'frontend', 'src', 'utils', 'advisorCarouselLayout.js');
19
+ const src = fs.readFileSync(srcPath, 'utf8')
20
+ .replaceAll('export const', 'const')
21
+ .replace('export function computeLayout', 'function computeLayout');
22
+ const tmp = path.join(OUT, '_advisorCarouselLayout.mjs');
23
+ fs.writeFileSync(tmp, `${src}\nexport { computeLayout, GAP, CONTROLS_W, PREFERRED_SLIDE, MIN_SLIDE, MAX_VISIBLE };\n`);
24
+ return import(pathToFileURL(tmp).href);
25
+ }
26
+
27
+ function extractCarouselCss() {
28
+ const css = fs.readFileSync(path.join(ROOT, 'frontend', 'src', 'styles', 'ChatPage.css'), 'utf8');
29
+ const start = css.indexOf('/* Advisor carousel:');
30
+ const end = css.indexOf('.single-response-wide');
31
+ if (start < 0 || end < 0) throw new Error('Could not extract carousel CSS');
32
+ return css.slice(start, end);
33
+ }
34
+
35
+ const { computeLayout, GAP, CONTROLS_W } = await loadLayoutModule();
36
+
37
+ const jest = spawnSync(
38
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
39
+ ['react-scripts', 'test', '--watchAll=false', '--testPathPattern=advisorCarouselLayout'],
40
+ {
41
+ cwd: path.join(ROOT, 'frontend'),
42
+ env: { ...process.env, CI: 'true' },
43
+ encoding: 'utf8',
44
+ },
45
+ );
46
+ if (jest.status !== 0) {
47
+ console.error(jest.stdout || '');
48
+ console.error(jest.stderr || '');
49
+ console.error('FAIL jest advisorCarouselLayout');
50
+ process.exit(1);
51
+ }
52
+ console.log('PASS jest advisorCarouselLayout');
53
+
54
+ const cases = [
55
+ { name: 'wide-1600', width: 1600, count: 3 },
56
+ { name: 'pane-940', width: 940, count: 3 },
57
+ { name: 'almost-3', width: 1231, count: 3 },
58
+ { name: 'overlay-3', width: 1242, count: 4 },
59
+ { name: 'min-2', width: 700, count: 3 },
60
+ { name: 'narrow-420', width: 388, count: 3 },
61
+ ];
62
+
63
+ const mathResults = cases.map((c) => {
64
+ const L = computeLayout(c.width, c.count);
65
+ const reserved = L.controlsMode === 'side' ? CONTROLS_W : 0;
66
+ const used = L.cardsWidth + reserved;
67
+ const ok = used <= c.width && L.visible >= 1 && L.visible <= 3;
68
+ const wantsArrows = c.count > L.visible;
69
+ const arrowsOk = wantsArrows ? L.controlsMode !== 'none' : L.controlsMode === 'none';
70
+ return { ...c, ...L, used, reserved, ok, arrowsOk, wantsArrows };
71
+ });
72
+
73
+ if (mathResults.some((r) => !r.ok || !r.arrowsOk)) {
74
+ console.error(JSON.stringify(mathResults, null, 2));
75
+ console.error('FAIL layout math');
76
+ process.exit(1);
77
+ }
78
+
79
+ const carouselCss = extractCarouselCss();
80
+ const browser = await chromium.launch({ headless: true, channel: 'chrome' }).catch(
81
+ () => chromium.launch({ headless: true }),
82
+ );
83
+
84
+ async function measureFixture(page, width, count, theme) {
85
+ const L = computeLayout(width, count);
86
+ const reserved = L.controlsMode === 'side' ? CONTROLS_W : 0;
87
+ const viewportWidth = L.visible * L.slideW + GAP * (L.visible - 1);
88
+ const showControls = L.controlsMode !== 'none';
89
+ await page.setContent(`<!doctype html>
90
+ <html data-theme="${theme}">
91
+ <head>
92
+ <style>
93
+ :root {
94
+ --border-primary: #d6d3cd;
95
+ --card-bg: #ffffff;
96
+ --bg-primary: #ffffff;
97
+ --text-primary: #1f2937;
98
+ --accent-primary: #2563eb;
99
+ --accent-fill: #2563eb;
100
+ --accent-on-accent: #ffffff;
101
+ --shadow-md: 0 4px 10px -2px rgba(40, 30, 10, 0.12);
102
+ --shadow-lg: 0 8px 18px -4px rgba(40, 30, 10, 0.16);
103
+ }
104
+ [data-theme="dark"] {
105
+ --border-primary: #3f3f46;
106
+ --card-bg: #1f1f23;
107
+ --bg-primary: #18181b;
108
+ --text-primary: #f4f4f5;
109
+ --accent-primary: #60a5fa;
110
+ --accent-fill: #3b82f6;
111
+ --shadow-md: 0 4px 10px -2px rgba(0, 0, 0, 0.4);
112
+ }
113
+ * { box-sizing: border-box; }
114
+ body { margin: 0; background: ${theme === 'dark' ? '#18181b' : '#f7f4ee'}; }
115
+ .messages-scroll {
116
+ width: ${width + 40}px;
117
+ padding: 20px;
118
+ overflow-x: hidden;
119
+ overflow-y: auto;
120
+ height: 420px;
121
+ }
122
+ .card-mock {
123
+ height: 220px;
124
+ border-radius: 12px;
125
+ border: 1px solid var(--border-primary);
126
+ background: ${theme === 'dark' ? '#2a2430' : '#fde8e8'};
127
+ }
128
+ ${carouselCss}
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <div class="messages-scroll" id="pane">
133
+ <div class="advisor-carousel carousel-mode controls-${showControls ? L.controlsMode : 'none'}"
134
+ style="width:100%" id="shell">
135
+ <div class="carousel-stage" id="stage">
136
+ <div class="carousel-viewport" id="viewport" style="width:${viewportWidth}px">
137
+ <div class="carousel-track" style="gap:${GAP}px">
138
+ ${Array.from({ length: count }, (_, i) => `
139
+ <div class="carousel-slide" style="width:${L.slideW}px;flex:0 0 ${L.slideW}px">
140
+ <div class="card-mock">card ${i + 1}</div>
141
+ </div>`).join('')}
142
+ </div>
143
+ </div>
144
+ ${showControls ? `
145
+ <div class="carousel-controls" id="controls">
146
+ <div class="carousel-controls-inner">
147
+ <button class="carousel-arrow" type="button" aria-label="Previous advisor">‹</button>
148
+ <button class="carousel-arrow" type="button" aria-label="Next advisor">›</button>
149
+ </div>
150
+ </div>` : ''}
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </body>
155
+ </html>`);
156
+
157
+ return page.evaluate(() => {
158
+ const pane = document.getElementById('pane');
159
+ const shell = document.getElementById('shell');
160
+ const viewport = document.getElementById('viewport');
161
+ const slides = [...document.querySelectorAll('.carousel-slide')];
162
+ const arrows = [...document.querySelectorAll('.carousel-arrow')];
163
+ const paneR = pane.getBoundingClientRect();
164
+ const shellR = shell.getBoundingClientRect();
165
+ const vpR = viewport.getBoundingClientRect();
166
+ const visibleSlides = slides.filter((el) => {
167
+ const r = el.getBoundingClientRect();
168
+ return r.left < vpR.right - 2 && r.right > vpR.left + 2;
169
+ });
170
+ const partial = visibleSlides.filter((el) => {
171
+ const r = el.getBoundingClientRect();
172
+ const vis = Math.min(r.right, vpR.right) - Math.max(r.left, vpR.left);
173
+ return vis < r.width - 2;
174
+ });
175
+ const arrowsClipped = arrows.filter((el) => {
176
+ const r = el.getBoundingClientRect();
177
+ return r.right > paneR.right + 1 || r.left < paneR.left - 1
178
+ || r.right > shellR.right + 1 || r.width < 8;
179
+ });
180
+ return {
181
+ paneW: Math.round(paneR.width),
182
+ shellW: Math.round(shellR.width),
183
+ vpW: Math.round(vpR.width),
184
+ slideCount: slides.length,
185
+ visibleSlideCount: visibleSlides.length,
186
+ partialCount: partial.length,
187
+ arrowCount: arrows.length,
188
+ arrowsClipped: arrowsClipped.length,
189
+ arrowRights: arrows.map((el) => Math.round(el.getBoundingClientRect().right)),
190
+ paneRight: Math.round(paneR.right),
191
+ };
192
+ });
193
+ }
194
+
195
+ const page = await browser.newPage();
196
+ const fixture = [];
197
+ for (const c of cases) {
198
+ for (const theme of ['light', 'dark']) {
199
+ const m = await measureFixture(page, c.width, c.count, theme);
200
+ const L = computeLayout(c.width, c.count);
201
+ const failPartial = m.partialCount > 0;
202
+ const failArrows = (c.count > L.visible) && (m.arrowCount < 2 || m.arrowsClipped > 0);
203
+ const failVisible = m.visibleSlideCount !== L.visible;
204
+ fixture.push({
205
+ name: `${c.name}-${theme}`,
206
+ layout: L,
207
+ metrics: m,
208
+ pass: !failPartial && !failArrows && !failVisible,
209
+ });
210
+ await page.screenshot({
211
+ path: path.join(OUT, `carousel-${c.name}-${theme}.png`),
212
+ });
213
+ }
214
+ }
215
+
216
+ const fixtureFails = fixture.filter((f) => !f.pass);
217
+ if (fixtureFails.length) {
218
+ console.error(JSON.stringify(fixtureFails, null, 2));
219
+ await browser.close();
220
+ console.error('FAIL carousel fixture overflow');
221
+ process.exit(1);
222
+ }
223
+ console.log('PASS carousel fixtures');
224
+
225
+ let live = { skipped: true };
226
+ try {
227
+ const health = await fetch(FRONTEND, { signal: AbortSignal.timeout(2500) });
228
+ if (health.ok) {
229
+ const guestRes = await fetch(`${BACKEND}/auth/guest`, {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({ choice: 'personal', free_text: null }),
233
+ });
234
+ if (guestRes.ok) {
235
+ const guest = await guestRes.json();
236
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
237
+ await ctx.addInitScript(([token, user]) => {
238
+ localStorage.setItem('authToken', token);
239
+ localStorage.setItem('user', JSON.stringify(user));
240
+ localStorage.setItem('theme', 'light');
241
+ }, [guest.access_token, guest.user]);
242
+ const livePage = await ctx.newPage();
243
+ await livePage.goto(FRONTEND, { waitUntil: 'domcontentloaded' });
244
+ await livePage.waitForSelector('.welcome-state, .floating-input-area', { timeout: 30000 });
245
+ await livePage.waitForTimeout(800);
246
+ const skip = livePage.locator('button:has-text("Skip tour"), button:has-text("Skip"), button:has-text("Maybe later")').first();
247
+ if (await skip.isVisible({ timeout: 800 }).catch(() => false)) {
248
+ await skip.click({ force: true }).catch(() => {});
249
+ }
250
+ const newChat = livePage.locator('button:has-text("New Chat"), button:has-text("New chat")').first();
251
+ if (await newChat.isVisible({ timeout: 1200 }).catch(() => false)) {
252
+ await newChat.click();
253
+ await livePage.waitForTimeout(400);
254
+ }
255
+ const ta = livePage.locator('textarea, [contenteditable="true"]').first();
256
+ await ta.click({ force: true });
257
+ await ta.fill('What should a household do first to stay safer online?');
258
+ await livePage.keyboard.press('Enter');
259
+ const carousel = livePage.locator('.advisor-carousel');
260
+ await carousel.waitFor({ timeout: 120000 });
261
+ await livePage.waitForTimeout(1200);
262
+ await livePage.screenshot({ path: path.join(OUT, 'carousel-live-1280.png') });
263
+
264
+ const checkLive = async (label, vw) => {
265
+ await livePage.setViewportSize({ width: vw, height: 800 });
266
+ await livePage.waitForTimeout(500);
267
+ const m = await livePage.evaluate(() => {
268
+ const shell = document.querySelector('.advisor-carousel');
269
+ const pane = document.querySelector('.messages-scroll');
270
+ const vp = document.querySelector('.carousel-viewport');
271
+ const slides = [...document.querySelectorAll('.advisor-carousel .carousel-slide')];
272
+ const arrows = [...document.querySelectorAll('.carousel-arrow')];
273
+ if (!shell || !vp || !pane) return { missing: true };
274
+ const shellR = shell.getBoundingClientRect();
275
+ const paneR = pane.getBoundingClientRect();
276
+ const vpR = vp.getBoundingClientRect();
277
+ const visible = slides.filter((el) => {
278
+ const r = el.getBoundingClientRect();
279
+ return r.left < vpR.right - 2 && r.right > vpR.left + 2 && r.width > 40;
280
+ });
281
+ const partial = visible.filter((el) => {
282
+ const r = el.getBoundingClientRect();
283
+ const vis = Math.min(r.right, vpR.right) - Math.max(r.left, vpR.left);
284
+ return vis < r.width - 2;
285
+ });
286
+ const clippedArrows = arrows.filter((el) => {
287
+ const r = el.getBoundingClientRect();
288
+ return r.right > paneR.right + 1 || r.left < paneR.left - 1 || r.width < 8;
289
+ });
290
+ return {
291
+ visible: Number(shell.dataset.visible || visible.length),
292
+ controls: shell.dataset.controls,
293
+ slideCount: slides.length,
294
+ visibleSlideCount: visible.length,
295
+ partialCount: partial.length,
296
+ arrowCount: arrows.length,
297
+ arrowsClipped: clippedArrows.length,
298
+ shellW: Math.round(shellR.width),
299
+ vpW: Math.round(vpR.width),
300
+ };
301
+ });
302
+ await livePage.screenshot({ path: path.join(OUT, `carousel-live-${label}.png`) });
303
+ return m;
304
+ };
305
+
306
+ live = {
307
+ skipped: false,
308
+ w1280: await checkLive('1280', 1280),
309
+ w1600: await checkLive('1600', 1600),
310
+ w900: await checkLive('900', 900),
311
+ };
312
+ await ctx.close();
313
+ }
314
+ }
315
+ } catch (err) {
316
+ live = { skipped: true, error: String(err).slice(0, 200) };
317
+ }
318
+
319
+ await browser.close();
320
+
321
+ const liveFails = [];
322
+ if (!live.skipped) {
323
+ for (const [label, m] of [['1280', live.w1280], ['1600', live.w1600], ['900', live.w900]]) {
324
+ if (!m || m.missing) liveFails.push(`${label} missing carousel`);
325
+ else {
326
+ if (m.partialCount > 0) liveFails.push(`${label} partial cards`);
327
+ if (m.slideCount > m.visibleSlideCount && (m.arrowCount < 2 || m.arrowsClipped > 0)) {
328
+ liveFails.push(`${label} arrows missing/clipped`);
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ const summary = { mathResults, fixture, live, liveFails };
335
+ fs.writeFileSync(path.join(OUT, 'carousel-summary.json'), JSON.stringify(summary, null, 2));
336
+ console.log(JSON.stringify({ math: mathResults.map((r) => ({ name: r.name, visible: r.visible, mode: r.controlsMode, used: r.used })), live, liveFails }, null, 2));
337
+
338
+ if (liveFails.length) {
339
+ console.error('FAIL live carousel layout');
340
+ process.exit(1);
341
+ }
342
+ console.log('Carousel verify finished');
.dev-tools/verify-chat-theme.mjs CHANGED
@@ -1,167 +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
- }
 
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() === '#0B7A8A',
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 CHANGED
@@ -1,247 +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);
 
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 CHANGED
@@ -1,163 +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');
 
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 CHANGED
@@ -1,253 +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);
 
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 CHANGED
@@ -1,186 +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);
 
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 CHANGED
@@ -1,20 +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
 
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 CHANGED
@@ -3,4 +3,4 @@
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
 
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 CHANGED
@@ -1,29 +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
 
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 CHANGED
@@ -1,31 +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
 
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 CHANGED
@@ -1,24 +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
 
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 CHANGED
@@ -1,37 +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/
 
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 CHANGED
@@ -1,27 +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/
 
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 CHANGED
@@ -1,74 +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)*
 
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 CHANGED
@@ -1,77 +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"]
 
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 CHANGED
@@ -1,32 +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"]
 
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 CHANGED
@@ -1,228 +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.
 
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 CHANGED
@@ -1,111 +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.
 
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 CHANGED
@@ -1,321 +1,322 @@
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"
 
 
1
+ # ============================================================================
2
+ # Cybersecurity Advisor — Application Configuration
3
+ # ============================================================================
4
+
5
+ app:
6
+ title: "Cybersecurity Advisor"
7
+ subtitle: "AI-Powered Security Guidance"
8
+ primary_color: "#0B7A8A"
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: "#0B7A8A"
140
+ bg_color: "#DCEEF1"
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: "#8B3DB8"
181
+ bg_color: "#F5E8FB"
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: "#0B7A8A"
190
+ bg_color: "#DCEEF1"
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
+ - If the user profile, conversation, or summaries name a goal or project, use it. When they say "my goal" or "custom security goal", name that goal in Thought and make every What-to-do bullet specific to it. Do not give generic phases as if you had no context.
212
+
213
+ **Formatting (Compact Markdown v1):**
214
+ - Use GitHub-Flavored Markdown.
215
+ - Output exactly three sections in this order:
216
+ - `### Thought` — complete reasoning or context in 1–2 finished sentences (never cut mid-sentence into the next section; never use … or ...).
217
+ - `### 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).
218
+ - `### 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.
219
+ - Use `###` for headings, `-` for bullets (no unicode bullets), keep number text on the same line (e.g., `1. Do X`).
220
+ - Insert one blank line between blocks.
221
+ - Never put actions under Thought, and never put reasoning bullets under What to do.
222
+ - Never truncate with ellipsis; finish every sentence and bullet.
223
+
224
+ personas_dir: "personas/cyber_advisors"
225
+
226
+ orchestrator:
227
+ min_words_without_keywords: 6
228
+ conversation_history_token_threshold: 4000
229
+ required_advisor: "jerry_huaute"
230
+ triage_advisor: "incident_responder"
231
+ followup_count: 3
232
+ specific_keywords:
233
+ - "security"
234
+ - "cyber"
235
+ - "threat"
236
+ - "vulnerability"
237
+ - "CVE"
238
+ - "malware"
239
+ - "ransomware"
240
+ - "phishing"
241
+ - "IAM"
242
+ - "MFA"
243
+ - "SIEM"
244
+ - "SOC"
245
+ - "pentest"
246
+ - "compliance"
247
+ - "NIST"
248
+ - "ISO"
249
+ - "incident"
250
+ - "forensics"
251
+ - "firewall"
252
+ - "encryption"
253
+ - "zero trust"
254
+ - "cloud"
255
+ - "AWS"
256
+ - "Azure"
257
+ - "CISSP"
258
+ - "audit"
259
+
260
+ clarification_questions:
261
+ - "What specific security topic would you like guidance on?"
262
+ - "Are you focused on prevention, detection, response, compliance, or career growth?"
263
+ - "What is your role and the system or environment you're asking about?"
264
+ - "What's the most urgent risk or decision you're facing right now?"
265
+
266
+ clarification_suggestions:
267
+ - "Ask about threat modeling or hardening a workload"
268
+ - "Get help with incident response or forensics steps"
269
+ - "Request compliance or audit preparation guidance"
270
+ - "Upload a policy, architecture diagram, or log excerpt for review"
271
+
272
+ auth:
273
+ algorithm: "HS256"
274
+ token_expiry_minutes: 43200
275
+
276
+ mongodb:
277
+ database_name: "cybersecurity_advisor"
278
+
279
+ llm:
280
+ provider: "vllm"
281
+ gemini:
282
+ model: "gemini-2.5-flash"
283
+ ollama:
284
+ model: "llama3.2:1b"
285
+ vllm:
286
+ api_url: "https://4090-x1-3.neonaiservices2.com/vllm0"
287
+ api_key: ""
288
+ # Leave empty for Bearer VLLM_API_KEY auth (current Neon 4090). Set only
289
+ # when the endpoint requires HTTP Basic (VLLM_API_USERNAME / HANA_*).
290
+ api_username: ""
291
+ model_id: "BrainForge/Security@2026.05.13"
292
+ neon_persona_orchestrator: "vanilla"
293
+ neon_persona_advisors: "CybersecurityExpert"
294
+ openai:
295
+ api_key: ""
296
+ model: "gpt-5.4"
297
+ orchestrator_reasoning_effort: "low"
298
+ persona_reasoning_effort: "none"
299
+ resilient:
300
+ race_timeout_seconds: 60
301
+
302
+ rag:
303
+ embedding_model: "all-MiniLM-L6-v2"
304
+ chroma_collection: "cybersecurity_advisor_documents"
305
+
306
+ tools:
307
+ current_datetime:
308
+ enabled: true
309
+ default_timezone: "America/Los_Angeles"
310
+
311
+ user_knowledge:
312
+ short_summary_max_tokens: 150
313
+ long_summary_max_tokens: 600
314
+ small_model_context_budget: 4096
315
+ extract_on_every_message: true
316
+ small_context_providers:
317
+ - vllm
318
+ - ollama
319
+
320
+ voice:
321
+ stt_endpoint: "https://whisper.neonaiservices.com"
322
+ tts_endpoint: "https://coqui.neonaiservices.com"
docker-compose.yml CHANGED
@@ -1,117 +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:
 
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 CHANGED
@@ -1,23 +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*
 
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 CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/package.json CHANGED
@@ -1,48 +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
- }
 
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/brand/cybersecurity-advisor-mark.png ADDED

Git LFS Details

  • SHA256: b3213f7947f14f863b7a8b70f411661ce3b8a3230a211657c53a8dd07fd172fd
  • Pointer size: 131 Bytes
  • Size of remote file: 563 kB
frontend/public/favicon.ico CHANGED

Git LFS Details

  • SHA256: 3d10f7da6c603178340081668c4ac5b3ae9743ca9a262ab0fcd312fbb9f48bdd
  • Pointer size: 129 Bytes
  • Size of remote file: 3.87 kB

Git LFS Details

  • SHA256: 1a6256a5076e4837d84f9a1e6df056e92344033a431969bda623760078b24b3c
  • Pointer size: 130 Bytes
  • Size of remote file: 18.1 kB
frontend/public/favicon.png ADDED

Git LFS Details

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

Git LFS Details

  • SHA256: c386396ec70db3608075b5fbfaac4ab1ccaa86ba05a68ab393ec551eb66c3e00
  • Pointer size: 129 Bytes
  • Size of remote file: 5.35 kB

Git LFS Details

  • SHA256: 4b7937ea16e7a6b5c895646e418c3c8d93f9046ef8fbd1b25027719c4a1948d1
  • Pointer size: 130 Bytes
  • Size of remote file: 62.8 kB
frontend/public/logo512.png CHANGED

Git LFS Details

  • SHA256: 9ea4f4da7050c0cc408926f6a39c253624e9babb1d43c7977cd821445a60b461
  • Pointer size: 129 Bytes
  • Size of remote file: 9.66 kB

Git LFS Details

  • SHA256: 69a039d8222ca04d12eb761f9b0ec85a861ea735b97417bc30cf51eb5d36a0b7
  • Pointer size: 131 Bytes
  • Size of remote file: 384 kB
frontend/public/manifest.json CHANGED
@@ -1,25 +1,30 @@
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
- }
 
 
 
 
 
 
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": "favicon.png",
12
+ "type": "image/png",
13
+ "sizes": "32x32"
14
+ },
15
+ {
16
+ "src": "logo192.png",
17
+ "type": "image/png",
18
+ "sizes": "192x192"
19
+ },
20
+ {
21
+ "src": "logo512.png",
22
+ "type": "image/png",
23
+ "sizes": "512x512"
24
+ }
25
+ ],
26
+ "start_url": ".",
27
+ "display": "standalone",
28
+ "theme_color": "#000000",
29
+ "background_color": "#ffffff"
30
+ }
frontend/public/robots.txt CHANGED
@@ -1,3 +1,3 @@
1
- # https://www.robotstxt.org/robotstxt.html
2
- User-agent: *
3
- Disallow:
 
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
frontend/src/App.js CHANGED
@@ -1,181 +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;
 
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/components/AboutYouModal.js CHANGED
@@ -1,637 +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;
 
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-fill, var(--accent-primary))',
63
+ color: 'var(--accent-on-accent, #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 CHANGED
@@ -1,47 +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;
 
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 CHANGED
@@ -1,261 +1,280 @@
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;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import MessageBubble from './MessageBubble';
4
+ import { computeLayout, GAP, PREFERRED_SLIDE } from '../utils/advisorCarouselLayout';
5
+
6
+ const findScrollParent = (node) => {
7
+ let el = node?.parentElement;
8
+ while (el) {
9
+ const { overflowY } = window.getComputedStyle(el);
10
+ if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
11
+ return el;
12
+ }
13
+ el = el.parentElement;
14
+ }
15
+ return null;
16
+ };
17
+
18
+ /**
19
+ * Show as many advisor answers as fit side-by-side; carousel when they don't.
20
+ * Messages should already be ordered most-relevant-first (orchestrator rank).
21
+ *
22
+ * Controls sit just to the right of the visible answer(s) when that fits.
23
+ * If not, they overlay the last visible card (or sit below) so they stay on-screen.
24
+ * They stay vertically centered in the visible chat pane.
25
+ */
26
+ const AdvisorCarousel = ({ messages = [], onReply, onExpand, onClick, onSearchReferences, userAvatarId, userAvatarOptions }) => {
27
+ const [activeIndex, setActiveIndex] = useState(0);
28
+ const [layout, setLayout] = useState({
29
+ visible: 1,
30
+ slideW: PREFERRED_SLIDE,
31
+ cardsWidth: PREFERRED_SLIDE,
32
+ controlsMode: 'none',
33
+ });
34
+ const shellRef = useRef(null);
35
+ const stageRef = useRef(null);
36
+ const controlsColRef = useRef(null);
37
+ const controlsInnerRef = useRef(null);
38
+
39
+ const messageKey = messages.map((m) => m.id).join('|');
40
+ const visibleCount = Math.min(layout.visible, messages.length || 1);
41
+ const maxIndex = Math.max(0, messages.length - visibleCount);
42
+ const showAll = visibleCount >= messages.length && messages.length > 1;
43
+ const showControls = layout.controlsMode !== 'none' && messages.length > visibleCount;
44
+
45
+ useEffect(() => {
46
+ setActiveIndex(0);
47
+ }, [messageKey]);
48
+
49
+ useEffect(() => {
50
+ setActiveIndex((i) => Math.min(i, maxIndex));
51
+ }, [maxIndex]);
52
+
53
+ const goPrev = useCallback(() => {
54
+ setActiveIndex((i) => Math.max(0, i - 1));
55
+ }, []);
56
+
57
+ const goNext = useCallback(() => {
58
+ setActiveIndex((i) => Math.min(maxIndex, i + 1));
59
+ }, [maxIndex]);
60
+
61
+ const measurePane = useCallback(() => {
62
+ const shell = shellRef.current;
63
+ if (!shell) return;
64
+ const available = Math.floor(shell.clientWidth);
65
+ if (available <= 0) return;
66
+ const next = computeLayout(available, messages.length);
67
+ setLayout((prev) => (
68
+ prev.visible === next.visible
69
+ && prev.slideW === next.slideW
70
+ && prev.controlsMode === next.controlsMode
71
+ ? prev
72
+ : next
73
+ ));
74
+ }, [messages.length]);
75
+
76
+ const updateControlPosition = useCallback(() => {
77
+ const col = controlsColRef.current;
78
+ const inner = controlsInnerRef.current;
79
+ const stage = stageRef.current;
80
+ const shell = shellRef.current;
81
+ if (!col || !inner || !stage) return;
82
+
83
+ if (layout.controlsMode === 'below') {
84
+ inner.style.top = '';
85
+ return;
86
+ }
87
+
88
+ const scrollParent = findScrollParent(stage);
89
+ const colRect = col.getBoundingClientRect();
90
+ const viewRect = scrollParent
91
+ ? scrollParent.getBoundingClientRect()
92
+ : { top: 0, bottom: window.innerHeight };
93
+
94
+ const inputEl = document.querySelector('.floating-input-area');
95
+ const inputTop = inputEl ? inputEl.getBoundingClientRect().top : viewRect.bottom;
96
+ const viewTop = viewRect.top;
97
+ const viewBottom = Math.min(viewRect.bottom, inputTop);
98
+
99
+ const overlapTop = Math.max(colRect.top, viewTop);
100
+ const overlapBottom = Math.min(colRect.bottom, viewBottom);
101
+ const innerH = inner.offsetHeight || 0;
102
+ const colH = col.offsetHeight || 0;
103
+
104
+ if (overlapBottom > overlapTop && innerH > 0 && colH > 0) {
105
+ const mid = (overlapTop + overlapBottom) / 2;
106
+ const maxTop = Math.max(0, colH - innerH);
107
+ let top = Math.max(0, Math.min(maxTop, mid - colRect.top - innerH / 2));
108
+
109
+ if (shell) {
110
+ const shellRect = shell.getBoundingClientRect();
111
+ const innerTopAbs = colRect.top + top;
112
+ if (innerTopAbs < shellRect.top) {
113
+ top = Math.max(0, shellRect.top - colRect.top);
114
+ }
115
+ const innerBottomAbs = colRect.top + top + innerH;
116
+ if (innerBottomAbs > shellRect.bottom) {
117
+ top = Math.max(0, Math.min(maxTop, shellRect.bottom - colRect.top - innerH));
118
+ }
119
+ }
120
+
121
+ inner.style.top = `${Math.round(top)}px`;
122
+ }
123
+ }, [layout.controlsMode]);
124
+
125
+ useLayoutEffect(() => {
126
+ measurePane();
127
+ const shell = shellRef.current;
128
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measurePane) : null;
129
+ if (shell) ro?.observe(shell);
130
+ const scrollParent = shell ? findScrollParent(shell) : null;
131
+ if (scrollParent) ro?.observe(scrollParent);
132
+ const chatArea = shell?.closest('.main-chat-area, .messages-scroll');
133
+ if (chatArea && chatArea !== scrollParent && chatArea !== shell) {
134
+ ro?.observe(chatArea);
135
+ }
136
+ window.addEventListener('resize', measurePane);
137
+ window.visualViewport?.addEventListener('resize', measurePane);
138
+ return () => {
139
+ window.removeEventListener('resize', measurePane);
140
+ window.visualViewport?.removeEventListener('resize', measurePane);
141
+ ro?.disconnect();
142
+ };
143
+ }, [measurePane]);
144
+
145
+ useLayoutEffect(() => {
146
+ if (!showControls) return undefined;
147
+
148
+ updateControlPosition();
149
+ const raf = window.requestAnimationFrame(() => updateControlPosition());
150
+
151
+ const stage = stageRef.current;
152
+ const scrollParent = stage ? findScrollParent(stage) : null;
153
+ const onScrollOrResize = () => updateControlPosition();
154
+
155
+ scrollParent?.addEventListener('scroll', onScrollOrResize, { passive: true });
156
+ window.addEventListener('resize', onScrollOrResize);
157
+
158
+ const ro = typeof ResizeObserver !== 'undefined'
159
+ ? new ResizeObserver(onScrollOrResize)
160
+ : null;
161
+ if (stage) ro?.observe(stage);
162
+ if (scrollParent) ro?.observe(scrollParent);
163
+
164
+ return () => {
165
+ window.cancelAnimationFrame(raf);
166
+ scrollParent?.removeEventListener('scroll', onScrollOrResize);
167
+ window.removeEventListener('resize', onScrollOrResize);
168
+ ro?.disconnect();
169
+ };
170
+ }, [showControls, messages.length, activeIndex, messageKey, visibleCount, updateControlPosition]);
171
+
172
+ if (messages.length === 1) {
173
+ return (
174
+ <div className="single-response-wide">
175
+ <MessageBubble
176
+ message={messages[0]}
177
+ onReply={onReply}
178
+ onExpand={onExpand}
179
+ onClick={onClick}
180
+ onSearchReferences={onSearchReferences}
181
+ showReplyButton={true}
182
+ userAvatarId={userAvatarId}
183
+ userAvatarOptions={userAvatarOptions}
184
+ />
185
+ </div>
186
+ );
187
+ }
188
+
189
+ const viewportWidth = visibleCount * layout.slideW + GAP * (visibleCount - 1);
190
+ const offset = showAll ? 0 : activeIndex * (layout.slideW + GAP);
191
+ const controlsMode = showControls ? layout.controlsMode : 'none';
192
+
193
+ return (
194
+ <div
195
+ className={`advisor-carousel carousel-mode controls-${controlsMode}${showAll ? ' showing-all' : ''}`}
196
+ ref={shellRef}
197
+ data-visible={visibleCount}
198
+ data-controls={controlsMode}
199
+ data-slide-w={layout.slideW}
200
+ >
201
+ <div className="carousel-stage" ref={stageRef}>
202
+ <div
203
+ className="carousel-viewport"
204
+ style={{ width: `${viewportWidth}px` }}
205
+ >
206
+ <div
207
+ className="carousel-track"
208
+ style={{
209
+ gap: `${GAP}px`,
210
+ transform: `translateX(-${offset}px)`,
211
+ }}
212
+ >
213
+ {messages.map((message) => (
214
+ <div
215
+ key={message.id}
216
+ className="carousel-slide"
217
+ style={{ width: `${layout.slideW}px`, flex: `0 0 ${layout.slideW}px` }}
218
+ >
219
+ <MessageBubble
220
+ message={message}
221
+ onReply={onReply}
222
+ onExpand={onExpand}
223
+ onClick={onClick}
224
+ onSearchReferences={onSearchReferences}
225
+ showReplyButton={true}
226
+ inlineAvatar={true}
227
+ userAvatarId={userAvatarId}
228
+ userAvatarOptions={userAvatarOptions}
229
+ />
230
+ </div>
231
+ ))}
232
+ </div>
233
+ </div>
234
+
235
+ {showControls && (
236
+ <div className="carousel-controls" ref={controlsColRef}>
237
+ <div className="carousel-controls-inner" ref={controlsInnerRef}>
238
+ <button
239
+ type="button"
240
+ className="carousel-arrow carousel-prev"
241
+ onClick={goPrev}
242
+ disabled={activeIndex === 0}
243
+ aria-label="Previous advisor"
244
+ >
245
+ <ChevronLeft size={28} strokeWidth={2.4} />
246
+ </button>
247
+ <button
248
+ type="button"
249
+ className="carousel-arrow carousel-next"
250
+ onClick={goNext}
251
+ disabled={activeIndex >= maxIndex}
252
+ aria-label="Next advisor"
253
+ >
254
+ <ChevronRight size={28} strokeWidth={2.4} />
255
+ </button>
256
+ </div>
257
+ </div>
258
+ )}
259
+ </div>
260
+
261
+ {showControls && (
262
+ <div className="carousel-dots" role="tablist" aria-label="Advisor answers">
263
+ {Array.from({ length: maxIndex + 1 }, (_, i) => (
264
+ <button
265
+ key={messages[i].id}
266
+ type="button"
267
+ className={`carousel-dot ${i === activeIndex ? 'active' : ''}`}
268
+ onClick={() => setActiveIndex(i)}
269
+ aria-label={`Show answers starting at ${i + 1}`}
270
+ aria-selected={i === activeIndex}
271
+ role="tab"
272
+ />
273
+ ))}
274
+ </div>
275
+ )}
276
+ </div>
277
+ );
278
+ };
279
+
280
+ export default AdvisorCarousel;
frontend/src/components/AdvisorStatusDropdown.js CHANGED
@@ -1,474 +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;
 
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-fill, var(--accent-primary));
234
+ color: var(--accent-on-accent, 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 CHANGED
@@ -1,106 +1,111 @@
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;
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { 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={config?.app?.title || 'Cybersecurity Advisor'}
57
+ disabled={isOnHome}
58
+ aria-disabled={isOnHome}
59
+ aria-label={config?.app?.title || 'Cybersecurity Advisor'}
60
+ >
61
+ <img
62
+ src="/brand/cybersecurity-advisor-mark.png"
63
+ alt=""
64
+ className="header-brand-mark"
65
+ />
66
+ </button>
67
+ <div className="header-brand">
68
+ <div className="brand-text">
69
+ <h1>{config?.app?.title || 'Advisory'}</h1>
70
+ <p>{config?.app?.subtitle || 'AI-Powered Guidance'}</p>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ {/* Hide the view pill bar on the home page — home is a landing page,
76
+ not part of the chat ↔ canvas surface. */}
77
+ {!isOnHome && (
78
+ <div className="canvas-tabs chat-view-tabs">
79
+ <button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
80
+ <button className={`tab ${isOnJourney ? 'active' : ''}`} onClick={() => goToCanvas('journey')}>Journey</button>
81
+ <button className={`tab ${tabActive('workspace') ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
82
+ <button className={`tab ${tabActive('deliverables') ? 'active' : ''}`} onClick={() => goToCanvas('deliverables')}>Documents</button>
83
+ </div>
84
+ )}
85
+
86
+ {/* Compact mobile dropdown — appears in place of the pill bar at narrow widths */}
87
+ {!isOnHome && (
88
+ <select
89
+ className="canvas-tabs-mobile"
90
+ value={isOnChat ? 'chat' : (isOnJourney ? 'journey' : (canvasSub || 'workspace'))}
91
+ onChange={(e) => {
92
+ const v = e.target.value;
93
+ if (v === 'chat') onNavigateToChat();
94
+ else goToCanvas(v);
95
+ }}
96
+ >
97
+ <option value="chat">Chat</option>
98
+ <option value="journey">Journey</option>
99
+ <option value="workspace">Workspace</option>
100
+ <option value="deliverables">Documents</option>
101
+ </select>
102
+ )}
103
+
104
+ <div className="header-right">
105
+ {children}
106
+ </div>
107
+ </header>
108
+ );
109
+ };
110
+
111
+ export default AppHeader;
frontend/src/components/AvatarPickerModal.js CHANGED
@@ -1,75 +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;
 
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 CHANGED
@@ -1,46 +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;
 
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 CHANGED
@@ -1,196 +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;
 
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: 'var(--accent-fill, var(--accent-primary))', color: 'var(--accent-on-accent, #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 CHANGED
@@ -1,60 +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;
 
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 CHANGED
@@ -1,38 +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;
 
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 CHANGED
@@ -1,412 +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;
 
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 var(--accent-primary)', 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 ? 'var(--accent-fill, var(--accent-primary))' : 'transparent',
344
+ color: !synthesizedMode ? 'var(--accent-on-accent, #fff)' : 'var(--accent-primary)',
345
+ }}
346
+ >
347
+ <Columns3 size={13} />
348
+ Panel
349
+ </button>
350
+ <div style={{ width: 1, background: 'var(--accent-primary)', 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 ? 'var(--accent-fill, var(--accent-primary))' : 'transparent',
361
+ color: synthesizedMode ? 'var(--accent-on-accent, #fff)' : 'var(--accent-primary)',
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 CHANGED
@@ -1,269 +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;
 
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 CHANGED
@@ -1,244 +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;
 
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 CHANGED
@@ -1,200 +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;
 
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, #0B7A8A)' : `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, #0B7A8A)', 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, #0B7A8A)', 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, #0B7A8A)', 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-fill, var(--accent-primary, #0B7A8A))', color: 'var(--accent-on-accent, #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 CHANGED
@@ -1,88 +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;
 
1
+ import React, { useState, useMemo } from 'react';
2
+ import { useAppConfig } from '../contexts/AppConfigContext';
3
+ import SecurityChatBotIcon from './icons/SecurityChatBotIcon';
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
+ <SecurityChatBotIcon size={22} />
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 CHANGED
@@ -1,277 +1,244 @@
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;
 
1
+ import React, { useState } from 'react';
2
+ import { Eye, EyeOff, Mail, Lock, ArrowRight } 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
+ return (
90
+ <div className="login-page">
91
+ <div className="login-content">
92
+ <div className="login-container">
93
+ {/* Header */}
94
+ <div className="login-header">
95
+ <div className="logo-container">
96
+ <img
97
+ src="/brand/cybersecurity-advisor-mark.png"
98
+ alt=""
99
+ className="logo-icon"
100
+ />
101
+ </div>
102
+ <h1 className="login-title">Welcome Back</h1>
103
+ <p className="login-subtitle">
104
+ {config?.login?.subtitle || 'Sign in to continue'}
105
+ </p>
106
+ </div>
107
+
108
+ {/* Main Login Form */}
109
+ <div className="login-form-container">
110
+ <form onSubmit={handleSubmit} className="login-form">
111
+
112
+ {/* Email Field */}
113
+ <div className="form-group">
114
+ <label htmlFor="email" className="form-label">
115
+ Email Address
116
+ </label>
117
+ <div className="input-container">
118
+ <Mail className="input-icon" />
119
+ <input
120
+ type="email"
121
+ id="email"
122
+ name="email"
123
+ value={formData.email}
124
+ onChange={handleInputChange}
125
+ className={`form-input ${errors.email ? 'error' : ''}`}
126
+ placeholder="Enter your email"
127
+ disabled={isLoading}
128
+ />
129
+ </div>
130
+ {errors.email && (
131
+ <span className="error-message">{errors.email}</span>
132
+ )}
133
+ </div>
134
+
135
+ {/* Password Field */}
136
+ <div className="form-group">
137
+ <label htmlFor="password" className="form-label">
138
+ Password
139
+ </label>
140
+ <div className="input-container">
141
+ <Lock className="input-icon" />
142
+ <input
143
+ type={showPassword ? 'text' : 'password'}
144
+ id="password"
145
+ name="password"
146
+ value={formData.password}
147
+ onChange={handleInputChange}
148
+ className={`form-input ${errors.password ? 'error' : ''}`}
149
+ placeholder="Enter your password"
150
+ disabled={isLoading}
151
+ />
152
+ <button
153
+ type="button"
154
+ onClick={() => setShowPassword(!showPassword)}
155
+ className="password-toggle"
156
+ disabled={isLoading}
157
+ >
158
+ {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
159
+ </button>
160
+ </div>
161
+ {errors.password && (
162
+ <span className="error-message">{errors.password}</span>
163
+ )}
164
+ </div>
165
+
166
+ {/* Forgot Password */}
167
+ <div className="form-actions">
168
+ <button type="button" className="forgot-password">
169
+ Forgot your password?
170
+ </button>
171
+ </div>
172
+
173
+ {/* Submit Error */}
174
+ {errors.submit && (
175
+ <div className="submit-error">
176
+ {errors.submit}
177
+ </div>
178
+ )}
179
+
180
+ {/* Submit Button */}
181
+ <button
182
+ type="submit"
183
+ className={`submit-btn ${isLoading ? 'loading' : ''}`}
184
+ disabled={isLoading}
185
+ >
186
+ {isLoading ? (
187
+ <>
188
+ <div className="loading-spinner"></div>
189
+ Signing In...
190
+ </>
191
+ ) : (
192
+ <>
193
+ Sign In
194
+ <ArrowRight size={16} />
195
+ </>
196
+ )}
197
+ </button>
198
+ </form>
199
+
200
+ <div className="divider">
201
+ <span>or</span>
202
+ </div>
203
+
204
+ <div className="social-login">
205
+ <button
206
+ type="button"
207
+ className="guest-explore-btn"
208
+ onClick={onExploreAsGuest}
209
+ disabled={isLoading || !onExploreAsGuest}
210
+ >
211
+ Explore as Guest
212
+ </button>
213
+ </div>
214
+ </div>
215
+
216
+ {/* Footer */}
217
+ <div className="login-footer">
218
+ <p className="login-signup-prompt">
219
+ Don't have an account?{' '}
220
+ <button
221
+ type="button"
222
+ className="link-btn"
223
+ onClick={onNavigateToSignup}
224
+ >
225
+ Sign up here
226
+ </button>
227
+ </p>
228
+ <div className="login-powered-by">
229
+ <a href="https://neon.ai" target="_blank" rel="noopener noreferrer" className="footer-neon-link">
230
+ <img src="/neon-logo.png" alt="" className="footer-neon-logo" />
231
+ Powered by Neon.ai
232
+ </a>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ <footer className="login-page-footer">
238
+ <CopyrightNotice className="login-page-copyright" />
239
+ </footer>
240
+ </div>
241
+ );
242
+ };
243
+
244
+ export default Login;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/MessageBubble.js CHANGED
@@ -1,672 +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
- };
 
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: 'var(--accent-primary)',
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-fill, var(--accent-primary))', color: 'var(--accent-on-accent, #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
+ };