Added chat/resume
This commit is contained in:
parent
621bf46a39
commit
574d040492
92
Dockerfile
92
Dockerfile
@ -594,68 +594,68 @@ ENV PATH=/opt/backstory:$PATH
|
|||||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
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 apt-get update && apt install -y software-properties-common libtbb-dev
|
||||||
RUN add-apt-repository ppa:deadsnakes/ppa \
|
# RUN add-apt-repository ppa:deadsnakes/ppa \
|
||||||
&& apt-get update \
|
# && apt-get update \
|
||||||
&& apt-get install -y python3.10 net-tools
|
# && apt-get install -y python3.10 net-tools
|
||||||
RUN ln -sf /usr/bin/python3.10 /usr/bin/python3
|
# RUN ln -sf /usr/bin/python3.10 /usr/bin/python3
|
||||||
|
|
||||||
RUN apt-get install -y ca-certificates git wget curl gcc g++ \
|
# RUN apt-get install -y ca-certificates git wget curl gcc g++ \
|
||||||
&& apt-get clean \
|
# && apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
# && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /home/ollama_ov_server
|
# WORKDIR /home/ollama_ov_server
|
||||||
ARG GOVERSION=1.24.1
|
# 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
|
# 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
|
# 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 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
|
# 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
|
# 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 CGO_ENABLED=1
|
||||||
ENV GODEBUG=cgocheck=0
|
# ENV GODEBUG=cgocheck=0
|
||||||
|
|
||||||
ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64
|
# ENV CGO_LDFLAGS=-L$GENAI_DIR/runtime/lib/intel64
|
||||||
ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include
|
# ENV CGO_CFLAGS=-I$GENAI_DIR/runtime/include
|
||||||
|
|
||||||
WORKDIR /home/ollama_ov_server
|
# WORKDIR /home/ollama_ov_server
|
||||||
RUN git clone https://github.com/openvinotoolkit/openvino_contrib.git
|
# 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/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
|
# ENV OLLAMA_HOST=0.0.0.0:11434
|
||||||
EXPOSE 11434
|
# EXPOSE 11434
|
||||||
|
|
||||||
RUN apt-get update \
|
# RUN apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
# && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
pip \
|
# pip \
|
||||||
&& apt-get clean \
|
# && apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
# && 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-4B-int4-ov
|
||||||
#ENV model=Qwen3-8B-int4-ov -- didn't work
|
# #ENV model=Qwen3-8B-int4-ov -- didn't work
|
||||||
#RUN huggingface-cli download OpenVINO/${model}
|
# #RUN huggingface-cli download OpenVINO/${model}
|
||||||
#RUN modelscope download --model OpenVINO/${model} --local_dir ./${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 tar -zcvf /root/.ollama/models/${model}.tar.gz /root/.cache/hub/models--OpenVINO--${model}
|
||||||
#RUN { \
|
# #RUN { \
|
||||||
# echo "FROM ${model}.tar.gz" ; \
|
# # echo "FROM ${model}.tar.gz" ; \
|
||||||
# echo "ModelType 'OpenVINO'" ; \
|
# # echo "ModelType 'OpenVINO'" ; \
|
||||||
#} > /root/.ollama/models/Modelfile
|
# #} > /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 /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
|
FROM llm-base AS vllm
|
||||||
|
|
||||||
|
@ -188,27 +188,27 @@ services:
|
|||||||
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
|
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
|
||||||
- CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check
|
- CAP_SYS_PTRACE # PTRACE_MODE_READ_REALCREDS ptrace access mode check
|
||||||
|
|
||||||
ollama-ov-server:
|
# ollama-ov-server:
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
target: ollama-ov-server
|
# target: ollama-ov-server
|
||||||
container_name: ollama-ov-server
|
# container_name: ollama-ov-server
|
||||||
restart: "no"
|
# restart: "no"
|
||||||
env_file:
|
# env_file:
|
||||||
- .env
|
# - .env
|
||||||
environment:
|
# environment:
|
||||||
- OLLAMA_HOST=0.0.0.0
|
# - OLLAMA_HOST=0.0.0.0
|
||||||
- ONEAPI_DEVICE_SELECTOR=level_zero:0
|
# - ONEAPI_DEVICE_SELECTOR=level_zero:0
|
||||||
devices:
|
# devices:
|
||||||
- /dev/dri:/dev/dri
|
# - /dev/dri:/dev/dri
|
||||||
ports:
|
# ports:
|
||||||
- 11435:11434 # ollama serve port
|
# - 11435:11434 # ollama serve port
|
||||||
networks:
|
# networks:
|
||||||
- internal
|
# - internal
|
||||||
volumes:
|
# volumes:
|
||||||
- ./cache:/root/.cache # Cache hub models and neo_compiler_cache
|
# - ./cache:/root/.cache # Cache hub models and neo_compiler_cache
|
||||||
- ./ollama:/root/.ollama # Cache the ollama models
|
# - ./ollama:/root/.ollama # Cache the ollama models
|
||||||
|
|
||||||
vllm:
|
vllm:
|
||||||
build:
|
build:
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
.d2h-file-side-diff .d2h-code-line pre {
|
.d2h-file-side-diff .d2h-code-line pre {
|
||||||
white-space: pre-wrap !important;
|
white-space: pre-wrap !important;
|
||||||
word-wrap: break-word !important;
|
word-wrap: break-word !important;
|
||||||
overflow-wrap: break-word !important;
|
overflow-wrap: break-word !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.d2h-file-header {
|
.d2h-file-header {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
@ -4,21 +4,42 @@ import { Box, SxProps } from '@mui/material';
|
|||||||
import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui';
|
import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui';
|
||||||
import 'diff2html/bundles/css/diff2html.min.css';
|
import 'diff2html/bundles/css/diff2html.min.css';
|
||||||
import './DiffViewer.css';
|
import './DiffViewer.css';
|
||||||
|
// import { Scrollable } from './Scrollable';
|
||||||
|
|
||||||
|
interface DiffFile {
|
||||||
|
content: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
original: string;
|
original: DiffFile;
|
||||||
content: string;
|
modified: DiffFile;
|
||||||
|
changeLog: string;
|
||||||
sx?: SxProps;
|
sx?: SxProps;
|
||||||
outputFormat?: 'line-by-line' | 'side-by-side';
|
outputFormat?: 'line-by-line' | 'side-by-side';
|
||||||
drawFileList?: boolean;
|
drawFileList?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
|
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 diffRef = useRef<HTMLDivElement>(null);
|
||||||
const diffString = createPatch('Resume', original || '', content || '', 'original', 'modified', {
|
const diffString = createPatch(
|
||||||
context: 5,
|
'Resume',
|
||||||
});
|
original.content || '',
|
||||||
|
modified.content || '',
|
||||||
|
original.name,
|
||||||
|
modified.name,
|
||||||
|
{
|
||||||
|
context: 5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (diffRef.current && diffString) {
|
if (diffRef.current && diffString) {
|
||||||
@ -29,7 +50,7 @@ const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
|
|||||||
const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, {
|
const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, {
|
||||||
drawFileList: false,
|
drawFileList: false,
|
||||||
matching: 'lines',
|
matching: 'lines',
|
||||||
outputFormat: 'side-by-side',
|
outputFormat,
|
||||||
synchronisedScroll: true,
|
synchronisedScroll: true,
|
||||||
highlight: true,
|
highlight: true,
|
||||||
fileContentToggle: false,
|
fileContentToggle: false,
|
||||||
@ -44,17 +65,33 @@ const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={diffRef}
|
|
||||||
className="diff-viewer"
|
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
fontFamily: 'monospace',
|
boxSizing: 'border-box',
|
||||||
fontSize: '14px',
|
display: 'flex',
|
||||||
maxWidth: '100%',
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
flex: 1 /* Take remaining space in some-container */,
|
||||||
|
overflow: 'auto' /* Scroll if content overflows */,
|
||||||
|
p: 0,
|
||||||
|
gap: 1,
|
||||||
...sx,
|
...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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -521,7 +521,7 @@ const Message = (props: MessageProps): JSX.Element => {
|
|||||||
let metadataView = <></>;
|
let metadataView = <></>;
|
||||||
let metadata: ChatMessageMetaData | null =
|
let metadata: ChatMessageMetaData | null =
|
||||||
'metadata' in message ? (message.metadata as ChatMessageMetaData) || null : 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;
|
metadata = null;
|
||||||
}
|
}
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
|
@ -1,23 +1,9 @@
|
|||||||
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
|
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
|
||||||
import {
|
import { Box, Button, Tooltip, SxProps, Typography } from '@mui/material';
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Tooltip,
|
|
||||||
SxProps,
|
|
||||||
Typography,
|
|
||||||
Tabs,
|
|
||||||
Tab,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material';
|
import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material';
|
||||||
// import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing';
|
import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
|
||||||
import UndoIcon from '@mui/icons-material/Undo';
|
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
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 {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
@ -35,7 +21,6 @@ import PropagateLoader from 'react-spinners/PropagateLoader';
|
|||||||
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
||||||
import { Scrollable } from 'components/Scrollable';
|
import { Scrollable } from 'components/Scrollable';
|
||||||
import { StyledMarkdown } from './StyledMarkdown';
|
import { StyledMarkdown } from './StyledMarkdown';
|
||||||
import { DiffViewer } from './DiffViewer';
|
|
||||||
|
|
||||||
const emptyMetadata: ChatMessageMetaData = {
|
const emptyMetadata: ChatMessageMetaData = {
|
||||||
model: 'qwen2.5',
|
model: 'qwen2.5',
|
||||||
@ -64,126 +49,12 @@ const defaultMessage: ChatMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ResumeChatProps {
|
interface ResumeChatProps {
|
||||||
onResumeChange: (resume: string) => void; // Callback when the resume changes
|
onResumeChange: (prompt: string, resume: string) => void; // Callback when the resume changes
|
||||||
resume: string;
|
resume: string;
|
||||||
session: string; // Session identifier for the chat
|
session: string; // Session identifier for the chat
|
||||||
sx?: SxProps; // Optional styles for the component
|
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>(
|
const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||||
(props: ResumeChatProps, ref): JSX.Element => {
|
(props: ResumeChatProps, ref): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
@ -215,6 +86,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
|||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [streaming, setStreaming] = useState<boolean>(false);
|
const [streaming, setStreaming] = useState<boolean>(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [lastPrompt, setLastPrompt] = useState<string>('');
|
||||||
|
|
||||||
const onDelete = async (session: ChatSession): Promise<void> => {
|
const onDelete = async (session: ChatSession): Promise<void> => {
|
||||||
if (!session.id) {
|
if (!session.id) {
|
||||||
@ -237,6 +109,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
|||||||
|
|
||||||
const messageContent = message;
|
const messageContent = message;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
|
setLastPrompt(message);
|
||||||
|
|
||||||
const chatMessage: ChatMessageUser = {
|
const chatMessage: ChatMessageUser = {
|
||||||
sessionId: chatSession.id,
|
sessionId: chatSession.id,
|
||||||
@ -267,6 +140,8 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
|||||||
msg.extraContext = {
|
msg.extraContext = {
|
||||||
isAnswer: true,
|
isAnswer: true,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
onResumeChange(lastPrompt, msg.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
@ -424,23 +299,22 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
|||||||
>
|
>
|
||||||
{message.role === 'user' && (
|
{message.role === 'user' && (
|
||||||
<>
|
<>
|
||||||
<PersonIcon />
|
<PersonIcon sx={{ mr: 1 }} />
|
||||||
<Typography>{message.content}</Typography>
|
<Typography>{message.content}</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{message.role === 'assistant' && (
|
{message.role === 'assistant' && (
|
||||||
<>
|
<>
|
||||||
{message.extraContext?.isAnswer ? (
|
{message.extraContext?.isAnswer ? (
|
||||||
<StyledMarkdown content={message.content} />
|
<>
|
||||||
|
<QuestionAnswerIcon sx={{ mr: 1 }} />
|
||||||
|
<StyledMarkdown content={message.content} />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EditViewer
|
<>
|
||||||
original={resume}
|
<PrecisionManufacturingIcon sx={{ mr: 1 }} />
|
||||||
content={message.content}
|
<StyledMarkdown content="Resume updated." />
|
||||||
onUpdate={(content: string): void => {
|
</>
|
||||||
onResumeChange && onResumeChange(content);
|
|
||||||
}}
|
|
||||||
sx={{ maxWidth: '100%', boxSizing: 'border-box' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -34,6 +34,8 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
maxHeight: '100%',
|
||||||
|
minHeight: 0, // Ensure it can shrink to fit content
|
||||||
// backgroundColor: '#F5F5F5',
|
// backgroundColor: '#F5F5F5',
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
|
@ -3,7 +3,6 @@ import React, { JSX, ReactElement, useEffect, useState } from 'react';
|
|||||||
import { Outlet, useLocation, Routes, Route, matchPath } from 'react-router-dom';
|
import { Outlet, useLocation, Routes, Route, matchPath } from 'react-router-dom';
|
||||||
import { Box, Container, Paper } from '@mui/material';
|
import { Box, Container, Paper } from '@mui/material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SxProps, Theme } from '@mui/material';
|
|
||||||
import { darken } from '@mui/material/styles';
|
import { darken } from '@mui/material/styles';
|
||||||
import { Header } from 'components/layout/Header';
|
import { Header } from 'components/layout/Header';
|
||||||
import { Scrollable } from 'components/Scrollable';
|
import { Scrollable } from 'components/Scrollable';
|
||||||
@ -23,12 +22,11 @@ export type NavigationLinkType = {
|
|||||||
|
|
||||||
interface BackstoryPageContainerProps {
|
interface BackstoryPageContainerProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
variant?: 'normal' | 'fullWidth';
|
variant?: 'normal' | 'fullWidth';
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => {
|
const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => {
|
||||||
const { children, sx, variant = 'normal' } = props;
|
const { children, variant = 'normal' } = props;
|
||||||
console.log({ variant });
|
console.log({ variant });
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
@ -43,13 +41,13 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element
|
|||||||
maxWidth: variant === 'normal' ? '1024px' : '100% !important',
|
maxWidth: variant === 'normal' ? '1024px' : '100% !important',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
...sx,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
p: { xs: 0, sm: 0.5 },
|
p: 0,
|
||||||
|
m: 0,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minHeight: 'min-content',
|
minHeight: 'min-content',
|
||||||
}}
|
}}
|
||||||
@ -60,7 +58,7 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
m: 0,
|
m: 0,
|
||||||
p: 0.5,
|
p: 0,
|
||||||
minHeight: 'min-content',
|
minHeight: 'min-content',
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: 'background.paper',
|
||||||
borderRadius: 0.5,
|
borderRadius: 0.5,
|
||||||
|
@ -194,8 +194,10 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
|||||||
|
|
||||||
{candidate.location && (
|
{candidate.location && (
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<strong>Location:</strong> {candidate.location.city},{' '}
|
<strong>Location:</strong>{' '}
|
||||||
{candidate.location.state || candidate.location.country}
|
{candidate.location.city
|
||||||
|
? `${candidate.location.city}, ${candidate.location.state}`
|
||||||
|
: candidate.location.text}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{candidate.email && (
|
{candidate.email && (
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -24,13 +24,17 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Theme,
|
|
||||||
Chip,
|
Chip,
|
||||||
Alert,
|
Alert,
|
||||||
Stack,
|
Stack,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import PrintIcon from '@mui/icons-material/Print';
|
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 {
|
import {
|
||||||
Delete as DeleteIcon,
|
Delete as DeleteIcon,
|
||||||
Restore as RestoreIcon,
|
Restore as RestoreIcon,
|
||||||
@ -41,19 +45,15 @@ import {
|
|||||||
Person as PersonIcon,
|
Person as PersonIcon,
|
||||||
Schedule as ScheduleIcon,
|
Schedule as ScheduleIcon,
|
||||||
ModelTraining,
|
ModelTraining,
|
||||||
Email as EmailIcon,
|
|
||||||
Phone as PhoneIcon,
|
|
||||||
LocationOn as LocationIcon,
|
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
RestoreFromTrash as RestoreFromTrashIcon,
|
RestoreFromTrash as RestoreFromTrashIcon,
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
|
PrecisionManufacturing,
|
||||||
// Language as WebsiteIcon,
|
// Language as WebsiteIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import InputIcon from '@mui/icons-material/Input';
|
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import PreviewIcon from '@mui/icons-material/Preview';
|
import PreviewIcon from '@mui/icons-material/Preview';
|
||||||
import EditDocumentIcon from '@mui/icons-material/EditDocument';
|
import EditDocumentIcon from '@mui/icons-material/EditDocument';
|
||||||
import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
||||||
|
|
||||||
import { useReactToPrint } from 'react-to-print';
|
import { useReactToPrint } from 'react-to-print';
|
||||||
|
|
||||||
@ -63,12 +63,14 @@ import { StyledMarkdown } from 'components/StyledMarkdown';
|
|||||||
import { Resume } from 'types/types';
|
import { Resume } from 'types/types';
|
||||||
import { BackstoryTextField } from 'components/BackstoryTextField';
|
import { BackstoryTextField } from 'components/BackstoryTextField';
|
||||||
import { JobInfo } from './JobInfo';
|
import { JobInfo } from './JobInfo';
|
||||||
import './ResumeInfo.css';
|
|
||||||
import { Scrollable } from 'components/Scrollable';
|
import { Scrollable } from 'components/Scrollable';
|
||||||
import * as Types from 'types/types';
|
import * as Types from 'types/types';
|
||||||
import { StreamingOptions } from 'services/api-client';
|
import { StreamingOptions } from 'services/api-client';
|
||||||
import { StatusBox, StatusIcon } from './StatusIcon';
|
import { StatusBox, StatusIcon } from './StatusIcon';
|
||||||
import { ResumeChat } from 'components/ResumeChat';
|
import { ResumeChat } from 'components/ResumeChat';
|
||||||
|
import { DiffViewer } from 'components/DiffViewer';
|
||||||
|
import { ResumePreview, resumeStyles } from './ResumePreview';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface ResumeInfoProps {
|
interface ResumeInfoProps {
|
||||||
resume: Resume;
|
resume: Resume;
|
||||||
@ -88,457 +90,9 @@ interface ResumeRevision {
|
|||||||
jobId: string;
|
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 }}> </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 ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||||
const { setSnack } = useAppState();
|
const { setSnack } = useAppState();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { resume } = props;
|
const { resume } = props;
|
||||||
const { user, apiClient } = useAuth();
|
const { user, apiClient } = useAuth();
|
||||||
const { sx, variant = 'normal' } = props;
|
const { sx, variant = 'normal' } = props;
|
||||||
@ -551,6 +105,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
const [editContent, setEditContent] = useState<string>('');
|
const [editContent, setEditContent] = useState<string>('');
|
||||||
const [editSystemPrompt, setEditSystemPrompt] = useState<string>('');
|
const [editSystemPrompt, setEditSystemPrompt] = useState<string>('');
|
||||||
const [editPrompt, setEditPrompt] = useState<string>('');
|
const [editPrompt, setEditPrompt] = useState<string>('');
|
||||||
|
const [lastRevision, setLastRevision] = useState<Resume | null>(null);
|
||||||
const [saving, setSaving] = useState<boolean>(false);
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
const [tabValue, setTabValue] = useState('markdown');
|
const [tabValue, setTabValue] = useState('markdown');
|
||||||
const [jobTabValue, setJobTabValue] = useState('chat');
|
const [jobTabValue, setJobTabValue] = useState('chat');
|
||||||
@ -630,6 +185,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
// Handle revision selection
|
// Handle revision selection
|
||||||
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
|
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
|
||||||
const newRevisionId = event.target.value;
|
const newRevisionId = event.target.value;
|
||||||
|
if (selectedRevision === 'current') {
|
||||||
|
setLastRevision(activeResume);
|
||||||
|
}
|
||||||
setSelectedRevision(newRevisionId);
|
setSelectedRevision(newRevisionId);
|
||||||
await loadRevisionContent(newRevisionId);
|
await loadRevisionContent(newRevisionId);
|
||||||
};
|
};
|
||||||
@ -638,6 +196,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
const restoreRevision = (): void => {
|
const restoreRevision = (): void => {
|
||||||
setEditContent(revisionContent);
|
setEditContent(revisionContent);
|
||||||
setSelectedRevision('current');
|
setSelectedRevision('current');
|
||||||
|
setLastRevision(null);
|
||||||
setSnack('Revision restored to editor.');
|
setSnack('Revision restored to editor.');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -674,14 +233,14 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
setActiveResume({ ...resume });
|
setActiveResume({ ...resume });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (prompt = ''): Promise<void> => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const resumeUpdate = {
|
const resumeUpdate = {
|
||||||
...activeResume,
|
...activeResume,
|
||||||
resume: editContent,
|
resume: editContent,
|
||||||
systemPrompt: editSystemPrompt,
|
systemPrompt: editSystemPrompt,
|
||||||
prompt: editPrompt,
|
prompt: prompt || editPrompt,
|
||||||
};
|
};
|
||||||
const result = await apiClient.updateResume(resumeUpdate);
|
const result = await apiClient.updateResume(resumeUpdate);
|
||||||
console.log('Resume updated:', result);
|
console.log('Resume updated:', result);
|
||||||
@ -705,6 +264,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
setEditSystemPrompt(activeResume.systemPrompt || '');
|
setEditSystemPrompt(activeResume.systemPrompt || '');
|
||||||
setEditPrompt(activeResume.prompt || '');
|
setEditPrompt(activeResume.prompt || '');
|
||||||
setSelectedRevision('current');
|
setSelectedRevision('current');
|
||||||
|
setLastRevision(null);
|
||||||
setRevisionContent(activeResume.resume);
|
setRevisionContent(activeResume.resume);
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
@ -773,6 +333,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
|
|
||||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
|
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
|
||||||
if (newValue === 'print') {
|
if (newValue === 'print') {
|
||||||
|
console.log('Printing resume...');
|
||||||
reactToPrintFn();
|
reactToPrintFn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -878,8 +439,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Job ID: {activeResume.job?.id}
|
Job ID: {activeResume.job?.id}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography
|
||||||
Resume ID: {activeResume.id}
|
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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@ -1042,14 +615,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
|
|
||||||
<Tabs value={tabValue} onChange={handleTabChange}>
|
<Tabs value={tabValue} onChange={handleTabChange}>
|
||||||
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
|
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
|
||||||
{activeResume.systemPrompt && (
|
{lastRevision && <Tab value="diff" icon={<DifferenceIcon />} label="Changes" />}
|
||||||
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
|
|
||||||
)}
|
|
||||||
{activeResume.systemPrompt && (
|
|
||||||
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
|
|
||||||
)}
|
|
||||||
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
||||||
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
||||||
|
{activeResume.systemPrompt && (
|
||||||
|
<Tab value="context" icon={<TuneIcon />} label="Context" />
|
||||||
|
)}
|
||||||
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
|
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
@ -1162,16 +733,29 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{selectedRevision !== 'current' && (
|
{selectedRevision !== 'current' && (
|
||||||
<Tooltip title="Restore this revision to editor">
|
<>
|
||||||
<Button
|
<Tooltip title="See changes against current version">
|
||||||
size="small"
|
<Button
|
||||||
startIcon={<RestoreFromTrashIcon />}
|
size="small"
|
||||||
onClick={restoreRevision}
|
startIcon={<PrecisionManufacturing />}
|
||||||
disabled={loadingRevision}
|
onClick={() => setTabValue('diff')}
|
||||||
>
|
disabled={loadingRevision}
|
||||||
Restore
|
>
|
||||||
</Button>
|
Changes
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Restore this revision to editor">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<RestoreFromTrashIcon />}
|
||||||
|
onClick={restoreRevision}
|
||||||
|
disabled={loadingRevision}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@ -1213,6 +797,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
fontSize: '12px',
|
||||||
}}
|
}}
|
||||||
placeholder="Enter resume content..."
|
placeholder="Enter resume content..."
|
||||||
/>
|
/>
|
||||||
@ -1240,22 +827,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tabValue === 'systemPrompt' && (
|
{tabValue === 'context' && (
|
||||||
<BackstoryTextField
|
<StyledMarkdown content={activeResume.systemPrompt || ''} />
|
||||||
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 === 'prompt' && (
|
{tabValue === 'prompt' && (
|
||||||
<BackstoryTextField
|
<BackstoryTextField
|
||||||
@ -1275,50 +848,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
placeholder="Edit prompt..."
|
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
|
<Box
|
||||||
className="document-container"
|
className="document-container"
|
||||||
ref={printContentRef}
|
ref={printContentRef}
|
||||||
sx={currentStyle.contentStyle}
|
sx={currentStyle.contentStyle}
|
||||||
>
|
>
|
||||||
<Box
|
<ResumePreview resume={activeResume} selectedStyle={selectedStyle} />
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
@ -1363,14 +906,22 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
<ResumeChat
|
<ResumeChat
|
||||||
session={activeResume.id || ''}
|
session={activeResume.id || ''}
|
||||||
resume={editContent}
|
resume={editContent}
|
||||||
onResumeChange={(newResume: string): void => {
|
onResumeChange={(prompt: string, newResume: string): void => {
|
||||||
setEditContent(newResume);
|
if (newResume !== editContent) {
|
||||||
setActiveResume({ ...activeResume, resume: newResume });
|
handleSave(prompt).then(() => {
|
||||||
|
setEditContent(newResume);
|
||||||
|
setLastRevision(activeResume);
|
||||||
|
setActiveResume({ ...activeResume, prompt, resume: newResume });
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
m: 1,
|
m: 1,
|
||||||
p: 1,
|
p: 1,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
position: 'relative',
|
||||||
|
maxWidth: 'fit-content',
|
||||||
|
minWidth: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1381,7 +932,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button>
|
<Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={() => {
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
startIcon={<SaveIcon />}
|
startIcon={<SaveIcon />}
|
||||||
|
@ -39,6 +39,7 @@ import { useAuth } from 'hooks/AuthContext';
|
|||||||
import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists
|
import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Resume } from 'types/types';
|
import { Resume } from 'types/types';
|
||||||
|
import { formatDate } from 'utils/formatDate';
|
||||||
|
|
||||||
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
|
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
@ -214,16 +215,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
|
|
||||||
const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder);
|
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 => {
|
const getSortIcon = (field: SortField): JSX.Element => {
|
||||||
if (sortField !== field) return <></>;
|
if (sortField !== field) return <></>;
|
||||||
return sortOrder === 'asc' ? (
|
return sortOrder === 'asc' ? (
|
||||||
@ -436,7 +427,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
noWrap
|
noWrap
|
||||||
sx={{ display: 'block', fontSize: '0.7rem' }}
|
sx={{ display: 'block', fontSize: '0.7rem' }}
|
||||||
>
|
>
|
||||||
{formatDate(resume.updatedAt)}
|
{formatDate(resume.updatedAt, isMobile, isSmall)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -469,7 +460,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<TableCell sx={{ py: 0.5, px: 1 }}>
|
<TableCell sx={{ py: 0.5, px: 1 }}>
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||||
{formatDate(resume.updatedAt)}
|
{formatDate(resume.updatedAt, isMobile, isSmall)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{resume.createdAt && (
|
{resume.createdAt && (
|
||||||
<Typography
|
<Typography
|
||||||
@ -477,7 +468,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
|||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ display: 'block', fontSize: '0.7rem' }}
|
sx={{ display: 'block', fontSize: '0.7rem' }}
|
||||||
>
|
>
|
||||||
Created: {formatDate(resume.createdAt)}
|
Created: {formatDate(resume.createdAt, isMobile, isSmall)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@ -61,7 +61,8 @@ export const navigationConfig: NavigationConfig = {
|
|||||||
{
|
{
|
||||||
id: 'chat',
|
id: 'chat',
|
||||||
label: 'Candidate Chat',
|
label: 'Candidate Chat',
|
||||||
path: '/chat',
|
path: '/chat/:resumeId?',
|
||||||
|
variant: 'fullWidth',
|
||||||
icon: <ChatIcon />,
|
icon: <ChatIcon />,
|
||||||
component: <CandidateChatPage />,
|
component: <CandidateChatPage />,
|
||||||
userTypes: ['guest', 'candidate', 'employer'],
|
userTypes: ['guest', 'candidate', 'employer'],
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
|
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 { Send as SendIcon } from '@mui/icons-material';
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import {
|
import {
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ChatMessageStatus,
|
ChatMessageStatus,
|
||||||
ChatMessageMetaData,
|
ChatMessageMetaData,
|
||||||
CandidateQuestion,
|
CandidateQuestion,
|
||||||
|
Resume,
|
||||||
} from 'types/types';
|
} from 'types/types';
|
||||||
import { ConversationHandle } from 'components/Conversation';
|
import { ConversationHandle } from 'components/Conversation';
|
||||||
import { Message } from 'components/Message';
|
import { Message } from 'components/Message';
|
||||||
@ -22,6 +23,9 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
|
|||||||
import { BackstoryQuery } from 'components/BackstoryQuery';
|
import { BackstoryQuery } from 'components/BackstoryQuery';
|
||||||
import { CandidatePicker } from 'components/ui/CandidatePicker';
|
import { CandidatePicker } from 'components/ui/CandidatePicker';
|
||||||
import { Scrollable } from 'components/Scrollable';
|
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 = {
|
const emptyMetadata: ChatMessageMetaData = {
|
||||||
model: 'qwen2.5',
|
model: 'qwen2.5',
|
||||||
@ -55,6 +59,8 @@ interface CandidateChatPageProps {
|
|||||||
|
|
||||||
const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>(
|
const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>(
|
||||||
(props: CandidateChatPageProps, ref): JSX.Element => {
|
(props: CandidateChatPageProps, ref): JSX.Element => {
|
||||||
|
const { resumeId } = useParams<{ resumeId?: string }>();
|
||||||
|
const [resume, setResume] = useState<Resume | null>(null);
|
||||||
const { sx } = props;
|
const { sx } = props;
|
||||||
const { apiClient } = useAuth();
|
const { apiClient } = useAuth();
|
||||||
const { selectedCandidate } = useSelectedCandidate();
|
const { selectedCandidate } = useSelectedCandidate();
|
||||||
@ -72,6 +78,19 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
|
|||||||
const [streaming, setStreaming] = useState<boolean>(false);
|
const [streaming, setStreaming] = useState<boolean>(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
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> => {
|
const onDelete = async (session: ChatSession): Promise<void> => {
|
||||||
if (!session.id) {
|
if (!session.id) {
|
||||||
return;
|
return;
|
||||||
@ -101,6 +120,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
|
|||||||
status: 'done',
|
status: 'done',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
extraContext: {
|
||||||
|
candidateId: resume?.job?.id,
|
||||||
|
jobId: resume?.job?.id || '',
|
||||||
|
resumeId: resume?.id || '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
setProcessingMessage({
|
setProcessingMessage({
|
||||||
@ -176,8 +200,10 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
|
|||||||
apiClient
|
apiClient
|
||||||
.getOrCreateChatSession(
|
.getOrCreateChatSession(
|
||||||
selectedCandidate,
|
selectedCandidate,
|
||||||
`Backstory chat with ${selectedCandidate.fullName}`,
|
resumeId
|
||||||
'candidate_chat'
|
? `Backstory Resume chat ${resumeId}`
|
||||||
|
: `Backstory Chat with ${selectedCandidate.fullName}`,
|
||||||
|
resumeId ? 'resume_chat' : 'candidate_chat'
|
||||||
)
|
)
|
||||||
.then(session => {
|
.then(session => {
|
||||||
setChatSession(session);
|
setChatSession(session);
|
||||||
@ -222,7 +248,12 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
timestamp: new Date(),
|
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,
|
metadata: emptyMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -235,139 +266,166 @@ const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'row',
|
||||||
height: '100%' /* Restrict to main-container's height */,
|
height: '100%' /* Restrict to main-container's height */,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: 0 /* Prevent flex overflow */,
|
minHeight: 0 /* Prevent flex overflow */,
|
||||||
maxHeight: 'min-content',
|
maxHeight: '100%',
|
||||||
'& > *:not(.Scrollable)': {
|
'& > *:not(.Scrollable)': {
|
||||||
flexShrink: 0 /* Prevent shrinking */,
|
flexShrink: 0 /* Prevent shrinking */,
|
||||||
},
|
},
|
||||||
position: 'relative',
|
|
||||||
...sx,
|
...sx,
|
||||||
|
p: 0,
|
||||||
|
m: 0,
|
||||||
|
backgroundColor: '#D3CDBF' /* Warm Gray */,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
|
{resume && (
|
||||||
<CandidateInfo
|
<Box
|
||||||
key={selectedCandidate.username}
|
|
||||||
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
|
|
||||||
elevation={4}
|
|
||||||
candidate={selectedCandidate}
|
|
||||||
variant="normal"
|
|
||||||
sx={{
|
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',
|
display: 'flex',
|
||||||
flexGrow: 1,
|
flexDirection: 'column',
|
||||||
flex: 1 /* Take remaining space in some-container */,
|
width: '50%',
|
||||||
overflowY: 'auto' /* Scroll if content overflows */,
|
maxHeight: '100%',
|
||||||
pt: 2,
|
p: 0,
|
||||||
pl: 1,
|
m: 0,
|
||||||
pr: 1,
|
position: 'relative',
|
||||||
pb: 2,
|
backgroundColor: '#f5f5f5',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
|
<Box
|
||||||
{messages.map((message: ChatMessage) => (
|
sx={{
|
||||||
<Message key={message.id} {...{ chatSession, message }} />
|
position: 'relative',
|
||||||
))}
|
display: 'flex',
|
||||||
{processingMessage !== null && (
|
justifyContent: 'center',
|
||||||
<Message {...{ chatSession, message: processingMessage }} />
|
p: 1,
|
||||||
)}
|
|
||||||
{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',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<strong>{resume.job?.title}</strong> at
|
||||||
variant="contained"
|
<strong>{resume.job?.company}</strong>. Last updated{' '}
|
||||||
onClick={(): void => {
|
{formatDate(resume.updatedAt, false, true)}.
|
||||||
sendMessage(
|
</Box>
|
||||||
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ''
|
<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
|
||||||
</Button>
|
variant="contained"
|
||||||
</span>
|
onClick={(): void => {
|
||||||
</Tooltip>
|
sendMessage(
|
||||||
|
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={streaming || loading}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -752,12 +752,12 @@ class ApiClient {
|
|||||||
}>(response);
|
}>(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}`, {
|
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
|
||||||
headers: this.defaultHeaders,
|
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 }> {
|
async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// Generated TypeScript types from Pydantic models
|
// Generated TypeScript types from Pydantic models
|
||||||
// Source: src/backend/models.py
|
// 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
|
// 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 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";
|
export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error";
|
||||||
|
|
||||||
@ -284,7 +284,7 @@ export interface Certification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatContext {
|
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;
|
relatedEntityId?: string;
|
||||||
relatedEntityType?: "job" | "candidate" | "employer";
|
relatedEntityType?: "job" | "candidate" | "employer";
|
||||||
additionalContext?: Record<string, any>;
|
additionalContext?: Record<string, any>;
|
||||||
|
11
frontend/src/utils/formatDate.tsx
Normal file
11
frontend/src/utils/formatDate.tsx
Normal 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 };
|
@ -185,6 +185,7 @@ class ChatContextType(str, Enum):
|
|||||||
JOB_REQUIREMENTS = "job_requirements"
|
JOB_REQUIREMENTS = "job_requirements"
|
||||||
CANDIDATE_CHAT = "candidate_chat"
|
CANDIDATE_CHAT = "candidate_chat"
|
||||||
INTERVIEW_PREP = "interview_prep"
|
INTERVIEW_PREP = "interview_prep"
|
||||||
|
RESUME_CHAT = "resume_chat"
|
||||||
RESUME_REVIEW = "resume_review"
|
RESUME_REVIEW = "resume_review"
|
||||||
EDIT_RESUME = "edit_resume"
|
EDIT_RESUME = "edit_resume"
|
||||||
GENERAL = "general"
|
GENERAL = "general"
|
||||||
|
@ -72,6 +72,9 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
self.md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
|
self.md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
|
||||||
self.processing_lock = asyncio.Lock()
|
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')
|
# self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
|
||||||
|
|
||||||
# Path for storing file hash state
|
# Path for storing file hash state
|
||||||
@ -220,7 +223,17 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
return files_processed
|
return files_processed
|
||||||
|
|
||||||
async def process_file_update(self, file_path):
|
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
|
# Use a lock to make the check-and-add atomic
|
||||||
async with self.processing_lock:
|
async with self.processing_lock:
|
||||||
@ -240,15 +253,16 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
if not current_hash:
|
if not current_hash:
|
||||||
return
|
return
|
||||||
|
|
||||||
if file_path in self.file_hashes and self.file_hashes[file_path] == current_hash:
|
# Use the update_lock to make hash check and update atomic
|
||||||
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
|
|
||||||
async with self.update_lock:
|
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)
|
await self._update_document_in_collection(file_path)
|
||||||
|
|
||||||
# Save the hash state after successful update
|
# Save the hash state after successful update
|
||||||
@ -488,23 +502,31 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
logging.error(f"Error hashing file {file_path}: {e}")
|
logging.error(f"Error hashing file {file_path}: {e}")
|
||||||
return None
|
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):
|
def on_modified(self, event):
|
||||||
"""Handle file modification events."""
|
"""Handle file modification events."""
|
||||||
if event.is_directory:
|
if event.is_directory or not self._should_process_file(event.src_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
file_path = event.src_path
|
file_path = event.src_path
|
||||||
# Schedule the update using asyncio
|
|
||||||
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
|
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
|
||||||
logging.info(f"File modified: {file_path}")
|
logging.info(f"File modified: {file_path}")
|
||||||
|
|
||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
"""Handle file creation events."""
|
"""Handle file creation events."""
|
||||||
if event.is_directory:
|
if event.is_directory or not self._should_process_file(event.src_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
file_path = event.src_path
|
file_path = event.src_path
|
||||||
# Schedule the update using asyncio
|
|
||||||
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
|
asyncio.run_coroutine_threadsafe(self.process_file_update(file_path), self.loop)
|
||||||
logging.info(f"File created: {file_path}")
|
logging.info(f"File created: {file_path}")
|
||||||
|
|
||||||
@ -550,13 +572,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
if file_path.endswith(extensions):
|
if file_path.endswith(extensions):
|
||||||
p = Path(file_path)
|
p = Path(file_path)
|
||||||
p_as_md = p.with_suffix(".md")
|
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,
|
# If file_path.md doesn't exist or file_path is newer than file_path.md,
|
||||||
# fire off markitdown
|
# fire off markitdown
|
||||||
if (not p_as_md.exists()) or (p.stat().st_mtime > p_as_md.stat().st_mtime):
|
if (not p_as_md.exists()) or (p.stat().st_mtime > p_as_md.stat().st_mtime):
|
||||||
self._markitdown(file_path, p_as_md)
|
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
|
return
|
||||||
|
|
||||||
chunks = self._markdown_chunker.process_file(file_path)
|
chunks = self._markdown_chunker.process_file(file_path)
|
||||||
|
@ -147,11 +147,17 @@ async def get_resume(
|
|||||||
):
|
):
|
||||||
"""Get a specific resume by ID"""
|
"""Get a specific resume by ID"""
|
||||||
try:
|
try:
|
||||||
resume = await database.get_resume(current_user.id, resume_id)
|
resume_data = await database.get_resume(current_user.id, resume_id)
|
||||||
if not resume:
|
if not resume_data:
|
||||||
raise HTTPException(status_code=404, detail="Resume not found")
|
raise HTTPException(status_code=404, detail="Resume not found")
|
||||||
|
resume = Resume.model_validate(resume_data)
|
||||||
return {"success": True, "resume": resume}
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user