Spaces:
Running
Running
NeonClary Cursor commited on
Commit ·
689d159
1
Parent(s): 6004480
Fix LLM chat startup and polish cybersecurity advisor UI.
Browse filesLoad 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 +1 -1
- multi_llm_chatbot_backend/.env.example +5 -1
- multi_llm_chatbot_backend/app/api/routes/auth.py +18 -12
- multi_llm_chatbot_backend/app/api/routes/chat.py +7 -13
- multi_llm_chatbot_backend/app/api/routes/onboarding.py +5 -3
- multi_llm_chatbot_backend/app/api/routes/user_profile.py +45 -22
- multi_llm_chatbot_backend/app/core/bootstrap.py +23 -6
- multi_llm_chatbot_backend/app/core/env_loader.py +31 -0
- multi_llm_chatbot_backend/app/core/onboarding_agent.py +4 -2
- multi_llm_chatbot_backend/app/main.py +3 -3
- multi_llm_chatbot_backend/app/models/user_profile.py +6 -3
- phd-advisor-frontend/src/components/MessageBubble.js +30 -26
- phd-advisor-frontend/src/components/ProfileWalkthrough.js +100 -64
- phd-advisor-frontend/src/components/ThinkingIndicator.js +24 -40
- phd-advisor-frontend/src/pages/ChatPage.js +12 -69
- phd-advisor-frontend/src/styles/CanvasPage.css +8 -3
- phd-advisor-frontend/src/styles/ChatPage.css +8 -30
- phd-advisor-frontend/src/styles/components.css +48 -6
cybersecurity_config.yaml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
# ============================================================================
|
| 4 |
|
| 5 |
app:
|
| 6 |
-
title: "Cybersecurity Advisor
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
await db.user_profiles.update_one(
|
| 265 |
{"user_id": current_user.id},
|
| 266 |
-
{"$set":
|
| 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 |
-
|
|
|
|
| 36 |
parts = []
|
| 37 |
-
|
| 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":
|
| 188 |
-
"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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 110 |
-
|
| 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 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 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
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
for line in shared.read_text(encoding="utf-8").splitlines():
|
| 24 |
line = line.strip()
|
| 25 |
-
if line.startswith(
|
| 26 |
return line.split("=", 1)[1].strip()
|
| 27 |
return ""
|
| 28 |
|
| 29 |
|
| 30 |
def _vllm_api_key() -> str:
|
| 31 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def _openai_api_key() -> str:
|
| 35 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 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 =
|
| 306 |
-
|
| 307 |
-
|
| 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 |
-
|
| 315 |
-
|
| 316 |
-
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, overflow: 'hidden'
|
| 317 |
-
}}
|
| 318 |
>
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 =
|
| 110 |
-
const isLast = step ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}/{
|
| 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) /
|
| 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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
</div>
|
| 23 |
-
<div
|
| 24 |
className="thinking-bubble"
|
| 25 |
-
style={{
|
| 26 |
backgroundColor: colors.bgColor,
|
| 27 |
-
borderColor: colors.color + '40'
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
|
|
|
|
| 260 |
|
| 261 |
try {
|
| 262 |
-
await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${
|
| 263 |
method: 'POST',
|
| 264 |
headers: {
|
| 265 |
'Authorization': `Bearer ${authToken}`,
|
| 266 |
'Content-Type': 'application/json'
|
| 267 |
},
|
| 268 |
body: JSON.stringify({
|
| 269 |
-
session_id:
|
| 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:
|
| 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.
|
| 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:
|
| 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:
|
| 531 |
margin-bottom: 8px;
|
| 532 |
}
|
| 533 |
|
| 534 |
.advisor-message-name {
|
| 535 |
-
font-weight:
|
| 536 |
-
font-size:
|
|
|
|
|
|
|
| 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:
|
| 769 |
-
font-size:
|
|
|
|
| 770 |
margin: 0;
|
| 771 |
}
|
| 772 |
|
|
@@ -1184,7 +1222,11 @@
|
|
| 1184 |
}
|
| 1185 |
|
| 1186 |
.advisor-message-text {
|
| 1187 |
-
font-size:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 */
|