NeonClary Cursor commited on
Commit
689d159
·
1 Parent(s): 6004480

Fix LLM chat startup and polish cybersecurity advisor UI.

Browse files

Load shared.env before bootstrap so API keys are available, fix chat-stream session binding, and remove the chat title from the header.

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

cybersecurity_config.yaml CHANGED
@@ -3,7 +3,7 @@
3
  # ============================================================================
4
 
5
  app:
6
- title: "Cybersecurity Advisor Canvas"
7
  subtitle: "AI-Powered Security Guidance"
8
  primary_color: "#0F172A"
9
  logo_icon: "Shield"
 
3
  # ============================================================================
4
 
5
  app:
6
+ title: "Cybersecurity Advisor"
7
  subtitle: "AI-Powered Security Guidance"
8
  primary_color: "#0F172A"
9
  logo_icon: "Shield"
multi_llm_chatbot_backend/.env.example CHANGED
@@ -9,4 +9,8 @@ VLLM_API_KEY=
9
  OPENAI_API_KEY=
10
  GEMINI_API_KEY=
11
 
12
- CORS_ORIGINS=http://localhost:3010,http://127.0.0.1:3010
 
 
 
 
 
9
  OPENAI_API_KEY=
10
  GEMINI_API_KEY=
11
 
12
+ # Include every frontend origin you use locally (React default is 3000)
13
+ CORS_ORIGINS=http://localhost:3010,http://127.0.0.1:3010,http://localhost:3000,http://127.0.0.1:3000
14
+
15
+ # Optional override; default is %USERPROFILE%\.secrets\shared.env
16
+ # SHARED_ENV=C:\Users\dream\.secrets\shared.env
multi_llm_chatbot_backend/app/api/routes/auth.py CHANGED
@@ -90,17 +90,19 @@ async def signup(user_data: UserCreate):
90
  result = await db.users.insert_one(user.dict(by_alias=True))
91
  user.id = result.inserted_id
92
 
93
- profile_seed = {}
 
 
 
 
 
94
  if user_data.researchArea:
95
  profile_seed["timezone"] = user_data.researchArea
96
- if profile_seed:
97
- profile_seed["user_id"] = user.id
98
- profile_seed["updated_at"] = datetime.utcnow()
99
- await db.user_profiles.update_one(
100
- {"user_id": user.id},
101
- {"$set": profile_seed},
102
- upsert=True,
103
- )
104
 
105
  # Create access token
106
  access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
@@ -260,11 +262,15 @@ async def update_profile(
260
  )
261
 
262
  await db.users.update_one({"_id": current_user.id}, {"$set": updates})
263
- if body.researchArea:
 
 
 
 
 
264
  await db.user_profiles.update_one(
265
  {"user_id": current_user.id},
266
- {"$set": {"timezone": body.researchArea, "updated_at": datetime.utcnow()},
267
- "$setOnInsert": {"user_id": current_user.id}},
268
  upsert=True,
269
  )
270
  updated_user = await db.users.find_one({"_id": current_user.id})
 
90
  result = await db.users.insert_one(user.dict(by_alias=True))
91
  user.id = result.inserted_id
92
 
93
+ profile_seed: dict = {
94
+ "user_id": user.id,
95
+ "updated_at": datetime.utcnow(),
96
+ }
97
+ if user_data.academicStage:
98
+ profile_seed["knowledge_level"] = user_data.academicStage
99
  if user_data.researchArea:
100
  profile_seed["timezone"] = user_data.researchArea
101
+ await db.user_profiles.update_one(
102
+ {"user_id": user.id},
103
+ {"$set": profile_seed},
104
+ upsert=True,
105
+ )
 
 
 
106
 
107
  # Create access token
108
  access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
 
262
  )
263
 
264
  await db.users.update_one({"_id": current_user.id}, {"$set": updates})
265
+ profile_sync: dict = {"updated_at": datetime.utcnow()}
266
+ if body.academicStage is not None:
267
+ profile_sync["knowledge_level"] = body.academicStage
268
+ if body.researchArea is not None:
269
+ profile_sync["timezone"] = body.researchArea
270
+ if len(profile_sync) > 1:
271
  await db.user_profiles.update_one(
272
  {"user_id": current_user.id},
273
+ {"$set": profile_sync, "$setOnInsert": {"user_id": current_user.id}},
 
274
  upsert=True,
275
  )
276
  updated_user = await db.users.find_one({"_id": current_user.id})
multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -16,29 +16,22 @@ 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
- _PROFILE_FIELDS = [
26
- "cyber_role", "organization_type", "primary_domains", "certifications",
27
- "tools_stack", "compliance_focus", "current_goals", "learning_preferences", "timezone",
28
- ]
29
-
30
 
31
  async def _attach_user_profile_context(session, user: User) -> None:
32
  """Load Mongo profile + signup fields into session for persona prompts."""
33
  try:
34
  db = get_database()
35
- profile = await db.user_profiles.find_one({"user_id": user.id}) or {}
 
36
  parts = []
37
- if user.academicStage:
38
- parts.append(f"knowledge_level: {user.academicStage}")
39
- if user.researchArea and not profile.get("timezone"):
40
- parts.append(f"timezone: {user.researchArea}")
41
- for key in _PROFILE_FIELDS:
42
  val = profile.get(key)
43
  if val:
44
  if isinstance(val, list):
