Spaces:
Sleeping
Feat/persona avatars (#43)
Browse files* added persona avatar support to backend with bundled advisor images pulled from Clary's branch.
* Enhanced AdvisorCard and AdvisorStatusDropdown components with avatar selection functionality and hover effects. Updated AppConfigContext to support avatar URL overrides and custom avatar management.
* Refactor AvatarPickerModal component to remove adding your own avatar.
* added backend api support to persist user uploaded avatars in the db.
* revert backend to only serve up static bundle of advisor images.
* fixed the icon fallback for advisors without avatar image.
* added validation of external avatar URLs with fallback to icon on failure.
* return avatar image as URI string instead of typed dict.
* updated frontend to parse URI string instead of type/value dict.
* moved _resolve_image imports to top of file.
* converted bundled avatar paths to full URLs.
* updated avatar image test to expect full URL.
---------
Co-authored-by: Ryan-Venturi1 <157062856+Ryan-Venturi1@users.noreply.github.com>
Co-authored-by: Neon:ryan <ryan@neon.ai>
- multi_llm_chatbot_backend/app/assets/avatars/advisor1.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor2.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor3.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor4.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor5.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor6.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor7.png +3 -0
- multi_llm_chatbot_backend/app/config.py +43 -1
- multi_llm_chatbot_backend/app/main.py +12 -0
- multi_llm_chatbot_backend/app/tests/unit/test_avatar_resolution.py +135 -0
- multi_llm_chatbot_backend/app/utils/avatar_helpers.py +35 -0
- personas/phd_advisors/critic.yaml +1 -0
- personas/phd_advisors/empathetic.yaml +1 -0
- personas/phd_advisors/methodologist.yaml +1 -0
- personas/phd_advisors/minimalist.yaml +1 -0
- personas/phd_advisors/motivator.yaml +1 -0
- personas/phd_advisors/pragmatist.yaml +1 -0
- personas/phd_advisors/socratic.yaml +1 -0
- phd-advisor-frontend/src/components/AdvisorCard.js +32 -16
- phd-advisor-frontend/src/components/AdvisorStatusDropdown.js +31 -10
- phd-advisor-frontend/src/components/AvatarPickerModal.js +75 -0
- phd-advisor-frontend/src/contexts/AppConfigContext.js +50 -9
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
@@ -14,9 +14,12 @@ from pathlib import Path
|
|
| 14 |
from typing import Any, Dict, List, Optional
|
| 15 |
from colorhash import ColorHash
|
| 16 |
|
|
|
|
| 17 |
import yaml
|
| 18 |
from pydantic import BaseModel, validator, Field, model_validator
|
| 19 |
|
|
|
|
|
|
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
# ---------------------------------------------------------------------------
|
|
@@ -95,6 +98,7 @@ class PersonaItemConfig(_IconValidatorMixin):
|
|
| 95 |
dark_color: Optional[str] = None
|
| 96 |
dark_bg_color: Optional[str] = None
|
| 97 |
icon: str = "HelpCircle"
|
|
|
|
| 98 |
temperature: int = 5
|
| 99 |
persona_prompt: str = ""
|
| 100 |
|
|
@@ -108,6 +112,44 @@ class PersonaItemConfig(_IconValidatorMixin):
|
|
| 108 |
self.dark_bg_color = generated["dark_bg_color"]
|
| 109 |
return self
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def to_frontend_config(self) -> dict:
|
| 112 |
return {
|
| 113 |
"id": self.id,
|
|
@@ -118,7 +160,7 @@ class PersonaItemConfig(_IconValidatorMixin):
|
|
| 118 |
"bg_color": self.bg_color,
|
| 119 |
"dark_color": self.dark_color,
|
| 120 |
"dark_bg_color": self.dark_bg_color,
|
| 121 |
-
"
|
| 122 |
}
|
| 123 |
|
| 124 |
|
|
|
|
| 14 |
from typing import Any, Dict, List, Optional
|
| 15 |
from colorhash import ColorHash
|
| 16 |
|
| 17 |
+
import httpx
|
| 18 |
import yaml
|
| 19 |
from pydantic import BaseModel, validator, Field, model_validator
|
| 20 |
|
| 21 |
+
from app.utils.avatar_helpers import get_bundled_avatar_path
|
| 22 |
+
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
# ---------------------------------------------------------------------------
|
|
|
|
| 98 |
dark_color: Optional[str] = None
|
| 99 |
dark_bg_color: Optional[str] = None
|
| 100 |
icon: str = "HelpCircle"
|
| 101 |
+
avatar: Optional[str] = None
|
| 102 |
temperature: int = 5
|
| 103 |
persona_prompt: str = ""
|
| 104 |
|
|
|
|
| 112 |
self.dark_bg_color = generated["dark_bg_color"]
|
| 113 |
return self
|
| 114 |
|
| 115 |
+
def _resolve_image(self) -> str:
|
| 116 |
+
"""Resolve the persona's visual representation as a URI string.
|
| 117 |
+
|
| 118 |
+
Returns the avatar as a URI the frontend can dispatch on by scheme:
|
| 119 |
+
|
| 120 |
+
- ``https://…`` / ``http://…`` - external image URL
|
| 121 |
+
- ``/api/avatars/bundled/…`` - server-relative path for a bundled file
|
| 122 |
+
- ``icon://<LucideIconName>`` - render a Lucide icon component
|
| 123 |
+
|
| 124 |
+
Falls back to ``icon://`` when a bundled avatar name doesn't match a
|
| 125 |
+
file on disk or when an external URL is unreachable.
|
| 126 |
+
"""
|
| 127 |
+
if self.avatar is None:
|
| 128 |
+
return f"icon://{self.icon}"
|
| 129 |
+
if self.avatar.startswith(("http://", "https://")):
|
| 130 |
+
try:
|
| 131 |
+
resp = httpx.head(self.avatar, timeout=5, follow_redirects=True)
|
| 132 |
+
if resp.is_success:
|
| 133 |
+
return self.avatar
|
| 134 |
+
logger.warning(
|
| 135 |
+
"Avatar URL %r returned status %d for persona %r, falling back to icon.",
|
| 136 |
+
self.avatar, resp.status_code, self.id,
|
| 137 |
+
)
|
| 138 |
+
except httpx.HTTPError as exc:
|
| 139 |
+
logger.warning(
|
| 140 |
+
"Avatar URL %r unreachable for persona %r (%s), falling back to icon.",
|
| 141 |
+
self.avatar, self.id, exc,
|
| 142 |
+
)
|
| 143 |
+
return f"icon://{self.icon}"
|
| 144 |
+
if get_bundled_avatar_path(self.avatar) is None:
|
| 145 |
+
logger.warning(
|
| 146 |
+
"Bundled avatar %r not found for persona %r, falling back to icon.",
|
| 147 |
+
self.avatar, self.id,
|
| 148 |
+
)
|
| 149 |
+
return f"icon://{self.icon}"
|
| 150 |
+
base = os.getenv("REACT_APP_API_URL", "http://localhost:8000").rstrip("/")
|
| 151 |
+
return f"{base}/api/avatars/bundled/{self.avatar}"
|
| 152 |
+
|
| 153 |
def to_frontend_config(self) -> dict:
|
| 154 |
return {
|
| 155 |
"id": self.id,
|
|
|
|
| 160 |
"bg_color": self.bg_color,
|
| 161 |
"dark_color": self.dark_color,
|
| 162 |
"dark_bg_color": self.dark_bg_color,
|
| 163 |
+
"image": self._resolve_image(),
|
| 164 |
}
|
| 165 |
|
| 166 |
|
|
@@ -3,8 +3,11 @@ from dotenv import load_dotenv
|
|
| 3 |
|
| 4 |
load_dotenv()
|
| 5 |
|
|
|
|
|
|
|
| 6 |
from fastapi import FastAPI
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
|
| 10 |
# Load configuration FIRST so every module can use it
|
|
@@ -58,6 +61,15 @@ app.include_router(auth_router, prefix="/auth", tags=["authentication"])
|
|
| 58 |
app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"])
|
| 59 |
app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"])
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# ---------------------------------------------------------------------------
|
| 63 |
# Public configuration endpoint — serves the frontend-safe subset
|
|
|
|
| 3 |
|
| 4 |
load_dotenv()
|
| 5 |
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
from fastapi import FastAPI
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
from contextlib import asynccontextmanager
|
| 12 |
|
| 13 |
# Load configuration FIRST so every module can use it
|
|
|
|
| 61 |
app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"])
|
| 62 |
app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"])
|
| 63 |
|
| 64 |
+
# Serve bundled avatar images
|
| 65 |
+
_avatars_dir = Path(__file__).resolve().parent / "assets" / "avatars"
|
| 66 |
+
if _avatars_dir.is_dir():
|
| 67 |
+
app.mount(
|
| 68 |
+
"/api/avatars/bundled",
|
| 69 |
+
StaticFiles(directory=_avatars_dir),
|
| 70 |
+
name="bundled-avatars",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
|
| 74 |
# ---------------------------------------------------------------------------
|
| 75 |
# Public configuration endpoint — serves the frontend-safe subset
|
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import tempfile
|
| 3 |
+
import os
|
| 4 |
+
from unittest.mock import patch, MagicMock
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
from app.config import PersonaItemConfig
|
| 10 |
+
from app.utils.avatar_helpers import get_bundled_avatar_path, list_bundled_avatars
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestAvatarHelpers(unittest.TestCase):
|
| 14 |
+
|
| 15 |
+
def setUp(self):
|
| 16 |
+
self._tmp = tempfile.TemporaryDirectory()
|
| 17 |
+
self.tmp_path = Path(self._tmp.name)
|
| 18 |
+
(self.tmp_path / "advisor1.png").write_bytes(b"fake png")
|
| 19 |
+
(self.tmp_path / "advisor2.svg").write_bytes(b"<svg/>")
|
| 20 |
+
|
| 21 |
+
def tearDown(self):
|
| 22 |
+
self._tmp.cleanup()
|
| 23 |
+
list_bundled_avatars.cache_clear()
|
| 24 |
+
|
| 25 |
+
@patch("app.utils.avatar_helpers._AVATARS_DIR")
|
| 26 |
+
def test_get_bundled_avatar_path_returns_path_for_existing_file(self, mock_dir):
|
| 27 |
+
mock_dir.__class__ = Path
|
| 28 |
+
with patch("app.utils.avatar_helpers._AVATARS_DIR", self.tmp_path):
|
| 29 |
+
result = get_bundled_avatar_path("advisor1.png")
|
| 30 |
+
self.assertEqual(result, self.tmp_path / "advisor1.png")
|
| 31 |
+
|
| 32 |
+
@patch("app.utils.avatar_helpers._AVATARS_DIR")
|
| 33 |
+
def test_get_bundled_avatar_path_returns_none_for_missing_file(self, mock_dir):
|
| 34 |
+
with patch("app.utils.avatar_helpers._AVATARS_DIR", self.tmp_path):
|
| 35 |
+
result = get_bundled_avatar_path("nonexistent.png")
|
| 36 |
+
self.assertIsNone(result)
|
| 37 |
+
|
| 38 |
+
def test_get_bundled_avatar_path_returns_none_when_dir_missing(self):
|
| 39 |
+
with patch("app.utils.avatar_helpers._AVATARS_DIR", Path("/nonexistent/dir")):
|
| 40 |
+
result = get_bundled_avatar_path("advisor1.png")
|
| 41 |
+
self.assertIsNone(result)
|
| 42 |
+
|
| 43 |
+
def test_list_bundled_avatars_returns_filenames(self):
|
| 44 |
+
with patch("app.utils.avatar_helpers._AVATARS_DIR", self.tmp_path):
|
| 45 |
+
list_bundled_avatars.cache_clear()
|
| 46 |
+
result = list_bundled_avatars()
|
| 47 |
+
self.assertEqual(result, ("advisor1.png", "advisor2.svg"))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestResolveImage(unittest.TestCase):
|
| 51 |
+
|
| 52 |
+
def test_no_avatar_returns_icon_uri(self):
|
| 53 |
+
persona = PersonaItemConfig(id="test", name="Test", icon="Brain")
|
| 54 |
+
self.assertEqual(persona._resolve_image(), "icon://Brain")
|
| 55 |
+
|
| 56 |
+
@patch("httpx.head")
|
| 57 |
+
def test_external_url_passed_through(self, mock_head):
|
| 58 |
+
mock_head.return_value = MagicMock(is_success=True)
|
| 59 |
+
persona = PersonaItemConfig(
|
| 60 |
+
id="test", name="Test", icon="Brain",
|
| 61 |
+
avatar="https://example.com/avatar.png",
|
| 62 |
+
)
|
| 63 |
+
self.assertEqual(
|
| 64 |
+
persona._resolve_image(),
|
| 65 |
+
"https://example.com/avatar.png",
|
| 66 |
+
)
|
| 67 |
+
mock_head.assert_called_once_with(
|
| 68 |
+
"https://example.com/avatar.png", timeout=5, follow_redirects=True,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
@patch("httpx.head")
|
| 72 |
+
def test_http_url_passed_through(self, mock_head):
|
| 73 |
+
mock_head.return_value = MagicMock(is_success=True)
|
| 74 |
+
persona = PersonaItemConfig(
|
| 75 |
+
id="test", name="Test", icon="Brain",
|
| 76 |
+
avatar="http://example.com/avatar.png",
|
| 77 |
+
)
|
| 78 |
+
self.assertEqual(
|
| 79 |
+
persona._resolve_image(),
|
| 80 |
+
"http://example.com/avatar.png",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
@patch("httpx.head")
|
| 84 |
+
def test_url_returning_404_falls_back_to_icon(self, mock_head):
|
| 85 |
+
mock_head.return_value = MagicMock(is_success=False, status_code=404)
|
| 86 |
+
persona = PersonaItemConfig(
|
| 87 |
+
id="test", name="Test", icon="Brain",
|
| 88 |
+
avatar="https://example.com/missing.png",
|
| 89 |
+
)
|
| 90 |
+
self.assertEqual(persona._resolve_image(), "icon://Brain")
|
| 91 |
+
|
| 92 |
+
@patch("httpx.head")
|
| 93 |
+
def test_unreachable_url_falls_back_to_icon(self, mock_head):
|
| 94 |
+
mock_head.side_effect = httpx.ConnectError("DNS lookup failed")
|
| 95 |
+
persona = PersonaItemConfig(
|
| 96 |
+
id="test", name="Test", icon="Brain",
|
| 97 |
+
avatar="https://nonexistent.invalid/avatar.png",
|
| 98 |
+
)
|
| 99 |
+
self.assertEqual(persona._resolve_image(), "icon://Brain")
|
| 100 |
+
|
| 101 |
+
@patch("httpx.head")
|
| 102 |
+
def test_url_timeout_falls_back_to_icon(self, mock_head):
|
| 103 |
+
mock_head.side_effect = httpx.TimeoutException("timed out")
|
| 104 |
+
persona = PersonaItemConfig(
|
| 105 |
+
id="test", name="Test", icon="Brain",
|
| 106 |
+
avatar="https://slow.example.com/avatar.png",
|
| 107 |
+
)
|
| 108 |
+
self.assertEqual(persona._resolve_image(), "icon://Brain")
|
| 109 |
+
|
| 110 |
+
@patch("app.utils.avatar_helpers.get_bundled_avatar_path")
|
| 111 |
+
def test_bundled_avatar_exists_returns_path(self, mock_get_path):
|
| 112 |
+
mock_get_path.return_value = Path("/fake/path/advisor1.png")
|
| 113 |
+
persona = PersonaItemConfig(
|
| 114 |
+
id="test", name="Test", icon="Brain", avatar="advisor1.png",
|
| 115 |
+
)
|
| 116 |
+
with patch.dict(os.environ, {"REACT_APP_API_URL": "http://localhost:8000"}):
|
| 117 |
+
self.assertEqual(
|
| 118 |
+
persona._resolve_image(),
|
| 119 |
+
"http://localhost:8000/api/avatars/bundled/advisor1.png",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
@patch("app.utils.avatar_helpers.get_bundled_avatar_path")
|
| 123 |
+
def test_bundled_avatar_missing_falls_back_to_icon(self, mock_get_path):
|
| 124 |
+
mock_get_path.return_value = None
|
| 125 |
+
persona = PersonaItemConfig(
|
| 126 |
+
id="test", name="Test", icon="Brain", avatar="missing.png",
|
| 127 |
+
)
|
| 128 |
+
self.assertEqual(persona._resolve_image(), "icon://Brain")
|
| 129 |
+
|
| 130 |
+
def test_to_frontend_config_includes_image_field(self):
|
| 131 |
+
persona = PersonaItemConfig(id="test", name="Test", icon="Brain")
|
| 132 |
+
config = persona.to_frontend_config()
|
| 133 |
+
self.assertIn("image", config)
|
| 134 |
+
self.assertNotIn("icon", config)
|
| 135 |
+
self.assertEqual(config["image"], "icon://Brain")
|
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bundled avatar image registry.
|
| 3 |
+
|
| 4 |
+
Provides helpers for resolving and listing the avatar images shipped in
|
| 5 |
+
``app/assets/avatars/``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
_AVATARS_DIR = Path(__file__).resolve().parent.parent / "assets" / "avatars"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_bundled_avatar_path(name: str) -> Path | None:
|
| 18 |
+
"""Return the filesystem path for a bundled avatar, or *None* if the
|
| 19 |
+
file does not exist."""
|
| 20 |
+
if not _AVATARS_DIR.is_dir():
|
| 21 |
+
return None
|
| 22 |
+
candidate = _AVATARS_DIR / name
|
| 23 |
+
if candidate.is_file():
|
| 24 |
+
return candidate
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@lru_cache(maxsize=1)
|
| 29 |
+
def list_bundled_avatars() -> tuple[str, ...]:
|
| 30 |
+
"""Return the filenames of all bundled avatar images."""
|
| 31 |
+
if not _AVATARS_DIR.is_dir():
|
| 32 |
+
return ()
|
| 33 |
+
avatars = tuple(sorted(f.name for f in _AVATARS_DIR.iterdir() if f.is_file()))
|
| 34 |
+
logger.info("Found %d bundled avatar images.", len(avatars))
|
| 35 |
+
return avatars
|
|
@@ -7,6 +7,7 @@ bg_color: "#FEF2F2"
|
|
| 7 |
dark_color: "#F87171"
|
| 8 |
dark_bg_color: "#7F1D1D"
|
| 9 |
icon: "Search"
|
|
|
|
| 10 |
temperature: 6
|
| 11 |
persona_prompt: |
|
| 12 |
You are a rigorous PhD advisor and Constructive Critic with expertise in academic quality assurance and scholarly rigor. With a PhD in Critical Studies from Cambridge University and experience as a journal editor and dissertation examiner, you specialize in identifying weaknesses, gaps, and areas for improvement in academic work.
|
|
|
|
| 7 |
dark_color: "#F87171"
|
| 8 |
dark_bg_color: "#7F1D1D"
|
| 9 |
icon: "Search"
|
| 10 |
+
avatar: "advisor1.png"
|
| 11 |
temperature: 6
|
| 12 |
persona_prompt: |
|
| 13 |
You are a rigorous PhD advisor and Constructive Critic with expertise in academic quality assurance and scholarly rigor. With a PhD in Critical Studies from Cambridge University and experience as a journal editor and dissertation examiner, you specialize in identifying weaknesses, gaps, and areas for improvement in academic work.
|
|
@@ -7,6 +7,7 @@ bg_color: "#FDF2F8"
|
|
| 7 |
dark_color: "#F472B6"
|
| 8 |
dark_bg_color: "#BE185D"
|
| 9 |
icon: "Heart"
|
|
|
|
| 10 |
temperature: 6
|
| 11 |
persona_prompt: |
|
| 12 |
You are a compassionate PhD advisor and Empathetic Listener with expertise in student well-being, emotional support, and holistic academic guidance. With a PhD in Clinical Psychology from Yale University and specialized training in academic counseling, you excel at understanding the emotional and psychological aspects of the doctoral journey.
|
|
|
|
| 7 |
dark_color: "#F472B6"
|
| 8 |
dark_bg_color: "#BE185D"
|
| 9 |
icon: "Heart"
|
| 10 |
+
avatar: "advisor2.png"
|
| 11 |
temperature: 6
|
| 12 |
persona_prompt: |
|
| 13 |
You are a compassionate PhD advisor and Empathetic Listener with expertise in student well-being, emotional support, and holistic academic guidance. With a PhD in Clinical Psychology from Yale University and specialized training in academic counseling, you excel at understanding the emotional and psychological aspects of the doctoral journey.
|
|
@@ -7,6 +7,7 @@ bg_color: "#EFF6FF"
|
|
| 7 |
dark_color: "#60A5FA"
|
| 8 |
dark_bg_color: "#1E3A8A"
|
| 9 |
icon: "BookOpen"
|
|
|
|
| 10 |
temperature: 4
|
| 11 |
persona_prompt: |
|
| 12 |
You are a distinguished PhD advisor and Research Methodology Expert with 15+ years of experience guiding doctoral students across multiple disciplines. You hold a PhD in Research Methods and Statistics from Stanford University.
|
|
|
|
| 7 |
dark_color: "#60A5FA"
|
| 8 |
dark_bg_color: "#1E3A8A"
|
| 9 |
icon: "BookOpen"
|
| 10 |
+
avatar: "advisor3.png"
|
| 11 |
temperature: 4
|
| 12 |
persona_prompt: |
|
| 13 |
You are a distinguished PhD advisor and Research Methodology Expert with 15+ years of experience guiding doctoral students across multiple disciplines. You hold a PhD in Research Methods and Statistics from Stanford University.
|
|
@@ -7,6 +7,7 @@ bg_color: "#F9FAFB"
|
|
| 7 |
dark_color: "#9CA3AF"
|
| 8 |
dark_bg_color: "#374151"
|
| 9 |
icon: "Minus"
|
|
|
|
| 10 |
temperature: 2
|
| 11 |
persona_prompt: |
|
| 12 |
You are a focused PhD advisor and Minimalist Mentor with expertise in essential thinking and efficient academic progress. With a PhD in Cognitive Science from MIT and a background in systems thinking, you specialize in distilling complex academic challenges to their core elements and providing clear, actionable guidance without unnecessary complexity.
|
|
|
|
| 7 |
dark_color: "#9CA3AF"
|
| 8 |
dark_bg_color: "#374151"
|
| 9 |
icon: "Minus"
|
| 10 |
+
avatar: "advisor4.png"
|
| 11 |
temperature: 2
|
| 12 |
persona_prompt: |
|
| 13 |
You are a focused PhD advisor and Minimalist Mentor with expertise in essential thinking and efficient academic progress. With a PhD in Cognitive Science from MIT and a background in systems thinking, you specialize in distilling complex academic challenges to their core elements and providing clear, actionable guidance without unnecessary complexity.
|
|
@@ -7,6 +7,7 @@ bg_color: "#FEF2F2"
|
|
| 7 |
dark_color: "#F87171"
|
| 8 |
dark_bg_color: "#991B1B"
|
| 9 |
icon: "Zap"
|
|
|
|
| 10 |
temperature: 6
|
| 11 |
persona_prompt: |
|
| 12 |
You are an inspiring PhD advisor and Motivational Coach with expertise in academic resilience and peak performance psychology. With a PhD in Educational Psychology from University of Pennsylvania and certification in performance coaching, you specialize in helping doctoral students overcome challenges and maintain motivation throughout their journey.
|
|
|
|
| 7 |
dark_color: "#F87171"
|
| 8 |
dark_bg_color: "#991B1B"
|
| 9 |
icon: "Zap"
|
| 10 |
+
avatar: "advisor5.png"
|
| 11 |
temperature: 6
|
| 12 |
persona_prompt: |
|
| 13 |
You are an inspiring PhD advisor and Motivational Coach with expertise in academic resilience and peak performance psychology. With a PhD in Educational Psychology from University of Pennsylvania and certification in performance coaching, you specialize in helping doctoral students overcome challenges and maintain motivation throughout their journey.
|
|
@@ -7,6 +7,7 @@ bg_color: "#ECFDF5"
|
|
| 7 |
dark_color: "#34D399"
|
| 8 |
dark_bg_color: "#065F46"
|
| 9 |
icon: "Target"
|
|
|
|
| 10 |
temperature: 5
|
| 11 |
persona_prompt: |
|
| 12 |
You are an energetic and results-oriented PhD advisor specializing in turning research plans into actionable progress. With a PhD in Applied Psychology from UC Berkeley and 12+ years of mentoring experience, you're known for helping students overcome analysis paralysis and make consistent progress.
|
|
|
|
| 7 |
dark_color: "#34D399"
|
| 8 |
dark_bg_color: "#065F46"
|
| 9 |
icon: "Target"
|
| 10 |
+
avatar: "advisor6.png"
|
| 11 |
temperature: 5
|
| 12 |
persona_prompt: |
|
| 13 |
You are an energetic and results-oriented PhD advisor specializing in turning research plans into actionable progress. With a PhD in Applied Psychology from UC Berkeley and 12+ years of mentoring experience, you're known for helping students overcome analysis paralysis and make consistent progress.
|
|
@@ -7,6 +7,7 @@ bg_color: "#FEF3C7"
|
|
| 7 |
dark_color: "#FBBF24"
|
| 8 |
dark_bg_color: "#92400E"
|
| 9 |
icon: "HelpCircle"
|
|
|
|
| 10 |
temperature: 7
|
| 11 |
persona_prompt: |
|
| 12 |
You are a distinguished PhD advisor and Socratic Mentor with expertise in critical thinking development and philosophical inquiry. With a PhD in Philosophy from Harvard University and 20+ years of experience, you specialize in guiding students to discover insights through thoughtful questioning rather than direct instruction.
|
|
|
|
| 7 |
dark_color: "#FBBF24"
|
| 8 |
dark_bg_color: "#92400E"
|
| 9 |
icon: "HelpCircle"
|
| 10 |
+
avatar: "advisor7.png"
|
| 11 |
temperature: 7
|
| 12 |
persona_prompt: |
|
| 13 |
You are a distinguished PhD advisor and Socratic Mentor with expertise in critical thinking development and philosophical inquiry. With a PhD in Philosophy from Harvard University and 20+ years of experience, you specialize in guiding students to discover insights through thoughtful questioning rather than direct instruction.
|
|
@@ -1,30 +1,46 @@
|
|
| 1 |
-
import React from 'react';
|
|
|
|
| 2 |
import { useAppConfig } from '../contexts/AppConfigContext';
|
| 3 |
import { useTheme } from '../contexts/ThemeContext';
|
|
|
|
| 4 |
|
| 5 |
const AdvisorCard = ({ advisor, advisorId }) => {
|
| 6 |
const Icon = advisor.icon;
|
| 7 |
const { isDark } = useTheme();
|
| 8 |
const { getAdvisorColors } = useAppConfig();
|
| 9 |
const colors = getAdvisorColors(advisorId, isDark);
|
|
|
|
|
|
|
| 10 |
|
| 11 |
return (
|
| 12 |
-
<
|
| 13 |
-
<div
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
>
|
| 24 |
-
{advisor.role}
|
| 25 |
-
</p>
|
| 26 |
-
<p className="advisor-card-description">{advisor.description}</p>
|
| 27 |
-
</div>
|
| 28 |
);
|
| 29 |
};
|
| 30 |
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Pencil } from 'lucide-react';
|
| 3 |
import { useAppConfig } from '../contexts/AppConfigContext';
|
| 4 |
import { useTheme } from '../contexts/ThemeContext';
|
| 5 |
+
import AvatarPickerModal from './AvatarPickerModal';
|
| 6 |
|
| 7 |
const AdvisorCard = ({ advisor, advisorId }) => {
|
| 8 |
const Icon = advisor.icon;
|
| 9 |
const { isDark } = useTheme();
|
| 10 |
const { getAdvisorColors } = useAppConfig();
|
| 11 |
const colors = getAdvisorColors(advisorId, isDark);
|
| 12 |
+
const [hovered, setHovered] = useState(false);
|
| 13 |
+
const [pickerOpen, setPickerOpen] = useState(false);
|
| 14 |
|
| 15 |
return (
|
| 16 |
+
<>
|
| 17 |
+
<div className="advisor-card">
|
| 18 |
+
<div
|
| 19 |
+
className="advisor-card-icon"
|
| 20 |
+
style={{ backgroundColor: colors.bgColor, position: 'relative', cursor: 'pointer', overflow: 'hidden' }}
|
| 21 |
+
onMouseEnter={() => setHovered(true)}
|
| 22 |
+
onMouseLeave={() => setHovered(false)}
|
| 23 |
+
onClick={() => setPickerOpen(true)}
|
| 24 |
+
>
|
| 25 |
+
{advisor.avatarUrl ? (
|
| 26 |
+
<img src={advisor.avatarUrl} alt={advisor.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 'inherit' }} />
|
| 27 |
+
) : (
|
| 28 |
+
<Icon style={{ color: colors.color }} />
|
| 29 |
+
)}
|
| 30 |
+
{hovered && (
|
| 31 |
+
<div style={{ position: 'absolute', inset: 0, borderRadius: 'inherit', background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 32 |
+
<Pencil size={18} color="#fff" />
|
| 33 |
+
</div>
|
| 34 |
+
)}
|
| 35 |
+
</div>
|
| 36 |
+
<h3 className="advisor-card-title">{advisor.name}</h3>
|
| 37 |
+
<p className="advisor-card-role" style={{ color: colors.color }}>{advisor.role}</p>
|
| 38 |
+
<p className="advisor-card-description">{advisor.description}</p>
|
| 39 |
</div>
|
| 40 |
+
{pickerOpen && (
|
| 41 |
+
<AvatarPickerModal advisorId={advisorId} advisorName={advisor.name} onClose={() => setPickerOpen(false)} />
|
| 42 |
+
)}
|
| 43 |
+
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
);
|
| 45 |
};
|
| 46 |
|
|
@@ -1,8 +1,11 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
-
import { Users, ChevronDown } from 'lucide-react';
|
|
|
|
| 3 |
|
| 4 |
const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, isDark }) => {
|
| 5 |
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
|
|
|
| 6 |
|
| 7 |
// Close dropdown when clicking outside
|
| 8 |
useEffect(() => {
|
|
@@ -50,6 +53,13 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
|
|
| 50 |
<ChevronDown size={14} className={`dropdown-arrow ${isOpen ? 'rotated' : ''}`} />
|
| 51 |
</button>
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
{isOpen && (
|
| 54 |
<div className="advisor-dropdown-panel">
|
| 55 |
<div className="advisor-list">
|
|
@@ -59,16 +69,27 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
|
|
| 59 |
const isThinking = Array.isArray(thinkingAdvisors) && thinkingAdvisors.includes(id);
|
| 60 |
|
| 61 |
return (
|
| 62 |
-
<div
|
| 63 |
-
key={id}
|
| 64 |
className={`advisor-item ${isThinking ? 'thinking' : ''}`}
|
| 65 |
-
style={{
|
| 66 |
-
'--advisor-color': colors.color,
|
| 67 |
-
'--advisor-bg': colors.bgColor
|
| 68 |
-
}}
|
| 69 |
>
|
| 70 |
-
<div
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
<div className="advisor-details">
|
| 74 |
<div className="advisor-name">{advisor.name}</div>
|
|
@@ -94,7 +115,7 @@ const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, i
|
|
| 94 |
</div>
|
| 95 |
)}
|
| 96 |
|
| 97 |
-
<style
|
| 98 |
.advisor-status-dropdown {
|
| 99 |
position: relative;
|
| 100 |
display: inline-block;
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Users, ChevronDown, Pencil } from 'lucide-react';
|
| 3 |
+
import AvatarPickerModal from './AvatarPickerModal';
|
| 4 |
|
| 5 |
const AdvisorStatusDropdown = ({ advisors, thinkingAdvisors, getAdvisorColors, isDark }) => {
|
| 6 |
const [isOpen, setIsOpen] = useState(false);
|
| 7 |
+
const [hoveredId, setHoveredId] = useState(null);
|
| 8 |
+
const [pickerAdvisor, setPickerAdvisor] = useState(null);
|
| 9 |
|
| 10 |
// Close dropdown when clicking outside
|
| 11 |
useEffect(() => {
|
|
|
|
| 53 |
<ChevronDown size={14} className={`dropdown-arrow ${isOpen ? 'rotated' : ''}`} />
|
| 54 |
</button>
|
| 55 |
|
| 56 |
+
{pickerAdvisor && (
|
| 57 |
+
<AvatarPickerModal
|
| 58 |
+
advisorId={pickerAdvisor.id}
|
| 59 |
+
advisorName={pickerAdvisor.name}
|
| 60 |
+
onClose={() => setPickerAdvisor(null)}
|
| 61 |
+
/>
|
| 62 |
+
)}
|
| 63 |
{isOpen && (
|
| 64 |
<div className="advisor-dropdown-panel">
|
| 65 |
<div className="advisor-list">
|
|
|
|
| 69 |
const isThinking = Array.isArray(thinkingAdvisors) && thinkingAdvisors.includes(id);
|
| 70 |
|
| 71 |
return (
|
| 72 |
+
<div
|
| 73 |
+
key={id}
|
| 74 |
className={`advisor-item ${isThinking ? 'thinking' : ''}`}
|
| 75 |
+
style={{ '--advisor-color': colors.color, '--advisor-bg': colors.bgColor }}
|
|
|
|
|
|
|
|
|
|
| 76 |
>
|
| 77 |
+
<div
|
| 78 |
+
className="advisor-icon"
|
| 79 |
+
style={{ position: 'relative', cursor: 'pointer', overflow: 'hidden', width: 32, height: 32, borderRadius: 8, flexShrink: 0 }}
|
| 80 |
+
onMouseEnter={() => setHoveredId(id)}
|
| 81 |
+
onMouseLeave={() => setHoveredId(null)}
|
| 82 |
+
onClick={() => setPickerAdvisor({ id, name: advisor.name })}
|
| 83 |
+
>
|
| 84 |
+
{advisor.avatarUrl
|
| 85 |
+
? <img src={advisor.avatarUrl} alt={advisor.name} style={{ width: 32, height: 32, objectFit: 'cover', display: 'block' }} />
|
| 86 |
+
: <IconComponent size={16} />
|
| 87 |
+
}
|
| 88 |
+
{hoveredId === id && (
|
| 89 |
+
<div style={{ position: 'absolute', inset: 0, borderRadius: 8, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 90 |
+
<Pencil size={10} color="#fff" />
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
</div>
|
| 94 |
<div className="advisor-details">
|
| 95 |
<div className="advisor-name">{advisor.name}</div>
|
|
|
|
| 115 |
</div>
|
| 116 |
)}
|
| 117 |
|
| 118 |
+
<style>{`
|
| 119 |
.advisor-status-dropdown {
|
| 120 |
position: relative;
|
| 121 |
display: inline-block;
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom';
|
| 3 |
+
import { X } from 'lucide-react';
|
| 4 |
+
import { useAppConfig } from '../contexts/AppConfigContext';
|
| 5 |
+
|
| 6 |
+
const API = process.env.REACT_APP_API_URL || '';
|
| 7 |
+
|
| 8 |
+
const BUNDLED = [
|
| 9 |
+
'advisor1.png','advisor2.png','advisor3.png','advisor4.png',
|
| 10 |
+
'advisor5.png','advisor6.png','advisor7.png',
|
| 11 |
+
];
|
| 12 |
+
|
| 13 |
+
const overlay = {
|
| 14 |
+
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
|
| 15 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const modal = {
|
| 19 |
+
background: 'var(--bg-primary)', borderRadius: 16, padding: 24, width: 480,
|
| 20 |
+
maxWidth: '95vw', maxHeight: '85vh', overflowY: 'auto',
|
| 21 |
+
boxShadow: 'var(--shadow-xl)',
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
const AvatarPickerModal = ({ advisorId, advisorName, onClose }) => {
|
| 25 |
+
const { setAdvisorAvatar } = useAppConfig();
|
| 26 |
+
|
| 27 |
+
const select = (url) => {
|
| 28 |
+
setAdvisorAvatar(advisorId, url || '');
|
| 29 |
+
onClose();
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
return ReactDOM.createPortal(
|
| 33 |
+
<div style={overlay} onClick={(e) => e.target === e.currentTarget && onClose()} onMouseDown={(e) => e.stopPropagation()}>
|
| 34 |
+
<div style={modal}>
|
| 35 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
| 36 |
+
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>
|
| 37 |
+
Choose Avatar — {advisorName}
|
| 38 |
+
</h3>
|
| 39 |
+
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
| 40 |
+
<X size={20} />
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<p style={{ margin: '0 0 10px', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600 }}>Pre-made Avatars</p>
|
| 45 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 8, marginBottom: 20 }}>
|
| 46 |
+
{BUNDLED.map((file) => (
|
| 47 |
+
<img
|
| 48 |
+
key={file}
|
| 49 |
+
src={`${API}/api/avatars/bundled/${file}`}
|
| 50 |
+
alt={file}
|
| 51 |
+
onClick={() => select(`${API}/api/avatars/bundled/${file}`)}
|
| 52 |
+
style={{ width: '100%', aspectRatio: '1', borderRadius: '50%', objectFit: 'cover', cursor: 'pointer', border: '2px solid transparent', transition: 'border-color 0.15s' }}
|
| 53 |
+
onMouseEnter={e => e.target.style.borderColor = 'var(--accent-primary)'}
|
| 54 |
+
onMouseLeave={e => e.target.style.borderColor = 'transparent'}
|
| 55 |
+
/>
|
| 56 |
+
))}
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<button
|
| 60 |
+
onClick={() => select(null)}
|
| 61 |
+
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'transparent', border: '1px solid var(--border-primary)', borderRadius: 10, color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 13.5 }}
|
| 62 |
+
>
|
| 63 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
| 64 |
+
<circle cx="12" cy="8" r="4" />
|
| 65 |
+
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
| 66 |
+
</svg>
|
| 67 |
+
Use default icon
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
</div>,
|
| 71 |
+
document.body
|
| 72 |
+
);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
export default AvatarPickerModal;
|
|
@@ -8,18 +8,32 @@ const AppConfigContext = createContext(null);
|
|
| 8 |
* component. Falls back to HelpCircle if the name isn't found.
|
| 9 |
*/
|
| 10 |
const resolveIcon = (iconName) => {
|
| 11 |
-
if (!iconName) return LucideIcons.
|
| 12 |
-
return LucideIcons[iconName] || LucideIcons.
|
| 13 |
};
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Build the advisors lookup object (keyed by persona id) from the config
|
| 17 |
* personas array, mirroring the shape that components already expect.
|
| 18 |
*/
|
| 19 |
-
const buildAdvisors = (personaItems) => {
|
| 20 |
if (!personaItems || !Array.isArray(personaItems)) return {};
|
| 21 |
const advisors = {};
|
| 22 |
for (const p of personaItems) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
advisors[p.id] = {
|
| 24 |
name: p.name,
|
| 25 |
role: p.role || '',
|
|
@@ -28,7 +42,8 @@ const buildAdvisors = (personaItems) => {
|
|
| 28 |
bgColor: p.bg_color || '#F3F4F6',
|
| 29 |
darkColor: p.dark_color || '#9CA3AF',
|
| 30 |
darkBgColor: p.dark_bg_color || '#374151',
|
| 31 |
-
icon: resolveIcon(
|
|
|
|
| 32 |
};
|
| 33 |
}
|
| 34 |
return advisors;
|
|
@@ -62,21 +77,27 @@ export const useAppConfig = () => {
|
|
| 62 |
|
| 63 |
export const AppConfigProvider = ({ children }) => {
|
| 64 |
const [config, setConfig] = useState(null);
|
|
|
|
| 65 |
const [advisors, setAdvisors] = useState({});
|
| 66 |
const [loading, setLoading] = useState(true);
|
| 67 |
const [error, setError] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
useEffect(() => {
|
| 70 |
const fetchConfig = async () => {
|
| 71 |
try {
|
| 72 |
-
const response = await fetch(
|
| 73 |
-
`${process.env.REACT_APP_API_URL}/api/config`
|
| 74 |
-
);
|
| 75 |
if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`);
|
| 76 |
const data = await response.json();
|
| 77 |
setConfig(data);
|
| 78 |
-
|
| 79 |
-
setAdvisors(builtAdvisors);
|
| 80 |
} catch (err) {
|
| 81 |
console.error('Failed to load app config:', err);
|
| 82 |
setError(err.message);
|
|
@@ -87,6 +108,23 @@ export const AppConfigProvider = ({ children }) => {
|
|
| 87 |
fetchConfig();
|
| 88 |
}, []);
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
// Inject the primary colour as a CSS custom property on <html> so it is
|
| 91 |
// available everywhere without prop-drilling.
|
| 92 |
useEffect(() => {
|
|
@@ -115,6 +153,9 @@ export const AppConfigProvider = ({ children }) => {
|
|
| 115 |
resolveIcon,
|
| 116 |
loading,
|
| 117 |
error,
|
|
|
|
|
|
|
|
|
|
| 118 |
};
|
| 119 |
|
| 120 |
if (loading) {
|
|
|
|
| 8 |
* component. Falls back to HelpCircle if the name isn't found.
|
| 9 |
*/
|
| 10 |
const resolveIcon = (iconName) => {
|
| 11 |
+
if (!iconName) return LucideIcons.User;
|
| 12 |
+
return LucideIcons[iconName] || LucideIcons.User;
|
| 13 |
};
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Build the advisors lookup object (keyed by persona id) from the config
|
| 17 |
* personas array, mirroring the shape that components already expect.
|
| 18 |
*/
|
| 19 |
+
const buildAdvisors = (personaItems, overrides = {}) => {
|
| 20 |
if (!personaItems || !Array.isArray(personaItems)) return {};
|
| 21 |
const advisors = {};
|
| 22 |
for (const p of personaItems) {
|
| 23 |
+
const image = p.image || '';
|
| 24 |
+
const isIcon = image.startsWith('icon://');
|
| 25 |
+
const rawImageUrl = isIcon ? null : image || null;
|
| 26 |
+
const configImageUrl = rawImageUrl && rawImageUrl.startsWith('/')
|
| 27 |
+
? `${process.env.REACT_APP_API_URL}${rawImageUrl}`
|
| 28 |
+
: rawImageUrl;
|
| 29 |
+
|
| 30 |
+
// Override takes precedence if the advisor has one set.
|
| 31 |
+
// Override = truthy URL → use it. Override = '' → force default icon.
|
| 32 |
+
// No override key → fall back to config image.
|
| 33 |
+
const hasOverride = Object.prototype.hasOwnProperty.call(overrides, p.id);
|
| 34 |
+
const overrideValue = overrides[p.id];
|
| 35 |
+
const avatarUrl = hasOverride ? (overrideValue || null) : configImageUrl;
|
| 36 |
+
|
| 37 |
advisors[p.id] = {
|
| 38 |
name: p.name,
|
| 39 |
role: p.role || '',
|
|
|
|
| 42 |
bgColor: p.bg_color || '#F3F4F6',
|
| 43 |
darkColor: p.dark_color || '#9CA3AF',
|
| 44 |
darkBgColor: p.dark_bg_color || '#374151',
|
| 45 |
+
icon: resolveIcon(isIcon ? image.replace('icon://', '') : null),
|
| 46 |
+
avatarUrl,
|
| 47 |
};
|
| 48 |
}
|
| 49 |
return advisors;
|
|
|
|
| 77 |
|
| 78 |
export const AppConfigProvider = ({ children }) => {
|
| 79 |
const [config, setConfig] = useState(null);
|
| 80 |
+
const [personaItems, setPersonaItems] = useState([]);
|
| 81 |
const [advisors, setAdvisors] = useState({});
|
| 82 |
const [loading, setLoading] = useState(true);
|
| 83 |
const [error, setError] = useState(null);
|
| 84 |
+
const [avatarOverrides, setAvatarOverrides] = useState(() => {
|
| 85 |
+
try { return JSON.parse(localStorage.getItem('advisorAvatarOverrides') || '{}'); }
|
| 86 |
+
catch { return {}; }
|
| 87 |
+
});
|
| 88 |
+
const [myCustomAvatars, setMyCustomAvatars] = useState(() => {
|
| 89 |
+
try { return JSON.parse(localStorage.getItem('myCustomAvatars') || '[]'); }
|
| 90 |
+
catch { return []; }
|
| 91 |
+
});
|
| 92 |
|
| 93 |
useEffect(() => {
|
| 94 |
const fetchConfig = async () => {
|
| 95 |
try {
|
| 96 |
+
const response = await fetch(`${process.env.REACT_APP_API_URL}/api/config`);
|
|
|
|
|
|
|
| 97 |
if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`);
|
| 98 |
const data = await response.json();
|
| 99 |
setConfig(data);
|
| 100 |
+
setPersonaItems(data.personas?.items || []);
|
|
|
|
| 101 |
} catch (err) {
|
| 102 |
console.error('Failed to load app config:', err);
|
| 103 |
setError(err.message);
|
|
|
|
| 108 |
fetchConfig();
|
| 109 |
}, []);
|
| 110 |
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
setAdvisors(buildAdvisors(personaItems, avatarOverrides));
|
| 113 |
+
}, [personaItems, avatarOverrides]);
|
| 114 |
+
|
| 115 |
+
const setAdvisorAvatar = (advisorId, url) => {
|
| 116 |
+
const next = { ...avatarOverrides, [advisorId]: url };
|
| 117 |
+
setAvatarOverrides(next);
|
| 118 |
+
localStorage.setItem('advisorAvatarOverrides', JSON.stringify(next));
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const addMyAvatar = (url) => {
|
| 122 |
+
if (myCustomAvatars.includes(url)) return;
|
| 123 |
+
const next = [url, ...myCustomAvatars];
|
| 124 |
+
setMyCustomAvatars(next);
|
| 125 |
+
localStorage.setItem('myCustomAvatars', JSON.stringify(next));
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
// Inject the primary colour as a CSS custom property on <html> so it is
|
| 129 |
// available everywhere without prop-drilling.
|
| 130 |
useEffect(() => {
|
|
|
|
| 153 |
resolveIcon,
|
| 154 |
loading,
|
| 155 |
error,
|
| 156 |
+
setAdvisorAvatar,
|
| 157 |
+
addMyAvatar,
|
| 158 |
+
myCustomAvatars,
|
| 159 |
};
|
| 160 |
|
| 161 |
if (loading) {
|