NeonClary Cursor commited on
Commit
20fa64f
·
1 Parent(s): f61792d

Adopt FEAT cybersecurity branding, CSS, Canvas/Chat/Sidebar UI, HF Spaces deploy infra, SQLite shim wiring, last-week's advisor cap + thinking-indicator fixes

Browse files
Files changed (32) hide show
  1. Dockerfile +68 -35
  2. README.md +31 -2
  3. docker-compose.yml +29 -38
  4. multi_llm_chatbot_backend/app/api/routes/chat.py +47 -3
  5. multi_llm_chatbot_backend/app/api/routes/chat_sessions.py +7 -77
  6. multi_llm_chatbot_backend/app/config.py +34 -11
  7. multi_llm_chatbot_backend/app/core/canvas_database.py +30 -63
  8. multi_llm_chatbot_backend/app/core/database.py +36 -69
  9. multi_llm_chatbot_backend/app/core/improved_orchestrator.py +98 -36
  10. multi_llm_chatbot_backend/app/core/rag_manager.py +22 -4
  11. multi_llm_chatbot_backend/app/main.py +39 -19
  12. multi_llm_chatbot_backend/app/utils/chroma_client.py +22 -1
  13. multi_llm_chatbot_backend/requirements.txt +5 -2
  14. phd-advisor-frontend/package-lock.json +923 -70
  15. phd-advisor-frontend/package.json +6 -1
  16. phd-advisor-frontend/public/index.html +2 -2
  17. phd-advisor-frontend/public/manifest.json +2 -2
  18. phd-advisor-frontend/src/App.js +8 -13
  19. phd-advisor-frontend/src/components/AdvisorStatusDropdown.js +217 -103
  20. phd-advisor-frontend/src/components/CopyrightNotice.js +1 -1
  21. phd-advisor-frontend/src/components/MessageBubble.js +30 -26
  22. phd-advisor-frontend/src/components/Sidebar.js +246 -120
  23. phd-advisor-frontend/src/components/Signup.js +35 -23
  24. phd-advisor-frontend/src/components/ThinkingIndicator.js +24 -40
  25. phd-advisor-frontend/src/data/userGuide.js +37 -68
  26. phd-advisor-frontend/src/pages/CanvasPage.js +1006 -508
  27. phd-advisor-frontend/src/pages/ChatPage.js +168 -87
  28. phd-advisor-frontend/src/pages/HomePage.js +8 -21
  29. phd-advisor-frontend/src/styles/CanvasPage.css +0 -0
  30. phd-advisor-frontend/src/styles/ChatPage.css +11 -38
  31. phd-advisor-frontend/src/styles/Sidebar.css +1 -70
  32. phd-advisor-frontend/src/styles/components.css +58 -6
Dockerfile CHANGED
@@ -1,45 +1,78 @@
1
- # syntax=docker/dockerfile:1
2
- FROM node:24-bookworm AS base
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- LABEL vendor=neon.ai \
5
- ai.neon.name="CCAI-Demo"
 
6
 
