Compare commits

..

No commits in common. "main" and "deployed-250528" have entirely different histories.

287 changed files with 11684 additions and 54587 deletions

2
.gitignore vendored
View File

@ -1,4 +1,3 @@
venv/**
users/**
!users/eliza
users-prod/**
@ -15,4 +14,3 @@ 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-local
FROM ubuntu:oracular AS python
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-local AS llm-base
FROM python AS llm-base
# Install Intel graphics runtimes
RUN apt-get update \
@ -124,8 +124,7 @@ 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 "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"
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
@ -178,46 +177,13 @@ RUN pip install names-dataset
FROM llm-base AS backstory
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]'
#COPY /src/requirements.txt /opt/backstory/src/requirements.txt
#RUN pip install -r /opt/backstory/src/requirements.txt
RUN pip install 'markitdown[all]' pydantic
# 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 { \
@ -226,10 +192,6 @@ 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'; \
@ -250,7 +212,7 @@ RUN { \
echo ' while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' echo "Launching Backstory server..."'; \
echo ' python3 src/backend/main.py "${@}" || echo "Backstory server died."'; \
echo ' python src/server.py "${@}" || echo "Backstory server died."'; \
echo ' echo "Sleeping for 3 seconds."'; \
echo ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \
@ -270,12 +232,9 @@ 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
@ -304,16 +263,17 @@ 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
# 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
#ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
RUN wget -qO - ${OLLAMA_VERSION} | \
tar --strip-components=1 -C . -xzv
@ -470,7 +430,7 @@ ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint-jupyter.sh" ]
FROM python-local AS miniircd
FROM python AS miniircd
# Get a couple prerequisites
RUN apt-get update \
@ -592,131 +552,3 @@ 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,35 +4,21 @@ 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:
- redis
- ollama
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
@ -40,8 +26,6 @@ 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)
@ -51,25 +35,19 @@ services:
build:
context: .
dockerfile: Dockerfile
target: backstory-prod
#image: backstory
target: backstory
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:
- redis
- ollama
networks:
- internal
ports:
@ -86,46 +64,13 @@ 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
@ -136,12 +81,39 @@ 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:
@ -149,6 +121,7 @@ services:
devices:
- /dev/dri:/dev/dri
depends_on:
- ollama
- miniircd
ports:
- 8888:8888 # Jupyter Notebook
@ -164,7 +137,7 @@ services:
context: .
dockerfile: Dockerfile
target: miniircd
#image: miniircd
image: miniircd
container_name: miniircd
restart: "no"
env_file:
@ -225,7 +198,3 @@ networks:
internal:
driver: bridge
volumes:
redis_data:
driver: local

View File

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

View File

@ -1,32 +0,0 @@
{
"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 @@
.prettied-it
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
build
deployed

View File

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

View File

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

View File

@ -9,51 +9,40 @@ module.exports = {
},
setupMiddlewares: (middlewares, devServer) => {
const { createProxyMiddleware } = require('http-proxy-middleware');
if (!devServer) {
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) {
proxyRes.headers['cache-control'] = 'no-cache';
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';
},
})
);
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'];
// 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'];
// Ignore source map warnings for node_modules
webpackConfig.ignoreWarnings = [/Failed to parse source map/];
webpackConfig.resolve.extensions = [
...webpackConfig.resolve.extensions,
'.ts',
'.tsx',
];
return webpackConfig;
},
},

File diff suppressed because it is too large Load Diff

View File

@ -15,51 +15,35 @@
"@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.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@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",
"lint": "eslint src/**/*.{ts,tsx} --no-color",
"lint:fix": "eslint src/**/*.{ts,tsx} --fix --no-color",
"format": "prettier --write src/**/*.{ts,tsx}"
"test": "craco test"
},
"eslintConfig": {
"extends": [
@ -82,14 +66,6 @@
"devDependencies": {
"@craco/craco": "^7.1.0",
"@types/markdown-it": "^14.1.2",
"@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"
"@types/plotly.js": "^2.35.5"
}
}

View File

@ -1,7 +0,0 @@
#!/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

@ -1,307 +0,0 @@
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

@ -1,492 +0,0 @@
# 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.

BIN
frontend/public/eliza.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

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

BIN
frontend/public/profile.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

261
frontend/src/App/Main.css Normal file
View File

@ -0,0 +1,261 @@
.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;
}

330
frontend/src/App/Main.tsx Normal file
View File

@ -0,0 +1,330 @@
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

@ -0,0 +1,98 @@
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

@ -1,53 +0,0 @@
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', // Midnight Blue
secondary: '#1A2536',//D3CDBF', // Warm Gray
},
background: {
default: '#D3CDBF', // Warm Gray
@ -34,32 +34,9 @@ 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: {
@ -92,32 +69,7 @@ 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
},
},
},
},
},
});
export { backstoryTheme };
export { backstoryTheme };

View File

@ -0,0 +1,54 @@
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

@ -0,0 +1,146 @@
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

@ -0,0 +1,227 @@
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

@ -0,0 +1,53 @@
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

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

View File

@ -0,0 +1,14 @@
.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

@ -0,0 +1,625 @@
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

@ -0,0 +1,62 @@
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

@ -0,0 +1,90 @@
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

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

View File

@ -2,9 +2,9 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import mermaid, { MermaidConfig } from 'mermaid';
import { SxProps } from '@mui/material/styles';
import { Box } from '@mui/material';
import { useResizeObserverAndMutationObserver } from '../hooks/useAutoScrollToBottom';
import { useResizeObserverAndMutationObserver } from './useAutoScrollToBottom';
const defaultMermaidConfig: MermaidConfig = {
const defaultMermaidConfig : MermaidConfig = {
startOnLoad: true,
securityLevel: 'loose',
fontFamily: 'Fira Code',
@ -19,7 +19,7 @@ interface MermaidProps {
const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
const { chart, sx, className, mermaidConfig } = props;
const [visible, setVisible] = useState<boolean>(false);
const [ visible, setVisible] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const checkVisible = useCallback(() => {
@ -32,35 +32,31 @@ const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
}, [containerRef, setVisible]);
useEffect(() => {
const renderMermaid = async (): Promise<void> => {
const renderMermaid = async () => {
if (containerRef.current && visible && chart) {
try {
await mermaid.initialize(mermaidConfig || defaultMermaidConfig);
await mermaid.run({ nodes: [containerRef.current] });
} catch (e) {
console.error('Mermaid render error:', e, containerRef.current);
console.error("Mermaid render error:", e, containerRef.current);
}
}
};
}
renderMermaid();
}, [containerRef, mermaidConfig, visible, chart]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, null, checkVisible);
return (
<Box
className={className || 'Mermaid'}
ref={containerRef}
sx={{
display: 'flex',
flexGrow: 1,
...sx,
}}
>
{chart}
</Box>
);
return <Box className={className || "Mermaid"} ref={containerRef} sx={{
display: "flex",
flexGrow: 1,
...sx
}}>
{chart}
</Box>;
};
export { Mermaid };
export {
Mermaid
};

View File

@ -0,0 +1,340 @@
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,8 +1,7 @@
import React from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { RefObject, useRef, forwardRef } from 'react';
import { useAutoScrollToBottom } from '../hooks/useAutoScrollToBottom';
import { RefObject, useRef } from 'react';
import { useAutoScrollToBottom } from './useAutoScrollToBottom';
interface ScrollableProps {
children?: React.ReactNode;
@ -10,22 +9,19 @@ interface ScrollableProps {
autoscroll?: boolean;
textFieldRef?: RefObject<HTMLElement | null>; // Reference to the element that triggers auto-scroll
fallbackThreshold?: number;
contentUpdateTrigger?: any;
className?: string;
}
const Scrollable = forwardRef((props: ScrollableProps, ref) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33 } = props;
const Scrollable = (props: ScrollableProps) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props;
// Create a default ref if textFieldRef is not provided
const defaultTextFieldRef = useRef<HTMLElement | null>(null);
const scrollRef = useAutoScrollToBottom(
textFieldRef ?? defaultTextFieldRef,
true,
fallbackThreshold
);
const scrollRef = useAutoScrollToBottom(textFieldRef ?? defaultTextFieldRef, true, fallbackThreshold, contentUpdateTrigger);
return (
<Box
className={`Scrollable ${className || ''}`}
className={`Scrollable ${className || ""}`}
sx={{
display: 'flex',
flexDirection: 'column',
@ -33,17 +29,14 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
p: 0,
flexGrow: 1,
overflow: 'auto',
position: 'relative',
maxHeight: '100%',
minHeight: 0, // Ensure it can shrink to fit content
// backgroundColor: '#F5F5F5',
...sx,
}}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : ref}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : undefined}
>
{children}
</Box>
);
});
Scrollable.displayName = 'Scrollable';
export { useAutoScrollToBottom, Scrollable };
};
export { useAutoScrollToBottom, Scrollable };

