Compare commits

...

187 Commits

Author SHA1 Message Date
f725f35af7 Remove ollama from being managed by backstory 2025-08-05 17:18:33 -07:00
234148f046 Fix auth issue 2025-08-04 16:21:29 -07:00
064868e96e Lots of mobile and desktop edit view tweaks 2025-07-19 14:23:28 -07:00
97272d9175 Fixing mobile styles 2025-07-19 12:12:43 -07:00
7b392409ca Added improved RAG content editor 2025-07-18 16:00:52 -07:00
a0e83d3cfb Allow direct link to resume edit 2025-07-17 16:46:38 -07:00
35a296889b Improved resume editing 2025-07-17 16:44:19 -07:00
65471b9fe0 Replace tagline 2025-07-17 10:06:09 -07:00
0fc43b84ad Reduced information text size on mobile 2025-07-16 19:08:20 -07:00
33d91d1cbb Copy edits from Chelsea 2025-07-16 19:06:09 -07:00
a51606e848 Resize chat due to headers on mobile 2025-07-16 18:52:55 -07:00
a5bf96437e Added some mobile view work 2025-07-16 18:41:01 -07:00
ed69096ef0 Put resume questions first 2025-07-16 18:20:57 -07:00
794efe0f95 Added mobile view of resume chat 2025-07-16 18:17:59 -07:00
c2564f5966 Reverted text area changes 2025-07-16 17:35:24 -07:00
ac3eccd61d Improved QnA 2025-07-16 17:29:09 -07:00
2ac5f5f078 Added interactive resume chat with agentic backend 2025-07-16 17:03:38 -07:00
574d040492 Added chat/resume 2025-07-16 16:12:10 -07:00
621bf46a39 Revision control 2025-07-15 12:03:07 -07:00
b32f0948c4 Added ANSWER programmatic info 2025-07-15 11:12:30 -07:00
ddd024cc4a Updated resume layout 2025-07-15 10:46:12 -07:00
James Ketrenos
a0207f4d28 Updating docker config 2025-07-15 10:44:21 -07:00
79372231eb Add AI editing for resumes 2025-07-15 10:24:39 -07:00
c31752e50f Updated requirements.txt 2025-07-15 10:24:39 -07:00
James Ketrenos
31f55397d0 Allow frontend env to override docker-compose.yml 2025-07-14 12:17:21 -07:00
bc071ee36e Add pip venv generation on first run 2025-07-14 12:16:51 -07:00
f7031fda8a Add vLLM 2025-07-14 10:43:09 -07:00
d1ae090cf3 Wrap call in formatApiRequest 2025-07-14 10:43:05 -07:00
179bef1564 Anthropic backend working
Add regenerate skill assessment
2025-07-11 13:24:47 -07:00
fd6ec5015c Added regen
Fixed Anthropic backend
2025-07-11 13:23:38 -07:00
a46172d696 Added job regenerate 2025-07-10 17:27:38 -07:00
80b63fe0e1 Plumbed cache through agents
Removed some dead frontend code
2025-07-10 15:04:34 -07:00
2fe1f5b181 Added caching to LLM queries 2025-07-09 13:08:36 -07:00
b130cd3974 Added dynamic QR code generation for job-analysis 2025-07-08 18:01:38 -07:00
cf5936730c Reworked initial state loading logic 2025-07-08 14:10:08 -07:00
89bcc1cb55 Rework step logic so deep links work 2025-07-08 14:02:58 -07:00
fbe0bd98c7 Fix beta banner to not go to docs/beta and instead go to / 2025-07-08 14:02:47 -07:00
4a7a72812f Do not use cached skills if content updated
Add candidate/job route to job-analysis
2025-07-08 13:48:47 -07:00
a0cb23df28 Fix race condition with the same item entering queue multiple times 2025-07-08 13:17:36 -07:00
d66e1ee1e4 Added resume styles 2025-07-08 12:46:59 -07:00
cacbb0fd0f Fix ordering on steps 2025-07-01 18:11:52 -07:00
b5eaf8bd43 Fixes for mobile 2025-07-01 16:30:04 -07:00
92065ab182 Fix job info panel not opening in mobile mode 2025-07-01 15:57:19 -07:00
150228f83d Updated How it Works 2025-07-01 15:53:54 -07:00
aa6be077e6 Refactored job analysis sequence 2025-07-01 14:59:08 -07:00
e0992e77b2 Filter jobs in Jobs view 2025-06-27 15:10:32 -07:00
71d27f5ace Job search works (had to reorder route) 2025-06-27 14:52:48 -07:00
5e37e17724 Added more chat interactions 2025-06-27 11:30:03 -07:00
1e04f2e070 Added question editing 2025-06-27 11:00:10 -07:00
4a95c72a6f Having Claude finish implementation of question dialog 2025-06-27 10:46:05 -07:00
c470d719ea JobsView onJobView now works 2025-06-27 10:31:05 -07:00
0c32e26955 Added qr code generation 2025-06-27 09:55:58 -07:00
f3b9e0c2e7 Deployed 2025-06-24 08:39:32 -07:00
98092e12d6 Added missing verify-email route 2025-06-20 14:09:51 -07:00
ff3e4605a1 Fixed all eslint and prettier issues 2025-06-20 13:56:07 -07:00
2b1fbf2eaf Reduced font size in hero 2025-06-20 12:44:57 -07:00
7257e6d160 Prettier and eslint fixes in progress 2025-06-20 11:56:21 -07:00
c1e6ab9360 Prettier and eslint fixes in progress 2025-06-20 11:55:25 -07:00
0fba24b173 Prettier and eslint fixes in progress 2025-06-20 11:49:22 -07:00
8ddc13d1eb Fix minwidth so it resizes correctly 2025-06-20 11:49:02 -07:00
b8f7cbcf30 prettier fixes 2025-06-20 11:17:22 -07:00
9c9578cc46 Improved resume generation by reordering context 2025-06-20 11:14:07 -07:00
c643b0d8f8 Added some print margins 2025-06-19 10:32:38 -07:00
88a3f85635 Added fullWidth variant for navigation routes 2025-06-19 09:10:05 -07:00
ffd4b829f6 Added analysis complete progress 2025-06-19 08:55:01 -07:00
5c867af814 Adding dense JobsTable 2025-06-19 08:32:22 -07:00
17381dded1 Fixing eslint issues 2025-06-18 16:40:46 -07:00
66b68270cd Prettier / eslint reformatting 2025-06-18 14:34:52 -07:00
f9307070a3 Reformatting tsx 2025-06-18 14:26:07 -07:00
54d5df3fac Removed lint warnings 2025-06-18 13:55:44 -07:00
2cf3fa7b04 ruff reformat 2025-06-18 13:53:07 -07:00
f1c2e16389 Reformatting with ruff 2025-06-18 13:30:54 -07:00
f53ff967cb Removing unused dependencies 2025-06-18 13:11:51 -07:00
168fe8cc8e Moved auth_utils to utils 2025-06-18 12:36:32 -07:00
aefc14c610 Move model_cast into helpers 2025-06-18 12:31:17 -07:00
e5ac267935 ttl deletion for rate limits 2025-06-18 12:30:42 -07:00
cbd6ead5f3 Added some comments 2025-06-18 12:24:04 -07:00
ed2b99e8b9 Refactored database.py into sub-files 2025-06-18 12:17:35 -07:00
dbbfe852e2 Added llm providers entrypoint 2025-06-17 17:22:27 -07:00
a2276c58ef Added multi-llm doc 2025-06-17 17:12:34 -07:00
a4f1fe2e35 Fixed pydantic warning 2025-06-17 17:10:05 -07:00
46faf456cf Refactored code into separate files 2025-06-17 17:09:23 -07:00
4edf11a62d Fixed database async usage with background tasks 2025-06-13 10:32:30 -07:00
dba497c854 Fixed error checking on profile upload 2025-06-13 10:32:12 -07:00
afd8c1df21 Updated so user can pass in default name 2025-06-13 10:31:58 -07:00
3a5b0f86fb Let guests view candidates 2025-06-12 16:35:32 -07:00
5b33d7fa5f Beta on How It Works positioning 2025-06-12 16:35:20 -07:00
5750577eaf Add printing and lots of fixes 2025-06-12 16:27:08 -07:00
6845ed7c62 Fix debug border 2025-06-12 16:26:39 -07:00
d69ef95a41 Let enter behavior be configurable 2025-06-12 16:26:23 -07:00
a5f16494fc Fix launch for prod 2025-06-12 16:25:54 -07:00
30d7035946 Fix launch for prod 2025-06-12 16:25:44 -07:00
586282d7fa Added doc preview 2025-06-12 12:27:39 -07:00
8dcc1c0336 Added Resume viewing 2025-06-12 09:22:06 -07:00
0bc9f74c7f Tweaked JobViewer for mobile 2025-06-12 07:49:21 -07:00
85eac72750 Let guest users see job list 2025-06-11 23:05:53 -07:00
e0ed154476 Added JobViewer 2025-06-11 23:04:43 -07:00
53e0d4aafb Mabye ready for beta?!?! 2025-06-11 16:16:34 -07:00
d2d0bb29ac Beta seems functional 2025-06-11 15:58:40 -07:00
74201d0a71 Menus restructured 2025-06-11 15:11:35 -07:00
7a166fe920 Menu re-work almost done 2025-06-11 10:36:35 -07:00
4689aa66c6 not working 2025-06-11 10:23:55 -07:00
7c78f39b02 Moved HowItWorks into home page 2025-06-11 10:20:06 -07:00
e61e88561e Fixed some Mobile UI issues 2025-06-10 18:03:46 -07:00
0a23b7deae How It Works 2025-06-10 15:17:36 -07:00
bb4017b835 Full initial flow for guest working 2025-06-10 15:10:26 -07:00
3a21f2e510 Restructuring top level UI 2025-06-10 11:24:00 -07:00
4f4187eba4 Working on job creation flow 2025-06-09 19:57:08 -07:00
9edf5a5b23 Added recursive pydantic model converter 2025-06-09 19:56:55 -07:00
1531a05de0 Pruned out React warnings 2025-06-09 18:09:02 -07:00
149ce9e9b3 Tweaks 2025-06-09 16:37:47 -07:00
a197535bea Resume generation almost working 2025-06-09 16:14:32 -07:00
dd0ab5eda6 Integrating new resume generator 2025-06-09 11:57:12 -07:00
1fbc5317d3 Locked job creation behind access restriction 2025-06-09 11:14:59 -07:00
7b457244ae Changed header menu
Added date fields to Job
2025-06-09 10:59:24 -07:00
781275e9a9 Skill assessment is now streaming 2025-06-09 09:19:00 -07:00
817a8e4b66 UI style tweaks 2025-06-08 21:16:29 -07:00
3970cca715 RAG working 2025-06-08 21:08:29 -07:00
d477b85e5a Do not show LLM data for user messages 2025-06-08 20:45:28 -07:00
20f8d7bd32 Guest seems to work! 2025-06-08 20:42:23 -07:00
35ef9898f1 Restructured nav items 2025-06-08 14:46:35 -07:00
43b332bd47 Restructured for menus 2025-06-08 14:36:21 -07:00
38635f0fd4 Fixed Docs menu item 2025-06-08 13:46:03 -07:00
d41f9a9e75 New navigation system 2025-06-08 13:38:49 -07:00
18863a23d9 Added missing files 2025-06-08 12:53:53 -07:00
588b1d9b61 Improved doc loading 2025-06-08 12:53:38 -07:00
82df3758cd Updated email templates 2025-06-07 16:19:38 -07:00
8aa8577874 Multi-LLM backend working with Ollama 2025-06-07 15:03:00 -07:00
5fed56ba76 Scrolling in sub pages is working correctly 2025-06-07 09:39:00 -07:00
fc6fc57922 Scrolling in sub pages is working correctly 2025-06-07 09:38:53 -07:00
b76141f3d1 Cooooool! 2025-06-05 16:42:40 -07:00
b88949bb76 Analysis worked! 2025-06-05 16:27:18 -07:00
1a13d41f28 Almost working through automatic flow 2025-06-05 16:03:26 -07:00
504985a06b Job submission and parsing from doc working 2025-06-05 14:25:57 -07:00
48e6eeaa71 Chat and stuff is working again 2025-06-04 18:09:25 -07:00
efef926e45 Fixed tab errors 2025-06-04 11:11:42 -07:00
4f7b2f3e6a Refresh maintains selected entities 2025-06-04 10:31:26 -07:00
a912e4d24c Job analysis is working 2025-06-03 22:30:09 -07:00
d9a0267cfa Skill tracking almost working 2025-06-03 21:29:03 -07:00
7586725f11 Skill tracking almost working 2025-06-03 21:28:55 -07:00
cb97cabfc3 Job analysis in flight 2025-06-03 17:00:18 -07:00
05c53653ed AI generation and deletion working 2025-06-03 16:16:20 -07:00
357b42ea7c Working on user management 2025-06-03 15:24:07 -07:00
40d59042ef Persona creation in progress 2025-06-03 09:59:26 -07:00
88a2b9dd90 Fixed RAG bug 2025-06-02 21:05:30 -07:00
cde4305c9b UI tweaks 2025-06-02 20:45:29 -07:00
138f61b777 Working on Job Analysis 2025-06-02 20:24:46 -07:00
0994c95f91 Single chat session per candidate 2025-06-02 19:45:04 -07:00
f286868cbe Image profile update works 2025-06-02 17:36:47 -07:00
699acf9313 Works 2025-06-02 17:26:07 -07:00
a65f48034c File upload working 2025-06-02 16:06:25 -07:00
149bbdf73b RAG working in candidate page 2025-06-02 13:03:04 -07:00
bb84709f44 Misc tweaks to prompt 2025-06-01 15:59:10 -07:00
c07510c525 Chat is working again 2025-06-01 14:39:42 -07:00
3fc6b1ab4d Chat is working with new api 2025-06-01 14:26:41 -07:00
4919da84d6 MFA is working 2025-06-01 14:09:28 -07:00
d7a81481a2 MFA is working 2025-06-01 13:42:32 -07:00
360673e60d Moved JWT token to .env 2025-06-01 11:49:09 -07:00
32f81f6314 Implementing MFA 2025-05-31 19:40:30 -07:00
35701d9719 Implementing MFA 2025-05-31 19:25:04 -07:00
9b320366ce Remove Dashboard from tab menu -- it is available under the user menu 2025-05-31 18:35:37 -07:00
e1c1bcf097 Fixed more UI issues 2025-05-31 17:37:04 -07:00
40ab58fffe UI improvement for mobile/desktop on chat page 2025-05-31 17:27:44 -07:00
8f0ff5da68 Moved RAG context into user message instead of system, and now it works 2025-05-31 11:32:51 -07:00
77440a9d6b Rag is being generated (again) however the LLM is not using it. 2025-05-31 11:31:36 -07:00
4a80004363 Added ComingSoon. Fixes #9 2025-05-30 12:44:10 -07:00
7280672726 Fix #7 -- backend now uses and stores hashed password 2025-05-30 12:19:28 -07:00
a03497a552 Updated UI, auth flows, and refactored 2025-05-30 11:20:41 -07:00
8f6c39c3f7 Drawer opens and closes 2025-05-30 02:46:13 -07:00
4330bd4b7c Date conversion is working from localstorage and via API 2025-05-30 02:41:52 -07:00
adb407b19a Adding type conversion 2025-05-30 02:21:55 -07:00
89b71a1428 Integrated into UI. need mobile view for chatpage 2025-05-29 16:58:14 -07:00
c2601bf17a Chat working with multiple users 2025-05-29 16:43:57 -07:00
27d9ab467a Added mockup chat page 2025-05-29 15:05:41 -07:00
b823c1e839 Fixed a couple style issues 2025-05-29 14:34:56 -07:00
11447b68aa Chat is working again, just not saving 2025-05-29 14:15:21 -07:00
02a278736e Almost working again 2025-05-28 22:50:38 -07:00
b5b3a1f5dc Working on model conversion 2025-05-28 19:09:02 -07:00
a8a8d3738d Back up to latest changes 2025-05-28 16:37:09 -07:00
474bbbed52 Rolling back prod 2025-05-28 16:31:05 -07:00
68a4ccb6d3 Hooking back up 2025-05-28 16:12:32 -07:00
f7e41c710c Updating types 2025-05-28 13:36:35 -07:00
168de8a2b9 Continuing to restructure 2025-05-28 13:00:52 -07:00
ae2557d524 Updated to remove unused items 2025-05-28 09:51:49 -07:00
5720c51f15 Restructured 2025-05-28 09:32:36 -07:00
179a3dcc43 Phone entry 2025-05-28 00:49:25 -07:00
71c8cb0ac8 Transitioning to Redis 2025-05-27 22:01:20 -07:00
287 changed files with 54597 additions and 11694 deletions

2
.gitignore vendored
View File

@ -1,3 +1,4 @@
venv/**
users/**
!users/eliza
users-prod/**
@ -14,3 +15,4 @@ chromadb/**
chromadb-prod/**
dev-keys/**
**/__pycache__/**
.vscode

View File

@ -1,7 +1,7 @@
#
# Build Pyton 3.11 for use in later stages
#
FROM ubuntu:oracular AS python
FROM ubuntu:oracular AS python-local
SHELL [ "/bin/bash", "-c" ]
@ -62,7 +62,7 @@ RUN cmake .. \
# * ollama-ipex-llm
# * src/server.py - model server supporting RAG and fine-tuned models
#
FROM python AS llm-base
FROM python-local AS llm-base
# Install Intel graphics runtimes
RUN apt-get update \
@ -124,7 +124,8 @@ RUN pip install intel-extension-for-pytorch==2.7.10+xpu oneccl_bind_pt==2.7.0+xp
# To use bitsandbytes non-CUDA backends, be sure to install:
RUN pip install "transformers>=4.45.1"
# Note, if you don't want to reinstall BNBs dependencies, append the `--no-deps` flag!
RUN pip install --force-reinstall --no-deps "https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-1.0.0-py3-none-manylinux_2_24_x86_64.whl"
RUN pip install --force-reinstall "https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl"
#RUN pip install --force-reinstall --no-deps "https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_multi-backend-refactor/bitsandbytes-1.0.0-py3-none-manylinux_2_24_x86_64.whl"
# Install ollama python module
RUN pip install ollama langchain-ollama
@ -177,13 +178,46 @@ RUN pip install names-dataset
FROM llm-base AS backstory
#COPY /src/requirements.txt /opt/backstory/src/requirements.txt
#RUN pip install -r /opt/backstory/src/requirements.txt
RUN pip install 'markitdown[all]' pydantic
SHELL [ "/opt/backstory/shell" ]
#COPY /src/requirements.txt /opt/backstory/requirements.txt
#RUN pip install -r /opt/backstory/requirements.txt
RUN pip install 'markitdown[all]' pydantic 'pydantic[email]'
# Prometheus
RUN pip install prometheus-client prometheus-fastapi-instrumentator
# Redis
RUN pip install "redis[hiredis]>=4.5.0"
# New backend implementation
RUN pip install fastapi uvicorn "python-jose[cryptography]" bcrypt python-multipart schedule
# Needed for email verification
RUN pip install pyyaml user-agents cryptography
# OpenAPI CLI generator
RUN pip install openapi-python-client
# QR code generator
RUN pip install setuptools pyqrcode pypng
# Anthropic and other backends
RUN pip install anthropic pydantic_ai
# Automatic type conversion pydantic -> typescript
RUN pip install pydantic typing-inspect jinja2
RUN pip freeze > /opt/backstory/requirements.txt
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
nodejs \
npm \
&& npm install -g typescript \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
SHELL [ "/bin/bash", "-c" ]
RUN { \
@ -192,6 +226,10 @@ RUN { \
echo 'set -e'; \
echo 'echo "Setting pip environment to /opt/backstory"'; \
echo 'if [[ -e /opt/intel/oneapi/setvars.sh ]]; then source /opt/intel/oneapi/setvars.sh; fi' ; \
echo 'if [[ ! -d /opt/backstory/venv/bin ]]; then'; \
echo ' python3 -m venv --system-site-packages /opt/backstory/venv'; \
echo ' pip install -r /opt/backstory/requirements.txt'; \
echo 'fi'; \
echo 'source /opt/backstory/venv/bin/activate'; \
echo ''; \
echo 'if [[ "${1}" == "/bin/bash" ]] || [[ "${1}" =~ ^(/opt/backstory/)?shell$ ]]; then'; \
@ -212,7 +250,7 @@ RUN { \
echo ' while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' echo "Launching Backstory server..."'; \
echo ' python src/server.py "${@}" || echo "Backstory server died."'; \
echo ' python3 src/backend/main.py "${@}" || echo "Backstory server died."'; \
echo ' echo "Sleeping for 3 seconds."'; \
echo ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \
@ -232,9 +270,12 @@ ENV SYCL_PI_LEVEL_ZERO_USE_IMMEDIATE_COMMANDLISTS=1
ENV SYCL_CACHE_PERSISTENT=1
ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint.sh" ]
FROM backstory AS backstory-prod
COPY /src/ /opt/backstory/src/
ENTRYPOINT [ "/entrypoint.sh" ]
FROM ubuntu:oracular AS ollama
@ -263,17 +304,16 @@ RUN apt-get update \
WORKDIR /opt/ollama
# Download the nightly ollama release from ipex-llm
#ENV OLLAMA_VERSION=https://github.com/intel/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
#ENV OLLAMA_VERSION=https://github.com/intel/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250415-ubuntu.tgz
# NOTE: NO longer at github.com/intel -- now at ipex-llm
# This version does not work:
# ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
#ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
# Does not work -- crashes
# ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250612-ubuntu.tgz
RUN wget -qO - ${OLLAMA_VERSION} | \
tar --strip-components=1 -C . -xzv
@ -430,7 +470,7 @@ ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint-jupyter.sh" ]
FROM python AS miniircd
FROM python-local AS miniircd
# Get a couple prerequisites
RUN apt-get update \
@ -552,3 +592,131 @@ COPY /frontend/ /opt/backstory/frontend/
ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint.sh" ]
# FROM ubuntu:24.04 AS ollama-ov-server
# SHELL ["/bin/bash", "-c"]
# RUN apt-get update && apt install -y software-properties-common libtbb-dev
# RUN add-apt-repository ppa:deadsnakes/ppa \
# && apt-get update \
# && apt-get install -y python3.10 net-tools
# RUN ln -sf /usr/bin/python3.10 /usr/bin/python3
# RUN apt-get install -y ca-certificates git wget curl gcc g++ \
# && apt-get clean \
# && rm -rf /var/lib/apt/lists/*
# WORKDIR /home/ollama_ov_server
# ARG GOVERSION=1.24.1
# RUN curl -fsSL https://golang.org/dl/go${GOVERSION}.linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
# ENV PATH=/usr/local/go/bin:$PATH
# RUN wget https://storage.openvinotoolkit.org/repositories/openvino_genai/packages/nightly/2025.2.0.0.dev20250513/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64.tar.gz
# RUN tar -xzf openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64.tar.gz
# ENV GENAI_DIR=/home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64
# RUN source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh
# ENV CGO_ENABLED=1
# ENV GODEBUG=cgocheck=0
# ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64
# ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include
# WORKDIR /home/ollama_ov_server
# RUN git clone https://github.com/openvinotoolkit/openvino_contrib.git
# WORKDIR /home/ollama_ov_server/openvino_contrib/modules/ollama_openvino
# RUN go build -o /usr/bin/ollama .
# ENV OLLAMA_HOST=0.0.0.0:11434
# EXPOSE 11434
# RUN apt-get update \
# && DEBIAN_FRONTEND=noninteractive apt-get install -y \
# pip \
# && apt-get clean \
# && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
# RUN pip install huggingface_hub modelscope
# #ENV model=Qwen3-4B-int4-ov
# #ENV model=Qwen3-8B-int4-ov -- didn't work
# #RUN huggingface-cli download OpenVINO/${model}
# #RUN modelscope download --model OpenVINO/${model} --local_dir ./${model}
# #RUN tar -zcvf /root/.ollama/models/${model}.tar.gz /root/.cache/hub/models--OpenVINO--${model}
# #RUN { \
# # echo "FROM ${model}.tar.gz" ; \
# # echo "ModelType 'OpenVINO'" ; \
# #} > /root/.ollama/models/Modelfile
# #
# #RUN /bin/bash -c "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama create ${model}:v1 -f /root/.ollama/models/Modelfile"
# ENTRYPOINT ["/bin/bash", "-c", "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama serve"]
FROM llm-base AS vllm
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
WORKDIR /opt
RUN git clone https://github.com/vllm-project/vllm.git
WORKDIR /opt/vllm
RUN wget -O - https://astral.sh/uv/install.sh | sh
ENV PATH=~/.local/bin:$PATH
RUN { \
echo '#!/bin/bash' ; \
echo 'source /opt/backstory/venv/bin/activate'; \
echo 'if [[ "${1}" != "" ]]; then bash -c "${@}"; else bash -i; fi' ; \
} > /opt/vllm/shell ; \
chmod +x /opt/vllm/shell
RUN uv venv --python 3.12 --seed
SHELL [ "/opt/vllm/shell" ]
RUN pip install --upgrade pip ; \
pip install -v -r requirements/xpu.txt
RUN VLLM_TARGET_DEVICE=xpu python setup.py install
SHELL [ "/bin/bash", "-c" ]
RUN { \
echo '#!/bin/bash'; \
echo 'echo "Container: vLLM"'; \
echo 'set -e'; \
echo 'source /opt/backstory/venv/bin/activate'; \
echo 'while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' echo "Launching vLLM server..."'; \
echo ' python3 -m vllm.entrypoints.openai.api_server \'; \
echo ' --model=Qwen/Qwen3-8b \' ; \
echo ' --device xpu' ; \
# echo ' --dtype=bfloat16 \' ; \
# echo ' --max_model_len=1024 \' ; \
# echo ' --distributed-executor-backend=ray \' ; \
# echo ' --pipeline-parallel-size=2 \' ; \
# echo ' -tp=1' ; \
echo ' echo "Sleeping for 3 seconds."'; \
echo ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \
echo ' echo "/opt/vllm/block-server exists. Sleeping for 3 seconds."'; \
echo ' once=1' ; \
echo ' fi' ; \
echo ' fi' ; \
echo ' sleep 3'; \
echo 'done' ; \
} > /entrypoint.sh \
&& chmod +x /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]

View File

@ -4,21 +4,35 @@ services:
context: .
dockerfile: Dockerfile
target: backstory
#image: backstory
container_name: backstory
image: backstory
restart: "always"
env_file:
- .env
environment:
- PRODUCTION=0
- FRONTEND_URL=${FRONTEND_URL:-https://backstory-beta.ketrenos.com}
- REDIS_URL=redis://redis:6379
- REDIS_DB=0
- SSL_ENABLED=true
# To use Anthropic, uncomment the following lines and comment out the OpenAI lines
# - DEFAULT_LLM_PROVIDER=anthropic
# - MODEL_NAME=claude-3-5-haiku-latest
- DEFAULT_LLM_PROVIDER=ollama
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- OLLAMA_HOST=http://battle-linux.ketrenos.com:11434
# Test with OpenVINO; it doesn't work though
# - MODEL_NAME=Qwen3-4B-int4-ov:v1
# - OLLAMA_HOST=http://ollama-ov-server:11434
devices:
- /dev/dri:/dev/dri
depends_on:
- ollama
- redis
networks:
- internal
ports:
- 7860:7860 # gradio port for testing
- 8912:8911 # FastAPI React server
volumes:
- ./cache:/root/.cache # Persist all models and GPU kernel cache
@ -26,6 +40,8 @@ services:
- ./dev-keys:/opt/backstory/keys:ro # Developer keys
- ./users:/opt/backstory/users:rw # Live mount of user data
- ./src:/opt/backstory/src:rw # Live mount server src
- ./frontend/src/types:/opt/backstory/frontend/src/types # Live mount of types for pydantic->ts
- ./venv:/opt/backstory/venv:rw # Live mount for python venv
cap_add: # used for running ze-monitor within container
- CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
@ -35,19 +51,25 @@ services:
build:
context: .
dockerfile: Dockerfile
target: backstory
image: backstory
target: backstory-prod
#image: backstory
container_name: backstory-prod
restart: "always"
env_file:
- .env
environment:
- PRODUCTION=1
- FRONTEND_URL=${FRONTEND_URL:-https://backstory.ketrenos.com}
- REDIS_URL=redis://redis:6379
- REDIS_DB=1
- SSL_ENABLED=false
- DEFAULT_LLM_PROVIDER=ollama
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- OLLAMA_HOST=http://battle-linux.ketrenos.com:11434
devices:
- /dev/dri:/dev/dri
depends_on:
- ollama
- redis
networks:
- internal
ports:
@ -64,13 +86,46 @@ services:
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
- CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check
redis:
image: redis:7-alpine
container_name: redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
networks:
- internal
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Redis Commander for GUI management
redis-commander:
image: rediscommander/redis-commander:latest
container_name: redis-commander
ports:
- "8081:8081"
environment:
- REDIS_HOSTS=redis:redis:6379
networks:
- internal
depends_on:
- redis
profiles:
- tools # Only start with --profile tools
frontend:
build:
context: .
dockerfile: Dockerfile
target: frontend
container_name: frontend
image: frontend
#image: frontend
restart: "always"
env_file:
- .env
@ -81,39 +136,12 @@ services:
networks:
- internal
ollama:
build:
context: .
dockerfile: Dockerfile
target: ollama
image: ollama
container_name: ollama
restart: "always"
env_file:
- .env
environment:
- OLLAMA_HOST=0.0.0.0
- ONEAPI_DEVICE_SELECTOR=level_zero:0
devices:
- /dev/dri:/dev/dri
ports:
- 11434:11434 # ollama serve port
networks:
- internal
volumes:
- ./cache:/root/.cache # Cache hub models and neo_compiler_cache
- ./ollama:/root/.ollama # Cache the ollama models
cap_add: # used for running ze-monitor within container
- CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
- CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check
jupyter:
build:
context: .
dockerfile: Dockerfile
target: jupyter
image: jupyter
#image: jupyter
container_name: jupyter
restart: "always"
env_file:
@ -121,7 +149,6 @@ services:
devices:
- /dev/dri:/dev/dri
depends_on:
- ollama
- miniircd
ports:
- 8888:8888 # Jupyter Notebook
@ -137,7 +164,7 @@ services:
context: .
dockerfile: Dockerfile
target: miniircd
image: miniircd
#image: miniircd
container_name: miniircd
restart: "no"
env_file:
@ -198,3 +225,7 @@ networks:
internal:
driver: bridge
volumes:
redis_data:
driver: local

6
frontend/.eslintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
build
coverage
src/types/*
src/services/*

32
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,32 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"rules": {
"react/react-in-jsx-scope": "off",
"prettier/prettier": ["error", { "arrowParens": "avoid" }],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
},
"settings": {
"react": {
"version": "detect"
}
}
}

2
frontend/.gitignore vendored
View File

@ -1,4 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.prettied-it
build
deployed

6
frontend/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
src/types/*
node_modules
build
dist
coverage

9
frontend/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@ -14,35 +14,46 @@ module.exports = {
throw new Error('webpack-dev-server is not defined');
}
devServer.app.use('/api', createProxyMiddleware({
target: 'https://backstory:8911',
changeOrigin: true,
secure: false,
buffer: false,
proxyTimeout: 3600000,
onProxyRes: function(proxyRes, req, res) {
// Remove any header that might cause buffering
proxyRes.headers['transfer-encoding'] = 'chunked';
delete proxyRes.headers['content-length'];
devServer.app.use(
'/api',
createProxyMiddleware({
target: 'https://backstory:8911',
changeOrigin: true,
secure: false,
buffer: false,
proxyTimeout: 3600000,
onProxyRes: function (proxyRes, req, res) {
proxyRes.headers['cache-control'] = 'no-cache';
// Set proper streaming headers
proxyRes.headers['cache-control'] = 'no-cache';
proxyRes.headers['content-type'] = 'text/event-stream';
proxyRes.headers['connection'] = 'keep-alive';
},
}));
if (
req.url.includes('/docs') ||
req.url.includes('/redoc') ||
req.url.includes('/openapi.json')
) {
return; // Let original headers pass through
}
// Remove any header that might cause buffering
proxyRes.headers['transfer-encoding'] = 'chunked';
delete proxyRes.headers['content-length'];
// Set proper streaming headers
proxyRes.headers['cache-control'] = 'no-cache';
proxyRes.headers['content-type'] = 'text/event-stream';
proxyRes.headers['connection'] = 'keep-alive';
},
})
);
return middlewares;
}
},
webpack: {
configure: (webpackConfig) => {
webpackConfig.devtool = 'source-map';
// Add .ts and .tsx to resolve.extensions
webpackConfig.resolve.extensions = [
...webpackConfig.resolve.extensions,
'.ts',
'.tsx',
];
webpackConfig.resolve.extensions = [...webpackConfig.resolve.extensions, '.ts', '.tsx'];
// Ignore source map warnings for node_modules
webpackConfig.ignoreWarnings = [/Failed to parse source map/];
return webpackConfig;
},
},

File diff suppressed because it is too large Load Diff

View File

@ -15,35 +15,51 @@
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/lodash": "^4.17.17",
"@types/luxon": "^3.6.2",
"@types/node": "^16.18.126",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@uiw/react-json-view": "^2.0.0-alpha.31",
"@uiw/react-markdown-editor": "^6.1.4",
"country-state-city": "^3.2.1",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"jsonrepair": "^3.12.0",
"libphonenumber-js": "^1.12.9",
"lodash": "^4.17.21",
"lucide-react": "^0.511.0",
"luxon": "^3.6.1",
"markdown-it": "^14.1.0",
"mermaid": "^11.6.0",
"mui-markdown": "^2.0.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-diff-view": "^3.3.1",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-markdown-it": "^1.0.2",
"react-phone-number-input": "^3.4.12",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
"react-scripts": "^5.0.1",
"react-spinners": "^0.15.0",
"react-to-print": "^3.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"source-map-explorer": "^2.5.3",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "WDS_SOCKET_HOST=backstory-beta.ketrenos.com WDS_SOCKET_PORT=443 craco start",
"build": "craco build",
"test": "craco test"
"test": "craco test",
"lint": "eslint src/**/*.{ts,tsx} --no-color",
"lint:fix": "eslint src/**/*.{ts,tsx} --fix --no-color",
"format": "prettier --write src/**/*.{ts,tsx}"
},
"eslintConfig": {
"extends": [
@ -66,6 +82,14 @@
"devDependencies": {
"@craco/craco": "^7.1.0",
"@types/markdown-it": "^14.1.2",
"@types/plotly.js": "^2.35.5"
"@types/plotly.js": "^2.35.5",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8"
}
}

7
frontend/pretty-it Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
if [[ ! -e .prettied-it ]]; then
find src -name '*tsx' | while read file; do npx prettier --write $file; done
else
find src -name '*tsx' -newer .prettied-it | while read file; do npx prettier --write $file; done
fi
touch .prettied-it

View File

@ -0,0 +1,307 @@
This documents all authentication flows in Backstory. Here are the key flows explained:
# 🔐 Core Authentication Flows
1. Registration & Email Verification
`Registration → Email Sent → Email Verification → Account Activation → Login`
Includes resend verification with rate limiting
Handles expired tokens and error cases
2. Login on Trusted Device
`Login → Credentials Check → Device Trust Check → Immediate Access`
Fastest path with access/refresh tokens issued immediately
3. Login on New Device (MFA)
`Login → Credentials Check → New Device Detected → Auto-send MFA Email → MFA Dialog → Code Verification → Access Granted`
Optional device trust for future logins
4. App Initialization & Token Management
`App Start → Check Tokens → Auto-refresh if needed → Load Dashboard`
Handles expired tokens gracefully
# 🛡️ Security Features Covered
## Rate Limiting & Protection
* Login attempt limiting
* MFA resend limiting (max 3)
* Verification email rate limiting
* Account lockout for abuse
## Token Management
* Access token expiration handling
* Refresh token rotation
* Token blacklisting on logout
* Force logout on revoked tokens
## Device Security
* Device fingerprinting
* Trusted device management
* MFA for new devices
* Device removal capabilities
# 🔄 Key Decision Points
1. Has Valid Tokens? → Dashboard vs Login
2. Trusted Device? → Immediate access vs MFA
3. Account Active? → Login vs Error message
4. MFA Code Valid? → Success vs Retry/Lock
5. Token Expired? → Refresh vs Re-login
# 📱 User Experience Flows
## Happy Path (Returning User)
`App Start → Valid Tokens → Dashboard (2 steps)`
## New User Journey
`Registration → Email Verification → Login → Dashboard (4 steps)`
## New Device Login
`Login → MFA Email → Code Entry → Dashboard (3 steps)`
## 🔧 Implementation Notes
**Background Tasks**: Email sending doesn't block user flow
**Error Recovery**: Clear paths back to working states
**Admin Features**: User management and security monitoring
**Future Features**: Password reset flow is mapped out
# Flow Diagram
This diagram serves as the complete authentication architecture reference, showing every possible user journey and system state transition.
```
flowchart TD
%% ================================
%% REGISTRATION FLOWS
%% ================================
Start([User Visits App]) --> CheckTokens{Has Valid Tokens?}
CheckTokens -->|Yes| LoadUser[Load User Profile]
CheckTokens -->|No| LandingPage[Landing Page]
LandingPage --> RegisterChoice{Registration Type}
RegisterChoice --> CandidateReg[Candidate Registration Form]
RegisterChoice --> EmployerReg[Employer Registration Form]
RegisterChoice --> LoginPage[Login Page]
%% Candidate Registration Flow
CandidateReg --> CandidateValidation{Form Valid?}
CandidateValidation -->|No| CandidateReg
CandidateValidation -->|Yes| CandidateSubmit[POST /candidates]
CandidateSubmit --> CandidateCheck{User Exists?}
CandidateCheck -->|Yes| CandidateError[Show Error: User Exists]
CandidateError --> CandidateReg
CandidateCheck -->|No| CandidateEmailSent[Auto-send Verification Email]
CandidateEmailSent --> CandidateSuccess[Show Success Dialog]
%% Employer Registration Flow
EmployerReg --> EmployerValidation{Form Valid?}
EmployerValidation -->|No| EmployerReg
EmployerValidation -->|Yes| EmployerSubmit[POST /employers]
EmployerSubmit --> EmployerCheck{User Exists?}
EmployerCheck -->|Yes| EmployerError[Show Error: User Exists]
EmployerError --> EmployerReg
EmployerCheck -->|No| EmployerEmailSent[Auto-send Verification Email]
EmployerEmailSent --> EmployerSuccess[Show Success Dialog]
%% Email Verification Flow
CandidateSuccess --> CheckEmail[User Checks Email]
EmployerSuccess --> CheckEmail
CheckEmail --> ClickLink[Click Verification Link]
ClickLink --> VerifyEmail[GET /verify-email?token=xxx]
VerifyEmail --> TokenValid{Token Valid & Not Expired?}
TokenValid -->|No| VerifyError[Show Error: Invalid/Expired Token]
TokenValid -->|Yes| ActivateAccount[Activate Account in DB]
ActivateAccount --> VerifySuccess[Show Success: Account Activated]
VerifySuccess --> RedirectLogin[Redirect to Login]
%% Resend Verification
VerifyError --> ResendOption{Resend Verification?}
ResendOption -->|Yes| ResendEmail[POST /auth/resend-verification]
ResendEmail --> RateLimitCheck{Within Rate Limits?}
RateLimitCheck -->|No| ResendError[Show Rate Limit Error]
RateLimitCheck -->|Yes| FindPending{Pending Verification Found?}
FindPending -->|No| ResendGeneric[Generic Success Message]
FindPending -->|Yes| ResendSuccess[New Email Sent]
ResendSuccess --> CheckEmail
ResendGeneric --> CheckEmail
ResendOption -->|No| RegisterChoice
%% ================================
%% LOGIN FLOWS
%% ================================
RedirectLogin --> LoginPage
LoginPage --> LoginForm[Enter Email/Password]
LoginForm --> LoginSubmit[POST /auth/login]
LoginSubmit --> CredentialsValid{Credentials Valid?}
CredentialsValid -->|No| LoginError[Show Login Error]
LoginError --> LoginForm
CredentialsValid -->|Yes| AccountActive{Account Active?}
AccountActive -->|No| AccountError[Show Account Status Error]
AccountError --> LoginForm
AccountActive -->|Yes| DeviceCheck{Trusted Device?}
%% Trusted Device Flow
DeviceCheck -->|Yes| TrustedLogin[Update Last Login]
TrustedLogin --> IssueTokens[Issue Access + Refresh Tokens]
IssueTokens --> LoginSuccess[Store Tokens Locally]
LoginSuccess --> LoadUser
%% New Device Flow (MFA Required)
DeviceCheck -->|No| NewDevice[Detect New Device]
NewDevice --> GenerateMFA[Generate 6-digit MFA Code]
GenerateMFA --> SendMFAEmail[Auto-send MFA Email]
SendMFAEmail --> MFAResponse[Return MFA Required Response]
MFAResponse --> ShowMFADialog[Show MFA Input Dialog]
ShowMFADialog --> MFAInput[User Enters 6-digit Code]
MFAInput --> MFASubmit[POST /auth/mfa/verify]
MFASubmit --> MFAValid{Code Valid & Not Expired?}
MFAValid -->|No| MFAError[Show MFA Error]
MFAError --> MFARetry{Attempts < 5?}
MFARetry -->|Yes| MFAInput
MFARetry -->|No| MFALocked[Lock MFA Session]
MFALocked --> LoginForm
MFAValid -->|Yes| RememberDevice{Remember Device?}
RememberDevice -->|Yes| AddTrustedDevice[Add to Trusted Devices]
RememberDevice -->|No| SkipTrust[Skip Adding Device]
AddTrustedDevice --> MFASuccess[Update Last Login]
SkipTrust --> MFASuccess
MFASuccess --> IssueTokens
%% MFA Resend Flow
ShowMFADialog --> MFAResend{Need Resend?}
MFAResend -->|Yes| ResendMFA[POST /auth/mfa/resend]
ResendMFA --> ResendLimit{< 3 Resends?}
ResendLimit -->|No| ResendLocked[Max Resends Reached]
ResendLocked --> LoginForm
ResendLimit -->|Yes| NewMFACode[Generate New Code]
NewMFACode --> SendNewMFA[Send New Email]
SendNewMFA --> ShowMFADialog
%% ================================
%% APP INITIALIZATION & TOKEN MANAGEMENT
%% ================================
LoadUser --> TokenExpired{Access Token Expired?}
TokenExpired -->|No| Dashboard[Load Dashboard]
TokenExpired -->|Yes| RefreshCheck{Has Refresh Token?}
RefreshCheck -->|No| ClearTokens[Clear Local Storage]
ClearTokens --> LandingPage
RefreshCheck -->|Yes| RefreshAttempt[POST /auth/refresh]
RefreshAttempt --> RefreshValid{Refresh Token Valid?}
RefreshValid -->|No| ClearTokens
RefreshValid -->|Yes| NewTokens[Issue New Access Token]
NewTokens --> UpdateStorage[Update Local Storage]
UpdateStorage --> Dashboard
%% ================================
%% LOGOUT FLOWS
%% ================================
Dashboard --> LogoutChoice{Logout Type}
LogoutChoice --> SingleLogout[Logout This Device]
LogoutChoice --> LogoutAll[Logout All Devices]
SingleLogout --> LogoutRequest[POST /auth/logout]
LogoutRequest --> BlacklistTokens[Blacklist Tokens]
BlacklistTokens --> LogoutComplete[Clear Local Storage]
LogoutAll --> LogoutAllRequest[POST /auth/logout-all]
LogoutAllRequest --> RevokeAllTokens[Revoke All User Tokens]
RevokeAllTokens --> LogoutComplete
LogoutComplete --> LandingPage
%% ================================
%% ERROR HANDLING & EDGE CASES
%% ================================
Dashboard --> TokenRevoked{Token Blacklisted?}
TokenRevoked -->|Yes| ForceLogout[Force Logout]
ForceLogout --> ClearTokens
%% Rate Limiting
LoginForm --> RateLimit{Too Many Attempts?}
RateLimit -->|Yes| AccountLock[Temporary Account Lock]
AccountLock --> LockMessage[Show Lockout Message]
LockMessage --> WaitPeriod[Wait for Unlock]
WaitPeriod --> LoginForm
%% Network Errors
LoginSubmit --> NetworkError{Network Error?}
NetworkError -->|Yes| RetryLogin[Show Retry Option]
RetryLogin --> LoginForm
%% ================================
%% ADMIN FLOWS (Optional)
%% ================================
Dashboard --> AdminCheck{Is Admin?}
AdminCheck -->|Yes| AdminPanel[Admin Panel]
AdminPanel --> ManageVerifications[Manage Pending Verifications]
AdminPanel --> ViewSecurityLogs[View Security Logs]
AdminPanel --> ManageUsers[Manage User Accounts]
AdminCheck -->|No| Dashboard
%% ================================
%% PASSWORD RESET FLOW (Future)
%% ================================
LoginForm --> ForgotPassword[Forgot Password Link]
ForgotPassword --> ResetEmail[Enter Email for Reset]
ResetEmail --> ResetRequest[POST /auth/password-reset/request]
ResetRequest --> ResetEmailSent[Password Reset Email Sent]
ResetEmailSent --> ResetLink[Click Reset Link in Email]
ResetLink --> ResetForm[Enter New Password]
ResetForm --> ResetSubmit[POST /auth/password-reset/confirm]
ResetSubmit --> ResetSuccess[Password Reset Successfully]
ResetSuccess --> LoginForm
%% ================================
%% DEVICE MANAGEMENT
%% ================================
Dashboard --> DeviceSettings[Device Settings]
DeviceSettings --> ViewDevices[View Trusted Devices]
ViewDevices --> RemoveDevice[Remove Trusted Device]
RemoveDevice --> DeviceRemoved[Device Removed Successfully]
DeviceRemoved --> ViewDevices
%% ================================
%% STYLING
%% ================================
classDef startEnd fill:#e1f5fe
classDef process fill:#f3e5f5
classDef decision fill:#fff3e0
classDef error fill:#ffebee
classDef success fill:#e8f5e8
classDef security fill:#fce4ec
class Start,LandingPage startEnd
class LoginSuccess,VerifySuccess,MFASuccess,Dashboard success
class LoginError,VerifyError,MFAError,AccountError error
class DeviceCheck,TokenValid,CredentialsValid,MFAValid decision
class GenerateMFA,SendMFAEmail,BlacklistTokens security
```

View File

@ -0,0 +1,492 @@
# Type Safety Setup and Configuration
This document describes how to set up and maintain type consistency between the Python Pydantic backend and TypeScript frontend.
## Files Overview
### 1. TypeScript Types (`front/src/types/types.ts`)
- Complete TypeScript type definitions for all entities
- Includes enums, interfaces, and utility types
- Used by React components and API calls
### 2. Pydantic Models (`src/models.py`)
- Python data models with validation
- Backend API request/response validation
- Database schema definitions
### 3. Type Generation Tool (`src/generate_types.py`)
- Automated TypeScript generation from Pydantic models
- Keeps types in sync
- Watch mode for development
## Setup Instructions
### 2. Generate TypeScript Types
Run the type generation tool:
```bash
# One-time generation
docker compose exec backstory shell "python src/generate_types.py --source src/models.py --output frontend/src/types/types.ts"
# Watch mode for development
docker compose exec backstory shell "python src/generate_types.py --source src/models.py --output frontend/src/types/types.ts --watch"
```
### 3. API Client Setup
Create an API client that uses the types:
```typescript
// api/client.ts
import * as Types from '../types/types';
import { formatApiRequest, parseApiResponse } from '../types/conversion';
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async createCandidate(candidate: Types.Candidate): Promise<Types.Candidate> {
const response = await fetch(`${this.baseUrl}/candidates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formatApiRequest(candidate))
});
const data = await response.json();
const apiResponse = parseApiResponse<Types.Candidate>(data);
if (!apiResponse.success) {
throw new Error(apiResponse.error?.message || 'API request failed');
}
return apiResponse.data!;
}
async getCandidates(request: Types.PaginatedRequest): Promise<Types.PaginatedResponse<Types.Candidate>> {
const params = new URLSearchParams(formatApiRequest(request));
const response = await fetch(`${this.baseUrl}/candidates?${params}`);
const data = await response.json();
return parseApiResponse<Types.PaginatedResponse<Types.Candidate>>(data).data!;
}
}
export default ApiClient;
```
### 4. Backend API Setup
Use Pydantic models in your FastAPI/Flask routes:
```python
# api/routes.py (FastAPI example)
from fastapi import FastAPI, HTTPException
from typing import List
from models import Candidate, PaginatedRequest, PaginatedResponse, ApiResponse
app = FastAPI()
@app.post("/candidates", response_model=ApiResponse[Candidate])
async def create_candidate(candidate: Candidate):
try:
# Validate and save candidate
saved_candidate = await save_candidate(candidate)
return ApiResponse(success=True, data=saved_candidate)
except Exception as e:
return ApiResponse(success=False, error={"code": "CREATION_FAILED", "message": str(e)})
@app.get("/candidates", response_model=ApiResponse[PaginatedResponse[Candidate]])
async def get_candidates(request: PaginatedRequest):
try:
candidates = await fetch_candidates(request)
return ApiResponse(success=True, data=candidates)
except Exception as e:
return ApiResponse(success=False, error={"code": "FETCH_FAILED", "message": str(e)})
```
## Development Workflow
### 1. Making Changes
When you modify data structures:
1. **Update Pydantic models first** in `models.py`
2. **Regenerate TypeScript types** using the generation tool
3. **Update API endpoints** to use new models
4. **Update frontend components** to use new types
5. **Run validation** to ensure consistency
### 2. Type Generation Automation
For automatic type generation in development, add to your package.json:
```json
{
"scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:types\"",
"dev:frontend": "react-scripts start",
"dev:types": "python ../backend/pydantic_to_typescript.py --input ../backend/models.py --output src/types/types.ts --watch",
"generate-types": "python ../backend/pydantic_to_typescript.py --input ../backend/models.py --output src/types/types.ts"
}
}
```
### 3. Validation Strategy
Use validation at multiple layers:
```typescript
// Component validation example
import { validateData } from '../types/validation';
const UserForm: React.FC = () => {
const [formData, setFormData] = useState<Partial<Types.Candidate>>({});
const [errors, setErrors] = useState<ValidationError[]>([]);
const handleSubmit = async () => {
const validation = validateData<Types.Candidate>(formData, 'Candidate');
if (!validation.isValid) {
setErrors(validation.errors);
return;
}
try {
await apiClient.createCandidate(validation.data!);
// Success handling
} catch (error) {
// Error handling
}
};
return (
// Form JSX with error display
);
};
```
## Testing and Validation
### 1. Type Consistency Tests
Create tests to ensure types stay in sync:
```typescript
// tests/type-consistency.test.ts
import { candidateFromPydantic, candidateToPydantic } from '../types/conversion';
describe('Type Consistency', () => {
test('candidate conversion roundtrip', () => {
const originalCandidate: Types.Candidate = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: 'test@example.com',
userType: 'candidate',
firstName: 'John',
lastName: 'Doe',
skills: [],
experience: [],
education: [],
preferredJobTypes: ['full-time'],
location: { city: 'Austin', country: 'USA' },
languages: [],
certifications: [],
createdAt: new Date(),
updatedAt: new Date(),
status: 'active'
};
// Convert to Python format and back
const pythonFormat = candidateToPydantic(originalCandidate);
const backToTypeScript = candidateFromPydantic(pythonFormat);
expect(backToTypeScript).toEqual(originalCandidate);
});
test('validation consistency', () => {
const invalidCandidate = {
id: 'invalid-uuid',
email: 'not-an-email',
userType: 'invalid-type'
};
const validation = validateData<Types.Candidate>(invalidCandidate, 'Candidate');
expect(validation.isValid).toBe(false);
expect(validation.errors.length).toBeGreaterThan(0);
});
});
```
### 2. API Integration Tests
Test the full API integration:
```python
# tests/test_api_integration.py
import pytest
from fastapi.testclient import TestClient
from api.routes import app
from models import Candidate
client = TestClient(app)
def test_candidate_creation():
candidate_data = {
"email": "test@example.com",
"user_type": "candidate",
"first_name": "John",
"last_name": "Doe",
"skills": [],
"experience": [],
"education": [],
"preferred_job_types": ["full-time"],
"location": {"city": "Austin", "country": "USA"},
"languages": [],
"certifications": []
}
response = client.post("/candidates", json=candidate_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["email"] == candidate_data["email"]
def test_type_validation():
invalid_data = {
"email": "not-an-email",
"user_type": "invalid"
}
response = client.post("/candidates", json=invalid_data)
assert response.status_code == 422 # Validation error
```
## Best Practices
### 1. Field Naming Conventions
- **TypeScript**: Use `camelCase` for consistency with JavaScript conventions
- **Python**: Use `snake_case` for consistency with Python conventions
- **API**: Always use the conversion utilities to transform between formats
### 2. Date Handling
```typescript
// Always use Date objects in TypeScript
const user: Types.BaseUser = {
// ...
createdAt: new Date(),
updatedAt: new Date()
};
// Convert to ISO string for API
const apiData = formatApiRequest(user);
// apiData.created_at will be "2024-01-01T00:00:00.000Z"
```
```python
# Use datetime objects in Python
from datetime import datetime
from models import BaseUser
user = BaseUser(
# ...
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
```
### 3. Optional Fields
Handle optional fields consistently:
```typescript
// TypeScript - use optional chaining
const user: Types.Candidate = getUser();
const profileImage = user.profileImage ?? '/default-avatar.png';
```
```python
# Python - use Optional type hints
from typing import Optional
from pydantic import BaseModel
class User(BaseModel):
profile_image: Optional[str] = None
```
### 4. Enum Synchronization
Keep enums in sync between TypeScript and Python:
```typescript
// TypeScript
export type UserStatus = 'active' | 'inactive' | 'pending' | 'banned';
```
```python
# Python
from enum import Enum
class UserStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
BANNED = "banned"
```
## CI/CD Integration
### 1. Type Generation in CI
Add type generation to your CI pipeline:
```yaml
# .github/workflows/ci.yml
name: CI/CD Pipeline
on: [push, pull_request]
jobs:
type-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install Python dependencies
run: pip install pydantic typing-inspect jinja2
- name: Generate TypeScript types
run: python backend/pydantic_to_typescript.py --input backend/models.py --output frontend/src/types/types.ts
- name: Check for type changes
run: |
if git diff --exit-code frontend/src/types/types.ts; then
echo "Types are in sync"
else
echo "Types are out of sync!"
exit 1
fi
test:
needs: type-sync
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# ... rest of test steps
```
### 2. Pre-commit Hooks
Set up pre-commit hooks to ensure types stay in sync:
```yaml
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: generate-types
name: Generate TypeScript types
entry: python backend/pydantic_to_typescript.py --input backend/models.py --output frontend/src/types/types.ts
language: system
files: backend/models.py
pass_filenames: false
- id: validate-types
name: Validate type consistency
entry: npm run test:types
language: system
files: 'frontend/src/types/.*\.(ts|js)
pass_filenames: false
```
## Troubleshooting
### Common Issues
1. **Date Serialization Errors**
- Ensure dates are converted to ISO strings before API calls
- Use the conversion utilities consistently
2. **Field Name Mismatches**
- Always use the conversion utilities
- Check that Pydantic field aliases match TypeScript property names
3. **Type Generation Failures**
- Ensure all dependencies are installed
- Check that the input Python file is valid
- Verify that all Pydantic models extend BaseModel
4. **Validation Inconsistencies**
- Keep validation rules in sync between TypeScript and Python
- Use the same enum values and constraints
### Debugging Tools
1. **Type Validation Debugging**
```typescript
// Add debug logging
const validation = validateData<Types.Candidate>(data, 'Candidate');
if (!validation.isValid) {
console.log('Validation errors:', validation.errors);
validation.errors.forEach(error => {
console.log(`Field: ${error.field}, Message: ${error.message}, Value:`, error.value);
});
}
```
2. **Conversion Debugging**
```typescript
// Log conversion results
const originalData = { firstName: 'John', lastName: 'Doe' };
const converted = toSnakeCase(originalData);
console.log('Original:', originalData);
console.log('Converted:', converted);
const backConverted = toCamelCase(converted);
console.log('Back converted:', backConverted);
```
## Performance Considerations
### 1. Type Generation
- Run type generation only when models change
- Use file watching in development
- Cache generated types in production builds
### 2. Validation
- Use validation strategically (form submission, API boundaries)
- Consider lazy validation for large datasets
- Cache validation results when appropriate
### 3. Conversion
- Minimize conversions in hot paths
- Consider keeping data in the appropriate format for the layer
- Use object pooling for frequently converted objects
## Maintenance
### Regular Tasks
1. **Weekly**: Review type generation logs for any issues
2. **Monthly**: Update dependencies and test compatibility
3. **Quarterly**: Review and optimize validation rules
4. **Annually**: Evaluate new tools and migration paths
### Monitoring
Set up monitoring for:
- Type generation failures
- Validation error rates
- API request/response format mismatches
- Performance impacts of type operations
This setup ensures type safety and consistency across your full-stack application while maintaining developer productivity and code quality.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -7,7 +7,7 @@
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Interactive chat with an enhanced LLM."
content="Your complete professional story, beyond a single page."
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--

View File

@ -1,6 +1,6 @@
{
"short_name": "ai.ketrenos.com",
"name": "Ketrenos AI Chat",
"short_name": "backstory.ketrenos.com",
"name": "Backstory",
"icons": [
{
"src": "favicon.ico",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

View File

@ -1,261 +0,0 @@
.App {
overflow: hidden;
}
div {
box-sizing: border-box;
overflow-wrap: break-word;
word-break: break-word;
}
.gl-container #scene {
top: 0px !important;
left: 0px !important;
}
pre {
max-width: 100%;
max-height: 100%;
overflow: auto;
white-space: pre-wrap;
box-sizing: border-box;
border: 3px solid #E0E0E0;
}
button {
overflow-wrap: initial;
word-break: initial;
}
.TabPanel {
display: flex;
height: 100%;
}
.MuiToolbar-root .MuiBox-root {
border-bottom: none;
}
.MuiTabs-root .MuiTabs-indicator {
background-color: orange;
}
.SystemInfo {
display: flex;
flex-direction: column;
gap: 5px;
padding: 5px;
flex-grow: 1;
}
.SystemInfoItem {
display: flex; /* Grid for individual items */
flex-direction: row;
flex-grow: 1;
}
.SystemInfoItem > div:first-child {
display: flex;
justify-self: end; /* Align the first column content to the right */
width: 10rem;
}
.SystemInfoItem > div:last-child {
display: flex;
flex-grow: 1;
justify-self: end; /* Align the first column content to the right */
}
.DocBox {
display: flex;
flex-direction: column;
flex-grow: 1;
max-width: 2048px;
margin: 0 auto;
}
.Controls {
display: flex;
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
overflow-y: auto;
padding: 10px;
flex-direction: column;
margin-left: 10px;
box-sizing: border-box;
overflow-x: visible;
min-width: 10rem;
flex-grow: 1;
}
.MessageContent div > p:first-child {
margin-top: 0;
}
.MenuCard.MuiCard-root {
display: flex;
flex-direction: column;
min-width: 10rem;
flex-grow: 1;
background-color: #1A2536; /* Midnight Blue */
color: #D3CDBF; /* Warm Gray */
border-radius: 0;
}
.MenuCard.MuiCard-root button {
min-height: 64px;
}
/* Prevent toolbar from shrinking vertically when media < 600px */
.MuiToolbar-root {
min-height: 72px !important;
padding-left: 16px !important;
padding-right: 16px !important;
}
.ChatBox {
display: flex;
flex-direction: column;
flex-grow: 1;
max-width: 1024px;
width: 100%;
margin: 0 auto;
background-color: #D3CDBF;
}
.user-message.MuiCard-root {
background-color: #DCF8C6;
border: 1px solid #B2E0A7;
color: #333333;
margin-bottom: 0.75rem;
margin-left: 1rem;
border-radius: 0.25rem;
min-width: 80%;
max-width: 80%;
justify-self: right;
display: flex;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
flex-direction: column;
align-items: self-end;
align-self: end;
flex-grow: 0;
}
.About.MuiCard-root,
.assistant-message.MuiCard-root {
border: 1px solid #E0E0E0;
background-color: #FFFFFF;
color: #333333;
margin-bottom: 0.75rem;
margin-right: 1rem;
min-width: 70%;
border-radius: 0.25rem;
justify-self: left;
display: flex;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
flex-direction: column;
flex-grow: 0;
padding: 16px 0;
font-size: 0.9rem;
}
.About.MuiCard-root {
display: flex;
flex-grow: 1;
width: 100%;
margin-left: 0;
margin-right: 0;
}
.About .MuiCardContent-root,
.assistant-message .MuiCardContent-root {
padding: 0 16px !important;
font-size: 0.9rem;
}
.About span,
.assistant-message span {
font-size: 0.9rem;
}
.user-message .MuiCardContent-root:last-child,
.assistant-message .MuiCardContent-root:last-child,
.About .MuiCardContent-root:last-child {
padding: 16px;
}
.users > div {
padding: 0.25rem;
}
.user-active {
font-weight: bold;
}
.metadata {
border: 1px solid #E0E0E0;
font-size: 0.75rem;
padding: 0.125rem;
}
/* Reduce general whitespace in markdown content */
* p.MuiTypography-root {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
/* Reduce space between headings and content */
* h1.MuiTypography-root,
* h2.MuiTypography-root,
* h3.MuiTypography-root,
* h4.MuiTypography-root,
* h5.MuiTypography-root,
* h6.MuiTypography-root {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-size: 1rem;
}
/* Reduce space in lists */
* ul.MuiTypography-root,
* ol.MuiTypography-root {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
* li.MuiTypography-root {
margin-bottom: 0.25rem;
font-size: 0.9rem;
}
* .MuiTypography-root li {
margin-top: 0;
margin-bottom: 0;
padding: 0;
font-size: 0.9rem;
}
/* Reduce space around code blocks */
* .MuiTypography-root pre {
border: 1px solid #F5F5F5;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
margin-top: 0;
margin-bottom: 0;
font-size: 0.9rem;
}
.PromptStats .MuiTableCell-root {
font-size: 0.8rem;
}
#SystemPromptInput {
font-size: 0.9rem;
line-height: 1.25rem;
}

View File

@ -1,330 +0,0 @@
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import useMediaQuery from '@mui/material/useMediaQuery';
import Card from '@mui/material/Card';
import { styled } from '@mui/material/styles';
import Avatar from '@mui/material/Avatar';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Tooltip from '@mui/material/Tooltip';
import AppBar from '@mui/material/AppBar';
import Drawer from '@mui/material/Drawer';
import Toolbar from '@mui/material/Toolbar';
import SettingsIcon from '@mui/icons-material/Settings';
import IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import MenuIcon from '@mui/icons-material/Menu';
import { ConversationHandle } from '../Components/Conversation';
import { Query } from '../Components/ChatQuery';
import { Scrollable } from '../Components/Scrollable';
import { BackstoryPage, BackstoryTabProps } from '../Components/BackstoryTab';
import { HomePage } from '../Pages/HomePage';
import { LoadingPage } from '../Pages/LoadingPage';
import { ResumeBuilderPage } from '../Pages/ResumeBuilderPage';
import { VectorVisualizerPage } from '../Pages/VectorVisualizerPage';
import { AboutPage } from '../Pages/AboutPage';
import { ControlsPage } from '../Pages/ControlsPage';
import { SetSnackType } from '../Components/Snack';
import './Main.css';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
interface MainProps {
sessionId: string,
setSnack: SetSnackType
}
const Main = (props: MainProps) => {
const { sessionId } = props;
const navigate = useNavigate();
const location = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [activeTab, setActiveTab] = useState<number>(0);
const [tab, setTab] = useState<any>(undefined);
const isDesktop = useMediaQuery('(min-width:650px)');
const prevIsDesktopRef = useRef<boolean>(isDesktop);
const chatRef = useRef<ConversationHandle>(null);
const [subRoute, setSubRoute] = useState<string>("");
const backstoryProps = useMemo(() => {
const handleSubmitChatQuery = (query: Query) => {
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
setActiveTab(0);
};
return { ...props, route: subRoute, setRoute: setSubRoute, submitQuery: handleSubmitChatQuery };
}, [props, setActiveTab, subRoute]);
useEffect(() => {
if (prevIsDesktopRef.current === isDesktop)
return;
if (menuOpen) {
setMenuOpen(false);
}
prevIsDesktopRef.current = isDesktop;
}, [isDesktop, setMenuOpen, menuOpen])
const tabs: BackstoryTabProps[] = useMemo(() => {
const homeTab: BackstoryTabProps = {
label: "",
path: "",
tabProps: {
label: "Backstory",
sx: { flexGrow: 1, fontSize: '1rem' },
icon:
<Avatar sx={{
width: 24,
height: 24
}}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />,
iconPosition: "start"
},
children: <HomePage ref={chatRef} {...backstoryProps} />
};
const loadingTab: BackstoryTabProps = {
...homeTab,
children: <LoadingPage {...backstoryProps} />
};
const resumeBuilderTab: BackstoryTabProps = {
label: "Resume Builder",
path: "resume-builder",
children: <ResumeBuilderPage {...backstoryProps} />
};
const contextVisualizerTab: BackstoryTabProps = {
label: "Context Visualizer",
path: "context-visualizer",
children: <VectorVisualizerPage sx={{ p: 1 }} {...backstoryProps} />
};
const aboutTab = {
label: "About",
path: "about",
children: <AboutPage {...backstoryProps} />
};
const controlsTab: BackstoryTabProps = {
path: "controls",
tabProps: {
sx: { flexShrink: 1, flexGrow: 0, fontSize: '1rem' },
icon: <SettingsIcon />
},
children: (
<Scrollable
autoscroll={false}
sx={{
maxWidth: "1024px",
height: "calc(100vh - 72px)",
flexDirection: "column",
margin: "0 auto",
p: 1,
}}
>
<ControlsPage {...backstoryProps} />
</Scrollable>
)
};
if (sessionId === undefined || !sessionId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
return [loadingTab];
} else {
return [
homeTab,
resumeBuilderTab,
contextVisualizerTab,
aboutTab,
controlsTab,
];
}
}, [backstoryProps, sessionId]);
const handleMenuClose = () => {
setIsMenuClosing(true);
setMenuOpen(false);
};
const handleMenuTransitionEnd = () => {
setIsMenuClosing(false);
};
const handleMenuToggle = () => {
if (!isMenuClosing) {
setMenuOpen(!menuOpen);
}
};
useEffect(() => {
if (tab === undefined || tab === tabs[activeTab]) {
return;
}
setTab(tabs[activeTab]);
setSubRoute("");
}, [tabs, activeTab, tab]);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
if (newValue > tabs.length) {
console.log(`Invalid tab requested: ${newValue}`);
return;
}
setActiveTab(newValue);
handleMenuClose();
};
useEffect(() => {
if (sessionId === undefined || !sessionId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
return;
}
const pathParts = window.location.pathname.split('/').filter(Boolean);
const currentPath = pathParts.length < 2 ? '' : pathParts[0];
let currentSubRoute = pathParts.length > 2 ? pathParts.slice(1, -1).join('/') : '';
let tabIndex = tabs.findIndex((tab) => tab.path === currentPath);
if (tabIndex === -1) {
console.log(`Invalid path "${currentPath}" -- redirecting to default`);
tabIndex = 0
currentSubRoute = ""
}
setActiveTab(tabIndex);
setTab(tabs[tabIndex]);
setSubRoute(currentSubRoute);
console.log(`Initial load set to tab ${tabs[tabIndex].path} subRoute: ${currentSubRoute}`);
}, [tabs, sessionId]);
useEffect(() => {
if (tab === undefined || sessionId === undefined) {
return;
}
let path = tab.path ? `/${tab.path}` : '';
if (subRoute) path += `/${subRoute}`;
path += `/${sessionId}`;
if (path !== location.pathname) {
console.log(`Pusing state ${path}`)
navigate(path, { replace: true });
}
}, [tab, subRoute, sessionId, navigate, location.pathname]);
/* toolbar height is 64px + 8px margin-top */
const Offset = styled('div')(() => ({ minHeight: '72px', height: '72px' }));
return (
<Box className="App"
sx={{ display: 'flex', flexDirection: 'column' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
maxWidth: "100vw"
}}
>
<Toolbar>
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "row" }}>
{!isDesktop &&
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "row" }}>
<IconButton
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
color="inherit"
onClick={handleMenuToggle}
>
<Tooltip title="Navigation">
<MenuIcon />
</Tooltip>
</IconButton>
<Tooltip title="Backstory">
<Box
sx={{ m: 1, gap: 1, display: "flex", flexDirection: "row", alignItems: "center", fontWeight: "bold", fontSize: "1.0rem", cursor: "pointer" }}
onClick={() => { setActiveTab(0); setMenuOpen(false); }}
>
<Avatar sx={{
width: 24,
height: 24
}}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />
BACKSTORY
</Box>
</Tooltip>
</Box>
}
{menuOpen === false && isDesktop &&
<Tabs sx={{ display: "flex", flexGrow: 1 }}
value={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="fullWidth"
allowScrollButtonsMobile
onChange={handleTabChange}
aria-label="Backstory navigation">
{tabs.map((tab, index) => <Tab key={index} value={index} label={tab.label} {...tab.tabProps} />)}
</Tabs>
}
</Box>
</Toolbar>
</AppBar>
<Offset />
<Box sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }} >
<Drawer
container={window.document.body}
variant="temporary"
open={menuOpen}
onTransitionEnd={handleMenuTransitionEnd}
onClose={handleMenuClose}
sx={{
display: 'block',
'& .MuiDrawer-paper': { boxSizing: 'border-box' },
}}
slotProps={{
root: {
keepMounted: true, // Better open performance on mobile.
},
}}
>
<Toolbar />
<Card className="MenuCard">
<Tabs sx={{ display: "flex", flexGrow: 1 }}
orientation="vertical"
value={activeTab}
indicatorColor="secondary"
textColor="inherit"
variant="scrollable"
allowScrollButtonsMobile
onChange={handleTabChange}
aria-label="Backstory navigation">
{tabs.map((tab, index) => <Tab key={index} value={index} label={tab.label} {...tab.tabProps} />)}
</Tabs>
</Card>
</Drawer>
{
tabs.map((tab: any, i: number) =>
<BackstoryPage key={i} active={i === activeTab} path={tab.path}>{tab.children}</BackstoryPage>
)
}
</Box>
</Box >
);
};
export {
Main
}

View File

@ -1,98 +0,0 @@
import { useEffect, useState, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { connectionBase } from '../Global';
import { SetSnackType } from '../Components/Snack';
const getSessionId = async (userId?: string) => {
const endpoint = userId
? `/api/context/u/${encodeURIComponent(userId)}`
: `/api/context`;
const response = await fetch(connectionBase + endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw Error("Server is temporarily down.");
}
const newSession = (await response.json()).id;
console.log(`Session created: ${newSession}`);
return newSession;
};
interface SessionWrapperProps {
setSnack: SetSnackType;
children: React.ReactNode;
}
const SessionWrapper = ({ setSnack, children }: SessionWrapperProps) => {
const navigate = useNavigate();
const location = useLocation();
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const fetchingRef = useRef(false);
const [retry, setRetry] = useState<number>(0);
useEffect(() => {
console.log(`SessionWrapper: ${location.pathname}`);
const ensureSessionId = async () => {
const parts = location.pathname.split("/").filter(Boolean);
const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89ab][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/i;
// Case: path starts with "u/{USERID}"
if (parts.length >= 2 && parts[0] === "u") {
const userId = parts[1];
// Case: "u/{USERID}" - fetch session for this user
const activeSession = await getSessionId(userId);
setSessionId(activeSession);
// Append session to path
const newPath = [...parts, activeSession].join("/");
navigate(`/${activeSession}`, { replace: true });
return;
}
// Default case (original behavior)
const hasSession = parts.length !== 0 && pattern.test(parts[parts.length - 1]);
if (!hasSession) {
let activeSession = sessionId;
if (!activeSession) {
activeSession = await getSessionId();
setSessionId(activeSession);
}
const newPath = [...parts, activeSession].join("/");
navigate(`/${newPath}`, { replace: true });
}
};
if (!fetchingRef.current) {
fetchingRef.current = true;
ensureSessionId()
.catch((e) => {
console.error(e);
setSnack("Backstory is temporarily unavailable. Retrying in 5 seconds.", "warning");
setTimeout(() => {
fetchingRef.current = false;
setRetry(retry => retry + 1);
}, 5000);
})
.finally(() => {
if (fetchingRef.current) {
fetchingRef.current = false;
}
});
}
}, [location.pathname, navigate, setSnack, sessionId, retry]);
return <>{children}</>;
};
export { SessionWrapper };

View File

@ -15,7 +15,7 @@ pre {
overflow: auto;
white-space: pre-wrap;
box-sizing: border-box;
border: 3px solid #E0E0E0;
border: 3px solid #e0e0e0;
}
button {
@ -72,8 +72,8 @@ button {
.Controls {
display: flex;
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
overflow-y: auto;
padding: 10px;
flex-direction: column;
@ -93,8 +93,8 @@ button {
flex-direction: column;
min-width: 10rem;
flex-grow: 1;
background-color: #1A2536; /* Midnight Blue */
color: #D3CDBF; /* Warm Gray */
background-color: #1a2536; /* Midnight Blue */
color: #d3cdbf; /* Warm Gray */
border-radius: 0;
}
@ -115,12 +115,12 @@ button {
max-width: 1024px;
width: 100%;
margin: 0 auto;
background-color: #D3CDBF;
background-color: #d3cdbf;
}
.user-message.MuiCard-root {
background-color: #DCF8C6;
border: 1px solid #B2E0A7;
background-color: #dcf8c6;
border: 1px solid #b2e0a7;
color: #333333;
margin-bottom: 0.75rem;
margin-left: 1rem;
@ -140,8 +140,8 @@ button {
.Docs.MuiCard-root,
.assistant-message.MuiCard-root {
border: 1px solid #E0E0E0;
background-color: #FFFFFF;
border: 1px solid #e0e0e0;
background-color: #ffffff;
color: #333333;
margin-bottom: 0.75rem;
margin-right: 1rem;
@ -158,7 +158,6 @@ button {
font-size: 0.9rem;
}
.Docs.MuiCard-root {
display: flex;
flex-grow: 1;
@ -181,7 +180,7 @@ button {
.user-message .MuiCardContent-root:last-child,
.assistant-message .MuiCardContent-root:last-child,
.Docs .MuiCardContent-root:last-child {
padding: 16px;
padding: 16px;
}
.users > div {
@ -193,7 +192,7 @@ button {
}
.metadata {
border: 1px solid #E0E0E0;
border: 1px solid #e0e0e0;
font-size: 0.75rem;
padding: 0.125rem;
}
@ -206,7 +205,7 @@ button {
}
/* Reduce space between headings and content */
* h1.MuiTypography-root,
/* * h1.MuiTypography-root,
* h2.MuiTypography-root,
* h3.MuiTypography-root,
* h4.MuiTypography-root,
@ -215,12 +214,12 @@ button {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-size: 1rem;
}
} */
/* Reduce space in lists */
* ul.MuiTypography-root,
* ol.MuiTypography-root {
margin-top: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
@ -239,7 +238,7 @@ button {
/* Reduce space around code blocks */
* .MuiTypography-root pre {
border: 1px solid #F5F5F5;
border: 1px solid #f5f5f5;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
margin-top: 0;

View File

@ -0,0 +1,53 @@
import React, { useEffect, useState, useRef, JSX } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { backstoryTheme } from './BackstoryTheme';
import { ConversationHandle } from 'components/Conversation';
import { CandidateRoute } from 'routes/CandidateRoute';
import { BackstoryLayout } from 'components/layout/BackstoryLayout';
import { ChatQuery } from 'types/types';
import { AuthProvider } from 'hooks/AuthContext';
import { AppStateProvider } from 'hooks/GlobalContext';
import './BackstoryApp.css';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
const BackstoryApp = (): JSX.Element => {
const navigate = useNavigate();
const location = useLocation();
const chatRef = useRef<ConversationHandle>(null);
const submitQuery = (query: ChatQuery): void => {
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
navigate('/chat');
};
const [page, setPage] = useState<string>('');
useEffect(() => {
const currentRoute = location.pathname.split('/')[1]
? `/${location.pathname.split('/')[1]}`
: '/';
setPage(currentRoute);
}, [location.pathname]);
// Render appropriate routes based on user type
return (
<ThemeProvider theme={backstoryTheme}>
<AuthProvider>
<AppStateProvider>
<Routes>
<Route path="/u/:username" element={<CandidateRoute />} />
{/* Static/shared routes */}
<Route path="/*" element={<BackstoryLayout {...{ page, chatRef, submitQuery }} />} />
</Routes>
</AppStateProvider>
</AuthProvider>
</ThemeProvider>
);
};
export { BackstoryApp };

View File

@ -12,7 +12,7 @@ const backstoryTheme = createTheme({
},
text: {
primary: '#2E2E2E', // Charcoal Black
secondary: '#1A2536',//D3CDBF', // Warm Gray
secondary: '#1A2536', // Midnight Blue
},
background: {
default: '#D3CDBF', // Warm Gray
@ -34,9 +34,32 @@ const backstoryTheme = createTheme({
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
},
h2: {
fontSize: '1.75rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '1rem',
},
h3: {
fontSize: '1.5rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.75rem',
},
h4: {
fontSize: '1.25rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.5rem',
},
body1: {
fontSize: '1rem',
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.5rem',
},
body2: {
fontSize: '0.875rem',
color: '#2E2E2E', // Charcoal Black
},
},
components: {
@ -69,6 +92,31 @@ const backstoryTheme = createTheme({
},
},
},
MuiPaper: {
styleOverrides: {
root: {
// padding: '0.5rem',
borderRadius: '4px',
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: '0.5rem',
},
},
},
MuiListItem: {
styleOverrides: {
root: {
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity
},
},
},
},
},
});

View File

@ -1,54 +0,0 @@
import React, { ReactElement, JSXElementConstructor } from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { ChatSubmitQueryInterface } from './ChatQuery';
import { SetSnackType } from './Snack';
interface BackstoryElementProps {
sessionId: string,
setSnack: SetSnackType,
submitQuery: ChatSubmitQueryInterface,
sx?: SxProps<Theme>,
}
interface BackstoryPageProps extends BackstoryElementProps {
route?: string,
setRoute?: (route: string) => void,
};
interface BackstoryTabProps {
label?: string,
path: string,
children?: ReactElement<BackstoryPageProps>,
active?: boolean,
className?: string,
tabProps?: {
label?: string,
sx?: SxProps,
icon?: string | ReactElement<unknown, string | JSXElementConstructor<any>> | undefined,
iconPosition?: "bottom" | "top" | "start" | "end" | undefined
}
};
function BackstoryPage(props: BackstoryTabProps) {
const { className, active, children } = props;
return (
<Box
className={ className || "BackstoryTab"}
sx={{ "display": active ? "flex" : "none", p: 0, m: 0, borders: "none" }}
>
{children}
</Box>
);
}
export type {
BackstoryPageProps,
BackstoryTabProps,
BackstoryElementProps,
};
export {
BackstoryPage
}

View File

@ -1,146 +0,0 @@
import React, { useRef, useEffect, CSSProperties, KeyboardEvent, useState, useImperativeHandle } from 'react';
import { useTheme } from '@mui/material/styles';
import './BackstoryTextField.css';
// Define ref interface for exposed methods
interface BackstoryTextFieldRef {
getValue: () => string;
setValue: (value: string) => void;
getAndResetValue: () => string;
}
interface BackstoryTextFieldProps {
value?: string;
disabled?: boolean;
placeholder: string;
onEnter: (value: string) => void;
style?: CSSProperties;
}
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>((props, ref) => {
const {
value = '',
disabled = false,
placeholder,
onEnter,
style,
} = props;
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
const [editValue, setEditValue] = useState<string>(value);
// Sync editValue with prop value if it changes externally
useEffect(() => {
setEditValue(value || "");
}, [value]);
// Adjust textarea height based on content
useEffect(() => {
if (!textareaRef.current || !shadowRef.current) {
return;
}
const textarea = textareaRef.current;
const shadow = shadowRef.current;
// Set shadow value to editValue or placeholder if editValue is empty
shadow.value = editValue || placeholder;
// Ensure shadow textarea has same content-relevant styles
const computed = getComputedStyle(textarea);
shadow.style.width = computed.width; // Match width for accurate wrapping
shadow.style.fontSize = computed.fontSize;
shadow.style.lineHeight = computed.lineHeight;
shadow.style.fontFamily = computed.fontFamily;
shadow.style.letterSpacing = computed.letterSpacing;
shadow.style.wordSpacing = computed.wordSpacing;
// Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
// Reset height to auto to allow shrinking
textarea.style.height = 'auto';
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
});
// Cleanup RAF to prevent memory leaks
return () => cancelAnimationFrame(raf);
}, [editValue, placeholder]);
// Expose getValue method via ref
useImperativeHandle(ref, () => ({
getValue: () => editValue,
setValue: (value: string) => setEditValue(value),
getAndResetValue: () => { const _ev = editValue; setEditValue(''); return _ev; }
}));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline
onEnter(editValue);
setEditValue(''); // Clear textarea
}
};
const fullStyle: CSSProperties = {
display: 'flex',
flexGrow: 1,
width: '100%',
padding: '16.5px 14px',
resize: 'none',
overflow: 'hidden',
boxSizing: 'border-box',
minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding
lineHeight: '1.5',
borderRadius: '4px',
fontSize: '16px',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
...style,
};
return (
<>
<textarea
className="BackstoryTextField"
ref={textareaRef}
value={editValue}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
style={fullStyle}
/>
<textarea
className="BackgroundTextField"
ref={shadowRef}
aria-hidden="true"
style={{
...fullStyle,
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px', // No padding to match content height
margin: '0px',
border: '0px', // Remove border to avoid extra height
height: 'auto', // Allow natural height
minHeight: '0px',
}}
readOnly
tabIndex={-1}
/>
</>
);
});
export type {
BackstoryTextFieldRef
};
export { BackstoryTextField };

View File

@ -1,227 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { SxProps, Theme } from '@mui/material';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
import { MessageRoles } from './Message';
import { ErrorOutline, InfoOutline, Memory, Psychology, /* Stream, */ } from '@mui/icons-material';
interface ChatBubbleProps {
role: MessageRoles,
isInfo?: boolean;
children: React.ReactNode;
sx?: SxProps<Theme>;
className?: string;
title?: string;
expanded?: boolean;
expandable?: boolean;
onExpand?: (open: boolean) => void;
}
function ChatBubble(props: ChatBubbleProps) {
const { role, children, sx, className, title, onExpand, expandable, expanded } = props;
const theme = useTheme();
const defaultRadius = '16px';
const defaultStyle = {
padding: theme.spacing(1, 2),
fontSize: '0.875rem',
alignSelf: 'flex-start',
maxWidth: '100%',
minWidth: '100%',
height: 'fit-content',
'& > *': {
color: 'inherit',
overflow: 'hidden',
m: 0,
},
'& > :last-child': {
mb: 0,
m: 0,
p: 0,
},
};
const styles: any = {
assistant: {
...defaultStyle,
backgroundColor: theme.palette.primary.main,
border: `1px solid ${theme.palette.secondary.main}`,
borderRadius: `${defaultRadius} ${defaultRadius} ${defaultRadius} 0`,
color: theme.palette.primary.contrastText,
},
content: {
...defaultStyle,
backgroundColor: '#F5F2EA',
border: `1px solid ${theme.palette.custom.highlight}`,
borderRadius: 0,
alignSelf: 'center',
color: theme.palette.text.primary,
padding: '8px 8px',
marginBottom: '0px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)',
fontSize: '0.9rem',
lineHeight: '1.3',
fontFamily: theme.typography.fontFamily,
},
error: {
...defaultStyle,
backgroundColor: '#F8E7E7',
border: `1px solid #D83A3A`,
borderRadius: defaultRadius,
maxWidth: '90%',
minWidth: '90%',
alignSelf: 'center',
color: '#8B2525',
padding: '10px 16px',
boxShadow: '0 1px 3px rgba(216, 58, 58, 0.15)',
},
'fact-check': 'qualifications',
'job-description': 'content',
'job-requirements': 'qualifications',
info: {
...defaultStyle,
backgroundColor: '#BFD8D8',
border: `1px solid ${theme.palette.secondary.main}`,
borderRadius: defaultRadius,
color: theme.palette.text.primary,
opacity: 0.95,
},
processing: 'status',
qualifications: {
...defaultStyle,
backgroundColor: theme.palette.primary.light,
border: `1px solid ${theme.palette.secondary.main}`,
borderRadius: `${defaultRadius} ${defaultRadius} ${defaultRadius} 0`,
color: theme.palette.primary.contrastText,
},
resume: 'content',
searching: 'status',
status: {
...defaultStyle,
backgroundColor: 'rgba(74, 122, 125, 0.15)',
border: `1px solid ${theme.palette.secondary.light}`,
borderRadius: '4px',
maxWidth: '75%',
minWidth: '75%',
alignSelf: 'center',
color: theme.palette.secondary.dark,
fontWeight: 500,
fontSize: '0.95rem',
padding: '8px 12px',
opacity: 0.9,
transition: 'opacity 0.3s ease-in-out',
},
streaming: 'assistant',
system: {
...defaultStyle,
backgroundColor: '#EDEAE0',
border: `1px dashed ${theme.palette.custom.highlight}`,
borderRadius: defaultRadius,
maxWidth: '90%',
minWidth: '90%',
alignSelf: 'center',
color: theme.palette.text.primary,
fontStyle: 'italic',
},
thinking: 'status',
user: {
...defaultStyle,
backgroundColor: theme.palette.background.default,
border: `1px solid ${theme.palette.custom.highlight}`,
borderRadius: `${defaultRadius} ${defaultRadius} 0 ${defaultRadius}`,
alignSelf: 'flex-end',
color: theme.palette.primary.main,
},
};
// Resolve string references in styles
for (const [key, value] of Object.entries(styles)) {
if (typeof value === 'string') {
styles[key] = styles[value];
}
}
const icons: any = {
error: <ErrorOutline color="error" />,
info: <InfoOutline color="info" />,
processing: <LocationSearchingIcon />,
searching: <Memory />,
thinking: <Psychology />,
tooling: <LocationSearchingIcon />,
};
// Render Accordion for expandable content
if (expandable || title) {
// Determine if Accordion is controlled
const isControlled = typeof expanded === 'boolean' && typeof onExpand === 'function';
return (
<Accordion
expanded={isControlled ? expanded : undefined} // Omit expanded prop for uncontrolled
defaultExpanded={expanded} // Default to collapsed for uncontrolled Accordion
className={className}
onChange={(_event, newExpanded) => {
if (isControlled && onExpand) {
onExpand(newExpanded); // Call onExpand with new state
}
}}
sx={{ ...styles[role], ...sx }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
slotProps={{
content: {
sx: {
fontWeight: 'bold',
fontSize: '1.1rem',
m: 0,
p: 0,
display: 'flex',
justifyItems: 'center',
},
},
}}
>
{title || ''}
</AccordionSummary>
<AccordionDetails sx={{ mt: 0, mb: 0, p: 0, pl: 2, pr: 2 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
// Render non-expandable content
return (
<Box
className={className}
sx={{
...(role in styles ? styles[role] : styles['status']),
gap: 1,
display: 'flex',
...sx,
flexDirection: 'row',
}}
>
{icons[role] !== undefined && icons[role]}
<Box sx={{ p: 0, m: 0, gap: 0, display: 'flex', flexGrow: 1, flexDirection: 'column' }}>
{children}
</Box>
</Box>
);
}
export type {
ChatBubbleProps
};
export {
ChatBubble
};

View File

@ -1,53 +0,0 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
/* backstory/src/utils/message.py */
type Tunables = {
enable_rag?: boolean,
enable_tools?: boolean,
enable_context?: boolean,
};
/* backstory/src/server.py */
type Query = {
prompt: string,
tunables?: Tunables,
agent_options?: {},
};
type ChatSubmitQueryInterface = (query: Query) => void;
interface ChatQueryInterface {
query: Query,
submitQuery?: ChatSubmitQueryInterface
}
const ChatQuery = (props : ChatQueryInterface) => {
const { query, submitQuery } = props;
if (submitQuery === undefined) {
return (<Box>{query.prompt}</Box>);
}
return (
<Button variant="outlined" sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight,
m: 1
}}
size="small" onClick={(e: any) => { submitQuery(query); }}>
{query.prompt}
</Button>
);
}
export type {
ChatQueryInterface,
Query,
ChatSubmitQueryInterface,
Tunables,
};
export {
ChatQuery,
};

View File

@ -1,8 +0,0 @@
type ContextStatus = {
context_used: number,
max_context: number
};
export type {
ContextStatus
};

View File

@ -1,14 +0,0 @@
.Conversation {
display: flex;
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
flex-grow: 1;
padding: 10px;
flex-direction: column;
font-size: 0.9rem;
width: 100%;
margin: 0 auto;
overflow-y: auto;
height: calc(100vh - 72px);
}

View File

@ -1,625 +0,0 @@
import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send';
import CancelIcon from '@mui/icons-material/Cancel';
import { SxProps, Theme } from '@mui/material';
import PropagateLoader from "react-spinners/PropagateLoader";
import { Message, MessageList, BackstoryMessage } from './Message';
import { ContextStatus } from './ContextStatus';
import { Scrollable } from './Scrollable';
import { DeleteConfirmation } from './DeleteConfirmation';
import { Query } from './ChatQuery';
import './Conversation.css';
import { BackstoryTextField, BackstoryTextFieldRef } from './BackstoryTextField';
import { BackstoryElementProps } from './BackstoryTab';
import { connectionBase } from '../Global';
const loadingMessage: BackstoryMessage = { "role": "status", "content": "Establishing connection with server..." };
type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check';
interface ConversationHandle {
submitQuery: (query: Query) => void;
fetchHistory: () => void;
}
interface ConversationProps extends BackstoryElementProps {
className?: string, // Override default className
type: ConversationMode, // Type of Conversation chat
placeholder?: string, // Prompt to display in TextField input
actionLabel?: string, // Label to put on the primary button
resetAction?: () => void, // Callback when Reset is pressed
resetLabel?: string, // Label to put on Reset button
defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField
defaultQuery?: string, // Default text to populate the TextField input
preamble?: MessageList, // Messages to display at start of Conversation until Action has been invoked
hidePreamble?: boolean, // Whether to hide the preamble after an Action has been invoked
hideDefaultPrompts?: boolean, // Whether to hide the defaultPrompts after an Action has been invoked
messageFilter?: ((messages: MessageList) => MessageList) | undefined, // Filter callback to determine which Messages to display in Conversation
messages?: MessageList, //
sx?: SxProps<Theme>,
onResponse?: ((message: BackstoryMessage) => void) | undefined, // Event called when a query completes (provides messages)
};
const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: ConversationProps, ref) => {
const {
sessionId,
actionLabel,
className,
defaultPrompts,
defaultQuery,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetAction,
resetLabel,
setSnack,
submitQuery,
sx,
type,
} = props;
const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0);
const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<MessageList>([]);
const [filteredConversation, setFilteredConversation] = useState<MessageList>([]);
const [processingMessage, setProcessingMessage] = useState<BackstoryMessage | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<BackstoryMessage | undefined>(undefined);
const timerRef = useRef<any>(null);
const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 });
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const conversationRef = useRef<MessageList>([]);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Update the context status
const updateContextStatus = useCallback(() => {
const fetchContextStatus = async () => {
try {
const response = await fetch(connectionBase + `/api/context-status/${sessionId}/${type}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setContextStatus(data);
}
catch (error) {
console.error('Error getting context status:', error);
setSnack("Unable to obtain context status.", "error");
}
};
fetchContextStatus();
}, [setContextStatus, setSnack, sessionId, type]);
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered = messageFilter(conversation); /* Do not copy conversation or useEffect will loop forever */
//console.log(`${conversation.length - filtered.length} messages filtered out.`);
}
if (filtered.length === 0) {
setFilteredConversation([
...(preamble || []),
...(messages || []),
]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : (preamble || [])),
...(messages || []),
...filtered,
]);
};
}, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]);
const fetchHistory = useCallback(async () => {
let retries = 5;
while (--retries > 0) {
try {
const response = await fetch(connectionBase + `/api/history/${sessionId}/${type}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const { messages } = await response.json();
if (messages === undefined || messages.length === 0) {
console.log(`History returned for ${type} from server with 0 entries`)
setConversation([])
setNoInteractions(true);
} else {
console.log(`History returned for ${type} from server with ${messages.length} entries:`, messages)
const backstoryMessages: BackstoryMessage[] = messages;
setConversation(backstoryMessages.flatMap((backstoryMessage: BackstoryMessage) => {
if (backstoryMessage.status === "partial") {
return [{
...backstoryMessage,
role: "assistant",
content: backstoryMessage.response || "",
expanded: false,
expandable: true,
}]
}
return [{
role: 'user',
content: backstoryMessage.prompt || "",
}, {
...backstoryMessage,
role: ['done'].includes(backstoryMessage.status || "") ? "assistant" : backstoryMessage.status,
content: backstoryMessage.response || "",
}] as MessageList;
}));
setNoInteractions(false);
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
updateContextStatus();
return;
} catch (error) {
console.error('Error generating session ID:', error);
setProcessingMessage({ role: "error", content: `Unable to obtain history from server. Retrying in 3 seconds (${retries} remain.)` });
setTimeout(() => {
setProcessingMessage(undefined);
}, 3000);
await new Promise(resolve => setTimeout(resolve, 3000));
setSnack("Unable to obtain chat history.", "error");
}
};
}, [setConversation, updateContextStatus, setSnack, type, sessionId]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (sessionId === undefined) {
setProcessingMessage(loadingMessage);
return;
}
fetchHistory();
}, [fetchHistory, sessionId, setProcessing]);
const startCountdown = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current);
setCountdown(seconds);
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current);
timerRef.current = null;
return 0;
}
return prev - 1;
});
}, 1000);
};
const stopCountdown = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
setCountdown(0);
}
};
const handleEnter = (value: string) => {
const query: Query = {
prompt: value
}
sendQuery(query);
};
useImperativeHandle(ref, () => ({
submitQuery: (query: Query) => {
sendQuery(query);
},
fetchHistory: () => { return fetchHistory(); }
}));
// If context status changes, show a warning if necessary. If it drops
// back below the threshold, clear the warning trigger
useEffect(() => {
const context_used_percentage = Math.round(100 * contextStatus.context_used / contextStatus.max_context);
if (context_used_percentage >= 90 && !contextWarningShown) {
setSnack(`${context_used_percentage}% of context used. You may wish to start a new chat.`, "warning");
setContextWarningShown(true);
}
if (context_used_percentage < 90 && contextWarningShown) {
setContextWarningShown(false);
}
setContextUsedPercentage(context_used_percentage)
}, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]);
const reset = async () => {
try {
const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ reset: ['history'] })
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
} catch (e) {
setSnack("Error resetting history", "error")
console.error('Error resetting history:', e);
}
};
const cancelQuery = () => {
console.log("Stop query");
stopRef.current = true;
};
const sendQuery = async (query: Query) => {
query.prompt = query.prompt.trim();
// If the request was empty, a default request was provided,
// and there is no prompt for the user, send the default request.
if (!query.prompt && defaultQuery && !prompt) {
query.prompt = defaultQuery.trim();
}
// Do not send an empty request.
if (!query.prompt) {
return;
}
stopRef.current = false;
setNoInteractions(false);
setConversation([
...conversationRef.current,
{
role: 'user',
origin: type,
content: query.prompt,
disableCopy: true
}
]);
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
try {
setProcessing(true);
// Add initial processing message
setProcessingMessage(
{ role: 'status', content: 'Submitting request...', disableCopy: true }
);
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
let data: any = query;
if (type === "job_description") {
data = {
prompt: "",
agent_options: {
job_description: query.prompt,
}
}
}
const response = await fetch(connectionBase + `/api/${type}/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(data)
});
setSnack(`Query sent.`, "info");
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
let streaming_response = ""
// Set up stream processing with explicit chunking
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const process_line = async (line: string) => {
let update = JSON.parse(line);
switch (update.status) {
case 'done':
case 'partial':
if (update.status === 'done') stopCountdown();
if (update.status === 'done') setStreamingMessage(undefined);
if (update.status === 'done') setProcessingMessage(undefined);
const backstoryMessage: BackstoryMessage = update;
setConversation([
...conversationRef.current, {
...backstoryMessage,
role: 'assistant',
origin: type,
prompt: ['done', 'partial'].includes(update.status) ? update.prompt : '',
content: backstoryMessage.response || "",
expanded: update.status === "done" ? true : false,
expandable: update.status === "done" ? false : true,
}] as MessageList);
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
const metadata = update.metadata;
if (metadata) {
updateContextStatus();
}
if (onResponse) {
onResponse(update);
}
break;
case 'error':
// Show error
setConversation([
...conversationRef.current, {
...update,
role: 'error',
origin: type,
content: update.response || "",
}] as MessageList);
setProcessing(false);
stopCountdown();
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
break;
default:
// Force an immediate state update based on the message type
// Update processing message with immediate re-render
if (update.status === "streaming") {
streaming_response += update.chunk
setStreamingMessage({ role: update.status, content: streaming_response, disableCopy: true });
} else {
setProcessingMessage({ role: update.status, content: update.response, disableCopy: true });
/* Reset stream on non streaming message */
streaming_response = ""
}
startCountdown(Math.ceil(update.remaining_time));
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
break;
}
}
while (!stopRef.current) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
// Process each complete line immediately
buffer += chunk;
let lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
await process_line(line);
} catch (e) {
setSnack("Error processing query", "error")
console.error(e);
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
try {
await process_line(buffer);
} catch (e) {
setSnack("Error processing query", "error")
console.error(e);
}
}
if (stopRef.current) {
await reader.cancel();
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setSnack("Processing cancelled", "warning");
}
stopCountdown();
setProcessing(false);
stopRef.current = false;
} catch (error) {
console.error('Fetch error:', error);
setSnack("Unable to process query", "error");
setProcessingMessage({ role: 'error', content: "Unable to process query", disableCopy: true });
setTimeout(() => {
setProcessingMessage(undefined);
}, 5000);
stopRef.current = false;
setProcessing(false);
stopCountdown();
return;
}
};
return (
// <Scrollable
// className={`${className || ""} Conversation`}
// autoscroll
// textFieldRef={viewableElementRef}
// fallbackThreshold={0.5}
// sx={{
// p: 1,
// mt: 0,
// ...sx
// }}
// >
<Box sx={{ p: 1, mt: 0, overflow: "hidden", ...sx }}>
{
filteredConversation.map((message, index) =>
<Message key={index} expanded={message.expanded === undefined ? true : message.expanded} {...{ sendQuery, message, connectionBase, sessionId, setSnack, submitQuery }} />
)
}
{
processingMessage !== undefined &&
<Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: processingMessage, submitQuery }} />
}
{
streamingMessage !== undefined &&
<Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: streamingMessage, submitQuery }} />
}
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 1,
}}>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
{processing === true && countdown > 0 && (
<Box
sx={{
pt: 1,
fontSize: "0.7rem",
color: "darkgrey"
}}
>Response will be stopped in: {countdown}s</Box>
)}
</Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}>
{placeholder &&
<Box sx={{ display: "flex", flexGrow: 1, p: 0, m: 0, flexDirection: "column" }}
ref={viewableElementRef}>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
/>
</Box>
}
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<DeleteConfirmation
label={resetLabel || "all data"}
disabled={sessionId === undefined || processingMessage !== undefined || noInteractions}
onDelete={() => { reset(); resetAction && resetAction(); }} />
<Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={sessionId === undefined || processingMessage !== undefined}
onClick={() => { sendQuery({ prompt: (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "" }); }}>
{actionLabel}<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={() => { cancelQuery(); }}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={stopRef.current || sessionId === undefined || processing === false}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
{
defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>);
})
}
</Box>
}
<Box sx={{ ml: "0.25rem", fontSize: "0.6rem", color: "darkgrey", display: "flex", flexShrink: 1, flexDirection: "row", gap: 1, mb: "auto", mt: 1 }}>
Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context}
{
contextUsedPercentage >= 90 ? <Typography sx={{ fontSize: "0.6rem", color: "red" }}>WARNING: Context almost exhausted. You should start a new chat.</Typography>
: (contextUsedPercentage >= 50 ? <Typography sx={{ fontSize: "0.6rem", color: "orange" }}>NOTE: Context is getting long. Queries will be slower, and the LLM may stop issuing tool calls.</Typography>
: <></>)
}
</Box>
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box >
);
});
export type {
ConversationProps,
ConversationHandle,
};
export {
Conversation
};

View File

@ -1,62 +0,0 @@
import { useState } from 'react';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
import { Tooltip } from '@mui/material';
import { SxProps, Theme } from '@mui/material';
interface CopyBubbleProps extends IconButtonProps {
content: string | undefined,
sx?: SxProps<Theme>;
tooltip?: string;
}
const CopyBubble = ({
content,
sx,
tooltip = "Copy to clipboard",
onClick,
...rest
} : CopyBubbleProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = (e: any) => {
if (content === undefined) {
return;
}
navigator.clipboard.writeText(content.trim()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds
});
if (onClick) {
onClick(e);
}
};
return (
<Tooltip title={tooltip} placement="top" arrow>
<IconButton
onClick={(e) => { handleCopy(e) }}
sx={{
width: 24,
height: 24,
opacity: 0.75,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
...sx,
}}
size="small"
color={copied ? "success" : "default"}
{...rest}
>
{copied ? <CheckIcon sx={{ width: 16, height: 16 }} /> : <ContentCopyIcon sx={{ width: 16, height: 16 }} />}
</IconButton>
</Tooltip>
);
}
export {
CopyBubble
}

View File

@ -1,90 +0,0 @@
import React, { useState } from 'react';
import {
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
useMediaQuery,
Tooltip,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ResetIcon from '@mui/icons-material/History';
interface DeleteConfirmationProps {
onDelete: () => void,
disabled?: boolean,
label?: string,
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined
};
const DeleteConfirmation = (props : DeleteConfirmationProps) => {
const { onDelete, disabled, label, color } = props;
const [open, setOpen] = useState(false);
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const handleConfirmReset = () => {
onDelete();
setOpen(false);
};
return (
<>
<Tooltip title={label ? `Reset ${label}` : "Reset"} >
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="reset"
onClick={handleClickOpen}
color={color || "inherit"}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={disabled}
>
<ResetIcon />
</IconButton>
</span>
</Tooltip>
<Dialog
fullScreen={fullScreen}
open={open}
onClose={handleClose}
aria-labelledby="responsive-dialog-title"
>
<DialogTitle id="responsive-dialog-title">
{"Confirm Reset"}
</DialogTitle>
<DialogContent>
<DialogContentText>
This action will permanently reset { label ? label.toLocaleLowerCase() : "all data" } without the ability to recover it.
Are you sure you want to continue?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleConfirmReset} color="error" variant="contained">
Reset { label || "Everything" }
</Button>
</DialogActions>
</Dialog>
</>
);
}
export {
DeleteConfirmation
};

View File

@ -1,99 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Box } from '@mui/material';
import { Message } from './Message';
import { ChatBubble } from '../Components/ChatBubble';
import { BackstoryElementProps } from './BackstoryTab';
interface DocumentProps extends BackstoryElementProps {
title: string;
expanded?: boolean;
filepath?: string;
content?: string;
disableCopy?: boolean;
children?: React.ReactNode;
onExpand?: (open: boolean) => void;
}
const Document = (props: DocumentProps) => {
const { sessionId, setSnack, submitQuery, filepath, content, title, expanded, disableCopy, onExpand, children } = props;
const backstoryProps = {
submitQuery,
setSnack,
sessionId
}
const [document, setDocument] = useState<string>("");
// Get the markdown
useEffect(() => {
if (document !== "" || !filepath) {
return;
}
const fetchDocument = async () => {
try {
const response = await fetch(filepath, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw Error(`${filepath} not found.`);
}
const data = await response.text();
setDocument(data);
} catch (error: any) {
console.error('Error obtaining About content information:', error);
setDocument(`${filepath} not found.`);
};
};
fetchDocument();
}, [document, setDocument, filepath])
return (
<Box>
{children !== undefined && <ChatBubble
{...{
sx: {
mt: 1,
p: 1,
flexGrow: 0,
display: "flex",
flexDirection: "column",
pt: "8px",
pb: "8px",
marginTop: "8px !important", // Remove whitespace from expanded Accordion
marginBottom: "0px !important", // Remove whitespace from expanded Accordion
},
role: "content",
title,
expanded,
disableCopy,
onExpand
}}>{children}</ChatBubble>}
{children === undefined && <Message
{...{
sx: {
display: 'flex',
flexDirection: 'column',
p: 1,
m: 0,
flexGrow: 0,
marginTop: "8px !important", // Remove whitespace from expanded Accordion
},
message: { role: 'content', title: title, content: document || content || "" },
expanded,
disableCopy,
onExpand,
}}
{...backstoryProps} />
}
</Box>
);
};
export {
Document
};

View File

@ -1,340 +0,0 @@
import { useState, useRef } from 'react';
import Divider from '@mui/material/Divider';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Card from '@mui/material/Card';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Collapse from '@mui/material/Collapse';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { ExpandMore } from './ExpandMore';
import { SxProps, Theme } from '@mui/material';
import JsonView from '@uiw/react-json-view';
import { ChatBubble } from './ChatBubble';
import { StyledMarkdown } from '../NewApp/Components/StyledMarkdown';
import { VectorVisualizer } from './VectorVisualizer';
import { SetSnackType } from './Snack';
import { CopyBubble } from './CopyBubble';
import { Scrollable } from './Scrollable';
import { BackstoryElementProps } from './BackstoryTab';
type MessageRoles =
'assistant' |
'content' |
'error' |
'fact-check' |
'info' |
'job-description' |
'job-requirements' |
'processing' |
'qualifications' |
'resume' |
'status' |
'streaming' |
'system' |
'thinking' |
'user';
type BackstoryMessage = {
// Only two required fields
role: MessageRoles,
content: string,
// Rest are optional
prompt?: string;
preamble?: {};
status?: string;
remaining_time?: number;
full_content?: string;
response?: string; // Set when status === 'done', 'partial', or 'error'
chunk?: string; // Used when status === 'streaming'
timestamp?: number;
disableCopy?: boolean,
user?: string,
title?: string,
origin?: string,
display?: string, /* Messages generated on the server for filler should not be shown */
id?: string,
isProcessing?: boolean,
actions?: string[],
metadata?: MessageMetaData,
expanded?: boolean,
expandable?: boolean,
};
interface MessageMetaData {
query?: {
query_embedding: number[];
vector_embedding: number[];
},
origin: string,
rag: any[],
tools?: {
tool_calls: any[],
},
eval_count: number,
eval_duration: number,
prompt_eval_count: number,
prompt_eval_duration: number,
connectionBase: string,
setSnack: SetSnackType,
}
type MessageList = BackstoryMessage[];
interface MessageProps extends BackstoryElementProps {
sx?: SxProps<Theme>,
message: BackstoryMessage,
expanded?: boolean,
onExpand?: (open: boolean) => void,
className?: string,
};
interface MessageMetaProps {
metadata: MessageMetaData,
messageProps: MessageProps
};
const MessageMeta = (props: MessageMetaProps) => {
const {
/* MessageData */
rag,
tools,
eval_count,
eval_duration,
prompt_eval_count,
prompt_eval_duration,
} = props.metadata || {};
const message: any = props.messageProps.message;
let llm_submission: string = "<|system|>\n"
llm_submission += message.system_prompt + "\n\n"
llm_submission += message.context_prompt
return (<>
{
prompt_eval_duration !== 0 && eval_duration !== 0 && <>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right" >Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Prompt</TableCell>
<TableCell align="right">{prompt_eval_count}</TableCell>
<TableCell align="right">{Math.round(prompt_eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(prompt_eval_count * 10 ** 9 / prompt_eval_duration)}</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Response</TableCell>
<TableCell align="right">{eval_count}</TableCell>
<TableCell align="right">{Math.round(eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(eval_count * 10 ** 9 / eval_duration)}</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Total</TableCell>
<TableCell align="right">{prompt_eval_count + eval_count}</TableCell>
<TableCell align="right">{Math.round((prompt_eval_duration + eval_duration) / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round((prompt_eval_count + eval_count) * 10 ** 9 / (prompt_eval_duration + eval_duration))}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</>
}
{
tools && tools.tool_calls && tools.tool_calls.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Tools queried
</Box>
</AccordionSummary>
<AccordionDetails>
{
tools.tool_calls.map((tool: any, index: number) =>
<Box key={index} sx={{ m: 0, p: 1, pt: 0, display: "flex", flexDirection: "column", border: "1px solid #e0e0e0" }}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 1, mb: 1, fontWeight: "bold" }}>
{tool.name}
</Box>
{tool.content !== "null" &&
<JsonView
displayDataTypes={false}
objectSortKeys={true}
collapsed={1} value={JSON.parse(tool.content)} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre>
}
}}
/>
</JsonView>
}
{tool.content === "null" && "No response from tool call"}
</Box>)
}
</AccordionDetails>
</Accordion>
}
{
rag.map((collection: any) => (
<Accordion key={collection.name}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Top {collection.ids.length} RAG matches from {collection.size} entries using an embedding vector of {collection.query_embedding.length} dimensions
</Box>
</AccordionSummary>
<AccordionDetails>
<VectorVisualizer inline
{...props.messageProps} {...props.metadata}
rag={collection} />
{/* { ...rag, query: message.prompt }} /> */}
</AccordionDetails>
</Accordion>
))
}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Full Response Details
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ pb: 1 }}>Copy LLM submission: <CopyBubble content={llm_submission} /></Box>
<JsonView displayDataTypes={false} objectSortKeys={true} collapsed={1} value={message} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "inline", border: "none", ...reset.style }}>{children.trim()}</pre>
}
}}
/>
</JsonView>
</AccordionDetails>
</Accordion>
</>);
};
const Message = (props: MessageProps) => {
const { message, submitQuery, sx, className, onExpand, setSnack, sessionId, expanded } = props;
const [metaExpanded, setMetaExpanded] = useState<boolean>(false);
const textFieldRef = useRef(null);
const backstoryProps = {
submitQuery,
sessionId,
setSnack
};
const handleMetaExpandClick = () => {
setMetaExpanded(!metaExpanded);
};
if (message === undefined) {
return (<></>);
}
if (message.content === undefined) {
console.info("Message content is undefined");
return (<></>);
}
const formattedContent = message.content.trim();
if (formattedContent === "") {
return (<></>);
}
return (
<ChatBubble
className={`${className || ""} Message Message-${message.role}`}
{...message}
expanded={expanded}
onExpand={onExpand}
sx={{
display: "flex",
flexDirection: "column",
pb: message.metadata ? 0 : "8px",
m: 0,
mt: 1,
marginBottom: "0px !important", // Remove whitespace from expanded Accordion
// overflowX: "auto"
...sx,
}}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto", m: 0, p: 0, paddingBottom: '0px !important' }}>
<Scrollable
className="MessageContent"
autoscroll
fallbackThreshold={0.5}
sx={{
p: 0,
m: 0,
// maxHeight: (message.role === "streaming") ? "20rem" : "unset",
display: "flex",
flexGrow: 1,
overflow: "auto", /* Handles scrolling for the div */
}}
>
<StyledMarkdown streaming={message.role === "streaming"} content={formattedContent} {...backstoryProps} />
</Scrollable>
</CardContent>
<CardActions disableSpacing sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between", alignItems: "center", width: "100%", p: 0, m: 0 }}>
{(message.disableCopy === undefined || message.disableCopy === false) && <CopyBubble content={message.content} />}
{message.metadata && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button variant="text" onClick={handleMetaExpandClick} sx={{ color: "darkgrey", p: 0 }}>
LLM information for this query
</Button>
<ExpandMore
expand={metaExpanded}
onClick={handleMetaExpandClick}
aria-expanded={message.expanded}
aria-label="show more"
>
<ExpandMoreIcon />
</ExpandMore>
</Box>
)}
</CardActions>
{message.metadata && <>
<Collapse in={metaExpanded} timeout="auto" unmountOnExit>
<CardContent>
<MessageMeta messageProps={props} metadata={message.metadata} />
</CardContent>
</Collapse>
</>}
</ChatBubble>
);
};
export type {
MessageProps,
MessageList,
BackstoryMessage,
MessageMetaData,
MessageRoles,
};
export {
Message,
MessageMeta,
};

View File

@ -1,80 +0,0 @@
import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
import { SxProps, Theme } from '@mui/material';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import './Snack.css';
type SeverityType = 'error' | 'info' | 'success' | 'warning' | undefined;
type SetSnackType = (message: string, severity?: SeverityType) => void;
interface SnackHandle {
setSnack: SetSnackType;
};
interface SnackProps {
sx?: SxProps<Theme>;
className?: string;
};
const Snack = forwardRef<SnackHandle, SnackProps>(({
className,
sx
}: SnackProps, ref) => {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [severity, setSeverity] = useState<SeverityType>("success");
// Set the snack pop-up and open it
const setSnack: SetSnackType = useCallback<SetSnackType>((message: string, severity: SeverityType = "success") => {
setTimeout(() => {
setMessage(message);
setSeverity(severity);
setOpen(true);
});
}, [setMessage, setSeverity, setOpen]);
useImperativeHandle(ref, () => ({
setSnack: (message: string, severity?: SeverityType) => {
setSnack(message, severity);
}
}));
const handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
setOpen(false);
};
return (
<Snackbar
className={className || "Snack"}
sx={{ ...sx }}
open={open}
autoHideDuration={(severity === "success" || severity === "info") ? 1500 : 6000}
onClose={handleSnackClose}>
<Alert
onClose={handleSnackClose}
severity={severity}
variant="filled"
sx={{ width: '100%' }}
>
{message}
</Alert>
</Snackbar>
)
});
export type {
SeverityType,
SetSnackType
};
export {
Snack
};

View File

@ -1,628 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Plot from 'react-plotly.js';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import useMediaQuery from '@mui/material/useMediaQuery';
import { SxProps, useTheme } from '@mui/material/styles';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import { Scrollable } from './Scrollable';
import { connectionBase } from '../Global';
import './VectorVisualizer.css';
import { BackstoryPageProps } from './BackstoryTab';
import { relative } from 'path';
interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean;
rag?: any;
};
interface Metadata {
id: string;
doc_type: string;
content: string;
distance?: number;
}
type QuerySet = {
ids: string[],
documents: string[],
metadatas: Metadata[],
embeddings: (number[])[],
distances?: (number | undefined)[],
dimensions?: number;
query?: string;
umap_embedding_2d?: number[];
umap_embedding_3d?: number[];
};
const emptyQuerySet = {
ids: [],
documents: [],
metadatas: [],
embeddings: [],
};
interface PlotData {
x: number[];
y: number[];
z?: number[];
colors: string[];
text: string[];
sizes: number[];
customdata: Metadata[];
}
const config: Partial<Plotly.Config> = {
responsive: true,
autosizable: true,
displaylogo: false,
showSendToCloud: false,
staticPlot: false,
frameMargins: 0,
scrollZoom: false,
doubleClick: false,
// | "lasso2d"
// | "select2d"
// | "sendDataToCloud"
// | "zoom2d"
// | "pan2d"
// | "zoomIn2d"
// | "zoomOut2d"
// | "autoScale2d"
// | "resetScale2d"
// | "hoverClosestCartesian"
// | "hoverCompareCartesian"
// | "zoom3d"
// | "pan3d"
// | "orbitRotation"
// | "tableRotation"
// | "handleDrag3d"
// | "resetCameraDefault3d"
// | "resetCameraLastSave3d"
// | "hoverClosest3d"
// | "zoomInGeo"
// | "zoomOutGeo"
// | "resetGeo"
// | "hoverClosestGeo"
// | "hoverClosestGl2d"
// | "hoverClosestPie"
// | "toggleHover"
// | "toImage"
// | "resetViews"
// | "toggleSpikelines"
// | "zoomInMapbox"
// | "zoomOutMapbox"
// | "resetViewMapbox"
// | "togglespikelines"
// | "togglehover"
// | "hovercompare"
// | "hoverclosest"
// | "v1hovermode";
modeBarButtonsToRemove: [
'lasso2d', 'select2d',
]
};
const layout: Partial<Plotly.Layout> = {
autosize: false,
clickmode: 'event+select',
paper_bgcolor: '#FFFFFF', // white
plot_bgcolor: '#FFFFFF', // white plot background
font: {
family: 'Roboto, sans-serif',
color: '#2E2E2E', // charcoal black
},
hovermode: 'closest',
scene: {
bgcolor: '#FFFFFF', // 3D plot background
zaxis: { title: 'Z', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
},
xaxis: { title: 'X', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
yaxis: { title: 'Y', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
margin: { r: 0, b: 0, l: 0, t: 0 },
legend: {
x: 0.8, // Horizontal position (0 to 1, 0 is left, 1 is right)
y: 0, // Vertical position (0 to 1, 0 is bottom, 1 is top)
xanchor: 'left',
yanchor: 'top',
orientation: 'h' // 'v' for horizontal legend
},
showlegend: true // Show the legend
};
const normalizeDimension = (arr: number[]): number[] => {
const min = Math.min(...arr);
const max = Math.max(...arr);
const range = max - min;
if (range === 0) return arr.map(() => 0.5); // flat dimension
return arr.map(v => (v - min) / range);
};
const emojiMap: Record<string, string> = {
query: '🔍',
resume: '📄',
projects: '📁',
jobs: '📁',
'performance-reviews': '📄',
news: '📰',
};
const colorMap: Record<string, string> = {
query: '#D4A017', // Golden Ochre — strong highlight
resume: '#4A7A7D', // Dusty Teal — secondary theme color
projects: '#1A2536', // Midnight Blue — rich and deep
news: '#D3CDBF', // Warm Gray — soft and neutral
'performance-reviews': '#8FD0D0', // Light red
'jobs': '#F3aD8F', // Warm Gray — soft and neutral
};
const DEFAULT_SIZE = 6.;
const DEFAULT_UNFOCUS_SIZE = 2.;
type Node = {
id: string,
content: string, // Portion of content that was used for embedding
full_content: string | undefined, // Portion of content plus/minus buffer
emoji: string,
doc_type: string,
source_file: string,
distance: number | undefined,
path: string,
chunk_begin: number,
line_begin: number,
chunk_end: number,
line_end: number,
sx: SxProps,
};
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
const { sessionId, setSnack, rag, inline, sx } = props;
const [plotData, setPlotData] = useState<PlotData | null>(null);
const [newQuery, setNewQuery] = useState<string>('');
const [querySet, setQuerySet] = useState<QuerySet>(rag || emptyQuerySet);
const [result, setResult] = useState<QuerySet | undefined>(undefined);
const [view2D, setView2D] = useState<boolean>(true);
const plotlyRef = useRef(null);
const boxRef = useRef<HTMLElement>(null);
const [node, setNode] = useState<Node | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [plotDimensions, setPlotDimensions] = useState({ width: 0, height: 0 });
/* Force resize of Plotly as it tends to not be the correct size if it is initially rendered
* off screen (eg., the VectorVisualizer is not on the tab the app loads to) */
useEffect(() => {
if (!boxRef.current) {
return;
}
const resize = () => {
requestAnimationFrame(() => {
const plotContainer = document.querySelector('.plot-container') as HTMLElement;
const svgContainer = document?.querySelector('.svg-container') as HTMLElement;
if (plotContainer && svgContainer) {
const plotContainerRect = plotContainer.getBoundingClientRect();
svgContainer.style.width = `${plotContainerRect.width}px`;
svgContainer.style.height = `${plotContainerRect.height}px`;
if (plotDimensions.width !== plotContainerRect.width || plotDimensions.height !== plotContainerRect.height) {
setPlotDimensions({ width: plotContainerRect.width, height: plotContainerRect.height });
}
}
});
}
resize();
});
// Get the collection to visualize
useEffect(() => {
if ((result !== undefined && result.dimensions !== (view2D ? 3 : 2)) || sessionId === undefined) {
return;
}
const fetchCollection = async () => {
try {
const response = await fetch(connectionBase + `/api/umap/${sessionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ dimensions: view2D ? 2 : 3 }),
});
const data: QuerySet = await response.json();
data.dimensions = view2D ? 2 : 3;
setResult(data);
} catch (error) {
console.error('Error obtaining collection information:', error);
setSnack("Unable to obtain collection information.", "error");
};
};
fetchCollection();
}, [result, setSnack, sessionId, view2D])
useEffect(() => {
if (!result || !result.embeddings) return;
if (result.embeddings.length === 0) return;
const full: QuerySet = {
ids: [...result.ids || []],
documents: [...result.documents || []],
embeddings: [...result.embeddings],
metadatas: [...result.metadatas || []],
};
let is2D = full.embeddings.every((v: number[]) => v.length === 2);
let is3D = full.embeddings.every((v: number[]) => v.length === 3);
if ((view2D && !is2D) || (!view2D && !is3D)) {
return;
}
if (!is2D && !is3D) {
console.warn('Modified vectors are neither 2D nor 3D');
return;
}
let query: QuerySet = {
ids: [],
documents: [],
embeddings: [],
metadatas: [],
distances: [],
};
let filtered: QuerySet = {
ids: [],
documents: [],
embeddings: [],
metadatas: [],
};
/* Loop through all items and divide into two groups:
* filtered is for any item not in the querySet
* query is for any item that is in the querySet
*/
full.ids.forEach((id, index) => {
const foundIndex = querySet.ids.indexOf(id);
/* Update metadata to hold the doc content and id */
full.metadatas[index].id = id;
full.metadatas[index].content = full.documents[index];
if (foundIndex !== -1) {
/* The query set will contain the distance to the query */
full.metadatas[index].distance = querySet.distances ? querySet.distances[foundIndex] : undefined;
query.ids.push(id);
query.documents.push(full.documents[index]);
query.embeddings.push(full.embeddings[index]);
query.metadatas.push(full.metadatas[index]);
} else {
/* THe filtered set does not have a distance */
full.metadatas[index].distance = undefined;
filtered.ids.push(id);
filtered.documents.push(full.documents[index]);
filtered.embeddings.push(full.embeddings[index]);
filtered.metadatas.push(full.metadatas[index]);
}
});
if (view2D && querySet.umap_embedding_2d && querySet.umap_embedding_2d.length) {
query.ids.unshift('query');
query.metadatas.unshift({ id: 'query', doc_type: 'query', content: querySet.query || '', distance: 0 });
query.embeddings.unshift(querySet.umap_embedding_2d);
}
if (!view2D && querySet.umap_embedding_3d && querySet.umap_embedding_3d.length) {
query.ids.unshift('query');
query.metadatas.unshift({ id: 'query', doc_type: 'query', content: querySet.query || '', distance: 0 });
query.embeddings.unshift(querySet.umap_embedding_3d);
}
const filtered_doc_types = filtered.metadatas.map(m => m.doc_type || 'unknown')
const query_doc_types = query.metadatas.map(m => m.doc_type || 'unknown')
const has_query = query.metadatas.length > 0;
const filtered_sizes = filtered.metadatas.map(m => has_query ? DEFAULT_UNFOCUS_SIZE : DEFAULT_SIZE);
const filtered_colors = filtered_doc_types.map(type => colorMap[type] || '#ff8080');
const filtered_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0]));
const filtered_y = normalizeDimension(filtered.embeddings.map((v: number[]) => v[1]));
const filtered_z = is3D ? normalizeDimension(filtered.embeddings.map((v: number[]) => v[2])) : undefined;
const query_sizes = query.metadatas.map(m => DEFAULT_SIZE + 2. * DEFAULT_SIZE * Math.pow((1. - (m.distance || 1.)), 3));
const query_colors = query_doc_types.map(type => colorMap[type] || '#ff8080');
const query_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0]));
const query_y = normalizeDimension(query.embeddings.map((v: number[]) => v[1]));
const query_z = is3D ? normalizeDimension(query.embeddings.map((v: number[]) => v[2])) : undefined;
const data: any = [{
name: 'All data',
x: filtered_x,
y: filtered_y,
mode: 'markers',
marker: {
size: filtered_sizes,
symbol: 'circle',
color: filtered_colors,
opacity: 1
},
text: filtered.ids,
customdata: filtered.metadatas,
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '&nbsp;',
}, {
name: 'Query',
x: query_x,
y: query_y,
mode: 'markers',
marker: {
size: query_sizes,
symbol: 'circle',
color: query_colors,
opacity: 1
},
text: query.ids,
customdata: query.metadatas,
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '%{text}',
}];
if (is3D) {
data[0].z = filtered_z;
data[1].z = query_z;
}
setPlotData(data);
}, [result, querySet, view2D]);
const handleKeyPress = (event: any) => {
if (event.key === 'Enter') {
sendQuery(newQuery);
}
};
const sendQuery = async (query: string) => {
if (!query.trim()) return;
setNewQuery('');
try {
const response = await fetch(`${connectionBase}/api/similarity/${sessionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
dimensions: view2D ? 2 : 3,
})
});
const data = await response.json();
setQuerySet(data);
} catch (error) {
console.error('Error obtaining query similarity information:', error);
setSnack("Unable to obtain query similarity information.", "error");
};
};
if (!plotData || sessionId === undefined) return (
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<div>Loading visualization...</div>
</Box>
);
const fetchRAGMeta = async (node: Node) => {
try {
const response = await fetch(connectionBase + `/api/umap/entry/${node.id}/${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const update: Node = {
...node,
full_content: await response.json()
}
setNode(update);
} catch (error) {
const msg = `Error obtaining content for ${node.id}.`
console.error(msg, error);
setSnack(msg, "error");
};
};
const onNodeSelected = (metadata: any) => {
let node: Node;
if (metadata.doc_type === 'query') {
node = {
...metadata,
content: `Similarity results for the query **${querySet.query || ''}**
The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '2' : '3'}-dimensional space. Larger dots represent relative similarity in N-dimensional space.
`,
emoji: emojiMap[metadata.doc_type],
sx: {
m: 0.5,
p: 2,
width: '3rem',
display: "flex",
alignContent: "center",
justifyContent: "center",
flexGrow: 0,
flexWrap: "wrap",
backgroundColor: colorMap[metadata.doc_type] || '#ff8080',
}
}
setNode(node);
return;
}
node = {
content: `Loading...`,
...metadata,
emoji: emojiMap[metadata.doc_type] || '❓',
}
setNode(node);
fetchRAGMeta(node);
};
return (
<Box className="VectorVisualizer"
ref={boxRef}
sx={{
...sx
}}>
<Box sx={{ p: 0, m: 0, gap: 0 }}>
<Paper sx={{
p: 0.5, m: 0,
display: "flex",
flexGrow: 0,
height: isMobile ? "auto" : "auto", //"320px",
minHeight: isMobile ? "auto" : "auto", //"320px",
maxHeight: isMobile ? "auto" : "auto", //"320px",
position: "relative",
flexDirection: "column"
}}>
<FormControlLabel
sx={{
display: "flex",
position: "relative",
width: "fit-content",
ml: 1,
mb: '-2.5rem',
zIndex: 100,
flexBasis: 0,
flexGrow: 0
}}
control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
<Plot
ref={plotlyRef}
onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }}
data={plotData}
useResizeHandler={true}
config={config}
style={{
display: "flex",
flexGrow: 1,
minHeight: '240px',
padding: 0,
margin: 0,
width: "100%",
height: "100%",
overflow: "hidden",
}}
layout={{...layout, width: plotDimensions.width, height: plotDimensions.height }}
/>
</Paper>
<Paper sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", mt: 0.5, p: 0.5, flexGrow: 1, minHeight: "fit-content" }}>
{node !== null &&
<Box sx={{ display: "flex", fontSize: "0.75rem", flexDirection: "column", flexGrow: 1, maxWidth: "100%", flexBasis: 1, maxHeight: "min-content" }}>
<TableContainer component={Paper} sx={{ mb: isMobile ? 1 : 0, mr: isMobile ? 0 : 1 }}>
<Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableBody sx={{ '& td': { verticalAlign: "top", fontSize: "0.75rem", }, '& td:first-of-type': { whiteSpace: "nowrap", width: "1rem" } }}>
<TableRow>
<TableCell>Type</TableCell>
<TableCell>{node.emoji} {node.doc_type}</TableCell>
</TableRow>
{node.source_file !== undefined && <TableRow>
<TableCell>File</TableCell>
<TableCell>{node.source_file.replace(/^.*\//, '')}</TableCell>
</TableRow>}
{node.path !== undefined && <TableRow>
<TableCell>Section</TableCell>
<TableCell>{node.path}</TableCell>
</TableRow>}
{node.distance !== undefined && <TableRow>
<TableCell>Distance</TableCell>
<TableCell>{node.distance}</TableCell>
</TableRow>}
</TableBody>
</Table>
</TableContainer>
{node.content !== "" && node.content !== undefined &&
<Paper elevation={6} sx={{ display: "flex", flexDirection: "column", border: "1px solid #808080", minHeight: "fit-content", mt: 1 }}>
<Box sx={{ display: "flex", background: "#404040", p: 1, color: "white" }}>Vector Embedded Content</Box>
<Box sx={{ display: "flex", p: 1, flexGrow: 1 }}>{node.content}</Box>
</Paper>
}
</Box>
}
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 2, flexBasis: 0, flexShrink: 1 }}>
{node === null &&
<Paper sx={{ m: 0.5, p: 2, flexGrow: 1 }}>
Click a point in the scatter-graph to see information about that node.
</Paper>
}
{node !== null && node.full_content &&
<Scrollable
autoscroll={false}
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
p: 0.5,
pl: 1,
flexShrink: 1,
position: "relative",
maxWidth: "100%",
}}
>
{
node.full_content.split('\n').map((line, index) => {
index += 1 + node.chunk_begin;
const bgColor = (index > node.line_begin && index <= node.line_end) ? '#f0f0f0' : 'auto';
return <Box key={index} sx={{ display: "flex", flexDirection: "row", borderBottom: '1px solid #d0d0d0', ':first-of-type': { borderTop: '1px solid #d0d0d0' }, backgroundColor: bgColor }}>
<Box sx={{ fontFamily: 'courier', fontSize: "0.8rem", minWidth: "2rem", pt: "0.1rem", align: "left", verticalAlign: "top" }}>{index}</Box>
<pre style={{ margin: 0, padding: 0, border: "none", minHeight: "1rem", overflow: "hidden" }} >{line || " "}</pre>
</Box>;
})
}
{!node.line_begin && <pre style={{ margin: 0, padding: 0, border: "none" }}>{node.content}</pre>}
</Scrollable>
}
</Box>
</Paper>
{!inline && querySet.query !== undefined && querySet.query !== '' &&
<Paper sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flexGrow: 0, minHeight: '2.5rem', maxHeight: '2.5rem', height: '2.5rem', alignItems: 'center', mt: 1, pb: 0 }}>
{querySet.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`}
{querySet.ids.length === 0 && "Enter query below to perform a similarity search."}
</Paper>
}
{
!inline &&
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField
variant="outlined"
fullWidth
type="text"
value={newQuery}
onChange={(e) => setNewQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..."
id="QueryInput"
/>
<Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(newQuery); }}><SendIcon /></Button>
</Tooltip>
</Box>
}
</Box>
</Box>
);
};
export type { VectorVisualizerProps };
export {
VectorVisualizer,
};

View File

@ -1,15 +0,0 @@
const getConnectionBase = (loc: any): string => {
if (!loc.host.match(/.*battle-linux.*/)
// && !loc.host.match(/.*backstory-beta.*/)
) {
return loc.protocol + "//" + loc.host;
} else {
return loc.protocol + "//battle-linux.ketrenos.com:8912";
}
}
const connectionBase = getConnectionBase(window.location);
export {
connectionBase
};

View File

@ -1,162 +0,0 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { backstoryTheme } from '../BackstoryTheme';
import { SeverityType } from '../Components/Snack';
import { Query } from '../Components/ChatQuery';
import { ConversationHandle } from './Components/Conversation';
import { UserProvider } from './Components/UserContext';
import { BetaPage } from './Pages/BetaPage';
import { UserRoute } from './routes/UserRoute';
import { BackstoryLayout } from './Components/BackstoryLayout';
import './BackstoryApp.css';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { connectionBase } from '../Global';
// Cookie handling functions
const getCookie = (name: string) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
return null;
};
const setCookie = (name: string, value: string, days = 7) => {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Strict`;
};
const BackstoryApp = () => {
const navigate = useNavigate();
const location = useLocation();
const snackRef = useRef<any>(null);
const chatRef = useRef<ConversationHandle>(null);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const setSnack = useCallback((message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
}, [snackRef]);
const submitQuery = (query: Query) => {
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
navigate('/chat');
};
const [page, setPage] = useState<string>("");
const [storeInCookie, setStoreInCookie] = useState(true);
// Extract session ID from URL query parameter or cookie
const urlParams = new URLSearchParams(window.location.search);
const urlSessionId = urlParams.get('id');
const cookieSessionId = getCookie('session_id');
// Fetch or join session on mount
useEffect(() => {
const fetchSession = async () => {
try {
let response;
let newSessionId;
let action = ""
if (urlSessionId) {
// Attempt to join session from URL
response = await fetch(`${connectionBase}/api/join-session/${urlSessionId}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Session not found');
}
newSessionId = (await response.json()).id;
action = "Joined";
} else if (cookieSessionId) {
// Attempt to join session from cookie
response = await fetch(`${connectionBase}/api/join-session/${cookieSessionId}`, {
credentials: 'include',
});
if (!response.ok) {
// Cookie session invalid, create new session
response = await fetch(`${connectionBase}/api/create-session`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to create session');
}
action = "Created new";
} else {
action = "Joined";
}
newSessionId = (await response.json()).id;
} else {
// Create a new session
response = await fetch(`${connectionBase}/api/create-session`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to create session');
}
action = "Created new";
newSessionId = (await response.json()).id;
}
setSessionId(newSessionId);
setSnack(`${action} session ${newSessionId}`);
// Store in cookie if user opts in
if (storeInCookie) {
setCookie('session_id', newSessionId);
}
// Update URL without reloading
if (!storeInCookie) {
// Update only the 'id' query parameter, preserving the current path
navigate(`${location.pathname}?id=${newSessionId}`, { replace: true });
} else {
// Clear all query parameters, preserve the current path
navigate(location.pathname, { replace: true });
}
} catch (err) {
setSnack("" + err);
}
};
fetchSession();
}, [cookieSessionId, setSnack, storeInCookie, urlSessionId]);
useEffect(() => {
const currentRoute = location.pathname.split("/")[1] ? `/${location.pathname.split("/")[1]}` : "/";
setPage(currentRoute);
}, [location.pathname]);
// Render appropriate routes based on user type
return (
<ThemeProvider theme={backstoryTheme}>
<UserProvider sessionId={sessionId} setSnack={setSnack}>
<Routes>
<Route path="/u/:username" element={<UserRoute sessionId={sessionId} setSnack={setSnack} />} />
{/* Static/shared routes */}
<Route
path="/*"
element={
<BackstoryLayout
sessionId={sessionId}
setSnack={setSnack}
page={page}
chatRef={chatRef}
snackRef={snackRef}
submitQuery={submitQuery}
/>
}
/>
</Routes>
</UserProvider>
</ThemeProvider>
);
};
export {
BackstoryApp
};

View File

@ -1,123 +0,0 @@
import { createTheme } from '@mui/material/styles';
const backstoryTheme = createTheme({
palette: {
primary: {
main: '#1A2536', // Midnight Blue
contrastText: '#D3CDBF', // Warm Gray
},
secondary: {
main: '#4A7A7D', // Dusty Teal
contrastText: '#FFFFFF', // White
},
text: {
primary: '#2E2E2E', // Charcoal Black
secondary: '#1A2536', // Midnight Blue
},
background: {
default: '#D3CDBF', // Warm Gray
paper: '#FFFFFF', // White
},
action: {
active: '#D4A017', // Golden Ochre
hover: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity
},
custom: {
highlight: '#D4A017', // Golden Ochre
contrast: '#2E2E2E', // Charcoal Black
},
},
typography: {
fontFamily: "'Roboto', sans-serif",
h1: {
fontSize: '2rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
},
h2: {
fontSize: '1.75rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '1rem',
},
h3: {
fontSize: '1.5rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.75rem',
},
h4: {
fontSize: '1.25rem',
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.5rem',
},
body1: {
fontSize: '1rem',
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.5rem',
},
body2: {
fontSize: '0.875rem',
color: '#2E2E2E', // Charcoal Black
},
},
components: {
MuiLink: {
styleOverrides: {
root: {
color: '#4A7A7D', // Dusty Teal (your secondary color)
textDecoration: 'none',
'&:hover': {
color: '#D4A017', // Golden Ochre on hover
textDecoration: 'underline',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
'&:hover': {
backgroundColor: 'rgba(212, 160, 23, 0.2)', // Golden Ochre hover
},
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#1A2536', // Midnight Blue
},
},
},
MuiPaper: {
styleOverrides: {
root: {
padding: '2rem',
borderRadius: '8px',
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: '0.5rem',
},
},
},
MuiListItem: {
styleOverrides: {
root: {
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity
},
},
},
},
},
});
export { backstoryTheme };

View File

@ -1,207 +0,0 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { Outlet, useLocation, Routes } from "react-router-dom";
import { Box, Container, Paper } from '@mui/material';
import { useNavigate } from "react-router-dom";
import ChatIcon from '@mui/icons-material/Chat';
import DescriptionIcon from '@mui/icons-material/Description';
import BarChartIcon from '@mui/icons-material/BarChart';
import SettingsIcon from '@mui/icons-material/Settings';
import WorkIcon from '@mui/icons-material/Work';
import InfoIcon from '@mui/icons-material/Info';
import { SxProps, Theme } from '@mui/material';
import {Header} from './Header';
import { Scrollable } from '../../Components/Scrollable';
import { Footer } from './Footer';
import { Snack, SetSnackType } from '../../Components/Snack';
import { useUser, UserInfo } from './UserContext';
import { getBackstoryDynamicRoutes } from './BackstoryRoutes';
import { LoadingComponent } from "../Components/LoadingComponent";
type NavigationLinkType = {
name: string;
path: string;
icon?: ReactElement<any>;
label?: ReactElement<any>;
};
const DefaultNavItems: NavigationLinkType[] = [
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
{ name: 'Docs', path: '/docs', icon: <InfoIcon /> },
// { name: 'How It Works', path: '/how-it-works', icon: <InfoIcon/> },
// { name: 'For Candidates', path: '/for-candidates', icon: <PersonIcon/> },
// { name: 'For Employers', path: '/for-employers', icon: <BusinessIcon/> },
// { name: 'Pricing', path: '/pricing', icon: <AttachMoneyIcon/> },
];
const CandidateNavItems : NavigationLinkType[]= [
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
{ name: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon /> },
{ name: 'Resume Builder', path: '/resume-builder', icon: <WorkIcon /> },
{ name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: <WorkIcon /> },
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' },
// { name: 'Profile', icon: <PersonIcon />, path: '/profile' },
// { name: 'Backstory', icon: <HistoryIcon />, path: '/backstory' },
{ name: 'Resumes', icon: <DescriptionIcon />, path: '/resumes' },
// { name: 'Q&A Setup', icon: <QuestionAnswerIcon />, path: '/qa-setup' },
{ name: 'Analytics', icon: <BarChartIcon />, path: '/analytics' },
{ name: 'Settings', icon: <SettingsIcon />, path: '/settings' },
];
const EmployerNavItems: NavigationLinkType[] = [
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
{ name: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon /> },
{ name: 'Resume Builder', path: '/resume-builder', icon: <WorkIcon /> },
{ name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: <WorkIcon /> },
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' },
// { name: 'Search', icon: <SearchIcon />, path: '/search' },
// { name: 'Saved', icon: <BookmarkIcon />, path: '/saved' },
// { name: 'Jobs', icon: <WorkIcon />, path: '/jobs' },
// { name: 'Company', icon: <BusinessIcon />, path: '/company' },
// { name: 'Analytics', icon: <BarChartIcon />, path: '/analytics' },
// { name: 'Settings', icon: <SettingsIcon />, path: '/settings' },
];
// Navigation links based on user type
const getNavigationLinks = (user: UserInfo | null): NavigationLinkType[] => {
if (!user) {
return DefaultNavItems;
}
if (user.type === 'candidate' && user.isAuthenticated) {
return CandidateNavItems;
}
// Employer navigation
return EmployerNavItems;
};
interface BackstoryPageContainerProps {
children?: React.ReactNode;
sx?: SxProps<Theme>;
};
const BackstoryPageContainer = (props : BackstoryPageContainerProps) => {
const { children, sx } = props;
return (
<Container
className="BackstoryPageContainer"
maxWidth="xl"
sx={{
display: "flex",
flexGrow: 1,
p: { xs: 0, sm: 0.5 }, // Zero padding on mobile (xs), 0.5 on larger screens (sm and up)
mt: 0,
mb: 0,
// width: "100%",
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
...sx
}}>
<Paper
elevation={2}
sx={{
display: "flex",
flexGrow: 1,
p: 0.5,
backgroundColor: 'background.paper',
borderRadius: 0.5,
minHeight: '80vh',
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
flexDirection: "column",
}}>
{children}
</Paper>
</Container>
);
}
const BackstoryLayout: React.FC<{
sessionId: string | undefined;
setSnack: SetSnackType;
page: string;
chatRef: React.Ref<any>;
snackRef: React.Ref<any>;
submitQuery: any;
}> = ({ sessionId, setSnack, page, chatRef, snackRef, submitQuery }) => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useUser();
const [navigationLinks, setNavigationLinks] = useState<NavigationLinkType[]>([]);
useEffect(() => {
setNavigationLinks(getNavigationLinks(user));
}, [user]);
let dynamicRoutes;
if (sessionId) {
dynamicRoutes = getBackstoryDynamicRoutes({
sessionId,
setSnack,
submitQuery,
chatRef
}, user);
}
return (
<Box sx={{ height: "100%", maxHeight: "100%", minHeight: "100%", flexDirection: "column" }}>
<Header {...{ setSnack, sessionId, user, currentPath: page, navigate, navigationLinks }} />
<Box sx={{
display: "flex",
width: "100%",
maxHeight: "100%",
minHeight: "100%",
flex: 1,
m: 0,
p: 0,
flexDirection: "column",
backgroundColor: "#D3CDBF", /* Warm Gray */
}}>
<Scrollable
className="BackstoryPageScrollable"
sx={{
m: 0,
p: 0,
mt: "72px", /* Needs to be kept in sync with the height of Header if the Header theme changes */
display: "flex",
flexDirection: "column",
backgroundColor: "background.default",
height: "100%",
maxHeight: "100%",
minHeight: "100%",
minWidth: "min-content",
}}
>
<BackstoryPageContainer>
{!sessionId &&
<Box>
<LoadingComponent
loadingText="Creating session..."
loaderType="linear"
withFade={true}
fadeDuration={1200} />
</Box>
}
{sessionId && <>
<Outlet />
{dynamicRoutes !== undefined && <Routes>{dynamicRoutes}</Routes>}
</>
}
{location.pathname === "/" && <Footer />}
</BackstoryPageContainer>
</Scrollable>
<Snack ref={snackRef} />
</Box>
</Box>
);
};
export type {
NavigationLinkType
};
export {
BackstoryLayout
};

View File

@ -1,93 +0,0 @@
import React, { Ref, Fragment, ReactNode } from "react";
import { Route } from "react-router-dom";
import { useUser } from "./UserContext";
import { Box, Typography, Container, Paper } from '@mui/material';
import { BackstoryPageProps } from '../../Components/BackstoryTab';
import { ConversationHandle } from './Conversation';
import { UserInfo } from './UserContext';
import { ChatPage } from '../Pages/ChatPage';
import { ResumeBuilderPage } from '../../Pages/ResumeBuilderPage';
import { DocsPage } from '../Pages/DocsPage';
import { CreateProfilePage } from '../Pages/CreateProfilePage';
import { VectorVisualizerPage } from 'Pages/VectorVisualizerPage';
import { HomePage } from '../Pages/HomePage';
import { BetaPage } from '../Pages/BetaPage';
import { CandidateListingPage } from '../Pages/CandidateListingPage';
import { JobAnalysisPage } from '../Pages/JobAnalysisPage';
import { DemoComponent } from "NewApp/Pages/DemoComponent";
import { GenerateCandidate } from "NewApp/Pages/GenerateCandidate";
import { ControlsPage } from '../../Pages/ControlsPage';
const DashboardPage = () => (<BetaPage><Typography variant="h4">Dashboard</Typography></BetaPage>);
const ProfilePage = () => (<BetaPage><Typography variant="h4">Profile</Typography></BetaPage>);
const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>);
const ResumesPage = () => (<BetaPage><Typography variant="h4">Resumes</Typography></BetaPage>);
const QASetupPage = () => (<BetaPage><Typography variant="h4">Q&A Setup</Typography></BetaPage>);
const AnalyticsPage = () => (<BetaPage><Typography variant="h4">Analytics</Typography></BetaPage>);
const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typography></BetaPage>);
const SearchPage = () => (<BetaPage><Typography variant="h4">Search</Typography></BetaPage>);
const SavedPage = () => (<BetaPage><Typography variant="h4">Saved</Typography></BetaPage>);
const JobsPage = () => (<BetaPage><Typography variant="h4">Jobs</Typography></BetaPage>);
const CompanyPage = () => (<BetaPage><Typography variant="h4">Company</Typography></BetaPage>);
const LogoutPage = () => (<BetaPage><Typography variant="h4">Logout page...</Typography></BetaPage>);
const LoginPage = () => (<BetaPage><Typography variant="h4">Login page...</Typography></BetaPage>);
interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
chatRef: Ref<ConversationHandle>
}
const getBackstoryDynamicRoutes = (props : BackstoryDynamicRoutesProps, user?: UserInfo | null) : ReactNode => {
const { sessionId, setSnack, submitQuery, chatRef } = props;
let index=0
const routes = [
<Route key={`${index++}`} path="/" element={<HomePage/>} />,
<Route key={`${index++}`} path="/chat" element={<ChatPage ref={chatRef} setSnack={setSnack} sessionId={sessionId} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} sessionId={sessionId} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} sessionId={sessionId} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} sessionId={sessionId} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/knowledge-explorer" element={<VectorVisualizerPage setSnack={setSnack} sessionId={sessionId} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/find-a-candidate" element={<CandidateListingPage {...{sessionId, setSnack, submitQuery}} />} />,
<Route key={`${index++}`} path="/job-analysis" element={<JobAnalysisPage />} />,
<Route key={`${index++}`} path="/generate-candidate" element={<GenerateCandidate {...{ sessionId, setSnack, submitQuery }} />} />,
<Route key={`${index++}`} path="/settings" element={<ControlsPage {...{ sessionId, setSnack, submitQuery }} />} />,
];
if (user === undefined || user === null) {
routes.push(<Route key={`${index++}`} path="/register" element={(<BetaPage><CreateProfilePage /></BetaPage>)} />);
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage />} />);
routes.push(<Route key={`${index++}`} path="*" element={<BetaPage />} />);
} else {
if (!user.isAuthenticated) {
routes.push(<Route key={`${index++}`} path="/register" element={(<BetaPage><CreateProfilePage /></BetaPage>)} />);
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage />} />);
} else {
routes.push(<Route key={`${index++}`} path="/logout" element={<LogoutPage />} />);
}
if (user.type === "candidate" && user.isAuthenticated) {
routes.splice(-1, 0, ...[
<Route key={`${index++}`} path="/profile" element={<ProfilePage />} />,
<Route key={`${index++}`} path="/backstory" element={<BackstoryPage />} />,
<Route key={`${index++}`} path="/resumes" element={<ResumesPage />} />,
<Route key={`${index++}`} path="/qa-setup" element={<QASetupPage />} />,
]);
}
if (user.type === "employer") {
routes.splice(-1, 0, ...[
<Route key={`${index++}`} path="/search" element={<SearchPage />} />,
<Route key={`${index++}`} path="/saved" element={<SavedPage />} />,
<Route key={`${index++}`} path="/jobs" element={<JobsPage />} />,
<Route key={`${index++}`} path="/company" element={<CompanyPage />} />,
]);
}
}
routes.push(<Route key={`${index++}`} path="*" element={<BetaPage />} />);
return routes;
};
export { getBackstoryDynamicRoutes };

View File

@ -1,52 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { SxProps, useTheme } from '@mui/material/styles';
import './Beta.css';
type BetaProps = {
adaptive?: boolean;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
sx?: SxProps;
}
const Beta: React.FC<BetaProps> = (props : BetaProps) => {
const { onClick, adaptive = true, sx = {} } = props;
const betaRef = useRef<HTMLElement | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [animationKey, setAnimationKey] = useState<number>(0);
const [firstPass, setFirstPass] = useState<boolean>(true);
useEffect(() => {
// Initial animation trigger
if (firstPass && betaRef.current) {
triggerAnimation();
setFirstPass(false);
}
}, [firstPass]);
const triggerAnimation = (): void => {
if (!betaRef.current) return;
// Increment animation key to force React to recreate the element
setAnimationKey(prevKey => prevKey + 1);
// Ensure the animate class is present
betaRef.current.classList.add('animate');
};
return (
<Box sx={sx} className={`beta-clipper ${adaptive && isMobile && "mobile"}`} onClick={(e) => { onClick && onClick(e); }}>
<Box ref={betaRef} className={`beta-label ${adaptive && isMobile && "mobile"}`}>
<Box key={animationKey} className="particles"></Box>
<Box>BETA</Box>
</Box>
</Box>
);
};
export {
Beta
};

View File

@ -1,147 +0,0 @@
import React from 'react';
import { Box, Link, Typography, Avatar, Grid, Chip, SxProps, CardHeader } from '@mui/material';
import {
Card,
CardContent,
CardActionArea,
Divider,
useTheme,
} from '@mui/material';
import { useMediaQuery } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { UserInfo, useUser } from "./UserContext";
import { CopyBubble } from "../../Components/CopyBubble";
interface CandidateInfoProps {
sessionId: string;
user?: UserInfo;
sx?: SxProps;
action?: string;
};
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
const { user } = useUser();
const {
sx,
action = '',
sessionId,
} = props;
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// Format RAG content size (e.g., if it's in bytes, convert to KB/MB)
const formatRagSize = (size: number): string => {
if (size < 1000) return `${size} RAG elements`;
if (size < 1000000) return `${(size / 1000).toFixed(1)}K RAG elements`;
return `${(size / 1000000).toFixed(1)}M RAG elements`;
};
const candidate = props.user || user;
if (!candidate) {
return <Box>No user loaded.</Box>;
}
return (
<Card
elevation={1}
sx={{
display: "flex",
borderColor: 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
...sx
}}
>
<CardContent sx={{ flexGrow: 1, p: 3, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<Grid container spacing={2}>
<Grid
size={{ xs: 12, sm: 2 }}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minWidth: "80px",
maxWidth: "80px"
}}>
<Avatar
src={candidate.has_profile ? `/api/u/${candidate.username}/profile/${sessionId}?timestamp=${Date.now()}` : ''}
alt={`${candidate.full_name}'s profile`}
sx={{
alignSelf: "flex-start",
width: 80,
height: 80,
border: '2px solid #e0e0e0',
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 10 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1 }}>
<Box>
<Box sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
alignItems: "left",
gap: 1, "& > .MuiTypography-root": { m: 0 }
}}>
{
action !== '' &&
<Typography variant="body1">{action}</Typography>
}
<Typography variant="h5" component="h1"
sx={{
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}>
{candidate.full_name}
</Typography>
</Box>
<Box sx={{ fontSize: "0.75rem", alignItems: "center" }} >
<Link href={`/u/${candidate.username}`}>{`/u/${candidate.username}`}</Link>
<CopyBubble
onClick={(event: any) => { event.stopPropagation() }}
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} />
</Box>
</Box>
{candidate.rag_content_size !== undefined && candidate.rag_content_size > 0 &&
<Chip
onClick={(event: React.MouseEvent<HTMLDivElement>) => { navigate('/knowledge-explorer'); event.stopPropagation() }}
label={formatRagSize(candidate.rag_content_size)}
color="primary"
size="small"
sx={{ ml: 2 }}
/>}
</Box>
<Typography variant="body1" color="text.secondary">
{candidate.description}
</Typography>
<Divider sx={{ my: 2 }} />
{ candidate.location && <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location}
</Typography> }
{ candidate.email && <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography> }
{ candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography> }
</Grid>
</Grid>
</CardContent>
</Card>
);
};
export { CandidateInfo };

View File

@ -1,474 +0,0 @@
import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send';
import CancelIcon from '@mui/icons-material/Cancel';
import { SxProps, Theme } from '@mui/material';
import PropagateLoader from "react-spinners/PropagateLoader";
import { Message, MessageList, BackstoryMessage, MessageRoles } from '../../Components/Message';
import { DeleteConfirmation } from '../../Components/DeleteConfirmation';
import { Query } from '../../Components/ChatQuery';
import { BackstoryTextField, BackstoryTextFieldRef } from '../../Components/BackstoryTextField';
import { BackstoryElementProps } from '../../Components/BackstoryTab';
import { connectionBase } from '../../Global';
import { useUser } from "../Components/UserContext";
import { streamQueryResponse, StreamQueryController } from './streamQueryResponse';
import './Conversation.css';
const loadingMessage: BackstoryMessage = { "role": "status", "content": "Establishing connection with server..." };
type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check' | 'persona';
interface ConversationHandle {
submitQuery: (query: Query) => void;
fetchHistory: () => void;
}
interface ConversationProps extends BackstoryElementProps {
className?: string, // Override default className
type: ConversationMode, // Type of Conversation chat
placeholder?: string, // Prompt to display in TextField input
actionLabel?: string, // Label to put on the primary button
resetAction?: () => void, // Callback when Reset is pressed
resetLabel?: string, // Label to put on Reset button
defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField
defaultQuery?: string, // Default text to populate the TextField input
preamble?: MessageList, // Messages to display at start of Conversation until Action has been invoked
hidePreamble?: boolean, // Whether to hide the preamble after an Action has been invoked
hideDefaultPrompts?: boolean, // Whether to hide the defaultPrompts after an Action has been invoked
messageFilter?: ((messages: MessageList) => MessageList) | undefined, // Filter callback to determine which Messages to display in Conversation
messages?: MessageList, //
sx?: SxProps<Theme>,
onResponse?: ((message: BackstoryMessage) => void) | undefined, // Event called when a query completes (provides messages)
};
const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: ConversationProps, ref) => {
const {
sessionId,
actionLabel,
className,
defaultPrompts,
defaultQuery,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetAction,
resetLabel,
setSnack,
submitQuery,
sx,
type,
} = props;
const { user } = useUser()
const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0);
const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<MessageList>([]);
const [filteredConversation, setFilteredConversation] = useState<MessageList>([]);
const [processingMessage, setProcessingMessage] = useState<BackstoryMessage | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<BackstoryMessage | undefined>(undefined);
const timerRef = useRef<any>(null);
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const conversationRef = useRef<MessageList>([]);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamQueryController>(null);
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered = messageFilter(conversation); /* Do not copy conversation or useEffect will loop forever */
//console.log(`${conversation.length - filtered.length} messages filtered out.`);
}
if (filtered.length === 0) {
setFilteredConversation([
...(preamble || []),
...(messages || []),
]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : (preamble || [])),
...(messages || []),
...filtered,
]);
};
}, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]);
const fetchHistory = useCallback(async () => {
let retries = 5;
while (--retries > 0) {
try {
const response = await fetch(connectionBase + `/api/history/${sessionId}/${type}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const { messages } = await response.json();
if (messages === undefined || messages.length === 0) {
console.log(`History returned for ${type} from server with 0 entries`)
setConversation([])
setNoInteractions(true);
} else {
console.log(`History returned for ${type} from server with ${messages.length} entries:`, messages)
const backstoryMessages: BackstoryMessage[] = messages;
setConversation(backstoryMessages.flatMap((backstoryMessage: BackstoryMessage) => {
if (backstoryMessage.status === "partial") {
return [{
...backstoryMessage,
role: "assistant",
content: backstoryMessage.response || "",
expanded: false,
expandable: true,
}]
}
return [{
role: 'user',
content: backstoryMessage.prompt || "",
}, {
...backstoryMessage,
role: ['done'].includes(backstoryMessage.status || "") ? "assistant" : backstoryMessage.status,
content: backstoryMessage.response || "",
}] as MessageList;
}));
setNoInteractions(false);
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
return;
} catch (error) {
console.error('Error generating session ID:', error);
setProcessingMessage({ role: "error", content: `Unable to obtain history from server. Retrying in 3 seconds (${retries} remain.)` });
setTimeout(() => {
setProcessingMessage(undefined);
}, 3000);
await new Promise(resolve => setTimeout(resolve, 3000));
setSnack("Unable to obtain chat history.", "error");
}
};
}, [setConversation,setSnack, type, sessionId]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (sessionId === undefined) {
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
if (user) {
fetchHistory();
}
}, [fetchHistory, sessionId, setProcessing, user]);
const startCountdown = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current);
setCountdown(seconds);
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current);
timerRef.current = null;
return 0;
}
return prev - 1;
});
}, 1000);
};
const stopCountdown = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
setCountdown(0);
}
};
const handleEnter = (value: string) => {
const query: Query = {
prompt: value
}
processQuery(query);
};
useImperativeHandle(ref, () => ({
submitQuery: (query: Query) => {
processQuery(query);
},
fetchHistory: () => { return fetchHistory(); }
}));
const reset = async () => {
try {
const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ reset: ['history'] })
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
} catch (e) {
setSnack("Error resetting history", "error")
console.error('Error resetting history:', e);
}
};
const cancelQuery = () => {
console.log("Stop query");
if (controllerRef.current) {
controllerRef.current.abort();
}
controllerRef.current = null;
};
const processQuery = (query: Query) => {
if (controllerRef.current) {
return;
}
setNoInteractions(false);
setConversation([
...conversationRef.current,
{
role: 'user',
origin: type,
content: query.prompt,
disableCopy: true
}
]);
setProcessing(true);
setProcessingMessage(
{ role: 'status', content: 'Submitting request...', disableCopy: true }
);
controllerRef.current = streamQueryResponse({
query,
type,
sessionId,
connectionBase,
onComplete: (msg) => {
console.log(msg);
switch (msg.status) {
case "done":
case "partial":
setConversation([
...conversationRef.current, {
...msg,
role: 'assistant',
origin: type,
prompt: ['done', 'partial'].includes(msg.status || "") ? msg.prompt : '',
content: msg.response || "",
expanded: msg.status === "done" ? true : false,
expandable: msg.status === "done" ? false : true,
}] as MessageList);
startCountdown(Math.ceil(msg.remaining_time || 0));
if (msg.status === "done") {
stopCountdown();
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
controllerRef.current = null;
}
if (onResponse) {
onResponse(msg);
}
break;
case "error":
// Show error
setConversation([
...conversationRef.current, {
...msg,
role: 'error',
origin: type,
content: msg.response || "",
}] as MessageList);
setProcessingMessage(msg);
setProcessing(false);
stopCountdown();
controllerRef.current = null;
break;
default:
setProcessingMessage({ role: (msg.status || "error") as MessageRoles, content: msg.response || "", disableCopy: true });
break;
}
},
onStreaming: (chunk) => {
setStreamingMessage({ role: "streaming", content: chunk, disableCopy: true });
}
});
};
return (
// <Scrollable
// className={`${className || ""} Conversation`}
// autoscroll
// textFieldRef={viewableElementRef}
// fallbackThreshold={0.5}
// sx={{
// p: 1,
// mt: 0,
// ...sx
// }}
// >
<Box className="Conversation" sx={{ flexGrow: 1, minHeight: "max-content", height: "max-content", maxHeight: "max-content", overflow: "hidden" }}>
<Box sx={{ p: 1, mt: 0, ...sx }}>
{
filteredConversation.map((message, index) =>
<Message key={index} expanded={message.expanded === undefined ? true : message.expanded} {...{ sendQuery: processQuery, message, connectionBase, sessionId, setSnack, submitQuery }} />
)
}
{
processingMessage !== undefined &&
<Message {...{ sendQuery: processQuery, connectionBase, sessionId, setSnack, message: processingMessage, submitQuery }} />
}
{
streamingMessage !== undefined &&
<Message {...{ sendQuery: processQuery, connectionBase, sessionId, setSnack, message: streamingMessage, submitQuery }} />
}
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 1,
}}>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
{processing === true && countdown > 0 && (
<Box
sx={{
pt: 1,
fontSize: "0.7rem",
color: "darkgrey"
}}
>Response will be stopped in: {countdown}s</Box>
)}
</Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}>
{placeholder &&
<Box sx={{ display: "flex", flexGrow: 1, p: 0, m: 0, flexDirection: "column" }}
ref={viewableElementRef}>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
/>
</Box>
}
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<DeleteConfirmation
label={resetLabel || "all data"}
disabled={sessionId === undefined || processingMessage !== undefined || noInteractions}
onDelete={() => { reset(); resetAction && resetAction(); }} />
<Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={sessionId === undefined || processingMessage !== undefined}
onClick={() => { processQuery({ prompt: (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "" }); }}>
{actionLabel}<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={() => { cancelQuery(); }}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={stopRef.current || sessionId === undefined || processing === false}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length !== 0 &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
{
defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>);
})
}
</Box>
}
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box >
</Box>
);
});
export type {
ConversationProps,
ConversationHandle,
};
export {
Conversation
};

View File

@ -1,57 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography } from '@mui/material';
import { Message } from '../../Components/Message';
import { ChatBubble } from '../../Components/ChatBubble';
import { BackstoryElementProps } from '../../Components/BackstoryTab';
import { StyledMarkdown } from './StyledMarkdown';
interface DocumentProps extends BackstoryElementProps {
filepath?: string;
}
const Document = (props: DocumentProps) => {
const { sessionId, setSnack, submitQuery, filepath } = props;
const backstoryProps = {
submitQuery,
setSnack,
sessionId
};
const [document, setDocument] = useState<string>("");
// Get the markdown
useEffect(() => {
if (!filepath) {
return;
}
const fetchDocument = async () => {
try {
const response = await fetch(filepath, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw Error(`${filepath} not found.`);
}
const data = await response.text();
setDocument(data);
} catch (error: any) {
console.error('Error obtaining Docs content information:', error);
setDocument(`${filepath} not found.`);
};
};
fetchDocument();
}, [document, setDocument, filepath])
return (<>
<StyledMarkdown {...backstoryProps} content={document}/>
</>);
};
export {
Document
};

View File

@ -1,155 +0,0 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import CancelIcon from '@mui/icons-material/Cancel';
import SendIcon from '@mui/icons-material/Send';
import PropagateLoader from 'react-spinners/PropagateLoader';
import { CandidateInfo } from '../Components/CandidateInfo';
import { Query } from '../../Components/ChatQuery'
import { Quote } from 'NewApp/Components/Quote';
import { streamQueryResponse, StreamQueryController } from '../Components/streamQueryResponse';
import { connectionBase } from 'Global';
import { UserInfo } from '../Components/UserContext';
import { BackstoryElementProps } from 'Components/BackstoryTab';
import { BackstoryTextField, BackstoryTextFieldRef } from 'Components/BackstoryTextField';
import { jsonrepair } from 'jsonrepair';
import { StyledMarkdown } from 'NewApp/Components/StyledMarkdown';
import { Scrollable } from 'Components/Scrollable';
import { Pulse } from 'NewApp/Components/Pulse';
import { useUser } from '../Components/UserContext';
interface GenerateImageProps extends BackstoryElementProps {
prompt: string
};
const GenerateImage = (props: GenerateImageProps) => {
const { user } = useUser();
const {sessionId, setSnack, prompt} = props;
const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>('');
const [timestamp, setTimestamp] = useState<number>(0);
const [image, setImage] = useState<string>('');
// Only keep refs that are truly necessary
const controllerRef = useRef<StreamQueryController>(null);
// Effect to trigger profile generation when user data is ready
useEffect(() => {
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
return;
}
if (!prompt) {
return;
}
setStatus('Starting image generation...');
setProcessing(true);
const start = Date.now();
controllerRef.current = streamQueryResponse({
query: {
prompt: prompt,
agent_options: {
username: user?.username,
}
},
type: "image",
sessionId,
connectionBase,
onComplete: (msg) => {
switch (msg.status) {
case "partial":
case "done":
if (msg.status === "done") {
if (!msg.response) {
setSnack("Image generation failed", "error");
} else {
setImage(msg.response);
}
setProcessing(false);
controllerRef.current = null;
}
break;
case "error":
console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
setSnack(msg.response || "", "error");
setProcessing(false);
controllerRef.current = null;
break;
default:
let data: any = {};
try {
data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
} catch (e) {
data = { message: msg.response };
}
if (msg.status !== "heartbeat") {
console.log(data);
}
if (data.timestamp) {
setTimestamp(data.timestamp);
} else {
setTimestamp(Date.now())
}
if (data.message) {
setStatus(data.message);
}
break;
}
}
});
}, [user, prompt, sessionId, setSnack]);
if (!sessionId) {
return <></>;
}
return (
<Box className="GenerateImage" sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
minHeight: "max-content",
}}>
{image !== '' && <img alt={prompt} src={`${image}/${sessionId}`} />}
{ prompt &&
<Quote size={processing ? "normal" : "small"} quote={prompt} sx={{ "& *": { color: "#2E2E2E !important" }}}/>
}
{processing &&
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 0,
gap: 1,
minHeight: "min-content",
mb: 2
}}>
{ status &&
<Box sx={{ display: "flex", flexDirection: "column"}}>
<Box sx={{ fontSize: "0.5rem"}}>Generation status</Box>
<Box sx={{ fontWeight: "bold"}}>{status}</Box>
</Box>
}
<PropagateLoader
size="10px"
loading={processing}
color="white"
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
}
</Box>);
};
export {
GenerateImage
};

View File

@ -1,446 +0,0 @@
import React, { useEffect, useState } from 'react';
import { NavigateFunction, useLocation } from 'react-router-dom';
import {
AppBar,
Toolbar,
Tooltip,
Typography,
Button,
IconButton,
Box,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Drawer,
Divider,
Avatar,
Tabs,
Tab,
Container,
Fade,
} from '@mui/material';
import { styled, useTheme } from '@mui/material/styles';
import {
Menu as MenuIcon,
Dashboard,
Person,
Logout,
Settings,
ExpandMore,
} from '@mui/icons-material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { NavigationLinkType } from './BackstoryLayout';
import { Beta } from './Beta';
import './Header.css';
import { useUser, UserInfo } from './UserContext';
import { SetSnackType } from '../../Components/Snack';
import { CopyBubble } from '../../Components/CopyBubble';
// Styled components
const StyledAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== 'transparent',
})<{ transparent?: boolean }>(({ theme, transparent }) => ({
backgroundColor: transparent ? 'transparent' : theme.palette.primary.main,
boxShadow: transparent ? 'none' : '',
transition: 'background-color 0.3s ease',
}));
const NavLinksContainer = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'center',
flex: 1,
[theme.breakpoints.down('md')]: {
display: 'none',
},
}));
const UserActionsContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const UserButton = styled(Button)(({ theme }) => ({
color: theme.palette.primary.contrastText,
textTransform: 'none',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}));
const MobileDrawer = styled(Drawer)(({ theme }) => ({
'& .MuiDrawer-paper': {
width: 280,
backgroundColor: theme.palette.background.paper,
},
}));
interface HeaderProps {
transparent?: boolean;
onLogout?: () => void;
className?: string;
navigate: NavigateFunction;
navigationLinks: NavigationLinkType[];
showLogin?: boolean;
currentPath: string;
sessionId?: string | null;
setSnack: SetSnackType,
}
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const { user } = useUser();
const {
transparent = false,
className,
navigate,
navigationLinks,
showLogin,
currentPath,
sessionId,
onLogout,
setSnack,
} = props;
const theme = useTheme();
const location = useLocation();
const BackstoryLogo = () => {
return <Typography
variant="h6"
className="BackstoryLogo"
noWrap
sx={{
cursor: "pointer",
fontWeight: 700,
letterSpacing: '.2rem',
color: theme.palette.primary.contrastText,
textDecoration: 'none',
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
textTransform: "uppercase",
}}
>
<Avatar sx={{ width: 24, height: 24 }}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />
Backstory
</Typography>
};
const navLinks : NavigationLinkType[] = [
{name: "Home", path: "/", label: <BackstoryLogo/>},
...navigationLinks
];
// State for page navigation
const [ currentTab, setCurrentTab ] = useState<string>("/");
// State for mobile drawer
const [mobileOpen, setMobileOpen] = useState(false);
// State for user menu
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const userMenuOpen = Boolean(userMenuAnchor);
useEffect(() => {
const parts = location.pathname.split('/');
let tab = '/';
if (parts.length > 1) {
tab = `/${parts[1]}`;
}
if (tab !== currentTab) {
setCurrentTab(tab);
}
}, [location, currentTab]);
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchor(event.currentTarget);
};
const handleUserMenuClose = () => {
setUserMenuAnchor(null);
};
const handleLogout = () => {
handleUserMenuClose();
if (onLogout) {
onLogout();
}
};
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
// Render desktop navigation links
const renderNavLinks = () => {
return (
<Tabs value={currentTab} onChange={(e, newValue) => setCurrentTab(newValue)}
indicatorColor="secondary"
textColor="inherit"
variant="fullWidth"
allowScrollButtonsMobile
aria-label="Backstory navigation"
>
{navLinks.map((link) => (
<Tab
sx={{
minWidth: link.path === '/' ? "max-content" : "auto",
}}
key={link.name}
value={link.path}
label={link.label ? link.label : link.name}
onClick={() => {
navigate(link.path);
}}
/>
))}
</Tabs>
);
};
// Render mobile drawer content
const renderDrawerContent = () => {
return (
<>
<Tabs
orientation="vertical"
value={currentTab} >
{navLinks.map((link) => (
<Tab
key={link.name}
value={link.path}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{link.icon && <Box sx={{ mr: 1 }}>{link.icon}</Box>}
{link.name}
</Box>
}
onClick={(e) => { handleDrawerToggle() ; setCurrentTab(link.path); navigate(link.path);} }
/>
))}
</Tabs>
<Divider />
{(!user || !user.isAuthenticated) && (showLogin === undefined || showLogin !== false) && (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
variant="contained"
color="secondary"
fullWidth
onClick={() => { navigate("/login"); }}
>
Login
</Button>
<Button
variant="outlined"
color="secondary"
fullWidth
onClick={() => { navigate("/register"); }}
>
Register
</Button>
</Box>
)}
</>
);
};
// Render user account section
const renderUserSection = () => {
if (showLogin !== undefined && showLogin === false) {
return <></>;
}
if (!user || !user.isAuthenticated) {
return (
<>
<Button
color="info"
variant="contained"
onClick={() => navigate("/login") }
sx={{
display: { xs: 'none', sm: 'block' },
color: theme.palette.primary.contrastText,
}}
>
Login
</Button>
<Button
color="secondary"
variant="contained"
onClick={() => { navigate("/register"); }}
sx={{ display: { xs: 'none', sm: 'block' } }}
>
Register
</Button>
</>
);
}
return (
<>
<UserButton
onClick={handleUserMenuOpen}
aria-controls={userMenuOpen ? 'user-menu' : undefined}
aria-haspopup="true"
aria-expanded={userMenuOpen ? 'true' : undefined}
>
<Avatar sx={{
width: 32,
height: 32,
bgcolor: theme.palette.secondary.main,
}}>
{user?.full_name.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
{user?.full_name}
</Box>
<ExpandMore fontSize="small" />
</UserButton>
<Menu
id="user-menu"
anchorEl={userMenuAnchor}
open={userMenuOpen}
onClose={handleUserMenuClose}
slots={{
transition: Fade,
}}
slotProps={{
list: {
'aria-labelledby': 'user-button',
sx: {
display: 'flex',
flexDirection: 'column', // Adjusted for menu items
alignItems: 'center',
gap: '1rem',
textTransform: 'uppercase', // All caps as requested
},
},
paper: {
sx: {
minWidth: 200, // Optional: ensures reasonable menu width
},
},
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<MenuItem onClick={handleUserMenuClose} component="a" href="/profile">
<ListItemIcon>
<Person fontSize="small" />
</ListItemIcon>
<ListItemText>Profile</ListItemText>
</MenuItem>
<MenuItem onClick={handleUserMenuClose} component="a" href="/dashboard">
<ListItemIcon>
<Dashboard fontSize="small" />
</ListItemIcon>
<ListItemText>Dashboard</ListItemText>
</MenuItem>
<MenuItem onClick={handleUserMenuClose} component="a" href="/settings">
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</>
);
};
return (
<StyledAppBar
position="fixed"
transparent={transparent}
className={className}
sx={{ overflow: "hidden" }}
>
<Container maxWidth="xl">
<Toolbar disableGutters>
{/* Logo Section */}
{/* Navigation Links - Desktop */}
<NavLinksContainer>
{renderNavLinks()}
</NavLinksContainer>
{/* User Actions Section */}
<UserActionsContainer>
{renderUserSection()}
{/* Mobile Menu Button */}
<Tooltip title="Open Menu">
<IconButton
color="inherit"
aria-label="open drawer"
edge="end"
onClick={handleDrawerToggle}
sx={{ display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
</Tooltip>
{sessionId && <CopyBubble
tooltip="Copy link"
color="inherit"
aria-label="copy link"
edge="end"
sx={{
width: 36,
height: 36,
opacity: 1,
bgcolor: 'inherit',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
}}
content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`}
onClick={() => { navigate(`${window.location.pathname}?id=${sessionId}`); setSnack("Link copied!") }}
size="large"
/>}
</UserActionsContainer>
{/* Mobile Navigation Drawer */}
<MobileDrawer
variant="temporary"
anchor="right"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile
}}
>
{renderDrawerContent()}
</MobileDrawer>
</Toolbar>
</Container>
<Beta sx={{ left: "-90px", "& .mobile": { right: "-72px" } }} onClick={() => { navigate('/docs/beta'); }} />
</StyledAppBar>
);
};
export {
Header
};

View File

@ -1,378 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
Grid,
Chip,
Divider,
Card,
CardContent,
useTheme,
LinearProgress
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PendingIcon from '@mui/icons-material/Pending';
import WarningIcon from '@mui/icons-material/Warning';
// Define TypeScript interfaces for our data structures
interface Citation {
text: string;
source: string;
relevance: number; // 0-100 scale
}
interface SkillMatch {
requirement: string;
status: 'pending' | 'complete' | 'error';
matchScore: number; // 0-100 scale
assessment: string;
citations: Citation[];
}
interface JobAnalysisProps {
jobTitle: string;
candidateName: string;
// This function would connect to your backend and return updates
fetchRequirements: () => Promise<string[]>;
// This function would fetch match data for a specific requirement
fetchMatchForRequirement: (requirement: string) => Promise<SkillMatch>;
}
const JobMatchAnalysis: React.FC<JobAnalysisProps> = ({
jobTitle,
candidateName,
fetchRequirements,
fetchMatchForRequirement
}) => {
const theme = useTheme();
const [requirements, setRequirements] = useState<string[]>([]);
const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]);
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(true);
const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0);
// Handle accordion expansion
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false);
};
// Fetch initial requirements
useEffect(() => {
const getRequirements = async () => {
try {
const fetchedRequirements = await fetchRequirements();
setRequirements(fetchedRequirements);
// Initialize skill matches with pending status
const initialSkillMatches = fetchedRequirements.map(req => ({
requirement: req,
status: 'pending' as const,
matchScore: 0,
assessment: '',
citations: []
}));
setSkillMatches(initialSkillMatches);
setLoadingRequirements(false);
} catch (error) {
console.error("Error fetching requirements:", error);
setLoadingRequirements(false);
}
};
getRequirements();
}, [fetchRequirements]);
// Fetch match data for each requirement
useEffect(() => {
const fetchMatchData = async () => {
if (requirements.length === 0) return;
// Process requirements one by one
for (let i = 0; i < requirements.length; i++) {
try {
const match = await fetchMatchForRequirement(requirements[i]);
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = match;
return updated;
});
// Update overall score
setSkillMatches(current => {
const completedMatches = current.filter(match => match.status === 'complete');
if (completedMatches.length > 0) {
const newOverallScore = completedMatches.reduce((sum, match) => sum + match.matchScore, 0) / completedMatches.length;
setOverallScore(newOverallScore);
}
return current;
});
} catch (error) {
console.error(`Error fetching match for requirement ${requirements[i]}:`, error);
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = {
...updated[i],
status: 'error',
assessment: 'Failed to analyze this requirement.'
};
return updated;
});
}
}
};
if (!loadingRequirements) {
fetchMatchData();
}
}, [requirements, loadingRequirements, fetchMatchForRequirement]);
// Get color based on match score
const getMatchColor = (score: number): string => {
if (score >= 80) return theme.palette.success.main;
if (score >= 60) return theme.palette.info.main;
if (score >= 40) return theme.palette.warning.main;
return theme.palette.error.main;
};
// Get icon based on status
const getStatusIcon = (status: string, score: number) => {
if (status === 'pending') return <PendingIcon />;
if (status === 'error') return <ErrorIcon color="error" />;
if (score >= 70) return <CheckCircleIcon color="success" />;
if (score >= 40) return <WarningIcon color="warning" />;
return <ErrorIcon color="error" />;
};
return (
<Box>
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Job Match Analysis
</Typography>
<Divider sx={{ mb: 2 }} />
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" component="h2">
Job: {jobTitle}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" component="h2">
Candidate: {candidateName}
</Typography>
</Grid>
<Grid size={{ xs: 12 }} sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h5" component="h2" sx={{ mr: 2 }}>
Overall Match:
</Typography>
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
{`${Math.round(overallScore)}%`}
</Typography>
</Box>
</Box>
<Chip
label={
overallScore >= 80 ? "Excellent Match" :
overallScore >= 60 ? "Good Match" :
overallScore >= 40 ? "Partial Match" : "Low Match"
}
sx={{
bgcolor: getMatchColor(overallScore),
color: 'white',
fontWeight: 'bold'
}}
/>
</Box>
</Grid>
</Grid>
</Paper>
{loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
Analyzing job requirements...
</Typography>
</Box>
) : (
<Box>
<Typography variant="h5" component="h2" gutterBottom>
Requirements Analysis
</Typography>
{skillMatches.map((match, index) => (
<Accordion
key={index}
expanded={expanded === `panel${index}`}
onChange={handleAccordionChange(`panel${index}`)}
sx={{
mb: 2,
border: '1px solid',
borderColor: match.status === 'complete'
? getMatchColor(match.matchScore)
: theme.palette.divider
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel${index}bh-content`}
id={`panel${index}bh-header`}
sx={{
bgcolor: match.status === 'complete'
? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit'
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'space-between'
}}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{getStatusIcon(match.status, match.matchScore)}
<Typography sx={{ ml: 1, fontWeight: 'medium' }}>
{match.requirement}
</Typography>
</Box>
{match.status === 'complete' ? (
<Chip
label={`${match.matchScore}% Match`}
size="small"
sx={{
bgcolor: getMatchColor(match.matchScore),
color: 'white',
minWidth: 90
}}
/>
) : match.status === 'pending' ? (
<Chip
label="Analyzing..."
size="small"
sx={{ bgcolor: theme.palette.grey[400], color: 'white', minWidth: 90 }}
/>
) : (
<Chip
label="Error"
size="small"
sx={{ bgcolor: theme.palette.error.main, color: 'white', minWidth: 90 }}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails>
{match.status === 'pending' ? (
<Box sx={{ width: '100%', p: 2 }}>
<LinearProgress />
<Typography sx={{ mt: 2 }}>
Analyzing candidate's match for this requirement...
</Typography>
</Box>
) : match.status === 'error' ? (
<Typography color="error">
{match.assessment || "An error occurred while analyzing this requirement."}
</Typography>
) : (
<Box>
<Typography variant="h6" gutterBottom>
Assessment:
</Typography>
<Typography paragraph sx={{ mb: 3 }}>
{match.assessment}
</Typography>
<Typography variant="h6" gutterBottom>
Supporting Evidence:
</Typography>
{match.citations.length > 0 ? (
match.citations.map((citation, citIndex) => (
<Card
key={citIndex}
variant="outlined"
sx={{
mb: 2,
borderLeft: '4px solid',
borderColor: theme.palette.primary.main,
}}
>
<CardContent>
<Typography variant="body1" component="div" sx={{ mb: 1, fontStyle: 'italic' }}>
"{citation.text}"
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
Source: {citation.source}
</Typography>
<Chip
size="small"
label={`Relevance: ${citation.relevance}%`}
sx={{
bgcolor: theme.palette.grey[200],
}}
/>
</Box>
</CardContent>
</Card>
))
) : (
<Typography color="text.secondary">
No specific evidence found in candidate's profile.
</Typography>
)}
</Box>
)}
</AccordionDetails>
</Accordion>
))}
</Box>
)}
</Box>
);
};
export { JobMatchAnalysis };

View File

@ -1,143 +0,0 @@
import React from 'react';
import { MuiMarkdown } from 'mui-markdown';
import { SxProps, useTheme } from '@mui/material/styles';
import { Link } from '@mui/material';
import { ChatQuery } from '../../Components/ChatQuery';
import Box from '@mui/material/Box';
import JsonView from '@uiw/react-json-view';
import { vscodeTheme } from '@uiw/react-json-view/vscode';
import { Mermaid } from '../../Components/Mermaid';
import { Scrollable } from '../../Components/Scrollable';
import { jsonrepair } from 'jsonrepair';
import { GenerateImage } from './GenerateImage';
import './StyledMarkdown.css';
import { BackstoryElementProps } from '../../Components/BackstoryTab';
interface StyledMarkdownProps extends BackstoryElementProps {
className?: string,
content: string,
streaming?: boolean,
};
const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProps) => {
const { className, sessionId, content, submitQuery, sx, streaming, setSnack } = props;
const theme = useTheme();
const overrides: any = {
pre: {
component: (element: any) => {
const { className } = element.children.props;
const content = element.children?.props?.children || "";
if (className === "lang-mermaid" && !streaming) {
return <Mermaid className="Mermaid" chart={content} />;
}
if (className === "lang-markdown") {
return <MuiMarkdown children={content} />;
}
if (className === "lang-json" && !streaming) {
try {
let fixed = JSON.parse(jsonrepair(content));
return <Scrollable className="JsonViewScrollable">
<JsonView
className="JsonView"
style={{
...vscodeTheme,
fontSize: "0.8rem",
maxHeight: "10rem",
padding: "14px 0",
overflow: "hidden",
width: "100%",
minHeight: "max-content",
backgroundColor: "transparent",
}}
displayDataTypes={false}
objectSortKeys={false}
collapsed={1}
shortenTextAfterLength={100}
value={fixed}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre>
}
}}
/>
</JsonView>
</Scrollable>
} catch (e) {
return <pre><code className="JsonRaw">{content}</code></pre>
};
}
return <pre><code className={className || ''}>{element.children}</code></pre>;
},
},
a: {
component: Link,
props: {
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
const href = event.currentTarget.getAttribute('href');
console.log("StyledMarkdown onClick:", href, sessionId);
if (href) {
if (href.match(/^\//)) {
event.preventDefault();
window.history.replaceState({}, '', `${href}`);
}
}
},
sx: {
wordBreak: "break-all",
color: theme.palette.secondary.main,
textDecoration: 'none',
'&:hover': {
color: theme.palette.custom.highlight,
textDecoration: 'underline',
}
}
}
},
ChatQuery: {
component: (props: { query: string }) => {
const queryString = props.query.replace(/(\w+):/g, '"$1":');
try {
const query = JSON.parse(queryString);
return <ChatQuery submitQuery={submitQuery} query={query} />
} catch (e) {
console.log("StyledMarkdown error:", queryString, e);
return props.query;
}
},
},
GenerateImage: {
component: (props: { prompt: string }) => {
const prompt = props.prompt.replace(/(\w+):/g, '"$1":');
try {
return <GenerateImage prompt={prompt} {...{sessionId, submitQuery, setSnack}}/>
} catch (e) {
console.log("StyledMarkdown error:", prompt, e);
return props.prompt;
}
},
},
};
return <Box
className={`MuiMarkdown ${className || ""}`}
sx={{
display: "flex",
m: 0,
p: 0,
boxSizing: "border-box",
flexGrow: 1,
height: "auto",
...sx
}}>
<MuiMarkdown
overrides={overrides}
children={content}
/>
</Box>;
};
export { StyledMarkdown };

View File

@ -1,106 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { Tunables } from '../../Components/ChatQuery';
import { SetSnackType } from '../../Components/Snack';
import { connectionBase } from '../../Global';
// Define the UserInfo interface for type safety
interface UserQuestion {
question: string;
tunables?: Tunables;
};
interface UserInfo {
type: 'candidate' | 'employer' | 'guest';
description: string;
rag_content_size: number;
username: string;
first_name: string;
last_name: string;
full_name: string;
contact_info: Record<string, string>;
questions: UserQuestion[],
isAuthenticated: boolean,
has_profile: boolean,
title: string;
location: string;
email: string;
phone: string;
// Fields used in AI generated personas
age?: number,
ethnicity?: string,
gender?: string,
};
type UserContextType = {
user: UserInfo | null;
setUser: (user: UserInfo | null) => void;
};
const UserContext = createContext<UserContextType | undefined>(undefined);
const useUser = () => {
const ctx = useContext(UserContext);
if (!ctx) throw new Error("useUser must be used within a UserProvider");
return ctx;
};
interface UserProviderProps {
children: React.ReactNode;
sessionId: string | undefined;
setSnack: SetSnackType;
};
const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) => {
const { sessionId, children, setSnack } = props;
const [user, setUser] = useState<UserInfo | null>(null);
useEffect(() => {
if (!sessionId || user) {
return;
}
const fetchUserFromSession = async (): Promise<UserInfo | null> => {
try {
let response;
response = await fetch(`${connectionBase}/api/user/${sessionId}`, {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Session not found');
}
const user: UserInfo = {
...(await response.json()),
type: "guest",
isAuthenticated: false,
logout: () => { },
}
console.log("Loaded user:", user);
setUser(user);
} catch (err) {
setSnack("" + err);
setUser(null);
}
return null;
};
fetchUserFromSession();
}, [sessionId, user, setUser]);
if (sessionId === undefined) {
return <></>;
}
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
};
export type {
UserInfo
};
export {
UserProvider,
useUser
};

View File

@ -1,161 +0,0 @@
import { BackstoryMessage } from '../../Components/Message';
import { Query } from '../../Components/ChatQuery';
import { jsonrepair } from 'jsonrepair';
type StreamQueryOptions = {
query: Query;
type: string;
sessionId: string;
connectionBase: string;
onComplete: (message: BackstoryMessage) => void;
onStreaming?: (message: string) => void;
};
type StreamQueryController = {
abort: () => void
};
const streamQueryResponse = (options: StreamQueryOptions) => {
const {
query,
type,
sessionId,
connectionBase,
onComplete,
onStreaming,
} = options;
const abortController = new AbortController();
const run = async () => {
query.prompt = query.prompt.trim();
let data: any = query;
if (type === "job_description") {
data = {
prompt: "",
agent_options: {
job_description: query.prompt,
},
};
}
try {
const response = await fetch(`${connectionBase}/api/${type}/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(data),
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let streaming_response = '';
const processLine = async (line: string) => {
const update = JSON.parse(jsonrepair(line));
switch (update.status) {
case "streaming":
streaming_response += update.chunk;
onStreaming?.(streaming_response);
break;
case 'error':
const errorMessage: BackstoryMessage = {
...update,
role: 'error',
origin: type,
content: update.response ?? '',
};
onComplete(errorMessage);
break;
default:
const message: BackstoryMessage = {
...update,
role: 'assistant',
origin: type,
prompt: update.prompt ?? '',
content: update.response ?? '',
expanded: update.status === 'done',
expandable: update.status !== 'done',
};
streaming_response = '';
onComplete(message);
break;
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
await processLine(line);
} catch (e) {
console.error('Error processing line:', e);
console.error(line);
}
}
}
if (buffer.trim()) {
try {
await processLine(buffer);
} catch (e) {
console.error('Error processing remaining buffer:', e);
}
}
} catch (error) {
if ((error as any).name === 'AbortError') {
console.log('Query aborted');
onComplete({
role: 'error',
origin: type,
content: 'Query was cancelled.',
response: error,
status: 'error',
} as BackstoryMessage);
} else {
console.error('Fetch error:', error);
onComplete({
role: 'error',
origin: type,
content: 'Unable to process query',
response: "" + error,
status: 'error',
} as BackstoryMessage);
}
}
};
run();
return {
abort: () => abortController.abort(),
};
};
export type {
StreamQueryController
};
export { streamQueryResponse };

View File

@ -1,202 +0,0 @@
import React from 'react';
import { backstoryTheme } from '../BackstoryTheme';
import { Box, Typography, Paper, Container } from '@mui/material';
// This component provides a visual demonstration of the theme colors
const BackstoryThemeVisualizerPage = () => {
const colorSwatch = (color: string, name: string, textColor = '#fff') => (
<div className="flex flex-col items-center">
<div
className="w-20 h-20 rounded-lg shadow-md flex items-center justify-center mb-2"
style={{ backgroundColor: color, color: textColor }}>
{name}
</div>
<span className="text-xs">{color}</span>
</div>
);
return (
<Box sx={{ backgroundColor: 'background.default', minHeight: '100%', py: 4 }}>
<Container maxWidth="lg">
<Paper sx={{ p: 4, boxShadow: 2 }}>
<div className="p-8">
<h1 className="text-2xl font-bold mb-6" style={{ color: backstoryTheme.palette.text.primary }}>
Backstory Theme Visualization
</h1>
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Primary Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.primary.main, 'Primary', backstoryTheme.palette.primary.contrastText)}
{colorSwatch(backstoryTheme.palette.secondary.main, 'Secondary', backstoryTheme.palette.secondary.contrastText)}
{colorSwatch(backstoryTheme.palette.custom.highlight, 'Highlight', '#fff')}
</div>
</div>
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Background Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.background.default, 'Default', '#000')}
{colorSwatch(backstoryTheme.palette.background.paper, 'Paper', '#000')}
</div>
</div>
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Text Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.text.primary, 'Primary', '#fff')}
{colorSwatch(backstoryTheme.palette.text.secondary, 'Secondary', '#fff')}
</div>
</div>
<div className="mb-8 border p-6 rounded-lg" style={{ backgroundColor: backstoryTheme.palette.background.paper }}>
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Typography Examples
</h2>
<div className="mb-4">
<h1 style={{
fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.h1.fontSize,
fontWeight: backstoryTheme.typography.h1.fontWeight,
color: backstoryTheme.typography.h1.color,
}}>
Heading 1 - Backstory Application
</h1>
</div>
<div className="mb-4">
<p style={{
fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.body1.fontSize,
color: backstoryTheme.typography.body1.color,
}}>
Body Text - This is how the regular text content will appear in the Backstory application.
The application uses Roboto as its primary font family, with carefully selected sizing and colors.
</p>
</div>
{/* <div className="mt-6">
<a href="#" style={{
color: backstoryTheme.components?.MuiLink?.styleOverrides.root.color || "inherit",
textDecoration: backstoryTheme.components.MuiLink.styleOverrides.root.textDecoration,
}}>
This is how links will appear by default
</a>
</div> */}
</div>
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
UI Component Examples
</h2>
<div className="p-4 mb-4 rounded-lg" style={{ backgroundColor: backstoryTheme.palette.background.paper }}>
<div className="p-2 mb-4 rounded" style={{ backgroundColor: backstoryTheme.palette.primary.main }}>
<span style={{ color: backstoryTheme.palette.primary.contrastText }}>
AppBar Background
</span>
</div>
<div style={{
padding: '8px 16px',
backgroundColor: backstoryTheme.palette.primary.main,
color: backstoryTheme.palette.primary.contrastText,
display: 'inline-block',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: backstoryTheme.typography.fontFamily,
}}>
Primary Button
</div>
<div className="mt-4" style={{
padding: '8px 16px',
backgroundColor: backstoryTheme.palette.secondary.main,
color: backstoryTheme.palette.secondary.contrastText,
display: 'inline-block',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: backstoryTheme.typography.fontFamily,
}}>
Secondary Button
</div>
<div className="mt-4" style={{
padding: '8px 16px',
backgroundColor: backstoryTheme.palette.action.active,
color: '#fff',
display: 'inline-block',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: backstoryTheme.typography.fontFamily,
}}>
Action Button
</div>
</div>
</div>
<div>
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Theme Color Breakdown
</h2>
<table className="border-collapse">
<thead>
<tr>
<th className="border p-2 text-left"
style={{ backgroundColor: backstoryTheme.palette.background.default, color: backstoryTheme.palette.text.primary }}>Color Name</th>
<th className="border p-2 text-left"
style={{ backgroundColor: backstoryTheme.palette.background.default, color: backstoryTheme.palette.text.primary }}>Hex Value</th>
<th className="border p-2 text-left"
style={{ backgroundColor: backstoryTheme.palette.background.default, color: backstoryTheme.palette.text.primary }}>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Primary Main</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.primary.main}</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Midnight Blue - Used for main headers and primary UI elements</td>
</tr>
<tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Primary Contrast</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.primary.contrastText}</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Warm Gray - Text that appears on primary color backgrounds</td>
</tr>
<tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Secondary Main</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.secondary.main}</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Dusty Teal - Used for secondary actions and accents</td>
</tr>
<tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Highlight</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.custom.highlight}</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Golden Ochre - Used for highlights, accents, and important actions</td>
</tr>
<tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Background Default</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.background.default}</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Warm Gray - Main background color for the application</td>
</tr>
<tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Text Primary</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.text.primary}</td>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Charcoal Black - Primary text color throughout the app</td>
</tr>
</tbody>
</table>
</div>
</div>
</Paper></Container></Box>
);
};
export {
BackstoryThemeVisualizerPage
};

View File

@ -1,361 +0,0 @@
import React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { Box, Container, Paper, Typography, Grid, Divider, CssBaseline } from '@mui/material';
import { backstoryTheme } from 'BackstoryTheme';
const BackstoryUIOverviewPage: React.FC = () => {
return (
<ThemeProvider theme={backstoryTheme}>
<CssBaseline />
<Box sx={{ bgcolor: 'background.default', overflow: "hidden", py: 4 }}>
<Container maxWidth="lg">
<Paper sx={{ p: 4, borderRadius: 2, boxShadow: 2 }}>
{/* Header */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}>
<Typography variant="h4" component="h1" sx={{ fontWeight: 'bold', color: 'primary.main', mb: 1 }}>
Backstory UI Architecture
</Typography>
<Typography variant="body1" color="text.secondary">
A visual overview of the dual-purpose application serving candidates and employers
</Typography>
</Box>
{/* User Types */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{xs: 12, md: 6}}>
<Box sx={{
p: 3,
bgcolor: 'rgba(74, 122, 125, 0.1)',
borderRadius: 2,
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)',
height: '100%'
}}>
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 2, fontWeight: 'bold' }}>
Candidate Experience
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{[
'Create comprehensive professional profiles',
'Configure AI assistant for employer Q&A',
'Generate tailored resumes for specific jobs',
'Track profile engagement metrics'
].map((item, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'secondary.main' }} />
<Typography variant="body2">{item}</Typography>
</Box>
))}
</Box>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6}}>
<Box sx={{
p: 3,
bgcolor: 'rgba(26, 37, 54, 0.1)',
borderRadius: 2,
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
height: '100%'
}}>
<Typography variant="h6" sx={{ color: 'primary.main', mb: 2, fontWeight: 'bold' }}>
Employer Experience
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{[
'Search for candidates with specific skills',
'Interact with candidate AI assistants',
'Generate position-specific candidate resumes',
'Manage talent pools and job listings'
].map((item, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'primary.main' }} />
<Typography variant="body2">{item}</Typography>
</Box>
))}
</Box>
</Box>
</Grid>
</Grid>
{/* UI Components */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}>
Key UI Components
</Typography>
<Grid container spacing={2}>
{[
{ title: 'Dashboards', description: 'Role-specific dashboards with card-based metrics and action items' },
{ title: 'Profile Editors', description: 'Comprehensive forms for managing professional information' },
{ title: 'Resume Builder', description: 'AI-powered tools for creating tailored resumes' },
{ title: 'Q&A Interface', description: 'Chat-like interface for employer-candidate AI interaction' },
{ title: 'Search & Filters', description: 'Advanced search with multiple filter categories' },
{ title: 'Analytics Dashboards', description: 'Visual metrics for tracking engagement and performance' }
].map((component, index) => (
<Grid size={{xs: 12, sm: 6, md: 4}} key={index}>
<Box sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
height: '100%',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'rgba(212, 160, 23, 0.05)',
borderColor: 'action.active',
transform: 'translateY(-2px)',
boxShadow: 1
}
}}>
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 1, fontWeight: 'medium' }}>
{component.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{component.description}
</Typography>
</Box>
</Grid>
))}
</Grid>
</Box>
{/* Navigation Structure */}
<Grid container spacing={3} sx={{ mb: 4 }}>
{[
{
title: 'Candidate Navigation',
items: ['Dashboard', 'Profile', 'Backstory', 'Resumes', 'Q&A Setup', 'Analytics', 'Settings'],
color: 'secondary.main',
borderColor: 'secondary.main'
},
{
title: 'Employer Navigation',
items: ['Dashboard', 'Search', 'Saved', 'Jobs', 'Company', 'Analytics', 'Settings'],
color: 'primary.main',
borderColor: 'primary.main'
},
{
title: 'Public Navigation',
items: ['Home', 'Docs', 'Pricing', 'Login', 'Register'],
color: 'custom.highlight',
borderColor: 'custom.highlight'
}
].map((nav, index) => (
<Grid size={{xs:12, md:4}} key={index}>
<Box sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 1,
height: '100%'
}}>
<Typography variant="h6" sx={{ color: 'text.primary', mb: 2, fontWeight: 'bold' }}>
{nav.title}
</Typography>
<Box sx={{
borderLeft: 3,
borderColor: nav.borderColor,
pl: 2,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 1.5
}}>
{nav.items.map((item, idx) => (
<Typography key={idx} sx={{ color: nav.color, fontWeight: 'medium' }}>
{item}
</Typography>
))}
</Box>
</Box>
</Grid>
))}
</Grid>
{/* Connection Points */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}>
System Connection Points
</Typography>
<Box sx={{ position: 'relative', py: 2 }}>
{/* Connection line */}
<Box sx={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: 1,
borderColor: 'divider',
zIndex: 0,
borderLeft: "1px solid",
overflow: "hidden",
}} />
{/* Connection points */}
{[
{ left: 'Candidate Profile', right: 'Employer Search' },
{ left: 'Q&A Setup', right: 'Q&A Interface' },
{ left: 'Resume Generator', right: 'Job Posts' }
].map((connection, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
mb: index < 2 ? 5 : 0,
position: 'relative',
zIndex: 1,
}}
>
<Box sx={{
flex: 1,
display: 'flex',
justifyContent: 'flex-end',
pr: 3
}}>
<Box sx={{
display: 'inline-block',
bgcolor: 'rgba(74, 122, 125, 0.1)',
p: 2,
borderRadius: 2,
color: 'secondary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)'
}}>
{connection.left}
</Box>
</Box>
<Box sx={{
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: 'custom.highlight',
zIndex: 2,
boxShadow: 2,
}} />
<Box sx={{
flex: 1,
pl: 3,
}}>
<Box sx={{
display: 'inline-block',
bgcolor: 'rgba(26, 37, 54, 0.1)',
p: 2,
borderRadius: 2,
color: 'primary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
}}>
{connection.right}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
{/* Mobile Adaptation */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 1 }}>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}>
Mobile Adaptation
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{
width: 200,
height: 400,
border: '4px solid',
borderColor: 'text.primary',
borderRadius: 5,
p: 1,
bgcolor: 'background.default'
}}>
<Box sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: '1px solid',
borderColor: 'divider',
borderRadius: 4,
overflow: 'hidden'
}}>
{/* Mobile header */}
<Box sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
p: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}>BACKSTORY</Typography>
<Box></Box>
</Box>
{/* Mobile content */}
<Box sx={{
flex: 1,
p: 1.5,
overflow: 'auto',
fontSize: '0.75rem'
}}>
<Typography sx={{ mb: 1, fontWeight: 'medium' }}>Welcome back, [Name]!</Typography>
<Typography sx={{ fontSize: '0.675rem', mb: 2 }}>Profile: 75% complete</Typography>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
mb: 2,
bgcolor: 'background.paper'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Resume Builder</Typography>
<Typography sx={{ fontSize: '0.675rem' }}>3 custom resumes</Typography>
</Box>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Recent Activity</Typography>
<Typography sx={{ fontSize: '0.675rem' }}> 5 profile views</Typography>
<Typography sx={{ fontSize: '0.675rem' }}> 2 downloads</Typography>
</Box>
</Box>
{/* Mobile footer */}
<Box sx={{
bgcolor: 'background.default',
p: 1,
display: 'flex',
justifyContent: 'space-around',
borderTop: '1px solid',
borderColor: 'divider'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', color: 'secondary.main' }}>Home</Typography>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>Profile</Typography>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>More</Typography>
</Box>
</Box>
</Box>
</Box>
</Box>
</Paper>
</Container>
</Box>
</ThemeProvider>
);
};
export {
BackstoryUIOverviewPage
};

View File

@ -1,25 +0,0 @@
import React from 'react';
import {
Typography,
} from '@mui/material';
import { BetaPage } from './BetaPage';
const MyIncompletePage = () => {
return (
<BetaPage
title="Analytics Dashboard"
subtitle="Our powerful analytics tools are coming soon"
returnLabel="Back to Home"
returnPath="/home"
>
<Typography variant="body1">
We're building a comprehensive analytics dashboard that will provide real-time insights
into your business performance. The expected completion date is June 15, 2025.
</Typography>
<Typography variant="body1" sx={{ mt: 2 }}>
Features will include custom reports, data visualization, and export capabilities.
</Typography>
</BetaPage>
);
};

View File

@ -1,83 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from "react-router-dom";
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import { BackstoryPageProps } from '../../Components/BackstoryTab';
import { CandidateInfo } from 'NewApp/Components/CandidateInfo';
import { connectionBase } from '../../Global';
import { UserInfo } from "../Components/UserContext";
const CandidateListingPage = (props: BackstoryPageProps) => {
const navigate = useNavigate();
const { sessionId, setSnack } = props;
const [users, setUsers] = useState<UserInfo[] | undefined>(undefined);
useEffect(() => {
if (users !== undefined) {
return;
}
const fetchUsers = async () => {
try {
let response;
response = await fetch(`${connectionBase}/api/u/${sessionId}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Session not found');
}
const users: UserInfo[] = await response.json();
users.forEach(u => {
u.type = 'guest';
u.isAuthenticated = false;
});
users.sort((a, b) => {
let result = a.last_name.localeCompare(b.last_name);
if (result === 0) {
result = a.first_name.localeCompare(b.first_name);
}
if (result === 0) {
result = a.username.localeCompare(b.username);
}
return result;
});
console.log(users);
setUsers(users);
} catch (err) {
setSnack("" + err);
}
};
fetchUsers();
}, [users, sessionId, setSnack]);
return (
<Box sx={{display: "flex", flexDirection: "column"}}>
<Box sx={{ p: 1, textAlign: "center" }}>
Not seeing a candidate you like?
<Button
variant="contained"
sx={{m: 1}}
onClick={() => { navigate('/generate-candidate')}}>
Generate your own perfect AI candidate!
</Button>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap"}}>
{users?.map((u, i) =>
<Box key={`${u.username}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) : void => {
navigate(`/u/${u.username}`)
}}
sx={{ cursor: "pointer" }}
>
<CandidateInfo sessionId={sessionId} sx={{ maxWidth: "320px", "cursor": "pointer", "&:hover": { border: "2px solid orange" }, border: "2px solid transparent" }} user={u} />
</Box>
)}
</Box>
</Box>
);
};
export {
CandidateListingPage
};

View File

@ -1,67 +0,0 @@
import React, { forwardRef, useEffect, useState } from 'react';
import { useNavigate } from "react-router-dom";
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import MuiMarkdown from 'mui-markdown';
import { BackstoryPageProps } from '../../Components/BackstoryTab';
import { Conversation, ConversationHandle } from '../Components/Conversation';
import { ChatQuery, Tunables } from '../../Components/ChatQuery';
import { MessageList } from '../../Components/Message';
import { CandidateInfo } from 'NewApp/Components/CandidateInfo';
import { connectionBase } from '../../Global';
import { LoadingComponent } from 'NewApp/Components/LoadingComponent';
import { useUser } from "../Components/UserContext";
import { Navigate } from 'react-router-dom';
const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
const navigate = useNavigate();
const { sessionId, setSnack, submitQuery } = props;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const { user } = useUser();
useEffect(() => {
if (!user) {
return;
}
setQuestions([
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
{user.questions.map(({ question, tunables }, i: number) =>
<ChatQuery key={i} query={{ prompt: question, tunables: tunables }} submitQuery={submitQuery} />
)}
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
{`As with all LLM interactions, the results may not be 100% accurate. Please contact **${user.full_name}** if you have any questions.`}
</MuiMarkdown>
</Box>]);
}, [user, isMobile, submitQuery]);
if (!user) {
return (<></>);
}
return (
<Box>
<CandidateInfo sessionId={sessionId} action="Chat with Backstory AI about " />
<Conversation
ref={ref}
{...{
multiline: true,
type: "chat",
placeholder: `What would you like to know about ${user?.first_name}?`,
resetLabel: "chat",
sessionId,
setSnack,
defaultPrompts: questions,
submitQuery,
}} />
</Box>);
});
export {
ChatPage
};

View File

@ -1,110 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<!-- Background gradient -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a2b45;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2a3b55;stop-opacity:1" />
</linearGradient>
<!-- Shadow filter -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="3" dy="3" stdDeviation="5" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Base background -->
<rect width="800" height="500" fill="url(#bgGradient)" rx="5" ry="5"/>
<!-- Abstract connection lines in background -->
<path d="M100,100 C300,50 500,200 700,100" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<path d="M100,200 C300,150 500,300 700,200" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<path d="M100,300 C300,250 500,400 700,300" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<path d="M100,400 C300,350 500,450 700,400" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<!-- Left person - more photorealistic style -->
<g filter="url(#shadow)">
<!-- Suit/blazer shape -->
<path d="M190,180 L230,170 Q260,230 250,300 L200,320 Q190,250 170,230 Z" fill="#2c3e50"/>
<!-- Shirt collar -->
<path d="M200,175 L230,170 L235,190 L210,195 Z" fill="#f5f5f5"/>
<!-- Head shape -->
<circle cx="210" cy="130" r="50" fill="#e0c4a8"/>
<!-- Hair -->
<path d="M170,115 Q210,80 250,115 L240,135 Q215,110 180,135 Z" fill="#4a3520"/>
<!-- Face features suggestion -->
<ellipse cx="195" cy="120" rx="5" ry="3" fill="#333333"/>
<ellipse cx="225" cy="120" rx="5" ry="3" fill="#333333"/>
<path d="M195,145 Q210,155 225,145" fill="none" stroke="#333333" stroke-width="2"/>
</g>
<!-- Middle elements - digital content -->
<g filter="url(#shadow)">
<!-- Resume/CV element -->
<rect x="310" y="150" width="180" height="240" rx="5" ry="5" fill="#f5f5f5"/>
<!-- Resume content suggestion -->
<line x1="330" y1="180" x2="470" y2="180" stroke="#333" stroke-width="3"/>
<line x1="330" y1="200" x2="470" y2="200" stroke="#333" stroke-width="1"/>
<line x1="330" y1="215" x2="470" y2="215" stroke="#333" stroke-width="1"/>
<line x1="330" y1="230" x2="470" y2="230" stroke="#333" stroke-width="1"/>
<line x1="330" y1="260" x2="390" y2="260" stroke="#333" stroke-width="2"/>
<line x1="330" y1="280" x2="470" y2="280" stroke="#333" stroke-width="1"/>
<line x1="330" y1="295" x2="470" y2="295" stroke="#333" stroke-width="1"/>
<line x1="330" y1="310" x2="470" y2="310" stroke="#333" stroke-width="1"/>
<line x1="330" y1="340" x2="390" y2="340" stroke="#333" stroke-width="2"/>
<line x1="330" y1="360" x2="470" y2="360" stroke="#333" stroke-width="1"/>
</g>
<!-- Digital connecting elements -->
<g>
<path d="M250,200 Q275,210 310,210" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="250" cy="200" r="5" fill="#4f97eb"/>
<circle cx="310" cy="210" r="5" fill="#4f97eb"/>
<path d="M250,250 Q285,240 310,260" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="250" cy="250" r="5" fill="#4f97eb"/>
<circle cx="310" cy="260" r="5" fill="#4f97eb"/>
<path d="M250,300 Q275,320 310,310" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="250" cy="300" r="5" fill="#4f97eb"/>
<circle cx="310" cy="310" r="5" fill="#4f97eb"/>
<path d="M490,200 Q515,210 550,190" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="490" cy="200" r="5" fill="#4f97eb"/>
<circle cx="550" cy="190" r="5" fill="#4f97eb"/>
<path d="M490,250 Q515,240 550,260" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="490" cy="250" r="5" fill="#4f97eb"/>
<circle cx="550" cy="260" r="5" fill="#4f97eb"/>
<path d="M490,300 Q515,320 550,310" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="490" cy="300" r="5" fill="#4f97eb"/>
<circle cx="550" cy="310" r="5" fill="#4f97eb"/>
</g>
<!-- Right person - more photorealistic style -->
<g filter="url(#shadow)">
<!-- Suit/blazer shape -->
<path d="M570,180 L610,170 Q630,230 620,300 L580,320 Q560,250 550,230 Z" fill="#2c3e50"/>
<!-- Shirt collar -->
<path d="M580,175 L610,170 L615,190 L590,195 Z" fill="#f5f5f5"/>
<!-- Head shape -->
<circle cx="590" cy="130" r="50" fill="#e0c4a8"/>
<!-- Hair -->
<path d="M550,110 Q590,80 625,110 L615,140 Q585,120 560,140 Z" fill="#774936"/>
<!-- Face features suggestion -->
<ellipse cx="575" cy="120" rx="5" ry="3" fill="#333333"/>
<ellipse cx="605" cy="120" rx="5" ry="3" fill="#333333"/>
<path d="M575,145 Q590,155 605,145" fill="none" stroke="#333333" stroke-width="2"/>
</g>
<!-- Top title and subtitle elements -->
<g>
<text x="400" y="60" font-family="Arial, sans-serif" font-size="24" text-anchor="middle" fill="#ffffff" font-weight="bold">Professional Conversations</text>
<text x="400" y="90" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="#e1e8f0">Discover the depth of your career journey</text>
</g>
<!-- Additional decorative elements -->
<circle cx="150" cy="420" r="30" fill="#3b5998" opacity="0.2"/>
<circle cx="650" cy="420" r="30" fill="#3b5998" opacity="0.2"/>
<circle cx="400" cy="450" r="20" fill="#3b5998" opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -1,153 +0,0 @@
import React from 'react';
import {JobMatchAnalysis} from '../Components/JobMatchAnalysis';
// Mock data and functions to simulate your backend
const mockRequirements = [
"5+ years of React development experience",
"Strong TypeScript skills",
"Experience with RESTful APIs",
"Knowledge of state management solutions (Redux, Context API)",
"Experience with CI/CD pipelines",
"Cloud platform experience (AWS, Azure, GCP)"
];
// Simulates fetching requirements with a delay
const mockFetchRequirements = async (): Promise<string[]> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(mockRequirements);
}, 1500); // Simulate network delay
});
};
// Simulates fetching match data for a requirement with varying delays
const mockFetchMatchForRequirement = async (requirement: string): Promise<any> => {
// Create different mock responses based on the requirement
const mockResponses: Record<string, any> = {
"5+ years of React development experience": {
requirement: "5+ years of React development experience",
status: "complete",
matchScore: 85,
assessment: "The candidate demonstrates extensive React experience spanning over 6 years, with a strong portfolio of complex applications and deep understanding of React's component lifecycle and hooks.",
citations: [
{
text: "Led frontend development team of 5 engineers to rebuild our customer portal using React and TypeScript, resulting in 40% improved performance and 30% reduction in bugs.",
source: "Resume, Work Experience",
relevance: 95
},
{
text: "Developed and maintained reusable React component library used across 12 different products within the organization.",
source: "Resume, Work Experience",
relevance: 90
},
{
text: "I've been working with React since 2017, building everything from small widgets to enterprise applications.",
source: "Cover Letter",
relevance: 85
}
]
},
"Strong TypeScript skills": {
requirement: "Strong TypeScript skills",
status: "complete",
matchScore: 90,
assessment: "The candidate shows excellent TypeScript proficiency through their work history and personal projects. They have implemented complex type systems and demonstrate an understanding of advanced TypeScript features.",
citations: [
{
text: "Converted a legacy JavaScript codebase of 100,000+ lines to TypeScript, implementing strict type checking and reducing runtime errors by 70%.",
source: "Resume, Projects",
relevance: 98
},
{
text: "Created comprehensive TypeScript interfaces for our GraphQL API, ensuring type safety across the entire application stack.",
source: "Resume, Technical Skills",
relevance: 95
}
]
},
"Experience with RESTful APIs": {
requirement: "Experience with RESTful APIs",
status: "complete",
matchScore: 75,
assessment: "The candidate has good experience with RESTful APIs, having both consumed and designed them. They understand REST principles but have less documented experience with API versioning and caching strategies.",
citations: [
{
text: "Designed and implemented a RESTful API serving over 1M requests daily with a focus on performance and scalability.",
source: "Resume, Technical Projects",
relevance: 85
},
{
text: "Worked extensively with third-party APIs including Stripe, Twilio, and Salesforce to integrate payment processing and communication features.",
source: "Resume, Work Experience",
relevance: 70
}
]
},
"Knowledge of state management solutions (Redux, Context API)": {
requirement: "Knowledge of state management solutions (Redux, Context API)",
status: "complete",
matchScore: 65,
assessment: "The candidate has moderate experience with state management, primarily using Redux. There is less evidence of Context API usage, which could indicate a knowledge gap in more modern React state management approaches.",
citations: [
{
text: "Implemented Redux for global state management in an e-commerce application, handling complex state logic for cart, user preferences, and product filtering.",
source: "Resume, Skills",
relevance: 80
},
{
text: "My experience includes working with state management libraries like Redux and MobX.",
source: "Cover Letter",
relevance: 60
}
]
},
"Experience with CI/CD pipelines": {
requirement: "Experience with CI/CD pipelines",
status: "complete",
matchScore: 40,
assessment: "The candidate shows limited experience with CI/CD pipelines. While they mention some exposure to Jenkins and GitLab CI, there is insufficient evidence of setting up or maintaining comprehensive CI/CD workflows.",
citations: [
{
text: "Familiar with CI/CD tools including Jenkins and GitLab CI.",
source: "Resume, Skills",
relevance: 40
}
]
},
"Cloud platform experience (AWS, Azure, GCP)": {
requirement: "Cloud platform experience (AWS, Azure, GCP)",
status: "complete",
matchScore: 30,
assessment: "The candidate demonstrates minimal experience with cloud platforms. There is a brief mention of AWS S3 and Lambda, but no substantial evidence of deeper cloud architecture knowledge or experience with Azure or GCP.",
citations: [
{
text: "Used AWS S3 for file storage and Lambda for image processing in a photo sharing application.",
source: "Resume, Projects",
relevance: 35
}
]
}
};
// Return a promise that resolves with the mock data after a delay
return new Promise((resolve) => {
// Different requirements resolve at different speeds to simulate real-world analysis
const delay = Math.random() * 5000 + 2000; // 2-7 seconds
setTimeout(() => {
resolve(mockResponses[requirement]);
}, delay);
});
};
const DemoComponent: React.FC = () => {
return (
<JobMatchAnalysis
jobTitle="Senior Frontend Developer"
candidateName="Alex Johnson"
fetchRequirements={mockFetchRequirements}
fetchMatchForRequirement={mockFetchMatchForRequirement}
/>
);
};
export { DemoComponent };

View File

@ -1,417 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router-dom';
import {
Box,
Drawer,
AppBar,
Toolbar,
IconButton,
Typography,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Grid,
Card,
CardContent,
CardActionArea,
Divider,
useTheme,
useMediaQuery
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import DescriptionIcon from '@mui/icons-material/Description';
import CodeIcon from '@mui/icons-material/Code';
import LayersIcon from '@mui/icons-material/Layers';
import DashboardIcon from '@mui/icons-material/Dashboard';
import PaletteIcon from '@mui/icons-material/Palette';
import AnalyticsIcon from '@mui/icons-material/Analytics';
import ViewQuiltIcon from '@mui/icons-material/ViewQuilt';
import { Document } from '../Components/Document';
import { BackstoryPageProps } from '../../Components/BackstoryTab';
import { BackstoryUIOverviewPage } from './BackstoryUIOverviewPage';
import { BackstoryAppAnalysisPage } from './BackstoryAppAnalysisPage';
import { BackstoryThemeVisualizerPage } from './BackstoryThemeVisualizerPage';
import { MockupPage } from './MockupPage';
// Get appropriate icon for document type
const getDocumentIcon = (title: string) => {
switch (title) {
case 'Docs':
return <DescriptionIcon />;
case 'BETA':
return <CodeIcon />;
case 'Resume Generation Architecture':
case 'Application Architecture':
return <LayersIcon />;
case 'UI Overview':
case 'UI Mockup':
return <DashboardIcon />;
case 'Theme Visualizer':
return <PaletteIcon />;
case 'App Analysis':
return <AnalyticsIcon />;
default:
return <ViewQuiltIcon />;
}
};
// Sidebar navigation component using MUI components
const Sidebar: React.FC<{
currentPage: string;
onDocumentSelect: (docName: string, open: boolean) => void;
onClose?: () => void;
isMobile: boolean;
}> = ({ currentPage, onDocumentSelect, onClose, isMobile }) => {
// Document definitions
const handleItemClick = (route: string) => {
onDocumentSelect(route, true);
if (isMobile && onClose) {
onClose();
}
};
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: 1,
borderColor: 'divider'
}}>
<Typography variant="h6" component="h2" fontWeight="bold">
Documentation
</Typography>
{isMobile && onClose && (
<IconButton
onClick={onClose}
size="small"
aria-label="Close navigation"
>
<CloseIcon />
</IconButton>
)}
</Box>
<Box sx={{
flexGrow: 1,
overflow: 'auto',
p: 1
}}>
<List>
{documents.map((doc, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
onClick={() => handleItemClick(doc.route)}
selected={currentPage === doc.route}
sx={{
borderRadius: 1,
mb: 0.5
}}
>
<ListItemIcon sx={{
color: currentPage === doc.route ? 'primary.main' : 'text.secondary',
minWidth: 40
}}>
{getDocumentIcon(doc.title)}
</ListItemIcon>
<ListItemText
primary={doc.title}
slotProps={{
primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular',
}
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Box>
);
};
type DocType = {
title: string;
route: string;
description: string;
};
const documents : DocType[] = [
{ title: "About", route: "about", description: "General information about the application and its purpose" },
{ title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features" },
{ title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated" },
{ title: "Application Architecture", route: "about-app", description: "System design and technical stack information" },
{ title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions" },
{ title: "Theme Visualizer", route: "theme-visualizer", description: "Explore and customize application themes and visual styles" },
{ title: "App Analysis", route: "app-analysis", description: "Statistics and performance metrics of the application" },
{ title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts" },
{ title: 'Text Mockups', route: "backstory-ui-mockups", description: "Early text mockups of many of the interaction points." },
];
const documentFromRoute = (route: string) : DocType | null => {
const index = documents.findIndex(v => v.route === route);
if (index === -1) {
return null
}
return documents[index];
};
// Helper function to get document title from route
const documentTitleFromRoute = (route: string): string => {
const doc = documentFromRoute(route);
if (doc === null) {
return 'Documentation'
}
return doc.title;
}
const DocsPage = (props: BackstoryPageProps) => {
const { sessionId, submitQuery, setSnack } = props;
const navigate = useNavigate();
const location = useLocation();
const { paramPage = '' } = useParams();
const [page, setPage] = useState<string>(paramPage);
const [drawerOpen, setDrawerOpen] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// Track location changes
useEffect(() => {
const parts = location.pathname.split('/');
if (parts.length > 2) {
setPage(parts[2]);
} else {
setPage('');
}
}, [location]);
// Close drawer when changing to desktop view
useEffect(() => {
if (!isMobile) {
setDrawerOpen(false);
}
}, [isMobile]);
// Handle document navigation
const onDocumentExpand = (docName: string, open: boolean) => {
console.log("Document expanded:", { docName, open, location });
if (open) {
const parts = location.pathname.split('/');
if (parts.length > 2) {
const basePath = parts.slice(0, -1).join('/');
navigate(`${basePath}/${docName}`);
} else {
navigate(docName);
}
} else {
const basePath = location.pathname.split('/').slice(0, -1).join('/');
navigate(`${basePath}`);
}
};
// Toggle mobile drawer
const toggleDrawer = () => {
setDrawerOpen(!drawerOpen);
};
// Close the drawer
const closeDrawer = () => {
setDrawerOpen(false);
};
interface DocViewProps {
page: string
};
const DocView = (props: DocViewProps) => {
const { page } = props;
const title = documentTitleFromRoute(page);
const icon = getDocumentIcon(title);
return (
<Card>
<CardContent>
<Box sx={{ color: 'inherit', fontSize: "1.75rem", fontWeight: "bold", display: "flex", flexDirection: "row", gap: 1, alignItems: "center", mr: 1.5 }}>
{icon}
{title}
</Box>
<Document
filepath={`/docs/${page}.md`}
sessionId={sessionId}
submitQuery={submitQuery}
setSnack={setSnack}
/>
</CardContent>
</Card>
);
};
// Render the appropriate content based on current page
function renderContent() {
switch (page) {
case 'ui-overview':
return (<BackstoryUIOverviewPage />);
case 'theme-visualizer':
return (<Paper sx={{ m: 0, p: 1 }}><BackstoryThemeVisualizerPage /></Paper>);
case 'app-analysis':
return (<BackstoryAppAnalysisPage />);
case 'ui-mockup':
return (<MockupPage />);
default:
if (documentFromRoute(page)) {
return <DocView page={page}/>
}
// Document grid for landing page
return (
<Paper sx={{ p: 3 }} elevation={1}>
<Typography variant="h4" component="h1" gutterBottom>
Documentation
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Select a document from the sidebar to view detailed technical information about the application.
</Typography>
<Grid container spacing={2}>
{documents.map((doc, index) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Card>
<CardActionArea onClick={() => onDocumentExpand(doc.route, true)}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Box sx={{ color: 'primary.main', mr: 1.5 }}>
{getDocumentIcon(doc.title)}
</Box>
<Typography variant="h6">{doc.title}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ ml: 5 }}>
{doc.description}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
</Paper>
);
}
}
// Calculate drawer width
const drawerWidth = 240;
return (
<Box sx={{ display: 'flex', height: '100%' }}>
{/* Mobile App Bar */}
{isMobile && (
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
display: { md: 'none' }
}}
elevation={0}
color="default"
>
<Toolbar>
<IconButton
aria-label="open drawer"
edge="start"
onClick={toggleDrawer}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ color: "white" }}>
{page ? documentTitleFromRoute(page) : "Documentation"}
</Typography>
</Toolbar>
</AppBar>
)}
{/* Navigation drawer */}
<Box
component="nav"
sx={{
width: { md: drawerWidth },
flexShrink: { md: 0 }
}}
>
{/* Mobile drawer (temporary) */}
{isMobile ? (
<Drawer
variant="temporary"
open={drawerOpen}
onClose={closeDrawer}
ModalProps={{
keepMounted: true, // Better open performance on mobile
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth
},
}}
>
<Sidebar
currentPage={page}
onDocumentSelect={onDocumentExpand}
onClose={closeDrawer}
isMobile={true}
/>
</Drawer>
) : (
// Desktop drawer (permanent)
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth,
position: 'relative',
height: '100%'
},
}}
open
>
<Sidebar
currentPage={page}
onDocumentSelect={onDocumentExpand}
isMobile={false}
/>
</Drawer>
)}
</Box>
{/* Main content */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { md: `calc(100% - ${drawerWidth}px)` },
pt: isMobile ? { xs: 8, sm: 9 } : 3, // Add padding top on mobile to account for AppBar
height: '100%',
overflow: 'auto'
}}
>
{renderContent()}
</Box>
</Box>
);
};
export { DocsPage };

View File

@ -1,390 +0,0 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import CancelIcon from '@mui/icons-material/Cancel';
import SendIcon from '@mui/icons-material/Send';
import PropagateLoader from 'react-spinners/PropagateLoader';
import { CandidateInfo } from '../Components/CandidateInfo';
import { Query } from '../../Components/ChatQuery'
import { Quote } from 'NewApp/Components/Quote';
import { streamQueryResponse, StreamQueryController } from '../Components/streamQueryResponse';
import { connectionBase } from 'Global';
import { UserInfo } from '../Components/UserContext';
import { BackstoryElementProps } from 'Components/BackstoryTab';
import { BackstoryTextField, BackstoryTextFieldRef } from 'Components/BackstoryTextField';
import { jsonrepair } from 'jsonrepair';
import { StyledMarkdown } from 'NewApp/Components/StyledMarkdown';
import { Scrollable } from 'Components/Scrollable';
import { Pulse } from 'NewApp/Components/Pulse';
const emptyUser : UserInfo = {
type: 'candidate',
description: "[blank]",
rag_content_size: 0,
username: "[blank]",
first_name: "[blank]",
last_name: "[blank]",
full_name: "[blank] [blank]",
contact_info: {},
questions: [],
isAuthenticated: false,
has_profile: false,
title: '[blank]',
location: '[blank]',
email: '[blank]',
phone: '[blank]',
};
const GenerateCandidate = (props: BackstoryElementProps) => {
const {sessionId, setSnack, submitQuery} = props;
const [streaming, setStreaming] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [prompt, setPrompt] = useState<string>('');
const [resume, setResume] = useState<string>('');
const [canGenImage, setCanGenImage] = useState<boolean>(false);
const [status, setStatus] = useState<string>('');
const [timestamp, setTimestamp] = useState<number>(0);
const [state, setState] = useState<number>(0); // Replaced stateRef
const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false);
// Only keep refs that are truly necessary
const controllerRef = useRef<StreamQueryController>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const generatePersona = useCallback((query: Query) => {
if (controllerRef.current) {
return;
}
setPrompt(query.prompt);
setState(0);
setStatus("Generating persona...");
setUser(emptyUser);
setStreaming('');
setResume('');
setProcessing(true);
setCanGenImage(false);
setShouldGenerateProfile(false); // Reset the flag
controllerRef.current = streamQueryResponse({
query,
type: "persona",
sessionId,
connectionBase,
onComplete: (msg) => {
switch (msg.status) {
case "partial":
case "done":
setState(currentState => {
switch (currentState) {
case 0: /* Generating persona */
let partialUser = JSON.parse(jsonrepair((msg.response || '').trim()));
if (!partialUser.full_name) {
partialUser.full_name = `${partialUser.first_name} ${partialUser.last_name}`;
}
console.log("Setting final user data:", partialUser);
setUser({ ...partialUser });
return 1; /* Generating resume */
case 1: /* Generating resume */
setResume(msg.response || '');
return 2; /* RAG generation */
case 2: /* RAG generation */
return 3; /* Image generation */
default:
return currentState;
}
});
if (msg.status === "done") {
setProcessing(false);
setCanGenImage(true);
setStatus('');
controllerRef.current = null;
setState(0);
// Set flag to trigger profile generation after user state updates
console.log("Persona generation complete, setting shouldGenerateProfile flag");
setShouldGenerateProfile(true);
}
break;
case "thinking":
setStatus(msg.response || '');
break;
case "error":
console.log(`Error generating persona: ${msg.response}`);
setSnack(msg.response || "", "error");
setProcessing(false);
setUser(emptyUser);
controllerRef.current = null;
setState(0);
break;
}
},
onStreaming: (chunk) => {
setStreaming(chunk);
}
});
}, [sessionId, setSnack]);
const cancelQuery = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.abort();
controllerRef.current = null;
setState(0);
setProcessing(false);
}
}, []);
const onEnter = useCallback((value: string) => {
if (processing) {
return;
}
const query: Query = {
prompt: value,
}
generatePersona(query);
}, [processing, generatePersona]);
const handleSendClick = useCallback(() => {
const value = (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "";
generatePersona({ prompt: value });
}, [generatePersona]);
// Effect to trigger profile generation when user data is ready
useEffect(() => {
console.log("useEffect triggered - shouldGenerateProfile:", shouldGenerateProfile, "user:", user?.username, user?.first_name);
if (shouldGenerateProfile && user?.username !== "[blank]" && user?.first_name !== "[blank]") {
console.log("Triggering profile generation with updated user data:", user);
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
return;
}
// Don't generate if we still have blank user data
if (user?.username === "[blank]" || user?.first_name === "[blank]") {
console.log("Cannot generate profile: user data not ready");
return;
}
const imagePrompt = `A photorealistic profile picture of a ${user?.age} year old ${user?.gender?.toLocaleLowerCase()} ${user?.ethnicity?.toLocaleLowerCase()} person. ${prompt}`
setStatus('Starting image generation...');
setProcessing(true);
setCanGenImage(false);
setState(3);
const start = Date.now();
controllerRef.current = streamQueryResponse({
query: {
prompt: imagePrompt,
agent_options: {
username: user?.username,
filename: "profile.png"
}
},
type: "image",
sessionId,
connectionBase,
onComplete: (msg) => {
// console.log("Profile generation response:", msg);
switch (msg.status) {
case "partial":
case "done":
if (msg.status === "done") {
setProcessing(false);
controllerRef.current = null;
setState(0);
setCanGenImage(true);
setShouldGenerateProfile(false);
setUser({
...(user ? user : emptyUser),
has_profile: true
});
}
break;
case "error":
console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
setSnack(msg.response || "", "error");
setProcessing(false);
controllerRef.current = null;
setState(0);
setCanGenImage(true);
setShouldGenerateProfile(false);
break;
default:
let data: any = {};
try {
data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
} catch (e) {
data = { message: msg.response };
}
if (msg.status !== "heartbeat") {
console.log(data);
}
if (data.timestamp) {
setTimestamp(data.timestamp);
} else {
setTimestamp(Date.now())
}
if (data.message) {
setStatus(data.message);
}
break;
}
}
});
}
}, [shouldGenerateProfile, user, prompt, sessionId, setSnack]);
// Handle streaming updates based on current state
useEffect(() => {
if (streaming.trim().length === 0) {
return;
}
try {
switch (state) {
case 0: /* Generating persona */
const partialUser = {...emptyUser, ...JSON.parse(jsonrepair(`${streaming.trim()}...`))};
if (!partialUser.full_name) {
partialUser.full_name = `${partialUser.first_name} ${partialUser.last_name}`;
}
setUser(partialUser);
break;
case 1: /* Generating resume */
setResume(streaming);
break;
case 3: /* RAG streaming */
break;
case 4: /* Image streaming */
break;
}
} catch {
// Ignore JSON parsing errors during streaming
}
}, [streaming, state]);
if (!sessionId) {
return <></>;
}
return (
<Box className="GenerateCandidate" sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
}}>
{user && <CandidateInfo
sessionId={sessionId}
user={user}
sx={{flexShrink: 1}}/>
}
{ prompt &&
<Quote quote={prompt}/>
}
{processing &&
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 2,
}}>
{ status && <Box sx={{ display: "flex", flexDirection: "column"}}>
<Box sx={{ fontSize: "0.5rem"}}>Generation status</Box>
<Box sx={{ fontWeight: "bold"}}>{status}</Box>
</Box>}
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
}
<Box sx={{display: "flex", flexDirection: "column"}}>
<Box sx={{
display: "flex",
flexDirection: "row",
position: "relative"
}}>
<Box sx={{ display: "flex", position: "relative", width: "min-content", height: "min-content" }}>
<Avatar
src={user?.has_profile ? `/api/u/${user.username}/profile/${sessionId}` : ''}
alt={`${user?.full_name}'s profile`}
sx={{
width: 80,
height: 80,
border: '2px solid #e0e0e0',
}}
/>
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px", mr: "-80px" }} timestamp={timestamp} />}
</Box>
<Tooltip title={`${user?.has_profile ? 'Re-': ''}Generate Picture`}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }}
variant="contained"
disabled={
sessionId === undefined || processing || !canGenImage
}
onClick={() => { setShouldGenerateProfile(true); }}>
{user?.has_profile ? 'Re-': ''}Generate Picture<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
{ resume !== '' &&
<Paper sx={{pt: 1, pb: 1, pl: 2, pr: 2}}>
<Scrollable sx={{flexGrow: 1}}>
<StyledMarkdown {...{content: resume, setSnack, sessionId, submitQuery}}/>
</Scrollable>
</Paper> }
<BackstoryTextField
style={{ flexGrow: 0, flexShrink: 1 }}
ref={backstoryTextRef}
disabled={processing}
onEnter={onEnter}
placeholder='Specify any characteristics you would like the persona to have. For example, "This person likes yo-yos."'
/>
<Box sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<Tooltip title={"Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={sessionId === undefined || processing}
onClick={handleSendClick}>
Generate New Persona<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={cancelQuery}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={controllerRef.current === null || !sessionId || processing === false}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{display: "flex", flexGrow: 1}}/>
</Box>);
};
export {
GenerateCandidate
};

View File

@ -1,677 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
TextField,
Grid,
Card,
CardContent,
CardActionArea,
Avatar,
Divider,
CircularProgress,
Container,
useTheme,
Snackbar,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
InputAdornment,
IconButton
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import PersonIcon from '@mui/icons-material/Person';
import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment';
import DescriptionIcon from '@mui/icons-material/Description';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import {JobMatchAnalysis} from '../Components/JobMatchAnalysis';
// Mock types for our application
interface Candidate {
id: string;
name: string;
title: string;
location: string;
email: string;
phone: string;
photoUrl?: string;
resume?: string;
}
interface User {
id: string;
name: string;
company: string;
role: string;
}
// Mock hook for getting the current user
const useUser = (): { user: User | null, loading: boolean } => {
// In a real app, this would check auth state and get user info
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Simulate fetching user data
setTimeout(() => {
setUser({
id: 'emp123',
name: 'Sarah Thompson',
company: 'Tech Innovations Inc.',
role: 'HR Manager'
});
setLoading(false);
}, 800);
}, []);
return { user, loading };
};
// Mock API for fetching candidates
const fetchCandidates = async (searchQuery: string = ''): Promise<Candidate[]> => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
const mockCandidates: Candidate[] = [
{
id: 'c1',
name: 'Alex Johnson',
title: 'Senior Frontend Developer',
location: 'Seattle, WA',
email: 'alex.johnson@example.com',
phone: '(555) 123-4567',
photoUrl: 'https://i.pravatar.cc/150?img=11'
},
{
id: 'c2',
name: 'Morgan Williams',
title: 'Full Stack Engineer',
location: 'Portland, OR',
email: 'morgan.w@example.com',
phone: '(555) 234-5678',
photoUrl: 'https://i.pravatar.cc/150?img=12'
},
{
id: 'c3',
name: 'Jamie Garcia',
title: 'DevOps Specialist',
location: 'San Francisco, CA',
email: 'jamie.g@example.com',
phone: '(555) 345-6789',
photoUrl: 'https://i.pravatar.cc/150?img=13'
},
{
id: 'c4',
name: 'Taylor Chen',
title: 'Backend Developer',
location: 'Austin, TX',
email: 'taylor.c@example.com',
phone: '(555) 456-7890',
photoUrl: 'https://i.pravatar.cc/150?img=14'
},
{
id: 'c5',
name: 'Jordan Smith',
title: 'UI/UX Developer',
location: 'Chicago, IL',
email: 'jordan.s@example.com',
phone: '(555) 567-8901',
photoUrl: 'https://i.pravatar.cc/150?img=15'
}
];
if (!searchQuery) return mockCandidates;
// Filter candidates based on search query
return mockCandidates.filter(candidate =>
candidate.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
candidate.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
candidate.location.toLowerCase().includes(searchQuery.toLowerCase())
);
};
// Main component
const JobAnalysisPage: React.FC = () => {
const theme = useTheme();
const { user, loading: userLoading } = useUser();
// State management
const [activeStep, setActiveStep] = useState(0);
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [loadingCandidates, setLoadingCandidates] = useState(false);
const [jobDescription, setJobDescription] = useState('');
const [jobTitle, setJobTitle] = useState('');
const [jobLocation, setJobLocation] = useState('');
const [analysisStarted, setAnalysisStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openUploadDialog, setOpenUploadDialog] = useState(false);
// Steps in our process
const steps = [
{ label: 'Select Candidate', icon: <PersonIcon /> },
{ label: 'Job Description', icon: <WorkIcon /> },
{ label: 'View Analysis', icon: <AssessmentIcon /> }
];
// Load initial candidates
useEffect(() => {
const loadCandidates = async () => {
setLoadingCandidates(true);
try {
const data = await fetchCandidates();
setCandidates(data);
} catch (err) {
setError('Failed to load candidates. Please try again.');
} finally {
setLoadingCandidates(false);
}
};
if (user) {
loadCandidates();
}
}, [user]);
// Handler for candidate search
const handleSearch = async () => {
setLoadingCandidates(true);
try {
const data = await fetchCandidates(searchQuery);
setCandidates(data);
} catch (err) {
setError('Search failed. Please try again.');
} finally {
setLoadingCandidates(false);
}
};
// Mock handlers for our analysis APIs
const fetchRequirements = async (): Promise<string[]> => {
// Simulates extracting requirements from the job description
await new Promise(resolve => setTimeout(resolve, 2000));
// This would normally parse the job description to extract requirements
const mockRequirements = [
"5+ years of React development experience",
"Strong TypeScript skills",
"Experience with RESTful APIs",
"Knowledge of state management solutions (Redux, Context API)",
"Experience with CI/CD pipelines",
"Cloud platform experience (AWS, Azure, GCP)"
];
return mockRequirements;
};
const fetchMatchForRequirement = async (requirement: string): Promise<any> => {
// Create different mock responses based on the requirement
const mockResponses: Record<string, any> = {
"5+ years of React development experience": {
requirement: "5+ years of React development experience",
status: "complete",
matchScore: 85,
assessment: "The candidate demonstrates extensive React experience spanning over 6 years, with a strong portfolio of complex applications and deep understanding of React's component lifecycle and hooks.",
citations: [
{
text: "Led frontend development team of 5 engineers to rebuild our customer portal using React and TypeScript, resulting in 40% improved performance and 30% reduction in bugs.",
source: "Resume, Work Experience",
relevance: 95
},
{
text: "Developed and maintained reusable React component library used across 12 different products within the organization.",
source: "Resume, Work Experience",
relevance: 90
},
{
text: "I've been working with React since 2017, building everything from small widgets to enterprise applications.",
source: "Cover Letter",
relevance: 85
}
]
},
"Strong TypeScript skills": {
requirement: "Strong TypeScript skills",
status: "complete",
matchScore: 90,
assessment: "The candidate shows excellent TypeScript proficiency through their work history and personal projects. They have implemented complex type systems and demonstrate an understanding of advanced TypeScript features.",
citations: [
{
text: "Converted a legacy JavaScript codebase of 100,000+ lines to TypeScript, implementing strict type checking and reducing runtime errors by 70%.",
source: "Resume, Projects",
relevance: 98
},
{
text: "Created comprehensive TypeScript interfaces for our GraphQL API, ensuring type safety across the entire application stack.",
source: "Resume, Technical Skills",
relevance: 95
}
]
},
"Experience with RESTful APIs": {
requirement: "Experience with RESTful APIs",
status: "complete",
matchScore: 75,
assessment: "The candidate has good experience with RESTful APIs, having both consumed and designed them. They understand REST principles but have less documented experience with API versioning and caching strategies.",
citations: [
{
text: "Designed and implemented a RESTful API serving over 1M requests daily with a focus on performance and scalability.",
source: "Resume, Technical Projects",
relevance: 85
},
{
text: "Worked extensively with third-party APIs including Stripe, Twilio, and Salesforce to integrate payment processing and communication features.",
source: "Resume, Work Experience",
relevance: 70
}
]
},
"Knowledge of state management solutions (Redux, Context API)": {
requirement: "Knowledge of state management solutions (Redux, Context API)",
status: "complete",
matchScore: 65,
assessment: "The candidate has moderate experience with state management, primarily using Redux. There is less evidence of Context API usage, which could indicate a knowledge gap in more modern React state management approaches.",
citations: [
{
text: "Implemented Redux for global state management in an e-commerce application, handling complex state logic for cart, user preferences, and product filtering.",
source: "Resume, Skills",
relevance: 80
},
{
text: "My experience includes working with state management libraries like Redux and MobX.",
source: "Cover Letter",
relevance: 60
}
]
},
"Experience with CI/CD pipelines": {
requirement: "Experience with CI/CD pipelines",
status: "complete",
matchScore: 40,
assessment: "The candidate shows limited experience with CI/CD pipelines. While they mention some exposure to Jenkins and GitLab CI, there is insufficient evidence of setting up or maintaining comprehensive CI/CD workflows.",
citations: [
{
text: "Familiar with CI/CD tools including Jenkins and GitLab CI.",
source: "Resume, Skills",
relevance: 40
}
]
},
"Cloud platform experience (AWS, Azure, GCP)": {
requirement: "Cloud platform experience (AWS, Azure, GCP)",
status: "complete",
matchScore: 30,
assessment: "The candidate demonstrates minimal experience with cloud platforms. There is a brief mention of AWS S3 and Lambda, but no substantial evidence of deeper cloud architecture knowledge or experience with Azure or GCP.",
citations: [
{
text: "Used AWS S3 for file storage and Lambda for image processing in a photo sharing application.",
source: "Resume, Projects",
relevance: 35
}
]
}
};
// Return a promise that resolves with the mock data after a delay
return new Promise((resolve) => {
// Different requirements resolve at different speeds to simulate real-world analysis
const delay = Math.random() * 5000 + 2000; // 2-7 seconds
setTimeout(() => {
resolve(mockResponses[requirement]);
}, delay);
});
};
// Navigation handlers
const handleNext = () => {
if (activeStep === 0 && !selectedCandidate) {
setError('Please select a candidate before continuing.');
return;
}
if (activeStep === 1 && (!jobTitle || !jobDescription)) {
setError('Please provide both job title and description before continuing.');
return;
}
if (activeStep === 2) {
setAnalysisStarted(true);
}
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleReset = () => {
setActiveStep(0);
setSelectedCandidate(null);
setJobDescription('');
setJobTitle('');
setJobLocation('');
setAnalysisStarted(false);
};
// Render function for the candidate selection step
const renderCandidateSelection = () => (
<Paper elevation={3} sx={{ p: 3, mt: 3, mb: 4, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
Select a Candidate
</Typography>
<Box sx={{ mb: 3, display: 'flex' }}>
<TextField
fullWidth
variant="outlined"
placeholder="Search candidates by name, title, or location"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleSearch} edge="end">
<SearchIcon />
</IconButton>
</InputAdornment>
),
}}
sx={{ mr: 2 }}
/>
</Box>
{loadingCandidates ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
) : candidates.length === 0 ? (
<Typography>No candidates found. Please adjust your search criteria.</Typography>
) : (
<Grid container spacing={3}>
{candidates.map((candidate) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={candidate.id}>
<Card
elevation={selectedCandidate?.id === candidate.id ? 8 : 1}
sx={{
height: '100%',
borderColor: selectedCandidate?.id === candidate.id ? theme.palette.primary.main : 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease'
}}
>
<CardActionArea
onClick={() => setSelectedCandidate(candidate)}
sx={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
<CardContent sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ display: 'flex', mb: 2, alignItems: 'center' }}>
<Avatar
src={candidate.photoUrl}
alt={candidate.name}
sx={{ width: 64, height: 64, mr: 2 }}
/>
<Box>
<Typography variant="h6" component="div">
{candidate.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{candidate.title}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
<Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
)}
</Paper>
);
// Render function for the job description step
const renderJobDescription = () => (
<Paper elevation={3} sx={{ p: 3, mt: 3, mb: 4, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
Enter Job Details
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
variant="outlined"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
required
margin="normal"
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Location"
variant="outlined"
value={jobLocation}
onChange={(e) => setJobLocation(e.target.value)}
margin="normal"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, mb: 1 }}>
<Typography variant="subtitle1" sx={{ mr: 2 }}>
Job Description
</Typography>
<Button
variant="outlined"
startIcon={<FileUploadIcon />}
size="small"
onClick={() => setOpenUploadDialog(true)}
>
Upload
</Button>
</Box>
<TextField
fullWidth
multiline
rows={12}
placeholder="Enter the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
required
InputProps={{
startAdornment: (
<InputAdornment position="start" sx={{ alignSelf: 'flex-start', mt: 1.5 }}>
<DescriptionIcon color="action" />
</InputAdornment>
),
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
The job description will be used to extract requirements for candidate matching.
</Typography>
</Grid>
</Grid>
</Paper>
);
// Render function for the analysis step
const renderAnalysis = () => (
<Box sx={{ mt: 3 }}>
{selectedCandidate && (
<JobMatchAnalysis
jobTitle={jobTitle}
candidateName={selectedCandidate.name}
fetchRequirements={fetchRequirements}
fetchMatchForRequirement={fetchMatchForRequirement}
/>
)}
</Box>
);
// If user is loading, show loading state
if (userLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
<CircularProgress />
</Box>
);
}
// If no user is logged in, show message
if (!user) {
return (
<Container maxWidth="md">
<Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: 'center' }}>
<Typography variant="h5" gutterBottom>
Please log in to access candidate analysis
</Typography>
<Button variant="contained" color="primary" sx={{ mt: 2 }}>
Log In
</Button>
</Paper>
</Container>
);
}
return (
<Container maxWidth="lg">
<Paper elevation={1} sx={{ p: 3, mt: 3, borderRadius: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Candidate Analysis
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
Match candidates to job requirements with AI-powered analysis
</Typography>
</Paper>
<Box sx={{ mt: 4, mb: 4 }}>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((step, index) => (
<Step key={index}>
<StepLabel StepIconComponent={() => (
<Avatar
sx={{
bgcolor: activeStep >= index ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white'
}}
>
{step.icon}
</Avatar>
)}>
{step.label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
{activeStep === 0 && renderCandidateSelection()}
{activeStep === 1 && renderJobDescription()}
{activeStep === 2 && renderAnalysis()}
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button
color="inherit"
disabled={activeStep === 0}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
<Box sx={{ flex: '1 1 auto' }} />
{activeStep === steps.length - 1 ? (
<Button onClick={handleReset} variant="outlined">
Start New Analysis
</Button>
) : (
<Button onClick={handleNext} variant="contained">
{activeStep === steps.length - 2 ? 'Start Analysis' : 'Next'}
</Button>
)}
</Box>
{/* Error Snackbar */}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
{/* Upload Dialog */}
<Dialog open={openUploadDialog} onClose={() => setOpenUploadDialog(false)}>
<DialogTitle>Upload Job Description</DialogTitle>
<DialogContent>
<DialogContentText>
Upload a job description document (.pdf, .docx, .txt, or .md)
</DialogContentText>
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Button
variant="outlined"
component="label"
startIcon={<FileUploadIcon />}
sx={{ mt: 1 }}
>
Choose File
<input
type="file"
hidden
accept=".pdf,.docx,.txt,.md"
onChange={() => {
// This would handle file upload in a real application
setOpenUploadDialog(false);
// Mock setting job description from file
setJobDescription(
"Senior Frontend Developer\n\nRequired Skills:\n- 5+ years of React development experience\n- Strong TypeScript skills\n- Experience with RESTful APIs\n- Knowledge of state management solutions (Redux, Context API)\n- Experience with CI/CD pipelines\n- Cloud platform experience (AWS, Azure, GCP)\n\nResponsibilities:\n- Develop and maintain frontend applications using React and TypeScript\n- Collaborate with backend developers to integrate APIs\n- Optimize applications for maximum speed and scalability\n- Design and implement new features and functionality\n- Ensure the technical feasibility of UI/UX designs"
);
setJobTitle("Senior Frontend Developer");
setJobLocation("Remote");
}}
/>
</Button>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenUploadDialog(false)}>Cancel</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export { JobAnalysisPage };

View File

@ -1,34 +0,0 @@
import React from 'react';
const RegisterPage = () => {
return (
<pre>
+------------------------------------------------------+
| BACKSTORY [Logo] Home |
+------------------------------------------------------+
| |
| Create Your Candidate Account |
| |
| [ ] Email |
| [ ] Password |
| [ ] Confirm Password |
| |
| [ ] I agree to the Terms & Privacy Policy |
| |
| [Create Account] |
| |
| Already have an account? [Login] |
| |
| --- or --- |
| |
| [Continue with Google] |
| [Continue with LinkedIn] |
| |
+------------------------------------------------------+
</pre>
);
};
export {
RegisterPage
};

View File

@ -1,77 +0,0 @@
import React, { useEffect } from "react";
import { Navigate, useParams, useNavigate, useLocation } from "react-router-dom";
import { useUser, UserInfo } from "../Components/UserContext";
import { Box } from "@mui/material";
import { connectionBase } from "../../Global";
import { SetSnackType } from '../../Components/Snack';
import { LoadingComponent } from "../Components/LoadingComponent";
interface UserRouteProps {
sessionId?: string | null;
setSnack: SetSnackType,
};
const UserRoute: React.FC<UserRouteProps> = (props: UserRouteProps) => {
const { sessionId, setSnack } = props;
const { username } = useParams<{ username: string }>();
const { user, setUser } = useUser();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (!sessionId) {
return;
}
const fetchUser = async (username: string): Promise<UserInfo | null> => {
try {
let response;
response = await fetch(`${connectionBase}/api/u/${username}/${sessionId}`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Session not found');
}
const user: UserInfo = {
...(await response.json()),
type: "guest",
isAuthenticated: false,
logout: () => { },
}
console.log("Loaded user:", user);
setUser(user);
navigate('/chat');
} catch (err) {
setSnack("" + err);
setUser(null);
navigate('/');
}
return null;
};
if (user?.username !== username && username) {
fetchUser(username);
} else {
if (user?.username) {
navigate('/chat');
} else {
navigate('/');
}
}
}, [user, username, setUser, sessionId, setSnack, navigate]);
if (sessionId === undefined || !user) {
return (<Box>
<LoadingComponent
loadingText="Fetching user information..."
loaderType="linear"
withFade={true}
fadeDuration={1200} />
</Box>);
} else {
return (<></>);
}
};
export { UserRoute };

View File

@ -1,111 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Scrollable } from '../Components/Scrollable';
import { BackstoryPageProps } from '../Components/BackstoryTab';
import { Document } from '../Components/Document';
const AboutPage = (props: BackstoryPageProps) => {
const { sessionId, submitQuery, setSnack, route, setRoute } = props;
const [subRoute, setSubRoute] = useState<string>("");
const [page, setPage] = useState<string>("");
useEffect(() => {
if (route === undefined) { return; }
const parts = route.split("/");
setPage(parts[0]);
parts.shift();
setSubRoute(parts.join("/"));
}, [route]);
useEffect(() => {
console.log(`AboutPage: ${page} - sub-route - ${subRoute}`);
}, [page, subRoute]);
useEffect(() => {
if (route) {
const parts = route.split("/");
if (parts[0] !== page) {
parts.shift();
const incomingSubRoute = parts.join("/");
if (incomingSubRoute !== subRoute) {
setSubRoute(incomingSubRoute);
}
}
} else if (subRoute) {
setRoute && setRoute(subRoute);
}
}, [page, route, setRoute, subRoute]);
useEffect(() => {
let newRoute = page;
if (subRoute) {
newRoute += '/' + subRoute;
}
if (route !== newRoute && setRoute) {
setRoute(newRoute);
}
}, [route, page, subRoute, setRoute]);
const onDocumentExpand = (document: string, open: boolean) => {
console.log("Document expanded:", document, open);
if (open) {
setSubRoute("");
setPage(document);
} else {
setSubRoute("");
setPage("");
}
}
return <Scrollable
autoscroll={false}
sx={{
maxWidth: "1024px",
height: "100%",
flexDirection: "column",
margin: "0 auto",
p: 1,
}}
>
<Document {...{
title: "About",
filepath: "/docs/about.md",
onExpand: (open: boolean) => { onDocumentExpand('about', open); },
expanded: page === 'about',
sessionId,
submitQuery: submitQuery,
setSnack,
}} />
<Document {...{
title: "BETA",
filepath: "/docs/beta.md",
onExpand: (open: boolean) => { onDocumentExpand('beta', open); },
expanded: page === 'beta',
sessionId,
submitQuery: submitQuery,
setSnack,
}} />
<Document {...{
title: "Resume Generation Architecture",
filepath: "/docs/resume-generation.md",
onExpand: (open: boolean) => { onDocumentExpand('resume-generation', open); },
expanded: page === 'resume-generation',
sessionId,
submitQuery: submitQuery,
setSnack,
}} />
<Document {...{
title: "Application Architecture",
filepath: "/docs/about-app.md",
onExpand: (open: boolean) => { onDocumentExpand('about-app', open); },
expanded: page === 'about-app',
sessionId,
submitQuery: submitQuery,
setSnack,
}} />
</Scrollable>;
};
export {
AboutPage
};

View File

@ -1,429 +0,0 @@
import React, { useState, useEffect, ReactElement } from 'react';
// import FormGroup from '@mui/material/FormGroup';
// import FormControlLabel from '@mui/material/FormControlLabel';
// import Switch from '@mui/material/Switch';
// import Divider from '@mui/material/Divider';
// import TextField from '@mui/material/TextField';
import Accordion from '@mui/material/Accordion';
import AccordionActions from '@mui/material/AccordionActions';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Typography from '@mui/material/Typography';
// import Button from '@mui/material/Button';
// import Box from '@mui/material/Box';
// import ResetIcon from '@mui/icons-material/History';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { connectionBase } from '../Global';
import { BackstoryPageProps } from '../Components/BackstoryTab';
interface ServerTunables {
system_prompt: string,
tools: Tool[],
rags: Tool[]
};
type Tool = {
type: string,
enabled: boolean
name: string,
description: string,
parameters?: any,
returns?: any
};
type GPUInfo = {
name: string,
memory: number,
discrete: boolean
}
type SystemInfo = {
"Installed RAM": string,
"Graphics Card": GPUInfo[],
"CPU": string
};
const SystemInfoComponent: React.FC<{ systemInfo: SystemInfo | undefined }> = ({ systemInfo }) => {
const [systemElements, setSystemElements] = useState<ReactElement[]>([]);
const convertToSymbols = (text: string) => {
return text
.replace(/\(R\)/g, '®') // Replace (R) with the ® symbol
.replace(/\(C\)/g, '©') // Replace (C) with the © symbol
.replace(/\(TM\)/g, '™'); // Replace (TM) with the ™ symbol
};
useEffect(() => {
if (systemInfo === undefined) {
return;
}
const elements = Object.entries(systemInfo).flatMap(([k, v]) => {
// If v is an array, repeat for each card
if (Array.isArray(v)) {
return v.map((card, index) => (
<div key={index} className="SystemInfoItem">
<div>{convertToSymbols(k)} {index}</div>
<div>{convertToSymbols(card.name)} {card.discrete ? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM` : "(integrated)"}</div>
</div>
));
}
// If it's not an array, handle normally
return (
<div key={k} className="SystemInfoItem">
<div>{convertToSymbols(k)}</div>
<div>{convertToSymbols(String(v))}</div>
</div>
);
});
setSystemElements(elements);
}, [systemInfo]);
return <div className="SystemInfo">{systemElements}</div>;
};
const ControlsPage = (props: BackstoryPageProps) => {
const { setSnack, sessionId } = props;
const [editSystemPrompt, setEditSystemPrompt] = useState<string>("");
const [systemInfo, setSystemInfo] = useState<SystemInfo | undefined>(undefined);
const [tools, setTools] = useState<Tool[]>([]);
const [rags, setRags] = useState<Tool[]>([]);
const [systemPrompt, setSystemPrompt] = useState<string>("");
const [messageHistoryLength, setMessageHistoryLength] = useState<number>(5);
const [serverTunables, setServerTunables] = useState<ServerTunables | undefined>(undefined);
useEffect(() => {
if (serverTunables === undefined || systemPrompt === serverTunables.system_prompt || !systemPrompt.trim() || sessionId === undefined) {
return;
}
const sendSystemPrompt = async (prompt: string) => {
try {
const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ "system_prompt": prompt }),
});
const tunables = await response.json();
serverTunables.system_prompt = tunables.system_prompt;
console.log(tunables);
setSystemPrompt(tunables.system_prompt)
setSnack("System prompt updated", "success");
} catch (error) {
console.error('Fetch error:', error);
setSnack("System prompt update failed", "error");
}
};
sendSystemPrompt(systemPrompt);
}, [systemPrompt, sessionId, setSnack, serverTunables]);
const reset = async (types: ("rags" | "tools" | "history" | "system_prompt")[], message: string = "Update successful.") => {
try {
const response = await fetch(connectionBase + `/api/reset/${sessionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ "reset": types }),
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const data = await response.json();
if (data.error) {
throw Error(data.error);
}
for (const [key, value] of Object.entries(data)) {
switch (key) {
case "rags":
setRags(value as Tool[]);
break;
case "tools":
setTools(value as Tool[]);
break;
case "system_prompt":
setSystemPrompt((value as ServerTunables)["system_prompt"].trim());
break;
case "history":
console.log('TODO: handle history reset');
break;
}
}
setSnack(message, "success");
} catch (error) {
console.error('Fetch error:', error);
setSnack("Unable to restore defaults", "error");
}
};
// Get the system information
useEffect(() => {
if (systemInfo !== undefined || sessionId === undefined) {
return;
}
const fetchSystemInfo = async () => {
try {
const response = await fetch(connectionBase + `/api/system-info/${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const data = await response.json();
if (data.error) {
throw Error(data.error);
}
setSystemInfo(data);
} catch (error) {
console.error('Error obtaining system information:', error);
setSnack("Unable to obtain system information.", "error");
};
}
fetchSystemInfo();
}, [systemInfo, setSystemInfo, setSnack, sessionId])
useEffect(() => {
setEditSystemPrompt(systemPrompt.trim());
}, [systemPrompt, setEditSystemPrompt]);
const toggleRag = async (tool: Tool) => {
tool.enabled = !tool.enabled
try {
const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ "rags": [{ "name": tool?.name, "enabled": tool.enabled }] }),
});
const tunables: ServerTunables = await response.json();
setRags(tunables.rags)
setSnack(`${tool?.name} ${tool.enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error('Fetch error:', error);
setSnack(`${tool?.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error");
tool.enabled = !tool.enabled
}
};
const toggleTool = async (tool: Tool) => {
tool.enabled = !tool.enabled
try {
const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ "tools": [{ "name": tool.name, "enabled": tool.enabled }] }),
});
const tunables: ServerTunables = await response.json();
setTools(tunables.tools)
setSnack(`${tool.name} ${tool.enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error('Fetch error:', error);
setSnack(`${tool.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error");
tool.enabled = !tool.enabled
}
};
// If the systemPrompt has not been set, fetch it from the server
useEffect(() => {
if (serverTunables !== undefined || sessionId === undefined) {
return;
}
const fetchTunables = async () => {
try {
// Make the fetch request with proper headers
const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
const data = await response.json();
// console.log("Server tunables: ", data);
setServerTunables(data);
setSystemPrompt(data["system_prompt"]);
setTools(data["tools"]);
setRags(data["rags"]);
} catch (error) {
console.error('Fetch error:', error);
setSnack("System prompt update failed", "error");
}
}
fetchTunables();
}, [sessionId, setServerTunables, setSystemPrompt, setMessageHistoryLength, serverTunables, setTools, setRags, setSnack]);
const toggle = async (type: string, index: number) => {
switch (type) {
case "rag":
if (rags === undefined) {
return;
}
toggleRag(rags[index])
break;
case "tool":
if (tools === undefined) {
return;
}
toggleTool(tools[index]);
}
};
const handleKeyPress = (event: any) => {
if (event.key === 'Enter' && event.ctrlKey) {
setSystemPrompt(editSystemPrompt);
}
};
return (<div className="Controls">
{/* <Typography component="span" sx={{ mb: 1 }}>
You can change the information available to the LLM by adjusting the following settings:
</Typography>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">System Prompt</Typography>
</AccordionSummary>
<AccordionActions style={{ display: "flex", flexDirection: "column" }}>
<TextField
variant="outlined"
fullWidth
multiline
slotProps={{
htmlInput: { style: { fontSize: "0.85rem", lineHeight: "1.25rem" } }
}}
type="text"
value={editSystemPrompt}
onChange={(e) => setEditSystemPrompt(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Enter the new system prompt.."
/>
<Box sx={{ display: "flex", flexDirection: "row", gap: "8px", paddingTop: "8px" }}>
<Button variant="contained" disabled={editSystemPrompt.trim() === systemPrompt.trim()} onClick={() => { setSystemPrompt(editSystemPrompt.trim()); }}>Set</Button>
<Button variant="outlined" onClick={() => { reset(["system_prompt"], "System prompt reset."); }} color="error">Reset</Button>
</Box>
</AccordionActions>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">Tunables</Typography>
</AccordionSummary>
<AccordionActions style={{ flexDirection: "column" }}>
<TextField
id="outlined-number"
label="Message history"
type="number"
helperText="Only use this many messages as context. 0 = All. Keeping this low will reduce context growth and improve performance."
value={messageHistoryLength}
onChange={(e: any) => setMessageHistoryLength(e.target.value)}
slotProps={{
htmlInput: {
min: 0
},
inputLabel: {
shrink: true,
},
}}
/>
</AccordionActions>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">Tools</Typography>
</AccordionSummary>
<AccordionDetails>
These tools can be made available to the LLM for obtaining real-time information from the Internet. The description provided to the LLM is provided for reference.
</AccordionDetails>
<AccordionActions>
<FormGroup sx={{ p: 1 }}>
{
(tools || []).map((tool, index) =>
<Box key={index}>
<Divider />
<FormControlLabel control={<Switch checked={tool.enabled} />} onChange={() => toggle("tool", index)} label={tool.name} />
<Typography sx={{ fontSize: "0.8rem", mb: 1 }}>{tool.description}</Typography>
</Box>
)
}</FormGroup>
</AccordionActions>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">RAG</Typography>
</AccordionSummary>
<AccordionDetails>
These RAG databases can be enabled / disabled for adding additional context based on the chat request.
</AccordionDetails>
<AccordionActions>
<FormGroup sx={{ p: 1, flexGrow: 1, justifyContent: "flex-start" }}>
{
(rags || []).map((rag, index) =>
<Box key={index} sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}>
<Divider />
<FormControlLabel
control={<Switch checked={rag.enabled} />}
onChange={() => toggle("rag", index)} label={rag.name}
/>
<Typography>{rag.description}</Typography>
</Box>
)
}</FormGroup>
</AccordionActions>
</Accordion> */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">System Information</Typography>
</AccordionSummary>
<AccordionDetails>
The server is running on the following hardware:
</AccordionDetails>
<AccordionActions>
<SystemInfoComponent systemInfo={systemInfo} />
</AccordionActions>
</Accordion>
{/* <Button startIcon={<ResetIcon />} onClick={() => { reset(["history"], "History cleared."); }}>Delete Backstory History</Button>
<Button onClick={() => { reset(["rags", "tools", "system_prompt"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button> */}
</div>);
}
export {
ControlsPage
};

View File

@ -1,116 +0,0 @@
import React, { forwardRef, useEffect, useState } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import MuiMarkdown from 'mui-markdown';
import { BackstoryPageProps } from '../Components//BackstoryTab';
import { Conversation, ConversationHandle } from '../Components/Conversation';
import { ChatQuery, Tunables } from '../Components/ChatQuery';
import { MessageList } from '../Components/Message';
import { connectionBase } from '../Global';
type UserData = {
user_name: string;
first_name: string;
last_name: string;
full_name: string;
contact_info: Record<string, string>;
questions: [{
question: string;
tunables?: Tunables
}]
};
const HomePage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
const { sessionId, setSnack, submitQuery } = props;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [preamble, setPreamble] = useState<MessageList>([]);
const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const [user, setUser] = useState<UserData | undefined>(undefined)
useEffect(() => {
if (user === undefined) {
return;
}
setPreamble([{
role: 'content',
title: 'Welcome to Backstory',
disableCopy: true,
content: `
Backstory is a RAG enabled expert system with access to real-time data running
self-hosted (no cloud) versions of industry leading Large and Small Language
Models (LLM/SLMs). It provides potential employees the opportunityt to ask
questions about a job candidate, as well as to allow the job candidate to
generate resumes based on their personal data.
This instances has been launched for ${user.full_name}.
What would you like to know about ${user.first_name}?
`,
}]);
setQuestions([
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
{user.questions.map(({ question, tunables }, i: number) =>
<ChatQuery key={i} query={{ prompt: question, tunables: tunables }} submitQuery={submitQuery} />
)}
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
{`As with all LLM interactions, the results may not be 100% accurate. Please contact **${user.full_name}** if you have any questions.`}
</MuiMarkdown>
</Box>]);
}, [user, isMobile, submitQuery]);
useEffect(() => {
const fetchUserData = async () => {
try {
const response = await fetch(connectionBase + `/api/user/${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setUser(data);
}
catch (error) {
console.error('Error getting user info:', error);
setSnack("Unable to obtain user information.", "error");
}
};
fetchUserData();
}, [setSnack, sessionId]);
if (sessionId === undefined || user === undefined) {
return <></>;
}
return <Conversation
ref={ref}
{...{
multiline: true,
type: "chat",
placeholder: "What would you like to know about James?",
resetLabel: "chat",
sessionId,
setSnack,
preamble: preamble,
defaultPrompts: questions,
submitQuery,
}}
/>;
});
export {
HomePage
};

View File

@ -1,21 +0,0 @@
import Box from '@mui/material/Box';
import { BackstoryPageProps } from '../Components/BackstoryTab';
import { BackstoryMessage, Message } from '../Components/Message';
const LoadingPage = (props: BackstoryPageProps) => {
const backstoryPreamble: BackstoryMessage = {
role: 'info',
title: 'Please wait while connecting to Backstory...',
disableCopy: true,
content: '...',
expandable: false,
}
return <Box sx={{display: "flex", flexGrow: 1, maxWidth: "1024px", margin: "0 auto"}}>
<Message message={backstoryPreamble} {...props} />
</Box>
};
export {
LoadingPage
};

View File

@ -1,6 +0,0 @@
.ResumeBuilder .JsonViewScrollable {
min-height: unset !important;
max-height: 30rem !important;
border: 1px solid orange;
overflow-x: auto !important;
}

View File

@ -1,388 +0,0 @@
import React, { useState, useCallback, useRef } from 'react';
import {
Tabs,
Tab,
Box,
} from '@mui/material';
import { SxProps } from '@mui/material';
import { ChatQuery, Query } from '../Components/ChatQuery';
import { MessageList, BackstoryMessage } from '../Components/Message';
import { Conversation } from '../Components/Conversation';
import { BackstoryPageProps } from '../Components/BackstoryTab';
import './ResumeBuilderPage.css';
/**
* ResumeBuilder component
*
* A responsive component that displays job descriptions, generated resumes and fact checks
* with different layouts for mobile and desktop views.
*/
const ResumeBuilderPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const {
sx,
sessionId,
setSnack,
submitQuery,
} = props
// State for editing job description
const [hasJobDescription, setHasJobDescription] = useState<boolean>(false);
const [hasResume, setHasResume] = useState<boolean>(false);
const [hasFacts, setHasFacts] = useState<boolean>(false);
const jobConversationRef = useRef<any>(null);
const resumeConversationRef = useRef<any>(null);
const factsConversationRef = useRef<any>(null);
const [activeTab, setActiveTab] = useState<number>(0);
/**
* Handle tab change for mobile view
*/
const handleTabChange = (_event: React.SyntheticEvent, newValue: number): void => {
setActiveTab(newValue);
};
const handleJobQuery = (query: Query) => {
console.log(`handleJobQuery: ${query.prompt} -- `, jobConversationRef.current ? ' sending' : 'no handler');
jobConversationRef.current?.submitQuery(query);
};
const handleResumeQuery = (query: Query) => {
console.log(`handleResumeQuery: ${query.prompt} -- `, resumeConversationRef.current ? ' sending' : 'no handler');
resumeConversationRef.current?.submitQuery(query);
};
const handleFactsQuery = (query: Query) => {
console.log(`handleFactsQuery: ${query.prompt} -- `, factsConversationRef.current ? ' sending' : 'no handler');
factsConversationRef.current?.submitQuery(query);
};
const filterJobDescriptionMessages = useCallback((messages: MessageList): MessageList => {
if (messages === undefined || messages.length === 0) {
return [];
}
if (messages.length > 0) {
messages[0].role = 'content';
messages[0].title = 'Job Description';
messages[0].disableCopy = false;
messages[0].expandable = true;
}
if (-1 !== messages.findIndex(m => m.status === 'done' || (m.actions && m.actions.includes("resume_generated")))) {
setHasResume(true);
setHasFacts(true);
}
return messages;
if (messages.length > 1) {
setHasResume(true);
setHasFacts(true);
}
if (messages.length > 3) {
// messages[2] is Show job requirements
messages[3].role = 'job-requirements';
messages[3].title = 'Job Requirements';
messages[3].disableCopy = false;
messages[3].expanded = false;
messages[3].expandable = true;
}
/* Filter out the 2nd and 3rd (0-based) */
const filtered = messages;//.filter((m, i) => i !== 1 && i !== 2);
console.warn("Set filtering back on");
return filtered;
}, [setHasResume, setHasFacts]);
const filterResumeMessages = useCallback((messages: MessageList): MessageList => {
if (messages === undefined || messages.length === 0) {
return [];
}
return messages;
if (messages.length > 1) {
// messages[0] is Show Qualifications
messages[1].role = 'qualifications';
messages[1].title = 'Candidate qualifications';
messages[1].disableCopy = false;
messages[1].expanded = false;
messages[1].expandable = true;
}
if (messages.length > 3) {
// messages[2] is Show Resume
messages[3].role = 'resume';
messages[3].title = 'Generated Resume';
messages[3].disableCopy = false;
messages[3].expanded = true;
messages[3].expandable = true;
}
/* Filter out the 1st and 3rd messages (0-based) */
const filtered = messages.filter((m, i) => i !== 0 && i !== 2);
return filtered;
}, []);
const filterFactsMessages = useCallback((messages: MessageList): MessageList => {
if (messages === undefined || messages.length === 0) {
return [];
}
if (messages.length > 1) {
// messages[0] is Show verification
messages[1].role = 'fact-check';
messages[1].title = 'Fact Check';
messages[1].disableCopy = false;
messages[1].expanded = true;
messages[1].expandable = true;
}
/* Filter out the 1st (0-based) */
const filtered = messages.filter((m, i) => i !== 0);
return filtered;
}, []);
const jobResponse = useCallback(async (message: BackstoryMessage) => {
if (message.actions && message.actions.includes("job_description")) {
if (jobConversationRef.current) {
await jobConversationRef.current.fetchHistory();
}
}
if (message.actions && message.actions.includes("resume_generated")) {
if (resumeConversationRef.current) {
await resumeConversationRef.current.fetchHistory();
}
setHasResume(true);
setActiveTab(1); // Switch to Resume tab
}
if (message.actions && message.actions.includes("facts_checked")) {
if (factsConversationRef.current) {
await factsConversationRef.current.fetchHistory();
}
setHasFacts(true);
}
}, [setHasFacts, setHasResume, setActiveTab]);
const resumeResponse = useCallback((message: BackstoryMessage): void => {
console.log('onResumeResponse', message);
setHasFacts(true);
}, [setHasFacts]);
const factsResponse = useCallback((message: BackstoryMessage): void => {
console.log('onFactsResponse', message);
}, []);
const resetJobDescription = useCallback(() => {
setHasJobDescription(false);
setHasResume(false);
setHasFacts(false);
}, [setHasJobDescription, setHasResume, setHasFacts]);
const resetResume = useCallback(() => {
setHasResume(false);
setHasFacts(false);
}, [setHasResume, setHasFacts]);
const resetFacts = useCallback(() => {
setHasFacts(false);
}, [setHasFacts]);
const renderJobDescriptionView = useCallback((sx?: SxProps) => {
console.log('renderJobDescriptionView');
const jobDescriptionQuestions = [
<Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery query={{ prompt: "What are the key skills necessary for this position?", tunables: { enable_tools: false } }} submitQuery={handleJobQuery} />
<ChatQuery query={{ prompt: "How much should this position pay (accounting for inflation)?", tunables: { enable_tools: false } }} submitQuery={handleJobQuery} />
</Box>,
];
const jobDescriptionPreamble: MessageList = [{
role: 'info',
content: `Once you paste a job description and press **Generate Resume**, Backstory will perform the following actions:
1. **Job Analysis**: LLM extracts requirements from '\`Job Description\`' to generate a list of desired '\`Skills\`'.
2. **Candidate Analysis**: LLM determines candidate qualifications by performing skill assessments.
For each '\`Skill\`' from **Job Analysis** phase:
1. **RAG**: Retrieval Augmented Generation collection is queried for context related content for each '\`Skill\`'.
2. **Evidence Creation**: LLM is queried to generate supporting evidence of '\`Skill\`' from the '\`RAG\`' and '\`Candidate Resume\`'.
3. **Resume Generation**: LLM is provided the output from the **Candidate Analysis:Evidence Creation** phase and asked to generate a professional resume.
See [About > Resume Generation Architecture](/about/resume-generation) for more details.
`,
disableCopy: true
}];
if (!hasJobDescription) {
return <Conversation
ref={jobConversationRef}
{...{
type: "job_description",
actionLabel: "Generate Resume",
preamble: jobDescriptionPreamble,
hidePreamble: true,
placeholder: "Paste a job description, then click Generate...",
multiline: true,
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
messageFilter: filterJobDescriptionMessages,
resetAction: resetJobDescription,
onResponse: jobResponse,
sessionId,
setSnack,
submitQuery,
sx,
}}
/>
} else {
return <Conversation
ref={jobConversationRef}
{...{
type: "job_description",
actionLabel: "Send",
placeholder: "Ask a question about this job description...",
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
messageFilter: filterJobDescriptionMessages,
defaultPrompts: jobDescriptionQuestions,
resetAction: resetJobDescription,
onResponse: jobResponse,
sessionId,
setSnack,
submitQuery,
sx,
}}
/>
}
}, [filterJobDescriptionMessages, hasJobDescription, sessionId, setSnack, jobResponse, resetJobDescription, hasFacts, hasResume, submitQuery]);
/**
* Renders the resume view with loading indicator
*/
const renderResumeView = useCallback((sx?: SxProps) => {
const resumeQuestions = [
<Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery query={{ prompt: "Is this resume a good fit for the provided job description?", tunables: { enable_tools: false } }} submitQuery={handleResumeQuery} />
<ChatQuery query={{ prompt: "Provide a more concise resume.", tunables: { enable_tools: false } }} submitQuery={handleResumeQuery} />
</Box>,
];
if (!hasFacts) {
return <Conversation
ref={resumeConversationRef}
{...{
type: "resume",
actionLabel: "Fact Check",
defaultQuery: "Fact check the resume.",
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
messageFilter: filterResumeMessages,
onResponse: resumeResponse,
resetAction: resetResume,
sessionId,
setSnack,
submitQuery,
sx,
}}
/>
} else {
return <Conversation
ref={resumeConversationRef}
{...{
type: "resume",
actionLabel: "Send",
placeholder: "Ask a question about this job resume...",
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
messageFilter: filterResumeMessages,
onResponse: resumeResponse,
resetAction: resetResume,
sessionId,
setSnack,
defaultPrompts: resumeQuestions,
submitQuery,
sx,
}}
/>
}
}, [filterResumeMessages, hasFacts, sessionId, setSnack, resumeResponse, resetResume, hasResume, submitQuery]);
/**
* Renders the fact check view
*/
const renderFactCheckView = useCallback((sx?: SxProps) => {
const factsQuestions = [
<Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery query={{ prompt: "Rewrite the resume to address any discrepancies.", tunables: { enable_tools: false } }} submitQuery={handleFactsQuery} />
</Box>,
];
return <Conversation
ref={factsConversationRef}
{...{
type: "fact_check",
actionLabel: "Send",
placeholder: "Ask a question about any discrepencies...",
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
messageFilter: filterFactsMessages,
defaultPrompts: factsQuestions,
resetAction: resetFacts,
onResponse: factsResponse,
sessionId,
submitQuery,
setSnack,
sx,
}}
/>
}, [ sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts, submitQuery]);
return (
<Box className="ResumeBuilder"
sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
margin: "0 auto",
overflow: "hidden",
backgroundColor: "#F5F5F5",
flexDirection: "column",
maxWidth: "1024px",
}}
>
{/* Tabs */}
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{ bgcolor: 'background.paper' }}
>
<Tab value={0} label="Job Description" />
{hasResume && <Tab value={1} label="Resume" />}
{hasFacts && <Tab value={2} label="Fact Check" />}
</Tabs>
{/* Document display area */}
<Box sx={{
display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx,
overflow: "hidden"
}}>
<Box sx={{ display: activeTab === 0 ? "flex" : "none" }}>{renderJobDescriptionView(/*{ height: "calc(100% - 72px - 48px)" }*/)}</Box>
<Box sx={{ display: activeTab === 1 ? "flex" : "none" }}>{renderResumeView(/*{ height: "calc(100% - 72px - 48px)" }*/)}</Box>
<Box sx={{ display: activeTab === 2 ? "flex" : "none" }}>{renderFactCheckView(/*{ height: "calc(100% - 72px - 48px)" }*/)}</Box>
</Box>
</Box>
);
};
export {
ResumeBuilderPage
};

112
frontend/src/README.md Normal file
View File

@ -0,0 +1,112 @@
# Disk structure
Below is the general directory structure for the Backstory platform, prioritizing maintainability and developer experience:
```
src/
├── components/ # Reusable UI components
│ ├── common/ # Generic components (Button, Modal, etc.)
│ ├── forms/ # Form-related components
│ ├── layout/ # Layout components (Header, Sidebar, etc.)
│ └── ui/ # MUI customizations and themed components
├── pages/ # Page-level components (route components)
│ ├── auth/
│ ├── dashboard/
│ ├── profile/
│ └── settings/
├── features/ # Feature-specific modules
│ ├── authentication/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── types/
│ ├── user-management/
│ └── analytics/
├── hooks/ # Custom React hooks
│ ├── api/ # API-related hooks
│ ├── ui/ # UI state hooks
│ └── utils/ # Utility hooks
├── services/ # API calls and external services
│ ├── api/
│ ├── auth/
│ └── storage/
├── store/ # State management (Redux/Zustand/Context)
│ ├── slices/ # If using Redux Toolkit
│ ├── providers/ # Context providers
│ └── types/
├── utils/ # Pure utility functions
│ ├── constants/
│ ├── helpers/
│ └── validators/
├── styles/ # Global styles and theme
│ ├── theme/ # MUI theme customization
│ ├── globals.css
│ └── variables.css
├── types/ # TypeScript type definitions
│ ├── api/
│ ├── common/
│ └── components/
├── assets/ # Static assets
│ ├── images/
│ ├── icons/
│ └── fonts/
├── config/ # Configuration files
│ ├── env.ts
│ ├── routes.ts
│ └── constants.ts
└── __tests__/ # Test files mirroring src structure
├── components/
├── pages/
└── utils/
```
# Key organizational principles:
1. Feature-Based Architecture
The features/ directory groups related functionality together, making it easy to find everything related to a specific feature in one place.
2. Clear Separation of Concerns
```
components/ - Pure UI components
pages/ - Route-level components
services/ - Data fetching and external APIs
hooks/ - Reusable logic
utils/ - Pure functions
```
3. Scalable Component Organization
Components are organized by purpose rather than alphabetically, with subcategories that make sense as the app grows.
4. Centralized Configuration
All app configuration lives in config/, making it easy to manage environment variables, routes, and constants.
5. Type Safety First
Dedicated types/ directory with clear categorization helps maintain type definitions as the app scales.
# Naming Conventions
* Use PascalCase for components (UserProfile.tsx)
* Use camelCase for utilities and hooks (formatDate.ts, useLocalStorage.ts)
* Use kebab-case for directories (user-management/)
# Index Files
Create index.ts files in major directories to enable clean imports:
```typescript
// components/common/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
// Import usage
import { Button, Modal } from '@/components/common';
```
# Path Aliases
Configure path aliases in your build tool:
```typescript
// Instead of: ../../../../components/common/Button
import { Button } from '@/components/common/Button';
```
This structure scales well while keeping related code co-located and maintaining clear boundaries between different types of functionality.

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
frontend/src/assets/wait.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@ -0,0 +1,39 @@
import React, { JSX } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import { CandidateQuestion } from 'types/types';
type ChatSubmitQueryInterface = (query: CandidateQuestion) => void;
interface BackstoryQueryInterface {
question: CandidateQuestion;
submitQuery?: ChatSubmitQueryInterface;
}
const BackstoryQuery = (props: BackstoryQueryInterface): JSX.Element => {
const { question, submitQuery } = props;
if (submitQuery === undefined) {
return <Box>{question.question}</Box>;
}
return (
<Button
variant="outlined"
sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight,
}}
size="small"
onClick={(): void => {
submitQuery(question);
}}
>
{question.question}
</Button>
);
};
export type { BackstoryQueryInterface, ChatSubmitQueryInterface };
export { BackstoryQuery };

View File

@ -0,0 +1,45 @@
import React, { ReactElement, JSX } from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
interface BackstoryElementProps {
// setSnack: SetSnackType,
// submitQuery: ChatSubmitQueryInterface,
sx?: SxProps<Theme>;
}
interface BackstoryPageProps extends BackstoryElementProps {
route?: string;
setRoute?: (route: string) => void;
}
interface BackstoryTabProps {
label?: string;
path: string;
children?: ReactElement<BackstoryPageProps>;
active?: boolean;
className?: string;
tabProps?: {
label?: string;
sx?: SxProps;
icon?: string | ReactElement<unknown, string> | undefined;
iconPosition?: 'bottom' | 'top' | 'start' | 'end' | undefined;
};
}
function BackstoryPage(props: BackstoryTabProps): JSX.Element {
const { className, active, children } = props;
return (
<Box
className={className || 'BackstoryTab'}
sx={{ display: active ? 'flex' : 'none', p: 0, m: 0, borders: 'none' }}
>
{children}
</Box>
);
}
export type { BackstoryPageProps, BackstoryTabProps, BackstoryElementProps };
export { BackstoryPage };

View File

@ -0,0 +1,160 @@
import React, {
useRef,
useEffect,
CSSProperties,
KeyboardEvent,
useState,
useImperativeHandle,
} from 'react';
import { SxProps, useTheme } from '@mui/material/styles';
import './BackstoryTextField.css';
import { Box } from '@mui/material';
// Define ref interface for exposed methods
interface BackstoryTextFieldRef {
getValue: () => string;
setValue: (value: string) => void;
getAndResetValue: () => string;
}
interface BackstoryTextFieldProps {
value?: string;
disabled?: boolean;
placeholder: string;
onEnter?: (value: string) => void;
onChange?: (value: string) => void;
style?: CSSProperties;
sx?: SxProps;
}
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>(
(props, ref) => {
const { value = '', disabled = false, placeholder, onEnter, onChange, style, sx } = props;
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
const [editValue, setEditValue] = useState<string>(value);
// Sync editValue with prop value if it changes externally
useEffect(() => {
setEditValue(value || '');
}, [value]);
// Adjust textarea height based on content
useEffect(() => {
if (!textareaRef.current || !shadowRef.current) {
return;
}
const textarea = textareaRef.current;
const shadow = shadowRef.current;
// Set shadow value to editValue or placeholder if editValue is empty
shadow.value = editValue || placeholder;
// Ensure shadow textarea has same content-relevant styles
const computed = getComputedStyle(textarea);
shadow.style.width = computed.width; // Match width for accurate wrapping
shadow.style.fontSize = computed.fontSize;
shadow.style.lineHeight = computed.lineHeight;
shadow.style.fontFamily = computed.fontFamily;
shadow.style.letterSpacing = computed.letterSpacing;
shadow.style.wordSpacing = computed.wordSpacing;
// Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
// Reset height to auto to allow shrinking
textarea.style.height = 'auto';
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
});
// Cleanup RAF to prevent memory leaks
return (): void => cancelAnimationFrame(raf);
}, [editValue, placeholder]);
// Expose getValue method via ref
useImperativeHandle(ref, () => ({
getValue: (): string => editValue,
setValue: (value: string): void => setEditValue(value),
getAndResetValue: (): string => {
const _ev = editValue;
setEditValue('');
return _ev;
},
}));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (!onEnter) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline
onEnter(editValue);
setEditValue(''); // Clear textarea
}
};
const fullStyle: CSSProperties = {
display: 'flex',
flexGrow: 1,
width: '100%',
padding: '16.5px 14px',
resize: 'none',
overflow: 'hidden',
boxSizing: 'border-box',
// minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding
lineHeight: '1.5',
borderRadius: '4px',
fontSize: '16px',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
...style,
};
return (
<Box sx={{ display: 'flex', flexGrow: 1, boxSizing: 'border-box', p: 1, ...sx }}>
<textarea
className="BackstoryTextField"
ref={textareaRef}
value={editValue}
disabled={disabled}
placeholder={placeholder}
onChange={(e): void => {
setEditValue(e.target.value);
onChange && onChange(e.target.value);
}}
onKeyDown={handleKeyDown}
style={fullStyle}
/>
<textarea
className="BackgroundTextField"
ref={shadowRef}
aria-hidden="true"
style={{
...fullStyle,
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px', // No padding to match content height
margin: '0px',
border: '0px', // Remove border to avoid extra height
height: 'auto', // Allow natural height
minHeight: '0px',
}}
readOnly
tabIndex={-1}
/>
</Box>
);
}
);
BackstoryTextField.displayName = 'BackstoryTextField';
export type { BackstoryTextFieldRef };
export { BackstoryTextField };

View File

@ -0,0 +1,91 @@
import React, { JSX, useCallback } from 'react';
import { Box } from '@mui/material';
import { VectorVisualizer } from 'components/VectorVisualizer';
import { DocumentManager } from 'components/DocumentManager';
const ContentManager = (): JSX.Element => {
const [filenames, setFilenames] = React.useState<string[]>([]);
const [editPrompt, setEditPrompt] = React.useState<string>('');
const [prompt, setPrompt] = React.useState<string>('');
const [filenameFilter, setFilenameFilter] = React.useState<string[]>([]);
const setFilter = useCallback(
(newFilter: string) => {
if (newFilter !== editPrompt) {
console.log(`Setting edit prompt to: ${newFilter}`);
setEditPrompt(newFilter);
}
if (newFilter !== prompt) {
console.log(`Setting prompt to: ${newFilter}`);
setPrompt(newFilter);
}
if (newFilter === '' && filenames.length > 0) {
console.log('Clearing filename filter');
setFilenames([]);
}
},
[editPrompt, prompt, filenames.length]
);
const onDocumentSelect = useCallback(
(document: { filename: string } | null): void => {
if (document) {
console.log(`Document selected: ${document.filename}`);
setFilenameFilter([document.filename]);
} else if (filenames.length > 0) {
console.log('No document selected, clearing filename filter');
setFilenameFilter([]);
}
},
[setFilenameFilter, filenames]
);
return (
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<VectorVisualizer
filenameFilter={filenameFilter}
query={editPrompt}
setQuery={(newPrompt: string) => {
editPrompt !== newPrompt && setEditPrompt(newPrompt);
}}
onQueryResult={(newPrompt: string, newFilenames: string[]) => {
if (newPrompt !== prompt) {
console.log(`Setting prompt to: ${newPrompt}`);
setPrompt(newPrompt);
}
let update = filenames.length !== newFilenames.length;
if (!update) {
for (let i = 0; i < filenames.length; i++) {
if (filenames[i] !== newFilenames[i]) {
update = true;
break;
}
}
}
if (update) {
console.log(`Updating filenames from ${filenames} to ${newFilenames}`);
setFilenames(newFilenames);
}
}}
/>
<DocumentManager
{...{
onDocumentSelect,
filter: prompt,
setFilter,
filenames,
}}
/>
</Box>
);
};
export { ContentManager };

View File

@ -0,0 +1,42 @@
import React from 'react';
import { SxProps, Theme } from '@mui/material';
import { BackstoryElementProps } from './BackstoryTab';
import { ChatMessage, ChatQuery, ChatMessageMetaData } from 'types/types';
const defaultMessage: ChatMessage = {
status: 'done',
type: 'text',
sessionId: '',
timestamp: new Date(),
content: '',
role: 'assistant',
metadata: null as unknown as ChatMessageMetaData,
};
type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check' | 'persona';
interface ConversationHandle {
submitQuery: (query: ChatQuery) => void;
fetchHistory: () => void;
}
interface ConversationProps extends BackstoryElementProps {
className?: string; // Override default className
type: ConversationMode; // Type of Conversation chat
placeholder?: string; // Prompt to display in TextField input
actionLabel?: string; // Label to put on the primary button
resetAction?: () => void; // Callback when Reset is pressed
resetLabel?: string; // Label to put on Reset button
defaultPrompts?: React.ReactElement[]; // Set of Elements to display after the TextField
defaultQuery?: string; // Default text to populate the TextField input
preamble?: ChatMessage[]; // Messages to display at start of Conversation until Action has been invoked
hidePreamble?: boolean; // Whether to hide the preamble after an Action has been invoked
hideDefaultPrompts?: boolean; // Whether to hide the defaultPrompts after an Action has been invoked
messageFilter?: ((messages: ChatMessage[]) => ChatMessage[]) | undefined; // Filter callback to determine which Messages to display in Conversation
messages?: ChatMessage[]; //
sx?: SxProps<Theme>;
onResponse?: ((message: ChatMessage) => void) | undefined; // Event called when a query completes (provides messages)
}
export type { ConversationProps, ConversationHandle };

View File

@ -0,0 +1,67 @@
import React, { JSX } from 'react';
import { useState } from 'react';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
import { Tooltip } from '@mui/material';
import { SxProps, Theme } from '@mui/material';
interface CopyBubbleProps extends IconButtonProps {
content: string | undefined;
sx?: SxProps<Theme>;
tooltip?: string;
}
const CopyBubble = ({
content,
sx,
tooltip = 'Copy to clipboard',
onClick,
...rest
}: CopyBubbleProps): JSX.Element => {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
if (content === undefined) {
return;
}
navigator.clipboard.writeText(content.trim()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds
});
if (onClick) {
onClick(e);
}
};
return (
<Tooltip title={tooltip} placement="top" arrow>
<IconButton
onClick={(e): void => {
handleCopy(e);
}}
sx={{
width: 24,
height: 24,
opacity: 0.75,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
...sx,
}}
size="small"
color={copied ? 'success' : 'default'}
{...rest}
>
{copied ? (
<CheckIcon sx={{ width: 16, height: 16 }} />
) : (
<ContentCopyIcon sx={{ width: 16, height: 16 }} />
)}
</IconButton>
</Tooltip>
);
};
export { CopyBubble };

View File

@ -0,0 +1,161 @@
import React, { JSX, useState } from 'react';
import {
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
useMediaQuery,
SxProps,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ResetIcon from '@mui/icons-material/History';
import DeleteIcon from '@mui/icons-material/Delete';
interface DeleteConfirmationProps {
// Legacy props for backward compatibility (uncontrolled mode)
onDelete?: () => void;
disabled?: boolean;
label?: string;
action?: 'delete' | 'reset';
color?:
| 'inherit'
| 'default'
| 'primary'
| 'secondary'
| 'error'
| 'info'
| 'success'
| 'warning'
| undefined;
sx?: SxProps;
// New props for controlled mode
open?: boolean;
onClose?: () => void;
onConfirm?: () => void;
title?: string;
message?: string;
icon?: React.ReactNode;
size?: 'small' | 'medium' | 'large';
// Optional props for button customization in controlled mode
hideButton?: boolean;
confirmButtonText?: string;
cancelButtonText?: string;
}
function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => {
const {
// Legacy props
onDelete,
disabled,
label,
color,
action = 'delete',
// New props
open: controlledOpen,
onClose: controlledOnClose,
onConfirm,
title,
message,
hideButton = false,
confirmButtonText,
cancelButtonText = 'Cancel',
size = 'large',
sx,
icon = props.action === 'reset' ? <ResetIcon /> : <DeleteIcon />,
} = props;
// Internal state for uncontrolled mode
const [internalOpen, setInternalOpen] = useState(false);
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
// Determine if we're in controlled or uncontrolled mode
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen;
const handleClickOpen = (): void => {
if (!isControlled) {
setInternalOpen(true);
}
};
const handleClose = (): void => {
if (isControlled) {
controlledOnClose?.();
} else {
setInternalOpen(false);
}
};
const handleConfirm = (): void => {
if (isControlled) {
onConfirm?.();
} else {
onDelete?.();
setInternalOpen(false);
}
};
// Determine dialog content based on mode
const dialogTitle = title || 'Confirm Reset';
const dialogMessage =
message ||
`This action will permanently ${capitalizeFirstLetter(action)} ${
label ? label.toLowerCase() : 'all data'
} without the ability to recover it. Are you sure you want to continue?`;
const confirmText =
confirmButtonText || `${capitalizeFirstLetter(action)} ${label || 'Everything'}`;
return (
<>
{/* Only show button if not hidden (for controlled mode) */}
{!hideButton && (
<IconButton
aria-label={action}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleClickOpen();
}}
title={label ? `${capitalizeFirstLetter(action)} ${label}` : 'Reset'}
color={color || 'default'}
sx={{ display: 'flex', margin: 'auto 0px', ...sx }}
size={size}
edge="start"
disabled={disabled}
>
{icon}
</IconButton>
)}
<Dialog
fullScreen={fullScreen}
open={isOpen}
onClose={handleClose}
aria-labelledby="responsive-dialog-title"
>
<DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
<DialogContent>
<DialogContentText>{dialogMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleClose}>
{cancelButtonText}
</Button>
<Button onClick={handleConfirm} color="error" variant="contained">
{confirmText}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export { DeleteConfirmation };

View File

@ -0,0 +1,21 @@
.d2h-file-side-diff .d2h-code-line pre {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.d2h-file-header {
display: none;
}
.d2h-code-line {
display: flex;
flex-direction: row;
max-width: 100%;
}
.d2h-code-line > span {
display: inline-flex;
max-width: 100%;
white-space: wrap;
}

View File

@ -0,0 +1,98 @@
import React, { useEffect, useRef } from 'react';
import { createPatch } from 'diff';
import { Box, SxProps } from '@mui/material';
import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui';
import 'diff2html/bundles/css/diff2html.min.css';
import './DiffViewer.css';
// import { Scrollable } from './Scrollable';
interface DiffFile {
content: string;
name: string;
}
interface DiffViewerProps {
original: DiffFile;
modified: DiffFile;
changeLog: string;
sx?: SxProps;
outputFormat?: 'line-by-line' | 'side-by-side';
drawFileList?: boolean;
}
const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
const {
original,
modified,
sx,
outputFormat = 'line-by-line',
changeLog,
drawFileList = false,
} = props;
const diffRef = useRef<HTMLDivElement>(null);
const diffString = createPatch(
'Resume',
original.content || '',
modified.content || '',
original.name,
modified.name,
{
context: 5,
}
);
useEffect(() => {
if (diffRef.current && diffString) {
// Clear previous content
diffRef.current.innerHTML = '';
diffRef.current.className = `diff-viewer`;
// Generate HTML from diff string
const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, {
drawFileList: false,
matching: 'lines',
outputFormat,
synchronisedScroll: true,
highlight: true,
fileContentToggle: false,
});
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
diff2htmlUi.synchronisedScroll();
// diffRef.current.innerHTML = diff2htmlUi;
}
}, [diffString, outputFormat, drawFileList]);
return (
<Box
sx={{
position: 'relative',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflow: 'auto' /* Scroll if content overflows */,
p: 0,
gap: 1,
...sx,
}}
>
<Box sx={{ display: 'flex', p: 1 }}>{changeLog}</Box>
<Box
ref={diffRef}
className="diff-viewer"
sx={{
display: 'flex',
position: 'relative',
minWidth: 'fit-content',
minHeight: 'fit-content',
fontFamily: 'monospace',
fontSize: '14px',
}}
/>
</Box>
);
};
export { DiffViewer };

View File

@ -0,0 +1,463 @@
import React, { JSX, useState, useMemo } from 'react';
import { Edit, Visibility, ArrowUpward, ArrowDownward } from '@mui/icons-material';
import {
Box,
Chip,
Dialog,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Theme,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { useAppState } from 'hooks/GlobalContext';
import { DeleteConfirmation } from './DeleteConfirmation';
import { DocumentView } from './DocumentView';
interface DocumentListProps {
documents: Types.Document[];
setDocuments: (documents: Types.Document[]) => void;
setSelectedDocument?: (document: Types.Document | null) => void;
selectedDocument?: Types.Document | null;
}
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileTypeColor = (theme: Theme, type: string): string => {
switch (type) {
case 'pdf':
return theme.palette.primary.main;
case 'docx':
return theme.palette.secondary.main;
case 'txt':
return theme.palette.success.main;
case 'md':
return theme.palette.warning.main;
default:
return theme.palette.primary.main;
}
};
type SortField = 'name' | 'date' | 'size' | 'type' | 'rag';
const DocumentList = (props: DocumentListProps): JSX.Element => {
const theme = useTheme();
const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { apiClient } = useAuth();
const { documents, setDocuments, setSelectedDocument, selectedDocument } = props;
const [documentView, setDocumentView] = useState<Types.Document | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [sortField, setSortField] = useState<SortField | null>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
// Sort documents
const sortedDocuments = useMemo(() => {
if (!sortField) return documents;
return [...documents].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
switch (sortField) {
case 'name':
aValue = a.filename.toLowerCase();
bValue = b.filename.toLowerCase();
break;
case 'date':
aValue = (a.updatedAt || a.uploadDate)?.getTime() || 0;
bValue = (b.updatedAt || b.uploadDate)?.getTime() || 0;
break;
case 'size':
aValue = a.size;
bValue = b.size;
break;
case 'type':
aValue = a.type.toLowerCase();
bValue = b.type.toLowerCase();
break;
case 'rag':
aValue = a.options?.includeInRag ? 1 : 0;
bValue = b.options?.includeInRag ? 1 : 0;
break;
default:
return 0;
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}, [documents, sortField, sortDirection]);
// Handle sorting
const handleSort = (field: SortField): void => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
// Render sort icon
const renderSortIcon = (field: SortField): JSX.Element | null => {
if (sortField !== field) return null;
return sortDirection === 'asc' ? (
<ArrowUpward fontSize="small" sx={{ ml: 0.5 }} />
) : (
<ArrowDownward fontSize="small" sx={{ ml: 0.5 }} />
);
};
// Start rename process
const viewDocument = (document: Types.Document, edit = false): void => {
console.log('Starting rename for document:', document, document.filename);
setDocumentView(document);
setIsEditing(edit);
};
// Handle document deletion
const handleDeleteDocument = async (document: Types.Document): Promise<void> => {
try {
// Call API to delete document
await apiClient.deleteCandidateDocument(document);
setDocuments(documents.filter(doc => doc.id !== document.id));
setSnack('Document deleted successfully', 'success');
// Close content view if this document was being viewed
if (selectedDocument?.id === document.id) {
setSelectedDocument && setSelectedDocument(null);
}
} catch (error) {
console.log(error);
}
};
// Handle RAG flag toggle
const handleRAGToggle = async (
document: Types.Document,
includeInRag: boolean
): Promise<void> => {
try {
document.options = { includeInRag };
// Call API to update RAG flag
await apiClient.updateCandidateDocument(document);
setDocuments(
documents.map(doc => (doc.id === document.id ? { ...doc, options: { includeInRag } } : doc))
);
setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success');
} catch (error) {
setSnack('Failed to update RAG setting', 'error');
}
};
return (
<Box
className="DocumentList"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
p: 0,
m: 0,
overflow: 'hidden',
width: '100%',
height: '100%',
position: 'relative',
}}
>
<TableContainer>
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 0.5 } }}>
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 600,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('name')}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Name
{renderSortIcon('name')}
</Box>
</TableCell>
{!isMobile && (
<TableCell
sx={{
fontWeight: 600,
width: '80px',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('type')}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Type
{renderSortIcon('type')}
</Box>
</TableCell>
)}
{!isMobile && (
<TableCell
sx={{
fontWeight: 600,
width: '80px',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('size')}
>
Size
{renderSortIcon('size')}
</TableCell>
)}
{!isMobile && (
<TableCell
sx={{
fontWeight: 600,
width: '100px',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('date')}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Date
{renderSortIcon('date')}
</Box>
</TableCell>
)}
<TableCell
sx={{
fontWeight: 600,
width: '80px',
textAlign: 'center',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('rag')}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
RAG
{renderSortIcon('rag')}
</Box>
</TableCell>
<TableCell sx={{ fontWeight: 600, width: '120px', textAlign: 'center' }}>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedDocuments.map(doc => (
<TableRow
key={doc.id}
hover
sx={{
backgroundColor:
selectedDocument?.id === doc.id ? 'action.selected' : 'transparent',
'&:hover': {
backgroundColor:
selectedDocument?.id === doc.id
? 'rgba(0, 0, 0, 0.25) !important' // Slightly darker when selected + hover
: 'action.hover !important',
},
cursor: 'pointer',
}}
onClick={() => {
setSelectedDocument &&
setSelectedDocument(selectedDocument?.id === doc.id ? null : doc);
}}
>
<TableCell sx={{ py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography
variant="body2"
sx={{
wordBreak: 'break-word',
fontSize: '0.875rem',
minWidth: 0,
flex: 1,
}}
>
{doc.filename}
</Typography>
{isMobile && (
<Box
sx={{
display: 'flex',
width: 'fit-content',
border: `1px solid ${getFileTypeColor(theme, doc.type)}`,
height: 20,
px: 1,
fontSize: '0.65rem',
alignItems: 'center',
justifyContent: 'center',
}}
>
{doc.type.toUpperCase()}
</Box>
)}
{isMobile && (
<Box sx={{ width: '100%', mt: 0.5 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.7rem' }}
>
{formatFileSize(doc.size)} {' '}
{(doc?.updatedAt || doc?.uploadDate)?.toLocaleDateString()}
</Typography>
</Box>
)}
</Box>
</TableCell>
{!isMobile && (
<TableCell sx={{ py: 1 }}>
<Box
sx={{
display: 'flex',
width: 'fit-content',
border: `1px solid ${getFileTypeColor(theme, doc.type)}`,
height: 20,
px: 1,
fontSize: '0.65rem',
alignItems: 'center',
justifyContent: 'center',
}}
>
{doc.type.toUpperCase()}
</Box>
</TableCell>
)}
{!isMobile && (
<TableCell sx={{ py: 1 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)}
</Typography>
</TableCell>
)}
{!isMobile && (
<TableCell sx={{ py: 1 }}>
<Typography variant="caption" color="text.secondary">
{(doc?.updatedAt || doc?.uploadDate)?.toLocaleDateString()}
</Typography>
</TableCell>
)}
<TableCell sx={{ py: 1, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<Chip
label="RAG"
size="small"
color="success"
variant={doc.options?.includeInRag ? 'filled' : 'outlined'}
onClick={e => {
e.stopPropagation();
handleRAGToggle(doc, !doc.options?.includeInRag);
}}
sx={{
height: 20,
fontSize: '0.65rem',
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
}}
/>
</TableCell>
<TableCell sx={{ py: 1, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 0.25 }}>
<IconButton
size="small"
onClick={(e): void => {
e.stopPropagation();
viewDocument(doc);
}}
title="View content"
sx={{ p: 0.5 }}
>
<Visibility fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={(e): void => {
e.stopPropagation();
viewDocument(doc, true);
}}
title="Rename"
sx={{ p: 0.5 }}
>
<Edit fontSize="small" />
</IconButton>
<DeleteConfirmation
onDelete={(): void => {
handleDeleteDocument(doc);
}}
// color="primary"
sx={{ minWidth: 'auto', maxHeight: 'min-content' }}
size="small"
action="delete"
label="this document"
title="Delete document"
message={`Are you sure you want to delete this document? This action cannot be undone.`}
/>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Rename Dialog */}
<Dialog
open={documentView !== null}
onClose={(): void => {
setDocumentView(null);
}}
maxWidth="sm"
fullWidth
>
{documentView && (
<DocumentView
document={documentView}
edit={isEditing}
onSave={() => {
setDocumentView(null);
setIsEditing(false);
setDocuments([...documents]);
}}
onCancel={() => {
setDocumentView(null);
}}
/>
)}
</Dialog>
</Box>
);
};
export { DocumentList };

View File

@ -0,0 +1,318 @@
import React, { useState, useEffect, JSX, useRef } from 'react';
import {
Box,
Button,
Grid,
useMediaQuery,
Typography,
Card,
CardContent,
IconButton,
Paper,
} from '@mui/material';
import ClearIcon from '@mui/icons-material/Clear';
import { styled } from '@mui/material/styles';
import { CloudUpload, Close } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { useAppState } from 'hooks/GlobalContext';
import { DocumentList } from './DocumentList';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
interface DocumentManagerProps {
filter?: string;
onDocumentSelect?: (document: Types.Document | null) => void;
setFilter?: (filter: string) => void;
filenames?: string[];
}
const DocumentManager = (props: DocumentManagerProps): JSX.Element => {
const { filter = '', filenames = [], setFilter, onDocumentSelect } = props;
const theme = useTheme();
const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { user, apiClient } = useAuth();
const [documents, setDocuments] = useState<Types.Document[]>([]);
const [filteredDocuments, setFilteredDocuments] = useState<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>('');
const [isViewingContent, setIsViewingContent] = useState(false);
// Check if user is a candidate
const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null;
useEffect(() => {
onDocumentSelect && onDocumentSelect(selectedDocument);
}, [selectedDocument, onDocumentSelect]);
const prevDepsRef = useRef({ documents, filenames });
useEffect(() => {
const prev = prevDepsRef.current;
// Check if the actual content changed
const shouldUpdate =
documents.length !== prev.documents.length ||
filenames.length !== prev.filenames.length ||
documents.some((doc, i) => doc.filename !== prev.documents[i]?.filename) ||
filenames.some((name, i) => name !== prev.filenames[i]);
if (shouldUpdate) {
prevDepsRef.current = { documents, filenames };
if (filenames.length > 0) {
const filtered = documents.filter(doc => filenames.includes(doc.filename));
setFilteredDocuments(filtered);
} else {
setFilteredDocuments(documents);
}
}
}, [documents, filenames]);
// Load documents on component mount
useEffect(() => {
const loadDocuments = async (): Promise<void> => {
try {
const results = await apiClient.getCandidateDocuments();
setDocuments(results.documents);
} catch (error) {
console.error(error);
setSnack('Failed to load documents', 'error');
}
};
if (candidate) {
loadDocuments();
}
}, [candidate, apiClient, setSnack]);
// Handle document upload
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case 'pdf':
docType = 'pdf';
break;
case 'docx':
docType = 'docx';
break;
case 'md':
docType = 'markdown';
break;
case 'txt':
docType = 'txt';
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
// Upload file (replace with actual API call)
const controller = apiClient.uploadCandidateDocument(
file,
{ includeInRag: true, isJobDocument: false },
{
onError: error => {
console.error(error);
setSnack(error.content, 'error');
},
}
);
const result = await controller.promise;
if (result && result.document) {
setDocuments(prev => [...prev, result.document]);
setSnack(`Document uploaded: ${file.name}`, 'success');
}
// Reset file input
e.target.value = '';
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
}
}
};
const updateDocuments = (updatedDocs: Types.Document[]): void => {
// Find documents that were deleted (in filteredDocuments but not in updatedDocs)
const deletedDocIds = filteredDocuments
.filter(doc => !updatedDocs.some(updated => updated.id === doc.id))
.map(doc => doc.id);
// Update the main documents array:
// 1. Remove any deleted documents
// 2. Update any modified documents
const updatedDocuments = documents
.filter(doc => !deletedDocIds.includes(doc.id)) // Remove deleted docs
.map(doc => {
// Check if this document was modified in updatedDocs
const modifiedDoc = updatedDocs.find(updated => updated.id === doc.id);
return modifiedDoc || doc; // Use modified version if available, otherwise keep original
});
setDocuments(updatedDocuments);
setFilteredDocuments(updatedDocs); // Update filtered docs to match what child returned
};
if (!candidate) {
return <Box>You must be logged in as a candidate to view this content.</Box>;
}
return (
<Box
sx={{
display: 'flex',
position: 'relative',
flexDirection: 'column',
flexGrow: 1,
gap: 2,
overflow: 'hidden',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
m: 0,
p: 1,
width: '100%',
verticalAlign: 'center',
gap: 1,
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography>
{filter && (
<>
<Typography variant={isMobile ? 'caption' : 'body2'} color="text.secondary">
RAG filter: {filter}
</Typography>
<ClearIcon
sx={{
height: 16,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => {
setFilter && setFilter('');
}}
/>
</>
)}
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? 'small' : 'medium'}
sx={{ justifySelf: 'flex-end', ml: 'auto' }}
>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box>
<Box>
{documents.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3,
}}
>
No additional documents uploaded
</Typography>
) : (
<DocumentList
{...{
documents: filteredDocuments,
setDocuments: updateDocuments,
selectedDocument,
setSelectedDocument,
}}
/>
)}
</Box>
{/* Document Content Viewer */}
{isViewingContent && (
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
<IconButton
size="small"
onClick={(): void => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}}
>
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50',
}}
>
<pre
style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{documentContent || 'Loading content...'}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
</Box>
);
};
export { DocumentManager };

View File

@ -0,0 +1,143 @@
import React, { useState, useEffect, JSX } from 'react';
import { BackstoryElementProps } from './BackstoryTab';
import * as Types from 'types/types';
import { Box, Button, TextField } from '@mui/material';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import { Scrollable } from './Scrollable';
interface DocumentViewProps extends BackstoryElementProps {
document: Types.Document;
edit?: boolean;
onSave?: (document: Types.Document) => void;
onCancel?: () => void;
}
const DocumentView = (props: DocumentViewProps): JSX.Element => {
const { apiClient } = useAuth();
const { setSnack } = useAppState();
const { document, edit = false, onSave, onCancel } = props;
const [editingName, setEditingName] = useState<string>(document.filename);
const [content, setContent] = useState<string>('');
const [editContent, setEditContent] = useState<string>('');
useEffect(() => {
if (!document) {
return;
}
const fetchDocument = async (): Promise<void> => {
try {
const response: Types.DocumentContentResponse = await apiClient.getCandidateDocumentText(
document
);
setContent(response.content || '');
setEditContent(response.content || '');
} catch (error) {
console.error('Error obtaining Docs content information:', error);
setContent(`${document.filename} not found.`);
}
};
fetchDocument();
}, [document, setContent]);
// Handle document rename
const handleDocumentUpdate = async (): Promise<void> => {
if (!editingName.trim()) {
setSnack('Document name cannot be empty', 'error');
return;
}
if (!editContent.trim()) {
setSnack('Document content cannot be empty. Delete instead.', 'error');
return;
}
try {
// Call API to rename document
document.filename = editingName;
let result: Types.Document;
if (editContent !== content) {
result = await apiClient.updateCandidateDocument(document, editContent);
} else {
result = await apiClient.updateCandidateDocument(document);
}
onSave && onSave(result);
setContent(editContent);
setSnack('Document updated successfully', 'success');
} catch (error) {
setSnack('Failed to udpate document', 'error');
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
padding: 2,
position: 'relative',
overflow: 'hidden',
}}
>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e): void => {
edit && setEditingName(e.target.value);
}}
onKeyUp={(e): void => {
if (e.key === 'Enter') {
handleDocumentUpdate();
}
}}
sx={{ pointerEvents: edit ? 'auto' : 'none' }}
/>
<Scrollable sx={{ width: '100%', height: 'calc(100% - 64px)', overflowY: 'auto' }}>
<TextField
margin="dense"
label="Content"
fullWidth
variant="outlined"
value={editContent}
onChange={(e): void => {
edit && setEditContent(e.target.value);
}}
sx={{ pointerEvents: edit ? 'auto' : 'none' }}
multiline
/>
</Scrollable>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2, direction: 'row' }}>
{edit && (
<Button
onClick={(): void => {
handleDocumentUpdate();
}}
variant="contained"
disabled={
!editingName.trim() ||
!editContent.trim() ||
(editingName === document.filename && editContent === content)
}
>
Save
</Button>
)}
<Button
onClick={(): void => {
onCancel && onCancel();
}}
>
{edit && (editingName !== document.filename || editContent !== content)
? 'Cancel'
: 'Close'}
</Button>
</Box>
</Box>
);
};
export { DocumentView };

View File

@ -0,0 +1,675 @@
import React, { useState, useEffect, JSX } from 'react';
import {
Box,
Card,
CardContent,
Typography,
TextField,
Button,
Alert,
CircularProgress,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Checkbox,
FormControlLabel,
Grid,
IconButton,
} from '@mui/material';
import {
Email as EmailIcon,
Security as SecurityIcon,
CheckCircle as CheckCircleIcon,
ErrorOutline as ErrorIcon,
Refresh as RefreshIcon,
DevicesOther as DevicesIcon,
VisibilityOff,
Visibility,
} from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { useNavigate } from 'react-router-dom';
import { MFAData } from 'types/types';
// Email Verification Component
const EmailVerificationPage = (_props: BackstoryPageProps): JSX.Element => {
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } =
useAuth();
const navigate = useNavigate();
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>('');
useEffect(() => {
// Get token from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const handleVerifyEmail = async (token: string): Promise<void> => {
if (!token) {
setStatus('error');
setMessage('Invalid verification link');
return;
}
try {
const result = await verifyEmail({ token });
if (result) {
setStatus('success');
setMessage(result.message);
setUserType(result.userType);
// Redirect to login after 3 seconds
setTimeout(() => {
navigate('/login');
}, 3000);
} else {
setStatus('error');
setMessage('Email verification failed');
}
} catch (error) {
setStatus('error');
setMessage('Email verification failed');
}
};
if (token) {
handleVerifyEmail(token);
}
}, [navigate, verifyEmail]);
const handleResendVerification = async (): Promise<void> => {
const email = getPendingVerificationEmail();
if (!email) {
setMessage('No pending verification email found.');
return;
}
try {
const success = await resendEmailVerification(email);
if (success) {
setMessage('Verification email sent successfully!');
}
} catch (error) {
setMessage('Failed to resend verification email.');
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.50',
p: 2,
}}
>
<Card sx={{ maxWidth: 500, width: '100%' }}>
<CardContent sx={{ p: 4 }}>
<Box textAlign="center" mb={3}>
{status === 'pending' && (
<>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" gutterBottom>
Verifying Email
</Typography>
<Typography color="text.secondary">
Please wait while we verify your email address...
</Typography>
</>
)}
{status === 'success' && (
<>
<CheckCircleIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h4" gutterBottom color="success.main">
Email Verified!
</Typography>
<Typography color="text.secondary">
Your {userType} account has been successfully activated.
</Typography>
</>
)}
{status === 'error' && (
<>
<ErrorIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
<Typography variant="h4" gutterBottom color="error.main">
Verification Failed
</Typography>
<Typography color="text.secondary">
We couldn&apos;t verify your email address.
</Typography>
</>
)}
</Box>
{isLoading && (
<Box display="flex" justifyContent="center" my={3}>
<CircularProgress />
</Box>
)}
{(message || error) && (
<Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
sx={{ mt: 2 }}
>
{message || error}
</Alert>
)}
{status === 'success' && (
<Box mt={3} textAlign="center">
<Typography variant="body2" color="text.secondary" mb={2}>
You will be redirected to the login page in a few seconds...
</Typography>
<Button
variant="contained"
onClick={(): void => {
navigate('/login');
}}
fullWidth
>
Go to Login
</Button>
</Box>
)}
{status === 'error' && (
<Box mt={3}>
<Button
variant="outlined"
onClick={handleResendVerification}
disabled={isLoading}
startIcon={<RefreshIcon />}
fullWidth
sx={{ mb: 2 }}
>
Resend Verification Email
</Button>
<Button
variant="contained"
onClick={(): void => {
navigate('/login');
}}
fullWidth
>
Back to Login
</Button>
</Box>
)}
</CardContent>
</Card>
</Box>
);
};
// MFA Verification Component
interface MFAVerificationDialogProps {
open: boolean;
onClose: () => void;
onVerificationSuccess: () => void;
}
const MFAVerificationDialog = (props: MFAVerificationDialogProps): JSX.Element => {
const { open, onClose, onVerificationSuccess } = props;
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
const [code, setCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false);
const [localError, setLocalError] = useState('');
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
useEffect(() => {
if (!open) return;
const timer = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(timer);
setLocalError('MFA code has expired. Please try logging in again.');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [open]);
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleVerifyMFA = async (): Promise<void> => {
if (!code || code.length !== 6) {
setLocalError('Please enter a valid 6-digit code');
return;
}
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
setLocalError('');
try {
const success = await verifyMFA({
email: mfaResponse.mfaData.email,
code,
deviceId: mfaResponse.mfaData.deviceId,
rememberDevice,
});
if (success) {
onVerificationSuccess();
onClose();
}
} catch (error) {
setLocalError('Verification failed. Please try again.');
}
};
const handleResendCode = async (): Promise<void> => {
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
try {
const success = await resendMFACode(
mfaResponse.mfaData.email,
mfaResponse.mfaData.deviceId,
mfaResponse.mfaData.deviceName
);
if (success) {
setTimeLeft(600); // Reset timer
setLocalError('');
alert('New verification code sent to your email');
}
} catch (error) {
setLocalError('Failed to resend code');
}
};
const handleClose = (): void => {
clearMFA();
onClose();
};
if (!mfaResponse || !mfaResponse.mfaData) return <></>;
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" />
<Typography variant="h6">Verify Your Identity</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Alert severity="info" sx={{ mb: 3 }}>
We&apos;ve detected a login from a new device:{' '}
<strong>{mfaResponse.mfaData.deviceName}</strong>
</Alert>
<Typography variant="body1" gutterBottom>
We&apos;ve sent a 6-digit verification code to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{mfaResponse.mfaData.email}
</Typography>
<TextField
fullWidth
label="Enter 6-digit code"
value={code}
onChange={(e): void => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
setLocalError('');
}}
placeholder="000000"
inputProps={{
maxLength: 6,
style: {
fontSize: 24,
textAlign: 'center',
letterSpacing: 8,
},
}}
sx={{ mt: 2, mb: 2 }}
error={!!(localError || errorMessage)}
helperText={localError || errorMessage}
/>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="body2" color="text.secondary">
Code expires in: {formatTime(timeLeft)}
</Typography>
<Button
size="small"
onClick={handleResendCode}
disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
>
Resend Code
</Button>
</Box>
<FormControlLabel
control={
<Checkbox
checked={rememberDevice}
onChange={(e): void => {
setRememberDevice(e.target.checked);
}}
/>
}
label="Remember this device for 90 days"
/>
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
If you didn&apos;t attempt to log in, please change your password immediately.
</Typography>
</Alert>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleVerifyMFA}
disabled={isLoading || !code || code.length !== 6 || timeLeft === 0}
>
{isLoading ? <CircularProgress size={20} /> : 'Verify'}
</Button>
</DialogActions>
</Dialog>
);
};
// Enhanced Registration Success Component
const RegistrationSuccessDialog = ({
open,
onClose,
email,
userType,
}: {
open: boolean;
onClose: () => void;
email: string;
userType: string;
}): JSX.Element => {
const { resendEmailVerification, isLoading } = useAuth();
const [resendMessage, setResendMessage] = useState('');
const handleResendVerification = async (): Promise<void> => {
try {
const success = await resendEmailVerification(email);
if (success) {
setResendMessage('Verification email sent!');
}
} catch (error: unknown) {
const tmp = error as { message?: string };
setResendMessage(tmp?.message || 'Network error. Please try again.');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent sx={{ textAlign: 'center', p: 4 }}>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h5" gutterBottom>
Check Your Email
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
We&apos;e sent a verification link to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
</Typography>
<Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: 'left' }}>
<Typography variant="body2">
<strong>Next steps:</strong>
<br />
1. Check your email inbox (and spam folder)
<br />
2. Click the verification link
<br />
3. Your {userType} account will be activated
</Typography>
</Alert>
{resendMessage && (
<Alert severity={resendMessage.includes('sent') ? 'success' : 'error'} sx={{ mb: 2 }}>
{resendMessage}
</Alert>
)}
</DialogContent>
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<Button
onClick={handleResendVerification}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
>
Resend Email
</Button>
<Button variant="contained" onClick={onClose}>
Got It
</Button>
</DialogActions>
</Dialog>
);
};
// Enhanced Login Component with MFA Support
const LoginForm = (): JSX.Element => {
const { login, mfaResponse, isLoading, error, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
const handleLogin = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
const success = await login({
login: email,
password,
});
console.log(`login success: ${success}`);
if (success) {
// Redirect based on user type - this could be handled in AuthContext
// or by a higher-level component that listens to auth state changes
handleLoginSuccess();
}
};
const handleMFASuccess = (): void => {
handleLoginSuccess();
};
const handleLoginSuccess = (): void => {
if (!user) {
navigate('/');
} else {
navigate(`/${user.userType}/dashboard`);
}
console.log('Login successful - redirect to dashboard');
};
return (
<Box component="form" onSubmit={handleLogin} sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
label="Email or Username"
value={email}
onChange={(e): void => {
setEmail(e.target.value);
}}
autoComplete="email"
autoFocus
/>
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e): void => {
setPassword(e.target.value);
}}
autoComplete="current-password"
placeholder="Create a strong password"
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={(): void => {
setShowPassword(!showPassword);
}}
onMouseDown={(e): void => {
e.preventDefault();
}}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{isLoading ? <CircularProgress size={20} /> : 'Sign In'}
</Button>
{/* MFA Dialog */}
<MFAVerificationDialog
open={mfaResponse?.mfaRequired || false}
onClose={(): void => {
console.log();
}} // This will be handled by clearMFA in the dialog
onVerificationSuccess={handleMFASuccess}
/>
</Box>
);
};
// Device Management Component
const TrustedDevicesManager = (): JSX.Element => {
const devices: MFAData[] = [];
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Trusted Devices
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Manage devices that you&apos;ve marked as trusted. You won&apos;t need to verify your
identity when signing in from these devices.
</Typography>
{devices.length === 0 ? (
<Alert severity="info">
No trusted devices yet. When you log in from a new device and choose to remember it, it
will appear here.
</Alert>
) : (
<Grid container spacing={2}>
{devices.map((device, index) => (
<Grid key={index} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1">{device.deviceName}</Typography>
{/* <Typography variant="body2" color="text.secondary">
Added: {new Date(device.addedAt).toLocaleDateString()}
</Typography> */}
{/* <Typography variant="body2" color="text.secondary">
Last used: {new Date(device.lastUsed).toLocaleDateString()}
</Typography> */}
<Button
size="small"
color="error"
sx={{ mt: 1 }}
onClick={(): void => {
console.log('Remove device');
}}
>
Remove
</Button>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</CardContent>
</Card>
);
};
export {
EmailVerificationPage,
MFAVerificationDialog,
TrustedDevicesManager,
RegistrationSuccessDialog,
LoginForm,
};

View File

@ -1,3 +1,4 @@
import React, { JSX } from 'react';
import { styled } from '@mui/material/styles';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
@ -5,7 +6,8 @@ interface ExpandMoreProps extends IconButtonProps {
expand: boolean;
}
const ExpandMore = styled((props: ExpandMoreProps) => {
const ExpandMore = styled((props: ExpandMoreProps): JSX.Element => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { expand, ...other } = props;
return <IconButton {...other} />;
})(({ theme }) => ({
@ -15,13 +17,13 @@ const ExpandMore = styled((props: ExpandMoreProps) => {
}),
variants: [
{
props: ({ expand }) => !expand,
props: ({ expand }): boolean => !expand,
style: {
transform: 'rotate(0deg)',
},
},
{
props: ({ expand }) => !!expand,
props: ({ expand }): boolean => !!expand,
style: {
transform: 'rotate(180deg)',
},
@ -29,6 +31,4 @@ const ExpandMore = styled((props: ExpandMoreProps) => {
],
}));
export {
ExpandMore
};
export { ExpandMore };

View File

@ -0,0 +1,138 @@
import React, { useEffect, useState, useRef, JSX } from 'react';
import Box from '@mui/material/Box';
import PropagateLoader from 'react-spinners/PropagateLoader';
import { Quote } from 'components/Quote';
import { BackstoryElementProps } from 'components/BackstoryTab';
import { ChatSession } from 'types/types';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
interface GenerateImageProps extends BackstoryElementProps {
prompt: string;
chatSession: ChatSession;
}
const GenerateImage = (props: GenerateImageProps): JSX.Element => {
const { user } = useAuth();
const { chatSession, prompt } = props;
const { setSnack } = useAppState();
const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>('');
const controllerRef = useRef<string>(null);
// Effect to trigger profile generation when user data is ready
useEffect(() => {
if (controllerRef.current) {
console.log('Controller already active, skipping profile generation');
return;
}
if (!prompt) {
return;
}
setStatus('Starting image generation...');
setProcessing(true);
// const start = Date.now();
// controllerRef.current = streamQueryResponse({
// query: {
// prompt: prompt,
// agentOptions: {
// username: name,
// }
// },
// type: "image",
// onComplete: (msg) => {
// switch (msg.status) {
// case "partial":
// case "done":
// if (msg.status === "done") {
// if (!msg.response) {
// setSnack("Image generation failed", "error");
// } else {
// setImage(msg.response);
// }
// setProcessing(false);
// controllerRef.current = null;
// }
// break;
// case "error":
// console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
// setSnack(msg.response || "", "error");
// setProcessing(false);
// controllerRef.current = null;
// break;
// default:
// let data: any = {};
// try {
// data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
// } catch (e) {
// data = { message: msg.response };
// }
// if (msg.status !== "heartbeat") {
// console.log(data);
// }
// if (data.message) {
// setStatus(data.message);
// }
// break;
// }
// }
// });
}, [user, prompt, setSnack]);
if (!chatSession) {
return <></>;
}
return (
<Box
className="GenerateImage"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
minHeight: 'max-content',
}}
>
{prompt && (
<Quote
size={processing ? 'normal' : 'small'}
quote={prompt}
sx={{ '& *': { color: '#2E2E2E !important' } }}
/>
)}
{processing && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 0,
gap: 1,
minHeight: 'min-content',
mb: 2,
}}
>
{status && (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ fontSize: '0.5rem' }}>Generation status</Box>
<Box sx={{ fontWeight: 'bold' }}>{status}</Box>
</Box>
)}
<PropagateLoader
size="10px"
loading={processing}
color="white"
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
</Box>
);
};
export { GenerateImage };

View File

@ -0,0 +1,559 @@
import React, { useState, useRef, JSX } from 'react';
import {
Box,
Button,
Typography,
TextField,
Grid,
useTheme,
useMediaQuery,
Chip,
Card,
CardContent,
CardHeader,
LinearProgress,
Stack,
} from '@mui/material';
import {
AutoFixHigh,
Psychology,
Build,
CloudUpload,
Description,
Business,
Work,
CheckCircle,
Star,
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import { BackstoryElementProps } from './BackstoryTab';
import * as Types from 'types/types';
import { StyledMarkdown } from './StyledMarkdown';
import { JobInfo } from './ui/JobInfo';
import { Scrollable } from './Scrollable';
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
const UploadBox = styled(Box)(({ theme }) => ({
border: `2px dashed ${theme.palette.primary.main}`,
borderRadius:
(typeof theme.shape.borderRadius === 'string'
? parseInt(theme.shape.borderRadius)
: theme.shape.borderRadius) * 2,
padding: theme.spacing(4),
textAlign: 'center',
backgroundColor: theme.palette.action.hover,
transition: 'all 0.3s ease',
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.selected,
borderColor: theme.palette.primary.dark,
},
}));
interface JobCreatorProps extends BackstoryElementProps {
onSave?: (job: Types.Job) => void;
}
const JobCreator = (props: JobCreatorProps): JSX.Element => {
const { user, apiClient } = useAuth();
const { onSave } = props;
const { setSnack } = useAppState();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [jobDescription, setJobDescription] = useState<string>('');
const [jobRequirements, setJobRequirements] = useState<Types.JobRequirements | null>(null);
const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [summary, setSummary] = useState<string>('');
const [job, setJob] = useState<Types.Job | null>(null);
const [jobStatus, setJobStatus] = useState<string>('');
const [jobStatusType, setJobStatusType] = useState<Types.ApiActivityType | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus): void => {
console.log('status:', status.content);
setJobStatusType(status.activity);
setJobStatus(status.content);
},
onMessage: (jobMessage: Types.JobRequirementsMessage): void => {
const job: Types.Job = jobMessage.job;
console.log('onMessage - job', job);
setJob(job);
setCompany(job.company || '');
setJobDescription(job.description);
setSummary(job.summary || '');
setJobTitle(job.title || '');
setJobRequirements(job.requirements || null);
setJobStatusType(null);
setJobStatus('');
},
onError: (error: Types.ChatMessageError): void => {
console.log('onError', error);
setSnack(error.content, 'error');
setIsProcessing(false);
},
onComplete: (): void => {
setJobStatusType(null);
setJobStatus('');
setIsProcessing(false);
},
};
const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case 'pdf':
docType = 'pdf';
break;
case 'docx':
docType = 'docx';
break;
case 'md':
docType = 'markdown';
break;
case 'txt':
docType = 'txt';
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
setIsProcessing(true);
setJobDescription('');
setJobTitle('');
setJobRequirements(null);
setSummary('');
const controller = apiClient.createJobFromFile(file, jobStatusHandlers);
const job = await controller.promise;
if (!job) {
return;
}
console.log(`Job id: ${job.id}`);
e.target.value = '';
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setIsProcessing(false);
}
}
};
const handleUploadClick = (): void => {
fileInputRef.current?.click();
};
const renderRequirementSection = (
title: string,
items: string[] | undefined,
icon: JSX.Element,
required = false
): JSX.Element => {
if (!items || items.length === 0) return <></>;
return (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
{icon}
<Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600 }}>
{title}
</Typography>
{required && <Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />}
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => (
<Chip key={index} label={item} variant="outlined" size="small" sx={{ mb: 1 }} />
))}
</Stack>
</Box>
);
};
const renderJobRequirements = (): JSX.Element => {
if (!jobRequirements) return <></>;
return (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Requirements Analysis"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{renderRequirementSection(
'Technical Skills (Required)',
jobRequirements.technicalSkills.required,
<Build color="primary" />,
true
)}
{renderRequirementSection(
'Technical Skills (Preferred)',
jobRequirements.technicalSkills.preferred,
<Build color="action" />
)}
{renderRequirementSection(
'Experience Requirements (Required)',
jobRequirements.experienceRequirements.required,
<Work color="primary" />,
true
)}
{renderRequirementSection(
'Experience Requirements (Preferred)',
jobRequirements.experienceRequirements.preferred,
<Work color="action" />
)}
{renderRequirementSection(
'Soft Skills',
jobRequirements.softSkills,
<Psychology color="secondary" />
)}
{renderRequirementSection(
'Experience',
jobRequirements.experience,
<Star color="warning" />
)}
{renderRequirementSection(
'Education',
jobRequirements.education,
<Description color="info" />
)}
{renderRequirementSection(
'Certifications',
jobRequirements.certifications,
<CheckCircle color="success" />
)}
{renderRequirementSection(
'Preferred Attributes',
jobRequirements.preferredAttributes,
<Star color="secondary" />
)}
</CardContent>
</Card>
);
};
const handleSave = async (): Promise<void> => {
const newJob: Types.Job = {
ownerId: user?.id || '',
ownerType: 'candidate',
description: jobDescription,
company: company,
summary: summary,
title: jobTitle,
requirements: jobRequirements || undefined,
createdAt: new Date(),
updatedAt: new Date(),
};
setIsProcessing(true);
const job = await apiClient.createJob(newJob);
setIsProcessing(false);
if (!job) {
setSnack('Failed to save job', 'error');
return;
}
onSave && onSave(job);
};
const handleExtractRequirements = async (): Promise<void> => {
try {
setIsProcessing(true);
const controller = apiClient.createJobFromDescription(jobDescription, jobStatusHandlers);
const job = await controller.promise;
if (!job) {
setIsProcessing(false);
return;
}
console.log(`Job id: ${job.id}`);
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setIsProcessing(false);
}
setIsProcessing(false);
};
const renderJobCreation = (): JSX.Element => {
return (
<Box
sx={{
width: '100%',
p: 1,
}}
>
{/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Information"
subheader="Upload a job description or enter details manually"
avatar={<Work color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: 'flex', alignItems: 'center' }}
>
<CloudUpload sx={{ mr: 1 }} />
Upload Job Description
</Typography>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drop your job description here
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Supported formats: PDF, DOCX, TXT, MD
</Typography>
<Button
variant="contained"
startIcon={<FileUploadIcon />}
disabled={isProcessing}
// onClick={handleUploadClick}
>
Choose File
</Button>
</UploadBox>
<VisuallyHiddenInput
ref={fileInputRef}
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleJobUpload}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: 'flex', alignItems: 'center' }}
>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</Typography>
<TextField
fullWidth
multiline
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e): void => {
setJobDescription(e.target.value);
}}
disabled={isProcessing}
sx={{ mb: 2 }}
/>
{jobRequirements === null && jobDescription && (
<Button
variant="outlined"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
</Grid>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* Job Details Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Details"
subheader="Enter specific information about the position"
avatar={<Business color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
variant="outlined"
value={jobTitle}
onChange={(e): void => {
setJobTitle(e.target.value);
}}
required
disabled={isProcessing}
InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Company"
variant="outlined"
value={company}
onChange={(e): void => {
setCompany(e.target.value);
}}
required
disabled={isProcessing}
InputProps={{
startAdornment: <Business sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
</Grid>
{/* <Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Location"
variant="outlined"
value={jobLocation}
onChange={(e) => setJobLocation(e.target.value)}
disabled={isProcessing}
InputProps={{
startAdornment: <LocationOn sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
</Grid> */}
</Grid>
</CardContent>
</Card>
{/* Job Summary */}
{summary !== '' && (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>{summary}</CardContent>
</Card>
)}
{/* Requirements Display */}
{renderJobRequirements()}
</Box>
);
};
return (
<Box
className="JobManagement"
sx={{
background: 'white',
p: 0,
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
{job === null && renderJobCreation()}
{job && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
gap: 1,
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
position: 'relative',
}}
>
<Scrollable
sx={{
display: 'flex',
flexGrow: 1,
position: 'relative',
maxHeight: '30rem',
}}
>
<JobInfo job={job} />
</Scrollable>
<Scrollable
sx={{
display: 'flex',
flexGrow: 1,
position: 'relative',
maxHeight: '30rem',
}}
>
<StyledMarkdown content={job.description} />
</Scrollable>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Box>
)}
</Box>
);
};
export { JobCreator };

View File

@ -0,0 +1,822 @@
import React, { useState, useEffect, useCallback, JSX, useMemo } from 'react';
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
Chip,
Card,
CardContent,
useTheme,
LinearProgress,
useMediaQuery,
Button,
Paper,
SxProps,
Tooltip,
IconButton,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import ModelTrainingIcon from '@mui/icons-material/ModelTraining';
import PendingIcon from '@mui/icons-material/Pending';
import WarningIcon from '@mui/icons-material/Warning';
import { Candidate, SkillAssessment, SkillStatus } from 'types/types';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { Job } from 'types/types';
import * as Types from 'types/types';
import { JobInfo } from './ui/JobInfo';
import { Scrollable } from 'components/Scrollable';
interface JobAnalysisScore {
score: number;
skills: SkillAssessment[];
}
interface JobAnalysisProps extends BackstoryPageProps {
job: Job;
candidate: Candidate;
variant?: 'small' | 'normal';
onAnalysisComplete: (analysis: JobAnalysisScore) => void;
}
interface SkillMatch extends SkillAssessment {
domain: string;
status: SkillStatus;
matchScore: number;
}
const JobMatchScore: React.FC<{ score: number; variant?: 'small' | 'normal'; sx?: SxProps }> = ({
variant = 'normal',
score,
sx = {},
}) => {
const theme = useTheme();
const getMatchColor = (score: number): string => {
if (score >= 80) return theme.palette.success.main;
if (score >= 60) return theme.palette.info.main;
if (score >= 40) return theme.palette.warning.main;
return theme.palette.error.main;
};
const suffix = variant === 'small' ? '' : ' Match';
return (
<Box
className="JobMatchScore"
sx={{
justifyContent: 'center',
m: 0,
p: 0,
width: variant === 'small' ? '8rem' : '10rem',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
...sx,
}}
>
<Chip
label={
score >= 80
? `Excellent${suffix}`
: score >= 60
? `Good${suffix}`
: score >= 40
? `Partial${suffix}`
: `Low${suffix}`
}
sx={{
bgcolor: getMatchColor(score),
color: 'white',
fontWeight: 'bold',
fontSize: variant === 'small' ? '0.7rem' : '1rem',
}}
/>
{variant !== 'small' && (
<Box
sx={{
position: 'relative',
display: 'inline-flex',
p: 0,
m: 0,
}}
>
<CircularProgress
variant="determinate"
value={score}
size={60}
thickness={5}
sx={{
color: getMatchColor(score),
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography
variant="caption"
component="div"
sx={{ fontWeight: 'bold', fontSize: '1rem' }}
>
{`${Math.round(score)}%`}
</Typography>
</Box>
</Box>
)}
</Box>
);
};
const calculateScore = (skillMatch: SkillAssessment): number => {
let score = 0;
switch (skillMatch.evidenceStrength.toUpperCase()) {
case 'STRONG':
score = 100;
break;
case 'MODERATE':
score = 75;
break;
case 'WEAK':
score = 50;
break;
case 'NONE':
score = 0;
break;
}
if (
skillMatch.evidenceStrength === 'none' &&
skillMatch.evidenceDetails &&
skillMatch.evidenceDetails.length > 3
) {
score = Math.min(skillMatch.evidenceDetails.length * 8, 40);
}
return score;
};
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
const { apiClient } = useAuth();
const theme = useTheme();
const [requirements, setRequirements] = useState<{ requirement: string; domain: string }[]>([]);
const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]);
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false);
const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0);
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>('');
const [percentage, setPercentage] = useState<number>(0);
const [analysis, setAnalysis] = useState<JobAnalysisScore | null>(null);
const [startAnalysis, setStartAnalysis] = useState<boolean>(true);
const [firstRun, setFirstRun] = useState<boolean>(true);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Handle accordion expansion
const handleAccordionChange =
(panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false);
};
const initializeRequirements = useCallback(
(job: Job): void => {
if (!job || !job.requirements) {
return;
}
const requirements: { requirement: string; domain: string }[] = [];
if (job.requirements?.technicalSkills) {
job.requirements.technicalSkills.required?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Technical Skills (required)',
})
);
job.requirements.technicalSkills.preferred?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Technical Skills (preferred)',
})
);
}
if (job.requirements?.experienceRequirements) {
job.requirements.experienceRequirements.required?.forEach(req =>
requirements.push({ requirement: req, domain: 'Experience (required)' })
);
job.requirements.experienceRequirements.preferred?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Experience (preferred)',
})
);
}
if (job.requirements?.softSkills) {
job.requirements.softSkills.forEach(req =>
requirements.push({ requirement: req, domain: 'Soft Skills' })
);
}
if (job.requirements?.experience) {
job.requirements.experience.forEach(req =>
requirements.push({ requirement: req, domain: 'Experience' })
);
}
if (job.requirements?.education) {
job.requirements.education.forEach(req =>
requirements.push({ requirement: req, domain: 'Education' })
);
}
if (job.requirements?.certifications) {
job.requirements.certifications.forEach(req =>
requirements.push({ requirement: req, domain: 'Certifications' })
);
}
if (job.requirements?.preferredAttributes) {
job.requirements.preferredAttributes.forEach(req =>
requirements.push({ requirement: req, domain: 'Preferred Attributes' })
);
}
const initialSkillMatches: SkillMatch[] = requirements.map(req => ({
skill: req.requirement,
skillModified: req.requirement,
candidateId: candidate.id || '',
domain: req.domain,
status: 'waiting' as const,
assessment: '',
description: '',
evidenceFound: false,
evidenceStrength: 'none',
evidenceDetails: [],
matchScore: 0,
}));
setRequirements(requirements);
setSkillMatches(initialSkillMatches);
setLoadingRequirements(false);
setOverallScore(0);
},
[candidate.id]
);
useEffect(() => {
initializeRequirements(job);
}, [job, initializeRequirements]);
const skillMatchHandlers = useMemo(() => {
return {
onStatus: (status: Types.ChatMessageStatus): void => {
console.log('Skill Match Status:', status.content);
setMatchStatus(status.content);
},
};
}, [setMatchStatus]);
// Fetch match data for each requirement
useEffect(() => {
if (
(!startAnalysis && !firstRun) ||
analyzing ||
!job.requirements ||
requirements.length === 0
) {
return;
}
const fetchMatchData = async (firstRun: boolean): Promise<void> => {
const currentAnalysis = await apiClient.getJobAnalysis(job, candidate);
for (let i = 0; i < requirements.length; i++) {
try {
let match: SkillMatch;
const existingMatch = currentAnalysis?.skills.find(
(match: SkillAssessment) => match.skill === requirements[i].requirement
);
if (existingMatch) {
match = {
...existingMatch,
status: 'complete',
matchScore: calculateScore(existingMatch),
domain: requirements[i].domain,
};
} else {
if (firstRun) {
continue;
}
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = { ...updated[i], status: 'pending' };
return updated;
});
const request = await apiClient.candidateMatchForRequirement(
candidate.id || '',
requirements[i].requirement,
false,
skillMatchHandlers
);
const result = await request.promise; /* Wait for the streaming result to complete */
const skillMatch = result.skillAssessment;
setMatchStatus('');
match = {
...skillMatch,
status: 'complete',
matchScore: calculateScore(skillMatch),
domain: requirements[i].domain,
};
}
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = match;
return updated;
});
// Update overall score
setSkillMatches(current => {
const completedMatches = current.filter(match => match.status === 'complete');
if (completedMatches.length > 0) {
const newOverallScore =
completedMatches.reduce((sum, match) => sum + match.matchScore, 0) /
completedMatches.length;
setOverallScore(Math.round(newOverallScore));
}
return current;
});
} catch (error) {
console.error(`Error fetching match for requirement ${requirements[i]}:`, error);
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = {
...updated[i],
status: 'error',
assessment: 'Failed to analyze this requirement.',
};
return updated;
});
}
setPercentage(Math.round(((i + 1) / requirements.length) * 100));
}
};
setAnalyzing(true);
setPercentage(0);
fetchMatchData(firstRun).then(() => {
setAnalyzing(false);
setStartAnalysis(false);
});
setFirstRun(false);
}, [
job,
startAnalysis,
analyzing,
requirements,
loadingRequirements,
apiClient,
candidate.id,
skillMatchHandlers,
firstRun,
]);
useEffect(() => {
if (skillMatches.length === 0) {
return;
}
const finishedAnalysis = skillMatches.every(
match => match.status === 'complete' || match.status === 'error'
);
if (!finishedAnalysis) {
return;
}
if (analysis && analysis.score === overallScore) {
return; // No change in score, skip setting analysis
}
const newAnalysis: JobAnalysisScore = {
score: overallScore,
skills: skillMatches,
};
setAnalysis(newAnalysis);
onAnalysisComplete && onAnalysisComplete(newAnalysis);
}, [onAnalysisComplete, skillMatches, overallScore, analysis]);
// Get color based on match score
const getMatchColor = (score: number): string => {
if (score >= 80) return theme.palette.success.main;
if (score >= 60) return theme.palette.info.main;
if (score >= 40) return theme.palette.warning.main;
return theme.palette.error.main;
};
// Get icon based on status
const getStatusIcon = (status: string, score: number): JSX.Element => {
if (status === 'pending' || status === 'waiting') return <PendingIcon />;
if (status === 'error') return <ErrorIcon color="error" />;
if (score >= 70) return <CheckCircleIcon color="success" />;
if (score >= 40) return <WarningIcon color="warning" />;
return <ErrorIcon color="error" />;
};
const handleAssesmentRegenerate = async (index: number, skill: string): Promise<void> => {
setSkillMatches(prev => {
const updated = [...prev];
updated[index] = {
...updated[index],
status: 'pending',
assessment: '',
evidenceDetails: [],
evidenceStrength: 'none',
matchScore: 0,
};
return updated;
});
setMatchStatus('Regenerating assessment...');
const request = apiClient.candidateMatchForRequirement(
candidate.id || '',
skill,
true,
skillMatchHandlers
);
const result = await request.promise; /* Wait for the streaming result to complete */
const skillMatch = result.skillAssessment;
setMatchStatus('');
setSkillMatches(prev => {
const updated = [...prev];
updated[index] = {
...skillMatch,
status: 'complete',
matchScore: calculateScore(skillMatch),
domain: updated[index].domain,
};
return updated;
});
// Update overall score
setSkillMatches(current => {
const completedMatches = current.filter(match => match.status === 'complete');
if (completedMatches.length > 0) {
const newOverallScore =
completedMatches.reduce((sum, match) => sum + match.matchScore, 0) /
completedMatches.length;
setOverallScore(Math.round(newOverallScore));
}
return current;
});
setAnalysis({
score: overallScore,
skills: skillMatches,
});
onAnalysisComplete &&
onAnalysisComplete({
score: overallScore,
skills: skillMatches,
});
setStartAnalysis(false);
setAnalyzing(false);
setFirstRun(false);
};
const beginAnalysis = (): void => {
initializeRequirements(job);
setStartAnalysis(true);
};
return (
<Scrollable
className="JobMatchAnalysis"
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
p: 1,
width: '100%',
minHeight: 0,
flexGrow: 1,
}}
>
{variant !== 'small' && <JobInfo job={job} variant="normal" />}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
mb: isMobile ? 1 : 2,
gap: 1,
justifyContent: 'space-between',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
flexGrow: 1,
gap: 1,
}}
>
{analyzing && overallScore !== 0 && (
<JobMatchScore
score={overallScore}
sx={{ width: isMobile ? '100%' : 'auto', flexGrow: 1 }}
/>
)}
{analyzing && (
<Paper
sx={{
width: '10rem',
ml: 1,
p: 1,
gap: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Typography variant="h5" component="h2" sx={{ m: 0, fontSize: '1rem' }}>
Analyzing
</Typography>
<Box
sx={{
position: 'relative',
display: 'inline-flex',
}}
>
<CircularProgress
variant="determinate"
value={percentage}
size={60}
thickness={5}
sx={{
color: 'orange',
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
{`${Math.round(percentage)}%`}
</Typography>
</Box>
</Box>
</Paper>
)}
</Box>
<Button
sx={{ marginLeft: 'auto' }}
disabled={analyzing || startAnalysis}
onClick={beginAnalysis}
variant="contained"
>
{analyzing ? 'Assessment in Progress' : 'Analyze Waiting Skills'}
</Button>
</Box>
{loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
Analyzing job requirements...
</Typography>
</Box>
) : (
<Box>
<Typography variant="h5" component="h2" gutterBottom>
Requirements Analysis
</Typography>
{skillMatches.map((match, index) => (
<Accordion
key={index}
expanded={expanded === `panel${index}`}
onChange={handleAccordionChange(`panel${index}`)}
sx={{
mb: 2,
border: '1px solid',
borderColor:
match.status === 'complete'
? getMatchColor(match.matchScore)
: theme.palette.divider,
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel${index}bh-content`}
id={`panel${index}bh-header`}
sx={{
bgcolor:
match.status === 'complete'
? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{getStatusIcon(match.status, match.matchScore)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0,
p: 0,
m: 0,
}}
>
<Typography
sx={{
ml: 1,
mb: 0,
fontWeight: 'medium',
marginBottom: '0px !important',
}}
>
{match.skill}
</Typography>
<Typography variant="caption" sx={{ ml: 1, fontWeight: 'light' }}>
{match.domain}
</Typography>
</Box>
</Box>
{match.status === 'complete' ? (
<Chip
label={`${match.matchScore}% Match`}
size="small"
sx={{
bgcolor: getMatchColor(match.matchScore),
color: 'white',
minWidth: 90,
pointerEvents: 'none',
}}
/>
) : match.status === 'waiting' ? (
<Chip
label="Waiting..."
size="small"
sx={{
bgcolor: 'rgb(189, 173, 85)',
color: 'white',
minWidth: 90,
pointerEvents: 'none',
}}
/>
) : match.status === 'pending' ? (
<Chip
label="Analyzing..."
size="small"
sx={{
bgcolor: theme.palette.grey[400],
color: 'white',
minWidth: 90,
pointerEvents: 'none',
}}
/>
) : (
<Chip
label="Error"
size="small"
sx={{
bgcolor: theme.palette.error.main,
color: 'white',
minWidth: 90,
pointerEvents: 'none',
}}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails>
{match.status === 'pending' ? (
<Box sx={{ width: '100%', p: 2 }}>
<LinearProgress />
<Typography sx={{ mt: 2 }}>
Analyzing candidate&apos;s match for this requirement... {matchStatus}
</Typography>
</Box>
) : match.status === 'error' ? (
<Typography color="error">
{match.assessment || 'An error occurred while analyzing this requirement.'}
</Typography>
) : (
<Box>
<Typography variant="h6" gutterBottom>
Assessment
</Typography>
<Typography paragraph sx={{ mb: 3 }}>
{match.assessment}
</Typography>
<Typography variant="h6" gutterBottom>
Supporting Evidence
</Typography>
{match.evidenceDetails && match.evidenceDetails.length > 0 ? (
match.evidenceDetails.map((evidence, evndex) => (
<Card
key={evndex}
variant="outlined"
sx={{
mb: 2,
borderLeft: '4px solid',
borderColor: theme.palette.primary.main,
}}
>
<CardContent>
<Typography
variant="body1"
component="div"
sx={{ mb: 1, fontStyle: 'italic' }}
>
&quot;{evidence.quote}&quot;
</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexDirection: 'column',
}}
>
<Typography variant="body2" color="text.secondary">
Relevance: {evidence.context}
</Typography>
<Typography variant="caption" color="text.secondary">
Source: {evidence.source}
</Typography>
{/* <Chip
size="small"
label={`Relevance: ${citation.relevance}%`}
sx={{
bgcolor: theme.palette.grey[200],
}}
/> */}
</Box>
</CardContent>
</Card>
))
) : (
<Typography color="text.secondary">
No specific evidence found in candidate&apos;s profile.
</Typography>
)}
<Typography variant="h6" gutterBottom>
Skill description
</Typography>
<Typography paragraph>{match.description}</Typography>
{match.status === 'complete' && (
<Tooltip title="Regenerate Assessment">
<IconButton
size="small"
onClick={(e): void => {
e.stopPropagation();
handleAssesmentRegenerate(index, match.skill);
}}
>
<ModelTrainingIcon />
</IconButton>
</Tooltip>
)}
{/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom>
RAG Information
</Typography>
<VectorVisualizer inline rag={match.ragResults[0]} />
</>
} */}
</Box>
)}
</AccordionDetails>
</Accordion>
))}
</Box>
)}
</Scrollable>
);
};
export type { JobAnalysisScore };
export { JobMatchAnalysis, JobMatchScore };

View File

@ -65,4 +65,4 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
);
};
export { LoadingComponent};
export { LoadingComponent };

View File

@ -0,0 +1,281 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
TextField,
Autocomplete,
Typography,
Grid,
Chip,
FormControlLabel,
Checkbox,
} from '@mui/material';
import { LocationOn, Public, Home } from '@mui/icons-material';
import { Country, State, City } from 'country-state-city';
import type { ICountry, IState, ICity } from 'country-state-city';
// Import from your types file - adjust path as needed
import type { Location } from 'types/types';
interface LocationInputProps {
value?: Partial<Location>;
onChange: (location: Partial<Location>) => void;
error?: boolean;
helperText?: string;
required?: boolean;
disabled?: boolean;
showCity?: boolean;
}
const LocationInput: React.FC<LocationInputProps> = ({
value = {},
onChange,
error = false,
helperText,
required = false,
disabled = false,
showCity = false,
}) => {
// Get all countries from the library
const allCountries = Country.getAllCountries();
const [selectedCountry, setSelectedCountry] = useState<ICountry | null>(
value.country ? allCountries.find(c => c.name === value.country) || null : null
);
const [selectedState, setSelectedState] = useState<IState | null>(null);
const [selectedCity, setSelectedCity] = useState<ICity | null>(null);
const [isRemote, setIsRemote] = useState<boolean>(value.remote || false);
// Get states for selected country
const availableStates = useMemo(() => {
return selectedCountry ? State.getStatesOfCountry(selectedCountry.isoCode) : [];
}, [selectedCountry]);
// Get cities for selected state
const availableCities = useMemo(() => {
return selectedCountry && selectedState
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
: [];
}, [selectedCountry, selectedState]);
// Initialize state and city from value prop
useEffect(() => {
if (selectedCountry && value.state) {
const stateMatch = availableStates.find(s => s.name === value.state);
setSelectedState(stateMatch || null);
}
}, [selectedCountry, value.state, availableStates]);
useEffect(() => {
if (selectedCountry && selectedState && value.city && showCity) {
const cityMatch = availableCities.find(c => c.name === value.city);
setSelectedCity(cityMatch || null);
}
}, [selectedCountry, selectedState, value.city, availableCities, showCity]);
// Update parent component when values change
useEffect(() => {
const newLocation: Partial<Location> = {};
if (selectedCountry) {
newLocation.country = selectedCountry.name;
}
if (selectedState) {
newLocation.state = selectedState.name;
}
if (selectedCity && showCity) {
newLocation.city = selectedCity.name;
}
if (isRemote) {
newLocation.remote = isRemote;
}
// Only call onChange if there's actual data or if clearing
if (Object.keys(newLocation).length > 0 || value.country || value.state || value.city) {
onChange(newLocation);
}
}, [
selectedCountry,
selectedState,
selectedCity,
isRemote,
onChange,
value.country,
value.state,
value.city,
showCity,
]);
const handleCountryChange = (_event: React.SyntheticEvent, newValue: ICountry | null): void => {
setSelectedCountry(newValue);
// Clear state and city when country changes
setSelectedState(null);
setSelectedCity(null);
};
const handleStateChange = (_event: React.SyntheticEvent, newValue: IState | null): void => {
setSelectedState(newValue);
// Clear city when state changes
setSelectedCity(null);
};
const handleCityChange = (_event: React.SyntheticEvent, newValue: ICity | null): void => {
setSelectedCity(newValue);
};
const handleRemoteToggle = (event: React.ChangeEvent<HTMLInputElement>): void => {
setIsRemote(event.target.checked);
};
return (
<Box>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LocationOn color="primary" />
Location {required && <span style={{ color: 'red' }}>*</span>}
</Typography>
<Grid container spacing={2}>
{/* Country Selection */}
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
<Autocomplete
value={selectedCountry}
onChange={handleCountryChange}
options={allCountries}
getOptionLabel={(option): string => option.name}
disabled={disabled}
renderInput={(params): React.ReactNode => (
<TextField
{...params}
label="Country"
variant="outlined"
required={required}
error={error && required && !selectedCountry}
helperText={
error && required && !selectedCountry ? 'Country is required' : helperText
}
InputProps={{
...params.InputProps,
startAdornment: <Public sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
)}
renderOption={(props, option): React.ReactNode => (
<Box component="li" {...props} key={option.isoCode}>
<img
loading="lazy"
width="20"
src={`https://flagcdn.com/w20/${option.isoCode.toLowerCase()}.png`}
srcSet={`https://flagcdn.com/w40/${option.isoCode.toLowerCase()}.png 2x`}
alt=""
style={{ marginRight: 8 }}
/>
{option.name}
</Box>
)}
/>
</Grid>
{/* State/Region Selection */}
{selectedCountry && (
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
<Autocomplete
value={selectedState}
onChange={handleStateChange}
options={availableStates}
getOptionLabel={(option): string => option.name}
disabled={disabled || availableStates.length === 0}
renderInput={(params): React.ReactNode => (
<TextField
{...params}
label="State/Region"
variant="outlined"
placeholder={
availableStates.length > 0 ? 'Select state/region' : 'No states available'
}
/>
)}
/>
</Grid>
)}
{/* City Selection */}
{showCity && selectedCountry && selectedState && (
<Grid size={{ xs: 12, sm: 4 }}>
<Autocomplete
value={selectedCity}
onChange={handleCityChange}
options={availableCities}
getOptionLabel={(option): string => option.name}
disabled={disabled || availableCities.length === 0}
renderInput={(params): React.ReactNode => (
<TextField
{...params}
label="City"
variant="outlined"
placeholder={availableCities.length > 0 ? 'Select city' : 'No cities available'}
InputProps={{
...params.InputProps,
startAdornment: <Home sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
)}
/>
</Grid>
)}
{/* Remote Work Option */}
<Grid size={{ xs: 12 }}>
<FormControlLabel
control={
<Checkbox
checked={isRemote}
onChange={handleRemoteToggle}
disabled={disabled}
color="primary"
/>
}
label="Open to remote work"
/>
</Grid>
{/* Location Summary Chips */}
{(selectedCountry || selectedState || selectedCity || isRemote) && (
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{selectedCountry && (
<Chip
icon={<Public />}
label={selectedCountry.name}
variant="outlined"
color="primary"
size="small"
/>
)}
{selectedState && (
<Chip
label={selectedState.name}
variant="outlined"
color="secondary"
size="small"
/>
)}
{selectedCity && showCity && (
<Chip
icon={<Home />}
label={selectedCity.name}
variant="outlined"
color="default"
size="small"
/>
)}
{isRemote && <Chip label="Remote" variant="filled" color="success" size="small" />}
</Box>
</Grid>
)}
</Grid>
</Box>
);
};
export { LocationInput };

Some files were not shown because too many files have changed in this diff Show More