Added chat/resume

This commit is contained in:
James Ketr 2025-07-16 16:12:10 -07:00
parent 621bf46a39
commit 574d040492
20 changed files with 498 additions and 1082 deletions

View File

@ -594,68 +594,68 @@ ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint.sh" ]
FROM ubuntu:24.04 AS ollama-ov-server
# FROM ubuntu:24.04 AS ollama-ov-server
SHELL ["/bin/bash", "-c"]
# 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 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/*
# 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
# 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 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
# 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_ENABLED=1
# ENV GODEBUG=cgocheck=0
ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64
ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include
# 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
# 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 .
# RUN go build -o /usr/bin/ollama .
ENV OLLAMA_HOST=0.0.0.0:11434
EXPOSE 11434
# 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 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
# 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}
# #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"
# #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"]
# 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

View File

@ -188,27 +188,27 @@ services:
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
- CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check
ollama-ov-server:
build:
context: .
dockerfile: Dockerfile
target: ollama-ov-server
container_name: ollama-ov-server
restart: "no"
env_file:
- .env
environment:
- OLLAMA_HOST=0.0.0.0
- ONEAPI_DEVICE_SELECTOR=level_zero:0
devices:
- /dev/dri:/dev/dri
ports:
- 11435:11434 # ollama serve port
networks:
- internal
volumes:
- ./cache:/root/.cache # Cache hub models and neo_compiler_cache
- ./ollama:/root/.ollama # Cache the ollama models
# ollama-ov-server:
# build:
# context: .
# dockerfile: Dockerfile
# target: ollama-ov-server
# container_name: ollama-ov-server
# restart: "no"
# env_file:
# - .env
# environment:
# - OLLAMA_HOST=0.0.0.0
# - ONEAPI_DEVICE_SELECTOR=level_zero:0
# devices:
# - /dev/dri:/dev/dri
# ports:
# - 11435:11434 # ollama serve port
# networks:
# - internal
# volumes:
# - ./cache:/root/.cache # Cache hub models and neo_compiler_cache
# - ./ollama:/root/.ollama # Cache the ollama models
vllm:
build:

View File

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

View File

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

View File