View File

@ -0,0 +1,80 @@
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

@ -0,0 +1,628 @@
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,19 +1,15 @@
import { useEffect, useRef, RefObject, useCallback } from 'react';
const debug = false;
const debug: boolean = false;
type ResizeCallback = () => void;
// Define the debounce function with cancel capability
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): T & { cancel: () => void } {
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
let timeout: NodeJS.Timeout | null = null;
let lastCall = 0;
let lastCall: number = 0;
const debounced = function (...args: Parameters<T>): void {
const debounced = function (...args: Parameters<T>) {
const now = Date.now();
// Execute immediately if wait time has passed since last call
@ -39,21 +35,21 @@ function debounce<T extends (...args: any[]) => void>(
};
// Add cancel method to clear pending timeout
debounced.cancel = function (): void {
debounced.cancel = function () {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced as T & { cancel: () => void };
return debounced;
}
const useResizeObserverAndMutationObserver = (
targetRef: RefObject<HTMLElement | null>,
scrollToRef: RefObject<HTMLElement | null> | null,
callback: ResizeCallback
): void => {
) => {
const callbackRef = useRef(callback);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
@ -72,12 +68,8 @@ const useResizeObserverAndMutationObserver = (
requestAnimationFrame(() => callbackRef.current());
}, 500);
const resizeObserver = new ResizeObserver((): void => {
debouncedCallback('resize');
});
const mutationObserver = new MutationObserver((): void => {
debouncedCallback('mutation');
});
const resizeObserver = new ResizeObserver((e: any) => { debouncedCallback("resize"); });
const mutationObserver = new MutationObserver((e: any) => { debouncedCallback("mutation"); });
// Observe container size
resizeObserver.observe(container);
@ -96,7 +88,7 @@ const useResizeObserverAndMutationObserver = (
resizeObserverRef.current = resizeObserver;
mutationObserverRef.current = mutationObserver;
return (): void => {
return () => {
debouncedCallback.cancel();
resizeObserver.disconnect();
mutationObserver.disconnect();
@ -110,120 +102,110 @@ const useResizeObserverAndMutationObserver = (
*/
const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth = true,
fallbackThreshold = 0.33
smooth: boolean = true,
fallbackThreshold: number = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
const lastScrollTop = useRef(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback(
(isPasteEvent = false) => {
const container = containerRef.current;
if (!container) return;
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => {
const container = containerRef.current;
if (!container) return;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
if (isPasteEvent && !scrollTo) {
console.error('Paste Event triggered without scrollTo');
}
if (isPasteEvent && !scrollTo) {
console.error("Paste Event triggered without scrollTo");
}
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) {
requestAnimationFrame(() => {
debug &&
console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) {
requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
}
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
debug &&
console.debug('Scrolling to container bottom (fallback):', {
scrollHeight,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
}
});
}
},
[fallbackThreshold, smooth, scrollToRef]
);
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom (fallback):', { scrollHeight });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
}
}, [fallbackThreshold, smooth, scrollToRef]);
useEffect(() => {
const container = containerRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const handleScroll = (ev: Event, pause?: number): void => {
const handleScroll = (ev: Event, pause?: number) => {
const currentScrollTop = container.scrollTop;
/* If the user is scrolling up *or* they used the scroll wheel and didn't scroll,
/* If the user is scrolling up *or* they used the scroll wheel and didn't scroll,
* they may be zooming in a region; pause scrolling */
isUserScrollingUpRef.current =
currentScrollTop <= lastScrollTop.current || pause ? true : false;
isUserScrollingUpRef.current = (currentScrollTop <= lastScrollTop.current) || pause ? true : false;
debug && console.debug(`Scrolling up or paused: ${isUserScrollingUpRef.current} ${pause}`);
lastScrollTop.current = currentScrollTop;
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
scrollTimeout.current = setTimeout(
() => {
isUserScrollingUpRef.current = false;
debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`);
},
pause ? pause : 500
);
scrollTimeout.current = setTimeout(() => {
isUserScrollingUpRef.current = false;
debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`);
}, pause ? pause : 500);
};
const pauseScroll = (ev: Event): void => {
debug && console.log('Pausing for mouse movement');
const pauseScroll = (ev: Event) => {
debug && console.log("Pausing for mouse movement");
handleScroll(ev, 500);
};
}
const pauseClick = (ev: Event): void => {
debug && console.log('Pausing for mouse click');
const pauseClick = (ev: Event) => {
debug && console.log("Pausing for mouse click");
handleScroll(ev, 1000);
};
}
const handlePaste = (): void => {
console.log('handlePaste');
const handlePaste = () => {
console.log("handlePaste");
// Delay scroll check to ensure DOM updates
setTimeout(() => {
console.log('scrolling for handlePaste');
console.log("scrolling for handlePaste");
requestAnimationFrame(() => checkAndScrollToBottom(true));
}, 100);
};
@ -246,7 +228,7 @@ const useAutoScrollToBottom = (
}
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
};
}, [smooth, scrollToRef, fallbackThreshold, checkAndScrollToBottom]);
}, [smooth, scrollToRef, fallbackThreshold, contentUpdateTrigger, checkAndScrollToBottom]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom);
@ -254,4 +236,7 @@ const useAutoScrollToBottom = (
return containerRef;
};
export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };
export {
useResizeObserverAndMutationObserver,
useAutoScrollToBottom
}

15
frontend/src/Global.tsx Normal file
View File

@ -0,0 +1,15 @@
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

@ -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,6 +158,7 @@ button {
font-size: 0.9rem;
}
.Docs.MuiCard-root {
display: flex;
flex-grow: 1;
@ -180,7 +181,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 {
@ -192,7 +193,7 @@ button {
}
.metadata {
border: 1px solid #e0e0e0;
border: 1px solid #E0E0E0;
font-size: 0.75rem;
padding: 0.125rem;
}
@ -205,7 +206,7 @@ button {
}
/* Reduce space between headings and content */
/* * h1.MuiTypography-root,
* h1.MuiTypography-root,
* h2.MuiTypography-root,
* h3.MuiTypography-root,
* h4.MuiTypography-root,
@ -214,12 +215,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;
}
@ -238,7 +239,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;
@ -253,4 +254,4 @@ button {
#SystemPromptInput {
font-size: 0.9rem;
line-height: 1.25rem;
}
}

View File

@ -0,0 +1,162 @@
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

@ -0,0 +1,123 @@
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

@ -0,0 +1,207 @@
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

@ -0,0 +1,93 @@
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

@ -0,0 +1,52 @@
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

@ -0,0 +1,147 @@
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

@ -0,0 +1,474 @@
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

@ -0,0 +1,57 @@
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,4 +1,4 @@
import React, { JSX } from 'react';
import React from 'react';
import {
Paper,
Box,
@ -13,11 +13,11 @@ import {
} from '@mui/material';
import { styled, useTheme } from '@mui/material/styles';
import {
// Facebook,
// Twitter,
Facebook,
Twitter,
LinkedIn,
// Instagram,
// YouTube,
Instagram,
YouTube,
Email,
LocationOn,
Copyright,
@ -56,7 +56,7 @@ const ContactItem = styled(Box)(({ theme }) => ({
}));
// Footer component
const Footer = (): JSX.Element => {
const Footer = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const currentYear = new Date().getFullYear();
@ -79,12 +79,11 @@ const Footer = (): JSX.Element => {
>
BACKSTORY
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: 'white' }}>
Helping candidates share their professional journey and connect with the right
employers through compelling backstories.
<Typography variant="body2" sx={{ mb: 2 }}>
Helping candidates share their professional journey and connect with the right employers through compelling backstories.
</Typography>
<Stack direction="row">
{/* <IconButton
<IconButton
size="small"
aria-label="Facebook"
sx={{
@ -113,7 +112,7 @@ const Footer = (): JSX.Element => {
onClick={() => window.open('https://twitter.com/', '_blank')}
>
<Twitter />
</IconButton> */}
</IconButton>
<IconButton
size="small"
aria-label="LinkedIn"
@ -123,15 +122,13 @@ const Footer = (): JSX.Element => {
'&:hover': {
backgroundColor: 'rgba(211, 205, 191, 0.1)',
color: theme.palette.action.active,
},
}}
onClick={(): void => {
window.open('https://www.linkedin.com/in/james-ketrenos/', '_blank');
}
}}
onClick={() => window.open('https://linkedin.com/', '_blank')}
>
<LinkedIn />
</IconButton>
{/* <IconButton
<IconButton
size="small"
aria-label="Instagram"
sx={{
@ -160,57 +157,53 @@ const Footer = (): JSX.Element => {
onClick={() => window.open('https://youtube.com/', '_blank')}
>
<YouTube />
</IconButton> */}
</IconButton>
</Stack>
</Box>
</Grid>
{/* Quick Links */}
{false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">For Candidates</FooterHeading>
<FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink>
<FooterLink href="/career-resources">Career Resources</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink>
</Grid>
</>
)}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">
For Candidates
</FooterHeading>
<FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink>
<FooterLink href="/career-resources">Career Resources</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink>
</Grid>
{/* Quick Links */}
{false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">For Employers</FooterHeading>
<FooterLink href="/post-job">Post a Job</FooterLink>
<FooterLink href="/search-candidates">Search Candidates</FooterLink>
<FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
</Grid>
</>
)}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">
For Employers
</FooterHeading>
<FooterLink href="/post-job">Post a Job</FooterLink>
<FooterLink href="/search-candidates">Search Candidates</FooterLink>
<FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
</Grid>
{/* Contact */}
{false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">Company</FooterHeading>
<FooterLink href="/about-us">About Us</FooterLink>
<FooterLink href="/our-team">Our Team</FooterLink>
<FooterLink href="/blog">Blog</FooterLink>
<FooterLink href="/press">Press</FooterLink>
<FooterLink href="/careers">Careers</FooterLink>
<FooterLink href="/contact-us">Contact Us</FooterLink>
</Grid>
</>
)}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">
Company
</FooterHeading>
<FooterLink href="/about-us">About Us</FooterLink>
<FooterLink href="/our-team">Our Team</FooterLink>
<FooterLink href="/blog">Blog</FooterLink>
<FooterLink href="/press">Press</FooterLink>
<FooterLink href="/careers">Careers</FooterLink>
<FooterLink href="/contact-us">Contact Us</FooterLink>
</Grid>
{/* Newsletter */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<ContactItem>
<Email sx={{ mr: 1, fontSize: 20 }} />
<FooterLink href="mailto:james_backstory@ketrenos.com">Email</FooterLink>
<FooterLink href="mailto:james_backstory@backstory.ketrenos.com">Email</FooterLink>
</ContactItem>
{/* <ContactItem>
<Phone sx={{ mr: 1, fontSize: 20 }} />
@ -218,7 +211,7 @@ const Footer = (): JSX.Element => {
</ContactItem> */}
<ContactItem>
<LocationOn sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="body2" sx={{ color: 'white' }}>
<Typography variant="body2">
Beaverton, OR 97003
</Typography>
</ContactItem>
@ -231,35 +224,23 @@ const Footer = (): JSX.Element => {
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Box display="flex" alignItems="center">
<Copyright sx={{ fontSize: 16, mr: 1, color: 'white' }} />
<Typography variant="body2" sx={{ color: 'white' }}>
<Copyright sx={{ fontSize: 16, mr: 1 }} />
<Typography variant="body2">
{currentYear} James P. Ketrenos. All rights reserved.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
{false && (
<>
<Stack
direction={isMobile ? 'column' : 'row'}
spacing={isMobile ? 1 : 3}
sx={{ textAlign: { xs: 'left', md: 'right' } }}
>
<FooterLink href="/terms" sx={{ mb: 0 }}>
Terms of Service
</FooterLink>
<FooterLink href="/privacy" sx={{ mb: 0 }}>
Privacy Policy
</FooterLink>
<FooterLink href="/accessibility" sx={{ mb: 0 }}>
Accessibility
</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>
Sitemap
</FooterLink>
</Stack>
</>
)}
<Stack
direction={isMobile ? 'column' : 'row'}
spacing={isMobile ? 1 : 3}
sx={{ textAlign: { xs: 'left', md: 'right' } }}
>
<FooterLink href="/terms" sx={{ mb: 0 }}>Terms of Service</FooterLink>
<FooterLink href="/privacy" sx={{ mb: 0 }}>Privacy Policy</FooterLink>
<FooterLink href="/accessibility" sx={{ mb: 0 }}>Accessibility</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>Sitemap</FooterLink>
</Stack>
</Grid>
</Grid>
</Container>
@ -267,4 +248,6 @@ const Footer = (): JSX.Element => {
);
};
export { Footer };
export {
Footer
};

View File

@ -0,0 +1,155 @@
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

@ -0,0 +1,446 @@
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

@ -0,0 +1,378 @@
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

@ -45,7 +45,7 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
</Box>
)}
</Grid>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary">
{loadingText}
@ -65,4 +65,4 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
);
};
export { LoadingComponent };
export { LoadingComponent};

