NeonCharlie-24 Ryan-Venturi1 Neon:ryan commited on
Commit
5461cdf
·
unverified ·
1 Parent(s): e19e41d

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 ADDED

Git LFS Details

  • SHA256: 8def49b76592eb14f37b308542534720dd5df516fe8c9b95dff00f2534f51727
  • Pointer size: 132 Bytes
  • Size of remote file: 5.79 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor2.png ADDED

Git LFS Details

  • SHA256: 676f77e3d01c0a905658e965be55f3b6d3a91370debc1ba930f9e5a7ed7a80a2
  • Pointer size: 132 Bytes
  • Size of remote file: 5.33 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor3.png ADDED

Git LFS Details

  • SHA256: 3e4e59ac55e3bc490f36b5f85b03c8634ebe1ae7727e888093c4b7e1879c4473
  • Pointer size: 132 Bytes
  • Size of remote file: 5.57 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor4.png ADDED

Git LFS Details

  • SHA256: b97c9f62b1cfb8d4ce6e57c187dbad6a6fab69b24e446af31dec8999ce800306
  • Pointer size: 132 Bytes
  • Size of remote file: 5.33 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor5.png ADDED

Git LFS Details

  • SHA256: 769b6a6461cdb2f92e3e71fbbcd2b1361b560fd7c49617f3a476eaf1a6bf9199
  • Pointer size: 132 Bytes
  • Size of remote file: 5.04 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor6.png ADDED

Git LFS Details

  • SHA256: 5d066f008dffcb0332642e35e3f4805ed30598c03055ac149ab9c5c2892f1c8b
  • Pointer size: 132 Bytes
  • Size of remote file: 5.3 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor7.png ADDED

Git LFS Details

  • SHA256: d7ef861863ee750e201e7ee5eb483494b7dbadc975d0f2fb5cb9fdc0f428e0e6
  • Pointer size: 132 Bytes
  • Size of remote file: 4.28 MB
multi_llm_chatbot_backend/app/config.py CHANGED
@@ -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
- "icon": self.icon
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
 
multi_llm_chatbot_backend/app/main.py CHANGED
@@ -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
multi_llm_chatbot_backend/app/tests/unit/test_avatar_resolution.py ADDED
@@ -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")
multi_llm_chatbot_backend/app/utils/avatar_helpers.py ADDED
@@ -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
personas/phd_advisors/critic.yaml CHANGED
@@ -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.
personas/phd_advisors/empathetic.yaml CHANGED
@@ -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.
personas/phd_advisors/methodologist.yaml CHANGED
@@ -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.
personas/phd_advisors/minimalist.yaml CHANGED
@@ -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.
personas/phd_advisors/motivator.yaml CHANGED
@@ -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.
personas/phd_advisors/pragmatist.yaml CHANGED
@@ -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.
personas/phd_advisors/socratic.yaml CHANGED
@@ -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.
phd-advisor-frontend/src/components/AdvisorCard.js CHANGED
@@ -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
- <div className="advisor-card">
13
- <div
14
- className="advisor-card-icon"
15
- style={{ backgroundColor: colors.bgColor }}
16
- >
17
- <Icon style={{ color: colors.color }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  </div>
19
- <h3 className="advisor-card-title">{advisor.name}</h3>
20
- <p
21
- className="advisor-card-role"
22
- style={{ color: colors.color }}
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
 
phd-advisor-frontend/src/components/AdvisorStatusDropdown.js CHANGED
@@ -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 className="advisor-icon">
71
- <IconComponent size={16} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 jsx>{`
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;
phd-advisor-frontend/src/components/AvatarPickerModal.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import { X } from 'lucide-react';
4
+ import { useAppConfig } from '../contexts/AppConfigContext';
5
+
6
+ const API = process.env.REACT_APP_API_URL || '';
7
+
8
+ const BUNDLED = [
9
+ 'advisor1.png','advisor2.png','advisor3.png','advisor4.png',
10
+ 'advisor5.png','advisor6.png','advisor7.png',
11
+ ];
12
+
13
+ const overlay = {
14
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
15
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
16
+ };
17
+
18
+ const modal = {
19
+ background: 'var(--bg-primary)', borderRadius: 16, padding: 24, width: 480,
20
+ maxWidth: '95vw', maxHeight: '85vh', overflowY: 'auto',
21
+ boxShadow: 'var(--shadow-xl)',
22
+ };
23
+
24
+ const AvatarPickerModal = ({ advisorId, advisorName, onClose }) => {
25
+ const { setAdvisorAvatar } = useAppConfig();
26
+
27
+ const select = (url) => {
28
+ setAdvisorAvatar(advisorId, url || '');
29
+ onClose();
30
+ };
31
+
32
+ return ReactDOM.createPortal(
33
+ <div style={overlay} onClick={(e) => e.target === e.currentTarget && onClose()} onMouseDown={(e) => e.stopPropagation()}>
34
+ <div style={modal}>
35
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
36
+ <h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>
37
+ Choose Avatar — {advisorName}
38
+ </h3>
39
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
40
+ <X size={20} />
41
+ </button>
42
+ </div>
43
+
44
+ <p style={{ margin: '0 0 10px', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600 }}>Pre-made Avatars</p>
45
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 8, marginBottom: 20 }}>
46
+ {BUNDLED.map((file) => (
47
+ <img
48
+ key={file}
49
+ src={`${API}/api/avatars/bundled/${file}`}
50
+ alt={file}
51
+ onClick={() => select(`${API}/api/avatars/bundled/${file}`)}
52
+ style={{ width: '100%', aspectRatio: '1', borderRadius: '50%', objectFit: 'cover', cursor: 'pointer', border: '2px solid transparent', transition: 'border-color 0.15s' }}
53
+ onMouseEnter={e => e.target.style.borderColor = 'var(--accent-primary)'}
54
+ onMouseLeave={e => e.target.style.borderColor = 'transparent'}
55
+ />
56
+ ))}
57
+ </div>
58
+
59
+ <button
60
+ onClick={() => select(null)}
61
+ style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'transparent', border: '1px solid var(--border-primary)', borderRadius: 10, color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 13.5 }}
62
+ >
63
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
64
+ <circle cx="12" cy="8" r="4" />
65
+ <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
66
+ </svg>
67
+ Use default icon
68
+ </button>
69
+ </div>
70
+ </div>,
71
+ document.body
72
+ );
73
+ };
74
+
75
+ export default AvatarPickerModal;
phd-advisor-frontend/src/contexts/AppConfigContext.js CHANGED
@@ -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.HelpCircle;
12
- return LucideIcons[iconName] || LucideIcons.HelpCircle;
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(p.icon),
 
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
- const builtAdvisors = buildAdvisors(data.personas?.items);
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) {