7
- ENV OVOS_CONFIG_BASE_FOLDER=neon
8
- ENV OVOS_CONFIG_FILENAME=neon.yaml
9
- ENV XDG_CONFIG_HOME=/config
10
-
11
- RUN apt-get update && \
12
- apt-get install -y --no-install-recommends \
13
- python3 \
14
- python3-pip \
15
- && rm -rf /var/lib/apt/lists/*
16
-
17
- # ---- Python dependencies (cached unless requirements.txt changes) ----------
18
- WORKDIR /ccai/multi_llm_chatbot_backend
19
- COPY multi_llm_chatbot_backend/requirements.txt ./
20
- RUN --mount=type=cache,target=/root/.cache/pip \
21
- pip install --break-system-packages -r requirements.txt
22
-
23
- # ---- Node dependencies (cached unless package.json changes) ----------------
24
- WORKDIR /ccai/phd-advisor-frontend
25
  COPY phd-advisor-frontend/package.json phd-advisor-frontend/package-lock.json* ./
26
  RUN --mount=type=cache,target=/root/.npm \
27
- npm install
 
 
28
 
29
- # ---- Copy the rest of the source code (this layer changes often) -----------
30
- WORKDIR /ccai
31
- COPY . .
 
32
 
33
- # ---- Backend target --------------------------------------------------------
34
- FROM base AS backend
35
- # Required for /api/voice/transcribe (browser WebM/Opus → WAV for Whisper).
 
 
 
 
36
  RUN apt-get update && \
37
  apt-get install -y --no-install-recommends ffmpeg && \
38
  rm -rf /var/lib/apt/lists/*
39
- WORKDIR /ccai/multi_llm_chatbot_backend
40
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
41
 
42
- # ---- Frontend target -------------------------------------------------------
43
- FROM base AS frontend
44
- WORKDIR /ccai/phd-advisor-frontend
45
- CMD [ "npm", "start" ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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).
14
+ # ---------------------------------------------------------------------------
15
 
16
+ # ---- 1. Frontend build (CRA) ----------------------------------------------
17
+ FROM node:20-bookworm AS frontend-build
18
+ WORKDIR /app/frontend
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  COPY phd-advisor-frontend/package.json phd-advisor-frontend/package-lock.json* ./
21
  RUN --mount=type=cache,target=/root/.npm \
22
+ npm ci
23
+
24
+ COPY phd-advisor-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 phd_config.yaml ./phd_config.yaml
67
+ COPY --chown=user undergrad_config.yaml ./undergrad_config.yaml
68
+ COPY --chown=user personas/ ./personas/
69
+
70
+ # ---- Frontend bundle ------------------------------------------------------
71
+ # main.py mounts $HOME/app/static at "/" so the SPA is served same-origin
72
+ # with the API.
73
+ COPY --chown=user --from=frontend-build /app/frontend/build ./static
74
+
75
+ ENV PYTHONPATH=$HOME/app
76
+
77
+ EXPOSE 7860
78
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,6 +1,35 @@
1
- # PhD Advisory Panel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- An AI-powered academic guidance system that provides personalized advice through specialized advisor personas. Get diverse perspectives on your PhD journey from multiple AI advisors, each bringing unique expertise in methodology, theory, and practical guidance.
4
 
5
  ## Features
6
 
 
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
+ An AI-powered cybersecurity guidance system that provides expert advice through a panel of specialized advisor personas. Ask about threats, compliance, incident response, architecture, and career growth and get diverse perspectives from multiple AI advisors. Built on Neon AI's Collaborative Conversational AI (CCAI) framework.
14
+
15
+ ## Hugging Face Spaces deployment
16
+
17
+ This Space ships as a single Docker image built from the repository root [`Dockerfile`](Dockerfile). The container does three things:
18
+
19
+ 1. Builds the React frontend (CRA) at image-build time with `REACT_APP_API_URL=""` so every `fetch` issues a relative URL.
20
+ 2. Serves the bundled SPA from FastAPI at `/`, with the API exposed on `/api/...`, `/auth/...`, etc., on the same `:7860` origin.
21
+ 3. Persists user data (auth, profiles, chat sessions, onboarding, canvas state) in **SQLite via `aiosqlite`** at `${DATA_DIR}/cybersecurity_panel.db`. Mount a Hugging Face Storage Bucket at `/data` to make the database survive Space rebuilds — there is **no MongoDB**, **no Atlas**, no third-party data plane (the persistence pattern follows [`CU-Student-AIProject-Helper`](https://github.com/NeonClary/CU-Student-AIProject-Helper)).
22
+
23
+ ### Required Space secrets
24
+
25
+ | Secret | Purpose |
26
+ |--------|---------|
27
+ | `JWT_SECRET_KEY` | Signs auth tokens. Set this to a long random string. |
28
+ | `GEMINI_API_KEY` | Powers the default Gemini provider (model: `gemini-2.5-flash`). |
29
+ | `OPENAI_API_KEY` | Optional — only required if you switch to the OpenAI provider. |
30
+ | `VLLM_API_KEY` | Optional — only required if you point the orchestrator/advisors at a Neon vLLM endpoint. |
31
+
32
 
 
33
 
34
  ## Features
35
 
docker-compose.yml CHANGED
@@ -1,48 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  services:
2
- backend:
3
  build:
4
  context: .
5
  dockerfile: Dockerfile
6
- target: backend
7
  ports:
8
- - "8000:8000"
9
- networks:
10
- - application
11
- depends_on:
12
- - database
13
  environment:
14
- MONGODB_CONNECTION_STRING: "mongodb://database:27017"
15
- MONGODB_DATABASE: phd_advisor
16
  JWT_SECRET_KEY: ${JWT_SECRET_KEY:-CHANGEME-by-overriding-in-dot-env-file}
17
- GEMINI_API_KEY: ${GEMINI_API_KEY:-?}
 
18
  VLLM_API_KEY: ${VLLM_API_KEY:-}
19
- CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
20
- GEMINI_MODEL: gemini-2.5-flash
21
- CONFIG_PATH: ${CONFIG_PATH:-/ccai/phd_config.yaml}
22
- frontend:
23
- build:
24
- context: .
25
- dockerfile: Dockerfile
26
- target: frontend
27
- ports:
28
- - "3000:3000"
29
- networks:
30
- - application
31
- depends_on:
32
- - backend
33
- environment:
34
- REACT_APP_API_URL: ${REACT_APP_API_URL:-http://localhost:8000}
35
- REACT_APP_TESTING_ONBOARDING: ${REACT_APP_TESTING_ONBOARDING:-false}
36
- database:
37
- image: mongo:8.0
38
  volumes:
39
- - mongo_data:/data/db
40
- networks:
41
- - application
42
  volumes:
43
- mongo_data:
44
- networks:
45
- application:
46
- driver: bridge
47
- driver_opts:
48
- com.docker.network.bridge.host_binding_ipv4: "0.0.0.0"
 
1
+ # ---------------------------------------------------------------------------
2
+ # Local-dev wrapper around the single-container HuggingFace Spaces image.
3
+ #
4
+ # This file used to spin up three services (FastAPI backend, CRA dev server,
5
+ # MongoDB). The HF deployment shape (single-container, SQLite on the bucket)
6
+ # is now also the local-dev shape — same image you push to the Space — so
7
+ # this is a one-service compose with a named volume that emulates the HF
8
+ # Storage Bucket mount.
9
+ #
10
+ # Override CYBERPANEL_HOST_PORT via .env (or shell) if 7861 conflicts; the
11
+ # in-container port is fixed at 7860 to mirror HF Spaces.
12
+ # ---------------------------------------------------------------------------
13
+
14
  services:
15
+ app:
16
  build:
17
  context: .
18
  dockerfile: Dockerfile
19
+ image: cybersecurity-panel:dev
20
  ports:
21
+ - "${CYBERPANEL_HOST_PORT:-7861}:7860"
 
 
 
 
22
  environment:
 
 
23
  JWT_SECRET_KEY: ${JWT_SECRET_KEY:-CHANGEME-by-overriding-in-dot-env-file}
24
+ GEMINI_API_KEY: ${GEMINI_API_KEY:-}
25
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
26
  VLLM_API_KEY: ${VLLM_API_KEY:-}
27
+ VLLM_API_USERNAME: ${VLLM_API_USERNAME:-}
28
+ GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}
29
+ CONFIG_PATH: ${CONFIG_PATH:-/home/user/app/cybersecurity_config.yaml}
30
+ # Same-origin in single-container; only relevant for cross-origin dev.
31
+ CORS_ORIGINS: ${CORS_ORIGINS:-*}
32
+ # SQLite + ChromaDB persistence both land here. The named volume below
33
+ # mirrors the HF Storage Bucket mount so user data survives rebuilds.
34
+ DATA_DIR: /data
 
 
 
 
 
 
 
 
 
 
 
35
  volumes:
36
+ - cybersecurity_data:/data
37
+
 
38
  volumes:
39
+ cybersecurity_data:
 
 
 
 
 
multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -16,12 +16,32 @@ from app.core.bootstrap import chat_orchestrator
16
  from app.core.database import get_database
17
  from app.core.session_manager import get_session_manager
18
  from app.models.user import User
 
19
 
20
  logger = logging.getLogger(__name__)
21
 
22
  router = APIRouter()
23
  session_manager = get_session_manager()
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # Enhanced data models
26
  class UserInput(BaseModel):
27
  user_input: str
@@ -91,6 +111,7 @@ async def chat_stream(
91
  sid = await get_or_create_session_for_request_async(request)
92
 
93
  session = session_manager.get_session(sid)
 
94
 
95
  # Append user message to in-memory session and persist to MongoDB
96
  session.append_message("user", message.user_input)
@@ -137,11 +158,33 @@ async def chat_stream(
137
  ).to_ndjson()
138
  return
139
 
140
- # Get personas most relevant to the current session
 
 
 
 
 
 
 
 
 
 
141
  top_personas = await chat_orchestrator.get_top_personas(
142
  session_id=sid,
 
 
143
  )
144
 
 
 
 
 
 
 
 
 
 
 
145
  done_queue: asyncio.Queue = asyncio.Queue()
146
 
147
  async def _run(pid: str) -> None:
@@ -155,9 +198,10 @@ async def chat_stream(
155
  await done_queue.put(result)
156
  except Exception as e:
157
  logger.exception(f"chat-stream _run failed for {pid}: {e}")
 
158
  await done_queue.put({
159
- "persona_id": persona.id,
160
- "persona_name": persona.name,
161
  "response": f"I ran into a technical issue. Please try again. ({e!s})",
162
  "used_documents": False,
163
  "document_chunks_used": 0,
 
16
  from app.core.database import get_database
17
  from app.core.session_manager import get_session_manager
18
  from app.models.user import User
19
+ from app.api.routes.user_profile import PROFILE_FIELDS, enrich_profile_from_user
20
 
21
  logger = logging.getLogger(__name__)
22
 
23
  router = APIRouter()
24
  session_manager = get_session_manager()
25
 
26
+
27
+ async def _attach_user_profile_context(session, user: User) -> None:
28
+ """Load Mongo profile + signup fields into session for persona prompts."""
29
+ try:
30
+ db = get_database()
31
+ doc = await db.user_profiles.find_one({"user_id": user.id})
32
+ profile = enrich_profile_from_user(doc, user)
33
+ parts = []
34
+ for key in PROFILE_FIELDS:
35
+ val = profile.get(key)
36
+ if val:
37
+ if isinstance(val, list):
38
+ val = ", ".join(str(v) for v in val)
39
+ parts.append(f"{key}: {val}")
40
+ if parts:
41
+ session.user_profile_context = "USER SECURITY PROFILE: " + "; ".join(parts)
42
+ except Exception as prof_err:
43
+ logger.warning(f"Could not load user profile: {prof_err}")
44
+
45
  # Enhanced data models
46
  class UserInput(BaseModel):
47
  user_input: str
 
111
  sid = await get_or_create_session_for_request_async(request)
112
 
113
  session = session_manager.get_session(sid)
114
+ await _attach_user_profile_context(session, current_user)
115
 
116
  # Append user message to in-memory session and persist to MongoDB
117
  session.append_message("user", message.user_input)
 
158
  ).to_ndjson()
159
  return
160
 
161
+ # Always relevance-rank to the top 3 advisors, scoped to the
162
+ # user's active-advisor selection (the header dropdown) when one is
163
+ # provided. The dropdown filters the candidate pool; the LLM
164
+ # ranking still picks the top 3 from that pool.
165
+ if message.active_advisors:
166
+ candidate_ids = [
167
+ pid for pid in message.active_advisors
168
+ if pid in chat_orchestrator.personas
169
+ ]
170
+ else:
171
+ candidate_ids = list(chat_orchestrator.personas.keys())
172
  top_personas = await chat_orchestrator.get_top_personas(
173
  session_id=sid,
174
+ k=3,
175
+ candidate_ids=candidate_ids,
176
  )
177
 
178
+ # Tell the client which advisors will respond so it can show
179
+ # thinking indicators for just those, not the entire active pool.
180
+ yield ChatStreamLine(
181
+ type="progress",
182
+ data={
183
+ "phase": "selected",
184
+ "selected_advisors": top_personas,
185
+ },
186
+ ).to_ndjson()
187
+
188
  done_queue: asyncio.Queue = asyncio.Queue()
189
 
190
  async def _run(pid: str) -> None:
 
198
  await done_queue.put(result)
199
  except Exception as e:
200
  logger.exception(f"chat-stream _run failed for {pid}: {e}")
201
+ failed_persona = chat_orchestrator.get_persona(pid)
202
  await done_queue.put({
203
+ "persona_id": pid,
204
+ "persona_name": failed_persona.name if failed_persona else pid,
205
  "response": f"I ran into a technical issue. Please try again. ({e!s})",
206
  "used_documents": False,
207
  "document_chunks_used": 0,
multi_llm_chatbot_backend/app/api/routes/chat_sessions.py CHANGED
@@ -43,12 +43,7 @@ async def create_chat_session(
43
  request: CreateChatSessionRequest,
44
  current_user: User = Depends(get_current_active_user)
45
  ):
46
- """
47
- Create a new chat session for the authenticated user.
48
- @param request: CreateChatSessionRequest with the session title
49
- @param current_user: Authenticated user from dependency injection
50
- @return: Dict with the new session id, title, timestamps, and message_count
51
- """
52
  try:
53
  db = get_database()
54
 
@@ -85,13 +80,7 @@ async def get_user_chat_sessions(
85
  limit: int = 50,
86
  skip: int = 0
87
  ):
88
- """
89
- Get all active chat sessions for the authenticated user.
90
- @param current_user: Authenticated user from dependency injection
91
- @param limit: Maximum number of sessions to return (default 50)
92
- @param skip: Number of sessions to skip for pagination (default 0)
93
- @return: List of ChatSessionResponse sorted by most recently updated
94
- """
95
  try:
96
  db = get_database()
97
 
@@ -124,11 +113,7 @@ async def get_user_chat_sessions(
124
  async def get_chat_sessions_count(
125
  current_user: User = Depends(get_current_active_user)
126
  ):
127
- """
128
- Get count of active, non-deleted chat sessions for the authenticated user.
129
- @param current_user: Authenticated user from dependency injection
130
- @return: Dict with the session count
131
- """
132
  try:
133
  db = get_database()
134
  user_object_id = ObjectId(str(current_user.id))
@@ -155,12 +140,7 @@ async def get_chat_session(
155
  session_id: str,
156
  current_user: User = Depends(get_current_active_user)
157
  ):
158
- """
159
- Get a specific chat session with all messages.
160
- @param session_id: MongoDB ObjectId of the chat session
161
- @param current_user: Authenticated user from dependency injection
162
- @return: Dict with session id, title, messages, and timestamps
163
- """
164
  try:
165
  db = get_database()
166
 
@@ -199,13 +179,7 @@ async def update_chat_session(
199
  request: UpdateChatSessionRequest,
200
  current_user: User = Depends(get_current_active_user)
201
  ):
202
- """
203
- Update a chat session's title and/or messages.
204
- @param session_id: MongoDB ObjectId of the chat session
205
- @param request: UpdateChatSessionRequest with optional title and messages
206
- @param current_user: Authenticated user from dependency injection
207
- @return: Dict with a confirmation message
208
- """
209
  try:
210
  db = get_database()
211
 
@@ -252,13 +226,7 @@ async def save_message_to_session(
252
  request: SaveMessageRequest,
253
  current_user: User = Depends(get_current_active_user)
254
  ):
255
- """
256
- Add a message to a chat session.
257
- @param session_id: MongoDB ObjectId of the chat session
258
- @param request: SaveMessageRequest with the message dict
259
- @param current_user: Authenticated user from dependency injection
260
- @return: Dict with a confirmation message
261
- """
262
  try:
263
  db = get_database()
264
 
@@ -290,50 +258,12 @@ async def save_message_to_session(
290
 
291
 
292
 
293
- @router.delete("/chat-sessions")
294
- async def delete_all_chat_sessions(
295
- current_user: User = Depends(get_current_active_user)
296
- ):
297
- """
298
- Soft-delete all chat sessions for the authenticated user.
299
- @param current_user: Authenticated user from dependency injection
300
- @return: Dict with a confirmation message and the number of deleted sessions
301
- """
302
- try:
303
- db = get_database()
304
-
305
- result = await db.chat_sessions.update_many(
306
- {
307
- "user_id": current_user.id,
308
- "is_active": True
309
- },
310
- {"$set": {"is_active": False, "updated_at": datetime.utcnow()}}
311
- )
312
-
313
- return {
314
- "message": f"Deleted {result.modified_count} chat sessions",
315
- "deleted_count": result.modified_count
316
- }
317
-
318
- except Exception as e:
319
- logger.error(f"Error deleting all chat sessions for user {current_user.id}: {e}")
320
- raise HTTPException(
321
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
322
- detail="Could not delete chat sessions"
323
- )
324
-
325
-
326
  @router.delete("/chat-sessions/{session_id}")
327
  async def delete_chat_session(
328
  session_id: str,
329
  current_user: User = Depends(get_current_active_user)
330
  ):
331
- """
332
- Soft-delete a single chat session.
333
- @param session_id: MongoDB ObjectId of the chat session
334
- @param current_user: Authenticated user from dependency injection
335
- @return: Dict with a confirmation message
336
- """
337
  try:
338
  db = get_database()
339
 
 
43
  request: CreateChatSessionRequest,
44
  current_user: User = Depends(get_current_active_user)
45
  ):
46
+ """Create a new chat session for the user"""
 
 
 
 
 
47
  try:
48
  db = get_database()
49
 
 
80
  limit: int = 50,
81
  skip: int = 0
82
  ):
83
+ """Get all chat sessions for the current user"""
 
 
 
 
 
 
84
  try:
85
  db = get_database()
86
 
 
113
  async def get_chat_sessions_count(
114
  current_user: User = Depends(get_current_active_user)
115
  ):
116
+ """Get count of user's chat sessions"""
 
 
 
 
117
  try:
118
  db = get_database()
119
  user_object_id = ObjectId(str(current_user.id))
 
140
  session_id: str,
141
  current_user: User = Depends(get_current_active_user)
142
  ):
143
+ """Get a specific chat session with all messages"""
 
 
 
 
 
144
  try:
145
  db = get_database()
146
 
 
179
  request: UpdateChatSessionRequest,
180
  current_user: User = Depends(get_current_active_user)
181
  ):
182
+ """Update a chat session (title or messages)"""
 
 
 
 
 
 
183
  try:
184
  db = get_database()
185
 
 
226
  request: SaveMessageRequest,
227
  current_user: User = Depends(get_current_active_user)
228
  ):
229
+ """Add a message to a chat session"""
 
 
 
 
 
 
230
  try:
231
  db = get_database()
232
 
 
258
 
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  @router.delete("/chat-sessions/{session_id}")
262
  async def delete_chat_session(
263
  session_id: str,
264
  current_user: User = Depends(get_current_active_user)
265
  ):
266
+ """Delete a chat session (soft delete)"""
 
 
 
 
 
267
  try:
268
  db = get_database()
269
 
multi_llm_chatbot_backend/app/config.py CHANGED
@@ -19,7 +19,6 @@ import yaml
19
  from pydantic import BaseModel, validator, Field, model_validator
20
 
21
  from app.utils.avatar_helpers import get_bundled_avatar_path
22
- from app.version import __version__
23
 
24
  logger = logging.getLogger(__name__)
25
 
@@ -49,11 +48,19 @@ class FeatureConfig(_IconValidatorMixin):
49
  icon: str = "HelpCircle"
50
 
51
 
 
 
 
 
 
 
 
52
  class AppConfig(BaseModel):
53
  title: str = "Advisor Canvas"
54
  subtitle: str = "AI-Powered Guidance"
55
  primary_color: str = "#7C3AED"
56
  footer_text: str = ""
 
57
 
58
 
59
  class HomepageConfig(BaseModel):
@@ -73,6 +80,8 @@ class LoginConfig(BaseModel):
73
  subtitle: str = "Sign in to continue"
74
  signup_subtitle: str = "Create your account to get personalized guidance from expert advisors"
75
  academic_stages: List[AcademicStage] = []
 
 
76
 
77
 
78
  class ExampleCategory(_IconValidatorMixin):
@@ -88,12 +97,6 @@ class ChatPageConfig(BaseModel):
88
  examples: List[ExampleCategory] = []
89
 
90
 
91
- class OnboardingConfig(BaseModel):
92
- features: List[FeatureConfig] = []
93
- tour_title: str = ""
94
- tour_body: str = ""
95
-
96
-
97
  class PersonaItemConfig(_IconValidatorMixin):
98
  id: str
99
  name: str
@@ -154,7 +157,11 @@ class PersonaItemConfig(_IconValidatorMixin):
154
  self.avatar, self.id,
155
  )
156
  return f"icon://{self.icon}"
157
- base = os.getenv("REACT_APP_API_URL", "http://localhost:8000").rstrip("/")
 
 
 
 
158
  return f"{base}/api/avatars/bundled/{self.avatar}"
159
 
160
  def to_frontend_config(self) -> dict:
@@ -194,6 +201,7 @@ class PersonasConfig(BaseModel):
194
 
195
  class OrchestratorConfig(BaseModel):
196
  min_words_without_keywords: int = 6
 
197
  specific_keywords: List[str] = []
198
  clarification_questions: List[str] = [
199
  "Could you provide more details about what you need help with?"]
@@ -270,12 +278,30 @@ class OllamaConfig(BaseModel):
270
  class VllmConfig(BaseModel):
271
  api_url: str = ""
272
  api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
 
275
  class LLMConfig(BaseModel):
 
276
  gemini: GeminiConfig = GeminiConfig()
277
  ollama: OllamaConfig = OllamaConfig()
278
  vllm: VllmConfig = VllmConfig()
 
 
279
 
280
 
281
  class RAGConfig(BaseModel):
@@ -311,7 +337,6 @@ class AppSettings(BaseModel):
311
  homepage: HomepageConfig = HomepageConfig()
312
  login: LoginConfig = LoginConfig()
313
  chat_page: ChatPageConfig = ChatPageConfig()
314
- onboarding: OnboardingConfig = OnboardingConfig()
315
  personas: PersonasConfig = PersonasConfig()
316
  orchestrator: OrchestratorConfig = OrchestratorConfig()
317
  auth: AuthConfig = AuthConfig()
@@ -333,11 +358,9 @@ class AppSettings(BaseModel):
333
  "homepage": self.homepage.dict(),
334
  "login": self.login.dict(),
335
  "chat_page": self.chat_page.dict(),
336
- "onboarding": self.onboarding.dict(),
337
  "personas": {
338
  "items": [p.to_frontend_config() for p in self.personas.items],
339
  },
340
- "version": __version__,
341
  }
342
 
343
 
 
19
  from pydantic import BaseModel, validator, Field, model_validator
20
 
21
  from app.utils.avatar_helpers import get_bundled_avatar_path
 
22
 
23
  logger = logging.getLogger(__name__)
24
 
 
48
  icon: str = "HelpCircle"
49
 
50
 
51
+ class UserAvatarOption(BaseModel):
52
+ id: str
53
+ icon: str = "User"
54
+ color: str = "#2563EB"
55
+ bg: str = "#EFF6FF"
56
+
57
+
58
  class AppConfig(BaseModel):
59
  title: str = "Advisor Canvas"
60
  subtitle: str = "AI-Powered Guidance"
61
  primary_color: str = "#7C3AED"
62
  footer_text: str = ""
63
+ user_avatars: List[UserAvatarOption] = []
64
 
65
 
66
  class HomepageConfig(BaseModel):
 
80
  subtitle: str = "Sign in to continue"
81
  signup_subtitle: str = "Create your account to get personalized guidance from expert advisors"
82
  academic_stages: List[AcademicStage] = []
83
+ knowledge_levels: List[AcademicStage] = []
84
+ timezones: List[AcademicStage] = []
85
 
86
 
87
  class ExampleCategory(_IconValidatorMixin):
 
97
  examples: List[ExampleCategory] = []
98
 
99
 
 
 
 
 
 
 
100
  class PersonaItemConfig(_IconValidatorMixin):
101
  id: str
102
  name: str
 
157
  self.avatar, self.id,
158
  )
159
  return f"icon://{self.icon}"
160
+ # Default to empty string ( relative URL) so single-origin Spaces
161
+ # deployments serve avatars off the same host as the SPA. Local
162
+ # ``npm start`` development setups can still set REACT_APP_API_URL
163
+ # explicitly to point at the backend on a different port.
164
+ base = os.getenv("REACT_APP_API_URL", "").rstrip("/")
165
  return f"{base}/api/avatars/bundled/{self.avatar}"
166
 
167
  def to_frontend_config(self) -> dict:
 
201
 
202
  class OrchestratorConfig(BaseModel):
203
  min_words_without_keywords: int = 6
204
+ conversation_history_token_threshold: int = 4000
205
  specific_keywords: List[str] = []
206
  clarification_questions: List[str] = [
207
  "Could you provide more details about what you need help with?"]
 
278
  class VllmConfig(BaseModel):
279
  api_url: str = ""
280
  api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
281
+ api_username: str = Field(default=os.getenv("VLLM_API_USERNAME", ""))
282
+ model_id: str = ""
283
+ neon_persona_orchestrator: str = "vanilla"
284
+ neon_persona_advisors: str = "CybersecurityExpert"
285
+
286
+
287
+ class OpenAIConfig(BaseModel):
288
+ api_key: str = Field(default=os.getenv("OPENAI_API_KEY", ""))
289
+ model: str = "gpt-5.4"
290
+ orchestrator_reasoning_effort: str = "low"
291
+ persona_reasoning_effort: str = "none"
292
+
293
+
294
+ class ResilientConfig(BaseModel):
295
+ race_timeout_seconds: float = 3.0
296
 
297
 
298
  class LLMConfig(BaseModel):
299
+ provider: str = "gemini"
300
  gemini: GeminiConfig = GeminiConfig()
301
  ollama: OllamaConfig = OllamaConfig()
302
  vllm: VllmConfig = VllmConfig()
303
+ openai: OpenAIConfig = OpenAIConfig()
304
+ resilient: ResilientConfig = ResilientConfig()
305
 
306
 
307
  class RAGConfig(BaseModel):
 
337
  homepage: HomepageConfig = HomepageConfig()
338
  login: LoginConfig = LoginConfig()
339
  chat_page: ChatPageConfig = ChatPageConfig()
 
340
  personas: PersonasConfig = PersonasConfig()
341
  orchestrator: OrchestratorConfig = OrchestratorConfig()
342
  auth: AuthConfig = AuthConfig()
 
358
  "homepage": self.homepage.dict(),
359
  "login": self.login.dict(),
360
  "chat_page": self.chat_page.dict(),
 
361
  "personas": {
362
  "items": [p.to_frontend_config() for p in self.personas.items],
363
  },
 
364
  }
365
 
366
 
multi_llm_chatbot_backend/app/core/canvas_database.py CHANGED
@@ -1,68 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
- from motor.motor_asyncio import AsyncIOMotorDatabase
3
 
4
  logger = logging.getLogger(__name__)
5
 
6
- async def setup_canvas_collections(db: AsyncIOMotorDatabase):
7
- """Setup MongoDB collections and indexes for PhD Canvas"""
8
- try:
9
- collection = db.phd_canvases
10
-
11
- # Create simple indexes
12
- await collection.create_index("user_id")
13
- logger.info("Created index on user_id")
14
-
15
- await collection.create_index("last_updated", background=True)
16
- logger.info("Created index on last_updated")
17
-
18
- # Create compound indexes
19
- await collection.create_index([("user_id", 1), ("last_updated", -1)])
20
- logger.info("Created compound index on user_id and last_updated")
21
-
22
- await collection.create_index([("user_id", 1), ("created_at", -1)])
23
- logger.info("Created compound index on user_id and created_at")
24
-
25
- # Ensure TTL index for old canvases (optional cleanup after 2 years)
26
- await collection.create_index(
27
- "created_at",
28
- expireAfterSeconds=63072000 # 2 years in seconds
29
- )
30
- logger.info("Created TTL index for canvas cleanup")
31
-
32
- logger.info("PhD Canvas database setup completed successfully")
33
-
34
- except Exception as e:
35
- logger.error("Error setting up canvas collections: %s", str(e))
36
- raise
37
 
38
- async def cleanup_old_canvas_data(db: AsyncIOMotorDatabase):
39
- """Cleanup old or orphaned canvas data (maintenance function)"""
40
- try:
41
- # Find canvases with no corresponding users (orphaned data)
42
- pipeline = [
43
- {
44
- "$lookup": {
45
- "from": "users",
46
- "localField": "user_id",
47
- "foreignField": "_id",
48
- "as": "user_data"
49
- }
50
- },
51
- {
52
- "$match": {
53
- "user_data": {"$size": 0} # No matching user found
54
- }
55
- }
56
- ]
57
-
58
- orphaned_canvases = await db.phd_canvases.aggregate(pipeline).to_list(length=100)
59
-
60
- if orphaned_canvases:
61
- orphaned_ids = [canvas["_id"] for canvas in orphaned_canvases]
62
- result = await db.phd_canvases.delete_many({"_id": {"$in": orphaned_ids}})
63
- logger.info("Cleaned up %d orphaned canvas records", result.deleted_count)
64
- else:
65
- logger.info("No orphaned canvas records found")
66
-
67
- except Exception as e:
68
- logger.error("Error during canvas cleanup: %s", str(e))
 
1
+ """PhD Canvas database setup — SQLite-shim aware.
2
+
3
+ The original implementation was a Mongo-only module that created compound
4
+ indexes and a TTL index on the ``phd_canvases`` collection at startup. With
5
+ the SQLite-on-HF-bucket persistence shim, the schema (including the
6
+ ``user_id`` index) is declared once at connection time inside
7
+ ``app.core.db``, so this module becomes a compatibility no-op.
8
+
9
+ We keep the function signatures so any caller that still imports
10
+ ``setup_canvas_collections`` or ``cleanup_old_canvas_data`` keeps working.
11
+ """
12
+
13
  import logging
14
+ from typing import Any
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ async def setup_canvas_collections(db: Any) -> None:
20
+ """No-op on the SQLite shim indexes are created by ``app.core.db``."""
21
+ logger.info(
22
+ "PhD Canvas setup: schema and indexes are managed by the SQLite shim; "
23
+ "skipping legacy Mongo index creation."
24
+ )
25
+
26
+
27
+ async def cleanup_old_canvas_data(db: Any) -> None:
28
+ """Maintenance helper — disabled on the SQLite shim.
29
+
30
+ The original used a Mongo ``$lookup`` aggregation against the ``users``
31
+ collection, which the in-process aggregation runner doesn't support. If
32
+ you need the orphan cleanup, run it via a separate script that does two
33
+ passes (``users``, then ``phd_canvases``) and calls ``delete_many``.
34
+ """
35
+ logger.info("cleanup_old_canvas_data is a no-op on the SQLite shim.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
multi_llm_chatbot_backend/app/core/database.py CHANGED
@@ -1,83 +1,50 @@
1
- import os
2
- from motor.motor_asyncio import AsyncIOMotorClient
3
- from pymongo.errors import ConnectionFailure
 
 
 
 
 
 
 
4
  import logging
5
 
6
- from app.config import get_settings
7
 
8
  logger = logging.getLogger(__name__)
9
 
10
- class Database:
11
- client: AsyncIOMotorClient = None
12
- database = None
13
-
14
- db = Database()
15
 
16
- async def connect_to_mongo():
17
- """Create database connection"""
 
 
 
 
 
 
18
  try:
19
- settings = get_settings()
20
 
21
- # Connection string: config.yaml → env var fallback (handled by Pydantic validator)
22
- mongo_url = settings.mongodb.connection_string
23
- if not mongo_url:
24
- # Last-resort fallback to raw env var (backwards compat)
25
- mongo_url = os.getenv("MONGODB_CONNECTION_STRING", "")
26
- if not mongo_url:
27
- raise ValueError(
28
- "MongoDB connection string not set. "
29
- "Provide it in config.yaml (mongodb.connection_string) "
30
- "or as the MONGODB_CONNECTION_STRING environment variable."
31
- )
32
 
33
- db_name = settings.mongodb.database_name
34
-
35
- db.client = AsyncIOMotorClient(mongo_url)
36
- db.database = db.client[db_name]
37
-
38
- # Test connection
39
- await db.client.admin.command('ping')
40
 
41
- logger.info(f"Successfully connected to MongoDB database: {db_name}")
42
-
43
- # Create indexes for better performance
44
- await create_indexes()
45
- try:
46
- from app.core.canvas_database import setup_canvas_collections
47
- await setup_canvas_collections(db.database) # Pass db directly
48
- logger.info("Canvas database initialization completed")
49
- except Exception as canvas_error:
50
- logger.error(f"Canvas database initialization failed: {canvas_error}")
51
-
52
- except ConnectionFailure as e:
53
- logger.error(f"Failed to connect to MongoDB: {e}")
54
- raise
55
- except Exception as e:
56
- logger.error(f"Unexpected error connecting to MongoDB: {e}")
57
- raise
58
 
59
- async def close_mongo_connection():
60
- """Close database connection"""
61
- if db.client:
62
- db.client.close()
63
- logger.info("Disconnected from MongoDB")
64
 
65
- async def create_indexes():
66
- """Create database indexes for performance"""
67
- try:
68
- # Index for users collection
69
- await db.database.users.create_index("email", unique=True)
70
- await db.database.users.create_index("created_at")
71
-
72
- # Indexes for chat_sessions collection
73
- await db.database.chat_sessions.create_index("user_id")
74
- await db.database.chat_sessions.create_index("created_at")
75
- await db.database.chat_sessions.create_index([("user_id", 1), ("created_at", -1)])
76
-
77
- logger.info("Database indexes created successfully")
78
- except Exception as e:
79
- logger.warning(f"Error creating indexes: {e}")
80
 
81
  def get_database():
82
- """Get database instance"""
83
- return db.database
 
1
+ """Database connection facade.
2
+
3
+ Used to wrap pymongo / motor against a real MongoDB instance. For Hugging
4
+ Face Spaces deployment we now back the same public API with SQLite on a HF
5
+ Storage Bucket mount (see ``app.core.db``). The shim emulates the Mongo
6
+ idioms the routers and services use, so this module simply forwards to it
7
+ and keeps the legacy ``connect_to_mongo`` / ``close_mongo_connection`` /
8
+ ``get_database`` API intact for ``main.py`` and the route files.
9
+ """
10
+
11
  import logging
12
 
13
+ from app.core.db import _close, _open, get_database as _get_database
14
 
15
  logger = logging.getLogger(__name__)
16
 
 
 
 
 
 
17
 
18
+ async def connect_to_mongo() -> None:
19
+ """Open the SQLite-backed database and run schema bootstrap."""
20
+ await _open()
21
+ logger.info("SQLite-backed database ready (Mongo API shim active).")
22
+ # Legacy hook: previously called ``setup_canvas_collections`` here.
23
+ # The phd_canvases table is already declared in the shim's schema, so
24
+ # the call below is a no-op but kept for symmetry with the original
25
+ # startup path.
26
  try:
27
+ from app.core.canvas_database import setup_canvas_collections
28
 
29
+ await setup_canvas_collections(_get_database())
30
+ logger.info("Canvas database initialization completed (no-op on SQLite shim).")
31
+ except Exception as canvas_error: # pragma: no cover
32
+ logger.error(f"Canvas database initialization failed: {canvas_error}")
 
 
 
 
 
 
 
33
 
 
 
 
 
 
 
 
34
 
35
+ async def close_mongo_connection() -> None:
36
+ """Best-effort connection close on FastAPI shutdown."""
37
+ await _close()
38
+ logger.info("Disconnected from SQLite-backed database.")
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
 
 
 
 
 
40
 
41
+ async def create_indexes() -> None:
42
+ """Index creation is handled by the shim's schema bootstrap; kept for
43
+ backwards compatibility so any out-of-process maintenance script that
44
+ imports this name keeps working."""
45
+ return None
46
+
 
 
 
 
 
 
 
 
 
47
 
48
  def get_database():
49
+ """Return the SQLite-backed Mongo-API-compatible Database object."""
50
+ return _get_database()
multi_llm_chatbot_backend/app/core/improved_orchestrator.py CHANGED
@@ -6,6 +6,7 @@ from app.core.rag_manager import get_rag_manager
6
  from app.config import get_settings
7
  from app.llm.llm_client import LLMClient, ToolCallResult
8
  from app.tools import get_tool_definitions, get_tool_executor
 
9
 
10
  import json
11
  import logging
@@ -61,15 +62,13 @@ class ImprovedChatOrchestrator:
61
  return ToolCallResult(text="", used_tool=False)
62
 
63
  system_prompt = (
64
- "You are a helpful assistant with access to external tools. "
65
- "Use the available tools when the user's question can be answered "
66
- "by one of them. If no tool is relevant, respond with a brief "
67
- "text answer. "
68
- "If a tool response includes 'truncated': true, let the user know "
69
- "how many total results were found and suggest they narrow their "
70
- "search for more specific results. "
71
- "Format your responses using markdown. Use bullet points "
72
- "to present structured data like course listings or professor ratings."
73
  )
74
 
75
  return await self.llm_client.generate_with_tools(
@@ -687,9 +686,14 @@ When analyzing the document context:
687
  """
688
  enhanced_context = []
689
 
690
- # Get recent conversation history (last 6 messages for efficiency)
691
- recent_messages = session.messages[-6:] if len(session.messages) > 6 else session.messages
692
-
 
 
 
 
 
693
  # Check if we actually have meaningful document content
694
  has_documents = bool(document_context and document_context.strip() and len(document_context.strip()) > 50)
695
 
@@ -711,11 +715,6 @@ When analyzing the document context:
711
 
712
  Always cite your sources when referencing information from their documents using the format: "According to your [document_name]..." or "In your [section_name] from [document_name]..."
713
  """
714
-
715
- enhanced_context.append({
716
- "role": "system",
717
- "content": system_message
718
- })
719
  else:
720
  # NO DOCUMENTS - Explicitly tell persona not to reference documents
721
  system_message = f"""{persona.system_prompt}
@@ -728,19 +727,58 @@ When analyzing the document context:
728
  3. Provide general guidance based on best practices in your area of expertise
729
 
730
  Do NOT make up document names or pretend to have access to files that don't exist."""
731
-
732
- enhanced_context.append({
733
- "role": "system",
734
- "content": system_message
735
- })
736
 
737
- # Add recent conversation messages (excluding system messages to avoid duplication)
738
- for message in recent_messages:
739
- if message.get('role') != 'system':
 
 
 
 
 
 
 
 
 
 
740
  enhanced_context.append({
741
- "role": message['role'],
742
- "content": message['content']
743
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
 
745
  return enhanced_context
746
 
@@ -871,10 +909,19 @@ When analyzing the document context:
871
  }
872
 
873
 
874
- async def get_top_personas(self, session_id: str, k: int = 3) -> List[str]:
 
 
 
 
 
875
  """
876
  Use the LLM to rank personas based on current session context.
877
  Falls back to default persona order if LLM fails or returns invalid data.
 
 
 
 
878
  """
879
  try:
880
  session = self.session_manager.get_session(session_id)
@@ -883,22 +930,33 @@ When analyzing the document context:
883
  logger.warning("No personas registered.")
884
  return []
885
 
 
 
 
 
 
 
 
 
 
 
 
886
  # Use the LLM from one of the existing persona objects
887
- llm = next(iter(self.personas.values())).llm
888
 
889
  # Use recent conversation context (last 5 messages)
890
  recent_context = "\n".join(
891
  msg['content'] for msg in session.get_recent_messages(5)
892
  )
893
 
894
- # Format available persona descriptions
895
  persona_descriptions = "\n".join([
896
  f"- ID: {p.id}\n Name: {p.name}\n Prompt: {p.system_prompt.strip()}"
897
- for p in self.personas.values()
898
  ])
899
 
900
- # Ensure k does not exceed the number of available personas
901
- k = min(k, len(self.personas))
902
 
903
  app_title = get_settings().app.title
904
 
@@ -935,15 +993,19 @@ When analyzing the document context:
935
  if isinstance(top_ids, dict):
936
  top_ids = next(iter(top_ids.values()), [])
937
 
938
- # Step 3: Filter valid persona IDs
939
- valid_ids = [pid for pid in top_ids if pid in self.personas]
940
 
941
  if len(valid_ids) < k:
942
  logger.warning(f"LLM returned insufficient or invalid IDs. Got: {valid_ids}")
943
- return list(self.personas.keys())[:k]
944
 
945
  return valid_ids[:k]
946
 
947
  except Exception as e:
948
  logger.error(f"Error selecting top personas: {e}")
 
 
 
 
949
  return list(self.personas.keys())[:k]
 
6
  from app.config import get_settings
7
  from app.llm.llm_client import LLMClient, ToolCallResult
8
  from app.tools import get_tool_definitions, get_tool_executor
9
+ from app.utils.chat_summary import generate_conversation_context_summary
10
 
11
  import json
12
  import logging
 
62
  return ToolCallResult(text="", used_tool=False)
63
 
64
  system_prompt = (
65
+ "You are a helpful cybersecurity assistant with access to external tools. "
66
+ "Use the available tools when the user's question can be answered by one of them. "
67
+ "Call get_current_datetime when the user asks about today, deadlines, timelines, "
68
+ "schedules, incident timing, or when accurate date/time context would improve "
69
+ "your guidance then weave the result into your answer. "
70
+ "If no tool is relevant, respond with a brief text answer. "
71
+ "Format your responses using markdown."
 
 
72
  )
73
 
74
  return await self.llm_client.generate_with_tools(
 
686
  """
687
  enhanced_context = []
688
 
689
+ conversation_messages = [
690
+ m for m in session.messages if m.get("role") != "system"
691
+ ]
692
+ history_tokens = self.context_manager._estimate_tokens_for_messages(
693
+ conversation_messages
694
+ )
695
+ threshold = get_settings().orchestrator.conversation_history_token_threshold
696
+
697
  # Check if we actually have meaningful document content
698
  has_documents = bool(document_context and document_context.strip() and len(document_context.strip()) > 50)
699
 
 
715
 
716
  Always cite your sources when referencing information from their documents using the format: "According to your [document_name]..." or "In your [section_name] from [document_name]..."
717
  """
 
 
 
 
 
718
  else:
719
  # NO DOCUMENTS - Explicitly tell persona not to reference documents
720
  system_message = f"""{persona.system_prompt}
 
727
  3. Provide general guidance based on best practices in your area of expertise
728
 
729
  Do NOT make up document names or pretend to have access to files that don't exist."""
 
 
 
 
 
730
 
731
+ if hasattr(session, "user_profile_context") and session.user_profile_context:
732
+ system_message += (
733
+ f"\n\n{session.user_profile_context}\n"
734
+ "Use this background to calibrate technical depth, examples, and priorities."
735
+ )
736
+
737
+ enhanced_context.append({
738
+ "role": "system",
739
+ "content": system_message,
740
+ })
741
+
742
+ if history_tokens < threshold:
743
+ for message in conversation_messages:
744
  enhanced_context.append({
745
+ "role": message["role"],
746
+ "content": message["content"],
747
  })
748
+ else:
749
+ msg_count = len(conversation_messages)
750
+ if (
751
+ session.conversation_summary
752
+ and session.conversation_summary_message_count == msg_count
753
+ ):
754
+ summary = session.conversation_summary
755
+ else:
756
+ llm = self.llm_client
757
+ if llm is None and self.personas:
758
+ llm = next(iter(self.personas.values())).llm
759
+ persona_names = {p.id: p.name for p in self.personas.values()}
760
+ summary = ""
761
+ if llm is not None:
762
+ summary = await generate_conversation_context_summary(
763
+ conversation_messages,
764
+ llm,
765
+ persona_names=persona_names,
766
+ )
767
+ if summary:
768
+ session.conversation_summary = summary
769
+ session.conversation_summary_message_count = msg_count
770
+ system_message += f"\n\nSummary of earlier conversation:\n{summary}"
771
+ enhanced_context[0]["content"] = system_message
772
+ else:
773
+ logger.warning(
774
+ "Summary generation failed; including full history (%d tokens)",
775
+ history_tokens,
776
+ )
777
+ for message in conversation_messages:
778
+ enhanced_context.append({
779
+ "role": message["role"],
780
+ "content": message["content"],
781
+ })
782
 
783
  return enhanced_context
784
 
 
909
  }
910
 
911
 
912
+ async def get_top_personas(
913
+ self,
914
+ session_id: str,
915
+ k: int = 3,
916
+ candidate_ids: Optional[List[str]] = None,
917
+ ) -> List[str]:
918
  """
919
  Use the LLM to rank personas based on current session context.
920
  Falls back to default persona order if LLM fails or returns invalid data.
921
+
922
+ When ``candidate_ids`` is provided (e.g., from the header's advisor
923
+ selection dropdown) ranking is restricted to that pool so users only
924
+ receive responses from advisors they've enabled.
925
  """
926
  try:
927
  session = self.session_manager.get_session(session_id)
 
930
  logger.warning("No personas registered.")
931
  return []
932
 
933
+ if candidate_ids:
934
+ candidate_personas = {
935
+ pid: self.personas[pid]
936
+ for pid in candidate_ids
937
+ if pid in self.personas
938
+ }
939
+ if not candidate_personas:
940
+ candidate_personas = dict(self.personas)
941
+ else:
942
+ candidate_personas = dict(self.personas)
943
+
944
  # Use the LLM from one of the existing persona objects
945
+ llm = next(iter(candidate_personas.values())).llm
946
 
947
  # Use recent conversation context (last 5 messages)
948
  recent_context = "\n".join(
949
  msg['content'] for msg in session.get_recent_messages(5)
950
  )
951
 
952
+ # Format available persona descriptions (only the candidate pool)
953
  persona_descriptions = "\n".join([
954
  f"- ID: {p.id}\n Name: {p.name}\n Prompt: {p.system_prompt.strip()}"
955
+ for p in candidate_personas.values()
956
  ])
957
 
958
+ # Ensure k does not exceed the number of candidate personas
959
+ k = min(k, len(candidate_personas))
960
 
961
  app_title = get_settings().app.title
962
 
 
993
  if isinstance(top_ids, dict):
994
  top_ids = next(iter(top_ids.values()), [])
995
 
996
+ # Step 3: Filter valid persona IDs against the candidate pool
997
+ valid_ids = [pid for pid in top_ids if pid in candidate_personas]
998
 
999
  if len(valid_ids) < k:
1000
  logger.warning(f"LLM returned insufficient or invalid IDs. Got: {valid_ids}")
1001
+ return list(candidate_personas.keys())[:k]
1002
 
1003
  return valid_ids[:k]
1004
 
1005
  except Exception as e:
1006
  logger.error(f"Error selecting top personas: {e}")
1007
+ if candidate_ids:
1008
+ fallback_ids = [pid for pid in candidate_ids if pid in self.personas]
1009
+ if fallback_ids:
1010
+ return fallback_ids[:k]
1011
  return list(self.personas.keys())[:k]
multi_llm_chatbot_backend/app/core/rag_manager.py CHANGED
@@ -12,6 +12,20 @@ from pathlib import Path
12
 
13
  logger = logging.getLogger(__name__)
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  # Download required NLTK data
16
  try:
17
  nltk.data.find('tokenizers/punkt')
@@ -177,14 +191,16 @@ class RAGManager:
177
  Handles document storage, embedding, and retrieval using ChromaDB
178
  """
179
 
180
- def __init__(self, embedding_model: str = None, persist_directory: str = "./chroma_db"):
181
  from app.config import get_settings
182
  settings = get_settings()
183
  if embedding_model is None:
184
  embedding_model = settings.rag.embedding_model
185
  self.embedding_model_name = embedding_model
 
 
186
  self.persist_directory = Path(persist_directory)
187
- self.persist_directory.mkdir(exist_ok=True)
188
 
189
  # Initialize embedding model
190
  logger.info(f"Loading embedding model: {embedding_model}")
@@ -459,13 +475,15 @@ class RAGManager:
459
 
460
 
461
  class EnhancedRAGManager:
462
- def __init__(self, persist_directory: str = "./chromadb_storage"):
463
  """Initialize enhanced RAG manager with improved document handling"""
464
  from app.config import get_settings
465
  settings = get_settings()
466
 
 
 
467
  self.persist_directory = persist_directory
468
- Path(persist_directory).mkdir(exist_ok=True)
469
 
470
  # Initialize ChromaDB client
471
  self.client = chromadb.PersistentClient(
 
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
+
16
+ def _default_chroma_path(subdir: str) -> str:
17
+ """Resolve the default ChromaDB persistence directory.
18
+
19
+ On HF Spaces (and any deployment that sets ``DATA_DIR``) the embeddings
20
+ must live on the bucket mount so they survive Space rebuilds. In local
21
+ dev DATA_DIR is unset, so we keep the historical working-directory path
22
+ so existing local installs and tests don't move their data.
23
+ """
24
+ data_dir = os.environ.get("DATA_DIR", "").strip()
25
+ if data_dir:
26
+ return str(Path(data_dir) / "chroma" / subdir)
27
+ return f"./{subdir}"
28
+
29
  # Download required NLTK data
30
  try:
31
  nltk.data.find('tokenizers/punkt')
 
191
  Handles document storage, embedding, and retrieval using ChromaDB
192
  """
193
 
194
+ def __init__(self, embedding_model: str = None, persist_directory: str = None):
195
  from app.config import get_settings
196
  settings = get_settings()
197
  if embedding_model is None:
198
  embedding_model = settings.rag.embedding_model
199
  self.embedding_model_name = embedding_model
200
+ if persist_directory is None:
201
+ persist_directory = _default_chroma_path("chroma_db")
202
  self.persist_directory = Path(persist_directory)
203
+ self.persist_directory.mkdir(parents=True, exist_ok=True)
204
 
205
  # Initialize embedding model
206
  logger.info(f"Loading embedding model: {embedding_model}")
 
475
 
476
 
477
  class EnhancedRAGManager:
478
+ def __init__(self, persist_directory: str = None):
479
  """Initialize enhanced RAG manager with improved document handling"""
480
  from app.config import get_settings
481
  settings = get_settings()
482
 
483
+ if persist_directory is None:
484
+ persist_directory = _default_chroma_path("chromadb_storage")
485
  self.persist_directory = persist_directory
486
+ Path(persist_directory).mkdir(parents=True, exist_ok=True)
487
 
488
  # Initialize ChromaDB client
489
  self.client = chromadb.PersistentClient(
multi_llm_chatbot_backend/app/main.py CHANGED
@@ -1,9 +1,9 @@
1
  import os
2
- from dotenv import load_dotenv
3
 
4
- load_dotenv()
5
 
6
- from pathlib import Path
7
 
8
  from fastapi import FastAPI
9
  from fastapi.middleware.cors import CORSMiddleware
@@ -12,7 +12,6 @@ from contextlib import asynccontextmanager
12
 
13
  # Load configuration FIRST so every module can use it
14
  from app.config import load_settings
15
- from app.version import __version__
16
  settings = load_settings()
17
 
18
  # Import the new database functions
@@ -23,6 +22,8 @@ from app.api.routes import router as main_router
23
  from app.api.routes.auth import router as auth_router
24
  from app.api.routes.chat_sessions import router as chat_sessions_router
25
  from app.api.routes.phd_canvas import router as phd_canvas_router
 
 
26
 
27
  import logging
28
 
@@ -41,7 +42,7 @@ async def lifespan(app: FastAPI):
41
 
42
  app = FastAPI(
43
  title=f"{settings.app.title} Backend",
44
- version=__version__,
45
  lifespan=lifespan
46
  )
47
 
@@ -61,6 +62,8 @@ app.include_router(main_router)
61
  app.include_router(auth_router, prefix="/auth", tags=["authentication"])
62
  app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"])
63
  app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"])
 
 
64
 
65
  # Serve bundled avatar images
66
  _avatars_dir = Path(__file__).resolve().parent / "assets" / "avatars"
@@ -80,17 +83,34 @@ def get_public_config():
80
  """Return the public (non-secret) application configuration."""
81
  return settings.get_frontend_config()
82
 
83
- @app.get("/")
84
- def root():
85
- return {
86
- "message": f"{settings.app.title} Backend",
87
- "version": __version__,
88
- "features": [
89
- "User Authentication",
90
- "Persistent Chat Sessions",
91
- "MongoDB Integration",
92
- "Ollama Support",
93
- "Gemini API Support",
94
- "Configurable Personas"
95
- ]
96
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ from pathlib import Path
3
 
4
+ from app.core.env_loader import load_application_env
5
 
6
+ load_application_env()
7
 
8
  from fastapi import FastAPI
9
  from fastapi.middleware.cors import CORSMiddleware
 
12
 
13
  # Load configuration FIRST so every module can use it
14
  from app.config import load_settings
 
15
  settings = load_settings()
16
 
17
  # Import the new database functions
 
22
  from app.api.routes.auth import router as auth_router
23
  from app.api.routes.chat_sessions import router as chat_sessions_router
24
  from app.api.routes.phd_canvas import router as phd_canvas_router
25
+ from app.api.routes.user_profile import router as user_profile_router
26
+ from app.api.routes.onboarding import router as onboarding_router
27
 
28
  import logging
29
 
 
42
 
43
  app = FastAPI(
44
  title=f"{settings.app.title} Backend",
45
+ version="2.0.0",
46
  lifespan=lifespan
47
  )
48
 
 
62
  app.include_router(auth_router, prefix="/auth", tags=["authentication"])
63
  app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"])
64
  app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"])
65
+ app.include_router(user_profile_router, prefix="/api", tags=["user-profile"])
66
+ app.include_router(onboarding_router, prefix="/api", tags=["onboarding"])
67
 
68
  # Serve bundled avatar images
69
  _avatars_dir = Path(__file__).resolve().parent / "assets" / "avatars"
 
83
  """Return the public (non-secret) application configuration."""
84
  return settings.get_frontend_config()
85
 
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Static SPA mount — Hugging Face Spaces / single-container deployment
89
+ # ---------------------------------------------------------------------------
90
+ # When the Docker build copies the React production bundle into ``./static``
91
+ # (sibling of this app/ directory), expose it at "/" so the API and the
92
+ # SPA share the FastAPI origin. In local development the static dir is
93
+ # absent and a JSON banner is returned at "/" instead.
94
+ _static_dir = Path(__file__).resolve().parent.parent / "static"
95
+ _should_mount_static = _static_dir.is_dir()
96
+
97
+ if _should_mount_static:
98
+ app.mount(
99
+ "/",
100
+ StaticFiles(directory=str(_static_dir), html=True),
101
+ name="spa",
102
+ )
103
+ else:
104
+ @app.get("/")
105
+ def root():
106
+ return {
107
+ "message": f"{settings.app.title} Backend",
108
+ "version": "2.0.0",
109
+ "features": [
110
+ "User Authentication",
111
+ "Persistent Chat Sessions (SQLite via aiosqlite)",
112
+ "Ollama Support",
113
+ "Gemini API Support",
114
+ "Configurable Personas",
115
+ ],
116
+ }
multi_llm_chatbot_backend/app/utils/chroma_client.py CHANGED
@@ -1,8 +1,29 @@
 
 
 
1
  import chromadb
2
  from chromadb.config import Settings
 
3
  from app.llm.embedding_client import get_embedding
4
 
5
- client = chromadb.PersistentClient(path="./chroma_storage")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  collection = client.get_or_create_collection("persona_knowledge")
8
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
  import chromadb
5
  from chromadb.config import Settings
6
+
7
  from app.llm.embedding_client import get_embedding
8
 
9
+
10
+ def _persona_knowledge_path() -> str:
11
+ """Resolve the persona-knowledge ChromaDB path.
12
+
13
+ Mirrors ``app.core.rag_manager._default_chroma_path``: when ``DATA_DIR``
14
+ is set (HF Spaces, any bucket-mounted deployment) the embeddings live
15
+ under ``${DATA_DIR}/chroma/persona_knowledge`` so they survive Space
16
+ rebuilds. Local installs keep the historical relative path.
17
+ """
18
+ data_dir = os.environ.get("DATA_DIR", "").strip()
19
+ if data_dir:
20
+ return str(Path(data_dir) / "chroma" / "persona_knowledge")
21
+ return "./chroma_storage"
22
+
23
+
24
+ _path = _persona_knowledge_path()
25
+ Path(_path).mkdir(parents=True, exist_ok=True)
26
+ client = chromadb.PersistentClient(path=_path)
27
 
28
  collection = client.get_or_create_collection("persona_knowledge")
29
 
multi_llm_chatbot_backend/requirements.txt CHANGED
@@ -6,6 +6,7 @@ python-multipart~=0.0
6
  # HTTP client for LLM APIs
7
  httpx~=0.28
8
  openai~=2.30
 
9
 
10
  # Document processing
11
  PyPDF2~=3.0
@@ -31,9 +32,11 @@ markdown~=3.0
31
  # PDF generation and export
32
  reportlab~=4.4
33
 
34
- # Database (MongoDB)
 
 
 
35
  pymongo~=4.16
36
- motor~=3.7
37
 
38
  # Authentication and security
39
  passlib[bcrypt]~=1.7
 
6
  # HTTP client for LLM APIs
7
  httpx~=0.28
8
  openai~=2.30
9
+ huggingface-hub~=0.36
10
 
11
  # Document processing
12
  PyPDF2~=3.0
 
32
  # PDF generation and export
33
  reportlab~=4.4
34
 
35
+ # Persistence: SQLite via aiosqlite on the HF Storage Bucket mount (no MongoDB).
36
+ # pymongo is kept *only* for bson.ObjectId and the DuplicateKeyError /
37
+ # PyMongoError types that the existing routers and the SQLite shim raise.
38
+ aiosqlite~=0.20
39
  pymongo~=4.16
 
40
 
41
  # Authentication and security
42
  passlib[bcrypt]~=1.7
phd-advisor-frontend/package-lock.json CHANGED
@@ -8,17 +8,22 @@
8
  "name": "phd-advisor-frontend",
9
  "version": "0.1.0",
10
  "dependencies": {
11
- "@reactour/tour": "^3.8.0",
12
  "@testing-library/dom": "^10.4.0",
13
  "@testing-library/jest-dom": "^6.6.3",
14
  "@testing-library/react": "^16.3.0",
15
  "@testing-library/user-event": "^13.5.0",
 
 
 
16
  "lucide-react": "^0.544.0",
17
  "react": "^19.1.0",
18
  "react-dom": "^19.1.0",
19
  "react-markdown": "^10.1.0",
20
  "react-scripts": "5.0.1",
 
21
  "remark-gfm": "^4.0.1",
 
22
  "web-vitals": "^2.1.4"
23
  }
24
  },
@@ -2069,6 +2074,108 @@
2069
  "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
2070
  "license": "MIT"
2071
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2072
  "node_modules/@csstools/normalize.css": {
2073
  "version": "12.1.1",
2074
  "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz",
@@ -2967,6 +3074,36 @@
2967
  "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
2968
  "license": "MIT"
2969
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2970
  "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
2971
  "version": "5.1.1-v1",
2972
  "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3033,6 +3170,12 @@
3033
  "node": ">= 8"
3034
  }
3035
  },
 
 
 
 
 
 
3036
  "node_modules/@pkgjs/parseargs": {
3037
  "version": "0.11.0",
3038
  "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3091,57 +3234,6 @@
3091
  }
3092
  }
3093
  },
3094
- "node_modules/@reactour/mask": {
3095
- "version": "1.2.0",
3096
- "resolved": "https://registry.npmjs.org/@reactour/mask/-/mask-1.2.0.tgz",
3097
- "integrity": "sha512-XLgBLWfKJybtZjNTSO5lt/SIvRlCZBadB6JfE/hO1ErqURRjYhnv+edC0Ki1haUCqMGFppWk3lwcPCjmK0xNog==",
3098
- "license": "MIT",
3099
- "dependencies": {
3100
- "@reactour/utils": "*"
3101
- },
3102
- "peerDependencies": {
3103
- "react": "16.x || 17.x || 18.x || 19.x"
3104
- }
3105
- },
3106
- "node_modules/@reactour/popover": {
3107
- "version": "1.3.0",
3108
- "resolved": "https://registry.npmjs.org/@reactour/popover/-/popover-1.3.0.tgz",
3109
- "integrity": "sha512-YdyjSmHPvEeQEcJM4gcGFa5pI/Yf4nZGqwG4JnT+rK1SyUJBIPnm4Gkl/h7/+1g0KCFMkwNwagS3ZiXvZB7ThA==",
3110
- "license": "MIT",
3111
- "dependencies": {
3112
- "@reactour/utils": "*"
3113
- },
3114
- "peerDependencies": {
3115
- "react": "16.x || 17.x || 18.x || 19.x"
3116
- }
3117
- },
3118
- "node_modules/@reactour/tour": {
3119
- "version": "3.8.0",
3120
- "resolved": "https://registry.npmjs.org/@reactour/tour/-/tour-3.8.0.tgz",
3121
- "integrity": "sha512-KZTFi1pAvoTVKKRdBN5+XCYxXBp4k4Ql/acZcXyPvec8VU24fkMSEeV+v8krfYQpoVcewxIu3gM6xWZZLjxi7w==",
3122
- "license": "MIT",
3123
- "dependencies": {
3124
- "@reactour/mask": "*",
3125
- "@reactour/popover": "*",
3126
- "@reactour/utils": "*"
3127
- },
3128
- "peerDependencies": {
3129
- "react": "16.x || 17.x || 18.x || 19.x"
3130
- }
3131
- },
3132
- "node_modules/@reactour/utils": {
3133
- "version": "0.6.0",
3134
- "resolved": "https://registry.npmjs.org/@reactour/utils/-/utils-0.6.0.tgz",
3135
- "integrity": "sha512-GqaLjQi7MJsgtAKjdiw2Eak1toFkADoLRnm1+HZpaD+yl+DkaHpC1N7JAl+kVOO5I17bWInPA+OFbXjO9Co8Qg==",
3136
- "license": "MIT",
3137
- "dependencies": {
3138
- "@rooks/use-mutation-observer": "^4.11.2",
3139
- "resize-observer-polyfill": "^1.5.1"
3140
- },
3141
- "peerDependencies": {
3142
- "react": "16.x || 17.x || 18.x || 19.x"
3143
- }
3144
- },
3145
  "node_modules/@rollup/plugin-babel": {
3146
  "version": "5.3.1",
3147
  "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -3221,15 +3313,6 @@
3221
  "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
3222
  "license": "MIT"
3223
  },
3224
- "node_modules/@rooks/use-mutation-observer": {
3225
- "version": "4.11.2",
3226
- "resolved": "https://registry.npmjs.org/@rooks/use-mutation-observer/-/use-mutation-observer-4.11.2.tgz",
3227
- "integrity": "sha512-vpsdrZdr6TkB1zZJcHx+fR1YC/pHs2BaqcuYiEGjBVbwY5xcC49+h0hAUtQKHth3oJqXfIX/Ng8S7s5HFHdM/A==",
3228
- "license": "MIT",
3229
- "peerDependencies": {
3230
- "react": ">=16.8.0"
3231
- }
3232
- },
3233
  "node_modules/@rtsao/scc": {
3234
  "version": "1.1.0",
3235
  "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3499,6 +3582,15 @@
3499
  "url": "https://github.com/sponsors/gregberge"
3500
  }
3501
  },
 
 
 
 
 
 
 
 
 
3502
  "node_modules/@testing-library/dom": {
3503
  "version": "10.4.0",
3504
  "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -3858,6 +3950,12 @@
3858
  "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
3859
  "license": "MIT"
3860
  },
 
 
 
 
 
 
3861
  "node_modules/@types/mdast": {
3862
  "version": "4.0.4",
3863
  "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -3927,6 +4025,16 @@
3927
  "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
3928
  "license": "MIT"
3929
  },
 
 
 
 
 
 
 
 
 
 
3930
  "node_modules/@types/resolve": {
3931
  "version": "1.17.1",
3932
  "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -4258,6 +4366,59 @@
4258
  "url": "https://opencollective.com/typescript-eslint"
4259
  }
4260
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4261
  "node_modules/@ungap/structured-clone": {
4262
  "version": "1.3.0",
4263
  "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -4429,6 +4590,15 @@
4429
  "deprecated": "Use your platform's native atob() and btoa() methods instead",
4430
  "license": "BSD-3-Clause"
4431
  },
 
 
 
 
 
 
 
 
 
4432
  "node_modules/accepts": {
4433
  "version": "1.3.8",
4434
  "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -5278,6 +5448,26 @@
5278
  "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
5279
  "license": "MIT"
5280
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5281
  "node_modules/batch": {
5282
  "version": "0.6.1",
5283
  "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
@@ -5416,6 +5606,15 @@
5416
  "node": ">=8"
5417
  }
5418
  },
 
 
 
 
 
 
 
 
 
5419
  "node_modules/browser-process-hrtime": {
5420
  "version": "1.0.0",
5421
  "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
@@ -5797,6 +5996,15 @@
5797
  "wrap-ansi": "^7.0.0"
5798
  }
5799
  },
 
 
 
 
 
 
 
 
 
5800
  "node_modules/co": {
5801
  "version": "4.6.0",
5802
  "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -5892,6 +6100,21 @@
5892
  "node": ">=4"
5893
  }
5894
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5895
  "node_modules/collect-v8-coverage": {
5896
  "version": "1.0.2",
5897
  "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -6025,6 +6248,16 @@
6025
  "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
6026
  "license": "MIT"
6027
  },
 
 
 
 
 
 
 
 
 
 
6028
  "node_modules/confusing-browser-globals": {
6029
  "version": "1.0.11",
6030
  "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
@@ -6139,6 +6372,12 @@
6139
  "node": ">=10"
6140
  }
6141
  },
 
 
 
 
 
 
6142
  "node_modules/cross-spawn": {
6143
  "version": "7.0.6",
6144
  "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6537,6 +6776,13 @@
6537
  "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
6538
  "license": "MIT"
6539
  },
 
 
 
 
 
 
 
6540
  "node_modules/damerau-levenshtein": {
6541
  "version": "1.0.8",
6542
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6817,6 +7063,12 @@
6817
  "url": "https://github.com/sponsors/wooorm"
6818
  }
6819
  },
 
 
 
 
 
 
6820
  "node_modules/didyoumean": {
6821
  "version": "1.2.2",
6822
  "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -7017,6 +7269,57 @@
7017
  "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
7018
  "license": "MIT"
7019
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7020
  "node_modules/ee-first": {
7021
  "version": "1.1.1",
7022
  "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8438,6 +8741,23 @@
8438
  }
8439
  }
8440
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8441
  "node_modules/for-each": {
8442
  "version": "0.3.5",
8443
  "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -9055,10 +9375,129 @@
9055
  "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
9056
  "license": "MIT",
9057
  "dependencies": {
9058
- "function-bind": "^1.1.2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9059
  },
9060
- "engines": {
9061
- "node": ">= 0.4"
 
9062
  }
9063
  },
9064
  "node_modules/hast-util-to-jsx-runtime": {
@@ -9088,6 +9527,22 @@
9088
  "url": "https://opencollective.com/unified"
9089
  }
9090
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9091
  "node_modules/hast-util-whitespace": {
9092
  "version": "3.0.0",
9093
  "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -9101,6 +9556,23 @@
9101
  "url": "https://opencollective.com/unified"
9102
  }
9103
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9104
  "node_modules/he": {
9105
  "version": "1.2.0",
9106
  "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -9385,6 +9857,28 @@
9385
  "node": ">=10.17.0"
9386
  }
9387
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9388
  "node_modules/iconv-lite": {
9389
  "version": "0.6.3",
9390
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -9436,6 +9930,21 @@
9436
  "node": ">= 4"
9437
  }
9438
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9439
  "node_modules/immer": {
9440
  "version": "9.0.21",
9441
  "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -11178,6 +11687,71 @@
11178
  "jiti": "bin/jiti.js"
11179
  }
11180
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11181
  "node_modules/js-tokens": {
11182
  "version": "4.0.0",
11183
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -11356,6 +11930,22 @@
11356
  "node": ">=4.0"
11357
  }
11358
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11359
  "node_modules/keyv": {
11360
  "version": "4.5.4",
11361
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11410,6 +12000,27 @@
11410
  "node": ">=0.10"
11411
  }
11412
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11413
  "node_modules/launch-editor": {
11414
  "version": "2.10.0",
11415
  "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz",
@@ -11800,6 +12411,25 @@
11800
  "url": "https://opencollective.com/unified"
11801
  }
11802
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11803
  "node_modules/mdast-util-mdx-expression": {
11804
  "version": "2.0.1",
11805
  "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -12179,6 +12809,25 @@
12179
  "url": "https://opencollective.com/unified"
12180
  }
12181
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12182
  "node_modules/micromark-factory-destination": {
12183
  "version": "2.0.1",
12184
  "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -12790,6 +13439,21 @@
12790
  "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
12791
  "license": "MIT"
12792
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12793
  "node_modules/normalize-path": {
12794
  "version": "3.0.0",
12795
  "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -13152,6 +13816,12 @@
13152
  "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
13153
  "license": "BlueOak-1.0.0"
13154
  },
 
 
 
 
 
 
13155
  "node_modules/param-case": {
13156
  "version": "3.0.4",
13157
  "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -14854,6 +15524,12 @@
14854
  "url": "https://github.com/sponsors/wooorm"
14855
  }
14856
  },
 
 
 
 
 
 
14857
  "node_modules/proxy-addr": {
14858
  "version": "2.0.7",
14859
  "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -14929,6 +15605,15 @@
14929
  "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
14930
  "license": "MIT"
14931
  },
 
 
 
 
 
 
 
 
 
14932
  "node_modules/queue-microtask": {
14933
  "version": "1.2.3",
14934
  "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -15495,6 +16180,25 @@
15495
  "node": ">=6"
15496
  }
15497
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15498
  "node_modules/relateurl": {
15499
  "version": "0.2.7",
15500
  "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@@ -15522,6 +16226,22 @@
15522
  "url": "https://opencollective.com/unified"
15523
  }
15524
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15525
  "node_modules/remark-parse": {
15526
  "version": "11.0.0",
15527
  "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -15607,12 +16327,6 @@
15607
  "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
15608
  "license": "MIT"
15609
  },
15610
- "node_modules/resize-observer-polyfill": {
15611
- "version": "1.5.1",
15612
- "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
15613
- "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
15614
- "license": "MIT"
15615
- },
15616
  "node_modules/resolve": {
15617
  "version": "1.22.10",
15618
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -15729,6 +16443,12 @@
15729
  "node": ">=10"
15730
  }
15731
  },
 
 
 
 
 
 
15732
  "node_modules/retry": {
15733
  "version": "0.13.1",
15734
  "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -16670,6 +17390,11 @@
16670
  "node": ">= 0.8"
16671
  }
16672
  },
 
 
 
 
 
16673
  "node_modules/stop-iteration-iterator": {
16674
  "version": "1.1.0",
16675
  "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -16979,6 +17704,12 @@
16979
  "webpack": "^5.0.0"
16980
  }
16981
  },
 
 
 
 
 
 
16982
  "node_modules/style-to-js": {
16983
  "version": "1.1.17",
16984
  "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz",
@@ -17131,6 +17862,30 @@
17131
  "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
17132
  "license": "MIT"
17133
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17134
  "node_modules/svgo": {
17135
  "version": "1.3.2",
17136
  "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz",
@@ -17470,6 +18225,12 @@
17470
  "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
17471
  "license": "MIT"
17472
  },
 
 
 
 
 
 
17473
  "node_modules/tmpl": {
17474
  "version": "1.0.5",
17475
  "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -17754,6 +18515,20 @@
17754
  "is-typedarray": "^1.0.0"
17755
  }
17756
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17757
  "node_modules/unbox-primitive": {
17758
  "version": "1.1.0",
17759
  "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -17815,6 +18590,16 @@
17815
  "node": ">=4"
17816
  }
17817
  },
 
 
 
 
 
 
 
 
 
 
17818
  "node_modules/unicode-property-aliases-ecmascript": {
17819
  "version": "2.1.0",
17820
  "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
@@ -17824,6 +18609,16 @@
17824
  "node": ">=4"
17825
  }
17826
  },
 
 
 
 
 
 
 
 
 
 
17827
  "node_modules/unified": {
17828
  "version": "11.0.5",
17829
  "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -17867,6 +18662,20 @@
17867
  "node": ">=8"
17868
  }
17869
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17870
  "node_modules/unist-util-is": {
17871
  "version": "6.0.0",
17872
  "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -17893,6 +18702,20 @@
17893
  "url": "https://opencollective.com/unified"
17894
  }
17895
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17896
  "node_modules/unist-util-stringify-position": {
17897
  "version": "4.0.0",
17898
  "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -18106,6 +18929,20 @@
18106
  "url": "https://opencollective.com/unified"
18107
  }
18108
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18109
  "node_modules/vfile-message": {
18110
  "version": "4.0.3",
18111
  "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -18130,6 +18967,12 @@
18130
  "browser-process-hrtime": "^1.0.0"
18131
  }
18132
  },
 
 
 
 
 
 
18133
  "node_modules/w3c-xmlserializer": {
18134
  "version": "2.0.0",
18135
  "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz",
@@ -18173,6 +19016,16 @@
18173
  "minimalistic-assert": "^1.0.0"
18174
  }
18175
  },
 
 
 
 
 
 
 
 
 
 
18176
  "node_modules/web-vitals": {
18177
  "version": "2.1.4",
18178
  "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
 
8
  "name": "phd-advisor-frontend",
9
  "version": "0.1.0",
10
  "dependencies": {
11
+ "@codemirror/legacy-modes": "^6.5.2",
12
  "@testing-library/dom": "^10.4.0",
13
  "@testing-library/jest-dom": "^6.6.3",
14
  "@testing-library/react": "^16.3.0",
15
  "@testing-library/user-event": "^13.5.0",
16
+ "@uiw/react-codemirror": "^4.25.9",
17
+ "katex": "^0.16.45",
18
+ "latex.js": "^0.12.6",
19
  "lucide-react": "^0.544.0",
20
  "react": "^19.1.0",
21
  "react-dom": "^19.1.0",
22
  "react-markdown": "^10.1.0",
23
  "react-scripts": "5.0.1",
24
+ "rehype-katex": "^7.0.1",
25
  "remark-gfm": "^4.0.1",
26
+ "remark-math": "^6.0.0",
27
  "web-vitals": "^2.1.4"
28
  }
29
  },
 
2074
  "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
2075
  "license": "MIT"
2076
  },
2077
+ "node_modules/@codemirror/autocomplete": {
2078
+ "version": "6.20.2",
2079
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
2080
+ "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
2081
+ "license": "MIT",
2082
+ "dependencies": {
2083
+ "@codemirror/language": "^6.0.0",
2084
+ "@codemirror/state": "^6.0.0",
2085
+ "@codemirror/view": "^6.17.0",
2086
+ "@lezer/common": "^1.0.0"
2087
+ }
2088
+ },
2089
+ "node_modules/@codemirror/commands": {
2090
+ "version": "6.10.3",
2091
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
2092
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
2093
+ "license": "MIT",
2094
+ "dependencies": {
2095
+ "@codemirror/language": "^6.0.0",
2096
+ "@codemirror/state": "^6.6.0",
2097
+ "@codemirror/view": "^6.27.0",
2098
+ "@lezer/common": "^1.1.0"
2099
+ }
2100
+ },
2101
+ "node_modules/@codemirror/language": {
2102
+ "version": "6.12.3",
2103
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
2104
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
2105
+ "license": "MIT",
2106
+ "dependencies": {
2107
+ "@codemirror/state": "^6.0.0",
2108
+ "@codemirror/view": "^6.23.0",
2109
+ "@lezer/common": "^1.5.0",
2110
+ "@lezer/highlight": "^1.0.0",
2111
+ "@lezer/lr": "^1.0.0",
2112
+ "style-mod": "^4.0.0"
2113
+ }
2114
+ },
2115
+ "node_modules/@codemirror/legacy-modes": {
2116
+ "version": "6.5.2",
2117
+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
2118
+ "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
2119
+ "license": "MIT",
2120
+ "dependencies": {
2121
+ "@codemirror/language": "^6.0.0"
2122
+ }
2123
+ },
2124
+ "node_modules/@codemirror/lint": {
2125
+ "version": "6.9.6",
2126
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
2127
+ "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
2128
+ "license": "MIT",
2129
+ "dependencies": {
2130
+ "@codemirror/state": "^6.0.0",
2131
+ "@codemirror/view": "^6.42.0",
2132
+ "crelt": "^1.0.5"
2133
+ }
2134
+ },
2135
+ "node_modules/@codemirror/search": {
2136
+ "version": "6.7.0",
2137
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
2138
+ "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
2139
+ "license": "MIT",
2140
+ "dependencies": {
2141
+ "@codemirror/state": "^6.0.0",
2142
+ "@codemirror/view": "^6.37.0",
2143
+ "crelt": "^1.0.5"
2144
+ }
2145
+ },
2146
+ "node_modules/@codemirror/state": {
2147
+ "version": "6.6.0",
2148
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
2149
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
2150
+ "license": "MIT",
2151
+ "dependencies": {
2152
+ "@marijn/find-cluster-break": "^1.0.0"
2153
+ }
2154
+ },
2155
+ "node_modules/@codemirror/theme-one-dark": {
2156
+ "version": "6.1.3",
2157
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
2158
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
2159
+ "license": "MIT",
2160
+ "dependencies": {
2161
+ "@codemirror/language": "^6.0.0",
2162
+ "@codemirror/state": "^6.0.0",
2163
+ "@codemirror/view": "^6.0.0",
2164
+ "@lezer/highlight": "^1.0.0"
2165
+ }
2166
+ },
2167
+ "node_modules/@codemirror/view": {
2168
+ "version": "6.42.1",
2169
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz",
2170
+ "integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==",
2171
+ "license": "MIT",
2172
+ "dependencies": {
2173
+ "@codemirror/state": "^6.6.0",
2174
+ "crelt": "^1.0.6",
2175
+ "style-mod": "^4.1.0",
2176
+ "w3c-keyname": "^2.2.4"
2177
+ }
2178
+ },
2179
  "node_modules/@csstools/normalize.css": {
2180
  "version": "12.1.1",
2181
  "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz",
 
3074
  "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
3075
  "license": "MIT"
3076
  },
3077
+ "node_modules/@lezer/common": {
3078
+ "version": "1.5.2",
3079
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
3080
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
3081
+ "license": "MIT"
3082
+ },
3083
+ "node_modules/@lezer/highlight": {
3084
+ "version": "1.2.3",
3085
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
3086
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
3087
+ "license": "MIT",
3088
+ "dependencies": {
3089
+ "@lezer/common": "^1.3.0"
3090
+ }
3091
+ },
3092
+ "node_modules/@lezer/lr": {
3093
+ "version": "1.4.10",
3094
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
3095
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
3096
+ "license": "MIT",
3097
+ "dependencies": {
3098
+ "@lezer/common": "^1.0.0"
3099
+ }
3100
+ },
3101
+ "node_modules/@marijn/find-cluster-break": {
3102
+ "version": "1.0.2",
3103
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
3104
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
3105
+ "license": "MIT"
3106
+ },
3107
  "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
3108
  "version": "5.1.1-v1",
3109
  "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
 
3170
  "node": ">= 8"
3171
  }
3172
  },
3173
+ "node_modules/@one-ini/wasm": {
3174
+ "version": "0.1.1",
3175
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
3176
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
3177
+ "license": "MIT"
3178
+ },
3179
  "node_modules/@pkgjs/parseargs": {
3180
  "version": "0.11.0",
3181
  "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
 
3234
  }
3235
  }
3236
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3237
  "node_modules/@rollup/plugin-babel": {
3238
  "version": "5.3.1",
3239
  "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
 
3313
  "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
3314
  "license": "MIT"
3315
  },
 
 
 
 
 
 
 
 
 
3316
  "node_modules/@rtsao/scc": {
3317
  "version": "1.1.0",
3318
  "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
 
3582
  "url": "https://github.com/sponsors/gregberge"
3583
  }
3584
  },
3585
+ "node_modules/@swc/helpers": {
3586
+ "version": "0.5.21",
3587
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
3588
+ "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
3589
+ "license": "Apache-2.0",
3590
+ "dependencies": {
3591
+ "tslib": "^2.8.0"
3592
+ }
3593
+ },
3594
  "node_modules/@testing-library/dom": {
3595
  "version": "10.4.0",
3596
  "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
 
3950
  "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
3951
  "license": "MIT"
3952
  },
3953
+ "node_modules/@types/katex": {
3954
+ "version": "0.16.8",
3955
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
3956
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
3957
+ "license": "MIT"
3958
+ },
3959
  "node_modules/@types/mdast": {
3960
  "version": "4.0.4",
3961
  "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
 
4025
  "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
4026
  "license": "MIT"
4027
  },
4028
+ "node_modules/@types/react": {
4029
+ "version": "19.2.14",
4030
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
4031
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
4032
+ "license": "MIT",
4033
+ "peer": true,
4034
+ "dependencies": {
4035
+ "csstype": "^3.2.2"
4036
+ }
4037
+ },
4038
  "node_modules/@types/resolve": {
4039
  "version": "1.17.1",
4040
  "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
 
4366
  "url": "https://opencollective.com/typescript-eslint"
4367
  }
4368
  },
4369
+ "node_modules/@uiw/codemirror-extensions-basic-setup": {
4370
+ "version": "4.25.9",
4371
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz",
4372
+ "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==",
4373
+ "license": "MIT",
4374
+ "dependencies": {
4375
+ "@codemirror/autocomplete": "^6.0.0",
4376
+ "@codemirror/commands": "^6.0.0",
4377
+ "@codemirror/language": "^6.0.0",
4378
+ "@codemirror/lint": "^6.0.0",
4379
+ "@codemirror/search": "^6.0.0",
4380
+ "@codemirror/state": "^6.0.0",
4381
+ "@codemirror/view": "^6.0.0"
4382
+ },
4383
+ "funding": {
4384
+ "url": "https://jaywcjlove.github.io/#/sponsor"
4385
+ },
4386
+ "peerDependencies": {
4387
+ "@codemirror/autocomplete": ">=6.0.0",
4388
+ "@codemirror/commands": ">=6.0.0",
4389
+ "@codemirror/language": ">=6.0.0",
4390
+ "@codemirror/lint": ">=6.0.0",
4391
+ "@codemirror/search": ">=6.0.0",
4392
+ "@codemirror/state": ">=6.0.0",
4393
+ "@codemirror/view": ">=6.0.0"
4394
+ }
4395
+ },
4396
+ "node_modules/@uiw/react-codemirror": {
4397
+ "version": "4.25.9",
4398
+ "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz",
4399
+ "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==",
4400
+ "license": "MIT",
4401
+ "dependencies": {
4402
+ "@babel/runtime": "^7.18.6",
4403
+ "@codemirror/commands": "^6.1.0",
4404
+ "@codemirror/state": "^6.1.1",
4405
+ "@codemirror/theme-one-dark": "^6.0.0",
4406
+ "@uiw/codemirror-extensions-basic-setup": "4.25.9",
4407
+ "codemirror": "^6.0.0"
4408
+ },
4409
+ "funding": {
4410
+ "url": "https://jaywcjlove.github.io/#/sponsor"
4411
+ },
4412
+ "peerDependencies": {
4413
+ "@babel/runtime": ">=7.11.0",
4414
+ "@codemirror/state": ">=6.0.0",
4415
+ "@codemirror/theme-one-dark": ">=6.0.0",
4416
+ "@codemirror/view": ">=6.0.0",
4417
+ "codemirror": ">=6.0.0",
4418
+ "react": ">=17.0.0",
4419
+ "react-dom": ">=17.0.0"
4420
+ }
4421
+ },
4422
  "node_modules/@ungap/structured-clone": {
4423
  "version": "1.3.0",
4424
  "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
 
4590
  "deprecated": "Use your platform's native atob() and btoa() methods instead",
4591
  "license": "BSD-3-Clause"
4592
  },
4593
+ "node_modules/abbrev": {
4594
+ "version": "2.0.0",
4595
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
4596
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
4597
+ "license": "ISC",
4598
+ "engines": {
4599
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
4600
+ }
4601
+ },
4602
  "node_modules/accepts": {
4603
  "version": "1.3.8",
4604
  "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
 
5448
  "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
5449
  "license": "MIT"
5450
  },
5451
+ "node_modules/base64-js": {
5452
+ "version": "1.5.1",
5453
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
5454
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
5455
+ "funding": [
5456
+ {
5457
+ "type": "github",
5458
+ "url": "https://github.com/sponsors/feross"
5459
+ },
5460
+ {
5461
+ "type": "patreon",
5462
+ "url": "https://www.patreon.com/feross"
5463
+ },
5464
+ {
5465
+ "type": "consulting",
5466
+ "url": "https://feross.org/support"
5467
+ }
5468
+ ],
5469
+ "license": "MIT"
5470
+ },
5471
  "node_modules/batch": {
5472
  "version": "0.6.1",
5473
  "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
 
5606
  "node": ">=8"
5607
  }
5608
  },
5609
+ "node_modules/brotli": {
5610
+ "version": "1.3.3",
5611
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
5612
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
5613
+ "license": "MIT",
5614
+ "dependencies": {
5615
+ "base64-js": "^1.1.2"
5616
+ }
5617
+ },
5618
  "node_modules/browser-process-hrtime": {
5619
  "version": "1.0.0",
5620
  "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
 
5996
  "wrap-ansi": "^7.0.0"
5997
  }
5998
  },
5999
+ "node_modules/clone": {
6000
+ "version": "2.1.2",
6001
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
6002
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
6003
+ "license": "MIT",
6004
+ "engines": {
6005
+ "node": ">=0.8"
6006
+ }
6007
+ },
6008
  "node_modules/co": {
6009
  "version": "4.6.0",
6010
  "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
 
6100
  "node": ">=4"
6101
  }
6102
  },
6103
+ "node_modules/codemirror": {
6104
+ "version": "6.0.2",
6105
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
6106
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
6107
+ "license": "MIT",
6108
+ "dependencies": {
6109
+ "@codemirror/autocomplete": "^6.0.0",
6110
+ "@codemirror/commands": "^6.0.0",
6111
+ "@codemirror/language": "^6.0.0",
6112
+ "@codemirror/lint": "^6.0.0",
6113
+ "@codemirror/search": "^6.0.0",
6114
+ "@codemirror/state": "^6.0.0",
6115
+ "@codemirror/view": "^6.0.0"
6116
+ }
6117
+ },
6118
  "node_modules/collect-v8-coverage": {
6119
  "version": "1.0.2",
6120
  "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
 
6248
  "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
6249
  "license": "MIT"
6250
  },
6251
+ "node_modules/config-chain": {
6252
+ "version": "1.1.13",
6253
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
6254
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
6255
+ "license": "MIT",
6256
+ "dependencies": {
6257
+ "ini": "^1.3.4",
6258
+ "proto-list": "~1.2.1"
6259
+ }
6260
+ },
6261
  "node_modules/confusing-browser-globals": {
6262
  "version": "1.0.11",
6263
  "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
 
6372
  "node": ">=10"
6373
  }
6374
  },
6375
+ "node_modules/crelt": {
6376
+ "version": "1.0.6",
6377
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
6378
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
6379
+ "license": "MIT"
6380
+ },
6381
  "node_modules/cross-spawn": {
6382
  "version": "7.0.6",
6383
  "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 
6776
  "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
6777
  "license": "MIT"
6778
  },
6779
+ "node_modules/csstype": {
6780
+ "version": "3.2.3",
6781
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
6782
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
6783
+ "license": "MIT",
6784
+ "peer": true
6785
+ },
6786
  "node_modules/damerau-levenshtein": {
6787
  "version": "1.0.8",
6788
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
 
7063
  "url": "https://github.com/sponsors/wooorm"
7064
  }
7065
  },
7066
+ "node_modules/dfa": {
7067
+ "version": "1.2.0",
7068
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
7069
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
7070
+ "license": "MIT"
7071
+ },
7072
  "node_modules/didyoumean": {
7073
  "version": "1.2.2",
7074
  "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
 
7269
  "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
7270
  "license": "MIT"
7271
  },
7272
+ "node_modules/editorconfig": {
7273
+ "version": "1.0.7",
7274
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
7275
+ "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
7276
+ "license": "MIT",
7277
+ "dependencies": {
7278
+ "@one-ini/wasm": "0.1.1",
7279
+ "commander": "^10.0.0",
7280
+ "minimatch": "^9.0.1",
7281
+ "semver": "^7.5.3"
7282
+ },
7283
+ "bin": {
7284
+ "editorconfig": "bin/editorconfig"
7285
+ },
7286
+ "engines": {
7287
+ "node": ">=14"
7288
+ }
7289
+ },
7290
+ "node_modules/editorconfig/node_modules/brace-expansion": {
7291
+ "version": "2.1.0",
7292
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
7293
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
7294
+ "license": "MIT",
7295
+ "dependencies": {
7296
+ "balanced-match": "^1.0.0"
7297
+ }
7298
+ },
7299
+ "node_modules/editorconfig/node_modules/commander": {
7300
+ "version": "10.0.1",
7301
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
7302
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
7303
+ "license": "MIT",
7304
+ "engines": {
7305
+ "node": ">=14"
7306
+ }
7307
+ },
7308
+ "node_modules/editorconfig/node_modules/minimatch": {
7309
+ "version": "9.0.9",
7310
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
7311
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
7312
+ "license": "ISC",
7313
+ "dependencies": {
7314
+ "brace-expansion": "^2.0.2"
7315
+ },
7316
+ "engines": {
7317
+ "node": ">=16 || 14 >=14.17"
7318
+ },
7319
+ "funding": {
7320
+ "url": "https://github.com/sponsors/isaacs"
7321
+ }
7322
+ },
7323
  "node_modules/ee-first": {
7324
  "version": "1.1.1",
7325
  "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
 
8741
  }
8742
  }
8743
  },
8744
+ "node_modules/fontkit": {
8745
+ "version": "2.0.4",
8746
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
8747
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
8748
+ "license": "MIT",
8749
+ "dependencies": {
8750
+ "@swc/helpers": "^0.5.12",
8751
+ "brotli": "^1.3.2",
8752
+ "clone": "^2.1.2",
8753
+ "dfa": "^1.2.0",
8754
+ "fast-deep-equal": "^3.1.3",
8755
+ "restructure": "^3.0.0",
8756
+ "tiny-inflate": "^1.0.3",
8757
+ "unicode-properties": "^1.4.0",
8758
+ "unicode-trie": "^2.0.0"
8759
+ }
8760
+ },
8761
  "node_modules/for-each": {
8762
  "version": "0.3.5",
8763
  "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
 
9375
  "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
9376
  "license": "MIT",
9377
  "dependencies": {
9378
+ "function-bind": "^1.1.2"
9379
+ },
9380
+ "engines": {
9381
+ "node": ">= 0.4"
9382
+ }
9383
+ },
9384
+ "node_modules/hast-util-from-dom": {
9385
+ "version": "5.0.1",
9386
+ "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
9387
+ "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
9388
+ "license": "ISC",
9389
+ "dependencies": {
9390
+ "@types/hast": "^3.0.0",
9391
+ "hastscript": "^9.0.0",
9392
+ "web-namespaces": "^2.0.0"
9393
+ },
9394
+ "funding": {
9395
+ "type": "opencollective",
9396
+ "url": "https://opencollective.com/unified"
9397
+ }
9398
+ },
9399
+ "node_modules/hast-util-from-html": {
9400
+ "version": "2.0.3",
9401
+ "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
9402
+ "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
9403
+ "license": "MIT",
9404
+ "dependencies": {
9405
+ "@types/hast": "^3.0.0",
9406
+ "devlop": "^1.1.0",
9407
+ "hast-util-from-parse5": "^8.0.0",
9408
+ "parse5": "^7.0.0",
9409
+ "vfile": "^6.0.0",
9410
+ "vfile-message": "^4.0.0"
9411
+ },
9412
+ "funding": {
9413
+ "type": "opencollective",
9414
+ "url": "https://opencollective.com/unified"
9415
+ }
9416
+ },
9417
+ "node_modules/hast-util-from-html-isomorphic": {
9418
+ "version": "2.0.0",
9419
+ "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
9420
+ "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
9421
+ "license": "MIT",
9422
+ "dependencies": {
9423
+ "@types/hast": "^3.0.0",
9424
+ "hast-util-from-dom": "^5.0.0",
9425
+ "hast-util-from-html": "^2.0.0",
9426
+ "unist-util-remove-position": "^5.0.0"
9427
+ },
9428
+ "funding": {
9429
+ "type": "opencollective",
9430
+ "url": "https://opencollective.com/unified"
9431
+ }
9432
+ },
9433
+ "node_modules/hast-util-from-html/node_modules/entities": {
9434
+ "version": "6.0.1",
9435
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
9436
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
9437
+ "license": "BSD-2-Clause",
9438
+ "engines": {
9439
+ "node": ">=0.12"
9440
+ },
9441
+ "funding": {
9442
+ "url": "https://github.com/fb55/entities?sponsor=1"
9443
+ }
9444
+ },
9445
+ "node_modules/hast-util-from-html/node_modules/parse5": {
9446
+ "version": "7.3.0",
9447
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
9448
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
9449
+ "license": "MIT",
9450
+ "dependencies": {
9451
+ "entities": "^6.0.0"
9452
+ },
9453
+ "funding": {
9454
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
9455
+ }
9456
+ },
9457
+ "node_modules/hast-util-from-parse5": {
9458
+ "version": "8.0.3",
9459
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
9460
+ "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
9461
+ "license": "MIT",
9462
+ "dependencies": {
9463
+ "@types/hast": "^3.0.0",
9464
+ "@types/unist": "^3.0.0",
9465
+ "devlop": "^1.0.0",
9466
+ "hastscript": "^9.0.0",
9467
+ "property-information": "^7.0.0",
9468
+ "vfile": "^6.0.0",
9469
+ "vfile-location": "^5.0.0",
9470
+ "web-namespaces": "^2.0.0"
9471
+ },
9472
+ "funding": {
9473
+ "type": "opencollective",
9474
+ "url": "https://opencollective.com/unified"
9475
+ }
9476
+ },
9477
+ "node_modules/hast-util-is-element": {
9478
+ "version": "3.0.0",
9479
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
9480
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
9481
+ "license": "MIT",
9482
+ "dependencies": {
9483
+ "@types/hast": "^3.0.0"
9484
+ },
9485
+ "funding": {
9486
+ "type": "opencollective",
9487
+ "url": "https://opencollective.com/unified"
9488
+ }
9489
+ },
9490
+ "node_modules/hast-util-parse-selector": {
9491
+ "version": "4.0.0",
9492
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
9493
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
9494
+ "license": "MIT",
9495
+ "dependencies": {
9496
+ "@types/hast": "^3.0.0"
9497
  },
9498
+ "funding": {
9499
+ "type": "opencollective",
9500
+ "url": "https://opencollective.com/unified"
9501
  }
9502
  },
9503
  "node_modules/hast-util-to-jsx-runtime": {
 
9527
  "url": "https://opencollective.com/unified"
9528
  }
9529
  },
9530
+ "node_modules/hast-util-to-text": {
9531
+ "version": "4.0.2",
9532
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
9533
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
9534
+ "license": "MIT",
9535
+ "dependencies": {
9536
+ "@types/hast": "^3.0.0",
9537
+ "@types/unist": "^3.0.0",
9538
+ "hast-util-is-element": "^3.0.0",
9539
+ "unist-util-find-after": "^5.0.0"
9540
+ },
9541
+ "funding": {
9542
+ "type": "opencollective",
9543
+ "url": "https://opencollective.com/unified"
9544
+ }
9545
+ },
9546
  "node_modules/hast-util-whitespace": {
9547
  "version": "3.0.0",
9548
  "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
 
9556
  "url": "https://opencollective.com/unified"
9557
  }
9558
  },
9559
+ "node_modules/hastscript": {
9560
+ "version": "9.0.1",
9561
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
9562
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
9563
+ "license": "MIT",
9564
+ "dependencies": {
9565
+ "@types/hast": "^3.0.0",
9566
+ "comma-separated-tokens": "^2.0.0",
9567
+ "hast-util-parse-selector": "^4.0.0",
9568
+ "property-information": "^7.0.0",
9569
+ "space-separated-tokens": "^2.0.0"
9570
+ },
9571
+ "funding": {
9572
+ "type": "opencollective",
9573
+ "url": "https://opencollective.com/unified"
9574
+ }
9575
+ },
9576
  "node_modules/he": {
9577
  "version": "1.2.0",
9578
  "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
 
9857
  "node": ">=10.17.0"
9858
  }
9859
  },
9860
+ "node_modules/hyphenation.de": {
9861
+ "version": "0.2.1",
9862
+ "resolved": "https://registry.npmjs.org/hyphenation.de/-/hyphenation.de-0.2.1.tgz",
9863
+ "integrity": "sha512-s6Y4TFA8xWjRLneOPI6HV/+wzfm2c2yurTvFaXlznmsbeI6waZhMpxu94fSXGNGsrPxrzI1zTtYDEWeEeaANnw==",
9864
+ "dependencies": {
9865
+ "hypher": "*"
9866
+ }
9867
+ },
9868
+ "node_modules/hyphenation.en-us": {
9869
+ "version": "0.2.1",
9870
+ "resolved": "https://registry.npmjs.org/hyphenation.en-us/-/hyphenation.en-us-0.2.1.tgz",
9871
+ "integrity": "sha512-ItXYgvIpfN8rfXl/GTBQC7DsSb5PPsKh9gGzViK/iWzCS5mvjDebFJ6xCcIYo8dal+nSp2rUzvTT7BosrKlL8A==",
9872
+ "dependencies": {
9873
+ "hypher": "*"
9874
+ }
9875
+ },
9876
+ "node_modules/hypher": {
9877
+ "version": "0.2.5",
9878
+ "resolved": "https://registry.npmjs.org/hypher/-/hypher-0.2.5.tgz",
9879
+ "integrity": "sha512-kUTpuyzBWWDO2VakmjHC/cxesg4lKQP+Fdc+7lrK4yvjNjkV9vm5UTZMDAwOyyHTOpbkYrAMlNZHG61NnE9vYQ==",
9880
+ "license": "BSD-3-Clause"
9881
+ },
9882
  "node_modules/iconv-lite": {
9883
  "version": "0.6.3",
9884
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
 
9930
  "node": ">= 4"
9931
  }
9932
  },
9933
+ "node_modules/image-size": {
9934
+ "version": "1.2.1",
9935
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
9936
+ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
9937
+ "license": "MIT",
9938
+ "dependencies": {
9939
+ "queue": "6.0.2"
9940
+ },
9941
+ "bin": {
9942
+ "image-size": "bin/image-size.js"
9943
+ },
9944
+ "engines": {
9945
+ "node": ">=16.x"
9946
+ }
9947
+ },
9948
  "node_modules/immer": {
9949
  "version": "9.0.21",
9950
  "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
 
11687
  "jiti": "bin/jiti.js"
11688
  }
11689
  },
11690
+ "node_modules/js-beautify": {
11691
+ "version": "1.14.11",
11692
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.11.tgz",
11693
+ "integrity": "sha512-rPogWqAfoYh1Ryqqh2agUpVfbxAhbjuN1SmU86dskQUKouRiggUTCO4+2ym9UPXllc2WAp0J+T5qxn7Um3lCdw==",
11694
+ "license": "MIT",
11695
+ "dependencies": {
11696
+ "config-chain": "^1.1.13",
11697
+ "editorconfig": "^1.0.3",
11698
+ "glob": "^10.3.3",
11699
+ "nopt": "^7.2.0"
11700
+ },
11701
+ "bin": {
11702
+ "css-beautify": "js/bin/css-beautify.js",
11703
+ "html-beautify": "js/bin/html-beautify.js",
11704
+ "js-beautify": "js/bin/js-beautify.js"
11705
+ },
11706
+ "engines": {
11707
+ "node": ">=14"
11708
+ }
11709
+ },
11710
+ "node_modules/js-beautify/node_modules/brace-expansion": {
11711
+ "version": "2.1.0",
11712
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
11713
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
11714
+ "license": "MIT",
11715
+ "dependencies": {
11716
+ "balanced-match": "^1.0.0"
11717
+ }
11718
+ },
11719
+ "node_modules/js-beautify/node_modules/glob": {
11720
+ "version": "10.5.0",
11721
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
11722
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
11723
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
11724
+ "license": "ISC",
11725
+ "dependencies": {
11726
+ "foreground-child": "^3.1.0",
11727
+ "jackspeak": "^3.1.2",
11728
+ "minimatch": "^9.0.4",
11729
+ "minipass": "^7.1.2",
11730
+ "package-json-from-dist": "^1.0.0",
11731
+ "path-scurry": "^1.11.1"
11732
+ },
11733
+ "bin": {
11734
+ "glob": "dist/esm/bin.mjs"
11735
+ },
11736
+ "funding": {
11737
+ "url": "https://github.com/sponsors/isaacs"
11738
+ }
11739
+ },
11740
+ "node_modules/js-beautify/node_modules/minimatch": {
11741
+ "version": "9.0.9",
11742
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
11743
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
11744
+ "license": "ISC",
11745
+ "dependencies": {
11746
+ "brace-expansion": "^2.0.2"
11747
+ },
11748
+ "engines": {
11749
+ "node": ">=16 || 14 >=14.17"
11750
+ },
11751
+ "funding": {
11752
+ "url": "https://github.com/sponsors/isaacs"
11753
+ }
11754
+ },
11755
  "node_modules/js-tokens": {
11756
  "version": "4.0.0",
11757
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
 
11930
  "node": ">=4.0"
11931
  }
11932
  },
11933
+ "node_modules/katex": {
11934
+ "version": "0.16.45",
11935
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
11936
+ "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==",
11937
+ "funding": [
11938
+ "https://opencollective.com/katex",
11939
+ "https://github.com/sponsors/katex"
11940
+ ],
11941
+ "license": "MIT",
11942
+ "dependencies": {
11943
+ "commander": "^8.3.0"
11944
+ },
11945
+ "bin": {
11946
+ "katex": "cli.js"
11947
+ }
11948
+ },
11949
  "node_modules/keyv": {
11950
  "version": "4.5.4",
11951
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
 
12000
  "node": ">=0.10"
12001
  }
12002
  },
12003
+ "node_modules/latex.js": {
12004
+ "version": "0.12.6",
12005
+ "resolved": "https://registry.npmjs.org/latex.js/-/latex.js-0.12.6.tgz",
12006
+ "integrity": "sha512-spMTeSq9cP4vidMQuPgoYGKmsQTMElZhDtNF3NyLIRc03XkuuPvMA0tzb0ShS/otaIJrB7DIHDk0GL3knCisEw==",
12007
+ "license": "MIT",
12008
+ "dependencies": {
12009
+ "commander": "8.x",
12010
+ "fs-extra": "10.x",
12011
+ "hyphenation.de": "*",
12012
+ "hyphenation.en-us": "*",
12013
+ "js-beautify": "1.14.x",
12014
+ "stdin": "*",
12015
+ "svgdom": "^0.1.8"
12016
+ },
12017
+ "bin": {
12018
+ "latex.js": "bin/latex.js"
12019
+ },
12020
+ "engines": {
12021
+ "node": ">= 14.0"
12022
+ }
12023
+ },
12024
  "node_modules/launch-editor": {
12025
  "version": "2.10.0",
12026
  "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz",
 
12411
  "url": "https://opencollective.com/unified"
12412
  }
12413
  },
12414
+ "node_modules/mdast-util-math": {
12415
+ "version": "3.0.0",
12416
+ "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
12417
+ "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
12418
+ "license": "MIT",
12419
+ "dependencies": {
12420
+ "@types/hast": "^3.0.0",
12421
+ "@types/mdast": "^4.0.0",
12422
+ "devlop": "^1.0.0",
12423
+ "longest-streak": "^3.0.0",
12424
+ "mdast-util-from-markdown": "^2.0.0",
12425
+ "mdast-util-to-markdown": "^2.1.0",
12426
+ "unist-util-remove-position": "^5.0.0"
12427
+ },
12428
+ "funding": {
12429
+ "type": "opencollective",
12430
+ "url": "https://opencollective.com/unified"
12431
+ }
12432
+ },
12433
  "node_modules/mdast-util-mdx-expression": {
12434
  "version": "2.0.1",
12435
  "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
 
12809
  "url": "https://opencollective.com/unified"
12810
  }
12811
  },
12812
+ "node_modules/micromark-extension-math": {
12813
+ "version": "3.1.0",
12814
+ "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
12815
+ "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
12816
+ "license": "MIT",
12817
+ "dependencies": {
12818
+ "@types/katex": "^0.16.0",
12819
+ "devlop": "^1.0.0",
12820
+ "katex": "^0.16.0",
12821
+ "micromark-factory-space": "^2.0.0",
12822
+ "micromark-util-character": "^2.0.0",
12823
+ "micromark-util-symbol": "^2.0.0",
12824
+ "micromark-util-types": "^2.0.0"
12825
+ },
12826
+ "funding": {
12827
+ "type": "opencollective",
12828
+ "url": "https://opencollective.com/unified"
12829
+ }
12830
+ },
12831
  "node_modules/micromark-factory-destination": {
12832
  "version": "2.0.1",
12833
  "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
 
13439
  "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
13440
  "license": "MIT"
13441
  },
13442
+ "node_modules/nopt": {
13443
+ "version": "7.2.1",
13444
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
13445
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
13446
+ "license": "ISC",
13447
+ "dependencies": {
13448
+ "abbrev": "^2.0.0"
13449
+ },
13450
+ "bin": {
13451
+ "nopt": "bin/nopt.js"
13452
+ },
13453
+ "engines": {
13454
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
13455
+ }
13456
+ },
13457
  "node_modules/normalize-path": {
13458
  "version": "3.0.0",
13459
  "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
 
13816
  "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
13817
  "license": "BlueOak-1.0.0"
13818
  },
13819
+ "node_modules/pako": {
13820
+ "version": "0.2.9",
13821
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
13822
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
13823
+ "license": "MIT"
13824
+ },
13825
  "node_modules/param-case": {
13826
  "version": "3.0.4",
13827
  "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
 
15524
  "url": "https://github.com/sponsors/wooorm"
15525
  }
15526
  },
15527
+ "node_modules/proto-list": {
15528
+ "version": "1.2.4",
15529
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
15530
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
15531
+ "license": "ISC"
15532
+ },
15533
  "node_modules/proxy-addr": {
15534
  "version": "2.0.7",
15535
  "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
 
15605
  "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
15606
  "license": "MIT"
15607
  },
15608
+ "node_modules/queue": {
15609
+ "version": "6.0.2",
15610
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
15611
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
15612
+ "license": "MIT",
15613
+ "dependencies": {
15614
+ "inherits": "~2.0.3"
15615
+ }
15616
+ },
15617
  "node_modules/queue-microtask": {
15618
  "version": "1.2.3",
15619
  "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
 
16180
  "node": ">=6"
16181
  }
16182
  },
16183
+ "node_modules/rehype-katex": {
16184
+ "version": "7.0.1",
16185
+ "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
16186
+ "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
16187
+ "license": "MIT",
16188
+ "dependencies": {
16189
+ "@types/hast": "^3.0.0",
16190
+ "@types/katex": "^0.16.0",
16191
+ "hast-util-from-html-isomorphic": "^2.0.0",
16192
+ "hast-util-to-text": "^4.0.0",
16193
+ "katex": "^0.16.0",
16194
+ "unist-util-visit-parents": "^6.0.0",
16195
+ "vfile": "^6.0.0"
16196
+ },
16197
+ "funding": {
16198
+ "type": "opencollective",
16199
+ "url": "https://opencollective.com/unified"
16200
+ }
16201
+ },
16202
  "node_modules/relateurl": {
16203
  "version": "0.2.7",
16204
  "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
 
16226
  "url": "https://opencollective.com/unified"
16227
  }
16228
  },
16229
+ "node_modules/remark-math": {
16230
+ "version": "6.0.0",
16231
+ "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
16232
+ "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
16233
+ "license": "MIT",
16234
+ "dependencies": {
16235
+ "@types/mdast": "^4.0.0",
16236
+ "mdast-util-math": "^3.0.0",
16237
+ "micromark-extension-math": "^3.0.0",
16238
+ "unified": "^11.0.0"
16239
+ },
16240
+ "funding": {
16241
+ "type": "opencollective",
16242
+ "url": "https://opencollective.com/unified"
16243
+ }
16244
+ },
16245
  "node_modules/remark-parse": {
16246
  "version": "11.0.0",
16247
  "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
 
16327
  "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
16328
  "license": "MIT"
16329
  },
 
 
 
 
 
 
16330
  "node_modules/resolve": {
16331
  "version": "1.22.10",
16332
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
 
16443
  "node": ">=10"
16444
  }
16445
  },
16446
+ "node_modules/restructure": {
16447
+ "version": "3.0.2",
16448
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
16449
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
16450
+ "license": "MIT"
16451
+ },
16452
  "node_modules/retry": {
16453
  "version": "0.13.1",
16454
  "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
 
17390
  "node": ">= 0.8"
17391
  }
17392
  },
17393
+ "node_modules/stdin": {
17394
+ "version": "0.0.1",
17395
+ "resolved": "https://registry.npmjs.org/stdin/-/stdin-0.0.1.tgz",
17396
+ "integrity": "sha512-2bacd1TXzqOEsqRa+eEWkRdOSznwptrs4gqFcpMq5tOtmJUGPZd10W5Lam6wQ4YQ/+qjQt4e9u35yXCF6mrlfQ=="
17397
+ },
17398
  "node_modules/stop-iteration-iterator": {
17399
  "version": "1.1.0",
17400
  "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
 
17704
  "webpack": "^5.0.0"
17705
  }
17706
  },
17707
+ "node_modules/style-mod": {
17708
+ "version": "4.1.3",
17709
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
17710
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
17711
+ "license": "MIT"
17712
+ },
17713
  "node_modules/style-to-js": {
17714
  "version": "1.1.17",
17715
  "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz",
 
17862
  "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
17863
  "license": "MIT"
17864
  },
17865
+ "node_modules/svgdom": {
17866
+ "version": "0.1.23",
17867
+ "resolved": "https://registry.npmjs.org/svgdom/-/svgdom-0.1.23.tgz",
17868
+ "integrity": "sha512-zTT8cz8rf07OCvRhTipJv+bt/MgL5Zm2ZF3IttK9zr/MKVDTrgo+usbDQMOK1PGzqDT7GA1SshnYlXvtafK7Fw==",
17869
+ "license": "MIT",
17870
+ "dependencies": {
17871
+ "fontkit": "^2.0.4",
17872
+ "image-size": "^1.2.1",
17873
+ "sax": "^1.4.1"
17874
+ },
17875
+ "funding": {
17876
+ "type": "github",
17877
+ "url": "https://github.com/sponsors/Fuzzyma"
17878
+ }
17879
+ },
17880
+ "node_modules/svgdom/node_modules/sax": {
17881
+ "version": "1.6.0",
17882
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
17883
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
17884
+ "license": "BlueOak-1.0.0",
17885
+ "engines": {
17886
+ "node": ">=11.0.0"
17887
+ }
17888
+ },
17889
  "node_modules/svgo": {
17890
  "version": "1.3.2",
17891
  "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz",
 
18225
  "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
18226
  "license": "MIT"
18227
  },
18228
+ "node_modules/tiny-inflate": {
18229
+ "version": "1.0.3",
18230
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
18231
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
18232
+ "license": "MIT"
18233
+ },
18234
  "node_modules/tmpl": {
18235
  "version": "1.0.5",
18236
  "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
 
18515
  "is-typedarray": "^1.0.0"
18516
  }
18517
  },
18518
+ "node_modules/typescript": {
18519
+ "version": "4.9.5",
18520
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
18521
+ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
18522
+ "license": "Apache-2.0",
18523
+ "peer": true,
18524
+ "bin": {
18525
+ "tsc": "bin/tsc",
18526
+ "tsserver": "bin/tsserver"
18527
+ },
18528
+ "engines": {
18529
+ "node": ">=4.2.0"
18530
+ }
18531
+ },
18532
  "node_modules/unbox-primitive": {
18533
  "version": "1.1.0",
18534
  "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
 
18590
  "node": ">=4"
18591
  }
18592
  },
18593
+ "node_modules/unicode-properties": {
18594
+ "version": "1.4.1",
18595
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
18596
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
18597
+ "license": "MIT",
18598
+ "dependencies": {
18599
+ "base64-js": "^1.3.0",
18600
+ "unicode-trie": "^2.0.0"
18601
+ }
18602
+ },
18603
  "node_modules/unicode-property-aliases-ecmascript": {
18604
  "version": "2.1.0",
18605
  "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
 
18609
  "node": ">=4"
18610
  }
18611
  },
18612
+ "node_modules/unicode-trie": {
18613
+ "version": "2.0.0",
18614
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
18615
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
18616
+ "license": "MIT",
18617
+ "dependencies": {
18618
+ "pako": "^0.2.5",
18619
+ "tiny-inflate": "^1.0.0"
18620
+ }
18621
+ },
18622
  "node_modules/unified": {
18623
  "version": "11.0.5",
18624
  "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
 
18662
  "node": ">=8"
18663
  }
18664
  },
18665
+ "node_modules/unist-util-find-after": {
18666
+ "version": "5.0.0",
18667
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
18668
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
18669
+ "license": "MIT",
18670
+ "dependencies": {
18671
+ "@types/unist": "^3.0.0",
18672
+ "unist-util-is": "^6.0.0"
18673
+ },
18674
+ "funding": {
18675
+ "type": "opencollective",
18676
+ "url": "https://opencollective.com/unified"
18677
+ }
18678
+ },
18679
  "node_modules/unist-util-is": {
18680
  "version": "6.0.0",
18681
  "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
 
18702
  "url": "https://opencollective.com/unified"
18703
  }
18704
  },
18705
+ "node_modules/unist-util-remove-position": {
18706
+ "version": "5.0.0",
18707
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
18708
+ "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
18709
+ "license": "MIT",
18710
+ "dependencies": {
18711
+ "@types/unist": "^3.0.0",
18712
+ "unist-util-visit": "^5.0.0"
18713
+ },
18714
+ "funding": {
18715
+ "type": "opencollective",
18716
+ "url": "https://opencollective.com/unified"
18717
+ }
18718
+ },
18719
  "node_modules/unist-util-stringify-position": {
18720
  "version": "4.0.0",
18721
  "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
 
18929
  "url": "https://opencollective.com/unified"
18930
  }
18931
  },
18932
+ "node_modules/vfile-location": {
18933
+ "version": "5.0.3",
18934
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
18935
+ "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
18936
+ "license": "MIT",
18937
+ "dependencies": {
18938
+ "@types/unist": "^3.0.0",
18939
+ "vfile": "^6.0.0"
18940
+ },
18941
+ "funding": {
18942
+ "type": "opencollective",
18943
+ "url": "https://opencollective.com/unified"
18944
+ }
18945
+ },
18946
  "node_modules/vfile-message": {
18947
  "version": "4.0.3",
18948
  "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
 
18967
  "browser-process-hrtime": "^1.0.0"
18968
  }
18969
  },
18970
+ "node_modules/w3c-keyname": {
18971
+ "version": "2.2.8",
18972
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
18973
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
18974
+ "license": "MIT"
18975
+ },
18976
  "node_modules/w3c-xmlserializer": {
18977
  "version": "2.0.0",
18978
  "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz",
 
19016
  "minimalistic-assert": "^1.0.0"
19017
  }
19018
  },
19019
+ "node_modules/web-namespaces": {
19020
+ "version": "2.0.1",
19021
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
19022
+ "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
19023
+ "license": "MIT",
19024
+ "funding": {
19025
+ "type": "github",
19026
+ "url": "https://github.com/sponsors/wooorm"
19027
+ }
19028
+ },
19029
  "node_modules/web-vitals": {
19030
  "version": "2.1.4",
19031
  "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
phd-advisor-frontend/package.json CHANGED
@@ -3,17 +3,22 @@
3
  "version": "0.1.0",
4
  "private": true,
5
  "dependencies": {
 
6
  "@testing-library/dom": "^10.4.0",
7
  "@testing-library/jest-dom": "^6.6.3",
8
  "@testing-library/react": "^16.3.0",
9
  "@testing-library/user-event": "^13.5.0",
 
 
 
10
  "lucide-react": "^0.544.0",
11
  "react": "^19.1.0",
12
  "react-dom": "^19.1.0",
13
- "@reactour/tour": "^3.8.0",
14
  "react-markdown": "^10.1.0",
15
  "react-scripts": "5.0.1",
 
16
  "remark-gfm": "^4.0.1",
 
17
  "web-vitals": "^2.1.4"
18
  },
19
  "scripts": {
 
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": {
phd-advisor-frontend/public/index.html CHANGED
@@ -7,7 +7,7 @@
7
  <meta name="theme-color" content="#000000" />
8
  <meta
9
  name="description"
10
- content="AI Advisor Panel — AI-Powered Guidance from Multiple Experts"
11
  />
12
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
  <!--
@@ -24,7 +24,7 @@
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>React App</title>
28
  </head>
29
  <body>
30
  <noscript>You need to enable JavaScript to run this app.</noscript>
 
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
  <!--
 
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>
phd-advisor-frontend/public/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
- "short_name": "AI Advisor Panel",
3
- "name": "Neon AI Panel of Experts",
4
  "icons": [
5
  {
6
  "src": "favicon.ico",
 
1
  {
2
+ "short_name": "Cybersecurity Advisor",
3
+ "name": "Cybersecurity Advisor",
4
  "icons": [
5
  {
6
  "src": "favicon.ico",
phd-advisor-frontend/src/App.js CHANGED
@@ -8,11 +8,6 @@ import CanvasPage from './pages/CanvasPage';
8
  import UserGuide from './components/UserGuide';
9
  import './styles/components.css';
10
 
11
- // Set REACT_APP_TESTING_ONBOARDING=true in your .env to force the onboarding
12
- // tour to run on every page load. Leave unset in production — tour will only
13
- // show once per user (localStorage).
14
- export const TESTING_ONBOARDING = process.env.REACT_APP_TESTING_ONBOARDING === 'true';
15
-
16
  function App() {
17
  const [currentView, setCurrentView] = useState('home');
18
  const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -43,7 +38,10 @@ function App() {
43
  setCurrentView('auth');
44
  };
45
 
46
- const navigateToCanvas = () => {
 
 
 
47
  setCurrentView('canvas');
48
  };
49
 
@@ -64,11 +62,6 @@ function App() {
64
  setCurrentView('chat');
65
  };
66
 
67
- const handleUserUpdate = (updatedUser) => {
68
- setUser(updatedUser);
69
- localStorage.setItem('user', JSON.stringify(updatedUser));
70
- };
71
-
72
  const handleSignOut = () => {
73
  localStorage.removeItem('authToken');
74
  localStorage.removeItem('user');
@@ -84,7 +77,9 @@ function App() {
84
  <div className="App">
85
  {currentView === 'home' && (
86
  <HomePage
 
87
  onNavigateToChat={isAuthenticated ? navigateToChat : navigateToAuth}
 
88
  isAuthenticated={isAuthenticated}
89
  />
90
  )}
@@ -92,9 +87,10 @@ function App() {
92
  <AuthPage onAuthSuccess={handleAuthSuccess} />
93
  )}
94
  {currentView === 'canvas' && isAuthenticated && (
95
- <CanvasPage
96
  user={user}
97
  authToken={authToken}
 
98
  onNavigateToChat={navigateToChat}
99
  onSignOut={handleSignOut}
100
  />
@@ -106,7 +102,6 @@ function App() {
106
  onNavigateToHome={navigateToHome}
107
  onNavigateToCanvas={navigateToCanvas}
108
  onSignOut={handleSignOut}
109
- onUserUpdate={handleUserUpdate}
110
  />
111
  )}
112
  {/* Global help center — listens for the 'open-user-guide' event */}
 