View File

@ -17,7 +17,7 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
previousTimestamp.current = timestamp;
setAnimationKey(prev => prev + 1);
setIsAnimating(true);
// Reset animation state after animation completes
const timer = setTimeout(() => {
setIsAnimating(false);
@ -37,8 +37,8 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
};
const baseCoreStyle: React.CSSProperties = {
width: 0,
height: 0,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: '#2196f3',
position: 'relative',
@ -135,8 +135,8 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
}
`}
</style>
<Box sx={{ ...containerStyle, ...sx }}>
<Box sx={{...containerStyle, ...sx}}>
{/* Base circle */}
<div style={coreStyle} />
@ -144,21 +144,35 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
{isAnimating && (
<>
{/* Primary pulse ring */}
<div key={`pulse-1-${animationKey}`} style={pulseRing1Style} />
<div
key={`pulse-1-${animationKey}`}
style={pulseRing1Style}
/>
{/* Secondary pulse ring with delay */}
<div key={`pulse-2-${animationKey}`} style={pulseRing2Style} />
<div
key={`pulse-2-${animationKey}`}
style={pulseRing2Style}
/>
{/* Ripple effect */}
<div key={`ripple-${animationKey}`} style={rippleStyle} />
<div
key={`ripple-${animationKey}`}
style={rippleStyle}
/>
{/* Outer ripple */}
<div key={`ripple-outer-${animationKey}`} style={outerRippleStyle} />
<div
key={`ripple-outer-${animationKey}`}
style={outerRippleStyle}
/>
</>
)}
</Box>
</>
);
};
export { Pulse };
export { Pulse } ;

View File

@ -1,4 +1,4 @@
import React, { JSX } from 'react';
import React from 'react';
import { Box, Typography, Paper, SxProps } from '@mui/material';
import { styled } from '@mui/material/styles';
@ -7,7 +7,7 @@ interface QuoteContainerProps {
}
const QuoteContainer = styled(Paper, {
shouldForwardProp: prop => prop !== 'size',
shouldForwardProp: (prop) => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
position: 'relative',
padding: size === 'small' ? theme.spacing(1) : theme.spacing(4),
@ -29,8 +29,8 @@ const QuoteContainer = styled(Paper, {
}));
const QuoteText = styled(Typography, {
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ size = 'normal' }) => ({
shouldForwardProp: (prop) => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
fontSize: size === 'small' ? '0.9rem' : '1.2rem',
lineHeight: size === 'small' ? 1.4 : 1.6,
fontStyle: 'italic',
@ -43,8 +43,8 @@ const QuoteText = styled(Typography, {
}));
const QuoteMark = styled(Typography, {
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ size = 'normal' }) => ({
shouldForwardProp: (prop) => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
fontSize: size === 'small' ? '2.5rem' : '4rem',
fontFamily: '"Georgia", "Times New Roman", serif',
fontWeight: 'bold',
@ -67,7 +67,7 @@ const ClosingQuote = styled(QuoteMark)(({ size = 'normal' }: QuoteContainerProps
}));
const AuthorText = styled(Typography, {
shouldForwardProp: prop => prop !== 'size',
shouldForwardProp: (prop) => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
marginTop: size === 'small' ? theme.spacing(1) : theme.spacing(2),
textAlign: 'right',
@ -82,8 +82,8 @@ const AuthorText = styled(Typography, {
}));
const AccentLine = styled(Box, {
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ size = 'normal' }) => ({
shouldForwardProp: (prop) => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
width: size === 'small' ? '40px' : '60px',
height: size === 'small' ? '1px' : '2px',
background: 'linear-gradient(90deg, #D4A017 0%, #4A7A7D 100%)', // Golden Ochre to Dusty Teal
@ -98,20 +98,20 @@ interface QuoteProps {
sx?: SxProps;
}
const Quote = (props: QuoteProps): JSX.Element => {
const Quote = (props: QuoteProps) => {
const { quote, author, size = 'normal', sx } = props;
return (
<QuoteContainer size={size} elevation={0} sx={sx}>
<OpeningQuote size={size}>&quot;</OpeningQuote>
<ClosingQuote size={size}>&quot;</ClosingQuote>
<OpeningQuote size={size}>"</OpeningQuote>
<ClosingQuote size={size}>"</ClosingQuote>
<Box sx={{ position: 'relative', zIndex: 2 }}>
<QuoteText size={size} variant="body1">
{quote}
</QuoteText>
<AccentLine size={size} />
{author && (
<AuthorText size={size} variant="body2">
{author}
@ -122,4 +122,4 @@ const Quote = (props: QuoteProps): JSX.Element => {
);
};
export { Quote };
export { Quote };

View File

@ -0,0 +1,143 @@
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

@ -0,0 +1,106 @@
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

@ -0,0 +1,161 @@
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,4 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { BrowserRouter } from 'react-router-dom';
import { Box, Typography, Paper, Container } from '@mui/material';
// Import the backstoryTheme
@ -12,14 +16,13 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="h1" component="h1" sx={{ mb: 3, color: 'primary.main' }}>
Backstory Application Analysis
</Typography>
<Typography variant="h2" component="h2">
Core Concept
</Typography>
<Typography variant="body1">
Backstory is a dual-purpose platform designed to bridge the gap between job candidates
and employers/recruiters with an AI-powered approach to professional profiles and resume
generation.
Backstory is a dual-purpose platform designed to bridge the gap between job candidates and
employers/recruiters with an AI-powered approach to professional profiles and resume generation.
</Typography>
<Typography variant="h3" component="h3" sx={{ mt: 3 }}>
@ -28,15 +31,14 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Job Candidates</strong> - Upload and manage comprehensive professional
histories and generate tailored resumes for specific positions
<strong>Job Candidates</strong> - Upload and manage comprehensive professional histories
and generate tailored resumes for specific positions
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Employers/Recruiters</strong> - Search for candidates, directly interact
with AI assistants about candidate experiences, and generate position-specific
resumes
<strong>Employers/Recruiters</strong> - Search for candidates, directly interact with AI
assistants about candidate experiences, and generate position-specific resumes
</Typography>
</li>
</Box>
@ -44,39 +46,34 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="h2" component="h2" sx={{ mt: 4 }}>
Key Features
</Typography>
<Typography variant="h3" component="h3" sx={{ mt: 3 }}>
For Candidates
</Typography>
<Box component="ul" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Complete Profile Management</strong> - Create detailed professional
histories beyond typical resume constraints
<strong>Complete Profile Management</strong> - Create detailed professional histories beyond typical resume constraints
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>AI-Assisted Q&A Setup</strong> - Configure an AI assistant to answer
employer questions about your experience
<strong>AI-Assisted Q&A Setup</strong> - Configure an AI assistant to answer employer questions about your experience
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Smart Resume Generator</strong> - Create tailored resumes for specific
positions using AI
<strong>Smart Resume Generator</strong> - Create tailored resumes for specific positions using AI
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Analytics Dashboard</strong> - Track profile views, resume downloads, and
employer engagement
<strong>Analytics Dashboard</strong> - Track profile views, resume downloads, and employer engagement
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Privacy Controls</strong> - Manage visibility and access to your
professional information
<strong>Privacy Controls</strong> - Manage visibility and access to your professional information
</Typography>
</li>
</Box>
@ -87,32 +84,27 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Advanced Candidate Search</strong> - Find candidates with specific skills,
experience levels, and qualifications
<strong>Advanced Candidate Search</strong> - Find candidates with specific skills, experience levels, and qualifications
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Interactive Q&A</strong> - Ask questions directly to candidate AI assistants
to learn more about their experience
<strong>Interactive Q&A</strong> - Ask questions directly to candidate AI assistants to learn more about their experience
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Resume Generation</strong> - Generate candidate resumes tailored to specific
job requirements
<strong>Resume Generation</strong> - Generate candidate resumes tailored to specific job requirements
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Talent Pool Management</strong> - Create and manage groups of candidates for
different positions
<strong>Talent Pool Management</strong> - Create and manage groups of candidates for different positions
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Job Posting Management</strong> - Create, manage, and track applications for
job postings
<strong>Job Posting Management</strong> - Create, manage, and track applications for job postings
</Typography>
</li>
</Box>
@ -130,20 +122,17 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Public Navigation</strong> - Home, Docs, Pricing, Login/Register accessible
to all users
<strong>Public Navigation</strong> - Home, Docs, Pricing, Login/Register accessible to all users
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Candidate Dashboard Navigation</strong> - Profile, Backstory, Resumes, Q&A
Setup, Analytics, Settings
<strong>Candidate Dashboard Navigation</strong> - Profile, Backstory, Resumes, Q&A Setup, Analytics, Settings
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Employer Dashboard Navigation</strong> - Dashboard, Search, Saved, Jobs,
Company, Analytics, Settings
<strong>Employer Dashboard Navigation</strong> - Dashboard, Search, Saved, Jobs, Company, Analytics, Settings
</Typography>
</li>
</Box>
@ -154,38 +143,32 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Dashboard Cards</strong> - Both user types have dashboards with card-based
information displays
<strong>Dashboard Cards</strong> - Both user types have dashboards with card-based information displays
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Tab-Based Content Organization</strong> - Many screens use horizontal tabs
to organize related content
<strong>Tab-Based Content Organization</strong> - Many screens use horizontal tabs to organize related content
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Form-Based Editors</strong> - Profile and content editors use structured
forms with varied input types
<strong>Form-Based Editors</strong> - Profile and content editors use structured forms with varied input types
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Three-Column Layouts</strong> - Many screens follow a left sidebar, main
content, right sidebar pattern
<strong>Three-Column Layouts</strong> - Many screens follow a left sidebar, main content, right sidebar pattern
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Preview/Edit Toggle</strong> - Resume and profile editing screens offer both
editing and preview modes
<strong>Preview/Edit Toggle</strong> - Resume and profile editing screens offer both editing and preview modes
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Filter-Based Search</strong> - Employer search uses multiple filter
categories to refine candidate results
<strong>Filter-Based Search</strong> - Employer search uses multiple filter categories to refine candidate results
</Typography>
</li>
</Box>
@ -194,8 +177,8 @@ const BackstoryAppAnalysisPage = () => {
Mobile Adaptations
</Typography>
<Typography variant="body1">
The mobile designs show a simplified navigation structure with bottom tabs and a
hamburger menu, maintaining the core functionality while adapting to smaller screens.
The mobile designs show a simplified navigation structure with bottom tabs and a hamburger menu,
maintaining the core functionality while adapting to smaller screens.
</Typography>
<Typography variant="h2" component="h2" sx={{ mt: 4 }}>
@ -208,26 +191,22 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>LLM Integration</strong> - Supports multiple AI models (Claude, GPT-4,
self-hosted models)
<strong>LLM Integration</strong> - Supports multiple AI models (Claude, GPT-4, self-hosted models)
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Candidate AI Assistant</strong> - Personalized AI chatbot that answers
questions about candidate experience
<strong>Candidate AI Assistant</strong> - Personalized AI chatbot that answers questions about candidate experience
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Resume Generation</strong> - AI-powered resume creation based on job
requirements
<strong>Resume Generation</strong> - AI-powered resume creation based on job requirements
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Skills Matching</strong> - Automated matching between candidate skills and
job requirements
<strong>Skills Matching</strong> - Automated matching between candidate skills and job requirements
</Typography>
</li>
</Box>
@ -243,20 +222,17 @@ const BackstoryAppAnalysisPage = () => {
</li>
<li>
<Typography variant="body1" component="div">
<strong>Data Import</strong> - LinkedIn profile import, resume parsing (PDF, DOCX),
CSV/JSON import
<strong>Data Import</strong> - LinkedIn profile import, resume parsing (PDF, DOCX), CSV/JSON import
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>ATS Compatibility</strong> - Integration with employer Applicant Tracking
Systems
<strong>ATS Compatibility</strong> - Integration with employer Applicant Tracking Systems
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Vector Databases</strong> - Semantic search capabilities for candidate
matching
<strong>Vector Databases</strong> - Semantic search capabilities for candidate matching
</Typography>
</li>
</Box>
@ -267,32 +243,27 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Beyond the Resume</strong> - Focuses on comprehensive professional stories
rather than just resume highlights
<strong>Beyond the Resume</strong> - Focuses on comprehensive professional stories rather than just resume highlights
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>AI-Mediated Communication</strong> - Uses AI to facilitate deeper
understanding of candidate experiences
<strong>AI-Mediated Communication</strong> - Uses AI to facilitate deeper understanding of candidate experiences
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Bidirectional Resume Generation</strong> - Both candidates and employers can
generate tailored resumes
<strong>Bidirectional Resume Generation</strong> - Both candidates and employers can generate tailored resumes
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Configurable AI Personalities</strong> - Candidates can customize how their
AI assistant responds to questions
<strong>Configurable AI Personalities</strong> - Candidates can customize how their AI assistant responds to questions
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Deep Analytics</strong> - Both candidates and employers receive insights
about their engagement
<strong>Deep Analytics</strong> - Both candidates and employers receive insights about their engagement
</Typography>
</li>
</Box>
@ -326,9 +297,7 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="body1">Role-based access for employer teams</Typography>
</li>
<li>
<Typography variant="body1">
Data management options for compliance requirements
</Typography>
<Typography variant="body1">Data management options for compliance requirements</Typography>
</li>
</Box>
</Paper>
@ -337,4 +306,7 @@ const BackstoryAppAnalysisPage = () => {
);
};
export { BackstoryAppAnalysisPage };
export {
BackstoryAppAnalysisPage
}

View File

@ -0,0 +1,202 @@
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

@ -0,0 +1,361 @@
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,19 +1,20 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Container,
Typography,
Paper,
Grid,
Button,
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import {
Box,
Container,
Typography,
Paper,
Grid,
Button,
useMediaQuery,
alpha,
GlobalStyles,
GlobalStyles
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ConstructionIcon from '@mui/icons-material/Construction';
import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
import { Beta } from '../components/ui/Beta';
import { Beta } from '../Components/Beta';
interface BetaPageProps {
children?: React.ReactNode;
@ -26,40 +27,30 @@ interface BetaPageProps {
const BetaPage: React.FC<BetaPageProps> = ({
children,
title = 'Coming Soon',
subtitle = 'This page is currently in development',
returnPath = '/',
returnLabel = 'Return to Backstory',
title = "Coming Soon",
subtitle = "This page is currently in development",
returnPath = "/",
returnLabel = "Return to Backstory",
onReturn,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [showSparkle, setShowSparkle] = useState<boolean>(false);
const navigate = useNavigate();
const location = useLocation();
if (!children) {
children = (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<Typography>
The page you requested (<b>{location.pathname.replace(/^\//, '')}</b>) is not yet ready.
</Typography>
</Box>
);
}
console.log("BetaPage", children);
// Enhanced sparkle effect for background elements
const [sparkles, setSparkles] = useState<
Array<{
id: number;
x: number;
y: number;
size: number;
opacity: number;
duration: number;
delay: number;
}>
>([]);
const [sparkles, setSparkles] = useState<Array<{
id: number;
x: number;
y: number;
size: number;
opacity: number;
duration: number;
delay: number;
}>>([]);
useEffect(() => {
// Generate sparkle elements with random properties
const newSparkles = Array.from({ length: 30 }).map((_, index) => ({
@ -71,18 +62,18 @@ const BetaPage: React.FC<BetaPageProps> = ({
duration: 2 + Math.random() * 4,
delay: Math.random() * 3,
}));
setSparkles(newSparkles);
// Show main sparkle effect after a short delay
const timer = setTimeout(() => {
setShowSparkle(true);
}, 500);
return () => clearTimeout(timer);
}, []);
const handleReturn = (): void => {
const handleReturn = () => {
if (onReturn) {
onReturn();
} else if (returnPath) {
@ -94,7 +85,6 @@ const BetaPage: React.FC<BetaPageProps> = ({
<Box
sx={{
minHeight: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
bgcolor: theme.palette.background.default,
@ -103,18 +93,8 @@ const BetaPage: React.FC<BetaPageProps> = ({
}}
>
{/* Animated background elements */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
overflow: 'hidden',
}}
>
{sparkles.map(sparkle => (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0, overflow: 'hidden' }}>
{sparkles.map((sparkle) => (
<Box
key={sparkle.id}
sx={{
@ -125,10 +105,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
height: sparkle.size,
borderRadius: '50%',
bgcolor: alpha(theme.palette.primary.main, sparkle.opacity),
boxShadow: `0 0 ${sparkle.size * 2}px ${alpha(
theme.palette.primary.main,
sparkle.opacity
)}`,
boxShadow: `0 0 ${sparkle.size * 2}px ${alpha(theme.palette.primary.main, sparkle.opacity)}`,
animation: `float ${sparkle.duration}s ease-in-out ${sparkle.delay}s infinite alternate`,
}}
/>
@ -137,7 +114,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 2 }}>
<Grid container spacing={4} direction="column" alignItems="center">
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}>
<Grid size={{xs: 12}} sx={{ textAlign: 'center', mb: 2 }}>
<Typography
variant="h2"
component="h1"
@ -151,13 +128,18 @@ const BetaPage: React.FC<BetaPageProps> = ({
>
{title}
</Typography>
<Typography variant="h5" component="h2" color="textSecondary" sx={{ mb: 6 }}>
<Typography
variant="h5"
component="h2"
color="textSecondary"
sx={{ mb: 6 }}
>
{subtitle}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 10, lg: 8 }} sx={{ mb: 4 }}>
<Grid size={{xs: 12, md: 10, lg: 8}} sx={{ mb: 4 }}>
<Paper
elevation={8}
sx={{
@ -187,45 +169,31 @@ const BetaPage: React.FC<BetaPageProps> = ({
>
<ConstructionIcon fontSize="large" />
</Box>
{/* Content */}
<Box sx={{ mt: 3, mb: 3 }}>
{children || (
<Box sx={{ textAlign: 'center', py: 4 }}>
<RocketLaunchIcon
fontSize="large"
color="primary"
sx={{
fontSize: 80,
<RocketLaunchIcon
fontSize="large"
color="primary"
sx={{
fontSize: 80,
mb: 2,
animation: 'rocketWobble 3s ease-in-out infinite',
}}
animation: 'rocketWobble 3s ease-in-out infinite'
}}
/>
<Typography>
We&apos;re working hard to bring you this exciting new feature!
We're working hard to bring you this exciting new feature!
</Typography>
<Typography color="textSecondary" sx={{ mt: 1 }}>
Check back soon for updates.
</Typography>
</Box>
)}
<Beta
adaptive={false}
sx={{
opacity: 0.5,
left: '-72px',
'& > div': {
paddingRight: '30px',
background: 'gold',
color: '#808080',
},
}}
onClick={(): void => {
navigate('/');
}}
/>
<Beta adaptive={false} sx={{ opacity: 0.5, left: "-72px", "& > div": { paddingRight: "30px", background: "gold", color: "#808080" } }} onClick={() => { navigate('/docs/beta'); }} />
</Box>
{/* Return button */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
@ -240,7 +208,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
boxShadow: `0 4px 14px ${alpha(theme.palette.primary.main, 0.4)}`,
'&:hover': {
boxShadow: `0 6px 20px ${alpha(theme.palette.primary.main, 0.6)}`,
},
}
}}
>
{returnLabel}
@ -280,10 +248,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
textShadow: `0 0 10px ${alpha(theme.palette.primary.main, 0.3)}`,
},
'100%': {
textShadow: `0 0 25px ${alpha(theme.palette.primary.main, 0.7)}, 0 0 40px ${alpha(
theme.palette.primary.main,
0.4
)}`,
textShadow: `0 0 25px ${alpha(theme.palette.primary.main, 0.7)}, 0 0 40px ${alpha(theme.palette.primary.main, 0.4)}`,
},
},
'@keyframes rocketWobble': {
@ -303,4 +268,6 @@ const BetaPage: React.FC<BetaPageProps> = ({
);
};
export { BetaPage };
export {
BetaPage
}

View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,83 @@
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

@ -0,0 +1,67 @@
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

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,110 @@
<?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>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import {
Box,
Button,
@ -15,12 +16,12 @@ import {
useMediaQuery,
CircularProgress,
Snackbar,
Alert,
Alert
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { CloudUpload, PhotoCamera } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
// import { Beta } from '../components/Beta';
import { Beta } from '../Components/Beta';
// Interfaces
interface ProfileFormData {
@ -48,23 +49,20 @@ const VisuallyHiddenInput = styled('input')({
const CreateProfilePage: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// State management
const [activeStep, setActiveStep] = useState<number>(0);
const [profileImage, setProfileImage] = useState<string | null>(null);
const [resumeFile, setResumeFile] = useState<File | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: 'success' | 'error';
}>({
const [snackbar, setSnackbar] = useState<{open: boolean, message: string, severity: "success" | "error"}>({
open: false,
message: '',
severity: 'success',
severity: 'success'
});
const [formData, setFormData] = useState<ProfileFormData>({
firstName: '',
lastName: '',
@ -91,7 +89,7 @@ const CreateProfilePage: React.FC = () => {
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const reader = new FileReader();
reader.onload = event => {
reader.onload = (event) => {
if (event.target?.result) {
setProfileImage(event.target.result.toString());
}
@ -107,7 +105,7 @@ const CreateProfilePage: React.FC = () => {
setSnackbar({
open: true,
message: `Resume uploaded: ${e.target.files[0].name}`,
severity: 'success',
severity: 'success'
});
}
};
@ -117,27 +115,27 @@ const CreateProfilePage: React.FC = () => {
if (activeStep === steps.length - 1) {
handleSubmit();
} else {
setActiveStep(prevStep => prevStep + 1);
setActiveStep((prevStep) => prevStep + 1);
}
};
const handleBack = () => {
setActiveStep(prevStep => prevStep - 1);
setActiveStep((prevStep) => prevStep - 1);
};
// Form submission
const handleSubmit = async () => {
setLoading(true);
// Simulate API call with timeout
setTimeout(() => {
setLoading(false);
setSnackbar({
open: true,
message: 'Profile created successfully! Redirecting to dashboard...',
severity: 'success',
severity: 'success'
});
// Redirect would happen here in a real application
// history.push('/dashboard');
}, 2000);
@ -147,11 +145,9 @@ const CreateProfilePage: React.FC = () => {
const isStepValid = () => {
switch (activeStep) {
case 0:
return (
formData.firstName.trim() !== '' &&
formData.lastName.trim() !== '' &&
formData.email.trim() !== ''
);
return formData.firstName.trim() !== '' &&
formData.lastName.trim() !== '' &&
formData.email.trim() !== '';
case 1:
return formData.jobTitle.trim() !== '';
case 2:
@ -167,33 +163,35 @@ const CreateProfilePage: React.FC = () => {
case 0:
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Grid size={{xs: 12}} sx={{ textAlign: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Avatar
src={profileImage || ''}
sx={{
width: 120,
height: 120,
sx={{
width: 120,
height: 120,
mb: 2,
border: `2px solid ${theme.palette.primary.main}`,
border: `2px solid ${theme.palette.primary.main}`
}}
/>
<IconButton color="primary" aria-label="upload picture" component="label">
<IconButton
color="primary"
aria-label="upload picture"
component="label"
>
<PhotoCamera />
<VisuallyHiddenInput type="file" accept="image/*" onChange={handleImageUpload} />
<VisuallyHiddenInput
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
</IconButton>
<Typography variant="caption" color="textSecondary">
Add profile photo
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Grid size={{xs: 12, sm: 6}}>
<TextField
required
fullWidth
@ -204,7 +202,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Grid size={{xs: 12, sm: 6}}>
<TextField
required
fullWidth
@ -215,7 +213,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs: 12}}>
<TextField
required
fullWidth
@ -227,7 +225,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs:12}}>
<TextField
fullWidth
label="Phone Number"
@ -242,7 +240,7 @@ const CreateProfilePage: React.FC = () => {
case 1:
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12 }}>
<Grid size={{xs:12}}>
<TextField
required
fullWidth
@ -253,7 +251,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs: 12}}>
<TextField
fullWidth
label="Location"
@ -264,7 +262,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs:12}}>
<TextField
fullWidth
multiline
@ -282,10 +280,10 @@ const CreateProfilePage: React.FC = () => {
case 2:
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12 }}>
<Grid size={{xs: 12}}>
<Typography variant="body1" component="p">
Upload your resume to complete your profile. We'll analyze it to better understand
your skills and experience. (Supported formats: .pdf, .docx, .md, and .txt)
Upload your resume to complete your profile. We'll analyze it to better understand your skills and experience.
(Supported formats: .pdf, .docx, .md, and .txt)
</Typography>
<Box sx={{ textAlign: 'center', mt: 2 }}>
<Button
@ -295,13 +293,13 @@ const CreateProfilePage: React.FC = () => {
sx={{ mb: 2 }}
>
Upload Resume
<VisuallyHiddenInput
type="file"
<VisuallyHiddenInput
type="file"
accept=".pdf,.docx,.txt,.md"
onChange={handleResumeUpload}
/>
</Button>
{resumeFile && (
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
File uploaded: {resumeFile.name}
@ -318,35 +316,41 @@ const CreateProfilePage: React.FC = () => {
return (
<Container component="main">
<Paper
elevation={3}
sx={{
p: { xs: 2, sm: 4 },
mt: { xs: 2, sm: 4 },
<Paper
elevation={3}
sx={{
p: { xs: 2, sm: 4 },
mt: { xs: 2, sm: 4 },
mb: { xs: 2, sm: 4 },
}}
>
<Typography component="h1" variant="h4" align="center" gutterBottom>
Create Your Profile
</Typography>
<Stepper
activeStep={activeStep}
<Stepper
activeStep={activeStep}
alternativeLabel={!isMobile}
orientation={isMobile ? 'vertical' : 'horizontal'}
sx={{ mt: 3, mb: 5 }}
>
{steps.map(label => (
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ mt: 2, mb: 4 }}>{getStepContent(activeStep)}</Box>
<Box sx={{ mt: 2, mb: 4 }}>
{getStepContent(activeStep)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button disabled={activeStep === 0} onClick={handleBack} variant="outlined">
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
Back
</Button>
<Button
@ -365,8 +369,8 @@ const CreateProfilePage: React.FC = () => {
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
@ -377,4 +381,4 @@ const CreateProfilePage: React.FC = () => {
);
};
export { CreateProfilePage };
export { CreateProfilePage };

View File

@ -0,0 +1,153 @@
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

@ -0,0 +1,417 @@
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

@ -0,0 +1,390 @@
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,5 +1,5 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import {
Box,
Button,
@ -18,9 +18,7 @@ import PersonSearchIcon from '@mui/icons-material/PersonSearch';
import WorkHistoryIcon from '@mui/icons-material/WorkHistory';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import DescriptionIcon from '@mui/icons-material/Description';
import professionalConversationPng from 'assets/Conversation.png';
import { ComingSoon } from 'components/ui/ComingSoon';
import { useAuth } from 'hooks/AuthContext';
import professionalConversationPng from './Conversation.png';
// Placeholder for Testimonials component
const Testimonials = () => {
@ -65,12 +63,11 @@ const HeroButton = (props: HeroButtonProps) => {
opacity: 0.9,
},
}));
return (
<HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</HeroStyledButton>
);
};
return <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</HeroStyledButton>
}
interface ActionButtonProps extends ButtonProps {
children?: string;
@ -85,12 +82,10 @@ const ActionButton = (props: ActionButtonProps) => {
navigate(path);
};
return (
<Button onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</Button>
);
};
return <Button onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</Button>
}
const FeatureIcon = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.action.active,
@ -109,7 +104,7 @@ const FeatureIcon = styled(Box)(({ theme }) => ({
const FeatureCard = ({
icon,
title,
description,
description
}: {
icon: React.ReactNode;
title: string;
@ -138,32 +133,20 @@ const FeatureCard = ({
};
const HomePage = () => {
const testimonials = false;
const { isGuest, guest, user } = useAuth();
const navigate = useNavigate();
if (isGuest) {
// Show guest-specific UI
console.log('Guest session:', guest?.sessionId || 'No guest');
} else {
// Show authenticated user UI
console.log('Authenticated user:', user?.email || 'No user');
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
return (<Box sx={{display: "flex", flexDirection: "column"}}>
{/* Hero Section */}
<HeroSection>
<Container>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: '1024px',
}}
>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: "1024px"
}}>
<Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography
variant="h2"
@ -171,18 +154,19 @@ const HomePage = () => {
sx={{
fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' },
mb: 2,
color: 'white',
mb: 2
}}
>
Your complete professional story, beyond a single page
</Typography>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
Let potential employers discover the depth of your experience through interactive
Q&A and tailored resumes
Let potential employers discover the depth of your experience through interactive Q&A and tailored resumes
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<HeroButton variant="contained" size="large">
<HeroButton
variant="contained"
size="large"
>
Get Started as Candidate
</HeroButton>
<HeroButton
@ -191,19 +175,14 @@ const HomePage = () => {
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active',
borderColor: 'action.active'
}}
>
Recruit Talent
</HeroButton>
</Stack>
</Box>
<Box
sx={{
justifyContent: 'center',
display: { xs: 'none', md: 'block' },
}}
>
<Box sx={{ justifyContent: "center", display: { xs: 'none', md: 'block' } }}>
<Box
component="img"
src={professionalConversationPng}
@ -233,42 +212,33 @@ const HomePage = () => {
How Backstory Works
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
}}
>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', md: 'row' }, gap: 4 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
For Job Seekers
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph>
Backstory helps you tell your complete professional story, highlight your
achievements, and showcase your skills beyond what fits on a traditional resume.
Backstory helps you tell your complete professional story, highlight your achievements, and showcase your skills beyond what fits on a traditional resume.
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold',
}}
>
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
1
</Box>
<Typography variant="body1">
@ -277,22 +247,20 @@ const HomePage = () => {
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold',
}}
>
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
2
</Box>
<Typography variant="body1">
@ -301,22 +269,20 @@ const HomePage = () => {
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold',
}}
>
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
3
</Box>
<Typography variant="body1">
@ -325,112 +291,103 @@ const HomePage = () => {
</Box>
</Stack>
<ActionButton
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Create Your Profile
</ActionButton>
</ActionButton>
</Box>
<ComingSoon>
<Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
For Employers
<Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
For Employers
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph>
Discover candidates with the perfect skills and experience for your positions by engaging in meaningful Q&A to learn more about their background.
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph>
Discover candidates with the perfect skills and experience for your positions by
engaging in meaningful Q&A to learn more about their background.
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
1
</Box>
<Typography variant="body1">
Search the candidate pool based on skills, experience, and location
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold',
}}
>
1
</Box>
<Typography variant="body1">
Search the candidate pool based on skills, experience, and location
</Typography>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
2
</Box>
<Typography variant="body1">
Ask personalized questions about candidates' experience and skills
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold',
}}
>
2
</Box>
<Typography variant="body1">
Ask personalized questions about candidates' experience and skills
</Typography>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
3
</Box>
<Typography variant="body1">
Generate targeted resumes that match your job requirements
</Typography>
</Box>
</Stack>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold',
}}
>
3
</Box>
<Typography variant="body1">
Generate targeted resumes that match your job requirements
</Typography>
</Box>
</Stack>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Start Recruiting
</ActionButton>
</Box>
</ComingSoon>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Start Recruiting
</ActionButton>
</Box>
</Box>
</Container>
@ -448,16 +405,7 @@ const HomePage = () => {
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Box
sx={{
flex: '1 1 250px',
minWidth: {
xs: '100%',
sm: 'calc(50% - 16px)',
md: 'calc(25% - 16px)',
},
}}
>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<FeatureCard
icon={
<FeatureIcon>
@ -468,16 +416,7 @@ const HomePage = () => {
description="Find the perfect candidates based on skills, experience, and fit for your specific requirements."
/>
</Box>
<Box
sx={{
flex: '1 1 250px',
minWidth: {
xs: '100%',
sm: 'calc(50% - 16px)',
md: 'calc(25% - 16px)',
},
}}
>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<FeatureCard
icon={
<FeatureIcon>
@ -488,16 +427,7 @@ const HomePage = () => {
description="Share your full professional journey beyond the limitations of a traditional resume."
/>
</Box>
<Box
sx={{
flex: '1 1 250px',
minWidth: {
xs: '100%',
sm: 'calc(50% - 16px)',
md: 'calc(25% - 16px)',
},
}}
>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<FeatureCard
icon={
<FeatureIcon>
@ -508,16 +438,7 @@ const HomePage = () => {
description="Ask detailed questions about a candidate's experience and get immediate answers."
/>
</Box>
<Box
sx={{
flex: '1 1 250px',
minWidth: {
xs: '100%',
sm: 'calc(50% - 16px)',
md: 'calc(25% - 16px)',
},
}}
>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<FeatureCard
icon={
<FeatureIcon>
@ -533,52 +454,57 @@ const HomePage = () => {
</Box>
{/* Testimonials Section */}
{testimonials && (
<Container sx={{ py: 8 }}>
<Typography
variant="h3"
component="h2"
align="center"
gutterBottom
sx={{ mb: 2, fontWeight: 600 }}
>
Success Stories
</Typography>
<Typography variant="body1" align="center" sx={{ mb: 6, maxWidth: 800, mx: 'auto' }}>
See how Backstory has transformed the hiring process for both candidates and employers.
</Typography>
<Container sx={{ py: 8 }}>
<Typography
variant="h3"
component="h2"
align="center"
gutterBottom
sx={{ mb: 2, fontWeight: 600 }}
>
Success Stories
</Typography>
<Typography
variant="body1"
align="center"
sx={{ mb: 6, maxWidth: 800, mx: 'auto' }}
>
See how Backstory has transformed the hiring process for both candidates and employers.
</Typography>
<Testimonials />
</Container>
)}
<Testimonials />
</Container>
{/* CTA Section */}
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
py: 8,
}}
>
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
py: 8
}}>
<Container>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
maxWidth: 800,
mx: 'auto',
}}
>
<Typography variant="h3" component="h2" gutterBottom sx={{ color: 'white' }}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
maxWidth: 800,
mx: 'auto'
}}>
<Typography variant="h3" component="h2" gutterBottom>
Ready to transform your hiring process?
</Typography>
<Typography variant="h6" sx={{ mb: 4 }}>
Join Backstory today and discover a better way to connect talent with opportunity.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="center">
<HeroButton variant="contained" size="large">
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
justifyContent="center"
>
<HeroButton
variant="contained"
size="large"
>
Sign Up as Candidate
</HeroButton>
<HeroButton
@ -587,7 +513,7 @@ const HomePage = () => {
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active',
borderColor: 'action.active'
}}
>
Sign Up as Employer
@ -596,8 +522,11 @@ const HomePage = () => {
</Box>
</Container>
</Box>
</Box>
</Box>
);
};
export { HomePage };
export {
HomePage
};

View File

@ -0,0 +1,677 @@
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,36 +1,15 @@
import React, { useState } from 'react';
import {
AppBar,
Box,
Button,
Chip,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Tab,
Tabs,
TextField,
Typography,
useMediaQuery,
useTheme,
AppBar, Avatar, Box, Button, Chip, Container, Divider, Drawer,
IconButton, InputBase, List, ListItem, ListItemButton, ListItemIcon,
ListItemText, Paper, Tab, Tabs, TextField, Typography,
useMediaQuery, useTheme
} from '@mui/material';
import {
Menu as MenuIcon,
Search as SearchIcon,
Description as FileTextIcon,
Person as UserIcon,
Settings as SettingsIcon,
Add as PlusIcon,
Edit as EditIcon,
Visibility as EyeIcon,
Save as SaveIcon,
Delete as TrashIcon,
AccessTime as ClockIcon,
Menu as MenuIcon, Search as SearchIcon, Description as FileTextIcon,
Person as UserIcon, Settings as SettingsIcon, Add as PlusIcon,
Edit as EditIcon, Visibility as EyeIcon, Save as SaveIcon,
Delete as TrashIcon, AccessTime as ClockIcon, ChevronRight as ChevronRightIcon
} from '@mui/icons-material';
interface Resume {
@ -43,74 +22,37 @@ interface Resume {
const MockupPage = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [activeTab, setActiveTab] = useState<string>('resume');
const [activeTab, setActiveTab] = useState<string>("resume");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
const [selectedResume, setSelectedResume] = useState<number | null>(null);
// Mock data
const savedResumes: Resume[] = [
{
id: 1,
name: 'Software Engineer - Tech Co',
date: 'May 15, 2025',
isRecent: true,
},
{
id: 2,
name: 'Product Manager - StartupX',
date: 'May 10, 2025',
isRecent: false,
},
{
id: 3,
name: 'Data Scientist - AI Corp',
date: 'May 5, 2025',
isRecent: false,
},
{ id: 1, name: "Software Engineer - Tech Co", date: "May 15, 2025", isRecent: true },
{ id: 2, name: "Product Manager - StartupX", date: "May 10, 2025", isRecent: false },
{ id: 3, name: "Data Scientist - AI Corp", date: "May 5, 2025", isRecent: false },
];
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
bgcolor: 'background.default',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: 'background.default' }}>
{/* Header */}
<AppBar
position="static"
color="default"
elevation={1}
sx={{ zIndex: theme => theme.zIndex.drawer + 1 }}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 2,
py: 1,
}}
>
<AppBar position="static" color="default" elevation={1} sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 2, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" component="h1" fontWeight="bold" color="text.primary">
Backstory
</Typography>
<Typography variant="h6" component="h1" fontWeight="bold" color="text.primary">Backstory</Typography>
{isMobile && (
<IconButton
edge="start"
color="inherit"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<IconButton edge="start" color="inherit" onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
<MenuIcon />
</IconButton>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{!isMobile && (
<Button startIcon={<PlusIcon />} color="primary" size="small">
<Button
startIcon={<PlusIcon />}
color="primary"
size="small"
>
New Resume
</Button>
)}
@ -129,25 +71,12 @@ const MockupPage = () => {
sx={{
width: 240,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: 240,
boxSizing: 'border-box',
position: 'relative',
},
[`& .MuiDrawer-paper`]: { width: 240, boxSizing: 'border-box', position: 'relative' },
}}
>
<Box
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>
Main
</Typography>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}>
@ -159,11 +88,7 @@ const MockupPage = () => {
</ListItem>
<ListItem disablePadding>
<ListItemButton
sx={{
borderRadius: 1,
bgcolor: 'primary.lighter',
color: 'primary.main',
}}
sx={{ borderRadius: 1, bgcolor: 'primary.lighter', color: 'primary.main' }}
>
<ListItemIcon sx={{ minWidth: 36, color: 'primary.main' }}>
<FileTextIcon fontSize="small" />
@ -175,9 +100,7 @@ const MockupPage = () => {
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>
My Content
</Typography>
<Typography variant="overline" color="text.secondary" gutterBottom>My Content</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}>
@ -205,22 +128,16 @@ const MockupPage = () => {
<Box sx={{ mt: 'auto' }}>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle2" color="text.primary" gutterBottom>
Recent Activity
</Typography>
<Typography variant="subtitle2" color="text.primary" gutterBottom>Recent Activity</Typography>
<List dense disablePadding>
{savedResumes
.filter(r => r.isRecent)
.map(resume => (
<ListItem key={resume.id} disablePadding sx={{ mb: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<ClockIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2" noWrap>
{resume.name}
</Typography>
</ListItem>
))}
{savedResumes.filter(r => r.isRecent).map(resume => (
<ListItem key={resume.id} disablePadding sx={{ mb: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<ClockIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2" noWrap>{resume.name}</Typography>
</ListItem>
))}
</List>
</Paper>
</Box>
@ -235,21 +152,12 @@ const MockupPage = () => {
onClose={() => setIsMobileMenuOpen(false)}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { width: 240 },
'& .MuiDrawer-paper': { width: 240 }
}}
>
<Box
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>
Main
</Typography>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton onClick={() => setIsMobileMenuOpen(false)}>
@ -274,9 +182,7 @@ const MockupPage = () => {
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>
My Content
</Typography>
<Typography variant="overline" color="text.secondary" gutterBottom>My Content</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton onClick={() => setIsMobileMenuOpen(false)}>
@ -308,21 +214,18 @@ const MockupPage = () => {
<Box sx={{ flex: 1, overflow: 'auto', p: 3 }}>
{/* Resume Builder content */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" component="h2" fontWeight="bold" gutterBottom>
Resume Builder
</Typography>
<Typography variant="body2" color="text.secondary">
Generate and customize resumes based on job descriptions
</Typography>
<Typography variant="h5" component="h2" fontWeight="bold" gutterBottom>Resume Builder</Typography>
<Typography variant="body2" color="text.secondary">Generate and customize resumes based on job descriptions</Typography>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
aria-label="Resume builder tabs"
variant={isMobile ? 'scrollable' : 'standard'}
scrollButtons={isMobile ? 'auto' : undefined}
variant={isMobile ? "scrollable" : "standard"}
scrollButtons={isMobile ? "auto" : undefined}
>
<Tab label="Job Description" value="job" />
<Tab label="Resume" value="resume" />
@ -330,12 +233,11 @@ const MockupPage = () => {
<Tab label="Saved Resumes" value="saved" />
</Tabs>
</Box>
{/* Tab content */}
{activeTab === 'job' && (
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Job Description
</Typography>
<Typography variant="h6" gutterBottom>Job Description</Typography>
<TextField
fullWidth
multiline
@ -350,22 +252,24 @@ const MockupPage = () => {
</Box>
</Paper>
)}
{activeTab === 'resume' && (
<Paper variant="outlined" sx={{ p: 3 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">Resume Editor</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" size="small" startIcon={<SaveIcon />}>
<Button
variant="outlined"
size="small"
startIcon={<SaveIcon />}
>
Save
</Button>
<Button variant="outlined" size="small" startIcon={<EyeIcon />}>
<Button
variant="outlined"
size="small"
startIcon={<EyeIcon />}
>
Preview
</Button>
</Box>
@ -375,46 +279,21 @@ const MockupPage = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Contact information */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Contact Information
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Contact Information</Typography>
<IconButton size="small" color="default">
<EditIcon fontSize="small" />
</IconButton>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: 2,
}}
>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<Box>
<Typography
variant="caption"
color="text.secondary"
display="block"
gutterBottom
>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
Full Name
</Typography>
<TextField size="small" fullWidth defaultValue="John Doe" />
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
display="block"
gutterBottom
>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
Email
</Typography>
<TextField size="small" fullWidth defaultValue="john@example.com" />
@ -424,17 +303,8 @@ const MockupPage = () => {
{/* Professional Summary */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Professional Summary
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Professional Summary</Typography>
<IconButton size="small" color="default">
<EditIcon fontSize="small" />
</IconButton>
@ -450,18 +320,13 @@ const MockupPage = () => {
{/* Work Experience */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Work Experience
</Typography>
<Button startIcon={<PlusIcon />} color="primary" size="small">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Work Experience</Typography>
<Button
startIcon={<PlusIcon />}
color="primary"
size="small"
>
Add Position
</Button>
</Box>
@ -469,9 +334,7 @@ const MockupPage = () => {
{/* Job entry */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" fontWeight="medium">
Senior Developer
</Typography>
<Typography variant="subtitle2" fontWeight="medium">Senior Developer</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small">
<EditIcon fontSize="small" />
@ -481,16 +344,10 @@ const MockupPage = () => {
</IconButton>
</Box>
</Box>
<Typography variant="body2" color="text.secondary">
Tech Company Inc. 2020-Present
</Typography>
<Typography variant="body2" color="text.secondary">Tech Company Inc. 2020-Present</Typography>
<Box component="ul" sx={{ pl: 2, mt: 1 }}>
<Typography component="li" variant="body2">
Led development of company's flagship product
</Typography>
<Typography component="li" variant="body2">
Improved performance by 40% through code optimization
</Typography>
<Typography component="li" variant="body2">Led development of company's flagship product</Typography>
<Typography component="li" variant="body2">Improved performance by 40% through code optimization</Typography>
</Box>
</Paper>
</Paper>
@ -503,7 +360,7 @@ const MockupPage = () => {
borderStyle: 'dashed',
p: 1.5,
color: 'text.secondary',
'&:hover': { bgcolor: 'background.default' },
'&:hover': { bgcolor: 'background.default' }
}}
startIcon={<PlusIcon />}
>
@ -511,19 +368,16 @@ const MockupPage = () => {
</Button>
</Box>
</Paper>
)}{' '}
{activeTab === 'saved' && (
)} {activeTab === 'saved' && (
<Paper variant="outlined" sx={{ p: 3 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">Saved Resumes</Typography>
<Button variant="contained" color="primary" size="small" startIcon={<PlusIcon />}>
<Button
variant="contained"
color="primary"
size="small"
startIcon={<PlusIcon />}
>
New Resume
</Button>
</Box>
@ -540,20 +394,15 @@ const MockupPage = () => {
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
bgcolor:
selectedResume === resume.id ? 'primary.lighter' : 'background.paper',
bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'background.paper',
borderColor: selectedResume === resume.id ? 'primary.light' : 'divider',
'&:hover': {
bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover',
},
'&:hover': { bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover' }
}}
onClick={() => setSelectedResume(resume.id)}
>
<Box>
<Typography variant="subtitle2">{resume.name}</Typography>
<Typography variant="caption" color="text.secondary">
Last edited: {resume.date}
</Typography>
<Typography variant="caption" color="text.secondary">Last edited: {resume.date}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton size="small">
@ -568,11 +417,10 @@ const MockupPage = () => {
</Box>
</Paper>
)}
{activeTab === 'fact' && (
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Fact Check
</Typography>
<Typography variant="h6" gutterBottom>Fact Check</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This tab shows how your resume content compares to your employment history data.
</Typography>
@ -592,8 +440,7 @@ const MockupPage = () => {
Skills Verification
</Typography>
<Typography variant="body2">
Some skills listed (React Native, Flutter) are not strongly supported by your
experience documents.
Some skills listed (React Native, Flutter) are not strongly supported by your experience documents.
</Typography>
</Paper>
</Box>
@ -614,7 +461,7 @@ const MockupPage = () => {
justifyContent: 'space-around',
borderTop: 1,
borderColor: 'divider',
zIndex: 1100,
zIndex: 1100
}}
elevation={3}
>
@ -625,7 +472,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'text.secondary',
color: 'text.secondary'
}}
component="button"
>
@ -639,7 +486,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'primary.main',
color: 'primary.main'
}}
component="button"
>
@ -653,7 +500,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'text.secondary',
color: 'text.secondary'
}}
component="button"
>
@ -664,6 +511,8 @@ const MockupPage = () => {
)}
</Box>
);
};
}
export { MockupPage };
export {
MockupPage
};

View File

@ -0,0 +1,34 @@
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

@ -0,0 +1,77 @@
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

@ -0,0 +1,111 @@
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

@ -0,0 +1,429 @@
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

@ -0,0 +1,116 @@
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

@ -0,0 +1,21 @@
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

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

View File

@ -0,0 +1,388 @@
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
};

View File

@ -1,14 +1,14 @@
import React from 'react';
import { VectorVisualizer } from '../components/VectorVisualizer';
import { BackstoryPageProps } from '../components/BackstoryTab';
import { VectorVisualizer } from '../Components/VectorVisualizer';
import { BackstoryPageProps } from '../Components/BackstoryTab';
import './VectorVisualizerPage.css';
interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean;
rag?: any;
}
};
const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
return <VectorVisualizer inline={false} {...props} />;
@ -16,4 +16,6 @@ const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisu
export type { VectorVisualizerProps };
export { VectorVisualizerPage };
export {
VectorVisualizerPage
};

View File

@ -1,112 +0,0 @@
# 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

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