@ -521,7 +521,7 @@ const Message = (props: MessageProps): JSX.Element => {
let metadataView = <></>;
let metadata: ChatMessageMetaData | null =
'metadata' in message ? (message.metadata as ChatMessageMetaData) || null : null;
if ('role' in message && message.role === 'user') {
if ('role' in message && message.role !== 'assistant') {
metadata = null;
}
if (metadata) {

View File

@ -1,23 +1,9 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import {
Box,
Button,
Tooltip,
SxProps,
Typography,
Tabs,
Tab,
Dialog,
DialogContent,
} from '@mui/material';
import { Box, Button, Tooltip, SxProps, Typography } from '@mui/material';
import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material';
// import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing';
import SaveIcon from '@mui/icons-material/Save';
import UndoIcon from '@mui/icons-material/Undo';
import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import { useAuth } from 'hooks/AuthContext';
import PreviewIcon from '@mui/icons-material/Preview';
import DifferenceIcon from '@mui/icons-material/Difference';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import {
ChatMessage,
ChatSession,
@ -35,7 +21,6 @@ import PropagateLoader from 'react-spinners/PropagateLoader';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { Scrollable } from 'components/Scrollable';
import { StyledMarkdown } from './StyledMarkdown';
import { DiffViewer } from './DiffViewer';
const emptyMetadata: ChatMessageMetaData = {
model: 'qwen2.5',
@ -64,126 +49,12 @@ const defaultMessage: ChatMessage = {
};
interface ResumeChatProps {
onResumeChange: (resume: string) => void; // Callback when the resume changes
onResumeChange: (prompt: string, resume: string) => void; // Callback when the resume changes
resume: string;
session: string; // Session identifier for the chat
sx?: SxProps; // Optional styles for the component
}
interface EditViewerProps {
original: string; // Original resume content
content: string; // Edited resume content
sx?: SxProps; // Optional styles for the component
onUpdate?: (content: string) => void; // Callback when the content is updated
}
const EditViewer = (props: EditViewerProps) => {
const { original, content, sx, onUpdate } = props;
const [tabValue, setTabValue] = useState<string>('raw');
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'diff') {
setTabValue(newValue);
} else {
setTabValue(newValue);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
<Tabs value={tabValue} onChange={handleTabChange} sx={{ m: 0 }}>
<Tab value="raw" icon={<EditDocumentIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
<Tab value="markdown" icon={<PreviewIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
<Tab value="diff" icon={<DifferenceIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
</Tabs>
{tabValue === 'raw' && (
<Box>
<pre>{content}</pre>
</Box>
)}
{tabValue === 'markdown' && <StyledMarkdown content={content} />}
<Dialog
open={tabValue === 'diff'}
onClose={(): void => {
setTabValue('raw');
}}
maxWidth="lg"
fullWidth
disableEscapeKeyDown={false}
fullScreen={true}
>
<DialogContent>
<Scrollable
sx={{
position: 'relative',
boxSizing: 'border-box',
maxHeight: '100%',
maxWidth: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
p: 0,
gap: 1,
}}
>
<DiffViewer original={original} content={content} />
</Scrollable>
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<Tooltip title="Revert Changes">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'flex-end',
}}
>
<Button
variant="contained"
onClick={(): void => {
setTabValue('raw');
onUpdate && onUpdate(original);
}}
disabled={!onUpdate}
>
<UndoIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Save Changes">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'flex-end',
}}
>
<Button
variant="contained"
onClick={(): void => {
setTabValue('raw');
onUpdate && onUpdate(content);
}}
disabled={!onUpdate}
>
<SaveIcon />
</Button>
</span>
</Tooltip>
</Box>
</DialogContent>
</Dialog>
</Box>
);
};
const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
(props: ResumeChatProps, ref): JSX.Element => {
const {
@ -215,6 +86,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [lastPrompt, setLastPrompt] = useState<string>('');
const onDelete = async (session: ChatSession): Promise<void> => {
if (!session.id) {
@ -237,6 +109,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
const messageContent = message;
setStreaming(true);
setLastPrompt(message);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id,
@ -267,6 +140,8 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
msg.extraContext = {
isAnswer: true,
};
} else {
onResumeChange(lastPrompt, msg.content);
}
setMessages(prev => {
@ -424,23 +299,22 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
>
{message.role === 'user' && (
<>
<PersonIcon />
<PersonIcon sx={{ mr: 1 }} />
<Typography>{message.content}</Typography>
</>
)}
{message.role === 'assistant' && (
<>
{message.extraContext?.isAnswer ? (
<StyledMarkdown content={message.content} />
<>
<QuestionAnswerIcon sx={{ mr: 1 }} />
<StyledMarkdown content={message.content} />
</>
) : (
<EditViewer
original={resume}
content={message.content}
onUpdate={(content: string): void => {
onResumeChange && onResumeChange(content);
}}
sx={{ maxWidth: '100%', boxSizing: 'border-box' }}
/>
<>
<PrecisionManufacturingIcon sx={{ mr: 1 }} />
<StyledMarkdown content="Resume updated." />
</>
)}
</>
)}

View File

@ -34,6 +34,8 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
flexGrow: 1,
overflow: 'auto',
position: 'relative',
maxHeight: '100%',
minHeight: 0, // Ensure it can shrink to fit content
// backgroundColor: '#F5F5F5',
...sx,
}}

View File

@ -3,7 +3,6 @@ import React, { JSX, ReactElement, useEffect, useState } from 'react';
import { Outlet, useLocation, Routes, Route, matchPath } from 'react-router-dom';
import { Box, Container, Paper } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { SxProps, Theme } from '@mui/material';
import { darken } from '@mui/material/styles';
import { Header } from 'components/layout/Header';
import { Scrollable } from 'components/Scrollable';
@ -23,12 +22,11 @@ export type NavigationLinkType = {
interface BackstoryPageContainerProps {
children?: React.ReactNode;
sx?: SxProps<Theme>;
variant?: 'normal' | 'fullWidth';
}
const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => {
const { children, sx, variant = 'normal' } = props;
const { children, variant = 'normal' } = props;
console.log({ variant });
return (
<Container
@ -43,13 +41,13 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element
maxWidth: variant === 'normal' ? '1024px' : '100% !important',
height: '100%',
minHeight: 0,
...sx,
}}
>
<Box
sx={{
display: 'flex',
p: { xs: 0, sm: 0.5 },
p: 0,
m: 0,
flexGrow: 1,
minHeight: 'min-content',
}}
@ -60,7 +58,7 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element
display: 'flex',
flexGrow: 1,
m: 0,
p: 0.5,
p: 0,
minHeight: 'min-content',
backgroundColor: 'background.paper',
borderRadius: 0.5,

View File

@ -194,8 +194,10 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
{candidate.location && (
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.city},{' '}
{candidate.location.state || candidate.location.country}
<strong>Location:</strong>{' '}
{candidate.location.city
? `${candidate.location.city}, ${candidate.location.state}`
: candidate.location.text}
</Typography>
)}
{candidate.email && (

View File

@ -1,152 +0,0 @@
/* A4 Portrait simulation for MuiMarkdown */
.a4-document .MuiTypography-root {
font-family: 'Roboto', 'Times New Roman', serif;
}
.a4-document {
/* display: flex; */
/* position: relative; */
/* A4 dimensions: 210mm x 297mm */
width: 210mm;
min-height: 297mm;
/* Alternative pixel-based approach (96 DPI) */
/* width: 794px; */
/* height: 1123px; */
/* Document styling */
background: white;
padding: 12mm; /* 1/4" margins all around */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
/* Typography for document feel */
font-family: 'Roboto', 'Times New Roman', serif;
font-size: 12pt;
line-height: 1.6;
color: #333;
/* Page break lines - repeating dotted lines every A4 height */
background-image:
repeating-linear-gradient(
#ddd,
#ddd 12mm,
transparent calc(12mm + 1px),
transparent calc(285mm - 1px), /* 297mm - 8mm top/bottom margins */
#ddd calc(285mm),
#ddd 297mm
);
background-size: 100% 297mm;
background-repeat: repeat-y;
/* Ensure proper page breaks for printing */
page-break-after: always;
/* Prevent content overflow */
box-sizing: border-box;
overflow: hidden;
}
/* Container to center the document */
.document-container {
display: flex;
justify-content: center;
background-color: #f5f5f5;
min-height: 100vh;
padding: 20px 0;
}
/* Responsive adjustments */
@media screen and (max-width: 900px) {
.a4-document {
width: 95vw;
height: auto;
min-height: 134vw; /* Maintains A4 aspect ratio (297/210 ≈ 1.414) */
margin: 10px auto;
padding: 6vw;
}
}
/* Print styles */
@media print {
.document-container {
background: none;
padding: 0mm !important;
margin: 0mm !important;
}
.a4-document {
width: 210mm;
margin: 0;
padding: 0;
box-shadow: none;
border: none;
page-break-after: always;
}
}
/* Additional MuiMarkdown specific adjustments */
.a4-document h1,
.a4-document h2,
.a4-document h3,
.a4-document h4,
.a4-document h5,
.a4-document h6 {
font-size: 1em;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
/* Put after above so they take precedence */
.a4-document h1,
.a4-document h2 {
font-size: 1.1em;
}
.a4-document p {
margin-bottom: 1em;
text-align: justify;
}
.a4-document ul,
.a4-document ol {
margin-bottom: 1em;
padding-left: 2em;
}
.a4-document blockquote {
margin: 1em 0;
padding-left: 1em;
border-left: 3px solid #ccc;
font-style: italic;
}
.a4-document code {
background-color: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.a4-document pre {
background-color: #f5f5f5;
padding: 1em;
border-radius: 5px;
overflow-x: auto;
margin: 1em 0;
}
.BackstoryResumeHeader {
gap: 1rem;
display: flex;
/* flex-direction: column; */
/* border: 3px solid orange; */
}
.BackstoryResumeHeader p {
/* border: 3px solid purple; */
margin: 0 !important;
}

View File

@ -24,13 +24,17 @@ import {
Select,
MenuItem,
InputLabel,
Theme,
Chip,
Alert,
Stack,
SelectChangeEvent,
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
// import SaveIcon from '@mui/icons-material/Save';
// import UndoIcon from '@mui/icons-material/Undo';
// import PreviewIcon from '@mui/icons-material/Preview';
import DifferenceIcon from '@mui/icons-material/Difference';
// import EditDocumentIcon from '@mui/icons-material/EditDocument';
import {
Delete as DeleteIcon,
Restore as RestoreIcon,
@ -41,19 +45,15 @@ import {
Person as PersonIcon,
Schedule as ScheduleIcon,
ModelTraining,
Email as EmailIcon,
Phone as PhoneIcon,
LocationOn as LocationIcon,
History as HistoryIcon,
RestoreFromTrash as RestoreFromTrashIcon,
Refresh as RefreshIcon,
PrecisionManufacturing,
// Language as WebsiteIcon,
} from '@mui/icons-material';
import InputIcon from '@mui/icons-material/Input';
import TuneIcon from '@mui/icons-material/Tune';
import PreviewIcon from '@mui/icons-material/Preview';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { parsePhoneNumberFromString } from 'libphonenumber-js';
import { useReactToPrint } from 'react-to-print';
@ -63,12 +63,14 @@ import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField';
import { JobInfo } from './JobInfo';
import './ResumeInfo.css';
import { Scrollable } from 'components/Scrollable';
import * as Types from 'types/types';
import { StreamingOptions } from 'services/api-client';
import { StatusBox, StatusIcon } from './StatusIcon';
import { ResumeChat } from 'components/ResumeChat';
import { DiffViewer } from 'components/DiffViewer';
import { ResumePreview, resumeStyles } from './ResumePreview';
import { useNavigate } from 'react-router-dom';
interface ResumeInfoProps {
resume: Resume;
@ -88,457 +90,9 @@ interface ResumeRevision {
jobId: string;
}
// Resume Style Definitions
interface ResumeStyle {
name: string;
description: string;
headerStyle: SxProps<Theme>;
footerStyle: SxProps<Theme>;
contentStyle: SxProps<Theme>;
markdownStyle: SxProps<Theme>;
color: {
primary: string;
secondary: string;
accent: string;
text: string;
background: string;
};
}
const generateResumeStyles = () => {
const defaultStyle = {
display: 'flex',
flexDirection: 'row',
};
return {
classic: {
name: 'Classic',
description: 'Traditional, professional serif design',
headerStyle: {
...defaultStyle,
fontFamily: '"Times New Roman", Times, serif',
borderBottom: '2px solid #2c3e50',
paddingBottom: 2,
marginBottom: 3,
},
footerStyle: {
fontFamily: '"Times New Roman", Times, serif',
borderTop: '2px solid #2c3e50',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Times New Roman", Times, serif',
lineHeight: 1.6,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Times New Roman", Times, serif',
'& h1, & h2, & h3': {
fontFamily: '"Times New Roman", Times, serif',
color: '#2c3e50',
borderBottom: '1px solid #bdc3c7',
paddingBottom: 1,
marginBottom: 2,
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
},
'& ul': {
paddingLeft: 3,
},
},
color: {
primary: '#2c3e50',
secondary: '#34495e',
accent: '#3498db',
text: '#2c3e50',
background: '#ffffff',
},
},
modern: {
name: 'Modern',
description: 'Clean, minimalist sans-serif layout',
headerStyle: {
...defaultStyle,
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
paddingLeft: 2,
marginBottom: 3,
backgroundColor: '#f8f9fa',
padding: 2,
borderRadius: 1,
},
footerStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
backgroundColor: '#f8f9fa',
paddingTop: 2,
borderRadius: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
lineHeight: 1.5,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
color: '#3498db',
fontWeight: 300,
marginBottom: 1.5,
},
'& h1': {
fontSize: '1.75rem',
},
'& h2': {
fontSize: '1.5rem',
},
'& h3': {
fontSize: '1.25rem',
},
'& p, & li': {
lineHeight: 1.5,
marginBottom: 0.75,
},
'& ul': {
paddingLeft: 2.5,
},
},
color: {
primary: '#3498db',
secondary: '#2c3e50',
accent: '#e74c3c',
text: '#2c3e50',
background: '#ffffff',
},
},
creative: {
name: 'Creative',
description: 'Colorful, unique design with personality',
headerStyle: {
...defaultStyle,
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
padding: 2.5,
borderRadius: 1.5,
marginBottom: 3,
},
footerStyle: {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
paddingTop: 2,
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
lineHeight: 1.6,
color: '#444444',
},
markdownStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
color: '#667eea',
fontWeight: 600,
marginBottom: 2,
},
'& h1': {
fontSize: '1.5rem',
},
'& h2': {
fontSize: '1.25rem',
},
'& h3': {
fontSize: '1.1rem',
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
color: '#444444',
},
'& strong': {
color: '#764ba2',
fontWeight: 600,
},
'& ul': {
paddingLeft: 3,
},
},
color: {
primary: '#667eea',
secondary: '#764ba2',
accent: '#f093fb',
text: '#444444',
background: '#ffffff',
},
},
corporate: {
name: 'Corporate',
description: 'Formal, structured business format',
headerStyle: {
...defaultStyle,
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
padding: 2.5,
marginBottom: 3,
backgroundColor: '#ecf0f1',
},
footerStyle: {
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
backgroundColor: '#ecf0f1',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Arial", sans-serif',
lineHeight: 1.4,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Arial", sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Arial", sans-serif',
color: '#34495e',
fontWeight: 'bold',
textTransform: 'uppercase',
fontSize: '0.875rem',
letterSpacing: '1px',
marginBottom: 1.5,
borderBottom: '1px solid #bdc3c7',
paddingBottom: 0.5,
},
'& h1': {
fontSize: '1rem',
},
'& h2': {
fontSize: '0.875rem',
},
'& h3': {
fontSize: '0.75rem',
},
'& p, & li': {
lineHeight: 1.4,
marginBottom: 0.75,
fontSize: '0.75rem',
},
'& ul': {
paddingLeft: 2,
},
},
color: {
primary: '#34495e',
secondary: '#2c3e50',
accent: '#95a5a6',
text: '#2c3e50',
background: '#ffffff',
},
},
};
};
const resumeStyles: Record<string, ResumeStyle> = generateResumeStyles();
// Styled Header Component
interface BackstoryStyledResumeProps {
candidate: Types.Candidate;
job?: Types.Job;
style: ResumeStyle;
}
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, style }) => {
return (
<>
<Box
className="BackstoryResumeFooter"
sx={{
...style.footerStyle,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
color: style.color.secondary,
}}
>
Dive deeper into my qualifications at Backstory...
<Box
component="img"
src={`/api/1.0/candidates/qr-code/${candidate.id || ''}/${(job && job.id) || ''}`}
alt="QR Code"
className="qr-code"
sx={{ display: 'flex', mt: 1, mb: 1 }}
/>
{candidate?.username
? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}`
: 'backstory'}
</Box>
<Box sx={{ pb: 2 }}>&nbsp;</Box>
</>
);
};
const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }) => {
const phone = parsePhoneNumberFromString(candidate.phone || '', 'US');
return (
<Box className="BackstoryResumeHeader" sx={style.headerStyle}>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<Box sx={{ display: 'flex' }}>
<Typography
variant="h4"
sx={{
fontWeight: 'bold',
mb: 1,
color: style.name === 'creative' ? '#ffffff' : style.color.primary,
fontFamily: 'inherit',
}}
>
{candidate.fullName}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
gap: 1,
}}
>
{candidate.description && (
<Box sx={{ display: 'flex' }}>
<Typography
variant="h6"
sx={{
mb: 2,
fontWeight: 300,
color: style.name === 'creative' ? '#ffffff' : style.color.secondary,
fontFamily: 'inherit',
fontSize: '0.8rem !important',
}}
>
{candidate.description}
</Typography>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
alignContent: 'center',
flexGrow: 1,
minWidth: 'fit-content',
gap: 1,
}}
>
{candidate.email && (
<Box sx={{ display: 'flex', alignItems: 'center', m: 0, p: 0 }}>
<EmailIcon
fontSize="small"
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
/>
<Typography
variant="body2"
sx={{
color: style.name === 'creative' ? '#ffffff' : style.color.text,
fontFamily: 'inherit',
}}
>
{candidate.email}
</Typography>
</Box>
)}
{phone?.isValid() && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PhoneIcon
fontSize="small"
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
/>
<Typography
variant="body2"
sx={{
color: style.name === 'creative' ? '#ffffff' : style.color.text,
fontFamily: 'inherit',
}}
>
{phone.formatInternational()}
</Typography>
</Box>
)}
{candidate.location && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<LocationIcon
fontSize="small"
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
/>
<Typography
variant="body2"
sx={{
color: style.name === 'creative' ? '#ffffff' : style.color.text,
fontFamily: 'inherit',
}}
>
{candidate.location.city
? `${candidate.location.city}, ${candidate.location.state}`
: candidate.location.text}
</Typography>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
);
};
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const { setSnack } = useAppState();
const navigate = useNavigate();
const { resume } = props;
const { user, apiClient } = useAuth();
const { sx, variant = 'normal' } = props;
@ -551,6 +105,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [editContent, setEditContent] = useState<string>('');
const [editSystemPrompt, setEditSystemPrompt] = useState<string>('');
const [editPrompt, setEditPrompt] = useState<string>('');
const [lastRevision, setLastRevision] = useState<Resume | null>(null);
const [saving, setSaving] = useState<boolean>(false);
const [tabValue, setTabValue] = useState('markdown');
const [jobTabValue, setJobTabValue] = useState('chat');
@ -630,6 +185,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
// Handle revision selection
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
const newRevisionId = event.target.value;
if (selectedRevision === 'current') {
setLastRevision(activeResume);
}
setSelectedRevision(newRevisionId);
await loadRevisionContent(newRevisionId);
};
@ -638,6 +196,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const restoreRevision = (): void => {
setEditContent(revisionContent);
setSelectedRevision('current');
setLastRevision(null);
setSnack('Revision restored to editor.');
};
@ -674,14 +233,14 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setActiveResume({ ...resume });
};
const handleSave = async (): Promise<void> => {
const handleSave = async (prompt = ''): Promise<void> => {
setSaving(true);
try {
const resumeUpdate = {
...activeResume,
resume: editContent,
systemPrompt: editSystemPrompt,
prompt: editPrompt,
prompt: prompt || editPrompt,
};
const result = await apiClient.updateResume(resumeUpdate);
console.log('Resume updated:', result);
@ -705,6 +264,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setEditSystemPrompt(activeResume.systemPrompt || '');
setEditPrompt(activeResume.prompt || '');
setSelectedRevision('current');
setLastRevision(null);
setRevisionContent(activeResume.resume);
setEditDialogOpen(true);
};
@ -773,6 +333,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'print') {
console.log('Printing resume...');
reactToPrintFn();
return;
}
@ -878,8 +439,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Typography variant="caption" color="text.secondary">
Job ID: {activeResume.job?.id}
</Typography>
<Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.id}
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 1 }}
>
Resume ID:
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
navigate(`/chat/${activeResume.id}`);
}}
>
{activeResume.id}
</Box>
</Typography>
</Box>
</Box>
@ -1042,14 +615,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
{activeResume.systemPrompt && (
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
)}
{activeResume.systemPrompt && (
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
)}
{lastRevision && <Tab value="diff" icon={<DifferenceIcon />} label="Changes" />}
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
{activeResume.systemPrompt && (
<Tab value="context" icon={<TuneIcon />} label="Context" />
)}
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
</Tabs>
</Box>
@ -1162,16 +733,29 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Tooltip>
{selectedRevision !== 'current' && (
<Tooltip title="Restore this revision to editor">
<Button
size="small"
startIcon={<RestoreFromTrashIcon />}
onClick={restoreRevision}
disabled={loadingRevision}
>
Restore
</Button>
</Tooltip>
<>
<Tooltip title="See changes against current version">
<Button
size="small"
startIcon={<PrecisionManufacturing />}
onClick={() => setTabValue('diff')}
disabled={loadingRevision}
>
Changes
</Button>
</Tooltip>
<Tooltip title="Restore this revision to editor">
<Button
size="small"
startIcon={<RestoreFromTrashIcon />}
onClick={restoreRevision}
disabled={loadingRevision}
>
Restore
</Button>
</Tooltip>
</>
)}
</Stack>
@ -1213,6 +797,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
flexGrow: 1,
flex: 1,
overflowY: 'auto',
fontFamily: 'monospace',
backgroundColor: '#fafafa',
fontSize: '12px',
}}
placeholder="Enter resume content..."
/>
@ -1240,22 +827,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
)}
</>
)}
{tabValue === 'systemPrompt' && (
<BackstoryTextField
value={editSystemPrompt}
onChange={(value): void => setEditSystemPrompt(value)}
style={{
position: 'relative',
maxHeight: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1,
overflowY: 'auto',
}}
placeholder="Edit system prompt..."
/>
{tabValue === 'context' && (
<StyledMarkdown content={activeResume.systemPrompt || ''} />
)}
{tabValue === 'prompt' && (
<BackstoryTextField
@ -1275,50 +848,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
placeholder="Edit prompt..."
/>
)}
{tabValue === 'preview' && (
{tabValue === 'diff' && lastRevision && (
<DiffViewer
changeLog={activeResume.prompt || ''}
original={{ content: revisionContent, name: 'original' }}
modified={{ content: lastRevision.resume, name: 'modified' }}
/>
)}
{tabValue === 'preview' && activeResume.candidate && (
<Box
className="document-container"
ref={printContentRef}
sx={currentStyle.contentStyle}
>
<Box
className="a4-document"
sx={{
backgroundColor: currentStyle.color.background,
padding: 5,
minHeight: '100vh',
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
}}
>
{/* Custom Header */}
{activeResume.candidate && (
<StyledHeader candidate={activeResume.candidate} style={currentStyle} />
)}
{/* Styled Markdown Content */}
<Box sx={currentStyle.markdownStyle}>
<StyledMarkdown
sx={{
position: 'relative',
maxHeight: '100%',
display: 'flex',
flexGrow: 1,
flex: 1,
...currentStyle.markdownStyle,
}}
content={editContent || activeResume.resume || ''}
/>
</Box>
{/* QR Code Footer */}
{activeResume.candidate && activeResume.job && (
<StyledFooter
candidate={activeResume.candidate}
job={activeResume.job}
style={currentStyle}
/>
)}
</Box>
<ResumePreview resume={activeResume} selectedStyle={selectedStyle} />
</Box>
)}
</Scrollable>
@ -1363,14 +906,22 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<ResumeChat
session={activeResume.id || ''}
resume={editContent}
onResumeChange={(newResume: string): void => {
setEditContent(newResume);
setActiveResume({ ...activeResume, resume: newResume });
onResumeChange={(prompt: string, newResume: string): void => {
if (newResume !== editContent) {
handleSave(prompt).then(() => {
setEditContent(newResume);
setLastRevision(activeResume);
setActiveResume({ ...activeResume, prompt, resume: newResume });
});
}
}}
sx={{
m: 1,
p: 1,
flexGrow: 1,
position: 'relative',
maxWidth: 'fit-content',
minWidth: '100%',
}}
/>
)}
@ -1381,7 +932,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<DialogActions>
<Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleSave}
onClick={() => {
handleSave();
}}
variant="contained"
disabled={saving}
startIcon={<SaveIcon />}

View File

@ -39,6 +39,7 @@ import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists
import { useNavigate, useParams } from 'react-router-dom';
import { Resume } from 'types/types';
import { formatDate } from 'utils/formatDate';
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
type SortOrder = 'asc' | 'desc';
@ -214,16 +215,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder);
const formatDate = (date: Date | undefined): string => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
...(isMobile ? {} : { year: 'numeric' }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }),
}).format(date);
};
const getSortIcon = (field: SortField): JSX.Element => {
if (sortField !== field) return <></>;
return sortOrder === 'asc' ? (
@ -436,7 +427,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
noWrap
sx={{ display: 'block', fontSize: '0.7rem' }}
>
{formatDate(resume.updatedAt)}
{formatDate(resume.updatedAt, isMobile, isSmall)}
</Typography>
)}
</TableCell>
@ -469,7 +460,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
{!isMobile && (
<TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{formatDate(resume.updatedAt)}
{formatDate(resume.updatedAt, isMobile, isSmall)}
</Typography>
{resume.createdAt && (
<Typography
@ -477,7 +468,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }}
>
Created: {formatDate(resume.createdAt)}
Created: {formatDate(resume.createdAt, isMobile, isSmall)}
</Typography>
)}
</TableCell>

View File

@ -61,7 +61,8 @@ export const navigationConfig: NavigationConfig = {
{
id: 'chat',
label: 'Candidate Chat',
path: '/chat',
path: '/chat/:resumeId?',
variant: 'fullWidth',
icon: <ChatIcon />,
component: <CandidateChatPage />,
userTypes: ['guest', 'candidate', 'employer'],

View File

@ -1,5 +1,5 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import { Box, Paper, Button, Tooltip, SxProps } from '@mui/material';
import { Box, Button, Tooltip, SxProps } from '@mui/material';
import { Send as SendIcon } from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext';
import {
@ -11,6 +11,7 @@ import {
ChatMessageStatus,
ChatMessageMetaData,
CandidateQuestion,
Resume,
} from 'types/types';
import { ConversationHandle } from 'components/Conversation';
import { Message } from 'components/Message';
@ -22,6 +23,9 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
import { BackstoryQuery } from 'components/BackstoryQuery';
import { CandidatePicker } from 'components/ui/CandidatePicker';
import { Scrollable } from 'components/Scrollable';
import { useParams } from 'react-router-dom';
import ResumePreview from 'components/ui/ResumePreview';
import { formatDate } from 'utils/formatDate';
const emptyMetadata: ChatMessageMetaData = {
model: 'qwen2.5',
@ -55,6 +59,8 @@ interface CandidateChatPageProps {
const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>(
(props: CandidateChatPageProps, ref): JSX.Element => {
const { resumeId } = useParams<{ resumeId?: string }>();
const [resume, setResume] = useState<Resume | null>(null);
const { sx } = props;
const { apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate();
@ -72,6 +78,19 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!resumeId || resume) return;
apiClient
.getResume(resumeId)
.then(resume => {
setResume(resume);
})
.catch(error => {
console.error('Failed to load resume:', error);
setSnack('Failed to load resume', 'error');
});
}, [resumeId, resume, apiClient, setSnack]);
const onDelete = async (session: ChatSession): Promise<void> => {
if (!session.id) {
return;
@ -101,6 +120,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
status: 'done',
type: 'text',
timestamp: new Date(),
extraContext: {
candidateId: resume?.job?.id,
jobId: resume?.job?.id || '',
resumeId: resume?.id || '',
},
};
setProcessingMessage({
@ -176,8 +200,10 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
apiClient
.getOrCreateChatSession(
selectedCandidate,
`Backstory chat with ${selectedCandidate.fullName}`,
'candidate_chat'
resumeId
? `Backstory Resume chat ${resumeId}`
: `Backstory Chat with ${selectedCandidate.fullName}`,
resumeId ? 'resume_chat' : 'candidate_chat'
)
.then(session => {
setChatSession(session);
@ -222,7 +248,12 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
type: 'text',
status: 'done',
timestamp: new Date(),
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`,
content:
`Welcome to the Backstory Chat about ${selectedCandidate.fullName}` +
(resume && ` and the ${resume.job?.title} position at ${resume.job?.company}`) +
`. Ask any questions you have about ${selectedCandidate.firstName}'${
selectedCandidate.firstName.slice(-1) !== 's' ? 's' : ''
} resume or skills.`,
metadata: emptyMetadata,
};
@ -235,139 +266,166 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
ref={ref}
sx={{
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
maxHeight: '100%',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
position: 'relative',
...sx,
p: 0,
m: 0,
backgroundColor: '#D3CDBF' /* Warm Gray */,
}}
>
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="normal"
{resume && (
<Box
sx={{
flexShrink: 1,
width: '100%',
maxHeight: 0,
minHeight: 'min-content',
}} // Prevent header from shrinking
/>
{/* <Button
sx={{ maxWidth: 'max-content' }}
onClick={(): void => {
setSelectedCandidate(null);
}}
variant="contained"
>
Change Candidates
</Button> */}
</Paper>
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
position: 'relative',
maxHeight: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
pt: 2,
pl: 1,
pr: 1,
pb: 2,
flexDirection: 'column',
width: '50%',
maxHeight: '100%',
p: 0,
m: 0,
position: 'relative',
backgroundColor: '#f5f5f5',
}}
>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
{processingMessage !== null && (
<Message {...{ chatSession, message: processingMessage }} />
)}
{streamingMessage !== null && (
<Message {...{ chatSession, message: streamingMessage }} />
)}
{streaming && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
)}
{selectedCandidate.questions?.length !== 0 && (
<Box sx={{ diplay: 'flex', flexDirection: 'column', gap: 1, p: 1 }}>
{' '}
{selectedCandidate.questions?.map((q, i) => (
<BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} />
))}
</Box>
)}
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
<DeleteConfirmation
onDelete={(): void => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
<Box
sx={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
p: 1,
}}
>
<Button
variant="contained"
onClick={(): void => {
sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ''
);
<strong>{resume.job?.title}</strong>&nbsp;at&nbsp;
<strong>{resume.job?.company}</strong>. Last updated{' '}
{formatDate(resume.updatedAt, false, true)}.
</Box>
<Scrollable sx={{ m: 0 }}>
<ResumePreview shadeMargins={false} resume={resume} />
</Scrollable>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '50%',
p: 1,
m: resume ? 0 : '0 auto',
position: 'relative',
backgroundColor: 'background.paper',
}}
>
{!resume && (
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="normal"
sx={{
width: '100%',
maxHeight: 0,
minHeight: 'min-content',
}} // Prevent header from shrinking
/>
)}
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
width: '100%',
}}
>
<Box sx={{ display: 'flex', flexGrow: 1 }}></Box>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
{processingMessage !== null && (
<Message {...{ chatSession, message: processingMessage }} />
)}
{streamingMessage !== null && (
<Message {...{ chatSession, message: streamingMessage }} />
)}
{streaming && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
)}
{selectedCandidate.questions?.length !== 0 && (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, p: 1, flex: 0 }}>
{' '}
{selectedCandidate.questions?.map((q, i) => (
<BackstoryQuery key={i} question={q} submitQuery={handleSubmitQuestion} />
))}
</Box>
)}
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', gap: 1 }}>
<DeleteConfirmation
onDelete={(): void => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
<Button
variant="contained"
onClick={(): void => {
sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) ||
''
);
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
</Box>
);