8
  import UserGuide from './components/UserGuide';
9
  import './styles/components.css';
10
 
 
 
 
 
 
11
  function App() {
12
  const [currentView, setCurrentView] = useState('home');
13
  const [isAuthenticated, setIsAuthenticated] = useState(false);
 
38
  setCurrentView('auth');
39
  };
40
 
41
+ const navigateToCanvas = (canvasView) => {
42
+ if (['insights', 'workspace', 'deliverables'].includes(canvasView)) {
43
+ localStorage.setItem('canvas-view-v2', canvasView);
44
+ }
45
  setCurrentView('canvas');
46
  };
47
 
 
62
  setCurrentView('chat');
63
  };
64
 
 
 
 
 
 
65
  const handleSignOut = () => {
66
  localStorage.removeItem('authToken');
67
  localStorage.removeItem('user');
 
77
  <div className="App">
78
  {currentView === 'home' && (
79
  <HomePage
80
+ onNavigateToHome={navigateToHome}
81
  onNavigateToChat={isAuthenticated ? navigateToChat : navigateToAuth}
82
+ onNavigateToCanvas={isAuthenticated ? navigateToCanvas : navigateToAuth}
83
  isAuthenticated={isAuthenticated}
84
  />
85
  )}
 
87
  <AuthPage onAuthSuccess={handleAuthSuccess} />
88
  )}
