Spaces:
Sleeping
Sleeping
NeonClary Cursor commited on
Commit ·
2b2d644
0
Parent(s):
Deploy Cybersecurity Panel to Hugging Face Space (flat tree for LFS).
Browse filesServe 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
- .dev-tools/header-check.mjs +190 -0
- .dev-tools/verify-chat-theme.mjs +167 -0
- .dockerignore +20 -0
- .gitattributes +6 -0
- .github/workflows/propose_release.yml +29 -0
- .github/workflows/publish_release.yml +31 -0
- .github/workflows/publish_test_build.yml +24 -0
- .github/workflows/unit_tests.yml +37 -0
- .gitignore +27 -0
- CHANGELOG.md +74 -0
- Dockerfile +77 -0
- Dockerfile.dev +32 -0
- PLAN.md +228 -0
- README.md +111 -0
- cybersecurity_config.yaml +321 -0
- docker-compose.yml +117 -0
- frontend/.gitignore +23 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +48 -0
- frontend/public/favicon.ico +3 -0
- frontend/public/index.html +43 -0
- frontend/public/logo192.png +3 -0
- frontend/public/logo512.png +3 -0
- frontend/public/manifest.json +25 -0
- frontend/public/neon-logo.png +3 -0
- frontend/public/robots.txt +3 -0
- frontend/src/App.js +181 -0
- frontend/src/App.test.js +8 -0
- frontend/src/components/AboutYouModal.js +637 -0
- frontend/src/components/AdvisorCard.js +47 -0
- frontend/src/components/AdvisorCarousel.js +109 -0
- frontend/src/components/AdvisorStatusDropdown.js +474 -0
- frontend/src/components/AppHeader.js +106 -0
- frontend/src/components/AvatarPickerModal.js +75 -0
- frontend/src/components/ChatInput.js +46 -0
- frontend/src/components/ClearDataModal.js +196 -0
- frontend/src/components/ConfirmDialog.js +60 -0
- frontend/src/components/CopyrightNotice.js +38 -0
- frontend/src/components/EnhancedChatInput.js +399 -0
- frontend/src/components/ExportButton.js +256 -0
- frontend/src/components/FileUpload.js +244 -0
- frontend/src/components/GuestIntakeModal.js +200 -0
- frontend/src/components/IntakePanel.js +88 -0
- frontend/src/components/Login.js +277 -0
- frontend/src/components/MessageBubble.js +672 -0
- frontend/src/components/ModelStatusModal.js +215 -0
- frontend/src/components/OnboardingChat.js +153 -0
- frontend/src/components/OnboardingTour.js +320 -0
- frontend/src/components/ProfileWalkthrough.js +297 -0
- 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
|
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
|
frontend/public/logo512.png
ADDED
|
Git LFS Details
|
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
|
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'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'm securing my personal digital life</strong>
|
| 118 |
+
<span style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#64748b' }}>
|
| 119 |
+
Passwords, MFA, backups, phishing — sample Journey & 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 & 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'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;
|