@@ -183,9 +176,10 @@ async def chat_stream(
183
  await done_queue.put(result)
184
  except Exception as e:
185
  logger.exception(f"chat-stream _run failed for {pid}: {e}")
 
186
  await done_queue.put({
187
- "persona_id": persona.id,
188
- "persona_name": persona.name,
189
  "response": f"I ran into a technical issue. Please try again. ({e!s})",
190
  "used_documents": False,
191
  "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):
 
176
  await done_queue.put(result)
177
  except Exception as e:
178
  logger.exception(f"chat-stream _run failed for {pid}: {e}")
179
+ failed_persona = chat_orchestrator.get_persona(pid)
180
  await done_queue.put({
181
+ "persona_id": pid,
182
+ "persona_name": failed_persona.name if failed_persona else pid,
183
  "response": f"I ran into a technical issue. Please try again. ({e!s})",
184
  "used_documents": False,
185
  "document_chunks_used": 0,
multi_llm_chatbot_backend/app/api/routes/onboarding.py CHANGED
@@ -35,7 +35,7 @@ from typing import Any, Dict, Optional
35
  from fastapi import APIRouter, Depends
36
  from pydantic import BaseModel
37
 
38
- from app.api.routes.user_profile import _is_field_filled
39
  from app.core.auth import get_current_active_user
40
  from app.core.bootstrap import create_llm_client
41
  from app.core.database import get_database
@@ -77,7 +77,8 @@ async def onboarding_start(
77
  message is generated based on which fields are still missing.
78
  """
79
  db = get_database()
80
- profile = await db.user_profiles.find_one({"user_id": current_user.id}) or {}
 
81
  progress = _progress(profile)
82
 
83
  if progress >= 100:
@@ -128,7 +129,8 @@ async def onboarding_chat(
128
  current_user: User = Depends(get_current_active_user),
129
  ) -> Dict[str, Any]:
130
  db = get_database()
131
- profile = await db.user_profiles.find_one({"user_id": current_user.id}) or {}
 
132
 
133
  agent = OnboardingAgent(create_llm_client())
134
  result = await agent.chat(msg.user_input, current_user.id, profile)
 
35
  from fastapi import APIRouter, Depends
36
  from pydantic import BaseModel
37
 
38
+ from app.api.routes.user_profile import _is_field_filled, enrich_profile_from_user
39
  from app.core.auth import get_current_active_user
40
  from app.core.bootstrap import create_llm_client
41
  from app.core.database import get_database
 
77
  message is generated based on which fields are still missing.
78
  """
79
  db = get_database()
80
+ doc = await db.user_profiles.find_one({"user_id": current_user.id})
81
+ profile = enrich_profile_from_user(doc, current_user)
82
  progress = _progress(profile)
83
 
84
  if progress >= 100:
 
129
  current_user: User = Depends(get_current_active_user),
130
  ) -> Dict[str, Any]:
131
  db = get_database()
132
+ doc = await db.user_profiles.find_one({"user_id": current_user.id})
133
+ profile = enrich_profile_from_user(doc, current_user)
134
 
135
  agent = OnboardingAgent(create_llm_client())
136
  result = await agent.chat(msg.user_input, current_user.id, profile)
multi_llm_chatbot_backend/app/api/routes/user_profile.py CHANGED
@@ -15,6 +15,8 @@ LOG = logging.getLogger(__name__)
15
  router = APIRouter()
16
 
17
  PROFILE_FIELDS = [
 
 
18
  "cyber_role",
19
  "organization_type",
20
  "primary_domains",
@@ -23,7 +25,6 @@ PROFILE_FIELDS = [
23
  "compliance_focus",
24
  "current_goals",
25
  "learning_preferences",
26
- "timezone",
27
  ]
28
 
29
  LIST_FIELDS = {"primary_domains", "certifications", "tools_stack"}
@@ -61,11 +62,35 @@ def _is_field_filled(value: Any) -> bool:
61
  return bool(value)
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def _calc_completion(doc: Dict[str, Any]) -> int:
65
  filled = sum(1 for f in PROFILE_FIELDS if _is_field_filled(doc.get(f)))
66
  return int(filled / len(PROFILE_FIELDS) * 100)
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  _SELECT_LOOKUP: Dict[str, Dict[str, str]] = {
70
  field: {opt.lower(): opt for opt in opts}
71
  for field, opts in _SELECT_OPTIONS.items()
@@ -99,6 +124,18 @@ def _normalize_field(key: str, value: Any) -> Any:
99
  return value
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  @router.get("/users/me/profile", response_model=UserProfileResponse)
103
  async def get_my_profile(
104
  current_user: User = Depends(get_current_active_user),
@@ -106,18 +143,8 @@ async def get_my_profile(
106
  db = get_database()
107
  doc = await db.user_profiles.find_one({"user_id": current_user.id})
108
  if not doc:
109
- resp = UserProfileResponse(user_id=str(current_user.id))
110
- if current_user.researchArea:
111
- resp.timezone = current_user.researchArea
112
- return resp
113
- fields = {k: _normalize_field(k, doc.get(k)) for k in PROFILE_FIELDS}
114
- return UserProfileResponse(
115
- user_id=str(doc["user_id"]),
116
- **fields,
117
- advisor_notes=doc.get("advisor_notes"),
118
- updated_at=doc.get("updated_at"),
119
- completion_pct=_calc_completion(doc),
120
- )
121
 
122
 
123
  @router.put("/users/me/profile", response_model=UserProfileResponse)
@@ -133,15 +160,11 @@ async def update_my_profile(
133
  {"$set": update_data, "$setOnInsert": {"user_id": current_user.id}},
134
  upsert=True,
135
  )
136
- doc = await db.user_profiles.find_one({"user_id": current_user.id})
137
- fields = {k: _normalize_field(k, doc.get(k)) for k in PROFILE_FIELDS}
138
- return UserProfileResponse(
139
- user_id=str(doc["user_id"]),
140
- **fields,
141
- advisor_notes=doc.get("advisor_notes"),
142
- updated_at=doc.get("updated_at"),
143
- completion_pct=_calc_completion(doc),
144
- )
145
 
146
 
147
  class ClearDataRequest(BaseModel):
 
15
  router = APIRouter()
16
 
17
  PROFILE_FIELDS = [
18
+ "knowledge_level",
19
+ "timezone",
20
  "cyber_role",
21
  "organization_type",
22
  "primary_domains",
 
25
  "compliance_focus",
26
  "current_goals",
27
  "learning_preferences",
 
28
  ]
29
 
30
  LIST_FIELDS = {"primary_domains", "certifications", "tools_stack"}
 
62
  return bool(value)
63
 
64
 
65
+ def enrich_profile_from_user(
66
+ doc: Optional[Dict[str, Any]], user: User
67
+ ) -> Dict[str, Any]:
68
+ """Merge account signup fields into the profile when profile slots are empty."""
69
+ merged = dict(doc or {})
70
+ if user.academicStage and not _is_field_filled(merged.get("knowledge_level")):
71
+ merged["knowledge_level"] = user.academicStage
72
+ if user.researchArea and not _is_field_filled(merged.get("timezone")):
73
+ merged["timezone"] = user.researchArea
74
+ return merged
75
+
76
+
77
  def _calc_completion(doc: Dict[str, Any]) -> int:
78
  filled = sum(1 for f in PROFILE_FIELDS if _is_field_filled(doc.get(f)))
79
  return int(filled / len(PROFILE_FIELDS) * 100)
80
 
81
 
82
+ def _profile_response(doc: Dict[str, Any], user: User) -> UserProfileResponse:
83
+ enriched = enrich_profile_from_user(doc, user)
84
+ fields = {k: _normalize_field(k, enriched.get(k)) for k in PROFILE_FIELDS}
85
+ return UserProfileResponse(
86
+ user_id=str(enriched.get("user_id", user.id)),
87
+ **fields,
88
+ advisor_notes=enriched.get("advisor_notes"),
89
+ updated_at=enriched.get("updated_at"),
90
+ completion_pct=_calc_completion(enriched),
91
+ )
92
+
93
+
94
  _SELECT_LOOKUP: Dict[str, Dict[str, str]] = {
95
  field: {opt.lower(): opt for opt in opts}
96
  for field, opts in _SELECT_OPTIONS.items()
 
124
  return value
125
 
126
 
127
+ async def _sync_profile_to_user(user: User, update_data: Dict[str, Any]) -> None:
128
+ """Keep users.academicStage / researchArea aligned with profile edits."""
129
+ user_updates: Dict[str, Any] = {}
130
+ if "knowledge_level" in update_data:
131
+ user_updates["academicStage"] = update_data["knowledge_level"] or None
132
+ if "timezone" in update_data:
133
+ user_updates["researchArea"] = update_data["timezone"] or None
134
+ if user_updates:
135
+ db = get_database()
136
+ await db.users.update_one({"_id": user.id}, {"$set": user_updates})
137
+
138
+
139
  @router.get("/users/me/profile", response_model=UserProfileResponse)
140
  async def get_my_profile(
141
  current_user: User = Depends(get_current_active_user),
 
143
  db = get_database()
144
  doc = await db.user_profiles.find_one({"user_id": current_user.id})
145
  if not doc:
146
+ doc = {"user_id": current_user.id}
147
+ return _profile_response(doc, current_user)
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  @router.put("/users/me/profile", response_model=UserProfileResponse)
 
160
  {"$set": update_data, "$setOnInsert": {"user_id": current_user.id}},
161
  upsert=True,
162
  )
163
+ await _sync_profile_to_user(current_user, update_data)
164
+ doc = await db.user_profiles.find_one({"user_id": current_user.id}) or {
165
+ "user_id": current_user.id
166
+ }
167
+ return _profile_response(doc, current_user)
 
 
 
 
168
 
169
 
170
  class ClearDataRequest(BaseModel):
multi_llm_chatbot_backend/app/core/bootstrap.py CHANGED
@@ -2,6 +2,10 @@
2
  import os
3
  from pathlib import Path
4
 
 
 
 
 
5
  from app.config import get_settings
6
  from app.llm.improved_gemini_client import ImprovedGeminiClient
7
  from app.llm.improved_ollama_client import ImprovedOllamaClient
@@ -17,22 +21,35 @@ current_provider = settings.llm.provider or "vllm"
17
  available_providers = ["ollama", "gemini", "vllm"]
18
 
19
 
20
- def _load_shared_env_vllm_key() -> str:
21
- shared = Path.home() / ".secrets" / "shared.env"
22
- if shared.exists():
 
 
 
 
 
23
  for line in shared.read_text(encoding="utf-8").splitlines():
24
  line = line.strip()
25
- if line.startswith("VLLM_API_KEY="):
26
  return line.split("=", 1)[1].strip()
27
  return ""
28
 
29
 
30
  def _vllm_api_key() -> str:
31
- return settings.llm.vllm.api_key or os.getenv("VLLM_API_KEY", "") or _load_shared_env_vllm_key()
 
 
 
 
32
 
33
 
34
  def _openai_api_key() -> str:
35
- return settings.llm.openai.api_key or os.getenv("OPENAI_API_KEY", "")
 
 
 
 
36
 
37
 
38
  def _build_neon_vllm(neon_persona: str | None) -> ImprovedVllmClient:
 
2
  import os
3
  from pathlib import Path
4
 
5
+ from app.core.env_loader import load_application_env
6
+
7
+ load_application_env()
8
+
9
  from app.config import get_settings
10
  from app.llm.improved_gemini_client import ImprovedGeminiClient
11
  from app.llm.improved_ollama_client import ImprovedOllamaClient
 
21
  available_providers = ["ollama", "gemini", "vllm"]
22
 
23
 
24
+ def _load_shared_env_var(name: str) -> str:
25
+ explicit = os.environ.get("SHARED_ENV", "").strip()
26
+ candidates = [Path(explicit)] if explicit else []
27
+ candidates.append(Path.home() / ".secrets" / "shared.env")
28
+ prefix = f"{name}="
29
+ for shared in candidates:
30
+ if not shared.is_file():
31
+ continue
32
  for line in shared.read_text(encoding="utf-8").splitlines():
33
  line = line.strip()
34
+ if line.startswith(prefix):
35
  return line.split("=", 1)[1].strip()
36
  return ""
37
 
38
 
39
  def _vllm_api_key() -> str:
40
+ return (
41
+ settings.llm.vllm.api_key
42
+ or os.getenv("VLLM_API_KEY", "")
43
+ or _load_shared_env_var("VLLM_API_KEY")
44
+ )
45
 
46
 
47
  def _openai_api_key() -> str:
48
+ return (
49
+ settings.llm.openai.api_key
50
+ or os.getenv("OPENAI_API_KEY", "")
51
+ or _load_shared_env_var("OPENAI_API_KEY")
52
+ )
53
 
54
 
55
  def _build_neon_vllm(neon_persona: str | None) -> ImprovedVllmClient:
multi_llm_chatbot_backend/app/core/env_loader.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load local and shared environment files before LLM bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ _BACKEND_ROOT = Path(__file__).resolve().parents[2]
11
+
12
+
13
+ def _shared_env_paths() -> list[Path]:
14
+ paths: list[Path] = []
15
+ explicit = os.environ.get("SHARED_ENV", "").strip()
16
+ if explicit:
17
+ paths.append(Path(explicit))
18
+ paths.append(Path.home() / ".secrets" / "shared.env")
19
+ return paths
20
+
21
+
22
+ def load_application_env() -> None:
23
+ """Load cwd .env, backend .env, then shared secrets (without overriding explicit env)."""
24
+ load_dotenv()
25
+ backend_env = _BACKEND_ROOT / ".env"
26
+ if backend_env.is_file():
27
+ load_dotenv(backend_env, override=False)
28
+ for path in _shared_env_paths():
29
+ if path.is_file():
30
+ load_dotenv(path, override=False)
31
+ return
multi_llm_chatbot_backend/app/core/onboarding_agent.py CHANGED
@@ -13,6 +13,10 @@ from app.core.database import get_database
13
  LOG = logging.getLogger(__name__)
14
 
15
  PROFILE_FIELDS: List[tuple] = [
 
 
 
 
16
  ("cyber_role", "What best describes your role right now?",
17
  "Job or learning role: student, SOC analyst, engineer, architect, manager, career changer, etc."),
18
  ("organization_type", "What type of organization are you in (or targeting)?",
@@ -29,8 +33,6 @@ PROFILE_FIELDS: List[tuple] = [
29
  "Incident readiness, certification, job search, architecture review, audit prep, etc."),
30
  ("learning_preferences", "How do you prefer to learn new security concepts?",
31
  "Hands-on labs, reading, videos, mentorship, certifications, capture-the-flag, etc."),
32
- ("timezone", "What time zone are you usually in?",
33
- "IANA timezone or region such as America/New_York, Europe/London, UTC"),
34
  ]
35
 
36
 
 
13
  LOG = logging.getLogger(__name__)
14
 
15
  PROFILE_FIELDS: List[tuple] = [
16
+ ("knowledge_level", "What is your cybersecurity knowledge level?",
17
+ "Level such as newcomer, foundational, practitioner, experienced, or expert"),
18
+ ("timezone", "What time zone are you usually in?",
19
+ "IANA timezone or region such as America/New_York, Europe/London, UTC"),
20
  ("cyber_role", "What best describes your role right now?",
21
  "Job or learning role: student, SOC analyst, engineer, architect, manager, career changer, etc."),
22
  ("organization_type", "What type of organization are you in (or targeting)?",
 
33
  "Incident readiness, certification, job search, architecture review, audit prep, etc."),
34
  ("learning_preferences", "How do you prefer to learn new security concepts?",
35
  "Hands-on labs, reading, videos, mentorship, certifications, capture-the-flag, etc."),
 
 
36
  ]
37
 
38
 
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
 
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
multi_llm_chatbot_backend/app/models/user_profile.py CHANGED
@@ -15,6 +15,8 @@ class UserProfile(BaseModel):
15
 
16
  id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
17
  user_id: PyObjectId
 
 
18
  cyber_role: Optional[str] = None
19
  organization_type: Optional[str] = None
20
  primary_domains: List[str] = []
@@ -23,12 +25,13 @@ class UserProfile(BaseModel):
23
  compliance_focus: Optional[str] = None
24
  current_goals: Optional[str] = None
25
  learning_preferences: Optional[str] = None
26
- timezone: Optional[str] = None
27
  advisor_notes: Optional[str] = None
28
  updated_at: datetime = Field(default_factory=datetime.utcnow)
29
 
30
 
31
  class UserProfileUpdate(BaseModel):
 
 
32
  cyber_role: Optional[str] = None
33
  organization_type: Optional[str] = None
34
  primary_domains: Optional[List[str]] = None
@@ -37,12 +40,13 @@ class UserProfileUpdate(BaseModel):
37
  compliance_focus: Optional[str] = None
38
  current_goals: Optional[str] = None
39
  learning_preferences: Optional[str] = None
40
- timezone: Optional[str] = None
41
  advisor_notes: Optional[str] = None
42
 
43
 
44
  class UserProfileResponse(BaseModel):
45
  user_id: str
 
 
46
  cyber_role: Optional[str] = None
47
  organization_type: Optional[str] = None
48
  primary_domains: List[str] = []
@@ -51,7 +55,6 @@ class UserProfileResponse(BaseModel):
51
  compliance_focus: Optional[str] = None
52
  current_goals: Optional[str] = None
53
  learning_preferences: Optional[str] = None
54
- timezone: Optional[str] = None
55
  advisor_notes: Optional[str] = None
56
  updated_at: Optional[datetime] = None
57
  completion_pct: int = 0
 
15
 
16
  id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
17
  user_id: PyObjectId
18
+ knowledge_level: Optional[str] = None
19
+ timezone: Optional[str] = None
20
  cyber_role: Optional[str] = None
21
  organization_type: Optional[str] = None
22
  primary_domains: List[str] = []
 
25
  compliance_focus: Optional[str] = None
26
  current_goals: Optional[str] = None
27
  learning_preferences: Optional[str] = None
 
28
  advisor_notes: Optional[str] = None
29
  updated_at: datetime = Field(default_factory=datetime.utcnow)
30
 
31
 
32
  class UserProfileUpdate(BaseModel):
33
+ knowledge_level: Optional[str] = None
34
+ timezone: Optional[str] = None
35
  cyber_role: Optional[str] = None
36
  organization_type: Optional[str] = None
37
  primary_domains: Optional[List[str]] = None
 
40
  compliance_focus: Optional[str] = None
41
  current_goals: Optional[str] = None
42
  learning_preferences: Optional[str] = None
 
43
  advisor_notes: Optional[str] = None
44
 
45
 
46
  class UserProfileResponse(BaseModel):
47
  user_id: str
48
+ knowledge_level: Optional[str] = None
49
+ timezone: Optional[str] = None
50
  cyber_role: Optional[str] = None
51
  organization_type: Optional[str] = None
52
  primary_domains: List[str] = []
 
55
  compliance_focus: Optional[str] = None
56
  current_goals: Optional[str] = None
57
  learning_preferences: Optional[str] = None
 
58
  advisor_notes: Optional[str] = None
59
  updated_at: Optional[datetime] = None
60
  completion_pct: int = 0
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/ProfileWalkthrough.js CHANGED
@@ -1,34 +1,80 @@
1
- import React, { useState, useEffect } from 'react';
2
  import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
 
3
 
4
- const STEPS = [
5
- {
6
- title: 'Role & environment',
7
- fields: [
8
- { key: 'cyber_role', label: 'Your role', type: 'select', options: ['Student / Learner', 'Career changer', 'SOC analyst', 'Security engineer', 'Architect / lead', 'Manager / director', 'Consultant', 'Other'] },
9
- { key: 'organization_type', label: 'Organization type', type: 'select', options: ['Startup', 'Mid-size company', 'Enterprise', 'Government / public sector', 'Education', 'MSP / MSSP', 'Independent / job seeker'] },
10
- { key: 'timezone', label: 'Time zone', type: 'text', placeholder: 'e.g. America/New_York' },
11
- ],
12
- },
13
- {
14
- title: 'Focus & tools',
15
- fields: [
16
- { key: 'primary_domains', label: 'Primary domains (comma-separated)', type: 'text', placeholder: 'e.g. cloud, appsec, IR, GRC, identity' },
17
- { key: 'certifications', label: 'Certifications (comma-separated)', type: 'text', placeholder: 'e.g. Security+, CISSP, OSCP, or none yet' },
18
- { key: 'tools_stack', label: 'Tools & platforms (comma-separated)', type: 'text', placeholder: 'e.g. Splunk, CrowdStrike, AWS, Jira' },
19
- ],
20
- },
21
- {
22
- title: 'Goals & learning',
23
- fields: [
24
- { key: 'compliance_focus', label: 'Compliance / frameworks', type: 'text', placeholder: 'e.g. SOC 2, NIST CSF, ISO 27001, HIPAA' },
25
- { key: 'current_goals', label: 'Current goals', type: 'textarea', placeholder: 'Audit prep, cert study, incident readiness, architecture review...' },
26
- { key: 'learning_preferences', label: 'How you learn best', type: 'text', placeholder: 'Labs, reading, CTFs, mentorship, certifications...' },
27
- ],
28
- },
29
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
 
 
32
  const [step, setStep] = useState(0);
33
  const [formData, setFormData] = useState({});
34
  const [saving, setSaving] = useState(false);
@@ -43,24 +89,11 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
43
  });
44
  if (resp.ok && !cancelled) {
45
  const profile = await resp.json();
46
- const init = {};
47
- STEPS.forEach(s => s.fields.forEach(f => {
48
- const val = profile[f.key];
49
- if (Array.isArray(val)) init[f.key] = val.join(', ');
50
- else if (val) init[f.key] = val;
51
- }));
52
- setFormData(init);
53
  }
54
  } catch (e) {
55
- // Fall back to existingProfile prop
56
  if (!cancelled && existingProfile) {
57
- const init = {};
58
- STEPS.forEach(s => s.fields.forEach(f => {
59
- const val = existingProfile[f.key];
60
- if (Array.isArray(val)) init[f.key] = val.join(', ');
61
- else if (val) init[f.key] = val;
62
- }));
63
- setFormData(init);
64
  }
65
  } finally {
66
  if (!cancelled) setLoading(false);
@@ -68,19 +101,19 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
68
  };
69
  fetchProfile();
70
  return () => { cancelled = true; };
71
- }, [authToken, existingProfile]);
72
 
73
- const handleChange = (key, value) => setFormData(prev => ({ ...prev, [key]: value }));
74
 
75
  const saveProfile = async () => {
76
  const payload = { ...formData };
77
- ['primary_domains', 'certifications', 'tools_stack'].forEach(k => {
78
  if (typeof payload[k] === 'string') {
79
- payload[k] = payload[k].split(',').map(s => s.trim()).filter(Boolean);
80
  }
81
  });
82
- const hasData = Object.values(payload).some(v =>
83
- Array.isArray(v) ? v.length > 0 : Boolean(v)
84
  );
85
  if (!hasData) return;
86
  try {
@@ -106,8 +139,15 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
106
  onClose();
107
  };
108
 
109
- const currentStep = STEPS[step];
110
- const isLast = step === STEPS.length - 1;
 
 
 
 
 
 
 
111
 
112
  return (
113
  <div onClick={handleClose} style={{
@@ -115,7 +155,7 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
115
  background: 'rgba(0,0,0,0.5)', display: 'flex',
116
  alignItems: 'center', justifyContent: 'center',
117
  }}>
118
- <div onClick={e => e.stopPropagation()} style={{
119
  background: 'var(--bg-primary)', borderRadius: 16,
120
  width: '90%', maxWidth: 480, padding: 24,
121
  boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
@@ -125,27 +165,24 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
125
  Loading profile...
126
  </div>
127
  ) : <>
128
- {/* Header */}
129
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
130
  <h3 style={{ margin: 0, fontSize: 16, color: 'var(--text-primary)' }}>
131
- {currentStep.title} ({step + 1}/{STEPS.length})
132
  </h3>
133
  <button onClick={handleClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
134
  <X size={18} />
135
  </button>
136
  </div>
137
 
138
- {/* Progress bar */}
139
  <div style={{ height: 4, background: 'var(--bg-secondary)', borderRadius: 2, marginBottom: 20 }}>
140
  <div style={{
141
  height: '100%', borderRadius: 2, background: 'var(--accent-primary)',
142
- width: `${((step + 1) / STEPS.length) * 100}%`, transition: 'width 0.3s',
143
  }} />
144
  </div>
145
 
146
- {/* Fields */}
147
  <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
148
- {currentStep.fields.map(f => (
149
  <div key={f.key}>
150
  <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
151
  {f.label}
@@ -153,7 +190,7 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
153
  {f.type === 'select' ? (
154
  <select
155
  value={formData[f.key] || ''}
156
- onChange={e => handleChange(f.key, e.target.value)}
157
  style={{
158
  width: '100%', padding: '8px 10px', borderRadius: 8,
159
  border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
@@ -161,12 +198,12 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
161
  }}
162
  >
163
  <option value="">Select...</option>
164
- {f.options.map(o => <option key={o} value={o}>{o}</option>)}
165
  </select>
166
  ) : f.type === 'textarea' ? (
167
  <textarea
168
  value={formData[f.key] || ''}
169
- onChange={e => handleChange(f.key, e.target.value)}
170
  placeholder={f.placeholder}
171
  rows={3}
172
  style={{
@@ -179,7 +216,7 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
179
  <input
180
  type="text"
181
  value={formData[f.key] || ''}
182
- onChange={e => handleChange(f.key, e.target.value)}
183
  placeholder={f.placeholder}
184
  style={{
185
  width: '100%', padding: '8px 10px', borderRadius: 8,
@@ -192,10 +229,9 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
192
  ))}
193
  </div>
194
 
195
- {/* Navigation */}
196
  <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
197
  <button
198
- onClick={() => setStep(s => s - 1)}
199
  disabled={step === 0}
200
  style={{
201
  display: 'flex', alignItems: 'center', gap: 4, padding: '8px 14px',
@@ -222,7 +258,7 @@ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
222
  </button>
223
  ) : (
224
  <button
225
- onClick={() => setStep(s => s + 1)}
226
  style={{
227
  display: 'flex', alignItems: 'center', gap: 4, padding: '8px 14px',
228
  borderRadius: 8, border: 'none',
 
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
  import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
3
+ import { useAppConfig } from '../contexts/AppConfigContext';
4
 
5
+ const buildSteps = (config) => {
6
+ const knowledgeLevels = (config?.login?.knowledge_levels || config?.login?.academic_stages || [])
7
+ .filter((o) => o.value)
8
+ .map((o) => ({ value: o.value, label: o.label }));
9
+
10
+ const timezones = (config?.login?.timezones || [])
11
+ .filter((o) => o.value)
12
+ .map((o) => ({ value: o.value, label: o.label }));
13
+
14
+ return [
15
+ {
16
+ title: 'Background',
17
+ fields: [
18
+ {
19
+ key: 'knowledge_level',
20
+ label: 'Cybersecurity knowledge level',
21
+ type: 'select',
22
+ options: knowledgeLevels.length
23
+ ? knowledgeLevels
24
+ : [
25
+ { value: 'newcomer', label: 'New to cybersecurity' },
26
+ { value: 'practitioner', label: 'Practitioner' },
27
+ ],
28
+ },
29
+ {
30
+ key: 'timezone',
31
+ label: 'Time zone',
32
+ type: 'select',
33
+ options: timezones.length
34
+ ? timezones
35
+ : [{ value: 'UTC', label: 'UTC' }],
36
+ },
37
+ ],
38
+ },
39
+ {
40
+ title: 'Role & environment',
41
+ fields: [
42
+ { key: 'cyber_role', label: 'Your role', type: 'select', options: ['Student / Learner', 'Career changer', 'SOC analyst', 'Security engineer', 'Architect / lead', 'Manager / director', 'Consultant', 'Other'] },
43
+ { key: 'organization_type', label: 'Organization type', type: 'select', options: ['Startup', 'Mid-size company', 'Enterprise', 'Government / public sector', 'Education', 'MSP / MSSP', 'Independent / job seeker'] },
44
+ ],
45
+ },
46
+ {
47
+ title: 'Focus & tools',
48
+ fields: [
49
+ { key: 'primary_domains', label: 'Primary domains (comma-separated)', type: 'text', placeholder: 'e.g. cloud, appsec, IR, GRC, identity' },
50
+ { key: 'certifications', label: 'Certifications (comma-separated)', type: 'text', placeholder: 'e.g. Security+, CISSP, OSCP, or none yet' },
51
+ { key: 'tools_stack', label: 'Tools & platforms (comma-separated)', type: 'text', placeholder: 'e.g. Splunk, CrowdStrike, AWS, Jira' },
52
+ ],
53
+ },
54
+ {
55
+ title: 'Goals & learning',
56
+ fields: [
57
+ { key: 'compliance_focus', label: 'Compliance / frameworks', type: 'text', placeholder: 'e.g. SOC 2, NIST CSF, ISO 27001, HIPAA' },
58
+ { key: 'current_goals', label: 'Current goals', type: 'textarea', placeholder: 'Audit prep, cert study, incident readiness, architecture review...' },
59
+ { key: 'learning_preferences', label: 'How you learn best', type: 'text', placeholder: 'Labs, reading, CTFs, mentorship, certifications...' },
60
+ ],
61
+ },
62
+ ];
63
+ };
64
+
65
+ const initFormFromProfile = (steps, profile) => {
66
+ const init = {};
67
+ steps.forEach((s) => s.fields.forEach((f) => {
68
+ const val = profile[f.key];
69
+ if (Array.isArray(val)) init[f.key] = val.join(', ');
70
+ else if (val) init[f.key] = val;
71
+ }));
72
+ return init;
73
+ };
74
 
75
  const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
76
+ const { config } = useAppConfig();
77
+ const steps = useMemo(() => buildSteps(config), [config]);
78
  const [step, setStep] = useState(0);
79
  const [formData, setFormData] = useState({});
80
  const [saving, setSaving] = useState(false);
 
89
  });
90
  if (resp.ok && !cancelled) {
91
  const profile = await resp.json();
92
+ setFormData(initFormFromProfile(steps, profile));
 
 
 
 
 
 
93
  }
94
  } catch (e) {
 
95
  if (!cancelled && existingProfile) {
96
+ setFormData(initFormFromProfile(steps, existingProfile));
 
 
 
 
 
 
97
  }
98
  } finally {
99
  if (!cancelled) setLoading(false);
 
101
  };
102
  fetchProfile();
103
  return () => { cancelled = true; };
104
+ }, [authToken, existingProfile, steps]);
105
 
106
+ const handleChange = (key, value) => setFormData((prev) => ({ ...prev, [key]: value }));
107
 
108
  const saveProfile = async () => {
109
  const payload = { ...formData };
110
+ ['primary_domains', 'certifications', 'tools_stack'].forEach((k) => {
111
  if (typeof payload[k] === 'string') {
112
+ payload[k] = payload[k].split(',').map((s) => s.trim()).filter(Boolean);
113
  }
114
  });
115
+ const hasData = Object.values(payload).some((v) =>
116
+ (Array.isArray(v) ? v.length > 0 : Boolean(v))
117
  );
118
  if (!hasData) return;
119
  try {
 
139
  onClose();
140
  };
141
 
142
+ const currentStep = steps[step];
143
+ const isLast = step === steps.length - 1;
144
+
145
+ const renderSelectOptions = (options) => options.map((o) => {
146
+ if (o && typeof o === 'object' && o.value != null) {
147
+ return <option key={o.value} value={o.value}>{o.label}</option>;
148
+ }
149
+ return <option key={o} value={o}>{o}</option>;
150
+ });
151
 
152
  return (
153
  <div onClick={handleClose} style={{
 
155
  background: 'rgba(0,0,0,0.5)', display: 'flex',
156
  alignItems: 'center', justifyContent: 'center',
157
  }}>
158
+ <div onClick={(e) => e.stopPropagation()} style={{
159
  background: 'var(--bg-primary)', borderRadius: 16,
160
  width: '90%', maxWidth: 480, padding: 24,
161
  boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
 
165
  Loading profile...
166
  </div>
167
  ) : <>
 
168
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
169
  <h3 style={{ margin: 0, fontSize: 16, color: 'var(--text-primary)' }}>
170
+ {currentStep.title} ({step + 1}/{steps.length})
171
  </h3>
172
  <button onClick={handleClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
173
  <X size={18} />
174
  </button>
175
  </div>
176
 
 
177
  <div style={{ height: 4, background: 'var(--bg-secondary)', borderRadius: 2, marginBottom: 20 }}>
178
  <div style={{
179
  height: '100%', borderRadius: 2, background: 'var(--accent-primary)',
180
+ width: `${((step + 1) / steps.length) * 100}%`, transition: 'width 0.3s',
181
  }} />
182
  </div>
183
 
 
184
  <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
185
+ {currentStep.fields.map((f) => (
186
  <div key={f.key}>
187
  <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
188
  {f.label}
 
190
  {f.type === 'select' ? (
191
  <select
192
  value={formData[f.key] || ''}
193
+ onChange={(e) => handleChange(f.key, e.target.value)}
194
  style={{
195
  width: '100%', padding: '8px 10px', borderRadius: 8,
196
  border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
 
198
  }}
199
  >
200
  <option value="">Select...</option>
201
+ {renderSelectOptions(f.options)}
202
  </select>
203
  ) : f.type === 'textarea' ? (
204
  <textarea
205
  value={formData[f.key] || ''}
206
+ onChange={(e) => handleChange(f.key, e.target.value)}
207
  placeholder={f.placeholder}
208
  rows={3}
209
  style={{
 
216
  <input
217
  type="text"
218
  value={formData[f.key] || ''}
219
+ onChange={(e) => handleChange(f.key, e.target.value)}
220
  placeholder={f.placeholder}
221
  style={{
222
  width: '100%', padding: '8px 10px', borderRadius: 8,
 
229
  ))}
230
  </div>
231
 
 
232
  <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
233
  <button
234
+ onClick={() => setStep((s) => s - 1)}
235
  disabled={step === 0}
236
  style={{
237
  display: 'flex', alignItems: 'center', gap: 4, padding: '8px 14px',
 
258
  </button>
259
  ) : (
260
  <button
261
+ onClick={() => setStep((s) => s + 1)}
262
  style={{
263
  display: 'flex', alignItems: 'center', gap: 4, padding: '8px 14px',
264
  borderRadius: 8, border: 'none',
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/pages/ChatPage.js CHANGED
@@ -254,19 +254,20 @@ const loadChatSession = async (sessionId) => {
254
  }
255
  };
256
 
257
- // Save a message to the current session
258
- const saveMessageToSession = async (message) => {
259
- if (!currentSessionId || !authToken) return;
 
260
 
261
  try {
262
- await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${currentSessionId}/messages`, {
263
  method: 'POST',
264
  headers: {
265
  'Authorization': `Bearer ${authToken}`,
266
  'Content-Type': 'application/json'
267
  },
268
  body: JSON.stringify({
269
- session_id: currentSessionId,
270
  message: {
271
  ...message,
272
  timestamp: message.timestamp.toISOString()
@@ -434,6 +435,10 @@ const handleNewChat = async (sessionId = null) => {
434
  }
435
  }
436
 
 
 
 
 
437
  // Update session title if this is the first message and title is generic
438
  if (messages.length === 0 && currentSessionTitle.includes('Chat ')) {
439
  const newTitle = inputMessage.length > 30
@@ -456,7 +461,7 @@ const handleNewChat = async (sessionId = null) => {
456
  body: JSON.stringify({
457
  user_input: inputMessage,
458
  response_length: 'medium',
459
- chat_session_id: currentSessionId // Include current session ID
460
  }),
461
  });
462
 
@@ -496,7 +501,7 @@ const handleNewChat = async (sessionId = null) => {
496
  };
497
  setMessages(prev => [...prev, msg]);
498
  setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
499
- await saveMessageToSession(msg);
500
  break;
501
  }
502
  case 'clarification':
@@ -823,11 +828,6 @@ const handleNewChat = async (sessionId = null) => {
823
  onNavigateToCanvas={onNavigateToCanvas}
824
  onMobileMenu={handleMobileMenuToggle}
825
  >
826
- {currentSessionTitle && (
827
- <div className="session-title-display">
828
- <span>{currentSessionTitle}</span>
829
- </div>
830
- )}
831
  <ExportButton
832
  hasMessages={hasConversationMessages}
833
  currentSessionId={currentSessionId}
@@ -996,67 +996,10 @@ const handleNewChat = async (sessionId = null) => {
996
  ? `Reply to ${replyingTo.advisorName}...`
997
  : chatPlaceholder
998
  }
999
- showProfileButtons={!userProfile || userProfile.completion_pct < 100}
1000
- onOpenOnboarding={() => setShowOnboarding(true)}
1001
- onOpenProfileForm={() => setShowProfileForm(true)}
1002
  />
1003
  </div>
1004
  </div>
1005
  </div>
1006
-
1007
- {showOnboarding && (
1008
- <OnboardingChat
1009
- authToken={authToken}
1010
- userName={user?.firstName}
1011
- onClose={() => { setShowOnboarding(false); loadProfile(); }}
1012
- />
1013
- )}
1014
-
1015
- {showProfileForm && (
1016
- <ProfileWalkthrough
1017
- authToken={authToken}
1018
- existingProfile={userProfile}
1019
- onClose={() => { setShowProfileForm(false); loadProfile(); }}
1020
- />
1021
- )}
1022
-
1023
- {showClearData && (
1024
- <ClearDataModal
1025
- authToken={authToken}
1026
- onClose={() => setShowClearData(false)}
1027
- onDataCleared={({ profile: clearedProfile, chats: clearedChats }) => {
1028
- if (clearedProfile) {
1029
- setUserProfile(null);
1030
- loadProfile();
1031
- }
1032
- if (clearedChats) {
1033
- setMessages([]);
1034
- setCurrentSessionId(null);
1035
- setCurrentSessionTitle('');
1036
- handleNewChat();
1037
- }
1038
- }}
1039
- />
1040
- )}
1041
-
1042
- {showAccount && (
1043
- <AccountModal
1044
- user={user}
1045
- authToken={authToken}
1046
- onClose={() => setShowAccount(false)}
1047
- onAccountUpdated={(updated) => {
1048
- if (user) {
1049
- user.firstName = updated.firstName;
1050
- user.lastName = updated.lastName;
1051
- user.email = updated.email;
1052
- }
1053
- }}
1054
- onAccountDeleted={() => {
1055
- setShowAccount(false);
1056
- onSignOut();
1057
- }}
1058
- />
1059
- )}
1060
  </div>
1061
  );
1062
  };
 
254
  }
255
  };
256
 
257
+ // Save a message to the current session (optional sessionId when state is not updated yet)
258
+ const saveMessageToSession = async (message, sessionIdOverride) => {
259
+ const sid = sessionIdOverride || currentSessionId;
260
+ if (!sid || !authToken) return;
261
 
262
  try {
263
+ await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sid}/messages`, {
264
  method: 'POST',
265
  headers: {
266
  'Authorization': `Bearer ${authToken}`,
267
  'Content-Type': 'application/json'
268
  },
269
  body: JSON.stringify({
270
+ session_id: sid,
271
  message: {
272
  ...message,
273
  timestamp: message.timestamp.toISOString()
 
435
  }
436
  }
437
 
438
+ saveMessageToSession(userMessage, sessionId).catch(err =>
439
+ console.error('Failed to persist user message:', err)
440
+ );
441
+
442
  // Update session title if this is the first message and title is generic
443
  if (messages.length === 0 && currentSessionTitle.includes('Chat ')) {
444
  const newTitle = inputMessage.length > 30
 
461
  body: JSON.stringify({
462
  user_input: inputMessage,
463
  response_length: 'medium',
464
+ chat_session_id: sessionId,
465
  }),
466
  });
467
 
 
501
  };
502
  setMessages(prev => [...prev, msg]);
503
  setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
504
+ await saveMessageToSession(msg, sessionId);
505
  break;
506
  }
507
  case 'clarification':
 
828
  onNavigateToCanvas={onNavigateToCanvas}
829
  onMobileMenu={handleMobileMenuToggle}
830
  >
 
 
 
 
 
831
  <ExportButton
832
  hasMessages={hasConversationMessages}
833
  currentSessionId={currentSessionId}
 
996
  ? `Reply to ${replyingTo.advisorName}...`
997
  : chatPlaceholder
998
  }
 
 
 
999
  />
1000
  </div>
1001
  </div>
1002
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  </div>
1004
  );
1005
  };
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
@@ -215,19 +215,24 @@
215
  justify-content: center;
216
  color: #fff;
217
  }
 
 
 
 
 
218
  .canvas-page-with-sidebar .brand-text h1 {
219
  font-size: 20px;
220
  font-weight: 700;
221
  color: var(--canvas-text);
222
  margin: 0;
223
- line-height: 1.15;
224
  letter-spacing: -0.01em;
225
  }
226
  .canvas-page-with-sidebar .brand-text p {
227
  font-size: 13px;
228
  color: var(--canvas-text-3);
229
- margin: 2px 0 0;
230
- line-height: 1;
231
  }
232
  .canvas-page-with-sidebar .theme-toggle .theme-icon { display: block; }
233
 
 
215
  justify-content: center;
216
  color: #fff;
217
  }
218
+ .canvas-page-with-sidebar .brand-text {
219
+ display: flex;
220
+ flex-direction: column;
221
+ gap: 6px;
222
+ }
223
  .canvas-page-with-sidebar .brand-text h1 {
224
  font-size: 20px;
225
  font-weight: 700;
226
  color: var(--canvas-text);
227
  margin: 0;
228
+ line-height: 1.2;
229
  letter-spacing: -0.01em;
230
  }
231
  .canvas-page-with-sidebar .brand-text p {
232
  font-size: 13px;
233
  color: var(--canvas-text-3);
234
+ margin: 0;
235
+ line-height: 1.35;
236
  }
237
  .canvas-page-with-sidebar .theme-toggle .theme-icon { display: block; }
238
 
phd-advisor-frontend/src/styles/ChatPage.css CHANGED
@@ -79,19 +79,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 +684,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;
 
79
  color: white;
80
  }
81
 
82
+ .brand-text {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 6px;
86
+ }
87
+
88
  .brand-text h1 {
89
  font-size: 20px;
90
  font-weight: 700;
91
  color: var(--text-primary);
92
  margin: 0;
93
+ line-height: 1.2;
94
  }
95
 
96
  .brand-text p {
97
  font-size: 13px;
98
  color: var(--text-secondary);
99
  margin: 0;
100
+ line-height: 1.35;
101
  }
102
 
103
  .header-right {
 
684
  margin-left: 70px; /* Width of sidebar when collapsed */
685
  }
686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  /* Optional header sign out button */
688
  .header-signout-btn {
689
  display: flex;
phd-advisor-frontend/src/styles/components.css CHANGED
@@ -527,13 +527,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 +552,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 +802,9 @@
765
  }
766
 
767
  .advisor-name {
768
- font-weight: 500;
769
- font-size: 14px;
 
770
  margin: 0;
771
  }
772
 
@@ -1184,7 +1222,11 @@
1184
  }
1185
 
1186
  .advisor-message-text {
1187
- font-size: 14px;
 
 
 
 
1188
  }
1189
 
1190
  /* Basic ChatInput mobile styles */
 
527
  .advisor-message-header {
528
  display: flex;
529
  align-items: center;
530
+ gap: 10px;
531
  margin-bottom: 8px;
532
  }
533
 
534
  .advisor-message-name {
535
+ font-weight: 600;
536
+ font-size: 1rem;
537
+ line-height: 1.6;
538
+ margin: 0;
539
  }
540
 
541
  .reply-badge {
 
552
  }
553
 
554
  .advisor-message-text {
555
+ font-size: 1rem;
556
  line-height: 1.6;
557
  }
558
 
559
+ /* Advisor icon in message header: circular badge, theme-aware fill */
560
+ .advisor-message-avatar-ring {
561
+ border-radius: 50%;
562
+ display: flex;
563
+ align-items: center;
564
+ justify-content: center;
565
+ flex-shrink: 0;
566
+ overflow: hidden;
567
+ background: #ffffff;
568
+ border: 1px solid rgba(15, 23, 42, 0.1);
569
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
570
+ }
571
+
572
+ [data-theme="dark"] .advisor-message-avatar-ring {
573
+ background: #1e293b;
574
+ border-color: rgba(248, 250, 252, 0.12);
575
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
576
+ }
577
+
578
+ .advisor-message-avatar-ring img {
579
+ width: 100%;
580
+ height: 100%;
581
+ object-fit: cover;
582
+ }
583
+
584
+ .advisor-message-avatar-icon {
585
+ flex-shrink: 0;
586
+ }
587
+
588
+ .advisor-message-avatar-initial {
589
+ font-weight: 700;
590
+ line-height: 1;
591
+ }
592
+
593
  .advisor-message-text > div:last-child p:last-child {
594
  margin-bottom: 0; /* Remove bottom margin from last paragraph */
595
  }
 
802
  }
803
 
804
  .advisor-name {
805
+ font-weight: 600;
806
+ font-size: 1rem;
807
+ line-height: 1.6;
808
  margin: 0;
809
  }
810
 
 
1222
  }
1223
 
1224
  .advisor-message-text {
1225
+ font-size: 1rem;
1226
+ }
1227
+
1228
+ .advisor-message-name {
1229
+ font-size: 1rem;
1230
  }
1231
 
1232
  /* Basic ChatInput mobile styles */