diff --git a/Dockerfile b/Dockerfile index 1631c90..c0a9899 100644 --- a/Dockerfile +++ b/Dockerfile @@ -287,6 +287,10 @@ RUN { \ echo ' sleep 3'; \ echo ' done &' ; \ echo ' fi' ; \ + echo ' if [[ ! -e src/cert.pem ]]; then' ; \ + echo ' echo "Generating self-signed certificate for HTTPS"'; \ + echo ' openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout src/key.pem -out src/cert.pem -subj "/C=US/ST=OR/L=Portland/O=Development/CN=localhost"'; \ + echo ' fi' ; \ echo ' while true; do'; \ echo ' echo "Launching Backstory server..."'; \ echo ' python src/server.py "${@}" || echo "Backstory server died. Restarting in 3 seconds."'; \ diff --git a/frontend/craco.config.js b/frontend/craco.config.js new file mode 100644 index 0000000..eff42a6 --- /dev/null +++ b/frontend/craco.config.js @@ -0,0 +1,12 @@ +module.exports = { + devServer: { + server: { + type: 'https', + // You can also specify custom certificates if needed: + // options: { + // cert: '/path/to/cert.pem', + // key: '/path/to/key.pem', + // } + } + } +}; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index be6d408..890e276 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@craco/craco": "^7.1.0", "@types/plotly.js": "^2.35.5" } }, @@ -1975,6 +1976,52 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "peer": true }, + "node_modules/@craco/craco": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@craco/craco/-/craco-7.1.0.tgz", + "integrity": "sha512-oRAcPIKYrfPXp9rSzlsDNeOaVtDiKhoyqSXUoqiK24jCkHr4T8m/a2f74yXIzCbIheoUWDOIfWZyRgFgT+cpqA==", + "dev": true, + "dependencies": { + "autoprefixer": "^10.4.12", + "cosmiconfig": "^7.0.1", + "cosmiconfig-typescript-loader": "^1.0.0", + "cross-spawn": "^7.0.3", + "lodash": "^4.17.21", + "semver": "^7.3.7", + "webpack-merge": "^5.8.0" + }, + "bin": { + "craco": "dist/bin/craco.js" + }, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "react-scripts": "^5.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/normalize.css": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", @@ -4379,6 +4426,30 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "devOptional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true + }, "node_modules/@turf/area": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", @@ -6780,6 +6851,20 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -7222,12 +7307,37 @@ "node": ">=10" } }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-1.0.9.tgz", + "integrity": "sha512-tRuMRhxN4m1Y8hP9SNYfz7jRwt8lZdWxdjg/ohg5esKmsndJIn4yT96oJVcf5x0eA11taXl+sIp+ielu529k6g==", + "dev": true, + "dependencies": { + "cosmiconfig": "^7", + "ts-node": "^10.7.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=7", + "typescript": ">=3" + } + }, "node_modules/country-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", "peer": true }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8151,6 +8261,15 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -9919,6 +10038,15 @@ "node": ">=8" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -11958,6 +12086,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -12155,6 +12295,15 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -13590,6 +13739,12 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -18877,6 +19032,18 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/shallow-copy": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", @@ -20501,6 +20668,67 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "devOptional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -21095,6 +21323,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -21436,6 +21670,20 @@ "node": ">=10.13.0" } }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/webpack-sources": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", @@ -21622,6 +21870,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, "node_modules/window-size": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", @@ -22085,6 +22339,15 @@ "node": ">=10" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ff0083e..0727517 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,6 @@ "@mui/icons-material": "^7.0.1", "@mui/material": "^7.0.1", "@tensorflow/tfjs": "^4.22.0", - "@tensorflow/tfjs-backend-webgl": "^4.22.0", "@tensorflow/tfjs-tsne": "^0.2.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -29,15 +28,13 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "tsne-js": "^1.0.3", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "start": "HTTPS=true craco start", + "build": "craco build", + "test": "craco test" }, "eslintConfig": { "extends": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72223f3..97daaa9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,24 +1,13 @@ -import React, { useState, useEffect, useRef, useCallback, ReactElement } from 'react'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import FormGroup from '@mui/material/FormGroup'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import useMediaQuery from '@mui/material/useMediaQuery'; import Card from '@mui/material/Card'; -import FormControlLabel from '@mui/material/FormControlLabel'; import { styled } from '@mui/material/styles'; import Avatar from '@mui/material/Avatar'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; -import Switch from '@mui/material/Switch'; -import Divider from '@mui/material/Divider'; import Tooltip from '@mui/material/Tooltip'; import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import TextField from '@mui/material/TextField'; -import Accordion from '@mui/material/Accordion'; -import AccordionActions from '@mui/material/AccordionActions'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; import AppBar from '@mui/material/AppBar'; import Drawer from '@mui/material/Drawer'; import Toolbar from '@mui/material/Toolbar'; @@ -26,20 +15,16 @@ import SettingsIcon from '@mui/icons-material/Settings'; import IconButton from '@mui/material/IconButton'; import Box from '@mui/material/Box'; import CssBaseline from '@mui/material/CssBaseline'; -import ResetIcon from '@mui/icons-material/History'; -import SendIcon from '@mui/icons-material/Send'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import MenuIcon from '@mui/icons-material/Menu'; -import PropagateLoader from "react-spinners/PropagateLoader"; import { ResumeBuilder } from './ResumeBuilder'; -import { Message, MessageList } from './Message'; +import { Message } from './Message'; import { MessageData } from './MessageMeta'; import { SeverityType } from './Snack'; -import { ContextStatus } from './ContextStatus'; import { VectorVisualizer } from './VectorVisualizer'; - +import { Controls } from './Controls'; +import { Conversation, ConversationHandle } from './Conversation'; import './App.css'; @@ -47,68 +32,7 @@ import '@fontsource/roboto/300.css'; import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; -import { MessageMetadata } from './MessageMeta'; -const welcomeMarkdown = ` -# Welcome to Backstory - -Backstory was written by James Ketrenos in order to provide answers to -questions potential employers may have about his work history. -You can ask things like: - - - - - - -You can click the text above to submit that query, or type it in yourself (or whatever questions you may have.) - -Backstory is a RAG enabled expert system with access to real-time data running self-hosted -(no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs). - -As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career, I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.`; - -const welcomeMessage: MessageData = { - "role": "assistant", "content": welcomeMarkdown -}; -const loadingMessage: MessageData = { "role": "assistant", "content": "Instancing chat session..." }; - -type Tool = { - type: string, - function?: { - name: string, - description: string, - parameters?: any, - returns?: any - }, - name?: string, - description?: string, - enabled: boolean -}; - -interface ControlsParams { - tools: Tool[], - rags: Tool[], - systemPrompt: string, - systemInfo: SystemInfo, - toggleTool: (tool: Tool) => void, - toggleRag: (tool: Tool) => void, - setSystemPrompt: (prompt: string) => void, - reset: (types: ("rags" | "tools" | "history" | "system-prompt" | "message-history-length")[], message: string) => Promise - messageHistoryLength: number, - setMessageHistoryLength: (messageHistoryLength: number) => void, -}; - -type GPUInfo = { - name: string, - memory: number, - discrete: boolean -} -type SystemInfo = { - "Installed RAM": string, - "Graphics Card": GPUInfo[], - "CPU": string -}; const getConnectionBase = (loc: any): string => { if (!loc.host.match(/.*battle-linux.*/)) { @@ -118,176 +42,6 @@ const getConnectionBase = (loc: any): string => { } } -const SystemInfoComponent: React.FC<{ systemInfo: SystemInfo }> = ({ systemInfo }) => { - const [systemElements, setSystemElements] = useState([]); - - const convertToSymbols = (text: string) => { - return text - .replace(/\(R\)/g, '®') // Replace (R) with the ® symbol - .replace(/\(C\)/g, '©') // Replace (C) with the © symbol - .replace(/\(TM\)/g, '™'); // Replace (TM) with the ™ symbol - }; - - useEffect(() => { - const elements = Object.entries(systemInfo).flatMap(([k, v]) => { - // If v is an array, repeat for each card - if (Array.isArray(v)) { - return v.map((card, index) => ( -
-
{convertToSymbols(k)} {index}
-
{convertToSymbols(card.name)} {card.discrete ? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM` : "(integrated)"}
-
- )); - } - - // If it's not an array, handle normally - return ( -
-
{convertToSymbols(k)}
-
{convertToSymbols(String(v))}
-
- ); - }); - - setSystemElements(elements); - }, [systemInfo]); - - return
{systemElements}
; -}; - -const Controls = ({ tools, rags, systemPrompt, toggleTool, toggleRag, messageHistoryLength, setMessageHistoryLength, setSystemPrompt, reset, systemInfo }: ControlsParams) => { - const [editSystemPrompt, setEditSystemPrompt] = useState(systemPrompt); - - useEffect(() => { - setEditSystemPrompt(systemPrompt); - }, [systemPrompt, setEditSystemPrompt]); - - const toggle = async (type: string, index: number) => { - switch (type) { - case "rag": - toggleRag(rags[index]) - break; - case "tool": - toggleTool(tools[index]); - } - }; - - const handleKeyPress = (event: any) => { - if (event.key === 'Enter' && event.ctrlKey) { - switch (event.target.id) { - case 'SystemPromptInput': - setSystemPrompt(editSystemPrompt); - break; - } - } - }; - - return (
- - You can change the information available to the LLM by adjusting the following settings: - - - - }> - System Prompt - - - setEditSystemPrompt(e.target.value)} - onKeyDown={handleKeyPress} - placeholder="Enter the new system prompt.." - id="SystemPromptInput" - /> -
- - -
-
-
- - }> - Tunables - - - setMessageHistoryLength(e.target.value)} - slotProps={{ - htmlInput: { - min: 0 - }, - inputLabel: { - shrink: true, - }, - }} - /> - - - - }> - Tools - - - These tools can be made available to the LLM for obtaining real-time information from the Internet. The description provided to the LLM is provided for reference. - - - - { - tools.map((tool, index) => - - - } onChange={() => toggle("tool", index)} label={tool?.function?.name} /> - {tool?.function?.description} - - ) - } - - - - }> - RAG - - - These RAG databases can be enabled / disabled for adding additional context based on the chat request. - - - - { - rags.map((rag, index) => - - - } onChange={() => toggle("rag", index)} label={rag?.name} /> - {rag?.description} - - ) - } - - - - }> - System Information - - - The server is running on the following hardware: - - - - - - - -
); -} - interface TabPanelProps { children?: React.ReactNode; @@ -312,16 +66,7 @@ function CustomTabPanel(props: TabPanelProps) { ); } -type Resume = { - resume: MessageData | undefined, - fact_check: MessageData | undefined, - job_description: string, - metadata: MessageMetadata -}; - const App = () => { - const [query, setQuery] = useState(''); - const [conversation, setConversation] = useState([]); const conversationRef = useRef(null); const [processing, setProcessing] = useState(false); const [sessionId, setSessionId] = useState(undefined); @@ -331,75 +76,13 @@ const App = () => { const [snackOpen, setSnackOpen] = useState(false); const [snackMessage, setSnackMessage] = useState(""); const [snackSeverity, setSnackSeverity] = useState("success"); - const [tools, setTools] = useState([]); - const [rags, setRags] = useState([]); - const [systemPrompt, setSystemPrompt] = useState(""); - const [serverSystemPrompt, setServerSystemPrompt] = useState(""); - const [systemInfo, setSystemInfo] = useState(undefined); - const [contextStatus, setContextStatus] = useState({ context_used: 0, max_context: 0 }); - const [contextWarningShown, setContextWarningShown] = useState(false); - const [contextUsedPercentage, setContextUsedPercentage] = useState(0); - const [lastEvalTPS, setLastEvalTPS] = useState(35); - const [lastPromptTPS, setLastPromptTPS] = useState(430); - const [countdown, setCountdown] = useState(0); - const [messageHistoryLength, setMessageHistoryLength] = useState(5); const [tab, setTab] = useState(0); const [about, setAbout] = useState(""); - const [jobDescription, setJobDescription] = useState(undefined); const [resume, setResume] = useState(undefined); const [facts, setFacts] = useState(undefined); - const timerRef = useRef(null); const isDesktop = useMediaQuery('(min-width:650px)'); const prevIsDesktopRef = useRef(isDesktop); - - const startCountdown = (seconds: number) => { - if (timerRef.current) clearInterval(timerRef.current); - setCountdown(seconds); - timerRef.current = setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - clearInterval(timerRef.current); - timerRef.current = null; - if (isScrolledToBottom()) { - setTimeout(() => { - scrollToBottom(); - }, 50) - } - return 0; - } - return prev - 1; - }); - }, 1000); - }; - - const stopCountdown = () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - setCountdown(0); - } - }; - - const isScrolledToBottom = useCallback(()=> { - // Current vertical scroll position - const scrollTop = window.scrollY || document.documentElement.scrollTop; - - // Total height of the page content - const scrollHeight = document.documentElement.scrollHeight; - - // Height of the visible window - const clientHeight = document.documentElement.clientHeight; - - // If we're at the bottom (allowing a small buffer of 16px) - return scrollTop + clientHeight >= scrollHeight - 16; - }, []); - - const scrollToBottom = useCallback(() => { - console.log("Scroll to bottom"); - window.scrollTo({ - top: document.body.scrollHeight, - }); - }, []); + const chatRef = useRef(null); // Set the snack pop-up and open it const setSnack = useCallback((message: string, severity: SeverityType = "success") => { @@ -419,27 +102,6 @@ const App = () => { prevIsDesktopRef.current = isDesktop; }, [isDesktop, setMenuOpen, menuOpen]) - // Get the system information - useEffect(() => { - if (systemInfo !== undefined || sessionId === undefined) { - return; - } - fetch(connectionBase + `/api/system-info/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(response => response.json()) - .then(data => { - setSystemInfo(data); - }) - .catch(error => { - console.error('Error obtaining system information:', error); - setSnack("Unable to obtain system information.", "error"); - }); - }, [systemInfo, setSystemInfo, connectionBase, setSnack, sessionId]) - // Get the About markdown useEffect(() => { if (about !== "") { @@ -467,50 +129,10 @@ const App = () => { fetchAbout(); }, [about, setAbout]) - // Update the context status - const updateContextStatus = useCallback(() => { - fetch(connectionBase + `/api/context-status/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(response => response.json()) - .then(data => { - setContextStatus(data); - }) - .catch(error => { - console.error('Error getting context status:', error); - setSnack("Unable to obtain context status.", "error"); - }); - }, [setContextStatus, connectionBase, setSnack, sessionId]); - // Set the initial chat history to "loading" or the welcome message if loaded. - useEffect(() => { - if (sessionId === undefined) { - setConversation([loadingMessage]); - } else { - fetch(connectionBase + `/api/history/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(response => response.json()) - .then(data => { - console.log(`Session id: ${sessionId} -- history returned from server with ${data.length} entries`) - setConversation([ - welcomeMessage, - ...data - ]); - }) - .catch(error => { - console.error('Error generating session ID:', error); - setSnack("Unable to obtain chat history.", "error"); - }); - updateContextStatus(); - } - }, [sessionId, setConversation, updateContextStatus, connectionBase, setSnack]); + const handleSubmitChatQuery = () => { + chatRef.current?.submitQuery(); + }; // Extract the sessionId from the URL if present, otherwise // request a sessionId from the server. @@ -540,295 +162,6 @@ const App = () => { }, [setSessionId, connectionBase]); - // If the systemPrompt has not been set, fetch it from the server - useEffect(() => { - if (serverSystemPrompt !== "" || sessionId === undefined) { - return; - } - const fetchTunables = async () => { - // Make the fetch request with proper headers - const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - }); - const data = await response.json(); - const serverSystemPrompt = data["system-prompt"].trim(); - setServerSystemPrompt(serverSystemPrompt); - setSystemPrompt(serverSystemPrompt); - setMessageHistoryLength(data["message-history-length"]); - } - - fetchTunables(); - }, [sessionId, serverSystemPrompt, setServerSystemPrompt, connectionBase]); - - // If the tools have not been set, fetch them from the server - useEffect(() => { - if (tools.length || sessionId === undefined) { - return; - } - const fetchTools = async () => { - try { - // Make the fetch request with proper headers - const response = await fetch(connectionBase + `/api/tools/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - }); - if (!response.ok) { - throw Error(); - } - const tools = await response.json(); - setTools(tools); - } catch (error: any) { - setSnack("Unable to fetch tools", "error"); - console.error(error); - } - } - - fetchTools(); - }, [sessionId, tools, setTools, setSnack, connectionBase]); - - // If the jobDescription and resume have not been set, fetch them from the server - useEffect(() => { - if (sessionId === undefined) { - return; - } - if (jobDescription !== "" || resume !== undefined) { - return; - } - const fetchResume = async () => { - try { - // Make the fetch request with proper headers - const response = await fetch(connectionBase + `/api/resume/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - }); - if (!response.ok) { - throw Error(); - } - const data: Resume[] = await response.json(); - if (data.length) { - const lastResume = data[data.length - 1]; - setJobDescription(lastResume['job_description']); - setResume(lastResume.resume); - if (lastResume['fact_check'] !== undefined) { - lastResume['fact_check'].role = 'info'; - setFacts(lastResume['fact_check']) - } else { - setFacts(undefined) - } - } - } catch (error: any) { - setSnack("Unable to fetch resume", "error"); - console.error(error); - } - } - - fetchResume(); - }, [sessionId, resume, jobDescription, setResume, setJobDescription, setSnack, connectionBase]); - - // If the RAGs have not been set, fetch them from the server - useEffect(() => { - if (rags.length || sessionId === undefined) { - return; - } - const fetchRags = async () => { - try { - // Make the fetch request with proper headers - const response = await fetch(connectionBase + `/api/rags/${sessionId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - }); - if (!response.ok) { - throw Error(); - } - const rags = await response.json(); - setRags(rags); - } catch (error: any) { - setSnack("Unable to fetch RAGs", "error"); - console.error(error); - } - } - - fetchRags(); - }, [sessionId, rags, setRags, setSnack, connectionBase]); - - // If context status changes, show a warning if necessary. If it drops - // back below the threshold, clear the warning trigger - useEffect(() => { - const context_used_percentage = Math.round(100 * contextStatus.context_used / contextStatus.max_context); - if (context_used_percentage >= 90 && !contextWarningShown) { - setSnack(`${context_used_percentage}% of context used. You may wish to start a new chat.`, "warning"); - setContextWarningShown(true); - } - if (context_used_percentage < 90 && contextWarningShown) { - setContextWarningShown(false); - } - setContextUsedPercentage(context_used_percentage) - }, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]); - - - const toggleRag = async (tool: Tool) => { - tool.enabled = !tool.enabled - try { - const response = await fetch(connectionBase + `/api/rags/${sessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ "tool": tool?.name, "enabled": tool.enabled }), - }); - - const rags = await response.json(); - setRags([...rags]) - setSnack(`${tool?.name} ${tool.enabled ? "enabled" : "disabled"}`); - } catch (error) { - console.error('Fetch error:', error); - setSnack(`${tool?.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error"); - tool.enabled = !tool.enabled - } - }; - - const toggleTool = async (tool: Tool) => { - tool.enabled = !tool.enabled - try { - const response = await fetch(connectionBase + `/api/tools/${sessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ "tool": tool?.function?.name, "enabled": tool.enabled }), - }); - - const tools = await response.json(); - setTools([...tools]) - setSnack(`${tool?.function?.name} ${tool.enabled ? "enabled" : "disabled"}`); - } catch (error) { - console.error('Fetch error:', error); - setSnack(`${tool?.function?.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error"); - tool.enabled = !tool.enabled - } - }; - - useEffect(() => { - if (systemPrompt === serverSystemPrompt || !systemPrompt.trim() || sessionId === undefined) { - return; - } - const sendSystemPrompt = async (prompt: string) => { - try { - const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ "system-prompt": prompt }), - }); - - const data = await response.json(); - const newPrompt = data["system-prompt"]; - if (newPrompt !== serverSystemPrompt) { - setServerSystemPrompt(newPrompt); - setSystemPrompt(newPrompt) - setSnack("System prompt updated", "success"); - } - } catch (error) { - console.error('Fetch error:', error); - setSnack("System prompt update failed", "error"); - } - }; - - sendSystemPrompt(systemPrompt); - - }, [systemPrompt, setServerSystemPrompt, serverSystemPrompt, connectionBase, sessionId, setSnack]); - - useEffect(() => { - if (sessionId === undefined) { - return; - } - const sendMessageHistoryLength = async (length: number) => { - try { - const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ "message-history-length": length }), - }); - - const data = await response.json(); - const newLength = data["message-history-length"]; - if (newLength !== messageHistoryLength) { - setMessageHistoryLength(newLength); - setSnack("Message history length updated", "success"); - } - } catch (error) { - console.error('Fetch error:', error); - setSnack("Message history length update failed", "error"); - } - }; - - sendMessageHistoryLength(messageHistoryLength); - - }, [messageHistoryLength, setMessageHistoryLength, connectionBase, sessionId, setSnack]); - - const reset = async (types: ("rags" | "tools" | "history" | "system-prompt" | "message-history-length")[], message: string = "Update successful.") => { - try { - const response = await fetch(connectionBase + `/api/reset/${sessionId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ "reset": types }), - }); - - if (response.ok) { - const data = await response.json(); - if (data.error) { - throw Error() - } - for (const [key, value] of Object.entries(data)) { - switch (key) { - case "rags": - setRags(value as Tool[]); - break; - case "tools": - setTools(value as Tool[]); - break; - case "system-prompt": - setServerSystemPrompt((value as any)["system-prompt"].trim()); - setSystemPrompt((value as any)["system-prompt"].trim()); - break; - case "history": - setConversation([welcomeMessage]); - break; - } - } - setSnack(message, "success"); - } else { - throw Error(`${{ status: response.status, message: response.statusText }}`); - } - } catch (error) { - console.error('Fetch error:', error); - setSnack("Unable to restore defaults", "error"); - } - }; - const handleMenuClose = () => { setIsMenuClosing(true); setMenuOpen(false); @@ -846,8 +179,8 @@ const App = () => { const settingsPanel = ( <> - {sessionId !== undefined && systemInfo !== undefined && - } + {sessionId !== undefined && + } ); @@ -900,200 +233,6 @@ const App = () => { ); - const submitQuery = (text: string) => { - sendQuery(text); - } - - const handleKeyPress = (event: any) => { - if (event.key === 'Enter') { - switch (event.target.id) { - case 'QueryInput': - sendQuery(query); - break; - } - } - }; - - const sendQuery = async (query: string) => { - if (!query.trim()) return; - - setTab(0); - - const userMessage: MessageData[] = [{ role: 'user', content: query }]; - - let scrolledToBottom; - - // Add user message to conversation - const newConversation: MessageList = [ - ...conversation, - ...userMessage - ]; - setConversation(newConversation); - scrollToBottom(); - - // Clear input - setQuery(''); - - try { - scrolledToBottom = isScrolledToBottom(); - setProcessing(true); - // Create a unique ID for the processing message - const processingId = Date.now().toString(); - - // Add initial processing message - setConversation(prev => [ - ...prev, - { role: 'assistant', content: 'Processing request...', id: processingId, isProcessing: true } - ]); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - - // Make the fetch request with proper headers - const response = await fetch(connectionBase + `/api/chat/${sessionId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ role: 'user', content: query.trim() }), - }); - - // We'll guess that the response will be around 500 tokens... - const token_guess = 500; - const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS); - - scrolledToBottom = isScrolledToBottom(); - setSnack(`Query sent. Response estimated in ${estimate}s.`, "info"); - startCountdown(Math.round(estimate)); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - - if (!response.ok) { - throw new Error(`Server responded with ${response.status}: ${response.statusText}`); - } - - if (!response.body) { - throw new Error('Response body is null'); - } - - // Set up stream processing with explicit chunking - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - const chunk = decoder.decode(value, { stream: true }); - - // Process each complete line immediately - buffer += chunk; - let lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep incomplete line in buffer - for (const line of lines) { - if (!line.trim()) continue; - - try { - const update = JSON.parse(line); - - // Force an immediate state update based on the message type - if (update.status === 'processing') { - scrolledToBottom = isScrolledToBottom(); - // Update processing message with immediate re-render - setConversation(prev => prev.map(msg => - msg.id === processingId - ? { ...msg, content: update.message } - : msg - )); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - - // Add a small delay to ensure React has time to update the UI - await new Promise(resolve => setTimeout(resolve, 0)); - - } else if (update.status === 'done') { - // Replace processing message with final result - scrolledToBottom = isScrolledToBottom(); - setConversation(prev => [ - ...prev.filter(msg => msg.id !== processingId), - update.message - ]); - const metadata = update.message.metadata; - const evalTPS = metadata.eval_count * 10 ** 9 / metadata.eval_duration; - const promptTPS = metadata.prompt_eval_count * 10 ** 9 / metadata.prompt_eval_duration; - setLastEvalTPS(evalTPS ? evalTPS : 35); - setLastPromptTPS(promptTPS ? promptTPS : 35); - updateContextStatus(); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - } else if (update.status === 'error') { - // Show error - scrolledToBottom = isScrolledToBottom(); - setConversation(prev => [ - ...prev.filter(msg => msg.id !== processingId), - { role: 'assistant', type: 'error', content: update.message } - ]); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - } - } catch (e) { - setSnack("Error processing query", "error") - console.error('Error parsing JSON:', e, line); - } - } - } - - // Process any remaining buffer content - if (buffer.trim()) { - try { - const update = JSON.parse(buffer); - - if (update.status === 'done') { - scrolledToBottom = isScrolledToBottom(); - setConversation(prev => [ - ...prev.filter(msg => msg.id !== processingId), - update.message - ]); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 500); - } - } - } catch (e) { - setSnack("Error processing query", "error") - } - } - - scrolledToBottom = isScrolledToBottom(); - stopCountdown(); - setProcessing(false); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - } catch (error) { - console.error('Fetch error:', error); - setSnack("Unable to process query", "error"); - scrolledToBottom = isScrolledToBottom(); - setConversation(prev => [ - ...prev.filter(msg => !msg.isProcessing), - { role: 'assistant', type: 'error', content: `Error: ${error}` } - ]); - setProcessing(false); - stopCountdown(); - if (scrolledToBottom) { - setTimeout(() => { scrollToBottom() }, 50); - } - } - }; - - const handleSnackClose = ( event: React.SyntheticEvent | Event, reason?: SnackbarCloseReason, @@ -1225,61 +364,21 @@ const App = () => { - - {conversation.map((message, index) => )} - - - {processing === true && countdown > 0 && ( - Estimated response time: {countdown}s - )} - - - Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context} - { - contextUsedPercentage >= 90 ? WARNING: Context almost exhausted. You should start a new chat. - : (contextUsedPercentage >= 50 ? NOTE: Context is getting long. Queries will be slower, and the LLM may stop issuing tool calls. - : <>) - } - - - - setQuery(e.target.value)} - onKeyDown={handleKeyPress} - placeholder="Enter your question..." - id="QueryInput" - /> - - - - + - + @@ -1293,7 +392,7 @@ const App = () => { - + diff --git a/frontend/src/ChatBubble.tsx b/frontend/src/ChatBubble.tsx index 110bf24..befdcd3 100644 --- a/frontend/src/ChatBubble.tsx +++ b/frontend/src/ChatBubble.tsx @@ -10,9 +10,10 @@ interface ChatBubbleProps { isFullWidth?: boolean; children: React.ReactNode; sx?: SxProps; + className?: string; } -function ChatBubble({ role, isFullWidth, children, sx }: ChatBubbleProps) { +function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubbleProps) { const theme = useTheme(); const styles = { @@ -76,7 +77,7 @@ function ChatBubble({ role, isFullWidth, children, sx }: ChatBubbleProps) { }; return ( - + {children} ); diff --git a/frontend/src/Controls.tsx b/frontend/src/Controls.tsx new file mode 100644 index 0000000..1507cfd --- /dev/null +++ b/frontend/src/Controls.tsx @@ -0,0 +1,495 @@ +import React, { useState, useEffect, ReactElement } from 'react'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; +import Divider from '@mui/material/Divider'; +import TextField from '@mui/material/TextField'; +import Accordion from '@mui/material/Accordion'; +import AccordionActions from '@mui/material/AccordionActions'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import ResetIcon from '@mui/icons-material/History'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +import { SeverityType } from './Snack'; + +type Tool = { + type: string, + function?: { + name: string, + description: string, + parameters?: any, + returns?: any + }, + name?: string, + description?: string, + enabled: boolean +}; + +interface ControlsParams { + connectionBase: string, + sessionId: string | undefined, + setSnack: (message: string, severity?: SeverityType) => void, +}; + +type GPUInfo = { + name: string, + memory: number, + discrete: boolean +} + + +type SystemInfo = { + "Installed RAM": string, + "Graphics Card": GPUInfo[], + "CPU": string +}; + +const SystemInfoComponent: React.FC<{ systemInfo: SystemInfo | undefined }> = ({ systemInfo }) => { + const [systemElements, setSystemElements] = useState([]); + + const convertToSymbols = (text: string) => { + return text + .replace(/\(R\)/g, '®') // Replace (R) with the ® symbol + .replace(/\(C\)/g, '©') // Replace (C) with the © symbol + .replace(/\(TM\)/g, '™'); // Replace (TM) with the ™ symbol + }; + + useEffect(() => { + if (systemInfo === undefined) { + return; + } + const elements = Object.entries(systemInfo).flatMap(([k, v]) => { + // If v is an array, repeat for each card + if (Array.isArray(v)) { + return v.map((card, index) => ( +
+
{convertToSymbols(k)} {index}
+
{convertToSymbols(card.name)} {card.discrete ? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM` : "(integrated)"}
+
+ )); + } + + // If it's not an array, handle normally + return ( +
+
{convertToSymbols(k)}
+
{convertToSymbols(String(v))}
+
+ ); + }); + + setSystemElements(elements); + }, [systemInfo]); + + return
{systemElements}
; +}; + +const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => { + const [editSystemPrompt, setEditSystemPrompt] = useState(""); + const [systemInfo, setSystemInfo] = useState(undefined); + const [tools, setTools] = useState([]); + const [rags, setRags] = useState([]); + const [systemPrompt, setSystemPrompt] = useState(""); + const [serverSystemPrompt, setServerSystemPrompt] = useState(""); + const [messageHistoryLength, setMessageHistoryLength] = useState(5); + + useEffect(() => { + if (systemPrompt === serverSystemPrompt || !systemPrompt.trim() || sessionId === undefined) { + return; + } + const sendSystemPrompt = async (prompt: string) => { + try { + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "system-prompt": prompt }), + }); + + const data = await response.json(); + const newPrompt = data["system-prompt"]; + if (newPrompt !== serverSystemPrompt) { + setServerSystemPrompt(newPrompt); + setSystemPrompt(newPrompt) + setSnack("System prompt updated", "success"); + } + } catch (error) { + console.error('Fetch error:', error); + setSnack("System prompt update failed", "error"); + } + }; + + sendSystemPrompt(systemPrompt); + + }, [systemPrompt, setServerSystemPrompt, serverSystemPrompt, connectionBase, sessionId, setSnack]); + + useEffect(() => { + if (sessionId === undefined) { + return; + } + const sendMessageHistoryLength = async (length: number) => { + try { + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "message-history-length": length }), + }); + + const data = await response.json(); + const newLength = data["message-history-length"]; + if (newLength !== messageHistoryLength) { + setMessageHistoryLength(newLength); + setSnack("Message history length updated", "success"); + } + } catch (error) { + console.error('Fetch error:', error); + setSnack("Message history length update failed", "error"); + } + }; + + sendMessageHistoryLength(messageHistoryLength); + + }, [messageHistoryLength, setMessageHistoryLength, connectionBase, sessionId, setSnack]); + const reset = async (types: ("rags" | "tools" | "history" | "system-prompt" | "message-history-length")[], message: string = "Update successful.") => { + try { + const response = await fetch(connectionBase + `/api/reset/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "reset": types }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.error) { + throw Error() + } + for (const [key, value] of Object.entries(data)) { + switch (key) { + case "rags": + setRags(value as Tool[]); + break; + case "tools": + setTools(value as Tool[]); + break; + case "system-prompt": + setServerSystemPrompt((value as any)["system-prompt"].trim()); + setSystemPrompt((value as any)["system-prompt"].trim()); + break; + case "history": + console.log('TODO: handle history reset'); + break; + } + } + setSnack(message, "success"); + } else { + throw Error(`${{ status: response.status, message: response.statusText }}`); + } + } catch (error) { + console.error('Fetch error:', error); + setSnack("Unable to restore defaults", "error"); + } + }; + + + // Get the system information + useEffect(() => { + if (systemInfo !== undefined || sessionId === undefined) { + return; + } + fetch(connectionBase + `/api/system-info/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(response => response.json()) + .then(data => { + setSystemInfo(data); + }) + .catch(error => { + console.error('Error obtaining system information:', error); + setSnack("Unable to obtain system information.", "error"); + }); + }, [systemInfo, setSystemInfo, connectionBase, setSnack, sessionId]) + + useEffect(() => { + setEditSystemPrompt(systemPrompt); + }, [systemPrompt, setEditSystemPrompt]); + + + const toggleRag = async (tool: Tool) => { + tool.enabled = !tool.enabled + try { + const response = await fetch(connectionBase + `/api/rags/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "tool": tool?.name, "enabled": tool.enabled }), + }); + + const rags = await response.json(); + setRags([...rags]) + setSnack(`${tool?.name} ${tool.enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error('Fetch error:', error); + setSnack(`${tool?.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error"); + tool.enabled = !tool.enabled + } + }; + + const toggleTool = async (tool: Tool) => { + tool.enabled = !tool.enabled + try { + const response = await fetch(connectionBase + `/api/tools/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ "tool": tool?.function?.name, "enabled": tool.enabled }), + }); + + const tools = await response.json(); + setTools([...tools]) + setSnack(`${tool?.function?.name} ${tool.enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error('Fetch error:', error); + setSnack(`${tool?.function?.name} ${tool.enabled ? "enabling" : "disabling"} failed.`, "error"); + tool.enabled = !tool.enabled + } + }; + + // If the tools have not been set, fetch them from the server + useEffect(() => { + if (tools.length || sessionId === undefined) { + return; + } + const fetchTools = async () => { + try { + // Make the fetch request with proper headers + const response = await fetch(connectionBase + `/api/tools/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + if (!response.ok) { + throw Error(); + } + const tools = await response.json(); + setTools(tools); + } catch (error: any) { + setSnack("Unable to fetch tools", "error"); + console.error(error); + } + } + + fetchTools(); + }, [sessionId, tools, setTools, setSnack, connectionBase]); + + // If the RAGs have not been set, fetch them from the server + useEffect(() => { + if (rags.length || sessionId === undefined) { + return; + } + const fetchRags = async () => { + try { + // Make the fetch request with proper headers + const response = await fetch(connectionBase + `/api/rags/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + if (!response.ok) { + throw Error(); + } + const rags = await response.json(); + setRags(rags); + } catch (error: any) { + setSnack("Unable to fetch RAGs", "error"); + console.error(error); + } + } + + fetchRags(); + }, [sessionId, rags, setRags, setSnack, connectionBase]); + + // If the systemPrompt has not been set, fetch it from the server + useEffect(() => { + if (serverSystemPrompt !== "" || sessionId === undefined) { + return; + } + const fetchTunables = async () => { + // Make the fetch request with proper headers + const response = await fetch(connectionBase + `/api/tunables/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + const data = await response.json(); + const serverSystemPrompt = data["system-prompt"].trim(); + setServerSystemPrompt(serverSystemPrompt); + setSystemPrompt(serverSystemPrompt); + setMessageHistoryLength(data["message-history-length"]); + } + + fetchTunables(); + }, [sessionId, serverSystemPrompt, setServerSystemPrompt, connectionBase]); + + + + + + const toggle = async (type: string, index: number) => { + switch (type) { + case "rag": + toggleRag(rags[index]) + break; + case "tool": + toggleTool(tools[index]); + } + }; + + const handleKeyPress = (event: any) => { + if (event.key === 'Enter' && event.ctrlKey) { + switch (event.target.id) { + case 'SystemPromptInput': + setSystemPrompt(editSystemPrompt); + break; + } + } + }; + + return (
+ + You can change the information available to the LLM by adjusting the following settings: + + + + }> + System Prompt + + + setEditSystemPrompt(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Enter the new system prompt.." + id="SystemPromptInput" + /> +
+ + +
+
+
+ + }> + Tunables + + + setMessageHistoryLength(e.target.value)} + slotProps={{ + htmlInput: { + min: 0 + }, + inputLabel: { + shrink: true, + }, + }} + /> + + + + }> + Tools + + + These tools can be made available to the LLM for obtaining real-time information from the Internet. The description provided to the LLM is provided for reference. + + + + { + tools.map((tool, index) => + + + } onChange={() => toggle("tool", index)} label={tool?.function?.name} /> + {tool?.function?.description} + + ) + } + + + + }> + RAG + + + These RAG databases can be enabled / disabled for adding additional context based on the chat request. + + + + { + rags.map((rag, index) => + + + } onChange={() => toggle("rag", index)} label={rag?.name} /> + {rag?.description} + + ) + } + + + + }> + System Information + + + The server is running on the following hardware: + + + + + + + +
); +} + + +export type { + ControlsParams +}; + +export { + Controls +}; \ No newline at end of file diff --git a/frontend/src/Conversation.tsx b/frontend/src/Conversation.tsx new file mode 100644 index 0000000..40b5041 --- /dev/null +++ b/frontend/src/Conversation.tsx @@ -0,0 +1,446 @@ +import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import SendIcon from '@mui/icons-material/Send'; + +import PropagateLoader from "react-spinners/PropagateLoader"; + +import { Message, MessageList } from './Message'; +import { SeverityType } from './Snack'; +import { ContextStatus } from './ContextStatus'; +import { MessageData } from './MessageMeta'; + +const welcomeMarkdown = ` +# Welcome to Backstory + +Backstory was written by James Ketrenos in order to provide answers to +questions potential employers may have about his work history. +You can ask things like: + + + + + + +You can click the text above to submit that query, or type it in yourself (or whatever questions you may have.) + +Backstory is a RAG enabled expert system with access to real-time data running self-hosted +(no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs). + +As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career, I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.`; + +const welcomeMessage: MessageData = { + "role": "assistant", "content": welcomeMarkdown +}; +const loadingMessage: MessageData = { "role": "assistant", "content": "Instancing chat session..." }; + +type ConversationMode = 'chat' | 'fact-check' | 'system'; + +interface ConversationHandle { + submitQuery: () => void; +} + +interface ConversationProps { + type: ConversationMode + prompt: string, + connectionBase: string, + sessionId: string | undefined, + setSnack: (message: string, severity: SeverityType) => void, +}; + +const Conversation = forwardRef(({prompt, type, sessionId, setSnack, connectionBase} : ConversationProps, ref) => { + const [query, setQuery] = useState(""); + const [contextUsedPercentage, setContextUsedPercentage] = useState(0); + const [processing, setProcessing] = useState(false); + const [countdown, setCountdown] = useState(0); + const [conversation, setConversation] = useState([]); + const timerRef = useRef(null); + const [lastEvalTPS, setLastEvalTPS] = useState(35); + const [lastPromptTPS, setLastPromptTPS] = useState(430); + const [contextStatus, setContextStatus] = useState({ context_used: 0, max_context: 0 }); + const [contextWarningShown, setContextWarningShown] = useState(false); + + // Update the context status + const updateContextStatus = useCallback(() => { + const fetchContextStatus = async () => { + try { + const response = await fetch(connectionBase + `/api/context-status/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + setContextStatus(data); + } + catch (error) { + console.error('Error getting context status:', error); + setSnack("Unable to obtain context status.", "error"); + } + }; + fetchContextStatus(); + }, [setContextStatus, connectionBase, setSnack, sessionId]); + + // Set the initial chat history to "loading" or the welcome message if loaded. + useEffect(() => { + if (sessionId === undefined) { + setConversation([loadingMessage]); + } else { + fetch(connectionBase + `/api/history/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(response => response.json()) + .then(data => { + console.log(`Session id: ${sessionId} -- history returned from server with ${data.length} entries`) + setConversation([ + welcomeMessage, + ...data + ]); + }) + .catch(error => { + console.error('Error generating session ID:', error); + setSnack("Unable to obtain chat history.", "error"); + }); + updateContextStatus(); + } + }, [sessionId, setConversation, updateContextStatus, connectionBase, setSnack]); + + + const isScrolledToBottom = useCallback(()=> { + // Current vertical scroll position + const scrollTop = window.scrollY || document.documentElement.scrollTop; + + // Total height of the page content + const scrollHeight = document.documentElement.scrollHeight; + + // Height of the visible window + const clientHeight = document.documentElement.clientHeight; + + // If we're at the bottom (allowing a small buffer of 16px) + return scrollTop + clientHeight >= scrollHeight - 16; + }, []); + + const scrollToBottom = useCallback(() => { + console.log("Scroll to bottom"); + window.scrollTo({ + top: document.body.scrollHeight, + }); + }, []); + + + const startCountdown = (seconds: number) => { + if (timerRef.current) clearInterval(timerRef.current); + setCountdown(seconds); + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current); + timerRef.current = null; + if (isScrolledToBottom()) { + setTimeout(() => { + scrollToBottom(); + }, 50) + } + return 0; + } + return prev - 1; + }); + }, 1000); + }; + + const submitQuery = (text: string) => { + sendQuery(text); + } + + const stopCountdown = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + setCountdown(0); + } + }; + + const handleKeyPress = (event: any) => { + if (event.key === 'Enter') { + switch (event.target.id) { + case 'QueryInput': + sendQuery(query); + break; + } + } + }; + + useImperativeHandle(ref, () => ({ + submitQuery: () => { + sendQuery(query); + } + })); + + // If context status changes, show a warning if necessary. If it drops + // back below the threshold, clear the warning trigger + useEffect(() => { + const context_used_percentage = Math.round(100 * contextStatus.context_used / contextStatus.max_context); + if (context_used_percentage >= 90 && !contextWarningShown) { + setSnack(`${context_used_percentage}% of context used. You may wish to start a new chat.`, "warning"); + setContextWarningShown(true); + } + if (context_used_percentage < 90 && contextWarningShown) { + setContextWarningShown(false); + } + setContextUsedPercentage(context_used_percentage) + }, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]); + + const sendQuery = async (query: string) => { + if (!query.trim()) return; + + //setTab(0); + + const userMessage: MessageData[] = [{ role: 'user', content: query }]; + + let scrolledToBottom; + + // Add user message to conversation + const newConversation: MessageList = [ + ...conversation, + ...userMessage + ]; + setConversation(newConversation); + scrollToBottom(); + + // Clear input + setQuery(''); + + try { + scrolledToBottom = isScrolledToBottom(); + setProcessing(true); + // Create a unique ID for the processing message + const processingId = Date.now().toString(); + + // Add initial processing message + setConversation(prev => [ + ...prev, + { role: 'assistant', content: 'Processing request...', id: processingId, isProcessing: true } + ]); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + + // Make the fetch request with proper headers + const response = await fetch(connectionBase + `/api/chat/${sessionId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ role: 'user', content: query.trim() }), + }); + + // We'll guess that the response will be around 500 tokens... + const token_guess = 500; + const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS); + + scrolledToBottom = isScrolledToBottom(); + setSnack(`Query sent. Response estimated in ${estimate}s.`, "info"); + startCountdown(Math.round(estimate)); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + // Set up stream processing with explicit chunking + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + const chunk = decoder.decode(value, { stream: true }); + + // Process each complete line immediately + buffer += chunk; + let lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + for (const line of lines) { + if (!line.trim()) continue; + + try { + const update = JSON.parse(line); + + // Force an immediate state update based on the message type + if (update.status === 'processing') { + scrolledToBottom = isScrolledToBottom(); + // Update processing message with immediate re-render + setConversation(prev => prev.map(msg => + msg.id === processingId + ? { ...msg, content: update.message } + : msg + )); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + + // Add a small delay to ensure React has time to update the UI + await new Promise(resolve => setTimeout(resolve, 0)); + + } else if (update.status === 'done') { + // Replace processing message with final result + scrolledToBottom = isScrolledToBottom(); + setConversation(prev => [ + ...prev.filter(msg => msg.id !== processingId), + update.message + ]); + const metadata = update.message.metadata; + const evalTPS = metadata.eval_count * 10 ** 9 / metadata.eval_duration; + const promptTPS = metadata.prompt_eval_count * 10 ** 9 / metadata.prompt_eval_duration; + setLastEvalTPS(evalTPS ? evalTPS : 35); + setLastPromptTPS(promptTPS ? promptTPS : 35); + updateContextStatus(); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + } else if (update.status === 'error') { + // Show error + scrolledToBottom = isScrolledToBottom(); + setConversation(prev => [ + ...prev.filter(msg => msg.id !== processingId), + { role: 'assistant', type: 'error', content: update.message } + ]); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + } + } catch (e) { + setSnack("Error processing query", "error") + console.error('Error parsing JSON:', e, line); + } + } + } + + // Process any remaining buffer content + if (buffer.trim()) { + try { + const update = JSON.parse(buffer); + + if (update.status === 'done') { + scrolledToBottom = isScrolledToBottom(); + setConversation(prev => [ + ...prev.filter(msg => msg.id !== processingId), + update.message + ]); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 500); + } + } + } catch (e) { + setSnack("Error processing query", "error") + } + } + + scrolledToBottom = isScrolledToBottom(); + stopCountdown(); + setProcessing(false); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + } catch (error) { + console.error('Fetch error:', error); + setSnack("Unable to process query", "error"); + scrolledToBottom = isScrolledToBottom(); + setConversation(prev => [ + ...prev.filter(msg => !msg.isProcessing), + { role: 'assistant', type: 'error', content: `Error: ${error}` } + ]); + setProcessing(false); + stopCountdown(); + if (scrolledToBottom) { + setTimeout(() => { scrollToBottom() }, 50); + } + } + }; + + return ( + + + {conversation.map((message, index) => )} + + + {processing === true && countdown > 0 && ( + Estimated response time: {countdown}s + )} + + + Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context} + { + contextUsedPercentage >= 90 ? WARNING: Context almost exhausted. You should start a new chat. + : (contextUsedPercentage >= 50 ? NOTE: Context is getting long. Queries will be slower, and the LLM may stop issuing tool calls. + : <>) + } + + + + setQuery(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Enter your question..." + id="QueryInput" + /> + + + + + + ); +}); + +export type { + ConversationProps, + ConversationHandle +}; + +export { + Conversation +}; \ No newline at end of file diff --git a/frontend/src/DocumentViewer.tsx b/frontend/src/DocumentViewer.tsx index 2825b57..49fa643 100644 --- a/frontend/src/DocumentViewer.tsx +++ b/frontend/src/DocumentViewer.tsx @@ -80,8 +80,8 @@ const DocumentViewer: React.FC = ({ /** * Trigger resume generation and update UI state */ - const triggerGeneration = useCallback((jobDescription: string | undefined) => { - if (jobDescription === undefined) { + const triggerGeneration = useCallback((description: string | undefined) => { + if (description === undefined) { setProcessing(undefined); setResume(undefined); setActiveTab(0); @@ -89,7 +89,7 @@ const DocumentViewer: React.FC = ({ } setProcessing("resume"); setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile - generateResume(jobDescription); + generateResume(description); }, [generateResume, setProcessing, setActiveTab, setResume]); /** @@ -108,6 +108,10 @@ const DocumentViewer: React.FC = ({ setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile }, [factCheck, setResume, setProcessing, setActiveTab, setFacts]); + useEffect(() => { + setEditJobDescription(jobDescription); + }, [jobDescription, setEditJobDescription]); + /** * Switch to resume tab when resume become available */ @@ -157,10 +161,10 @@ const DocumentViewer: React.FC = ({ }; const renderJobDescriptionView = () => { - const jobDescription = []; + const children = []; if (resume === undefined && processing === undefined) { - jobDescription.push( + children.push( = ({ ); } else { - jobDescription.push({editJobDescription}) + children.push({editJobDescription}) } - jobDescription.push( + children.push( = ({ ); - return jobDescription; + return children; } /** @@ -421,7 +425,7 @@ const ResumeActionCard: React.FC = ({ resume, processing, {resume !== undefined || processing === "resume" ? ( - NOTE: As with all LLMs, hallucination is always a possibility. If the generated resume seems too good to be true, Fact Check or, expand the LLM information for this query section (at the end of the resume) and click the links in the Top RAG matches to view the relavent RAG source document to read the details. Or go back to 'Backstory' and ask a question. + NOTE: As with all LLMs, hallucination is always a possibility. Click Fact Check to have the LLM analyze the generated resume vs. the actual resume. ) : ( diff --git a/frontend/src/Message.tsx b/frontend/src/Message.tsx index 7581414..5134bfd 100644 --- a/frontend/src/Message.tsx +++ b/frontend/src/Message.tsx @@ -1,12 +1,15 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; import CardContent from '@mui/material/CardContent'; import CardActions from '@mui/material/CardActions'; import Collapse from '@mui/material/Collapse'; import Typography from '@mui/material/Typography'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { ExpandMore } from './ExpandMore'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; import { MessageData, MessageMeta } from './MessageMeta'; import { ChatBubble } from './ChatBubble'; @@ -38,6 +41,19 @@ const ChatQuery = ({ text, submitQuery }: ChatQueryInterface) => { const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => { const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + const textFieldRef = useRef(null); + + const handleCopy = () => { + if (message === undefined || message.content === undefined) { + return; + } + + navigator.clipboard.writeText(message.content.trim()).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds + }); + }; const handleExpandClick = () => { setExpanded(!expanded); @@ -47,15 +63,43 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => { return (<>); } + if (message.content === undefined) { + console.info("Message content is undefined"); + return (<>); + } + const formattedContent = message.content.trim(); return ( - - + + + + {copied ? : } + + {message.role !== 'user' ? - + : - + {message.content} } @@ -86,6 +130,7 @@ export type { MessageInterface, MessageList, }; + export { Message, ChatQuery, diff --git a/frontend/src/MessageMeta.tsx b/frontend/src/MessageMeta.tsx index 102ef99..01da20a 100644 --- a/frontend/src/MessageMeta.tsx +++ b/frontend/src/MessageMeta.tsx @@ -100,7 +100,7 @@ const MessageMeta = ({ metadata }: MessageMetaInterface) => { } { - metadata.rag.name !== undefined && + metadata?.rag?.name !== undefined && }> diff --git a/frontend/src/ResumeBuilder.tsx b/frontend/src/ResumeBuilder.tsx index 93064e9..0deefb5 100644 --- a/frontend/src/ResumeBuilder.tsx +++ b/frontend/src/ResumeBuilder.tsx @@ -1,13 +1,11 @@ -import { useState, useCallback, } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import Box from '@mui/material/Box'; import { SeverityType } from './Snack'; import { ContextStatus } from './ContextStatus'; -import { MessageData } from './MessageMeta'; +import { MessageData, MessageMetadata } from './MessageMeta'; import { DocumentViewer } from './DocumentViewer'; interface ResumeBuilderProps { - scrollToBottom: () => void, - isScrolledToBottom: () => boolean, setProcessing: (processing: boolean) => void, processing: boolean, connectionBase: string, @@ -17,14 +15,20 @@ interface ResumeBuilderProps { setResume: (resume: MessageData | undefined) => void, facts: MessageData | undefined, setFacts: (facts: MessageData | undefined) => void, - jobDescription: string | undefined, - setJobDescription: (jobDescription: string | undefined) => void }; -const ResumeBuilder = ({ jobDescription, setJobDescription, facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => { +type Resume = { + resume: MessageData | undefined, + fact_check: MessageData | undefined, + job_description: string, + metadata: MessageMetadata +}; + +const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => { const [lastEvalTPS, setLastEvalTPS] = useState(35); const [lastPromptTPS, setLastPromptTPS] = useState(430); const [contextStatus, setContextStatus] = useState({ context_used: 0, max_context: 0 }); + const [jobDescription, setJobDescription] = useState(undefined); const updateContextStatus = useCallback(() => { fetch(connectionBase + `/api/context-status/${sessionId}`, { @@ -43,6 +47,49 @@ const ResumeBuilder = ({ jobDescription, setJobDescription, facts, setFacts, res }); }, [setContextStatus, connectionBase, setSnack, sessionId]); + // If the jobDescription and resume have not been set, fetch them from the server + useEffect(() => { + if (sessionId === undefined) { + return; + } + if (jobDescription !== undefined) { + return; + } + const fetchResume = async () => { + try { + // Make the fetch request with proper headers + const response = await fetch(connectionBase + `/api/resume/${sessionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + if (!response.ok) { + throw Error(); + } + const data: Resume[] = await response.json(); + if (data.length) { + const lastResume = data[data.length - 1]; + console.log(lastResume); + setJobDescription(lastResume['job_description']); + setResume(lastResume.resume); + if (lastResume['fact_check'] !== undefined && lastResume['fact_check'] !== null) { + lastResume['fact_check'].role = 'info'; + setFacts(lastResume['fact_check']) + } else { + setFacts(undefined) + } + } + } catch (error: any) { + setSnack("Unable to fetch resume", "error"); + console.error(error); + } + } + + fetchResume(); + }, [sessionId, resume, jobDescription, setResume, setJobDescription, setSnack, setFacts, connectionBase]); + // const startCountdown = (seconds: number) => { // if (timerRef.current) clearInterval(timerRef.current); // setCountdown(seconds); @@ -75,8 +122,8 @@ const ResumeBuilder = ({ jobDescription, setJobDescription, facts, setFacts, res return (<>); } - const generateResume = async (jobDescription: string) => { - if (!jobDescription.trim()) return; + const generateResume = async (description: string) => { + if (!description.trim()) return; setResume(undefined); setFacts(undefined); @@ -93,7 +140,7 @@ const ResumeBuilder = ({ jobDescription, setJobDescription, facts, setFacts, res 'Content-Type': 'application/json', 'Accept': 'application/json', }, - body: JSON.stringify({ content: jobDescription.trim() }), + body: JSON.stringify({ content: description.trim() }), }); // We'll guess that the response will be around 500 tokens... diff --git a/frontend/src/StyledMarkdown.tsx b/frontend/src/StyledMarkdown.tsx index 8343a23..30bf6eb 100644 --- a/frontend/src/StyledMarkdown.tsx +++ b/frontend/src/StyledMarkdown.tsx @@ -5,12 +5,13 @@ import { Link } from '@mui/material'; import { ChatQuery } from './Message'; interface StyledMarkdownProps { + className?: string, content: string, submitQuery?: (query: string) => void, [key: string]: any, // For any additional props }; -const StyledMarkdown: React.FC = ({ content, submitQuery, ...props }) => { +const StyledMarkdown: React.FC = ({ className, content, submitQuery, ...props }) => { const theme = useTheme(); let options: any = { @@ -42,7 +43,7 @@ const StyledMarkdown: React.FC = ({ content, submitQuery, . }; } - return ; + return ; }; export { StyledMarkdown }; \ No newline at end of file diff --git a/frontend/src/VectorVisualizer.tsx b/frontend/src/VectorVisualizer.tsx index b913a99..7d4b749 100644 --- a/frontend/src/VectorVisualizer.tsx +++ b/frontend/src/VectorVisualizer.tsx @@ -307,7 +307,7 @@ const VectorVisualizer: React.FC = ({ setSnack, connectio { queryEmbedding !== undefined && - + Query: {queryEmbedding.query} diff --git a/src/server.py b/src/server.py index ff7df30..4478ec8 100644 --- a/src/server.py +++ b/src/server.py @@ -136,7 +136,17 @@ DEFAULT_HISTORY_LENGTH=5 # %% # Globals +NAME = "James Ketrenos" context_tag = "INFO" + +resume_intro = f""" +As an AI/ML professional specializing in creating custom solutions to new problem domains, {NAME} developed a custom +language model applications that streamline information processing and content generation. This tailored resume +was created using a Retrieval-Augmented Generation system I built to efficiently match my relevant experience +with your specific needs—demonstrating both my technical capabilities and commitment to intelligent resource +optimization. +""" + system_message = f""" Launched on {DateTime()}. @@ -163,16 +173,20 @@ When answering queries, follow these steps: 3. Use the [JOB DESCRIPTION] provided to guide the focus, tone, and relevant skills or experience to highlight from the [WORK HISTORY]. 4. Identify and emphasisze the experiences, achievements, and responsibilities from the [WORK HISTORY] that best align with the [JOB DESCRIPTION]. 5. Do not use the [JOB DESCRIPTION] skills unless listed in [WORK HISTORY]. +6. Do not include any information unless it is provided in [WORK HISTORY] or [INTRO]. +7. Use the [INTRO] to highlight the use of AI in generating this resume. +8. Use the [WORK HISTORY] to create a polished, professional resume. +9. Do not list any locations in the resume. Structure the resume professionally with the following sections where applicable: * "Name: Use full name." -* "Professional Summary: A 2-4 sentence overview tailored to the job." +* "Professional Summary: A 2-4 sentence overview tailored to the job, using [INTRO] to highlight the use of AI in generating this resume." * "Skills: A bullet list of key skills derived from the work history and relevant to the job." * Professional Experience: A detailed list of roles, achievements, and responsibilities from the work history that relate to the job." * Education: Include only if available in the work history." -Do not include any information unless it is provided in [WORK HISTORY]. +Do not include any information unless it is provided in [WORK HISTORY] or [INTRO]. Ensure the langauge is clear, concise, and aligned with industry standards for professional resumes. """ @@ -372,9 +386,18 @@ class WebServer: self.file_watcher = None self.observer = None + self.ssl_enabled = os.path.exists(defines.key_path) and os.path.exists(defines.cert_path) + + if self.ssl_enabled: + allow_origins=["https://battle-linux.ketrenos.com:3000"] + else: + allow_origins=["http://battle-linux.ketrenos.com:3000"] + + logging.info(f"Allowed origins: {allow_origins}") + self.app.add_middleware( CORSMiddleware, - allow_origins=["http://battle-linux.ketrenos.com:3000"], + allow_origins=allow_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -1053,14 +1076,15 @@ class WebServer: if chroma_results: rag_docs.extend(chroma_results["documents"]) metadata["rag"] = { "name": rag["name"], **chroma_results } - preamble = f"The current time is {DateTime()}\n" - preamble = f"""[WORK HISTORY]:\n""" + preamble = f"[INTRO]\n{resume_intro}\n[/INTRO]\n" + preamble += f"""[WORK HISTORY]:\n""" for doc in rag_docs: preamble += f"{doc}\n" resume["rag"] += f"{doc}\n" preamble += f"\n[/WORK HISTORY]\n" - content = f"{preamble}\nUse the above WORK HISTORY to create the resume for this JOB DESCRIPTION. Do not use the JOB DESCRIPTION skills as skills the user posseses unless listed in WORK HISTORY:\n[JOB DESCRIPTION]\n{content}\n[/JOB DESCRIPTION]\n" + content = f"""{preamble}\n + Use the above [WORK HISTORY] and [INTRO] to create the resume for this [JOB DESCRIPTION]. Do not use the [JOB DESCRIPTION] in the generated resume unless the [WORK HISTORY] mentions them:\n[JOB DESCRIPTION]\n{content}\n[/JOB DESCRIPTION]\n""" try: # Estimate token length of new messages @@ -1152,7 +1176,22 @@ class WebServer: def run(self, host="0.0.0.0", port=WEB_PORT, **kwargs): try: - uvicorn.run(self.app, host=host, port=port) + if self.ssl_enabled: + logging.info(f"Starting web server at https://{host}:{port}") + uvicorn.run( + self.app, + host=host, + port=port, + ssl_keyfile=defines.key_path, + ssl_certfile=defines.cert_path + ) + else: + logging.info(f"Starting web server at http://{host}:{port}") + uvicorn.run( + self.app, + host=host, + port=port + ) except KeyboardInterrupt: if self.observer: self.observer.stop() @@ -1181,7 +1220,6 @@ def main(): # print(f"Vectorstore created with {collection.count()} documents") web_server = WebServer(logging, client, model) - logging.info(f"Starting web server at http://{args.web_host}:{args.web_port}") web_server.run(host=args.web_host, port=args.web_port, use_reloader=False) diff --git a/src/utils/defines.py b/src/utils/defines.py index 939d2fd..8a24f8c 100644 --- a/src/utils/defines.py +++ b/src/utils/defines.py @@ -11,4 +11,7 @@ max_context = 2048*8*2 doc_dir = "/opt/backstory/docs/" session_dir = "/opt/backstory/sessions" static_content = '/opt/backstory/frontend/deployed' -resume_doc = '/opt/backstory/docs/resume/generic.txt' \ No newline at end of file +resume_doc = '/opt/backstory/docs/resume/generic.txt' +# Only used for testing; backstory-prod will not use this +key_path = '/opt/backstory/src/key.pem' +cert_path = '/opt/backstory/src/cert.pem' \ No newline at end of file