89
  {currentView === 'canvas' && isAuthenticated && (
90
+ <CanvasPage
91
  user={user}
92
  authToken={authToken}
93
+ onNavigateToHome={navigateToHome}
94
  onNavigateToChat={navigateToChat}
95
  onSignOut={handleSignOut}
96
  />
 
102
  onNavigateToHome={navigateToHome}
103
  onNavigateToCanvas={navigateToCanvas}
104
  onSignOut={handleSignOut}
 
105
  />
106
  )}
107
  {/* Global help center — listens for the 'open-user-guide' event */}
phd-advisor-frontend/src/components/AdvisorStatusDropdown.js CHANGED
@@ -2,12 +2,19 @@ import React, { useState, useEffect } from 'react';
2
  import { Users, ChevronDown, Pencil } from 'lucide-react';
3
  import AvatarPickerModal from './AvatarPickerModal';
4
 
5
- const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, isDark }) => {
 
 
 
 
 
 
 
 
6
  const [isOpen, setIsOpen] = useState(false);
7
  const [hoveredId, setHoveredId] = useState(null);
8
  const [pickerAdvisor, setPickerAdvisor] = useState(null);
9
-
10
- // Close dropdown when clicking outside
11
  useEffect(() => {
12
  const handleClickOutside = (event) => {
13
  if (isOpen && !event.target.closest('.advisor-status-dropdown')) {
@@ -22,10 +29,13 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
22
  if (!advisors || typeof advisors !== 'object') {
23
  return null;
24
  }
25
-
26
  const advisorEntries = Object.entries(advisors);
27
- const thinkingCount = Array.isArray(thinkingAdvisors)
28
- ? thinkingAdvisors.filter(id => id !== 'system').length
 
 
 
29
  : 0;
30
  const totalAdvisors = advisorEntries.length;
31
 
@@ -33,16 +43,41 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
33
  setIsOpen(!isOpen);
34
  };
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  return (
37
  <div className="advisor-status-dropdown">
38
- <button
 
39
  className={`advisor-status-button ${isOpen ? 'open' : ''}`}
40
  onClick={handleToggle}
 
 
 
41
  >
42
  <div className="advisor-status-info">
43
  <Users size={16} />
44
  <span className="advisor-count">
45
- {totalAdvisors} Advisor{totalAdvisors !== 1 ? 's' : ''}
46
  </span>
47
  {thinkingCount > 0 && (
48
  <div className="thinking-badge">
@@ -52,7 +87,7 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
52
  </div>
53
  <ChevronDown size={14} className={`dropdown-arrow ${isOpen ? 'rotated' : ''}`} />
54
  </button>
55
-
56
  {pickerAdvisor && (
57
  <AvatarPickerModal
58
  advisorId={pickerAdvisor.id}
@@ -61,32 +96,62 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
61
  />
62
  )}
63
  {isOpen && (
64
- <div className="advisor-dropdown-panel">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  <div className="advisor-list">
66
  {advisorEntries.map(([id, advisor]) => {
67
  const IconComponent = advisor.icon;
68
  const colors = getAdvisorColors(id, isDark);
69
  const isThinking = Array.isArray(thinkingAdvisors) && thinkingAdvisors.includes(id);
70
-
 
71
  return (
72
  <div
73
  key={id}
74
- className={`advisor-item ${isThinking ? 'thinking' : ''}`}
75
  style={{ '--advisor-color': colors.color, '--advisor-bg': colors.bgColor }}
76
  >
 
 
 
 
 
 
 
 
77
  <div
78
  className="advisor-icon"
79
- style={{ position: 'relative', cursor: 'pointer', overflow: 'hidden', width: 32, height: 32, borderRadius: 8, flexShrink: 0 }}
 
 
 
 
 
 
 
 
80
  onMouseEnter={() => setHoveredId(id)}
81
  onMouseLeave={() => setHoveredId(null)}
82
- onClick={() => setPickerAdvisor({ id, name: advisor.name })}
83
  >
84
  {advisor.avatarUrl
85
- ? <img src={advisor.avatarUrl} alt={advisor.name} style={{ width: 32, height: 32, objectFit: 'cover', display: 'block' }} />
86
- : <IconComponent size={16} />
87
- }
88
  {hoveredId === id && (
89
- <div style={{ position: 'absolute', inset: 0, borderRadius: 8, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
90
  <Pencil size={10} color="#fff" />
91
  </div>
92
  )}
@@ -96,12 +161,14 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
96
  <div className="advisor-description">{advisor.description}</div>
97
  </div>
98
  <div className="advisor-status">
99
- {isThinking ? (
 
 
100
  <div className="status-thinking">
101
  <div className="thinking-dots">
102
- <div className="dot"></div>
103
- <div className="dot"></div>
104
- <div className="dot"></div>
105
  </div>
106
  </div>
107
  ) : (
@@ -114,13 +181,13 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
114
  </div>
115
  </div>
116
  )}
117
-
118
  <style>{`
119
  .advisor-status-dropdown {
120
  position: relative;
121
  display: inline-block;
122
  }
123
-
124
  .advisor-status-button {
125
  display: flex;
126
  align-items: center;
@@ -136,31 +203,32 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
136
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
137
  color: var(--text-primary);
138
  }
139
-
140
  .advisor-status-button:hover {
141
  background: var(--bg-secondary);
142
  border-color: var(--accent-primary);
143
  transform: translateY(-1px);
144
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
145
  }
146
-
147
  .advisor-status-button.open {
148
  background: var(--bg-secondary);
149
  border-color: var(--accent-primary);
150
  }
151
-
152
  .advisor-status-info {
153
  display: flex;
154
  align-items: center;
155
  gap: 6px;
156
  flex: 1;
157
  }
158
-
159
  .advisor-count {
160
  font-weight: 600;
161
  color: var(--text-primary);
 
162
  }
163
-
164
  .thinking-badge {
165
  background: var(--accent-primary);
166
  color: white;
@@ -170,22 +238,23 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
170
  font-weight: 600;
171
  animation: pulse 2s ease-in-out infinite;
172
  }
173
-
174
  .dropdown-arrow {
175
  color: var(--text-secondary);
176
  transition: transform 0.2s ease;
 
177
  }
178
-
179
  .dropdown-arrow.rotated {
180
  transform: rotate(180deg);
181
  }
182
-
183
  .advisor-dropdown-panel {
184
  position: absolute;
185
  top: calc(100% + 8px);
186
  right: 0;
187
- min-width: 280px;
188
- max-width: 320px;
189
  background: var(--bg-primary);
190
  border: 1px solid var(--border-primary);
191
  border-radius: 12px;
@@ -195,77 +264,147 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
195
  backdrop-filter: blur(20px);
196
  -webkit-backdrop-filter: blur(20px);
197
  }
198
-
199
  [data-theme="dark"] .advisor-dropdown-panel {
200
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
201
  }
202
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  .advisor-list {
204
- max-height: 300px;
205
  overflow-y: auto;
206
  scrollbar-width: thin;
207
  scrollbar-color: var(--border-primary) transparent;
208
  }
209
-
210
- .advisor-list::-webkit-scrollbar {
211
- width: 6px;
212
- }
213
-
214
- .advisor-list::-webkit-scrollbar-track {
215
- background: transparent;
216
- }
217
-
218
- .advisor-list::-webkit-scrollbar-thumb {
219
- background: var(--border-primary);
220
- border-radius: 3px;
221
- }
222
-
223
  .advisor-item {
224
  display: flex;
225
  align-items: center;
226
- gap: 12px;
227
- padding: 12px 16px;
228
  border-bottom: 1px solid var(--border-primary);
229
- transition: background-color 0.2s ease;
 
 
 
 
230
  }
231
-
232
  .advisor-item:last-child {
233
  border-bottom: none;
234
  }
235
-
236
  .advisor-item:hover {
237
  background: var(--bg-secondary);
238
  }
239
-
240
  .advisor-item.thinking {
241
  background: var(--advisor-bg);
 
242
  }
243
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  .advisor-icon {
 
 
 
245
  width: 32px;
246
  height: 32px;
247
  border-radius: 8px;
 
248
  background: var(--advisor-bg);
249
  color: var(--advisor-color);
250
  display: flex;
251
  align-items: center;
252
  justify-content: center;
253
- flex-shrink: 0;
254
  border: 1px solid var(--advisor-color);
255
  }
256
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  .advisor-details {
258
  flex: 1;
259
  min-width: 0;
260
  }
261
-
262
  .advisor-name {
263
  font-weight: 600;
264
  color: var(--text-primary);
265
  font-size: 13px;
266
  margin-bottom: 2px;
267
  }
268
-
269
  .advisor-description {
270
  font-size: 11px;
271
  color: var(--text-secondary);
@@ -274,22 +413,27 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
274
  text-overflow: ellipsis;
275
  white-space: nowrap;
276
  }
277
-
278
  .advisor-status {
279
  flex-shrink: 0;
280
  }
281
-
 
 
 
 
 
 
282
  .status-thinking {
283
  display: flex;
284
  align-items: center;
285
- gap: 4px;
286
  }
287
-
288
  .thinking-dots {
289
  display: flex;
290
  gap: 2px;
291
  }
292
-
293
  .thinking-dots .dot {
294
  width: 4px;
295
  height: 4px;
@@ -297,64 +441,34 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
297
  border-radius: 50%;
298
  animation: thinking-bounce 1.4s infinite ease-in-out both;
299
  }
300
-
301
  .thinking-dots .dot:nth-child(1) { animation-delay: -0.32s; }
302
  .thinking-dots .dot:nth-child(2) { animation-delay: -0.16s; }
303
- .thinking-dots .dot:nth-child(3) { animation-delay: 0s; }
304
-
305
  .status-ready {
306
  font-size: 11px;
307
  color: var(--text-tertiary);
308
  font-weight: 500;
309
  }
310
-
311
  @keyframes thinking-bounce {
312
  0%, 80%, 100% { transform: scale(0); }
313
  40% { transform: scale(1); }
314
  }
315
-
316
  @keyframes pulse {
317
  0%, 100% { opacity: 1; }
318
  50% { opacity: 0.7; }
319
  }
320
-
321
- /* Responsive Design */
322
  @media (max-width: 768px) {
323
  .advisor-status-dropdown {
324
  display: none;
325
  }
326
- .advisor-dropdown-panel {
327
- right: -20px;
328
- left: -20px;
329
- min-width: unset;
330
- max-width: unset;
331
- }
332
-
333
- .advisor-status-button {
334
- min-width: 120px;
335
- font-size: 12px;
336
- }
337
-
338
- .advisor-item {
339
- padding: 10px 12px;
340
- }
341
-
342
- .advisor-icon {
343
- width: 28px;
344
- height: 28px;
345
- }
346
-
347
- .advisor-name {
348
- font-size: 12px;
349
- }
350
-
351
- .advisor-description {
352
- font-size: 10px;
353
- }
354
  }
355
  `}</style>
356
  </div>
357
  );
358
  };
359
 
360
- export default AdvisorStatusDropdown;
 
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')) {
 
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
 
 
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">
 
87
  </div>
88
  <ChevronDown size={14} className={`dropdown-arrow ${isOpen ? 'rotated' : ''}`} />
89
  </button>
90
+
91
  {pickerAdvisor && (
92
  <AvatarPickerModal
93
  advisorId={pickerAdvisor.id}
 
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
  )}
 
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
  ) : (
 
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;
 
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;
 
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;
 
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);
 
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;
 
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;
phd-advisor-frontend/src/components/CopyrightNotice.js CHANGED
@@ -22,7 +22,7 @@ const CopyrightNotice = ({ variant = 'footer', className = '' }) => {
22
  Neon.ai
23
  </a>
24
  )}
25
- , {'\u00A9 '}University of Colorado Boulder. All rights reserved.{' '}
26
  <a
27
  href="https://www.neon.ai/contact"
28
  target="_blank"
 
22
  Neon.ai
23
  </a>
24
  )}
25
+ . All rights reserved.{' '}
26
  <a
27
  href="https://www.neon.ai/contact"
28
  target="_blank"
phd-advisor-frontend/src/components/MessageBubble.js CHANGED
@@ -302,34 +302,38 @@ const MessageBubble = ({
302
  const colors = getAdvisorColors(personaId, isDark);
303
  const isCopied = copiedStates[message.id];
304
 
305
- const avatarElement = (size = 40) => (
306
- advisor.avatarUrl ? (
307
- <img
308
- src={advisor.avatarUrl}
309
- alt={advisor.name || 'Advisor'}
310
- style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
311
- />
312
- ) : Icon ? (
313
  <div
314
- style={{
315
- width: size, height: size, borderRadius: '50%', backgroundColor: colors.bgColor || 'var(--bg-muted)',
316
- display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, overflow: 'hidden'
317
- }}
318
  >
319
- <Icon style={{ color: colors.color || 'var(--text-secondary)', width: size * 0.5, height: size * 0.5 }} />
320
- </div>
321
- ) : (
322
- <div
323
- style={{
324
- width: size, height: size, borderRadius: '50%', backgroundColor: colors.bgColor || 'var(--bg-muted)',
325
- display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
326
- color: colors.color || 'var(--text-secondary)', fontWeight: 600, fontSize: size * 0.4
327
- }}
328
- >
329
- {advisor.name ? advisor.name.charAt(0) : 'A'}
 
 
 
 
 
 
 
 
 
 
 
330
  </div>
331
- )
332
- );
333
 
334
  return (
335
  <div className={`advisor-message-container ${inlineAvatar ? 'inline-avatar-mode' : ''}`}>
@@ -350,7 +354,7 @@ const MessageBubble = ({
350
  }}
351
  >
352
  <div className="advisor-message-header">
353
- {inlineAvatar && avatarElement(32)}
354
  <h4
355
  className="advisor-message-name"
356
  style={{ color: colors.color || 'var(--text-primary)' }}
 
302
  const colors = getAdvisorColors(personaId, isDark);
303
  const isCopied = copiedStates[message.id];
304
 
305
+ const avatarElement = (size = 44) => {
306
+ const iconSize = Math.round(size * 0.52);
307
+ return (
 
 
 
 
 
308
  <div
309
+ className="advisor-message-avatar-ring"
310
+ style={{ width: size, height: size }}
 
 
311
  >
312
+ {advisor.avatarUrl ? (
313
+ <img
314
+ src={advisor.avatarUrl}
315
+ alt={advisor.name || 'Advisor'}
316
+ />
317
+ ) : Icon ? (
318
+ <Icon
319
+ className="advisor-message-avatar-icon"
320
+ style={{
321
+ color: colors.color || 'var(--text-secondary)',
322
+ width: iconSize,
323
+ height: iconSize,
324
+ }}
325
+ />
326
+ ) : (
327
+ <span
328
+ className="advisor-message-avatar-initial"
329
+ style={{ color: colors.color || 'var(--text-secondary)', fontSize: iconSize }}
330
+ >
331
+ {advisor.name ? advisor.name.charAt(0) : 'A'}
332
+ </span>
333
+ )}
334
  </div>
335
+ );
336
+ };
337
 
338
  return (
339
  <div className={`advisor-message-container ${inlineAvatar ? 'inline-avatar-mode' : ''}`}>
 
354
  }}
355
  >
356
  <div className="advisor-message-header">
357
+ {inlineAvatar && avatarElement(44)}
358
  <h4
359
  className="advisor-message-name"
360
  style={{ color: colors.color || 'var(--text-primary)' }}
phd-advisor-frontend/src/components/Sidebar.js CHANGED
@@ -1,22 +1,24 @@
1
  import React, { useState, useEffect } from 'react';
2
  import {
3
  MessageSquare,
4
- Plus,
5
  SquarePen,
6
  Search,
7
  MoreVertical,
8
  Trash2,
9
- Edit3,
10
  LogOut,
11
  User,
12
- Settings,
 
 
13
  PanelLeft,
14
- FileText
 
 
15
  } from 'lucide-react';
 
16
  import { useAppConfig } from '../contexts/AppConfigContext';
17
- import ConfirmDialog from './ConfirmDialog';
18
  import CopyrightNotice from './CopyrightNotice';
19
- import SettingsModal from './SettingsModal';
20
  import '../styles/Sidebar.css';
21
 
22
  const Sidebar = ({
@@ -25,27 +27,46 @@ const Sidebar = ({
25
  onSelectSession,
26
  onNewChat,
27
  onSignOut,
28
- onUserUpdate,
29
  authToken,
30
  onSidebarToggle,
31
  isMobileOpen = false,
32
  onMobileToggle,
33
- onNavigateToCanvas,
34
  refreshTrigger,
35
- onCurrentSessionDeleted
 
 
 
 
 
 
 
 
 
 
 
36
  }) => {
37
  const { config } = useAppConfig();
38
- const canvasLabel = config?.app?.title ? `${config.app.title} Canvas` : 'Canvas';
39
- const appVersion = config?.version;
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  const [chatSessions, setChatSessions] = useState([]);
41
  const [searchTerm, setSearchTerm] = useState('');
42
  const [isLoading, setIsLoading] = useState(true);
43
  const [showUserMenu, setShowUserMenu] = useState(false);
44
- const [showSettings, setShowSettings] = useState(false);
45
  const [isCollapsed, setIsCollapsed] = useState(false);
46
  const [isCreatingNewChat, setIsCreatingNewChat] = useState(false);
47
- const [showClearAllConfirm, setShowClearAllConfirm] = useState(false);
48
- const [isClearingAll, setIsClearingAll] = useState(false);
49
 
50
  useEffect(() => {
51
  if (authToken) {
@@ -159,28 +180,6 @@ const Sidebar = ({
159
  }
160
  };
161
 
162
- const handleClearAllChats = async () => {
163
- setIsClearingAll(true);
164
- try {
165
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions`, {
166
- method: 'DELETE',
167
- headers: {
168
- 'Authorization': `Bearer ${authToken}`,
169
- 'Content-Type': 'application/json'
170
- }
171
- });
172
- if (response.ok) {
173
- setChatSessions([]);
174
- onCurrentSessionDeleted?.();
175
- }
176
- } catch (error) {
177
- console.error('Error clearing all chat sessions:', error);
178
- } finally {
179
- setIsClearingAll(false);
180
- setShowClearAllConfirm(false);
181
- }
182
- };
183
-
184
  const toggleSidebar = () => {
185
  setIsCollapsed(!isCollapsed);
186
  // Close user menu when collapsing
@@ -214,8 +213,17 @@ const Sidebar = ({
214
  <>
215
  <div className="user-section">
216
  <div className="user-info">
217
- <div className="user-avatar">
218
- <User size={20} />
 
 
 
 
 
 
 
 
 
219
  </div>
220
  <div className="user-details">
221
  <span className="user-name">{user.firstName} {user.lastName}</span>
@@ -243,12 +251,21 @@ const Sidebar = ({
243
 
244
  {showUserMenu && (
245
  <div className="user-menu">
246
- <button
247
- className="user-menu-item"
248
- onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
249
- >
250
- <Settings size={16} />
251
- <span>Settings</span>
 
 
 
 
 
 
 
 
 
252
  </button>
253
  <button className="user-menu-item sign-out" onClick={onSignOut}>
254
  <LogOut size={16} />
@@ -260,34 +277,6 @@ const Sidebar = ({
260
  </div>
261
  </div>
262
 
263
- <div className="new-chat-row">
264
- <button
265
- className="new-chat-button"
266
- onClick={handleNewChat}
267
- disabled={isCreatingNewChat}
268
- >
269
- <Plus size={16} />
270
- <span>{isCreatingNewChat ? 'Creating...' : 'New Chat'}</span>
271
- </button>
272
- <button
273
- className="clear-all-chats-btn"
274
- onClick={() => setShowClearAllConfirm(true)}
275
- disabled={isClearingAll || chatSessions.length === 0}
276
- title="Clear all chats"
277
- aria-label="Clear all chats"
278
- >
279
- <Trash2 size={16} />
280
- </button>
281
- </div>
282
-
283
- <button
284
- className="sidebar-canvas-btn"
285
- onClick={onNavigateToCanvas}
286
- title={canvasLabel}
287
- >
288
- <FileText size={18} />
289
- {!isCollapsed && <span>{canvasLabel}</span>}
290
- </button>
291
  </>
292
  )}
293
 
@@ -309,14 +298,6 @@ const Sidebar = ({
309
  >
310
  <SquarePen size={20} />
311
  </button>
312
- <button
313
- className="sidebar-canvas-btn"
314
- onClick={onNavigateToCanvas}
315
- title={canvasLabel}
316
- >
317
- <FileText size={20} />
318
- {!isCollapsed && <span>{canvasLabel}</span>}
319
- </button>
320
  </div>
321
  )}
322
  </div>
@@ -328,24 +309,187 @@ const Sidebar = ({
328
  <Search size={16} className="search-icon" />
329
  <input
330
  type="text"
331
- placeholder="Search chats..."
 
 
 
 
 
 
332
  value={searchTerm}
333
  onChange={(e) => setSearchTerm(e.target.value)}
334
  className="search-input"
335
  />
336
  </div>
337
- <button
338
- className="new-chat-icon-btn"
339
- onClick={handleNewChat}
340
- disabled={isCreatingNewChat}
341
- title={isCreatingNewChat ? 'Creating...' : 'New Chat'}
342
- >
343
- <SquarePen size={18} />
344
- </button>
 
 
345
  </div>
346
  )}
347
 
348
- {/* Chat Sessions */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  <div className="chat-sessions">
350
  {isLoading ? (
351
  <div className="loading-sessions">
@@ -398,15 +542,8 @@ const Sidebar = ({
398
  </div>
399
  )}
400
  </div>
401
-
402
- {appVersion && (
403
- <div
404
- className="sidebar-version"
405
- title={`Version ${appVersion}`}
406
- >
407
- {isCollapsed ? `v${appVersion}` : `Version ${appVersion}`}
408
- </div>
409
  )}
 
410
  {/* Footer */}
411
  <div className={`sidebar-footer ${isCollapsed ? 'collapsed' : ''}`}>
412
  <a
@@ -423,34 +560,23 @@ const Sidebar = ({
423
  </div>
424
  </div>
425
 
426
- {isMobileOpen && (
427
- <div
428
- className="mobile-sidebar-overlay visible"
429
- onClick={() => onMobileToggle(false)}
 
 
430
  />
431
  )}
432
 
433
- <ConfirmDialog
434
- isOpen={showClearAllConfirm}
435
- title="Clear all chats?"
436
- message="This will permanently delete every chat in your sidebar. This action can't be undone."
437
- confirmLabel={isClearingAll ? 'Clearing…' : 'Clear all chats'}
438
- cancelLabel="Cancel"
439
- tone="danger"
440
- onConfirm={handleClearAllChats}
441
- onCancel={() => setShowClearAllConfirm(false)}
442
- />
443
- {showSettings && (
444
- <SettingsModal
445
- user={user}
446
- authToken={authToken}
447
- onUserUpdate={onUserUpdate}
448
- onSignOut={onSignOut}
449
- onClose={() => setShowSettings(false)}
450
  />
451
  )}
452
  </>
453
  );
454
  };
455
 
456
- export default Sidebar;
 
1
  import React, { useState, useEffect } from 'react';
2
  import {
3
  MessageSquare,
 
4
  SquarePen,
5
  Search,
6
  MoreVertical,
7
  Trash2,
 
8
  LogOut,
9
  User,
10
+ UserCircle,
11
+ DatabaseZap,
12
+ KeyRound,
13
  PanelLeft,
14
+ FileText,
15
+ ChevronRight,
16
+ Clock
17
  } from 'lucide-react';
18
+ import * as LucideIcons from 'lucide-react';
19
  import { useAppConfig } from '../contexts/AppConfigContext';
20
+ import UserAvatarPicker from './UserAvatarPicker';
21
  import CopyrightNotice from './CopyrightNotice';
 
22
  import '../styles/Sidebar.css';
23
 
24
  const Sidebar = ({
 
27
  onSelectSession,
28
  onNewChat,
29
  onSignOut,
 
30
  authToken,
31
  onSidebarToggle,
32
  isMobileOpen = false,
33
  onMobileToggle,
 
34
  refreshTrigger,
35
+ onCurrentSessionDeleted,
36
+ pageContext = 'chat',
37
+ canvasItems = [],
38
+ canvasSubview = 'workspace',
39
+ widgetGroups = [],
40
+ deliverableProjects = [],
41
+ insightSections = [],
42
+ userAvatarId,
43
+ onAvatarChange,
44
+ onOpenProfile,
45
+ onOpenAccount,
46
+ onOpenClearData,
47
  }) => {
48
  const { config } = useAppConfig();
49
+ const isOnCanvas = pageContext === 'canvas';
50
+ const [showAvatarPicker, setShowAvatarPicker] = useState(false);
51
+ const avatarOptions = config?.app?.user_avatars || [];
52
+ const currentAvatar = avatarOptions.find(a => a.id === userAvatarId);
53
+ const AvatarIcon = currentAvatar ? (LucideIcons[currentAvatar.icon] || User) : User;
54
+ const [expanded, setExpanded] = useState(() => {
55
+ try { return JSON.parse(localStorage.getItem('sidebar-expanded-v1') || '{}'); } catch { return {}; }
56
+ });
57
+ const toggleExpanded = (key) => {
58
+ setExpanded(prev => {
59
+ const next = { ...prev, [key]: !prev[key] };
60
+ localStorage.setItem('sidebar-expanded-v1', JSON.stringify(next));
61
+ return next;
62
+ });
63
+ };
64
  const [chatSessions, setChatSessions] = useState([]);
65
  const [searchTerm, setSearchTerm] = useState('');
66
  const [isLoading, setIsLoading] = useState(true);
67
  const [showUserMenu, setShowUserMenu] = useState(false);
 
68
  const [isCollapsed, setIsCollapsed] = useState(false);
69
  const [isCreatingNewChat, setIsCreatingNewChat] = useState(false);
 
 
70
 
71
  useEffect(() => {
72
  if (authToken) {
 
180
  }
181
  };
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  const toggleSidebar = () => {
184
  setIsCollapsed(!isCollapsed);
185
  // Close user menu when collapsing
 
213
  <>
214
  <div className="user-section">
215
  <div className="user-info">
216
+ <div
217
+ className="user-avatar"
218
+ onClick={() => onAvatarChange && setShowAvatarPicker(true)}
219
+ style={{
220
+ cursor: onAvatarChange ? 'pointer' : undefined,
221
+ backgroundColor: currentAvatar?.bg || undefined,
222
+ color: currentAvatar?.color || undefined,
223
+ }}
224
+ title={onAvatarChange ? 'Change avatar' : undefined}
225
+ >
226
+ <AvatarIcon size={20} />
227
  </div>
228
  <div className="user-details">
229
  <span className="user-name">{user.firstName} {user.lastName}</span>
 
251
 
252
  {showUserMenu && (
253
  <div className="user-menu">
254
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); setShowAvatarPicker(true); }}>
255
+ <User size={16} />
256
+ <span>Change Avatar</span>
257
+ </button>
258
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenProfile) onOpenProfile(); }}>
259
+ <UserCircle size={16} />
260
+ <span>Profile</span>
261
+ </button>
262
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenAccount) onOpenAccount(); }}>
263
+ <KeyRound size={16} />
264
+ <span>Account</span>
265
+ </button>
266
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenClearData) onOpenClearData(); }}>
267
+ <DatabaseZap size={16} />
268
+ <span>Clear User Data</span>
269
  </button>
270
  <button className="user-menu-item sign-out" onClick={onSignOut}>
271
  <LogOut size={16} />
 
277
  </div>
278
  </div>
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  </>
281
  )}
282
 
 
298
  >
299
  <SquarePen size={20} />
300
  </button>
 
 
 
 
 
 
 
 
301
  </div>
302
  )}
303
  </div>
 
309
  <Search size={16} className="search-icon" />
310
  <input
311
  type="text"
312
+ placeholder={
313
+ isOnCanvas
314
+ ? (canvasSubview === 'deliverables' ? 'Search drafts...'
315
+ : canvasSubview === 'insights' ? 'Search sections...'
316
+ : 'Search widgets...')
317
+ : 'Search chats...'
318
+ }
319
  value={searchTerm}
320
  onChange={(e) => setSearchTerm(e.target.value)}
321
  className="search-input"
322
  />
323
  </div>
324
+ {!isOnCanvas && (
325
+ <button
326
+ className="new-chat-icon-btn"
327
+ onClick={handleNewChat}
328
+ disabled={isCreatingNewChat}
329
+ title={isCreatingNewChat ? 'Creating...' : 'New Chat'}
330
+ >
331
+ <SquarePen size={18} />
332
+ </button>
333
+ )}
334
  </div>
335
  )}
336
 
337
+ {/* Canvas sidebar — subview-aware (Insights / Workspace / Deliverables) */}
338
+ {isOnCanvas ? (
339
+ <div className="canvas-sidebar-menu">
340
+ {!isCollapsed && (() => {
341
+ const q = searchTerm.toLowerCase();
342
+
343
+ // ---------- DELIVERABLES: project list with expandable section dropdown ----------
344
+ if (canvasSubview === 'deliverables') {
345
+ const projects = deliverableProjects.filter(p =>
346
+ !q || p.name.toLowerCase().includes(q) || p.sections.some(s => s.name.toLowerCase().includes(q))
347
+ );
348
+ if (projects.length === 0) {
349
+ return (
350
+ <div className="no-sessions">
351
+ {searchTerm ? 'No drafts match' : 'No drafts yet — create one in Documents'}
352
+ </div>
353
+ );
354
+ }
355
+ return projects.map(p => {
356
+ const open = expanded[`p-${p.id}`] ?? p.isActive;
357
+ const totalWords = p.sections.reduce((s, x) => s + x.wc, 0);
358
+ return (
359
+ <div key={p.id} className={`csm-project ${p.isActive ? 'active' : ''}`}>
360
+ <button
361
+ className="csm-project-head"
362
+ onClick={() => toggleExpanded(`p-${p.id}`)}
363
+ >
364
+ <ChevronRight size={12} className={`csm-chevron ${open ? 'open' : ''}`}/>
365
+ <FileText size={13}/>
366
+ <span className="csm-project-name">{p.name}</span>
367
+ {p.versions > 0 && (
368
+ <span className="csm-versions" title={`${p.versions} version${p.versions === 1 ? '' : 's'} saved`}>
369
+ <Clock size={10}/>{p.versions}
370
+ </span>
371
+ )}
372
+ </button>
373
+ {open && (
374
+ <div className="csm-project-body">
375
+ <button className="csm-row" onClick={p.onOpen}>
376
+ <span className="csm-row-icon">📂</span>
377
+ <span>Open editor</span>
378
+ </button>
379
+ {p.sections.map(s => (
380
+ <button key={s.id} className="csm-row csm-row-section" onClick={s.onClick}>
381
+ <span className="csm-row-bullet"/>
382
+ <span className="csm-row-label">{s.name}</span>
383
+ {s.wc > 0 && <span className="csm-row-meta">{s.wc}</span>}
384
+ </button>
385
+ ))}
386
+ <div className="csm-row csm-row-foot">
387
+ <Clock size={11}/>
388
+ <span>{p.versions} version{p.versions === 1 ? '' : 's'} · auto-saved</span>
389
+ </div>
390
+ <div className="csm-row csm-row-foot">
391
+ <span style={{ color: 'var(--text-tertiary, #9CA3AF)' }}>{totalWords} words total</span>
392
+ </div>
393
+ </div>
394
+ )}
395
+ </div>
396
+ );
397
+ });
398
+ }
399
+
400
+ // ---------- WORKSPACE: widgets grouped by category ----------
401
+ if (canvasSubview === 'workspace') {
402
+ const groups = widgetGroups
403
+ .map(g => ({
404
+ ...g,
405
+ items: g.items.filter(it => !q || it.label.toLowerCase().includes(q)),
406
+ }))
407
+ .filter(g => g.items.length > 0);
408
+ if (groups.length === 0) {
409
+ return (
410
+ <div className="no-sessions">
411
+ {searchTerm ? 'No widgets match' : 'Workspace is empty — add widgets'}
412
+ </div>
413
+ );
414
+ }
415
+ return groups.map(g => {
416
+ const open = expanded[`g-${g.id}`] ?? true;
417
+ return (
418
+ <div key={g.id} className="csm-group">
419
+ <button className="csm-group-head" onClick={() => toggleExpanded(`g-${g.id}`)}>
420
+ <ChevronRight size={11} className={`csm-chevron ${open ? 'open' : ''}`}/>
421
+ <span className="csm-group-name">{g.label}</span>
422
+ <span className="csm-group-count">{g.items.length}</span>
423
+ </button>
424
+ {open && (
425
+ <div className="csm-group-body">
426
+ {g.items.map(it => (
427
+ <button key={it.id} className={`csm-row ${it.critic ? 'critic' : ''}`} onClick={it.onClick}>
428
+ <span className="csm-row-bullet"/>
429
+ <span className="csm-row-label">{it.label}</span>
430
+ </button>
431
+ ))}
432
+ </div>
433
+ )}
434
+ </div>
435
+ );
436
+ });
437
+ }
438
+
439
+ // ---------- INSIGHTS: section list with confidence badges ----------
440
+ if (canvasSubview === 'insights') {
441
+ const sections = insightSections.filter(s => !q || s.name.toLowerCase().includes(q));
442
+ if (sections.length === 0) {
443
+ return <div className="no-sessions">{searchTerm ? 'No sections match' : 'No insights yet'}</div>;
444
+ }
445
+ return (
446
+ <div className="csm-group">
447
+ <div className="csm-group-head" style={{ cursor: 'default' }}>
448
+ <span className="csm-group-name">Sections</span>
449
+ <span className="csm-group-count">{sections.length}</span>
450
+ </div>
451
+ <div className="csm-group-body">
452
+ {sections.map(s => {
453
+ const complete = s.taskCount > 0 && s.doneCount === s.taskCount;
454
+ return (
455
+ <button key={s.id} className={`csm-row ${complete ? 'csm-row-done' : ''}`} onClick={s.onClick}>
456
+ <span className="csm-row-bullet"/>
457
+ <span className="csm-row-label">{s.name}</span>
458
+ {s.taskCount > 0 && (
459
+ <span className="csm-row-meta">{s.doneCount}/{s.taskCount}</span>
460
+ )}
461
+ </button>
462
+ );
463
+ })}
464
+ </div>
465
+ </div>
466
+ );
467
+ }
468
+
469
+ // ---------- Fallback: flat list (legacy) ----------
470
+ const items = canvasItems.filter(it => !q || it.label.toLowerCase().includes(q));
471
+ if (items.length === 0) {
472
+ return <div className="no-sessions">{searchTerm ? 'No matches' : 'Nothing here yet'}</div>;
473
+ }
474
+ return (
475
+ <div className="sessions-list">
476
+ {items.map((it) => (
477
+ <div key={it.id} className="session-item" onClick={it.onClick}>
478
+ <div className="session-content">
479
+ <div className="session-icon"><FileText size={16}/></div>
480
+ <div className="session-details">
481
+ <div className="session-title">{it.label}</div>
482
+ {it.sub && <div className="session-meta"><span>{it.sub}</span></div>}
483
+ </div>
484
+ </div>
485
+ </div>
486
+ ))}
487
+ </div>
488
+ );
489
+ })()}
490
+ </div>
491
+ ) : (
492
+ /* Chat Sessions */
493
  <div className="chat-sessions">
494
  {isLoading ? (
495
  <div className="loading-sessions">
 
542
  </div>
543
  )}
544
  </div>
 
 
 
 
 
 
 
 
545
  )}
546
+
547
  {/* Footer */}
548
  <div className={`sidebar-footer ${isCollapsed ? 'collapsed' : ''}`}>
549
  <a
 
560
  </div>
561
  </div>
562
 
563
+ {showAvatarPicker && (
564
+ <UserAvatarPicker
565
+ options={avatarOptions}
566
+ currentId={userAvatarId}
567
+ onSelect={(id) => { onAvatarChange?.(id); setShowAvatarPicker(false); }}
568
+ onClose={() => setShowAvatarPicker(false)}
569
  />
570
  )}
571
 
572
+ {isMobileOpen && (
573
+ <div
574
+ className="mobile-sidebar-overlay visible"
575
+ onClick={() => onMobileToggle(false)}
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  />
577
  )}
578
  </>
579
  );
580
  };
581
 
582
+ export default Sidebar;
phd-advisor-frontend/src/components/Signup.js CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState } from 'react';
2
- import { Eye, EyeOff, Mail, Lock, User, ArrowRight, BookOpen, Phone, GraduationCap } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
  import '../styles/Signup.css';
5
 
@@ -19,14 +19,22 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
19
  const [isLoading, setIsLoading] = useState(false);
20
  const [errors, setErrors] = useState({});
21
 
22
- const academicStages = config?.login?.academic_stages?.length
23
- ? config.login.academic_stages
24
- : [
25
- { value: '', label: 'Select your stage' },
26
- { value: 'beginner', label: 'Beginner' },
27
- { value: 'intermediate', label: 'Intermediate' },
28
- { value: 'advanced', label: 'Advanced' },
29
- ];
 
 
 
 
 
 
 
 
30
 
31
  const handleInputChange = (e) => {
32
  const { name, value } = e.target;
@@ -75,7 +83,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
75
  }
76
 
77
  if (!formData.academicStage) {
78
- newErrors.academicStage = 'Please select your academic stage';
79
  }
80
 
81
  setErrors(newErrors);
@@ -140,7 +148,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
140
  {/* Header */}
141
  <div className="signup-header">
142
  <div className="logo-container">
143
- <BookOpen className="logo-icon" />
144
  </div>
145
  <h1 className="signup-title">Join Our Community</h1>
146
  <p className="signup-subtitle">
@@ -285,13 +293,13 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
285
  </div>
286
  </div>
287
 
288
- {/* Academic Stage */}
289
  <div className="form-group">
290
  <label htmlFor="academicStage" className="form-label">
291
- Academic Stage
292
  </label>
293
  <div className="input-container">
294
- <GraduationCap className="input-icon" />
295
  <select
296
  id="academicStage"
297
  name="academicStage"
@@ -300,7 +308,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
300
  className={`form-select ${errors.academicStage ? 'error' : ''}`}
301
  disabled={isLoading}
302
  >
303
- {academicStages.map(stage => (
304
  <option key={stage.value} value={stage.value}>
305
  {stage.label}
306
  </option>
@@ -312,23 +320,27 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
312
  )}
313
  </div>
314
 
315
- {/* Research Area (Optional) */}
316
  <div className="form-group">
317
  <label htmlFor="researchArea" className="form-label">
318
- Research Area <span className="optional">(Optional)</span>
319
  </label>
320
  <div className="input-container">
321
- <BookOpen className="input-icon" />
322
- <input
323
- type="text"
324
  id="researchArea"
325
  name="researchArea"
326
  value={formData.researchArea}
327
  onChange={handleInputChange}
328
- className="form-input"
329
- placeholder="e.g., Computer Science, Biology, Psychology..."
330
  disabled={isLoading}
331
- />
 
 
 
 
 
 
332
  </div>
333
  </div>
334
 
 
1
  import React, { useState } from 'react';
2
+ import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Shield, Phone, Globe } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
  import '../styles/Signup.css';
5
 
 
19
  const [isLoading, setIsLoading] = useState(false);
20
  const [errors, setErrors] = useState({});
21
 
22
+ const knowledgeLevels = config?.login?.knowledge_levels?.length
23
+ ? config.login.knowledge_levels
24
+ : config?.login?.academic_stages?.length
25
+ ? config.login.academic_stages
26
+ : [
27
+ { value: '', label: 'Select your cybersecurity knowledge level' },
28
+ { value: 'newcomer', label: 'New to cybersecurity' },
29
+ { value: 'foundational', label: 'Foundational' },
30
+ { value: 'practitioner', label: 'Practitioner' },
31
+ { value: 'experienced', label: 'Experienced' },
32
+ { value: 'expert', label: 'Expert / specialist' },
33
+ ];
34
+
35
+ const timezones = config?.login?.timezones?.length
36
+ ? config.login.timezones
37
+ : [{ value: '', label: 'Select timezone (optional)' }];
38
 
39
  const handleInputChange = (e) => {
40
  const { name, value } = e.target;
 
83
  }
84
 
85
  if (!formData.academicStage) {
86
+ newErrors.academicStage = 'Please select your cybersecurity knowledge level';
87
  }
88
 
89
  setErrors(newErrors);
 
148
  {/* Header */}
149
  <div className="signup-header">
150
  <div className="logo-container">
151
+ <Shield className="logo-icon" />
152
  </div>
153
  <h1 className="signup-title">Join Our Community</h1>
154
  <p className="signup-subtitle">
 
293
  </div>
294
  </div>
295
 
296
+ {/* Cybersecurity knowledge level */}
297
  <div className="form-group">
298
  <label htmlFor="academicStage" className="form-label">
299
+ Cybersecurity knowledge level
300
  </label>
301
  <div className="input-container">
302
+ <Shield className="input-icon" />
303
  <select
304
  id="academicStage"
305
  name="academicStage"
 
308
  className={`form-select ${errors.academicStage ? 'error' : ''}`}
309
  disabled={isLoading}
310
  >
311
+ {knowledgeLevels.map(stage => (
312
  <option key={stage.value} value={stage.value}>
313
  {stage.label}
314
  </option>
 
320
  )}
321
  </div>
322
 
323
+ {/* Time zone (optional) */}
324
  <div className="form-group">
325
  <label htmlFor="researchArea" className="form-label">
326
+ Time zone <span className="optional">(Optional)</span>
327
  </label>
328
  <div className="input-container">
329
+ <Globe className="input-icon" />
330
+ <select
 
331
  id="researchArea"
332
  name="researchArea"
333
  value={formData.researchArea}
334
  onChange={handleInputChange}
335
+ className="form-select"
 
336
  disabled={isLoading}
337
+ >
338
+ {timezones.map(tz => (
339
+ <option key={tz.value} value={tz.value}>
340
+ {tz.label}
341
+ </option>
342
+ ))}
343
+ </select>
344
  </div>
345
  </div>
346
 
phd-advisor-frontend/src/components/ThinkingIndicator.js CHANGED
@@ -14,57 +14,41 @@ const ThinkingIndicator = ({ advisorId }) => {
14
 
15
  return (
16
  <div className="thinking-container">
17
- <div
18
- className="advisor-avatar"
19
- style={{ backgroundColor: colors.bgColor }}
20
- >
21
- {Icon ? <Icon style={{ color: colors.color }} /> : null}
 
 
22
  </div>
23
- <div
24
  className="thinking-bubble"
25
- style={{
26
  backgroundColor: colors.bgColor,
27
- borderColor: colors.color + '40' // Adding transparency to the border
28
  }}
29
  >
30
  <div className="thinking-header">
31
- <h4
32
- className="advisor-name"
33
- style={{ color: colors.color }}
34
- >
35
  {advisor.name}
36
  </h4>
37
  </div>
38
  <div className="thinking-dots">
39
- <div
40
- className="thinking-dot"
41
- style={{
42
- backgroundColor: colors.color,
43
- animationDelay: '0ms'
44
- }}
45
- ></div>
46
- <div
47
- className="thinking-dot"
48
- style={{
49
- backgroundColor: colors.color,
50
- animationDelay: '150ms'
51
- }}
52
- ></div>
53
- <div
54
- className="thinking-dot"
55
- style={{
56
- backgroundColor: colors.color,
57
- animationDelay: '300ms'
58
- }}
59
- ></div>
60
  </div>
61
- <p
62
- className="thinking-text"
63
- style={{
64
- color: colors.color,
65
- opacity: 0.8
66
- }}
67
- >
68
  thinking...
69
  </p>
70
  </div>
 
14
 
15
  return (
16
  <div className="thinking-container">
17
+ <div className="advisor-message-avatar-ring" style={{ width: 44, height: 44 }}>
18
+ {Icon ? (
19
+ <Icon
20
+ className="advisor-message-avatar-icon"
21
+ style={{ color: colors.color, width: 23, height: 23 }}
22
+ />
23
+ ) : null}
24
  </div>
25
+ <div
26
  className="thinking-bubble"
27
+ style={{
28
  backgroundColor: colors.bgColor,
29
+ borderColor: colors.color + '40',
30
  }}
31
  >
32
  <div className="thinking-header">
33
+ <h4 className="advisor-name" style={{ color: colors.color }}>
 
 
 
34
  {advisor.name}
35
  </h4>
36
  </div>
37
  <div className="thinking-dots">
38
+ <div
39
+ className="thinking-dot"
40
+ style={{ backgroundColor: colors.color, animationDelay: '0ms' }}
41
+ />
42
+ <div
43
+ className="thinking-dot"
44
+ style={{ backgroundColor: colors.color, animationDelay: '150ms' }}
45
+ />
46
+ <div
47
+ className="thinking-dot"
48
+ style={{ backgroundColor: colors.color, animationDelay: '300ms' }}
49
+ />
 
 
 
 
 
 
 
 
 
50
  </div>
51
+ <p className="thinking-text" style={{ color: colors.color, opacity: 0.8 }}>
 
 
 
 
 
 
52
  thinking...
53
  </p>
54
  </div>
phd-advisor-frontend/src/data/userGuide.js CHANGED
@@ -1,6 +1,5 @@
1
- // User Guide content. Content can be overridden per-application by replacing
2
- // these strings with values pulled from the backend config in the future.
3
- // Use {{appName}} as a placeholder. It gets replaced at render time.
4
 
5
  export const userGuideTopics = [
6
  {
@@ -9,30 +8,30 @@ export const userGuideTopics = [
9
  icon: 'Sparkles',
10
  content: `# Welcome to {{appName}}
11
 
12
- {{appName}} is your AI-powered academic guidance system. A panel of specialized AI advisors gives you diverse perspectives on your research, writing, methodology, and more.
13
 
14
  ## Your first steps
15
  1. **Start a new chat** using the pencil icon next to the search bar
16
- 2. **Type a question.** Anything about your research, methodology, or PhD journey
17
- 3. **Read multiple advisor responses.** Each persona brings a different lens
18
- 4. **Reply to a specific advisor** to dig deeper into their perspective
19
 
20
  ## Need help?
21
- You can return to this guide anytime by clicking the **?** icon in the header.`,
22
  },
23
  {
24
  id: 'advisors',
25
  title: 'Your Advisors',
26
- icon: 'GraduationCap',
27
  content: `# Your Advisors
28
 
29
- {{appName}} comes with {{advisorCount}} specialized advisor personas. Each one is tuned with a different perspective and area of expertise.
30
 
31
  ## Available advisors
32
  {{advisorList}}
33
 
34
  ## Seeing who's available
35
- Click the **"X Advisors"** dropdown in the top right of the chat to see all of your advisors and their current status.`,
36
  },
37
  {
38
  id: 'conversations',
@@ -41,18 +40,15 @@ Click the **"X Advisors"** dropdown in the top right of the chat to see all of y
41
  content: `# Conversations & Replies
42
 
43
  ## Asking a question
44
- Type into the chat box at the bottom. All advisors will respond with their unique perspective.
45
 
46
  ## Replying to a specific advisor
47
- Click on any advisor's response to **reply directly to them**. This continues the conversation with just that persona, letting you go deeper on their specific angle.
48
-
49
- ## Expanding a response
50
- Some responses include an **"Expand"** action to ask the advisor to elaborate further with more detail.
51
 
52
  ## Tips
53
- - Be specific. The more context, the better the advice
54
- - Ask follow-up questions to refine the response
55
- - Different advisors will sometimes disagree, and that's a feature, not a bug`,
56
  },
57
  {
58
  id: 'documents',
@@ -60,21 +56,18 @@ Some responses include an **"Expand"** action to ask the advisor to elaborate fu
60
  icon: 'Paperclip',
61
  content: `# Uploading Documents
62
 
63
- You can attach **PDFs, Word documents, and text files** to give your advisors context.
64
 
65
  ## How it works
66
  1. Click the paperclip icon in the chat input
67
  2. Select your file
68
- 3. Wait for it to process
69
- 4. Ask a question, and your advisors will reference the document
70
-
71
- ## What can it handle?
72
- - Research papers (PDF)
73
- - Drafts and chapters (DOCX, TXT)
74
- - Notes and outlines
75
 
76
- ## Behind the scenes
77
- Documents are processed using **RAG (retrieval-augmented generation)**. The system finds the most relevant chunks of your document for each question, so even long documents work well.`,
 
 
78
  },
79
  {
80
  id: 'sessions',
@@ -82,44 +75,24 @@ Documents are processed using **RAG (retrieval-augmented generation)**. The syst
82
  icon: 'MessagesSquare',
83
  content: `# Sessions & History
84
 
85
- Every conversation is automatically saved as a session.
86
-
87
- ## Finding past chats
88
- Use the **search bar** in the sidebar to filter your past sessions by title.
89
-
90
- ## Switching between sessions
91
- Click any session in the sidebar to return to it. Your full context is preserved.
92
-
93
- ## Starting a new chat
94
- Click the pencil/edit icon next to the search bar to start a fresh conversation.
95
-
96
- ## Renaming or deleting
97
- Hover any session to reveal the **menu**. From there you can rename or delete.`,
98
  },
99
  {
100
  id: 'canvas',
101
- title: 'Progress Canvas',
102
  icon: 'BarChart3',
103
  content: `# {{appName}} Canvas
104
 
105
- The Canvas is a **structured dashboard view** of your PhD journey. It pulls insights from your conversations and organizes them into 10 sections:
106
 
107
- - Research Progress
108
- - Methodology
109
- - Theoretical Framework
110
- - Challenges & Obstacles
111
- - Next Steps
112
- - Writing & Communication
113
- - Career Development
114
- - Literature Review
115
- - Data Analysis
116
- - Motivation & Mindset
117
 
118
- ## Accessing the Canvas
119
- Click the **{{appName}} Canvas** button in the sidebar.
120
-
121
- ## Exporting
122
- You can print or download the Canvas as a snapshot of your progress.`,
123
  },
124
  {
125
  id: 'tips',
@@ -127,17 +100,13 @@ You can print or download the Canvas as a snapshot of your progress.`,
127
  icon: 'Sparkles',
128
  content: `# Tips & Shortcuts
129
 
130
- ## Get better answers
131
- - **Provide context.** Mention your field, your stage, your specific concern.
132
- - **Quote your work.** Paste a paragraph from your draft for targeted feedback.
133
- - **Use multiple advisors.** Ask one for theory, another for practical next steps.
134
-
135
  ## Useful workflows
136
- - **Stuck on methodology?** Ask the Methodologist + Theorist together.
137
- - **Feeling burnt out?** The Motivational Coach + Empathetic Listener help reframe.
138
- - **Need to be challenged?** Talk to the Constructive Critic and Socratic Mentor.
 
139
 
140
  ## Theme
141
- Switch between light and dark mode using the toggle in the top right.`,
142
  },
143
  ];
 
1
+ // User Guide content for Cybersecurity Advisor.
2
+ // Use {{appName}} as a placeholder replaced at render time.
 
3
 
4
  export const userGuideTopics = [
5
  {
 
8
  icon: 'Sparkles',
9
  content: `# Welcome to {{appName}}
10
 
11
+ {{appName}} is your AI-powered cybersecurity guidance system. A panel of specialized advisors gives you diverse perspectives on threats, controls, incidents, compliance, architecture, and career growth.
12
 
13
  ## Your first steps
14
  1. **Start a new chat** using the pencil icon next to the search bar
15
+ 2. **Type a question** about security risks, tools, policies, incidents, or your career path
16
+ 3. **Read multiple advisor responses** each persona brings a different lens
17
+ 4. **Reply to a specific advisor** to go deeper on their angle
18
 
19
  ## Need help?
20
+ Return to this guide anytime via the **?** icon in the header.`,
21
  },
22
  {
23
  id: 'advisors',
24
  title: 'Your Advisors',
25
+ icon: 'Shield',
26
  content: `# Your Advisors
27
 
28
+ {{appName}} includes {{advisorCount}} specialized cybersecurity personas, powered by Neon BrainForge Security (4090 x1-3) with GPT-5.4 fallback when needed.
29
 
30
  ## Available advisors
31
  {{advisorList}}
32
 
33
  ## Seeing who's available
34
+ Click the **advisors** dropdown in the top right of the chat to see the full panel.`,
35
  },
36
  {
37
  id: 'conversations',
 
40
  content: `# Conversations & Replies
41
 
42
  ## Asking a question
43
+ Type into the chat box at the bottom. All advisors respond with their unique perspective.
44
 
45
  ## Replying to a specific advisor
46
+ Click an advisor's response to **reply directly to them** and continue one-on-one.
 
 
 
47
 
48
  ## Tips
49
+ - Include environment context (cloud, on-prem, SaaS, regulated industry)
50
+ - Paste log snippets, policy excerpts, or architecture notes for sharper advice
51
+ - Different advisors may disagree use that tension to stress-test decisions`,
52
  },
53
  {
54
  id: 'documents',
 
56
  icon: 'Paperclip',
57
  content: `# Uploading Documents
58
 
59
+ Attach **PDFs, Word documents, and text files** so advisors can reference your materials.
60
 
61
  ## How it works
62
  1. Click the paperclip icon in the chat input
63
  2. Select your file
64
+ 3. Wait for processing
65
+ 4. Ask a question advisors use **RAG** to pull relevant sections
 
 
 
 
 
66
 
67
+ ## Good uploads
68
+ - Incident reports and postmortems
69
+ - Architecture diagrams (exported as PDF)
70
+ - Policy drafts, audit findings, pen-test summaries`,
71
  },
72
  {
73
  id: 'sessions',
 
75
  icon: 'MessagesSquare',
76
  content: `# Sessions & History
77
 
78
+ Every conversation is saved as a session. Use the sidebar search to find past chats, switch sessions, or start a new chat with the pencil icon.`,
 
 
 
 
 
 
 
 
 
 
 
 
79
  },
80
  {
81
  id: 'canvas',
82
+ title: 'Security Canvas',
83
  icon: 'BarChart3',
84
  content: `# {{appName}} Canvas
85
 
86
+ The Canvas is a **structured workspace** for your security program. Insights from chats can inform widgets such as:
87
 
88
+ - Threat landscape
89
+ - Controls posture
90
+ - Open incidents & IR actions
91
+ - Compliance gaps
92
+ - Architecture decisions
93
+ - Skill development & certifications
 
 
 
 
94
 
95
+ Open Canvas from the sidebar. Layout and widgets auto-save in your browser.`,
 
 
 
 
96
  },
97
  {
98
  id: 'tips',
 
100
  icon: 'Sparkles',
101
  content: `# Tips & Shortcuts
102
 
 
 
 
 
 
103
  ## Useful workflows
104
+ - **Incident triage:** Incident Response Lead + Threat Modeling Analyst
105
+ - **Audit prep:** Compliance Advisor + Security Architect
106
+ - **Career planning:** Jerry Huaute Advisor + Security Career Mentor
107
+ - **Red-team mindset:** Use anti-yes-man Canvas widgets for challenge and scope checks
108
 
109
  ## Theme
110
+ Switch light/dark mode from the toggle in the header.`,
111
  },
112
  ];
phd-advisor-frontend/src/pages/CanvasPage.js CHANGED
@@ -1,574 +1,1072 @@
1
- import React, { useState, useEffect } from 'react';
2
- import {
3
- FileText,
4
- RefreshCw,
5
- Download,
6
- Calendar,
7
- TrendingUp,
8
- Target,
9
- BookOpen,
10
- Lightbulb,
11
- AlertTriangle,
12
- Users,
13
- BarChart3,
14
- Heart,
15
- ArrowLeft,
16
- Printer,
17
- Trash2,
18
- MessageCircle,
19
- ArrowRight
20
- } from 'lucide-react';
21
  import { useAppConfig } from '../contexts/AppConfigContext';
22
- import CopyrightNotice from '../components/CopyrightNotice';
23
- import ConfirmDialog from '../components/ConfirmDialog';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  import '../styles/CanvasPage.css';
25
 
26
- // Section icons mapping
27
- const sectionIcons = {
28
- research_progress: TrendingUp,
29
- methodology: BarChart3,
30
- theoretical_framework: BookOpen,
31
- challenges_obstacles: AlertTriangle,
32
- next_steps: Target,
33
- writing_communication: FileText,
34
- career_development: Users,
35
- literature_review: BookOpen,
36
- data_analysis: BarChart3,
37
- motivation_mindset: Heart
38
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- const CanvasSection = ({ section, sectionKey, isExpanded, onToggle }) => {
41
- const IconComponent = sectionIcons[sectionKey] || Lightbulb;
42
-
43
  return (
44
- <div className="canvas-section">
45
- <div
46
- className="section-header"
47
- onClick={() => onToggle(sectionKey)}
 
 
 
 
 
 
 
 
48
  >
49
- <div className="section-header-content">
50
- <IconComponent className="section-icon" />
51
- <div className="section-titles">
52
- <h3 className="section-title">{section.title}</h3>
53
- <p className="section-description">{section.description}</p>
54
- </div>
55
- </div>
56
- <div className="section-meta">
57
- <span className="insight-count">{section.insights.length} insights</span>
58
- <div className={`expand-arrow ${isExpanded ? 'expanded' : ''}`}>
59
-
60
- </div>
61
  </div>
62
  </div>
63
-
64
- {isExpanded && (
65
- <div className="section-content">
66
- {section.insights.length === 0 ? (
67
- <div className="empty-section">
68
- <Lightbulb className="empty-icon" />
69
- <p>No insights yet. Keep chatting with your advisors to build this section!</p>
70
- </div>
71
- ) : (
72
- <div className="insights-grid">
73
- {section.insights.map((insight, index) => (
74
- <div key={index} className="insight-card">
75
- <div className="insight-content">
76
- {insight.content}
77
- </div>
78
- <div className="insight-footer">
79
- <span className="insight-source">{insight.source_persona}</span>
80
- <span className="insight-confidence">
81
- {Math.round(insight.confidence_score * 100)}% confidence
82
- </span>
83
- </div>
84
- </div>
85
- ))}
86
- </div>
87
- )}
88
- </div>
89
- )}
90
  </div>
91
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  };
 
93
 
94
- const CanvasPage = ({ user, authToken, onNavigateToChat, onSignOut }) => {
95
- const { config } = useAppConfig();
96
- const appName = config?.app_settings?.app_name || 'Advisory Panel';
97
- const [canvasData, setCanvasData] = useState(null);
98
- const [isLoading, setIsLoading] = useState(true);
99
- const [isUpdating, setIsUpdating] = useState(false);
100
- const [expandedSections, setExpandedSections] = useState({});
101
- const [stats, setStats] = useState({});
102
- const [isPrintView, setIsPrintView] = useState(false);
103
- const [isProcessingFirstTime, setIsProcessingFirstTime] = useState(false);
104
- const [isRefreshing, setIsRefreshing] = useState(false);
105
- const [showClearConfirm, setShowClearConfirm] = useState(false);
106
- const [isClearing, setIsClearing] = useState(false);
107
 
108
- useEffect(() => {
109
- let pollInterval = null;
110
-
111
- const initializeCanvas = async () => {
112
- await fetchCanvas();
113
- await fetchStats();
114
- await triggerAutoUpdate();
115
-
116
- setTimeout(() => {
117
- checkForEmptyCanvasWithChats();
118
- }, 2000);
119
- };
120
-
121
- initializeCanvas();
122
-
123
- // Cleanup on unmount
124
- return () => {
125
- if (pollInterval) {
126
- clearInterval(pollInterval);
127
- }
128
- };
129
- }, []);
130
 
131
- const fetchCanvas = async () => {
132
- try {
133
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/phd-canvas`, {
134
- headers: {
135
- 'Authorization': `Bearer ${authToken}`,
136
- 'Content-Type': 'application/json'
137
- }
138
- });
139
 
140
- if (response.ok) {
141
- const data = await response.json();
142
- setCanvasData(data);
143
-
144
- // Auto-expand sections with insights
145
- const sectionsToExpand = {};
146
- Object.entries(data.sections).forEach(([key, section]) => {
147
- if (section.insights.length > 0) {
148
- sectionsToExpand[key] = true;
149
- }
150
- });
151
- setExpandedSections(sectionsToExpand);
152
- } else {
153
- console.error('Failed to fetch canvas');
154
- }
155
- } catch (error) {
156
- console.error('Error fetching canvas:', error);
157
- } finally {
158
- setIsLoading(false);
159
- }
160
  };
161
 
162
- const fetchStats = async () => {
163
- try {
164
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/phd-canvas/stats`, {
165
- headers: {
166
- 'Authorization': `Bearer ${authToken}`,
167
- 'Content-Type': 'application/json'
168
- }
169
- });
170
-
171
- if (response.ok) {
172
- const data = await response.json();
173
- setStats(data);
174
- }
175
- } catch (error) {
176
- console.error('Error fetching stats:', error);
177
- }
178
  };
179
 
180
- // Check if user has chats but empty canvas
181
- const checkForEmptyCanvasWithChats = async () => {
182
- try {
183
- const isEmpty = !canvasData || canvasData.total_insights === 0;
184
-
185
- if (isEmpty) {
186
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/count`, {
187
- headers: {
188
- 'Authorization': `Bearer ${authToken}`,
189
- 'Content-Type': 'application/json'
190
- }
191
- });
192
-
193
- if (response.ok) {
194
- const { count } = await response.json();
195
- if (count > 0) {
196
- console.log(`User has ${count} chats but empty canvas. Triggering full refresh.`);
197
- await handleFullRefresh();
198
- }
199
- } else {
200
- console.error('Failed to fetch chat sessions count:', response.status);
201
- // Don't trigger refresh if count fails
202
- return;
203
- }
204
- }
205
- } catch (error) {
206
- console.error('Error checking for empty canvas with chats:', error);
207
- // Stop the polling if there's an error
208
- return;
209
- }
210
  };
211
-
212
- const triggerAutoUpdate = async () => {
213
- try {
214
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/phd-canvas/auto-update`, {
215
- method: 'POST',
216
- headers: {
217
- 'Authorization': `Bearer ${authToken}`,
218
- 'Content-Type': 'application/json'
219
- }
220
- });
221
-
222
- if (response.ok) {
223
- const result = await response.json();
224
-
225
- // If this is a first-time canvas update, show appropriate message
226
- if (result.type === 'full_update') {
227
- console.log('First-time canvas detected. Processing all your chats...');
228
-
229
- // Show loading state
230
- setIsProcessingFirstTime(true);
231
- setIsUpdating(true);
232
-
233
- // Poll for updates every 10 seconds for up to 3 minutes
234
- let attempts = 0;
235
- const maxAttempts = 18; // 3 minutes / 10 seconds
236
-
237
- const pollForUpdates = setInterval(async () => {
238
- attempts++;
239
-
240
- try {
241
- await fetchCanvas();
242
-
243
- // If canvas now has insights, stop polling
244
- if (canvasData && canvasData.total_insights > 0) {
245
- clearInterval(pollForUpdates);
246
- setIsUpdating(false);
247
- setIsProcessingFirstTime(false);
248
- console.log('Canvas successfully populated with insights!');
249
- }
250
-
251
- // Stop polling after max attempts
252
- if (attempts >= maxAttempts) {
253
- clearInterval(pollForUpdates);
254
- setIsUpdating(false);
255
- setIsProcessingFirstTime(false);
256
- }
257
- } catch (error) {
258
- console.error('Error polling for updates:', error);
259
- }
260
- }, 10000);
261
- }
262
- }
263
- } catch (error) {
264
- console.error('Error triggering auto-update:', error);
265
- }
266
  };
267
 
268
- const handleClearCanvas = async () => {
269
- setIsClearing(true);
270
- try {
271
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/phd-canvas`, {
272
- method: 'DELETE',
273
- headers: {
274
- 'Authorization': `Bearer ${authToken}`,
275
- 'Content-Type': 'application/json'
276
- }
277
- });
278
- if (response.ok) {
279
- setCanvasData(null);
280
- await fetchCanvas();
281
- }
282
- } catch (error) {
283
- console.error('Error clearing canvas:', error);
284
- } finally {
285
- setIsClearing(false);
286
- setShowClearConfirm(false);
287
- }
288
  };
289
 
290
- const handleRefreshCanvas = async () => {
291
- // Prevent multiple simultaneous refresh requests
292
- if (isRefreshing || isUpdating) {
293
- console.log('Refresh already in progress, ignoring duplicate request');
294
- return;
295
- }
296
-
297
- setIsRefreshing(true);
298
- setIsUpdating(true);
299
-
300
- try {
301
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/phd-canvas/refresh`, {
302
- method: 'GET',
303
- headers: {
304
- 'Authorization': `Bearer ${authToken}`,
305
- 'Content-Type': 'application/json'
306
- }
307
- });
308
-
309
- if (response.ok) {
310
- const result = await response.json();
311
- console.log('Full refresh initiated:', result);
312
-
313
- // Poll for updates
314
- setTimeout(() => {
315
- fetchCanvas();
316
- fetchStats();
317
- }, 5000);
318
-
319
- setTimeout(() => {
320
- setIsUpdating(false);
321
- setIsRefreshing(false);
322
- }, 10000);
323
- }
324
- } catch (error) {
325
- console.error('Error refreshing canvas:', error);
326
- setIsUpdating(false);
327
- setIsRefreshing(false);
328
- }
329
  };
330
 
331
- const handleFullRefresh = async () => {
332
- // Prevent multiple simultaneous refresh requests
333
- if (isRefreshing || isUpdating) {
334
- console.log('Refresh already in progress, ignoring duplicate request');
335
- return;
336
- }
337
-
338
- setIsRefreshing(true);
339
- setIsUpdating(true);
340
-
341
- try {
342
- const response = await fetch(`${process.env.REACT_APP_API_URL}/api/phd-canvas/refresh`, {
343
- method: 'GET',
344
- headers: {
345
- 'Authorization': `Bearer ${authToken}`,
346
- 'Content-Type': 'application/json'
347
- }
348
- });
349
-
350
- if (response.ok) {
351
- const result = await response.json();
352
- console.log('Full refresh initiated:', result);
353
-
354
- // Poll for updates
355
- setTimeout(() => {
356
- fetchCanvas();
357
- fetchStats();
358
- }, 5000);
359
-
360
- setTimeout(() => {
361
- setIsUpdating(false);
362
- setIsRefreshing(false);
363
- }, 10000);
364
- }
365
- } catch (error) {
366
- console.error('Error refreshing canvas:', error);
367
- setIsUpdating(false);
368
- setIsRefreshing(false);
369
- }
370
  };
371
 
372
- const toggleSection = (sectionKey) => {
373
- setExpandedSections(prev => ({
374
- ...prev,
375
- [sectionKey]: !prev[sectionKey]
 
 
 
 
 
 
 
 
 
 
 
376
  }));
 
 
377
  };
378
 
379
- const handlePrint = () => {
380
- setIsPrintView(true);
381
- setTimeout(() => {
382
- window.print();
383
- setIsPrintView(false);
384
- }, 100);
385
- };
 
 
 
 
386
 
387
- const formatDate = (dateString) => {
388
- if (!dateString) return 'Never';
389
- return new Date(dateString).toLocaleDateString();
390
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
- if (isLoading) {
 
393
  return (
394
- <div className="canvas-loading">
395
- <div className="loading-spinner"></div>
396
- <p>Loading your {appName} Canvas...</p>
397
- </div>
 
 
 
 
 
 
 
 
 
398
  );
399
  }
400
 
401
- // Sort sections by priority and insights count
402
- const sortedSections = Object.entries(canvasData?.sections || {})
403
- .sort(([, a], [, b]) => {
404
- // First by priority (lower number = higher priority)
405
- if (a.priority !== b.priority) {
406
- return a.priority - b.priority;
407
- }
408
- // Then by insights count (more insights first)
409
- return b.insights.length - a.insights.length;
410
- });
411
-
412
  return (
413
- <div className={`canvas-page ${isPrintView ? 'print-view' : ''}`}>
414
- {/* Header */}
415
- <div className="canvas-header">
416
- <div className="canvas-header-top">
417
- <button
418
- className="back-button"
419
- onClick={onNavigateToChat}
420
- >
421
- <ArrowLeft size={20} />
422
- Back to Chat
423
- </button>
424
-
425
- <div className="header-actions">
426
- <button
427
- onClick={handleRefreshCanvas}
428
- disabled={isRefreshing || isUpdating}
429
- className={`canvas-icon-btn refresh-button ${(isRefreshing || isUpdating) ? 'disabled' : ''}`}
430
- title={(isRefreshing || isUpdating) ? 'Refreshing…' : 'Refresh Canvas'}
431
- aria-label="Refresh canvas"
432
- >
433
- <RefreshCw className={`refresh-icon ${(isRefreshing || isUpdating) ? 'spinning' : ''}`} />
434
- </button>
435
-
436
- <button
437
- className="canvas-icon-btn print-button"
438
- onClick={handlePrint}
439
- title="Print"
440
- aria-label="Print canvas"
441
- >
442
- <Printer className="action-icon" />
443
- </button>
444
 
445
- <button
446
- className="canvas-icon-btn clear-canvas-btn"
447
- onClick={() => setShowClearConfirm(true)}
448
- disabled={isClearing}
449
- title="Clear Canvas"
450
- aria-label="Clear canvas"
451
- >
452
- <Trash2 className="action-icon" />
453
- </button>
 
 
454
  </div>
 
 
 
 
455
  </div>
456
-
457
- <div className="canvas-title-section">
458
- <h1 className="canvas-title">
459
- <FileText className="canvas-title-icon" />
460
- {appName} Canvas
461
- </h1>
462
- <p className="canvas-subtitle">Your research progress at a glance</p>
463
  </div>
 
 
 
 
 
 
 
 
 
464
  </div>
465
 
466
- {/* Stats Bar */}
467
- <div className="canvas-stats">
468
- <div className="stat-item">
469
- <span className="stat-number">{canvasData?.total_insights || 0}</span>
470
- <span className="stat-label">Total Insights</span>
471
- </div>
472
- <div className="stat-item">
473
- <span className="stat-number">
474
- {Object.keys(canvasData?.sections || {}).filter(key =>
475
- canvasData.sections[key].insights.length > 0
476
- ).length}
477
- </span>
478
- <span className="stat-label">Active Sections</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  </div>
480
- <div className="stat-item">
481
- <span className="stat-number">
482
- {formatDate(canvasData?.last_updated)}
 
 
 
 
 
 
 
 
 
 
483
  </span>
484
- <span className="stat-label">Last Updated</span>
 
 
 
 
 
 
 
 
 
 
 
485
  </div>
486
- </div>
487
 
488
- {/* Canvas Content */}
489
- <div className="canvas-content">
490
- {sortedSections.length === 0 ? (
491
- <div className="empty-canvas">
492
- <FileText className="empty-canvas-icon" />
493
- <h2>Your Canvas is Empty</h2>
494
- {isUpdating || isProcessingFirstTime ? (
495
- <div>
496
- <p>
497
- {isProcessingFirstTime
498
- ? 'Processing your chat history to populate insights...'
499
- : 'Updating canvas with latest insights...'
500
- }
501
- </p>
502
- <div className="inline-loading-spinner">
503
- <RefreshCw className="spinning" />
504
- </div>
505
- <p className="processing-note">
506
- This may take a few minutes for extensive chat history.
507
- </p>
508
- </div>
509
- ) : (
510
- <div>
511
- <p>Start chatting with your AI advisors to populate your {appName} Canvas with insights!</p>
512
- <div className="empty-canvas-actions">
513
  <button
514
- className="empty-canvas-btn primary"
515
- onClick={onNavigateToChat}
 
 
516
  >
517
- <MessageCircle size={18} />
518
- <span>Start Chatting</span>
519
- <ArrowRight size={16} className="empty-canvas-btn-arrow" />
520
  </button>
521
- <button
522
- className="empty-canvas-btn secondary"
523
- onClick={handleFullRefresh}
524
- >
525
- <RefreshCw size={16} />
526
- <span>Process Existing Chats</span>
 
 
 
 
 
 
 
 
 
 
 
 
527
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
528
  </div>
529
- </div>
530
- )}
531
  </div>
532
- ) : (
533
- <div className="sections-container">
534
- {sortedSections.map(([sectionKey, section]) => (
535
- <CanvasSection
536
- key={sectionKey}
537
- section={section}
538
- sectionKey={sectionKey}
539
- isExpanded={expandedSections[sectionKey]}
540
- onToggle={toggleSection}
541
- />
542
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  </div>
544
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  </div>
 
 
 
546
 
547
- {/* Copyright Footer */}
548
- <footer className="canvas-copyright-footer">
549
- <CopyrightNotice />
550
- </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
552
- {/* Print Footer */}
553
- {isPrintView && (
554
- <div className="print-footer">
555
- <p>Generated by {appName} - {new Date().toLocaleDateString()}</p>
556
- <p>Student: {user?.email} | Total Insights: {canvasData?.total_insights || 0}</p>
 
 
 
 
 
 
 
 
 
 
 
 
557
  </div>
558
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
- <ConfirmDialog
561
- isOpen={showClearConfirm}
562
- title="Clear canvas?"
563
- message="This will permanently delete all insights on your canvas. This action can't be undone."
564
- confirmLabel={isClearing ? 'Clearing…' : 'Clear canvas'}
565
- cancelLabel="Cancel"
566
- tone="danger"
567
- onConfirm={handleClearCanvas}
568
- onCancel={() => setShowClearConfirm(false)}
 
 
 
 
 
 
 
 
569
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  </div>
571
  );
572
  };
573
 
574
- export default CanvasPage;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { HelpCircle } from 'lucide-react';
3
+ import { useTheme } from '../contexts/ThemeContext';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import { useAppConfig } from '../contexts/AppConfigContext';
5
+ import Sidebar from '../components/Sidebar';
6
+ import AppHeader from '../components/AppHeader';
7
+ import Icon from '../components/canvas/CanvasIcon';
8
+ import {
9
+ INSIGHTS, WIDGET_CATALOG, DEFAULT_LAYOUT, EMPTY_STATE, WORKSPACE_PRESETS,
10
+ } from '../components/canvas/canvasData';
11
+ import {
12
+ BibliographyWidget, KanbanWidget, PomodoroWidget, WritingWidget,
13
+ DeadlinesWidget, BudgetWidget, ReadingQueueWidget, NotesWidget,
14
+ HabitsWidget, GoalsWidget, MeetingsWidget,
15
+ OutlineWidget, HighlightsWidget, LatexWidget,
16
+ CalendarWidget, DocumenterWidget, ActivityWidget,
17
+ PhdJourneyWidget, PhdResourcesWidget,
18
+ StubWidget,
19
+ } from '../components/canvas/CanvasWidgets';
20
+ import {
21
+ Reviewer2Widget, DevilsAdvocateWidget, ScopeRealismWidget,
22
+ ReviewerModal, DevilsModal, ScopeModal,
23
+ } from '../components/canvas/CanvasCriticWidgets';
24
+ import {
25
+ AddCitationModal, AddTaskModal, AddDeadlineModal, LogWordsModal,
26
+ ConfirmRemoveModal, ReadingPaperModal, BudgetItemModal,
27
+ NoteModal, HabitModal, GoalModal, MeetingModal,
28
+ PaletteModal, CommandPaletteModal, GlobalSearchModal,
29
+ } from '../components/canvas/CanvasModals';
30
+ import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
31
+ import DeliverablesView, { TEMPLATES as DELIVERABLE_TEMPLATES } from '../components/canvas/CanvasDeliverables';
32
+ import { MOD } from '../components/canvas/platform';
33
  import '../styles/CanvasPage.css';
34
 
35
+ const LAYOUT_KEY = 'canvas-layout-v2';
36
+ const STATES_KEY = 'canvas-states-v2';
37
+ const VIEW_KEY = 'canvas-view-v2';
38
+
39
+ function renderWidget(type, state, setState, openModal, allStates) {
40
+ const props = { state, setState, openModal, allStates };
41
+ switch (type) {
42
+ case 'bibliography': return <BibliographyWidget {...props}/>;
43
+ case 'kanban': return <KanbanWidget {...props}/>;
44
+ case 'pomodoro': return <PomodoroWidget {...props}/>;
45
+ case 'writing': return <WritingWidget {...props}/>;
46
+ case 'deadlines': return <DeadlinesWidget {...props}/>;
47
+ case 'budget': return <BudgetWidget {...props}/>;
48
+ case 'reading-queue': return <ReadingQueueWidget {...props}/>;
49
+ case 'notes': return <NotesWidget {...props}/>;
50
+ case 'habits': return <HabitsWidget {...props}/>;
51
+ case 'goals': return <GoalsWidget {...props}/>;
52
+ case 'meeting-log': return <MeetingsWidget {...props}/>;
53
+ case 'reviewer-2': return <Reviewer2Widget {...props}/>;
54
+ case 'devils-advocate': return <DevilsAdvocateWidget {...props}/>;
55
+ case 'scope-realism': return <ScopeRealismWidget {...props}/>;
56
+ case 'outline': return <OutlineWidget {...props}/>;
57
+ case 'highlights': return <HighlightsWidget {...props}/>;
58
+ case 'latex': return <LatexWidget {...props}/>;
59
+ case 'calendar': return <CalendarWidget {...props}/>;
60
+ case 'documenter': return <DocumenterWidget {...props}/>;
61
+ case 'activity': return <ActivityWidget {...props}/>;
62
+ case 'phd-journey': return <PhdJourneyWidget {...props}/>;
63
+ case 'phd-resources': return <PhdResourcesWidget {...props}/>;
64
+ default: {
65
+ const meta = WIDGET_CATALOG.find(w => w.type === type);
66
+ return <StubWidget meta={meta}/>;
67
+ }
68
+ }
69
+ }
70
+
71
+ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver, onDragEnd, onDrop, state, setState, onRemove, onResize, openModal, allStates }) {
72
+ const meta = WIDGET_CATALOG.find(w => w.type === widget.type);
73
+ if (!meta) return null;
74
+ const sizes = ['S', 'M', 'L'];
75
+ const cycleSize = () => onResize(widget.id, sizes[(sizes.indexOf(widget.size) + 1) % sizes.length]);
76
 
 
 
 
77
  return (
78
+ <div
79
+ className={`widget size-${widget.size} ${meta.critic ? 'critic' : ''} ${isDragging ? 'dragging' : ''} ${isDragOver ? 'drag-over' : ''}`}
80
+ data-widget-id={widget.id}
81
+ data-widget-type={widget.type}
82
+ onDragOver={(e) => { e.preventDefault(); onDragOver(widget.id); }}
83
+ onDrop={(e) => { e.preventDefault(); onDrop(widget.id); }}
84
+ >
85
+ <div
86
+ className="widget-head"
87
+ draggable
88
+ onDragStart={(e) => { onDragStart(widget.id); e.dataTransfer.effectAllowed = 'move'; }}
89
+ onDragEnd={onDragEnd}
90
  >
91
+ <span className="drag-grip"><Icon name="grip" size={14}/></span>
92
+ <div className="widget-icon"><Icon name={meta.icon} size={14}/></div>
93
+ <div className="widget-title">{meta.name}</div>
94
+ {meta.critic && <span className="widget-tag">wedge</span>}
95
+ <span className="size-pill" onClick={cycleSize} title="Cycle size S → M → L">{widget.size}</span>
96
+ <div className="widget-actions">
97
+ <button className="icon-btn" onClick={() => onRemove(widget.id, meta.name)} title="Remove"><Icon name="trash" size={13}/></button>
 
 
 
 
 
98
  </div>
99
  </div>
100
+ <div className="widget-body">
101
+ {renderWidget(widget.type, state, setState, openModal, allStates)}
102
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  </div>
104
  );
105
+ }
106
+
107
+ // ============================================================================
108
+ // Insights view — AI-synthesized highlights with stats bar, filters, and
109
+ // click-to-expand source quotes.
110
+ // ============================================================================
111
+ const INSIGHT_CATEGORIES = [
112
+ { id: 'all', label: 'All' },
113
+ { id: 'open', label: 'Open' },
114
+ { id: 'in-progress', label: 'In progress' },
115
+ { id: 'completed', label: 'Completed' },
116
+ { id: 'abandoned', label: 'Abandoned' },
117
+ { id: 'pinned', label: 'Pinned' },
118
+ { id: 'high', label: 'High confidence' },
119
+ { id: 'progress', label: 'Progress' },
120
+ { id: 'theory', label: 'Theory' },
121
+ { id: 'literature', label: 'Literature' },
122
+ { id: 'action', label: 'Actions' },
123
+ { id: 'risk', label: 'Risks' },
124
+ ];
125
+ const CATEGORY_TINT = {
126
+ progress: 'rgba(16, 185, 129, 0.12)',
127
+ theory: 'rgba(99, 102, 241, 0.12)',
128
+ literature: 'rgba(245, 158, 11, 0.12)',
129
+ action: 'rgba(59, 130, 246, 0.12)',
130
+ risk: 'rgba(220, 38, 38, 0.12)',
131
+ };
132
+ const CATEGORY_FG = {
133
+ progress: '#10B981',
134
+ theory: '#818CF8',
135
+ literature: '#F59E0B',
136
+ action: '#3B82F6',
137
+ risk: '#DC2626',
138
  };
139
+ const confidenceTier = (c) => c >= 75 ? 'high' : c >= 60 ? 'med' : 'low';
140
 
141
+ // Task statuses live on each individual bullet within an insight, not on the
142
+ // whole card Daniel's feedback: "each card is a discrete task, not a set of tasks".
143
+ const TASK_STATUSES = [
144
+ { id: 'open', label: 'Open', color: 'var(--canvas-text-3)', icon: 'sparkles' },
145
+ { id: 'in-progress', label: 'In progress', color: '#3B82F6', icon: 'graph' },
146
+ { id: 'completed', label: 'Completed', color: '#10B981', icon: 'check' },
147
+ { id: 'abandoned', label: 'Abandoned', color: 'var(--canvas-text-4)', icon: 'x' },
148
+ ];
149
+ const TASK_STATUS_KEY = 'canvas-task-status-v1';
150
+ const taskKey = (insId, idx) => `${insId}::${idx}`;
 
 
 
151
 
152
+ function InsightsView({ widgetStates, setWidgetStates, onNavigateToChat }) {
153
+ const [pinned, setPinned] = useState(() => new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id)));
154
+ const [taskStatuses, setTaskStatuses] = useState(() => {
155
+ try { return JSON.parse(localStorage.getItem(TASK_STATUS_KEY) || '{}'); } catch { return {}; }
156
+ });
157
+ const [filter, setFilter] = useState('all');
158
+ const [sortBy, setSortBy] = useState('confidence');
159
+ const [expanded, setExpanded] = useState(new Set());
160
+ const [refreshing, setRefreshing] = useState(false);
161
+ const [openStatusMenu, setOpenStatusMenu] = useState(null);
162
+ // 'cards' = current cards-of-tasks layout, 'tasks' = flat task list per Daniel's
163
+ // "Sections in sidebar, Tasks in the main view" suggestion.
164
+ const [viewMode, setViewMode] = useState(() => localStorage.getItem('canvas-insights-view') || 'cards');
165
+ useEffect(() => { localStorage.setItem('canvas-insights-view', viewMode); }, [viewMode]);
 
 
 
 
 
 
 
 
166
 
167
+ useEffect(() => {
168
+ localStorage.setItem(TASK_STATUS_KEY, JSON.stringify(taskStatuses));
169
+ }, [taskStatuses]);
 
 
 
 
 
170
 
171
+ const taskStatusOf = (insId, idx) => taskStatuses[taskKey(insId, idx)] || 'open';
172
+ const setTaskStatus = (insId, idx, status) => {
173
+ setTaskStatuses(prev => ({ ...prev, [taskKey(insId, idx)]: status }));
174
+ const lbl = TASK_STATUSES.find(s => s.id === status)?.label || status;
175
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `Task marked ${lbl}`, kind: 'success' } }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  };
177
 
178
+ // Roll up to a card-level status: completed if all tasks done, abandoned if all abandoned,
179
+ // in-progress if any are in-progress, otherwise open.
180
+ const insightRollup = (ins) => {
181
+ const states = ins.bullets.map((_, idx) => taskStatusOf(ins.id, idx));
182
+ const total = states.length;
183
+ if (total === 0) return { state: 'open', done: 0, total: 0, pct: 0 };
184
+ const done = states.filter(s => s === 'completed').length;
185
+ const inProg = states.filter(s => s === 'in-progress').length;
186
+ const abandoned = states.filter(s => s === 'abandoned').length;
187
+ let state = 'open';
188
+ if (done === total) state = 'completed';
189
+ else if (abandoned === total) state = 'abandoned';
190
+ else if (inProg > 0 || done > 0) state = 'in-progress';
191
+ return { state, done, total, inProg, abandoned, pct: Math.round((done / total) * 100) };
 
 
192
  };
193
 
194
+ const togglePin = (id) => {
195
+ setPinned(prev => {
196
+ const n = new Set(prev);
197
+ if (n.has(id)) n.delete(id); else n.add(id);
198
+ return n;
199
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  };
201
+ const toggleExpand = (id) => {
202
+ setExpanded(prev => {
203
+ const n = new Set(prev);
204
+ if (n.has(id)) n.delete(id); else n.add(id);
205
+ return n;
206
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  };
208
 
209
+ const insightToTaskTitle = (ins) => {
210
+ const plain = (ins.bullets[0] || ins.summary || ins.title).replace(/<[^>]+>/g, '');
211
+ return plain.length > 80 ? plain.slice(0, 77) + '…' : plain;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  };
213
 
214
+ const sendToKanban = (ins) => {
215
+ if (!setWidgetStates) return;
216
+ const kanban = widgetStates.kanban || EMPTY_STATE.kanban;
217
+ const card = {
218
+ id: 'k' + Date.now(),
219
+ col: 'todo',
220
+ title: insightToTaskTitle(ins),
221
+ priority: 'med',
222
+ meta: `from Insights · ${ins.title}`,
223
+ };
224
+ setWidgetStates(s => ({ ...s, kanban: { ...kanban, cards: [...kanban.cards, card] } }));
225
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Sent to Kanban (To Do)', kind: 'success' } }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  };
227
 
228
+ // TODO(LLM): real refresh hits the orchestrator and re-synthesizes insights.
229
+ const handleRefresh = () => {
230
+ setRefreshing(true);
231
+ setTimeout(() => setRefreshing(false), 900);
232
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Refreshing insights… (stub)', kind: 'success' } }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  };
234
 
235
+ // "Ask follow-up": stash a draft prompt + insight context that the chat page
236
+ // can pick up on next navigation. Backend hookup TODO: include source-conversation IDs.
237
+ const askFollowUp = (ins) => {
238
+ const plain = (ins.bullets[0] || ins.summary || '').replace(/<[^>]+>/g, '');
239
+ const prompt = `Follow up on the insight "${ins.title}": ${plain}`;
240
+ try {
241
+ localStorage.setItem('canvas-chat-handoff', JSON.stringify({
242
+ at: Date.now(),
243
+ prompt,
244
+ insightId: ins.id,
245
+ insightTitle: ins.title,
246
+ }));
247
+ } catch { /* ignore */ }
248
+ window.dispatchEvent(new CustomEvent('canvas-toast', {
249
+ detail: { msg: `Follow-up drafted: "${ins.title}" — opening chat`, kind: 'success' },
250
  }));
251
+ // Use the existing navigation prop if present; falls back to the global event.
252
+ if (onNavigateToChat) onNavigateToChat();
253
  };
254
 
255
+ const filtered = useMemo(() => {
256
+ let r = INSIGHTS;
257
+ if (filter === 'pinned') r = r.filter(i => pinned.has(i.id));
258
+ else if (filter === 'high') r = r.filter(i => i.confidence >= 75);
259
+ else if (['open', 'in-progress', 'completed', 'abandoned'].includes(filter)) r = r.filter(i => insightRollup(i).state === filter);
260
+ else if (filter !== 'all') r = r.filter(i => i.category === filter);
261
+ if (sortBy === 'confidence') r = [...r].sort((a, b) => b.confidence - a.confidence);
262
+ if (sortBy === 'recent') r = [...r].sort((a, b) => (a.updatedMinutesAgo || 0) - (b.updatedMinutesAgo || 0));
263
+ if (sortBy === 'progress') r = [...r].sort((a, b) => insightRollup(b).pct - insightRollup(a).pct);
264
+ return r;
265
+ }, [filter, sortBy, pinned, taskStatuses]); // eslint-disable-line react-hooks/exhaustive-deps
266
 
267
+ // Aggregate stats over individual TASKS, not insights (Daniel's framing)
268
+ const stats = useMemo(() => {
269
+ const allTasks = INSIGHTS.flatMap(i => i.bullets.map((_, idx) => taskStatusOf(i.id, idx)));
270
+ const taskTotal = allTasks.length;
271
+ const completed = allTasks.filter(s => s === 'completed').length;
272
+ const inProgress = allTasks.filter(s => s === 'in-progress').length;
273
+ const abandoned = allTasks.filter(s => s === 'abandoned').length;
274
+ const open = taskTotal - completed - inProgress - abandoned;
275
+ const totalSources = INSIGHTS.reduce((s, i) => s + (i.sources || 0), 0);
276
+ const avgConf = Math.round(INSIGHTS.reduce((s, i) => s + i.confidence, 0) / INSIGHTS.length);
277
+ return {
278
+ sections: INSIGHTS.length, taskTotal, completed, inProgress, abandoned, open,
279
+ totalSources, avgConf, pinnedCount: pinned.size,
280
+ };
281
+ }, [pinned, taskStatuses]); // eslint-disable-line react-hooks/exhaustive-deps
282
+
283
+ const lastUpdated = Math.min(...INSIGHTS.map(i => i.updatedMinutesAgo || 0));
284
 
285
+ // Empty state — defensive (current data is hardcoded but a real backend could send [])
286
+ if (INSIGHTS.length === 0) {
287
  return (
288
+ <>
289
+ <div className="page-header">
290
+ <div>
291
+ <h1 className="page-title">Insights</h1>
292
+ <div className="page-sub">AI-synthesized from your research conversations.</div>
293
+ </div>
294
+ </div>
295
+ <div className="empty-cell">
296
+ <Icon name="sparkles" size={32} style={{ color: 'var(--canvas-text-4)' }}/>
297
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>No insights yet</div>
298
+ <div>Have a conversation with your advisors and insights will appear here.</div>
299
+ </div>
300
+ </>
301
  );
302
  }
303
 
 
 
 
 
 
 
 
 
 
 
 
304
  return (
305
+ <>
306
+ <div className="page-header">
307
+ <div>
308
+ <h1 className="page-title">Insights</h1>
309
+ <div className="page-sub">AI-synthesized from your research conversations.</div>
310
+ </div>
311
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ {/* Stats bar */}
314
+ <div className="insights-stats">
315
+ <div className="insights-stat">
316
+ <span className="insights-stat-value">{stats.completed}/{stats.taskTotal}</span>
317
+ <span className="insights-stat-label">tasks done</span>
318
+ </div>
319
+ <div className="insights-stat insights-stat-progress">
320
+ <div className="insights-progress-bar">
321
+ <i className="ip-completed" style={{ width: `${(stats.completed / Math.max(1, stats.taskTotal)) * 100}%` }}/>
322
+ <i className="ip-inprogress" style={{ width: `${(stats.inProgress / Math.max(1, stats.taskTotal)) * 100}%` }}/>
323
+ <i className="ip-abandoned" style={{ width: `${(stats.abandoned / Math.max(1, stats.taskTotal)) * 100}%` }}/>
324
  </div>
325
+ <span className="insights-stat-label">
326
+ {stats.completed} done · {stats.inProgress} in progress · {stats.open} open
327
+ {stats.abandoned > 0 && ` · ${stats.abandoned} abandoned`}
328
+ </span>
329
  </div>
330
+ <div className="insights-stat">
331
+ <span className="insights-stat-value">{stats.sections}</span>
332
+ <span className="insights-stat-label">sections</span>
333
+ </div>
334
+ <div className="insights-stat">
335
+ <span className="insights-stat-value">{stats.avgConf}%</span>
336
+ <span className="insights-stat-label">avg confidence</span>
337
  </div>
338
+ <span style={{ flex: 1 }}/>
339
+ <span className="insights-stat-update">
340
+ <span className="dot"/>
341
+ updated {lastUpdated} min ago
342
+ </span>
343
+ <button className="btn btn-ghost" onClick={handleRefresh} disabled={refreshing}>
344
+ {refreshing ? <div className="spinner"/> : <Icon name="refresh" size={13}/>}
345
+ Refresh
346
+ </button>
347
  </div>
348
 
349
+ {/* View toggle: Cards (sections with their tasks) vs Tasks (flat list) */}
350
+ <div className="insights-view-toggle">
351
+ <button className={viewMode === 'cards' ? 'active' : ''} onClick={() => setViewMode('cards')}>
352
+ <Icon name="layout" size={12}/>Cards
353
+ </button>
354
+ <button className={viewMode === 'tasks' ? 'active' : ''} onClick={() => setViewMode('tasks')}>
355
+ <Icon name="task" size={12}/>Tasks
356
+ </button>
357
+ </div>
358
+
359
+ {/* Filter + sort */}
360
+ <div className="insights-filters">
361
+ <div className="palette-cats" style={{ marginBottom: 0 }}>
362
+ {INSIGHT_CATEGORIES.map(c => {
363
+ const count =
364
+ c.id === 'all' ? INSIGHTS.length :
365
+ c.id === 'pinned' ? pinned.size :
366
+ c.id === 'high' ? INSIGHTS.filter(i => i.confidence >= 75).length :
367
+ ['open', 'in-progress', 'completed', 'abandoned'].includes(c.id) ? INSIGHTS.filter(i => insightRollup(i).state === c.id).length :
368
+ INSIGHTS.filter(i => i.category === c.id).length;
369
+ if (count === 0 && c.id !== 'all') return null;
370
+ return (
371
+ <button key={c.id}
372
+ className={`palette-cat ${filter === c.id ? 'active' : ''}`}
373
+ onClick={() => setFilter(c.id)}>
374
+ {c.label}<span style={{ marginLeft: 6, opacity: 0.6 }}>{count}</span>
375
+ </button>
376
+ );
377
+ })}
378
  </div>
379
+ <select className="select" style={{ width: 'auto', padding: '4px 8px', fontSize: 11, fontFamily: 'var(--canvas-mono)' }}
380
+ value={sortBy} onChange={e => setSortBy(e.target.value)}>
381
+ <option value="confidence">↓ confidence</option>
382
+ <option value="recent">↓ recent</option>
383
+ <option value="progress">↓ progress</option>
384
+ </select>
385
+ </div>
386
+
387
+ {/* Pinned strip — only when there are pins and we're not already filtering by pinned */}
388
+ {pinned.size > 0 && filter !== 'pinned' && (
389
+ <div className="insights-pinned-strip">
390
+ <span style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
391
+ Pinned
392
  </span>
393
+ {INSIGHTS.filter(i => pinned.has(i.id)).map(ins => (
394
+ <button key={ins.id} className="insights-pinned-pill" onClick={() => {
395
+ const el = document.getElementById(`insight-${ins.id}`);
396
+ if (el) {
397
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
398
+ el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
399
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
400
+ }
401
+ }}>
402
+ <Icon name={ins.icon} size={11}/>{ins.title}
403
+ </button>
404
+ ))}
405
  </div>
406
+ )}
407
 
408
+ {/* TASKS view — flat list of every bullet across insights */}
409
+ {viewMode === 'tasks' && (() => {
410
+ const allTasks = filtered.flatMap(ins => ins.bullets.map((b, idx) => ({
411
+ ins, idx, text: b, status: taskStatusOf(ins.id, idx),
412
+ })));
413
+ const statusOrder = { open: 0, 'in-progress': 1, completed: 2, abandoned: 3 };
414
+ const sorted = [...allTasks].sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
415
+ if (sorted.length === 0) {
416
+ return (
417
+ <div className="empty-cell">
418
+ <Icon name="task" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
419
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>No tasks match this filter</div>
420
+ <button className="btn btn-ghost" onClick={() => setFilter('all')}>Show all</button>
421
+ </div>
422
+ );
423
+ }
424
+ return (
425
+ <div className="insights-tasks-list">
426
+ {sorted.map(({ ins, idx, text, status }) => {
427
+ const meta = TASK_STATUSES.find(s => s.id === status);
428
+ const menuKey = `tv::${ins.id}::${idx}`;
429
+ const menuOpen = openStatusMenu === menuKey;
430
+ return (
431
+ <div key={menuKey} className={`insights-task-row task-${status}`}>
 
432
  <button
433
+ className="insight-task-check"
434
+ style={{ color: meta.color, borderColor: meta.color + '60' }}
435
+ onClick={() => setOpenStatusMenu(menuOpen ? null : menuKey)}
436
+ title={`Status: ${meta.label}`}
437
  >
438
+ {status === 'completed' && <Icon name="check" size={11}/>}
439
+ {status === 'in-progress' && <span className="task-check-dot"/>}
440
+ {status === 'abandoned' && <Icon name="x" size={10}/>}
441
  </button>
442
+ <div className="insights-task-body">
443
+ <button
444
+ className="insights-task-section"
445
+ onClick={() => {
446
+ setViewMode('cards');
447
+ setTimeout(() => {
448
+ const el = document.getElementById(`insight-${ins.id}`);
449
+ if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
450
+ }, 60);
451
+ }}
452
+ title="Jump to this section"
453
+ >
454
+ <Icon name={ins.icon} size={10}/>{ins.title}
455
+ </button>
456
+ <span className="insights-task-text" dangerouslySetInnerHTML={{ __html: text }}/>
457
+ </div>
458
+ <button className="chip" onClick={() => sendToKanban(ins)} title="Send this section's open tasks to Kanban">
459
+ <Icon name="task" size={11}/>Kanban
460
  </button>
461
+ {menuOpen && (
462
+ <div className="insight-status-menu" onMouseLeave={() => setOpenStatusMenu(null)}>
463
+ {TASK_STATUSES.map(s => (
464
+ <button key={s.id}
465
+ className={status === s.id ? 'active' : ''}
466
+ onClick={() => { setTaskStatus(ins.id, idx, s.id); setOpenStatusMenu(null); }}>
467
+ <Icon name={s.icon} size={11} style={{ color: s.color }}/>
468
+ {s.label}
469
+ </button>
470
+ ))}
471
+ </div>
472
+ )}
473
  </div>
474
+ );
475
+ })}
476
  </div>
477
+ );
478
+ })()}
479
+
480
+ {/* CARDS view (default) */}
481
+ {viewMode === 'cards' && (filtered.length === 0 ? (
482
+ <div className="empty-cell">
483
+ <Icon name="search" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
484
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>No insights match this filter</div>
485
+ <button className="btn btn-ghost" onClick={() => setFilter('all')}>Show all</button>
486
+ </div>
487
+ ) : (
488
+ <div className="insight-grid">
489
+ {filtered.map(ins => {
490
+ const isExpanded = expanded.has(ins.id);
491
+ const isPinned = pinned.has(ins.id);
492
+ const tier = confidenceTier(ins.confidence);
493
+ const tint = CATEGORY_TINT[ins.category] || 'var(--canvas-surface-2)';
494
+ const fg = CATEGORY_FG[ins.category] || 'var(--canvas-accent)';
495
+ const rollup = insightRollup(ins);
496
+ return (
497
+ <div
498
+ key={ins.id}
499
+ id={`insight-${ins.id}`}
500
+ className={`insight ${isPinned ? 'is-pinned' : ''} tier-${tier} status-${rollup.state}`}
501
+ >
502
+ <div className="insight-head">
503
+ <div className="insight-icon" style={{ background: tint, color: fg }}>
504
+ <Icon name={ins.icon} size={16}/>
505
+ </div>
506
+ <div className="insight-title">{ins.title}</div>
507
+ <ConfidenceRing value={ins.confidence}/>
508
+ </div>
509
+
510
+ {/* Per-card progress (rolls up the bullet tasks) */}
511
+ {rollup.total > 0 && (
512
+ <div className="insight-progress">
513
+ <div className="insight-progress-meta">
514
+ <span className="insight-progress-count">{rollup.done}/{rollup.total} tasks</span>
515
+ {rollup.state === 'completed' && <span className="insight-progress-badge done">✓ Resolved</span>}
516
+ {rollup.state === 'abandoned' && <span className="insight-progress-badge abandoned">Abandoned</span>}
517
+ {rollup.state === 'in-progress' && <span className="insight-progress-badge inprog">In progress</span>}
518
+ </div>
519
+ <div className="insight-progress-bar">
520
+ <i className="ip-completed" style={{ width: `${(rollup.done / rollup.total) * 100}%` }}/>
521
+ {rollup.inProg > 0 && <i className="ip-inprogress" style={{ width: `${(rollup.inProg / rollup.total) * 100}%` }}/>}
522
+ {rollup.abandoned > 0 && <i className="ip-abandoned" style={{ width: `${(rollup.abandoned / rollup.total) * 100}%` }}/>}
523
+ </div>
524
+ </div>
525
+ )}
526
+
527
+ <div className="insight-body">
528
+ <div>{ins.summary}</div>
529
+ <ul className="insight-tasks">
530
+ {ins.bullets.map((b, idx) => {
531
+ const status = taskStatusOf(ins.id, idx);
532
+ const meta = TASK_STATUSES.find(s => s.id === status);
533
+ const menuOpen = openStatusMenu === `${ins.id}::${idx}`;
534
+ return (
535
+ <li key={idx} className={`insight-task task-${status}`}>
536
+ <button
537
+ className="insight-task-check"
538
+ style={{ color: meta.color, borderColor: meta.color + '60' }}
539
+ onClick={() => setOpenStatusMenu(menuOpen ? null : `${ins.id}::${idx}`)}
540
+ title={`Status: ${meta.label}`}
541
+ >
542
+ {status === 'completed' && <Icon name="check" size={10}/>}
543
+ {status === 'in-progress' && <span className="task-check-dot"/>}
544
+ {status === 'abandoned' && <Icon name="x" size={9}/>}
545
+ </button>
546
+ <span className="insight-task-text" dangerouslySetInnerHTML={{ __html: b }}/>
547
+ {menuOpen && (
548
+ <div className="insight-status-menu" onMouseLeave={() => setOpenStatusMenu(null)}>
549
+ {TASK_STATUSES.map(s => (
550
+ <button key={s.id}
551
+ className={status === s.id ? 'active' : ''}
552
+ onClick={() => { setTaskStatus(ins.id, idx, s.id); setOpenStatusMenu(null); }}>
553
+ <Icon name={s.icon} size={11} style={{ color: s.color }}/>
554
+ {s.label}
555
+ </button>
556
+ ))}
557
+ </div>
558
+ )}
559
+ </li>
560
+ );
561
+ })}
562
+ </ul>
563
+ </div>
564
+
565
+ {/* Detail panel — quotes from sources, only when expanded */}
566
+ {isExpanded && ins.quotes && (
567
+ <div className="insight-detail">
568
+ <div className="insight-detail-head">Source quotes · {ins.sources} {ins.sources === 1 ? 'source' : 'sources'}</div>
569
+ {ins.quotes.map((q, i) => (
570
+ <div key={i} className="insight-quote">{q}</div>
571
+ ))}
572
+ </div>
573
+ )}
574
+
575
+ <div className="insight-foot">
576
+ <span className="insight-foot-meta">
577
+ <Icon name="message" size={10}/> {ins.sources}
578
+ <span className="insight-dot"/>
579
+ updated {ins.updatedMinutesAgo}m ago
580
+ </span>
581
+ </div>
582
+
583
+ <div className="insight-actions">
584
+ <button className="chip" onClick={() => askFollowUp(ins)} title="Open this insight in a new chat session">
585
+ <Icon name="message" size={11}/>Ask follow-up
586
+ </button>
587
+ <button className="chip" onClick={() => sendToKanban(ins)} title="Add all open tasks from this insight to your Kanban (To Do)">
588
+ <Icon name="task" size={11}/>Add to Kanban
589
+ </button>
590
+ <button className="chip" onClick={() => toggleExpand(ins.id)}>
591
+ <Icon name="expand" size={11}/>{isExpanded ? 'Collapse' : 'Source quotes'}
592
+ </button>
593
+ <button className={`chip ${isPinned ? 'pinned' : ''}`} onClick={() => togglePin(ins.id)}>
594
+ <Icon name="pin" size={11}/>{isPinned ? 'Pinned' : 'Pin'}
595
+ </button>
596
+ </div>
597
+ </div>
598
+ );
599
+ })}
600
+ </div>
601
+ ))}
602
+ </>
603
+ );
604
+ }
605
+
606
+ // Small SVG ring used for the confidence indicator
607
+ function ConfidenceRing({ value }) {
608
+ const r = 14;
609
+ const c = 2 * Math.PI * r;
610
+ const dash = c * (1 - value / 100);
611
+ const tier = confidenceTier(value);
612
+ const color = tier === 'high' ? '#10B981' : tier === 'med' ? '#F59E0B' : '#DC2626';
613
+ return (
614
+ <div className={`confidence-ring tier-${tier}`} title={`${value}% confidence (${tier})`}>
615
+ <svg width="32" height="32" viewBox="0 0 32 32">
616
+ <circle cx="16" cy="16" r={r} fill="none" stroke="var(--canvas-surface-3)" strokeWidth="3"/>
617
+ <circle cx="16" cy="16" r={r} fill="none" stroke={color} strokeWidth="3"
618
+ strokeDasharray={c} strokeDashoffset={dash} strokeLinecap="round"
619
+ transform="rotate(-90 16 16)"/>
620
+ </svg>
621
+ <span style={{ color }}>{value}</span>
622
+ </div>
623
+ );
624
+ }
625
+
626
+ function PresetPicker({ onPick }) {
627
+ return (
628
+ <div className="canvas-presets">
629
+ <div className="canvas-presets-head">
630
+ <div className="canvas-presets-title">Start from a preset</div>
631
+ <div className="canvas-presets-sub">Or skip and add widgets one at a time.</div>
632
+ </div>
633
+ <div className="canvas-presets-grid">
634
+ {WORKSPACE_PRESETS.map(p => (
635
+ <button key={p.id} className="canvas-preset-card" onClick={() => onPick(p)}>
636
+ <div className="canvas-preset-icon"><Icon name={p.icon} size={18}/></div>
637
+ <div className="canvas-preset-content">
638
+ <div className="canvas-preset-name">{p.name}</div>
639
+ <div className="canvas-preset-desc">{p.desc}</div>
640
+ <div className="canvas-preset-meta">{p.layout.length} widgets</div>
641
+ </div>
642
+ </button>
643
+ ))}
644
+ </div>
645
+ </div>
646
+ );
647
+ }
648
+
649
+ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetStates }) {
650
+ const [dragId, setDragId] = useState(null);
651
+ const [dragOverId, setDragOverId] = useState(null);
652
+
653
+ const onDragStart = (id) => setDragId(id);
654
+ const onDragOver = (id) => { if (id !== dragId) setDragOverId(id); };
655
+ const onDragEnd = () => { setDragId(null); setDragOverId(null); };
656
+ const onDrop = (targetId) => {
657
+ if (!dragId || dragId === targetId) { onDragEnd(); return; }
658
+ const next = [...layout];
659
+ const fromIdx = next.findIndex(w => w.id === dragId);
660
+ const toIdx = next.findIndex(w => w.id === targetId);
661
+ const [moved] = next.splice(fromIdx, 1);
662
+ next.splice(toIdx, 0, moved);
663
+ setLayout(next);
664
+ onDragEnd();
665
+ };
666
+
667
+ const setWState = (type) => (updater) => {
668
+ setWidgetStates(s => ({
669
+ ...s,
670
+ [type]: typeof updater === 'function' ? updater(s[type]) : updater,
671
+ }));
672
+ };
673
+
674
+ const removeWidget = (id, label) => {
675
+ openModal('confirm-remove', {
676
+ label,
677
+ onConfirm: () => {
678
+ setLayout(l => l.filter(w => w.id !== id));
679
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: label + ' removed', kind: 'success' } }));
680
+ },
681
+ });
682
+ };
683
+
684
+ const resizeWidget = (id, size) => setLayout(l => l.map(w => w.id === id ? { ...w, size } : w));
685
+
686
+ // Each widget starts from scratch — fresh empty state, no demo content.
687
+ const addWidget = (meta) => {
688
+ const id = 'w-' + Date.now();
689
+ setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]);
690
+ if (EMPTY_STATE[meta.type]) {
691
+ setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) }));
692
+ }
693
+ };
694
+
695
+ const applyPreset = (preset) => {
696
+ setLayout(preset.layout.map(w => ({ ...w })));
697
+ // Seed empty state for any widget types not already present
698
+ const seeds = {};
699
+ preset.layout.forEach(w => {
700
+ if (!widgetStates[w.type] && EMPTY_STATE[w.type]) {
701
+ seeds[w.type] = JSON.parse(JSON.stringify(EMPTY_STATE[w.type]));
702
+ }
703
+ });
704
+ if (Object.keys(seeds).length) setWidgetStates(s => ({ ...s, ...seeds }));
705
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `${preset.name} preset loaded`, kind: 'success' } }));
706
+ };
707
+
708
+ const reset = () => {
709
+ if (!window.confirm('Reset workspace? All widgets and content will be cleared.')) return;
710
+ setLayout([]);
711
+ setWidgetStates({});
712
+ localStorage.removeItem(LAYOUT_KEY);
713
+ localStorage.removeItem(STATES_KEY);
714
+ };
715
+
716
+ return (
717
+ <>
718
+ <div className="page-header">
719
+ <div>
720
+ <h1 className="page-title">Workspace</h1>
721
+ <div className="page-sub">{layout.length} widgets · {layout.filter(w => w.critic).length} anti-yes-man · drag headers to reorder, click size pill to resize</div>
722
+ </div>
723
+ <div style={{ display: 'flex', gap: 8 }}>
724
+ <button className="btn btn-ghost" onClick={reset} title="Reset layout"><Icon name="reset" size={13}/>Reset</button>
725
+ <button className="btn btn-primary" onClick={() => openModal('palette', {
726
+ layout, onAdd: addWidget,
727
+ })}><Icon name="plus" size={13}/>Add widget</button>
728
+ </div>
729
+ </div>
730
+ {layout.length === 0 && (
731
+ <PresetPicker onPick={applyPreset}/>
732
+ )}
733
+ <div className="workspace">
734
+ {layout.length === 0 && (
735
+ <div className="empty-cell">
736
+ <Icon name="layout" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
737
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>Or build from scratch</div>
738
+ <button className="btn btn-primary" onClick={() => openModal('palette', { layout, onAdd: addWidget })} style={{ marginTop: 6 }}>
739
+ <Icon name="plus" size={13}/>Add your first widget
740
+ </button>
741
  </div>
742
  )}
743
+ {layout.map((w) => (
744
+ <CanvasWidget
745
+ key={w.id}
746
+ widget={w}
747
+ isDragging={dragId === w.id}
748
+ isDragOver={dragOverId === w.id}
749
+ onDragStart={onDragStart}
750
+ onDragOver={onDragOver}
751
+ onDragEnd={onDragEnd}
752
+ onDrop={onDrop}
753
+ state={widgetStates[w.type] ?? (EMPTY_STATE[w.type] ?? {})}
754
+ setState={setWState(w.type)}
755
+ onRemove={removeWidget}
756
+ onResize={resizeWidget}
757
+ openModal={openModal}
758
+ allStates={widgetStates}
759
+ />
760
+ ))}
761
  </div>
762
+ </>
763
+ );
764
+ }
765
 
766
+ function ModalRouter({ modal, onClose }) {
767
+ if (!modal) return null;
768
+ const handleBackdropClick = (e) => { if (e.target === e.currentTarget) onClose(); };
769
+ let content = null;
770
+ switch (modal.kind) {
771
+ case 'palette': content = <PaletteModal data={modal.data} onClose={onClose}/>; break;
772
+ case 'add-citation': content = <AddCitationModal data={modal.data} onClose={onClose}/>; break;
773
+ case 'add-task': content = <AddTaskModal data={modal.data} onClose={onClose}/>; break;
774
+ case 'add-deadline': content = <AddDeadlineModal data={modal.data} onClose={onClose}/>; break;
775
+ case 'log-words': content = <LogWordsModal data={modal.data} onClose={onClose}/>; break;
776
+ case 'confirm-remove': content = <ConfirmRemoveModal data={modal.data} onClose={onClose}/>; break;
777
+ case 'reviewer-2': content = <ReviewerModal data={modal.data} onClose={onClose}/>; break;
778
+ case 'devils-advocate': content = <DevilsModal data={modal.data} onClose={onClose}/>; break;
779
+ case 'scope-realism': content = <ScopeModal data={modal.data} onClose={onClose}/>; break;
780
+ case 'reading-paper': content = <ReadingPaperModal data={modal.data} onClose={onClose}/>; break;
781
+ case 'budget-item': content = <BudgetItemModal data={modal.data} onClose={onClose}/>; break;
782
+ case 'note': content = <NoteModal data={modal.data} onClose={onClose}/>; break;
783
+ case 'habit': content = <HabitModal data={modal.data} onClose={onClose}/>; break;
784
+ case 'goal': content = <GoalModal data={modal.data} onClose={onClose}/>; break;
785
+ case 'meeting': content = <MeetingModal data={modal.data} onClose={onClose}/>; break;
786
+ case 'command': content = <CommandPaletteModal data={modal.data} onClose={onClose}/>; break;
787
+ case 'global-search': content = <GlobalSearchModal data={modal.data} onClose={onClose}/>; break;
788
+ default: return null;
789
+ }
790
+ return <div className="canvas-modal-backdrop" onClick={handleBackdropClick}>{content}</div>;
791
+ }
792
 
793
+ function ToastStack() {
794
+ const [toasts, setToasts] = useState([]);
795
+ useEffect(() => {
796
+ const handler = (e) => {
797
+ const id = Date.now() + Math.random();
798
+ setToasts(t => [...t, { id, ...e.detail }]);
799
+ setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3500);
800
+ };
801
+ window.addEventListener('canvas-toast', handler);
802
+ return () => window.removeEventListener('canvas-toast', handler);
803
+ }, []);
804
+ return (
805
+ <div className="toast-stack">
806
+ {toasts.map(t => (
807
+ <div key={t.id} className={`toast ${t.kind || 'success'}`}>
808
+ <Icon name={t.kind === 'critic' ? 'gavel' : t.kind === 'danger' ? 'alert' : 'check'} size={14}/>
809
+ <span>{t.msg}</span>
810
  </div>
811
+ ))}
812
+ </div>
813
+ );
814
+ }
815
+
816
+
817
+ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSignOut }) => {
818
+ const { theme, toggleTheme } = useTheme();
819
+ useAppConfig();
820
+ const [view, setView] = useState(() => localStorage.getItem(VIEW_KEY) || 'workspace');
821
+ const [modal, setModal] = useState(null);
822
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
823
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
824
+ const [tourForceShow, setTourForceShow] = useState(0);
825
+
826
+ const [layout, setLayout] = useState(() => {
827
+ try {
828
+ const saved = localStorage.getItem(LAYOUT_KEY);
829
+ return saved ? JSON.parse(saved) : DEFAULT_LAYOUT;
830
+ } catch { return DEFAULT_LAYOUT; }
831
+ });
832
+ const [widgetStates, setWidgetStates] = useState(() => {
833
+ try {
834
+ const saved = localStorage.getItem(STATES_KEY);
835
+ return saved ? JSON.parse(saved) : {};
836
+ } catch { return {}; }
837
+ });
838
+
839
+ useEffect(() => { localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); }, [layout]);
840
+ useEffect(() => { localStorage.setItem(STATES_KEY, JSON.stringify(widgetStates)); }, [widgetStates]);
841
+ useEffect(() => { localStorage.setItem(VIEW_KEY, view); }, [view]);
842
+
843
+ // Apply canvas theme attribute on body for scoped styling
844
+ useEffect(() => {
845
+ document.body.dataset.canvasTheme = theme;
846
+ return () => { delete document.body.dataset.canvasTheme; };
847
+ }, [theme]);
848
+
849
+ const openModal = useCallback((kind, data = {}) => setModal({ kind, data }), []);
850
+ const closeModal = useCallback(() => setModal(null), []);
851
+
852
+ const exportWorkspace = useCallback(() => {
853
+ const data = { layout, states: widgetStates };
854
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
855
+ const a = document.createElement('a');
856
+ a.href = URL.createObjectURL(blob);
857
+ a.download = 'canvas-workspace.json';
858
+ a.click();
859
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Workspace exported as JSON', kind: 'success' } }));
860
+ }, [layout, widgetStates]);
861
+
862
+ const openCommandPalette = useCallback(() => {
863
+ openModal('command', {
864
+ layout,
865
+ onSetView: (v) => setView(v),
866
+ onAddWidget: (meta) => {
867
+ const id = 'w-' + Date.now();
868
+ setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]);
869
+ if (EMPTY_STATE[meta.type]) {
870
+ setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) }));
871
+ }
872
+ },
873
+ onToggleTheme: toggleTheme,
874
+ onExport: exportWorkspace,
875
+ });
876
+ }, [openModal, layout, toggleTheme, exportWorkspace]);
877
+
878
+ const openGlobalSearch = useCallback(() => {
879
+ openModal('global-search', { states: widgetStates });
880
+ }, [openModal, widgetStates]);
881
+
882
+ // Critic widgets dispatch `canvas-open-in-chat` when the user wants real LLM history.
883
+ useEffect(() => {
884
+ const handler = () => onNavigateToChat && onNavigateToChat();
885
+ window.addEventListener('canvas-open-in-chat', handler);
886
+ return () => window.removeEventListener('canvas-open-in-chat', handler);
887
+ }, [onNavigateToChat]);
888
+
889
+ // Esc closes modal, ⌘K opens command palette, ⌘/ opens global content search,
890
+ // ? opens the welcome tour for help (matches the icon in the topbar).
891
+ useEffect(() => {
892
+ const k = (e) => {
893
+ if (e.key === 'Escape') closeModal();
894
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
895
+ e.preventDefault();
896
+ openCommandPalette();
897
+ }
898
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') {
899
+ e.preventDefault();
900
+ openGlobalSearch();
901
+ }
902
+ // ? key (Shift+/) — only when the user isn't typing in an input
903
+ if (e.key === '?' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {
904
+ e.preventDefault();
905
+ setTourForceShow(n => n + 1);
906
+ }
907
+ };
908
+ window.addEventListener('keydown', k);
909
+ return () => window.removeEventListener('keydown', k);
910
+ }, [closeModal, openCommandPalette, openGlobalSearch]);
911
+
912
+ // Highlight a widget when picked from the sidebar
913
+ const flashScrollTo = (selector) => {
914
+ const el = document.querySelector(selector);
915
+ if (!el) return;
916
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
917
+ el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
918
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
919
+ };
920
+
921
+ // Workspace: group widgets by category for the new sidebar
922
+ const widgetGroups = useMemo(() => {
923
+ const groups = {};
924
+ layout.forEach(w => {
925
+ const meta = WIDGET_CATALOG.find(m => m.type === w.type);
926
+ if (!meta) return;
927
+ const cat = meta.cat;
928
+ (groups[cat] ||= { id: cat, label: cat, items: [] }).items.push({
929
+ id: w.id,
930
+ label: meta.name,
931
+ icon: meta.icon,
932
+ critic: meta.critic,
933
+ onClick: () => flashScrollTo(`[data-widget-id="${w.id}"]`),
934
+ });
935
+ });
936
+ // Order: critic last
937
+ const order = ['research', 'writing', 'project', 'wellness', 'career', 'data', 'practical', 'critic'];
938
+ return order.map(c => groups[c]).filter(Boolean);
939
+ }, [layout]);
940
+
941
+ // Insights: list of sections — Daniel's feedback said sidebar should show sections here
942
+ const insightSections = useMemo(() => {
943
+ let taskMap = {};
944
+ try { taskMap = JSON.parse(localStorage.getItem(TASK_STATUS_KEY) || '{}'); } catch { /* ignore */ }
945
+ return INSIGHTS.map(ins => {
946
+ const states = ins.bullets.map((_, idx) => taskMap[taskKey(ins.id, idx)] || 'open');
947
+ const done = states.filter(s => s === 'completed').length;
948
+ return {
949
+ id: ins.id,
950
+ name: ins.title,
951
+ icon: ins.icon,
952
+ category: ins.category,
953
+ confidence: ins.confidence,
954
+ taskCount: ins.bullets.length,
955
+ doneCount: done,
956
+ onClick: () => flashScrollTo(`#insight-${ins.id}`),
957
+ };
958
+ });
959
+ }, [view, layout, widgetStates]); // eslint-disable-line react-hooks/exhaustive-deps
960
+
961
+ // Deliverables: list of projects with sections + history actions
962
+ const deliverableProjects = useMemo(() => {
963
+ try {
964
+ const dStore = JSON.parse(localStorage.getItem('canvas-deliverables-v2') || '{}');
965
+ const projects = Object.values(dStore.projects || {});
966
+ return projects.map(p => {
967
+ const t = DELIVERABLE_TEMPLATES.find(x => x.id === p.templateId);
968
+ return {
969
+ id: p.id,
970
+ name: p.name,
971
+ icon: t?.icon || 'book',
972
+ versions: p.versions?.length || 0,
973
+ isActive: p.id === dStore.activeProjectId,
974
+ sections: (t?.sections || []).map(s => ({
975
+ id: s.id,
976
+ name: s.name,
977
+ wc: ((p.sections || {})[s.id] || '').trim().split(/\s+/).filter(Boolean).length,
978
+ onClick: () => {
979
+ if (p.id !== dStore.activeProjectId) {
980
+ // Open this project first; section scroll happens after a tick.
981
+ const next = { ...dStore, activeProjectId: p.id };
982
+ localStorage.setItem('canvas-deliverables-v2', JSON.stringify(next));
983
+ window.dispatchEvent(new Event('storage'));
984
+ }
985
+ setTimeout(() => flashScrollTo(`#notion-section-${s.id}`), 80);
986
+ },
987
+ })),
988
+ onOpen: () => {
989
+ const next = { ...dStore, activeProjectId: p.id };
990
+ localStorage.setItem('canvas-deliverables-v2', JSON.stringify(next));
991
+ window.dispatchEvent(new Event('storage'));
992
+ },
993
+ };
994
+ });
995
+ } catch { return []; }
996
+ // re-derive when view or layout changes (layout proxy for "user did something")
997
+ }, [view, layout, widgetStates]); // eslint-disable-line react-hooks/exhaustive-deps
998
 
999
+ return (
1000
+ <div className="canvas-page-with-sidebar" data-canvas-theme={theme}>
1001
+ <Sidebar
1002
+ user={user}
1003
+ authToken={authToken}
1004
+ onSignOut={onSignOut}
1005
+ onSidebarToggle={setIsSidebarCollapsed}
1006
+ isMobileOpen={isMobileMenuOpen}
1007
+ onMobileToggle={setIsMobileMenuOpen}
1008
+ onNavigateToCanvas={() => {}}
1009
+ onSelectSession={(id) => onNavigateToChat && onNavigateToChat(id)}
1010
+ onNewChat={() => onNavigateToChat && onNavigateToChat()}
1011
+ pageContext="canvas"
1012
+ canvasSubview={view}
1013
+ widgetGroups={widgetGroups}
1014
+ deliverableProjects={deliverableProjects}
1015
+ insightSections={insightSections}
1016
  />
1017
+ <div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
1018
+ <div className="canvas-app-shell">
1019
+ <AppHeader
1020
+ currentPage={`canvas-${view}`}
1021
+ onNavigateToHome={onNavigateToHome}
1022
+ onNavigateToChat={onNavigateToChat}
1023
+ onNavigateToCanvas={(v) => setView(v || 'workspace')}
1024
+ onMobileMenu={() => setIsMobileMenuOpen(true)}
1025
+ >
1026
+ <button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
1027
+ <HelpCircle size={18}/>
1028
+ </button>
1029
+ <button className="icon-btn" onClick={openGlobalSearch} title={`Search canvas content (${MOD}+/)`}>
1030
+ <Icon name="search" size={16}/>
1031
+ </button>
1032
+ <button className="icon-btn" onClick={openCommandPalette} title={`Commands (${MOD}+K)`}>
1033
+ <Icon name="zap" size={16}/>
1034
+ </button>
1035
+ </AppHeader>
1036
+ <div className="canvas-content">
1037
+ {view === 'insights' && <InsightsView widgetStates={widgetStates} setWidgetStates={setWidgetStates} onNavigateToChat={onNavigateToChat}/>}
1038
+ {view === 'workspace' && <WorkspaceView openModal={openModal} layout={layout} setLayout={setLayout} widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
1039
+ {view === 'deliverables' && <DeliverablesView allStates={widgetStates}/>}
1040
+ </div>
1041
+ </div>
1042
+ </div>
1043
+ <ModalRouter modal={modal} onClose={closeModal}/>
1044
+ <ToastStack/>
1045
+ <CanvasWelcomeTour key={tourForceShow} forceShow={tourForceShow > 0}/>
1046
+ <ShortcutHint/>
1047
  </div>
1048
  );
1049
  };
1050
 
1051
+ // Subtle floating hint bar showing the most-used keyboard shortcuts.
1052
+ // Auto-hides on small screens and after the first 12s, until the user hovers.
1053
+ function ShortcutHint() {
1054
+ const [visible, setVisible] = useState(true);
1055
+ useEffect(() => {
1056
+ const t = setTimeout(() => setVisible(false), 12000);
1057
+ return () => clearTimeout(t);
1058
+ }, []);
1059
+ return (
1060
+ <div
1061
+ className={`canvas-shortcut-hint ${visible ? 'visible' : ''}`}
1062
+ onMouseEnter={() => setVisible(true)}
1063
+ onMouseLeave={() => setVisible(false)}
1064
+ >
1065
+ <span><kbd>{MOD}</kbd><kbd>K</kbd> commands</span>
1066
+ <span><kbd>{MOD}</kbd><kbd>/</kbd> search</span>
1067
+ <span><kbd>?</kbd> help</span>
1068
+ </div>
1069
+ );
1070
+ }
1071
+
1072
+ export default CanvasPage;
phd-advisor-frontend/src/pages/ChatPage.js CHANGED
@@ -1,28 +1,31 @@
1
  import React, { useState, useEffect, useRef, useMemo } from 'react';
2
-
3
- import { Home, MessageCircle, Reply, X, Sparkles, Users, Settings2, FileText, Menu, HelpCircle } from 'lucide-react';
4
-
5
  import EnhancedChatInput from '../components/EnhancedChatInput';
6
  import MessageBubble from '../components/MessageBubble';
7
  import ThinkingIndicator from '../components/ThinkingIndicator';
8
  import SuggestionsPanel from '../components/SuggestionsPanel';
9
- import ThemeToggle from '../components/ThemeToggle';
10
- import ProviderDropdown from '../components/ProviderDropdown';
11
  import ExportButton from '../components/ExportButton';
12
  import Sidebar from '../components/Sidebar';
13
  import { useAppConfig } from '../contexts/AppConfigContext';
14
  import { useTheme } from '../contexts/ThemeContext';
15
  import '../styles/ChatPage.css';
16
  import '../styles/EnhancedChatInput.css';
17
- import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown';
18
  import AdvisorCarousel from '../components/AdvisorCarousel';
19
- import OnboardingTour from '../components/OnboardingTour';
 
 
 
20
 
21
- const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut, onUserUpdate }) => {
 
 
22
  const { config, advisors, getAdvisorColors } = useAppConfig();
23
  const [messages, setMessages] = useState([]);
24
  const [isLoading, setIsLoading] = useState(false);
25
  const [thinkingAdvisors, setThinkingAdvisors] = useState([]);
 
26
  const [collectedInfo, setCollectedInfo] = useState({});
27
  const [replyingTo, setReplyingTo] = useState(null);
28
  const [currentProvider, setCurrentProvider] = useState('gemini');
@@ -38,7 +41,97 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig
38
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
39
  const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
40
 
41
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  const scrollToBottom = () => {
44
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -215,19 +308,20 @@ const loadChatSession = async (sessionId) => {
215
  }
216
  };
217
 
218
- // Save a message to the current session
219
- const saveMessageToSession = async (message) => {
220
- if (!currentSessionId || !authToken) return;
 
221
 
222
  try {
223
- await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${currentSessionId}/messages`, {
224
  method: 'POST',
225
  headers: {
226
  'Authorization': `Bearer ${authToken}`,
227
  'Content-Type': 'application/json'
228
  },
229
  body: JSON.stringify({
230
- session_id: currentSessionId,
231
  message: {
232
  ...message,
233
  timestamp: message.timestamp.toISOString()
@@ -395,6 +489,10 @@ const handleNewChat = async (sessionId = null) => {
395
  }
396
  }
397
 
 
 
 
 
398
  // Update session title if this is the first message and title is generic
399
  if (messages.length === 0 && currentSessionTitle.includes('Chat ')) {
400
  const newTitle = inputMessage.length > 30
@@ -405,6 +503,14 @@ const handleNewChat = async (sessionId = null) => {
405
 
406
  // Set loading state
407
  setIsLoading(true);
 
 
 
 
 
 
 
 
408
  setThinkingAdvisors(['system']);
409
 
410
  try {
@@ -417,7 +523,8 @@ const handleNewChat = async (sessionId = null) => {
417
  body: JSON.stringify({
418
  user_input: inputMessage,
419
  response_length: 'medium',
420
- chat_session_id: currentSessionId // Include current session ID
 
421
  }),
422
  });
423
 
@@ -457,7 +564,7 @@ const handleNewChat = async (sessionId = null) => {
457
  };
458
  setMessages(prev => [...prev, msg]);
459
  setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
460
- await saveMessageToSession(msg);
461
  break;
462
  }
463
  case 'clarification':
@@ -470,6 +577,15 @@ const handleNewChat = async (sessionId = null) => {
470
  }]);
471
  break;
472
  case 'progress':
 
 
 
 
 
 
 
 
 
473
  if (d.phase === 'complete') {
474
  break;
475
  }
@@ -753,94 +869,60 @@ const handleNewChat = async (sessionId = null) => {
753
  const chatPlaceholder = config?.chat_page?.placeholder || "Ask your advisors anything...";
754
 
755
  return (
756
- <OnboardingTour>
757
  <div className="chat-page-with-sidebar">
758
  {/* Sidebar Component */}
759
- <Sidebar
760
  user={user}
761
  currentSessionId={currentSessionId}
762
  onSelectSession={handleSelectSession}
763
  onNewChat={handleNewChat}
764
  onCurrentSessionDeleted={handleCurrentSessionDeleted}
765
  onSignOut={onSignOut}
766
- onUserUpdate={onUserUpdate}
767
  authToken={authToken}
768
  onSidebarToggle={handleSidebarToggle}
769
  isMobileOpen={isMobileMenuOpen}
770
  onMobileToggle={setIsMobileMenuOpen}
771
  onNavigateToCanvas={onNavigateToCanvas}
772
  refreshTrigger={sidebarRefreshTrigger}
 
 
 
 
 
773
  />
774
 
775
  <div className={`main-chat-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
776
  <div className="modern-chat-page">
777
- {/* Floating Header */}
778
- <div className="floating-header">
779
- <div className="header-left">
780
- <button
781
- className="mobile-menu-button"
782
- onClick={handleMobileMenuToggle}
783
- >
784
- <Menu size={20} />
785
- </button>
786
- <button onClick={onNavigateToHome} className="modern-home-btn">
787
- <Home size={20} />
788
- </button>
789
- <div className="header-brand">
790
- <div className="brand-icon">
791
- <Users size={24} />
792
- </div>
793
- <div className="brand-text">
794
- <h1>{config?.app?.title || 'Advisory'}</h1>
795
- <p>{config?.app?.subtitle || 'AI-Powered Guidance'}</p>
796
- </div>
797
- </div>
798
- </div>
799
-
800
- <div className="header-right">
801
- <AdvisorStatusDropdown
802
- advisors={advisors}
803
- thinkingAdvisors={thinkingAdvisors}
804
- getAdvisorColors={getAdvisorColors}
805
- isDark={isDark}
806
- />
807
-
808
- <div className="header-controls">
809
- {/* Add session title display */}
810
- {currentSessionTitle && (
811
- <div className="session-title-display">
812
- <span>{currentSessionTitle}</span>
813
- </div>
814
- )}
815
-
816
- {/* Export Button */}
817
- <ExportButton
818
- hasMessages={hasConversationMessages}
819
- currentSessionId={currentSessionId}
820
- authToken={authToken}
821
- />
822
-
823
- {/* Provider Dropdown */}
824
- <ProviderDropdown
825
- currentProvider={currentProvider}
826
- onProviderChange={handleProviderSwitch}
827
- isLoading={isProviderSwitching}
828
- />
829
-
830
- {/* Theme Toggle */}
831
- <ThemeToggle />
832
-
833
- {/* Help / User Guide */}
834
- <button
835
- className="header-help-btn"
836
- onClick={() => window.dispatchEvent(new CustomEvent('open-user-guide'))}
837
- title="Open user guide"
838
- >
839
- <HelpCircle />
840
- </button>
841
- </div>
842
- </div>
843
- </div>
844
 
845
  {/* Main Content */}
846
  <div className="chat-content">
@@ -1000,7 +1082,6 @@ const handleNewChat = async (sessionId = null) => {
1000
  </div>
1001
  </div>
1002
  </div>
1003
- </OnboardingTour>
1004
  );
1005
  };
1006
 
 
1
  import React, { useState, useEffect, useRef, useMemo } from 'react';
2
+ import { MessageCircle, Reply, X, Sparkles, Users, Settings2, FileText, HelpCircle } from 'lucide-react';
 
 
3
  import EnhancedChatInput from '../components/EnhancedChatInput';
4
  import MessageBubble from '../components/MessageBubble';
5
  import ThinkingIndicator from '../components/ThinkingIndicator';
6
  import SuggestionsPanel from '../components/SuggestionsPanel';
7
+ import AppHeader from '../components/AppHeader';
8
+ import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown';
9
  import ExportButton from '../components/ExportButton';
10
  import Sidebar from '../components/Sidebar';
11
  import { useAppConfig } from '../contexts/AppConfigContext';
12
  import { useTheme } from '../contexts/ThemeContext';
13
  import '../styles/ChatPage.css';
14
  import '../styles/EnhancedChatInput.css';
 
15
  import AdvisorCarousel from '../components/AdvisorCarousel';
16
+ import OnboardingChat from '../components/OnboardingChat';
17
+ import ProfileWalkthrough from '../components/ProfileWalkthrough';
18
+ import ClearDataModal from '../components/ClearDataModal';
19
+ import AccountModal from '../components/AccountModal';
20
 
21
+ const ACTIVE_ADVISORS_STORAGE_KEY = 'cybersecurityActiveAdvisorIds';
22
+
23
+ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => {
24
  const { config, advisors, getAdvisorColors } = useAppConfig();
25
  const [messages, setMessages] = useState([]);
26
  const [isLoading, setIsLoading] = useState(false);
27
  const [thinkingAdvisors, setThinkingAdvisors] = useState([]);
28
+ const [activeAdvisorIds, setActiveAdvisorIds] = useState([]);
29
  const [collectedInfo, setCollectedInfo] = useState({});
30
  const [replyingTo, setReplyingTo] = useState(null);
31
  const [currentProvider, setCurrentProvider] = useState('gemini');
 
41
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
42
  const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
43
 
44
+ const [userAvatarId, setUserAvatarId] = useState(
45
+ () => localStorage.getItem('userAvatarId') || (user?.avatarId ?? null)
46
+ );
47
+ const avatarOptions = config?.app?.user_avatars || [];
48
+
49
+ const handleAvatarChange = async (id) => {
50
+ setUserAvatarId(id);
51
+ localStorage.setItem('userAvatarId', id);
52
+ try {
53
+ await fetch(`${process.env.REACT_APP_API_URL}/auth/me`, {
54
+ method: 'PATCH',
55
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ avatarId: id }),
57
+ });
58
+ } catch (e) {
59
+ console.error('Failed to save avatar:', e);
60
+ }
61
+ };
62
+
63
+ const [showOnboarding, setShowOnboarding] = useState(false);
64
+ const [showProfileForm, setShowProfileForm] = useState(false);
65
+ const [showClearData, setShowClearData] = useState(false);
66
+ const [showAccount, setShowAccount] = useState(false);
67
+ const [userProfile, setUserProfile] = useState(null);
68
+
69
+ const loadProfile = async () => {
70
+ try {
71
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
72
+ headers: { Authorization: `Bearer ${authToken}` },
73
+ });
74
+ if (resp.ok) setUserProfile(await resp.json());
75
+ } catch (e) {
76
+ /* ignore */
77
+ }
78
+ };
79
+
80
+ useEffect(() => {
81
+ if (authToken) loadProfile();
82
+ }, [authToken]);
83
+
84
+ useEffect(() => {
85
+ const allIds = Object.keys(advisors || {});
86
+ if (allIds.length === 0) return;
87
+
88
+ setActiveAdvisorIds((prev) => {
89
+ let next = prev.filter((id) => allIds.includes(id));
90
+ if (next.length === 0) {
91
+ try {
92
+ const stored = JSON.parse(localStorage.getItem(ACTIVE_ADVISORS_STORAGE_KEY) || 'null');
93
+ if (Array.isArray(stored)) {
94
+ const valid = stored.filter((id) => allIds.includes(id));
95
+ if (valid.length > 0) next = valid;
96
+ }
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+ if (next.length === 0) next = [...allIds];
102
+ if (
103
+ prev.length === next.length &&
104
+ prev.every((id, index) => id === next[index])
105
+ ) {
106
+ return prev;
107
+ }
108
+ return next;
109
+ });
110
+ }, [advisors]);
111
+
112
+ const persistActiveAdvisorIds = (ids) => {
113
+ localStorage.setItem(ACTIVE_ADVISORS_STORAGE_KEY, JSON.stringify(ids));
114
+ };
115
+
116
+ const handleSetActiveAdvisors = (ids) => {
117
+ setActiveAdvisorIds(ids);
118
+ persistActiveAdvisorIds(ids);
119
+ };
120
+
121
+ const handleToggleAdvisor = (id) => {
122
+ setActiveAdvisorIds((prev) => {
123
+ const set = new Set(prev);
124
+ if (set.has(id)) {
125
+ if (set.size <= 1) return prev;
126
+ set.delete(id);
127
+ } else {
128
+ set.add(id);
129
+ }
130
+ const next = Array.from(set);
131
+ persistActiveAdvisorIds(next);
132
+ return next;
133
+ });
134
+ };
135
 
136
  const scrollToBottom = () => {
137
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
 
308
  }
309
  };
310
 
311
+ // Save a message to the current session (optional sessionId when state is not updated yet)
312
+ const saveMessageToSession = async (message, sessionIdOverride) => {
313
+ const sid = sessionIdOverride || currentSessionId;
314
+ if (!sid || !authToken) return;
315
 
316
  try {
317
+ await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sid}/messages`, {
318
  method: 'POST',
319
  headers: {
320
  'Authorization': `Bearer ${authToken}`,
321
  'Content-Type': 'application/json'
322
  },
323
  body: JSON.stringify({
324
+ session_id: sid,
325
  message: {
326
  ...message,
327
  timestamp: message.timestamp.toISOString()
 
489
  }
490
  }
491
 
492
+ saveMessageToSession(userMessage, sessionId).catch(err =>
493
+ console.error('Failed to persist user message:', err)
494
+ );
495
+
496
  // Update session title if this is the first message and title is generic
497
  if (messages.length === 0 && currentSessionTitle.includes('Chat ')) {
498
  const newTitle = inputMessage.length > 30
 
503
 
504
  // Set loading state
505
  setIsLoading(true);
506
+ const advisorsForRequest = activeAdvisorIds.length > 0
507
+ ? activeAdvisorIds
508
+ : Object.keys(advisors || {});
509
+ // Start with just the orchestrator's thinking bubble. The backend will
510
+ // emit a `progress { phase: 'selected', selected_advisors: [...] }` event
511
+ // naming the 3 advisors it picked, and that handler will add their
512
+ // ThinkingIndicators. This prevents the brief flash of thinking indicators
513
+ // for every advisor in the active pool before ranking has run.
514
  setThinkingAdvisors(['system']);
515
 
516
  try {
 
523
  body: JSON.stringify({
524
  user_input: inputMessage,
525
  response_length: 'medium',
526
+ chat_session_id: sessionId,
527
+ active_advisors: advisorsForRequest,
528
  }),
529
  });
530
 
 
564
  };
565
  setMessages(prev => [...prev, msg]);
566
  setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
567
+ await saveMessageToSession(msg, sessionId);
568
  break;
569
  }
570
  case 'clarification':
 
577
  }]);
578
  break;
579
  case 'progress':
580
+ if (d.phase === 'selected' && Array.isArray(d.selected_advisors)) {
581
+ setThinkingAdvisors(prev => {
582
+ const next = new Set(prev);
583
+ next.add('system');
584
+ d.selected_advisors.forEach(id => next.add(id));
585
+ return Array.from(next);
586
+ });
587
+ break;
588
+ }
589
  if (d.phase === 'complete') {
590
  break;
591
  }
 
869
  const chatPlaceholder = config?.chat_page?.placeholder || "Ask your advisors anything...";
870
 
871
  return (
 
872
  <div className="chat-page-with-sidebar">
873
  {/* Sidebar Component */}
874
+ <Sidebar
875
  user={user}
876
  currentSessionId={currentSessionId}
877
  onSelectSession={handleSelectSession}
878
  onNewChat={handleNewChat}
879
  onCurrentSessionDeleted={handleCurrentSessionDeleted}
880
  onSignOut={onSignOut}
 
881
  authToken={authToken}
882
  onSidebarToggle={handleSidebarToggle}
883
  isMobileOpen={isMobileMenuOpen}
884
  onMobileToggle={setIsMobileMenuOpen}
885
  onNavigateToCanvas={onNavigateToCanvas}
886
  refreshTrigger={sidebarRefreshTrigger}
887
+ userAvatarId={userAvatarId}
888
+ onAvatarChange={handleAvatarChange}
889
+ onOpenProfile={() => setShowProfileForm(true)}
890
+ onOpenAccount={() => setShowAccount(true)}
891
+ onOpenClearData={() => setShowClearData(true)}
892
  />
893
 
894
  <div className={`main-chat-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
895
  <div className="modern-chat-page">
896
+ <AppHeader
897
+ currentPage="chat"
898
+ onNavigateToHome={onNavigateToHome}
899
+ onNavigateToChat={() => {}}
900
+ onNavigateToCanvas={onNavigateToCanvas}
901
+ onMobileMenu={handleMobileMenuToggle}
902
+ >
903
+ <AdvisorStatusDropdown
904
+ advisors={advisors}
905
+ activeAdvisorIds={activeAdvisorIds}
906
+ onToggleAdvisor={handleToggleAdvisor}
907
+ onSetActiveAdvisors={handleSetActiveAdvisors}
908
+ thinkingAdvisors={thinkingAdvisors}
909
+ getAdvisorColors={getAdvisorColors}
910
+ isDark={isDark}
911
+ />
912
+ <ExportButton
913
+ hasMessages={hasConversationMessages}
914
+ currentSessionId={currentSessionId}
915
+ authToken={authToken}
916
+ />
917
+ {/* User guide button (from main) — slotted into AppHeader's children */}
918
+ <button
919
+ className="icon-btn header-help-btn"
920
+ onClick={() => window.dispatchEvent(new CustomEvent('open-user-guide'))}
921
+ title="Open user guide"
922
+ >
923
+ <HelpCircle size={18} />
924
+ </button>
925
+ </AppHeader>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
 
927
  {/* Main Content */}
928
  <div className="chat-content">
 
1082
  </div>
1083
  </div>
1084
  </div>
 
1085
  );
1086
  };
1087
 
phd-advisor-frontend/src/pages/HomePage.js CHANGED
@@ -1,34 +1,21 @@
1
  import React from 'react';
2
  import { MessageCircle, ArrowRight } from 'lucide-react';
3
  import AdvisorCard from '../components/AdvisorCard';
4
- import ThemeToggle from '../components/ThemeToggle';
5
  import CopyrightNotice from '../components/CopyrightNotice';
6
  import { useAppConfig } from '../contexts/AppConfigContext';
7
 
8
- const HomePage = ({ onNavigateToChat, isAuthenticated }) => {
9
  const { config, advisors, resolveIcon } = useAppConfig();
10
 
11
- const UsersIcon = resolveIcon('Users');
12
-
13
  return (
14
  <div className="homepage">
15
- {/* Header */}
16
- <header className="header">
17
- <div className="header-content">
18
- <div className="header-left">
19
- <div className="logo-container">
20
- <UsersIcon className="logo-icon" />
21
- </div>
22
- <div>
23
- <h1 className="logo-title">{config.app.title}</h1>
24
- <p className="logo-subtitle">{config.app.subtitle}</p>
25
- </div>
26
- </div>
27
- <div className="header-right">
28
- <ThemeToggle />
29
- </div>
30
- </div>
31
- </header>
32
 
33
  {/* Hero Section */}
34
  <main className="main">
 
1
  import React from 'react';
2
  import { MessageCircle, ArrowRight } from 'lucide-react';
3
  import AdvisorCard from '../components/AdvisorCard';
4
+ import AppHeader from '../components/AppHeader';
5
  import CopyrightNotice from '../components/CopyrightNotice';
6
  import { useAppConfig } from '../contexts/AppConfigContext';
7
 
8
+ const HomePage = ({ onNavigateToChat, isAuthenticated, onNavigateToHome, onNavigateToCanvas }) => {
9
  const { config, advisors, resolveIcon } = useAppConfig();
10
 
 
 
11
  return (
12
  <div className="homepage">
13
+ <AppHeader
14
+ currentPage="home"
15
+ onNavigateToHome={onNavigateToHome}
16
+ onNavigateToChat={onNavigateToChat}
17
+ onNavigateToCanvas={onNavigateToCanvas}
18
+ />
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  {/* Hero Section */}
21
  <main className="main">
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
The diff for this file is too large to render. See raw diff
 
phd-advisor-frontend/src/styles/ChatPage.css CHANGED
@@ -18,23 +18,18 @@
18
  position: sticky;
19
  top: 0;
20
  z-index: 100;
21
- background: rgba(255, 255, 255, 0.95);
22
  backdrop-filter: blur(20px);
23
  -webkit-backdrop-filter: blur(20px);
24
- border-bottom: 1px solid var(--border-primary);
25
  padding: 12px 24px;
26
  display: flex;
27
  align-items: center;
28
  justify-content: space-between;
29
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
30
  flex-shrink: 0;
31
  }
32
 
33
- [data-theme="dark"] .floating-header {
34
- background: rgba(31, 41, 55, 0.95);
35
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
36
- }
37
-
38
  .header-left {
39
  display: flex;
40
  align-items: center;
@@ -79,19 +74,25 @@
79
  color: white;
80
  }
81
 
 
 
 
 
 
 
82
  .brand-text h1 {
83
  font-size: 20px;
84
  font-weight: 700;
85
  color: var(--text-primary);
86
  margin: 0;
87
- line-height: 1;
88
  }
89
 
90
  .brand-text p {
91
  font-size: 13px;
92
  color: var(--text-secondary);
93
  margin: 0;
94
- line-height: 1;
95
  }
96
 
97
  .header-right {
@@ -678,34 +679,6 @@
678
  margin-left: 70px; /* Width of sidebar when collapsed */
679
  }
680
 
681
- /* Session title display in header */
682
- .session-title-display {
683
- display: flex;
684
- align-items: center;
685
- padding: 6px 12px;
686
- background: var(--bg-secondary, #f3f4f6);
687
- border-radius: 6px;
688
- margin-right: 8px;
689
- }
690
-
691
- .dark .session-title-display {
692
- background: var(--bg-secondary-dark, #374151);
693
- }
694
-
695
- .session-title-display span {
696
- font-size: 13px;
697
- font-weight: 500;
698
- color: var(--text-primary, #111827);
699
- max-width: 150px;
700
- white-space: nowrap;
701
- overflow: hidden;
702
- text-overflow: ellipsis;
703
- }
704
-
705
- .dark .session-title-display span {
706
- color: var(--text-primary-dark, #f9fafb);
707
- }
708
-
709
  /* Optional header sign out button */
710
  .header-signout-btn {
711
  display: flex;
 
18
  position: sticky;
19
  top: 0;
20
  z-index: 100;
21
+ background: var(--header-bg);
22
  backdrop-filter: blur(20px);
23
  -webkit-backdrop-filter: blur(20px);
24
+ border-bottom: 1px solid var(--header-border);
25
  padding: 12px 24px;
26
  display: flex;
27
  align-items: center;
28
  justify-content: space-between;
29
+ box-shadow: var(--header-shadow);
30
  flex-shrink: 0;
31
  }
32
 
 
 
 
 
 
33
  .header-left {
34
  display: flex;
35
  align-items: center;
 
74
  color: white;
75
  }
76
 
77
+ .brand-text {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 6px;
81
+ }
82
+
83
  .brand-text h1 {
84
  font-size: 20px;
85
  font-weight: 700;
86
  color: var(--text-primary);
87
  margin: 0;
88
+ line-height: 1.2;
89
  }
90
 
91
  .brand-text p {
92
  font-size: 13px;
93
  color: var(--text-secondary);
94
  margin: 0;
95
+ line-height: 1.35;
96
  }
97
 
98
  .header-right {
 
679
  margin-left: 70px; /* Width of sidebar when collapsed */
680
  }
681
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  /* Optional header sign out button */
683
  .header-signout-btn {
684
  display: flex;
phd-advisor-frontend/src/styles/Sidebar.css CHANGED
@@ -272,21 +272,13 @@
272
  background: #7f1d1d;
273
  }
274
 
275
- /* New Chat Row (button + clear-all icon) */
276
- .new-chat-row {
277
- display: flex;
278
- align-items: stretch;
279
- gap: 8px;
280
- width: 100%;
281
- }
282
-
283
  /* New Chat Button */
284
  .new-chat-button {
285
  display: flex;
286
  align-items: center;
287
  justify-content: center;
288
  gap: 8px;
289
- flex: 1;
290
  padding: 12px 16px;
291
  background: var(--primary-color, #3b82f6);
292
  color: white;
@@ -297,47 +289,6 @@
297
  transition: all 0.2s ease;
298
  }
299
 
300
- /* Clear All Chats Button — matches .new-chat-icon-btn dimensions */
301
- .clear-all-chats-btn {
302
- display: flex;
303
- align-items: center;
304
- justify-content: center;
305
- width: 36px;
306
- height: 36px;
307
- flex-shrink: 0;
308
- padding: 0;
309
- background: transparent;
310
- color: #ef4444;
311
- border: 1px solid rgba(239, 68, 68, 0.35);
312
- border-radius: 8px;
313
- cursor: pointer;
314
- transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.12s ease;
315
- }
316
-
317
- .clear-all-chats-btn:hover:not(:disabled) {
318
- background: #ef4444;
319
- color: #ffffff;
320
- border-color: #ef4444;
321
- transform: translateY(-1px);
322
- }
323
-
324
- .clear-all-chats-btn:disabled {
325
- opacity: 0.4;
326
- cursor: not-allowed;
327
- transform: none;
328
- }
329
-
330
- [data-theme="dark"] .clear-all-chats-btn {
331
- color: #f87171;
332
- border-color: rgba(248, 113, 113, 0.4);
333
- }
334
-
335
- [data-theme="dark"] .clear-all-chats-btn:hover:not(:disabled) {
336
- background: #ef4444;
337
- color: #ffffff;
338
- border-color: #ef4444;
339
- }
340
-
341
  .new-chat-button:hover {
342
  background: var(--primary-color-hover, #2563eb);
343
  transform: translateY(-1px);
@@ -902,24 +853,4 @@
902
  .mobile-sidebar-overlay.visible {
903
  display: block;
904
  }
905
- }
906
-
907
- .sidebar-version {
908
- padding: 8px 16px;
909
- border-top: 1px solid var(--border-light, #e5e7eb);
910
- font-size: 11px;
911
- color: var(--text-secondary, #6b7280);
912
- text-align: center;
913
- user-select: none;
914
- flex-shrink: 0;
915
- }
916
-
917
- .sidebar.collapsed .sidebar-version {
918
- padding: 8px 4px;
919
- font-size: 10px;
920
- }
921
-
922
- .dark .sidebar-version {
923
- border-top-color: var(--border-dark, #374151);
924
- color: var(--text-secondary-dark, #9ca3af);
925
  }
 
272
  background: #7f1d1d;
273
  }
274
 
 
 
 
 
 
 
 
 
275
  /* New Chat Button */
276
  .new-chat-button {
277
  display: flex;
278
  align-items: center;
279
  justify-content: center;
280
  gap: 8px;
281
+ width: 100%;
282
  padding: 12px 16px;
283
  background: var(--primary-color, #3b82f6);
284
  color: white;
 
289
  transition: all 0.2s ease;
290
  }
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  .new-chat-button:hover {
293
  background: var(--primary-color-hover, #2563eb);
294
  transform: translateY(-1px);
 
853
  .mobile-sidebar-overlay.visible {
854
  display: block;
855
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856
  }
phd-advisor-frontend/src/styles/components.css CHANGED
@@ -42,6 +42,11 @@
42
  --input-border: #D1D5DB;
43
  --input-focus: #6366F1;
44
  --input-focus-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
 
 
 
 
 
45
  }
46
 
47
  :root[data-theme="dark"] {
@@ -87,6 +92,11 @@
87
  --input-border: #4B5563;
88
  --input-focus: #818CF8;
89
  --input-focus-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
 
 
 
 
 
90
  }
91
 
92
  /* Theme Toggle Button */
@@ -527,13 +537,15 @@
527
  .advisor-message-header {
528
  display: flex;
529
  align-items: center;
530
- gap: 8px;
531
  margin-bottom: 8px;
532
  }
533
 
534
  .advisor-message-name {
535
- font-weight: 500;
536
- font-size: 14px;
 
 
537
  }
538
 
539
  .reply-badge {
@@ -550,9 +562,44 @@
550
  }
551
 
552
  .advisor-message-text {
 
553
  line-height: 1.6;
554
  }
555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  .advisor-message-text > div:last-child p:last-child {
557
  margin-bottom: 0; /* Remove bottom margin from last paragraph */
558
  }
@@ -765,8 +812,9 @@
765
  }
766
 
767
  .advisor-name {
768
- font-weight: 500;
769
- font-size: 14px;
 
770
  margin: 0;
771
  }
772
 
@@ -1184,7 +1232,11 @@
1184
  }
1185
 
1186
  .advisor-message-text {
1187
- font-size: 14px;
 
 
 
 
1188
  }
1189
 
1190
  /* Basic ChatInput mobile styles */
 
42
  --input-border: #D1D5DB;
43
  --input-focus: #6366F1;
44
  --input-focus-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
45
+
46
+ /* App header (floating bar) */
47
+ --header-bg: #EBF5FF;
48
+ --header-border: rgba(59, 130, 246, 0.2);
49
+ --header-shadow: 0 4px 20px rgba(37, 99, 235, 0.1);
50
  }
51
 
52
  :root[data-theme="dark"] {
 
92
  --input-border: #4B5563;
93
  --input-focus: #818CF8;
94
  --input-focus-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
95
+
96
+ /* App header (floating bar) */
97
+ --header-bg: rgba(23, 37, 64, 0.97);
98
+ --header-border: rgba(96, 165, 250, 0.18);
99
+ --header-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
100
  }
101
 
102
  /* Theme Toggle Button */
 
537
  .advisor-message-header {
538
  display: flex;
539
  align-items: center;
540
+ gap: 10px;
541
  margin-bottom: 8px;
542
  }
543
 
544
  .advisor-message-name {
545
+ font-weight: 600;
546
+ font-size: 1rem;
547
+ line-height: 1.6;
548
+ margin: 0;
549
  }
550
 
551
  .reply-badge {
 
562
  }
563
 
564
  .advisor-message-text {
565
+ font-size: 1rem;
566
  line-height: 1.6;
567
  }
568
 
569
+ /* Advisor icon in message header: circular badge, theme-aware fill */
570
+ .advisor-message-avatar-ring {
571
+ border-radius: 50%;
572
+ display: flex;
573
+ align-items: center;
574
+ justify-content: center;
575
+ flex-shrink: 0;
576
+ overflow: hidden;
577
+ background: #ffffff;
578
+ border: 1px solid rgba(15, 23, 42, 0.1);
579
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
580
+ }
581
+
582
+ [data-theme="dark"] .advisor-message-avatar-ring {
583
+ background: #1e293b;
584
+ border-color: rgba(248, 250, 252, 0.12);
585
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
586
+ }
587
+
588
+ .advisor-message-avatar-ring img {
589
+ width: 100%;
590
+ height: 100%;
591
+ object-fit: cover;
592
+ }
593
+
594
+ .advisor-message-avatar-icon {
595
+ flex-shrink: 0;
596
+ }
597
+
598
+ .advisor-message-avatar-initial {
599
+ font-weight: 700;
600
+ line-height: 1;
601
+ }
602
+
603
  .advisor-message-text > div:last-child p:last-child {
604
  margin-bottom: 0; /* Remove bottom margin from last paragraph */
605
  }
 
812
  }
813
 
814
  .advisor-name {
815
+ font-weight: 600;
816
+ font-size: 1rem;
817
+ line-height: 1.6;
818
  margin: 0;
819
  }
820
 
 
1232
  }
1233
 
1234
  .advisor-message-text {
1235
+ font-size: 1rem;
1236
+ }
1237
+
1238
+ .advisor-message-name {
1239
+ font-size: 1rem;
1240
  }
1241
 
1242
  /* Basic ChatInput mobile styles */