View File

@ -752,12 +752,12 @@ class ApiClient {
}>(response);
}
async getResume(resumeId: string): Promise<{ success: boolean; resume: Types.Resume }> {
async getResume(resumeId: string): Promise<Types.Resume> {
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
headers: this.defaultHeaders,
});
return handleApiResponse<{ success: boolean; resume: Types.Resume }>(response);
return this.handleApiResponseWithConversion<Types.Resume>(response, 'Resume');
}
async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> {

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// Source: src/backend/models.py
// Generated on: 2025-07-15T16:43:21.492940
// Generated on: 2025-07-16T21:30:37.986984
// DO NOT EDIT MANUALLY - This file is auto-generated
// ============================
@ -19,7 +19,7 @@ export type ApiStatusType = "streaming" | "status" | "done" | "error";
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_chat" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error";
@ -284,7 +284,7 @@ export interface Certification {
}
export interface ChatContext {
type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_chat" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
relatedEntityId?: string;
relatedEntityType?: "job" | "candidate" | "employer";
additionalContext?: Record<string, any>;

View File

@ -0,0 +1,11 @@
const formatDate = (date: Date | undefined, isMobile = false, isSmall = false): string => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
...(isMobile ? {} : { year: 'numeric' }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }),
}).format(date);
};
export { formatDate };

