NeonClary Cursor commited on
Commit
2b2d644
·
0 Parent(s):

Deploy Cybersecurity Panel to Hugging Face Space (flat tree for LFS).

Browse files

Serve SPA at / — move API heartbeat to /health so Starlette mounts are not shadowed.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Git LFS Details

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

Git LFS Details

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

Git LFS Details

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

Git LFS Details

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