View File

@ -185,6 +185,7 @@ class ChatContextType(str, Enum):
JOB_REQUIREMENTS = "job_requirements"
CANDIDATE_CHAT = "candidate_chat"
INTERVIEW_PREP = "interview_prep"
RESUME_CHAT = "resume_chat"
RESUME_REVIEW = "resume_review"
EDIT_RESUME = "edit_resume"
GENERAL = "general"

View File

@ -72,6 +72,9 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
self.md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
self.processing_lock = asyncio.Lock()
self.processing_debounce = {} # Add this line
self.debounce_delay = 1.0 # seconds
# self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
# Path for storing file hash state
@ -220,7 +223,17 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
return files_processed
async def process_file_update(self, file_path):
"""Process a file update event."""
"""Process a file update event, debounced to debounce_delay."""
# Debouncing logic
current_time = asyncio.get_event_loop().time()
if file_path in self.processing_debounce:
time_since_last = current_time - self.processing_debounce[file_path]
if time_since_last < self.debounce_delay:
logging.info(f"Debouncing {file_path} (last processed {time_since_last:.2f}s ago)")
return
self.processing_debounce[file_path] = current_time
# Use a lock to make the check-and-add atomic
async with self.processing_lock:
@ -240,15 +253,16 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
if not current_hash:
return
if file_path in self.file_hashes and self.file_hashes[file_path] == current_hash:
logging.info(f"Hash has not changed for {file_path}")
return
# Update file hash
self.file_hashes[file_path] = current_hash
# Process and update the file in ChromaDB
# Use the update_lock to make hash check and update atomic
async with self.update_lock:
if file_path in self.file_hashes and self.file_hashes[file_path] == current_hash:
logging.info(f"Hash has not changed for {file_path}")
return
# Update file hash BEFORE processing to prevent race conditions
self.file_hashes[file_path] = current_hash
# Process and update the file in ChromaDB
await self._update_document_in_collection(file_path)
# Save the hash state after successful update
@ -488,23 +502,31 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
logging.error(f"Error hashing file {file_path}: {e}")
return None
def _should_process_file(self, file_path):
"""Check if a file should be processed."""
# Skip temporary files, hidden files, etc.
file_name = os.path.basename(file_path)
if file_name.startswith(".") or file_name.endswith(".tmp"):
return False
# Add other filtering logic as needed
return True
def on_modified(self, event):
"""Handle file modification events."""
if event.is_directory:
if event.is_directory or not self._should_process_file(event.src_path):
return
file_path = event.src_path
# Schedule the update using asyncio
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
logging.info(f"File modified: {file_path}")
def on_created(self, event):
"""Handle file creation events."""
if event.is_directory:
if event.is_directory or not self._should_process_file(event.src_path):
return
file_path = event.src_path
# Schedule the update using asyncio
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
logging.info(f"File created: {file_path}")
@ -550,13 +572,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
if file_path.endswith(extensions):
p = Path(file_path)
p_as_md = p.with_suffix(".md")
if p_as_md.exists():
logging.info(f"newer: {p.stat().st_mtime > p_as_md.stat().st_mtime}")
# If file_path.md doesn't exist or file_path is newer than file_path.md,
# fire off markitdown
if (not p_as_md.exists()) or (p.stat().st_mtime > p_as_md.stat().st_mtime):
self._markitdown(file_path, p_as_md)
# Add the generated .md file to processing_files to prevent double-processing
self.processing_files.add(str(p_as_md))
return
chunks = self._markdown_chunker.process_file(file_path)

View File

@ -147,11 +147,17 @@ async def get_resume(
):
"""Get a specific resume by ID"""
try:
resume = await database.get_resume(current_user.id, resume_id)
if not resume:
resume_data = await database.get_resume(current_user.id, resume_id)
if not resume_data:
raise HTTPException(status_code=404, detail="Resume not found")
return {"success": True, "resume": resume}
resume = Resume.model_validate(resume_data)
job_data = await database.get_job(resume.job_id)
if job_data:
resume.job = Job.model_validate(job_data)
candidate_data = await database.get_candidate(resume.candidate_id)
if candidate_data:
resume.candidate = Candidate.model_validate(candidate_data)
return create_success_response(resume.model_dump(by_alias=True))
except HTTPException:
raise
except Exception as e: