78 Commits
v1.3.0 ... GCP

Author SHA1 Message Date
ItzCrazyKns
35a3eda213 Merge pull request #155 from notedsource/hristo/gcp-deploy-vertexai-models-embeddings
Hristo/gcp deploy vertexai models embeddings
2024-06-01 10:49:38 +05:30
Hristo
dfed6a0ad8 Use container restart policy from main 2024-05-30 17:21:40 -04:00
Hristo
e0d9522435 Merge branch 'master' of github.com:notedsource/Perplexica into hristo/gcp-deploy-vertexai-models-embeddings 2024-05-30 11:19:50 -04:00
Hristo
f7c3bc2823 No auth on root route for health checks, fix suggestions request 2024-05-30 11:18:31 -04:00
ItzCrazyKns
6fb0c5b362 Merge pull request #153 from aiyogg/master
feat(docker-compose): update docker-compose.yaml with restart policy
2024-05-30 16:02:09 +05:30
Chuck
f4628ae52d feat(docker-compose): update docker-compose.yaml with restart policy 2024-05-30 18:12:22 +08:00
Hristo
0ac971e6b4 Merge branch 'hristo/deploy-on-gcp-gke' of github.com:notedsource/Perplexica into hristo/vertexai-embeddings 2024-05-22 15:05:45 -04:00
Hristo
4ff6502dae Restore searxng dockerfile to enable remote builds 2024-05-22 15:04:25 -04:00
Hristo
795309cfe2 Private searxng instance 2024-05-22 14:52:47 -04:00
ItzCrazyKns
d04ba91c85 feat(routes): use coalescing operator 2024-05-22 10:45:16 +05:30
Hristo
8bf4269208 Add vertexai text embeddings capability 2024-05-21 16:23:34 -04:00
Hristo
4c7942d2e8 Merge branch 'master' of github.com:notedsource/Perplexica into hristo/deploy-on-gcp-gke 2024-05-21 15:41:23 -04:00
Hristo
aa55206a30 Add VertexAI deps using yarn not npm 2024-05-21 15:15:19 -04:00
Hristo
27d7b000d0 Add AI/ML infrence scope to OAuth credentials requested for cluster IAM account 2024-05-21 14:31:14 -04:00
ItzCrazyKns
7853c18b6f feat(docs): update port 2024-05-19 11:35:28 +05:30
ItzCrazyKns
64ea4b4289 feat(package): bump version 2024-05-18 13:11:24 +05:30
ItzCrazyKns
c61facef13 feat(message-box): display suggestions 2024-05-18 13:11:15 +05:30
ItzCrazyKns
fcff93a594 feat(message-actions): update rewrite button 2024-05-18 13:10:54 +05:30
ItzCrazyKns
3bfaf9be28 feat(app): add suggestion generation 2024-05-18 13:10:39 +05:30
ItzCrazyKns
68b595023e feat(suggestion-generator): update prompt 2024-05-18 13:10:09 +05:30
Hristo
8b9b4085ea Fix query appearing twice in chat history
The initial query appears twice in the prompt, this is ignored by OpenAI
models, however it breaks with Gemini models are they fail with an error
stating that AI and User prompts need to alternate.

Tested all search modes with both OpenAI GTP3 turbo and Vertex Gemini
1.0 and this changes appears to now function correctly with both.
2024-05-17 14:10:11 -04:00
Hristo
2e58dab30a Additional changes for VertexAI 2024-05-17 14:08:57 -04:00
Hristo
48018990be Ensure containers are brought backup when exiting on error
This is esp. important for the NodeJS (backend) container as  Node will
exit on any unhandled error, it is best practice to let the errored
process crash and start a new one in its place. It this case we use
docker to do that for us (`restart: always` policy)
2024-05-16 09:53:33 -04:00
Hristo
ebbe18ab45 Adds Google VertexAI as model provider 2024-05-14 15:05:17 -04:00
Hristo
cef75279c5 Add Google VertexAI deps. 2024-05-14 14:51:26 -04:00
ItzCrazyKns
180e204c2d feat(providers): add GPT-4 omni 2024-05-14 19:33:54 +05:30
ItzCrazyKns
0e2f4514b4 feat(readme): update readme 2024-05-13 20:10:44 +05:30
ItzCrazyKns
0993c5a760 feat(app): revert port & network changes 2024-05-13 19:58:17 +05:30
ItzCrazyKns
100872f2d9 feat(docker-compose): revert network changes 2024-05-12 14:04:05 +05:30
ItzCrazyKns
22aee27cda feat(env): remove port 2024-05-12 12:48:01 +05:30
ItzCrazyKns
9d30224faa feat(readme): update readme 2024-05-12 12:24:36 +05:30
ItzCrazyKns
b622df5a9f feat(docker-compose): update ports, change network type 2024-05-12 12:16:08 +05:30
ItzCrazyKns
1b18715f8f feat(docs): update PORT 2024-05-12 12:15:53 +05:30
ItzCrazyKns
9816eb1d36 feat(server): add bind address 2024-05-12 12:15:25 +05:30
ItzCrazyKns
828eeb0c77 feat(app-dockerfile): add PORT arg 2024-05-12 12:14:52 +05:30
ItzCrazyKns
c852bee8ed feat(app): add suspense boundary 2024-05-11 21:19:38 +05:30
ItzCrazyKns
954b4bf89a feat(readme): add search engine guide 2024-05-11 12:14:49 +05:30
ItzCrazyKns
3ef39c69a7 feat(chat-window): add ability to use q query param 2024-05-11 12:09:39 +05:30
ItzCrazyKns
7a28be9e1a feat(readme): add installation docs 2024-05-11 12:09:08 +05:30
ItzCrazyKns
a60145137c feat(docs): add networking 2024-05-11 10:23:05 +05:30
Hristo
c56a058a74 Websocket auth, pass access token in gke configs 2024-05-10 19:32:35 -04:00
Hristo
4e20c4ac56 Finalizes option to secure backend http endpoints with a token
- Also fixes to build commands in makefile
2024-05-10 18:11:23 -04:00
Hristo
e6c2042df6 Backend GKE Deploy, access key for backend
- Configs and automation for deploying backend to GKE
- First steps to adding an optional token check for requests to backend
- First steps frontend sending optional token to backend when configured
2024-05-10 16:07:58 -04:00
ItzCrazyKns
7eace1e6bd feat(searxng-container): bind mount & add limiter 2024-05-10 20:55:08 +05:30
Chuck
baef45b456 Merge branch 'ItzCrazyKns:master' into master 2024-05-10 12:00:18 +08:00
ItzCrazyKns
9a7af945b0 lint 2024-05-09 20:43:04 +05:30
ItzCrazyKns
09463999c2 feat(routes): add suggestions route 2024-05-09 20:42:03 +05:30
ItzCrazyKns
0f6986fc9b feat(agents): add suggestion generator agent 2024-05-09 20:41:43 +05:30
ItzCrazyKns
5e940914a3 feat(output-parsers): add list line output parser 2024-05-09 20:39:38 +05:30
Chuck
ac4cba32c8 fix(SettingsDialog): baseURL storage key 2024-05-09 15:53:57 +08:00
Hristo
0fedaef537 First pass at setting up GCP deploy config as infrastructure
- Terraform config files to setup cluster, deployments and services
  - Adds only Searxng deployment and test service in this commit

- Makefile to:
  - Build and push images
  - Run terraform with correct project configuration

- Env file template to help setting .env file with project configs
2024-05-08 18:19:59 -04:00
ItzCrazyKns
4f5f6be85f feat(working): fix grammatical mistake 2024-05-08 20:05:29 +05:30
ItzCrazyKns
17fbc28172 Merge pull request #86 from WanQuanXie/list-map-key-fix
fix(Chat): list map element must specify a unique key
2024-05-08 12:56:00 +05:30
ItzCrazyKns
655fbec583 Merge pull request #87 from ItzCrazyKns/develop/1.4.0
Develop/1.4.0
2024-05-08 09:51:10 +05:30
WanQuanXie
0af66f8b72 fix(Chat): list map element must specify a unique key 2024-05-08 09:57:11 +08:00
ItzCrazyKns
8f9c709648 Merge branch 'develop/1.4.0' of https://github.com/ItzCrazyKns/Perplexica into develop/1.4.0 2024-05-07 19:40:36 +05:30
ItzCrazyKns
2a1d6e261d feat(backend-dockerfile): use Debian based image 2024-05-07 19:40:33 +05:30
ItzCrazyKns
74d1df7d25 feat(package): bump version 2024-05-07 19:40:14 +05:30
ItzCrazyKns
e042ff491b feat(compose): remove expose directive 2024-05-07 19:39:59 +05:30
ItzCrazyKns
fc1bfb3888 Merge pull request #83 from ItzCrazyKns/master
Merge `master` into `develop/14.0`
2024-05-07 18:46:24 +05:30
ItzCrazyKns
d9ba36794a feat(readme): add donations 2024-05-07 13:03:06 +05:30
ItzCrazyKns
321e60b993 feat(embedding-providers): load separately, add bert & bge 2024-05-07 12:33:44 +05:30
ItzCrazyKns
68837e06ee feat(embedding-providers): add local models 2024-05-07 11:52:53 +05:30
WanQuanXie
01fc683d32 fix(SettingDialog): use value instead of selected props in <select>
avoid the browser console warning in devServer mode
2024-05-07 06:35:39 +08:00
ItzCrazyKns
f88f179920 feat(package): bump version 2024-05-06 20:01:57 +05:30
ItzCrazyKns
4cb0aeeee3 feat(settings): conditionally pick selected models 2024-05-06 20:00:56 +05:30
ItzCrazyKns
e8fe74ae7c feat(ws-managers): implement better error handling 2024-05-06 19:59:13 +05:30
ItzCrazyKns
ed47191d9b feat(readme): update readme 2024-05-06 13:00:07 +05:30
ItzCrazyKns
b4d787d333 feat(readme): add troubleshooting 2024-05-06 12:58:40 +05:30
ItzCrazyKns
38b1995677 feat(package): bump version 2024-05-06 12:36:13 +05:30
ItzCrazyKns
f28257b480 feat(settings): fetch localStorage at state change 2024-05-06 12:34:59 +05:30
ItzCrazyKns
9b088cd161 feat(package): bump version 2024-05-05 16:35:06 +05:30
ItzCrazyKns
94ea6c372a feat(chat-window): clear storage after error 2024-05-05 16:29:40 +05:30
ItzCrazyKns
6e61c88c9e feat(error-object): add key 2024-05-05 16:28:46 +05:30
ItzCrazyKns
ba7b92ffde feat(providers): add Content-Type header 2024-05-05 10:53:27 +05:30
ItzCrazyKns
f8fd2a6fb0 feat(package): bump version 2024-05-04 15:04:43 +05:30
ItzCrazyKns
0440a810f5 feat(http-headers): add Content-Type 2024-05-04 15:01:53 +05:30
ItzCrazyKns
e3fef3a1be feat(chat-window): add error handling 2024-05-04 14:56:54 +05:30
57 changed files with 2165 additions and 205 deletions

20
Makefile Normal file
View File

@ -0,0 +1,20 @@
.PHONY: run
run:
docker compose -f docker-compose.yaml up
.PHONY: rebuild-run
rebuild-run:
docker compose -f docker-compose.yaml build --no-cache \
&& docker compose -f docker-compose.yaml up
.PHONY: run-app-only
run-app-only:
docker compose -f app-docker-compose.yaml up
.PHONY: rebuild-run-app-only
rebuild-run-app-only:
docker compose -f app-docker-compose.yaml build --no-cache \
&& docker compose -f app-docker-compose.yaml up

View File

@ -10,9 +10,12 @@
- [Installation](#installation)
- [Getting Started with Docker (Recommended)](#getting-started-with-docker-recommended)
- [Non-Docker Installation](#non-docker-installation)
- [Ollama connection errors](#ollama-connection-errors)
- [Using as a Search Engine](#using-as-a-search-engine)
- [One-Click Deployment](#one-click-deployment)
- [Upcoming Features](#upcoming-features)
- [Support Us](#support-us)
- [Donations](#donations)
- [Contribution](#contribution)
- [Help and Support](#help-and-support)
@ -90,10 +93,52 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
**Note**: Using Docker is recommended as it simplifies the setup process, especially for managing environment variables and dependencies.
See the [installation documentation](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/installation) for more information like exposing it your network, etc.
### Ollama connection errors
If you're facing an Ollama connection error, it is often related to the backend not being able to connect to Ollama's API. How can you fix it? You can fix it by updating your Ollama API URL in the settings menu to the following:
On Windows: `http://host.docker.internal:11434`<br>
On Mac: `http://host.docker.internal:11434`<br>
On Linux: `http://private_ip_of_computer_hosting_ollama:11434`
You need to edit the ports accordingly.
## Using as a Search Engine
If you wish to use Perplexica as an alternative to traditional search engines like Google or Bing, or if you want to add a shortcut for quick access from your browser's search bar, follow these steps:
1. Open your browser's settings.
2. Navigate to the 'Search Engines' section.
3. Add a new site search with the following URL: `http://localhost:3000/?q=%s`. Replace `localhost` with your IP address or domain name, and `3000` with the port number if Perplexica is not hosted locally.
4. Click the add button. Now, you can use Perplexica directly from your browser's search bar.
## One-Click Deployment
[![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=267)
## Deploy Perplexica backend to Google GKE
0: Install `docker` and `terraform` (Process specific to your system)
1a: Copy the `sample.env` file to `.env`
1b: Copy the `deploy/gcp/sample.env` file to `deploy/gcp/.env`
2a: Fillout desired LLM provider access keys etc. in `.env`
- Note: you will have to comeback and edit this file again once you have the address of the K8s backend deploy
2b: Fillout the GCP info in `deploy/gcp/.env`
3: Edit `GCP_REPO` to the correct docker image repo path if you are using something other than Container registry
4: Edit the `PREFIX` if you would like images and GKE entities to be prefixed with something else
5: In `deploy/gcp` run `make init` to initialize terraform
6: Follow the normal Preplexica configuration steps outlined in the project readme
7: Auth docker with the appropriate credential for repo Ex. for `gcr.io` -> `gcloud auth configure-docker`
8: In `deploy/gcp` run `make build-deplpy` to build and push the project images to the repo, create a GKE cluster and deploy the app
9: Once deployed successfully edit the `.env` file in the root project folder and update the `REMOTE_BACKEND_ADDRESS` with the remote k8s deployment address and port
10: In root project folder run `make rebuild-run-app-only`
If you configured everything correctly frontend app will run locally and provide you with a local url to open it.
Now you can run queries against the remotely deployed backend from your local machine. :celebrate:
## Upcoming Features
- [ ] Finalizing Copilot Mode
@ -104,7 +149,15 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
## Support Us
If you find Perplexica useful, consider giving us a star on GitHub. This helps more people discover Perplexica and supports the development of new features. Your support is appreciated.
If you find Perplexica useful, consider giving us a star on GitHub. This helps more people discover Perplexica and supports the development of new features. Your support is greatly appreciated.
### Donations
We also accept donations to help sustain our project. If you would like to contribute, you can use the following button to make a donation in cryptocurrency. Thank you for your support!
<a href="https://nowpayments.io/donation?api_key=RFFKJH1-GRR4DQG-HFV1DZP-00G6MMK&source=lk_donation&medium=referral" target="_blank">
<img src="https://nowpayments.io/images/embeds/donation-button-white.svg" alt="Crypto donation button by NOWPayments">
</a>
## Contribution

13
app-docker-compose.yaml Normal file
View File

@ -0,0 +1,13 @@
services:
perplexica-frontend:
build:
context: .
dockerfile: app.dockerfile
args:
- NEXT_PUBLIC_SUPER_SECRET_KEY=${SUPER_SECRET_KEY}
- NEXT_PUBLIC_API_URL=https://${REMOTE_BACKEND_ADDRESS}/api
- NEXT_PUBLIC_WS_URL=wss://${REMOTE_BACKEND_ADDRESS}
expose:
- 3000
ports:
- 3000:3000

View File

@ -2,8 +2,11 @@ FROM node:alpine
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SUPER_SECRET_KEY
ENV NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_SUPER_SECRET_KEY=${NEXT_PUBLIC_SUPER_SECRET_KEY}
WORKDIR /home/perplexica
@ -12,4 +15,4 @@ COPY ui /home/perplexica/
RUN yarn install
RUN yarn build
CMD ["yarn", "start"]
CMD ["yarn", "start"]

View File

@ -1,4 +1,4 @@
FROM node:alpine
FROM node:buster-slim
ARG SEARXNG_API_URL

6
deploy/gcp/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.env
.auto.tfvars
.terraform
terraform.tfstate
terraform.tfstate.*
.terraform.lock.hcl

103
deploy/gcp/Makefile Normal file
View File

@ -0,0 +1,103 @@
# Adds all the deployment relevant sensitive information about project
include .env
# Adds secrets/ keys we have define for the project locally and deployment
include ../../.env
# Use `location-id-docker.pkg` for artifact registry Ex. west-1-docker.pkg
GCP_REPO=gcr.io
PREFIX=perplexica
SEARCH_PORT=8080
BACKEND_PORT=3001
SEARCH_IMAGE_TAG=$(GCP_REPO)/$(GCP_PROJECT_ID)/$(PREFIX)-searxng:latest
BACKEND_IMAGE_TAG=$(GCP_REPO)/$(GCP_PROJECT_ID)/$(PREFIX)-backend:latest
APP_IMAGE_TAG=$(GCP_REPO)/$(GCP_PROJECT_ID)/$(PREFIX)-app:latest
CLUSTER_NAME=$(PREFIX)-cluster
.PHONY: build-deploy
build-deploy: docker-build-all deploy
.PHONY: docker-build-all
docker-build-all: docker-build-push-searxng docker-build-push-backend docker-build-push-app
.PHONY: show_config
show_config:
@echo $(GCP_PROJECT_ID) \
&& echo $(CLUSTER_NAME) \
&& echo $(GCP_REGION) \
&& echo $(GCP_SERVICE_ACCOUNT_KEY_FILE) \
&& echo $(SEARCH_IMAGE_TAG) \
&& echo $(BACKEND_IMAGE_TAG) \
&& echo $(APP_IMAGE_TAG) \
&& echo $(SEARCH_PORT) \
&& echo $(BACKEND_PORT) \
&& echo $(OPENAI) \
&& echo $(SUPER_SECRET_KEY)
.PHONY: docker-build-push-searxng
docker-build-push-searxng:
cd ../../ && docker build -f ./deploy/gcp/searxng.dockerfile -t $(SEARCH_IMAGE_TAG) . --platform="linux/amd64"
docker push $(SEARCH_IMAGE_TAG)
.PHONY: docker-build-push-backend
docker-build-push-backend:
cd ../../ && docker build -f ./backend.dockerfile -t $(BACKEND_IMAGE_TAG) . --platform="linux/amd64"
docker push $(BACKEND_IMAGE_TAG)
.PHONY: docker-build-push-app
docker-build-push-app:
#
# cd ../../ && docker build -f ./app.dockerfile -t $(APP_IMAGE_TAG) . --platform="linux/amd64"
# docker push $(APP_IMAGE_TAG)
.PHONY: init
init:
terraform init
.PHONY: deploy
deploy:
export TF_VAR_project_id=$(GCP_PROJECT_ID) \
&& export TF_VAR_cluster_name=$(CLUSTER_NAME) \
&& export TF_VAR_region=$(GCP_REGION) \
&& export TF_VAR_key_file=$(GCP_SERVICE_ACCOUNT_KEY_FILE) \
&& export TF_VAR_search_image=$(SEARCH_IMAGE_TAG) \
&& export TF_VAR_backend_image=$(BACKEND_IMAGE_TAG) \
&& export TF_VAR_app_image=$(APP_IMAGE_TAG) \
&& export TF_VAR_search_port=$(SEARCH_PORT) \
&& export TF_VAR_backend_port=$(BACKEND_PORT) \
&& export TF_VAR_open_ai=$(OPENAI) \
&& export TF_VAR_secret_key=$(SUPER_SECRET_KEY) \
&& terraform apply
.PHONY: teardown
teardown:
export TF_VAR_project_id=$(GCP_PROJECT_ID) \
&& export TF_VAR_cluster_name=$(CLUSTER_NAME) \
&& export TF_VAR_region=$(GCP_REGION) \
&& export TF_VAR_key_file=$(GCP_SERVICE_ACCOUNT_KEY_FILE) \
&& export TF_VAR_search_image=$(SEARCH_IMAGE_TAG) \
&& export TF_VAR_backend_image=$(BACKEND_IMAGE_TAG) \
&& export TF_VAR_app_image=$(APP_IMAGE_TAG) \
&& export TF_VAR_search_port=$(SEARCH_PORT) \
&& export TF_VAR_backend_port=$(BACKEND_PORT) \
&& export TF_VAR_open_ai=$(OPENAI) \
&& export TF_VAR_secret_key=$(SUPER_SECRET_KEY) \
&& terraform destroy
.PHONY: auth-kubectl
auth-kubectl:
gcloud container clusters get-credentials $(CLUSTER_NAME) --region=$(GCP_REGION)
.PHONY: rollout-new-version-backend
rollout-new-version-backend: auth-kubectl
kubectl rollout restart deploy backend

View File

@ -0,0 +1,60 @@
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "5.28.0"
}
}
}
variable "project_id" {
description = "The ID of the project in which resources will be deployed."
type = string
}
variable "name" {
description = "The GKE Cluster name"
type = string
}
variable "region" {
description = "The GCP region to deploy to."
type = string
}
variable "key_file" {
description = "The path to the GCP service account key file."
type = string
}
provider "google" {
credentials = file(var.key_file)
project = var.project_id
region = var.region
}
resource "google_container_cluster" "cluster" {
name = var.name
location = var.region
initial_node_count = 1
remove_default_node_pool = true
}
resource "google_container_node_pool" "primary_preemptible_nodes" {
name = "${google_container_cluster.cluster.name}-node-pool"
location = var.region
cluster = google_container_cluster.cluster.name
node_count = 1
node_config {
machine_type = "n1-standard-4"
disk_size_gb = 25
spot = true
oauth_scopes = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/devstorage.read_only",
"https://www.googleapis.com/auth/logging.write",
"https://www.googleapis.com/auth/monitoring",
]
}
}

238
deploy/gcp/main.tf Normal file
View File

@ -0,0 +1,238 @@
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "5.28.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
}
}
}
provider "google" {
credentials = file(var.key_file)
project = var.project_id
region = var.region
}
data "google_client_config" "default" {
depends_on = [module.gke-cluster]
}
# Defer reading the cluster data until the GKE cluster exists.
data "google_container_cluster" "default" {
name = var.cluster_name
depends_on = [module.gke-cluster]
location = var.region
}
provider "kubernetes" {
host = "https://${data.google_container_cluster.default.endpoint}"
token = data.google_client_config.default.access_token
cluster_ca_certificate = base64decode(
data.google_container_cluster.default.master_auth[0].cluster_ca_certificate,
)
}
#####################################################################################################
# SearXNG - Search engine deployment and service
#####################################################################################################
resource "kubernetes_deployment" "searxng" {
metadata {
name = "searxng"
labels = {
app = "searxng"
}
}
spec {
replicas = 1
selector {
match_labels = {
component = "searxng"
}
}
template {
metadata {
labels = {
component = "searxng"
}
}
spec {
container {
image = var.search_image
name = "searxng-container"
port {
container_port = var.search_port
}
}
}
}
}
}
resource "kubernetes_service" "searxng_service" {
metadata {
name = "searxng-service"
namespace = "default"
annotations = {
"networking.gke.io/load-balancer-type" = "Internal" # Remove to create an external loadbalancer
}
}
spec {
selector = {
component = "searxng"
}
port {
port = var.search_port
target_port = var.search_port
}
type = "LoadBalancer"
}
}
#####################################################################################################
# Perplexica - backend deployment and service
#####################################################################################################
resource "kubernetes_deployment" "backend" {
metadata {
name = "backend"
labels = {
app = "backend"
}
}
spec {
replicas = 1
selector {
match_labels = {
component = "backend"
}
}
template {
metadata {
labels = {
component = "backend"
}
}
spec {
container {
image = var.backend_image
name = "backend-container"
port {
container_port = var.backend_port
}
env {
# searxng service ip
name = "SEARXNG_API_URL"
value = "http://${kubernetes_service.searxng_service.status[0].load_balancer[0].ingress[0].ip}:${var.search_port}"
}
env {
# openai key
name = "OPENAI"
value = var.open_ai
}
env {
# port
name = "PORT"
value = var.backend_port
}
env {
# Access key for backend
name = "SUPER_SECRET_KEY"
value = var.secret_key
}
}
}
}
}
}
resource "kubernetes_service" "backend_service" {
metadata {
name = "backend-service"
namespace = "default"
}
spec {
selector = {
component = "backend"
}
port {
port = var.backend_port
target_port = var.backend_port
}
type = "LoadBalancer"
}
}
#####################################################################################################
# Variable and module definitions
#####################################################################################################
variable "project_id" {
description = "The ID of the project in which the resources will be deployed."
type = string
}
variable "key_file" {
description = "The path to the GCP service account key file."
type = string
}
variable "region" {
description = "The GCP region to deploy to."
type = string
}
variable "cluster_name" {
description = "The GCP region to deploy to."
type = string
}
variable "search_image" {
description = "Tag for the searxng image"
type = string
}
variable "backend_image" {
description = "Tag for the Perplexica backend image"
type = string
}
variable "app_image" {
description = "Tag for the app image"
type = string
}
variable "open_ai" {
description = "OPENAI access key"
type = string
}
variable "secret_key" {
description = "Access key to secure backend endpoints"
type = string
}
variable "search_port" {
description = "Port for searxng service"
type = number
}
variable "backend_port" {
description = "Port for backend service"
type = number
}
module "gke-cluster" {
source = "./gke-cluster"
project_id = var.project_id
name = var.cluster_name
region = var.region
key_file = var.key_file
}

7
deploy/gcp/sample.env Normal file
View File

@ -0,0 +1,7 @@
# Rename this file to .env
# 0: Update to your GCP project id
# 1: Update to the path where the GCP service account credential file is kept
# 2: Update the region to your desired GCP region
GCP_PROJECT_ID=name-of-your-gcp-project
GCP_SERVICE_ACCOUNT_KEY_FILE=/Path/to/your/gcp-service-account-key-file.json
GCP_REGION=us-east1

View File

@ -0,0 +1,3 @@
FROM searxng/searxng
COPY searxng/ /etc/searxng/

View File

@ -1,45 +1,54 @@
services:
searxng:
build:
context: .
dockerfile: searxng.dockerfile
expose:
- 4000
image: docker.io/searxng/searxng:latest
volumes:
- ./searxng:/etc/searxng:rw
ports:
- 4000:8080
networks:
- perplexica-network
restart: unless-stopped
perplexica-backend:
build:
context: .
dockerfile: backend.dockerfile
args:
- SEARXNG_API_URL=http://searxng:8080
- SEARXNG_API_URL=null
volumes:
- "/Volumes/keys/headllamp/keys/:/var/keys/"
- "${GOOGLE_APPLICATION_CREDENTIALS}:/var/keys/gcp_service_account.json"
environment:
SEARXNG_API_URL: 'http://searxng:8080'
SUPER_SECRET_KEY: ${SUPER_SECRET_KEY}
OPENAI: ${OPENAI}
GROQ: ${GROQ}
OLLAMA_API_URL: ${OLLAMA_API_URL}
GOOGLE_APPLICATION_CREDENTIALS: /var/keys/gcp_service_account.json
USE_JWT: ${USE_JWT}
depends_on:
- searxng
expose:
- 3001
ports:
- 3001:3001
networks:
- perplexica-network
restart: unless-stopped
perplexica-frontend:
build:
context: .
dockerfile: app.dockerfile
args:
- NEXT_PUBLIC_SUPER_SECRET_KEY=${SUPER_SECRET_KEY}
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api
- NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001
depends_on:
- perplexica-backend
expose:
- 3000
ports:
- 3000:3000
networks:
- perplexica-network
restart: unless-stopped
networks:
perplexica-network:

View File

@ -5,7 +5,7 @@ Curious about how Perplexica works? Don't worry, we'll cover it here. Before we
We'll understand how Perplexica works by taking an example of a scenario where a user asks: "How does an A.C. work?". We'll break down the process into steps to make it easier to understand. The steps are as follows:
1. The message is sent via WS to the backend server where it invokes the chain. The chain will depend on your focus mode. For this example, let's assume we use the "webSearch" focus mode.
2. The chain is now invoked; first, the message is passed to another chain where it first predicts (using the chat history and the question) whether there is a need for sources or searching the web. If there is, it will generate a query (in accordance with the chat history) for searching the web that we'll take up later. If not, the chain will end there, and then the answer generator chain, also known as the response generator, will be started.
2. The chain is now invoked; first, the message is passed to another chain where it first predicts (using the chat history and the question) whether there is a need for sources and searching the web. If there is, it will generate a query (in accordance with the chat history) for searching the web that we'll take up later. If not, the chain will end there, and then the answer generator chain, also known as the response generator, will be started.
3. The query returned by the first chain is passed to SearXNG to search the web for information.
4. After the information is retrieved, it is based on keyword-based search. We then convert the information into embeddings and the query as well, then we perform a similarity search to find the most relevant sources to answer the query.
5. After all this is done, the sources are passed to the response generator. This chain takes all the chat history, the query, and the sources. It generates a response that is streamed to the UI.

View File

@ -0,0 +1,109 @@
# Expose Perplexica to a network
This guide will show you how to make Perplexica available over a network. Follow these steps to allow computers on the same network to interact with Perplexica. Choose the instructions that match the operating system you are using.
## Windows
1. Open PowerShell as Administrator
2. Navigate to the directory containing the `docker-compose.yaml` file
3. Stop and remove the existing Perplexica containers and images:
```
docker compose down --rmi all
```
4. Open the `docker-compose.yaml` file in a text editor like Notepad++
5. Replace `127.0.0.1` with the IP address of the server Perplexica is running on in these two lines:
```
args:
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api
- NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001
```
6. Save and close the `docker-compose.yaml` file
7. Rebuild and restart the Perplexica container:
```
docker compose up -d --build
```
## macOS
1. Open the Terminal application
2. Navigate to the directory with the `docker-compose.yaml` file:
```
cd /path/to/docker-compose.yaml
```
3. Stop and remove existing containers and images:
```
docker compose down --rmi all
```
4. Open `docker-compose.yaml` in a text editor like Sublime Text:
```
nano docker-compose.yaml
```
5. Replace `127.0.0.1` with the server IP in these lines:
```
args:
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api
- NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001
```
6. Save and exit the editor
7. Rebuild and restart Perplexica:
```
docker compose up -d --build
```
## Linux
1. Open the terminal
2. Navigate to the `docker-compose.yaml` directory:
```
cd /path/to/docker-compose.yaml
```
3. Stop and remove containers and images:
```
docker compose down --rmi all
```
4. Edit `docker-compose.yaml`:
```
nano docker-compose.yaml
```
5. Replace `127.0.0.1` with the server IP:
```
args:
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api
- NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001
```
6. Save and exit the editor
7. Rebuild and restart Perplexica:
```
docker compose up -d --build
```

View File

@ -1,6 +1,6 @@
{
"name": "perplexica-backend",
"version": "1.3.0",
"version": "1.5.0",
"license": "MIT",
"author": "ItzCrazyKns",
"scripts": {
@ -21,7 +21,9 @@
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@langchain/google-vertexai": "^0.0.16",
"@langchain/openai": "^0.0.25",
"@xenova/transformers": "^2.17.1",
"axios": "^1.6.8",
"compute-cosine-similarity": "^1.1.0",
"compute-dot": "^1.1.0",

View File

@ -8,4 +8,4 @@ GROQ = "" # Groq API key - gsk_1234567890abcdef1234567890abcdef
[API_ENDPOINTS]
SEARXNG = "http://localhost:32768" # SearxNG API URL
OLLAMA = "" # Ollama API URL - http://host.docker.internal:11434
OLLAMA = "" # Ollama API URL - http://host.docker.internal:11434

24
sample.env Normal file
View File

@ -0,0 +1,24 @@
# Copy this file over to .env and fill in the desired config.
# .env will become available to docker compose and these values will be
# used when running docker compose up
# Edit to set OpenAI access key
OPENAI=ADD OPENAI KEY HERE
# Uncomment and edit to set GROQ access key
# GROQ: ${GROQ}
# Uncomment and edit to set OLLAMA Url
# OLLAMA_API_URL: ${OLLAMA_API_URL}
# Address and port of the remotely deployed Perplexica backend
REMOTE_BACKEND_ADDRESS=111.111.111.111:0000
# Uncomment and edit to configure backend to reject requests without token
# leave commented to have open access to all endpoints
# Secret key to "secure" backend
# SUPER_SECRET_KEY=THISISASUPERSECRETKEYSERIOUSLY
# Uncomment and edit to configure a specific service account key file to use to
# auth with VertexAI when running (backend) full Perplexica stack locally
# GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/gcp-service-account-key-file.json

View File

@ -1,3 +0,0 @@
FROM searxng/searxng
COPY searxng-settings.yml /etc/searxng/settings.yml

3
searxng/limiter.toml Normal file
View File

@ -0,0 +1,3 @@
[botdetection.ip_limit]
# activate link_token method in the ip_limit method
link_token = true

50
searxng/uwsgi.ini Normal file
View File

@ -0,0 +1,50 @@
[uwsgi]
# Who will run the code
uid = searxng
gid = searxng
# Number of workers (usually CPU count)
# default value: %k (= number of CPU core, see Dockerfile)
workers = %k
# Number of threads per worker
# default value: 4 (see Dockerfile)
threads = 4
# The right granted on the created socket
chmod-socket = 666
# Plugin to use and interpreter config
single-interpreter = true
master = true
plugin = python3
lazy-apps = true
enable-threads = 4
# Module to import
module = searx.webapp
# Virtualenv and python path
pythonpath = /usr/local/searxng/
chdir = /usr/local/searxng/searx/
# automatically set processes name to something meaningful
auto-procname = true
# Disable request logging for privacy
disable-logging = true
log-5xx = true
# Set the max size of a request (request-body excluded)
buffer-size = 8192
# No keep alive
# See https://github.com/searx/searx-docker/issues/24
add-header = Connection: close
# uwsgi serves the static files
static-map = /static=/usr/local/searxng/searx/static
# expires set to one day
static-expires = /* 86400
static-gzip-all = True
offload-threads = 4

View File

@ -209,7 +209,6 @@ const createBasicAcademicSearchAnsweringChain = (
ChatPromptTemplate.fromMessages([
['system', basicAcademicSearchResponsePrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
strParser,

View File

@ -205,7 +205,6 @@ const createBasicRedditSearchAnsweringChain = (
ChatPromptTemplate.fromMessages([
['system', basicRedditSearchResponsePrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
strParser,

View File

@ -0,0 +1,55 @@
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import ListLineOutputParser from '../lib/outputParsers/listLineOutputParser';
import { PromptTemplate } from '@langchain/core/prompts';
import formatChatHistoryAsString from '../utils/formatHistory';
import { BaseMessage } from '@langchain/core/messages';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatOpenAI } from '@langchain/openai';
const suggestionGeneratorPrompt = `
You are an AI suggestion generator for an AI powered search engine. You will be given a conversation below. You need to generate 4-5 suggestions based on the conversation. The suggestion should be relevant to the conversation that can be used by the user to ask the chat model for more information.
You need to make sure the suggestions are relevant to the conversation and are helpful to the user. Keep a note that the user might use these suggestions to ask a chat model for more information.
Make sure the suggestions are medium in length and are informative and relevant to the conversation.
Provide these suggestions separated by newlines between the XML tags <suggestions> and </suggestions>. For example:
<suggestions>
Tell me more about SpaceX and their recent projects
What is the latest news on SpaceX?
Who is the CEO of SpaceX?
</suggestions>
Conversation:
{chat_history}
`;
type SuggestionGeneratorInput = {
chat_history: BaseMessage[];
};
const outputParser = new ListLineOutputParser({
key: 'suggestions',
});
const createSuggestionGeneratorChain = (llm: BaseChatModel) => {
return RunnableSequence.from([
RunnableMap.from({
chat_history: (input: SuggestionGeneratorInput) =>
formatChatHistoryAsString(input.chat_history),
}),
PromptTemplate.fromTemplate(suggestionGeneratorPrompt),
llm,
outputParser,
]);
};
const generateSuggestions = (
input: SuggestionGeneratorInput,
llm: BaseChatModel,
) => {
(llm as ChatOpenAI).temperature = 0;
const suggestionGeneratorChain = createSuggestionGeneratorChain(llm);
return suggestionGeneratorChain.invoke(input);
};
export default generateSuggestions;

View File

@ -203,7 +203,6 @@ const createBasicWebSearchAnsweringChain = (
ChatPromptTemplate.fromMessages([
['system', basicWebSearchResponsePrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
strParser,

View File

@ -165,7 +165,6 @@ const createBasicWolframAlphaSearchAnsweringChain = (llm: BaseChatModel) => {
ChatPromptTemplate.fromMessages([
['system', basicWolframAlphaSearchResponsePrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
strParser,

View File

@ -46,7 +46,6 @@ const createWritingAssistantChain = (llm: BaseChatModel) => {
ChatPromptTemplate.fromMessages([
['system', writingAssistantPrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
strParser,

View File

@ -205,7 +205,6 @@ const createBasicYoutubeSearchAnsweringChain = (
ChatPromptTemplate.fromMessages([
['system', basicYoutubeSearchResponsePrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
strParser,

View File

@ -3,7 +3,8 @@ import express from 'express';
import cors from 'cors';
import http from 'http';
import routes from './routes';
import { getPort } from './config';
import { requireAccessKey } from './auth';
import { getAccessKey, getPort } from './config';
import logger from './utils/logger';
const port = getPort();
@ -13,11 +14,21 @@ const server = http.createServer(app);
const corsOptions = {
origin: '*',
allowedHeaders: ['Authorization', 'Content-Type'],
};
app.use(cors(corsOptions));
if (getAccessKey()) {
app.all('/api/*', requireAccessKey);
}
app.use(express.json());
app.get('/', (_, res) => {
res.status(200).json({ status: 'ok' });
});
app.use('/api', routes);
app.get('/api', (_, res) => {
res.status(200).json({ status: 'ok' });

29
src/auth.ts Normal file
View File

@ -0,0 +1,29 @@
import { auth } from 'google-auth-library';
import { getAccessKey } from './config';
export const requireAccessKey = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
if (!checkAccessKey(authHeader)) {
return res.sendStatus(403);
}
next();
} else {
res.sendStatus(401);
}
};
export const checkAccessKey = (authHeader) => {
const token = authHeader.split(' ')[1];
return Boolean(authHeader && token === getAccessKey());
};
export const hasGCPCredentials = async () => {
try {
const credentials = await auth.getCredentials();
return Object.keys(credentials).length > 0;
} catch (e) {
return false;
}
};

View File

@ -8,6 +8,7 @@ interface Config {
GENERAL: {
PORT: number;
SIMILARITY_MEASURE: string;
SUPER_SECRET_KEY: string;
};
API_KEYS: {
OPENAI: string;
@ -28,18 +29,43 @@ const loadConfig = () =>
fs.readFileSync(path.join(__dirname, `../${configFileName}`), 'utf-8'),
) as any as Config;
const loadEnv = () => {
return {
GENERAL: {
PORT: Number(process.env.PORT),
SIMILARITY_MEASURE: process.env.SIMILARITY_MEASURE,
SUPER_SECRET_KEY: process.env.SUPER_SECRET_KEY,
},
API_KEYS: {
OPENAI: process.env.OPENAI,
GROQ: process.env.GROQ,
},
API_ENDPOINTS: {
SEARXNG: process.env.SEARXNG_API_URL,
OLLAMA: process.env.OLLAMA_API_URL,
},
} as Config;
};
export const getPort = () => loadConfig().GENERAL.PORT;
export const getAccessKey = () =>
loadEnv().GENERAL.SUPER_SECRET_KEY || loadConfig().GENERAL.SUPER_SECRET_KEY;
export const getSimilarityMeasure = () =>
loadConfig().GENERAL.SIMILARITY_MEASURE;
export const getOpenaiApiKey = () => loadConfig().API_KEYS.OPENAI;
export const getOpenaiApiKey = () =>
loadEnv().API_KEYS.OPENAI || loadConfig().API_KEYS.OPENAI;
export const getGroqApiKey = () => loadConfig().API_KEYS.GROQ;
export const getGroqApiKey = () =>
loadEnv().API_KEYS.GROQ || loadConfig().API_KEYS.GROQ;
export const getSearxngApiEndpoint = () => loadConfig().API_ENDPOINTS.SEARXNG;
export const getSearxngApiEndpoint = () =>
loadEnv().API_ENDPOINTS.SEARXNG || loadConfig().API_ENDPOINTS.SEARXNG;
export const getOllamaApiEndpoint = () => loadConfig().API_ENDPOINTS.OLLAMA;
export const getOllamaApiEndpoint = () =>
loadEnv().API_ENDPOINTS.OLLAMA || loadConfig().API_ENDPOINTS.OLLAMA;
export const updateConfig = (config: RecursivePartial<Config>) => {
const currentConfig = loadConfig();

View File

@ -0,0 +1,82 @@
import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings';
import { chunkArray } from '@langchain/core/utils/chunk_array';
export interface HuggingFaceTransformersEmbeddingsParams
extends EmbeddingsParams {
modelName: string;
model: string;
timeout?: number;
batchSize?: number;
stripNewLines?: boolean;
}
export class HuggingFaceTransformersEmbeddings
extends Embeddings
implements HuggingFaceTransformersEmbeddingsParams
{
modelName = 'Xenova/all-MiniLM-L6-v2';
model = 'Xenova/all-MiniLM-L6-v2';
batchSize = 512;
stripNewLines = true;
timeout?: number;
private pipelinePromise: Promise<any>;
constructor(fields?: Partial<HuggingFaceTransformersEmbeddingsParams>) {
super(fields ?? {});
this.modelName = fields?.model ?? fields?.modelName ?? this.model;
this.model = this.modelName;
this.stripNewLines = fields?.stripNewLines ?? this.stripNewLines;
this.timeout = fields?.timeout;
}
async embedDocuments(texts: string[]): Promise<number[][]> {
const batches = chunkArray(
this.stripNewLines ? texts.map((t) => t.replace(/\n/g, ' ')) : texts,
this.batchSize,
);
const batchRequests = batches.map((batch) => this.runEmbedding(batch));
const batchResponses = await Promise.all(batchRequests);
const embeddings: number[][] = [];
for (let i = 0; i < batchResponses.length; i += 1) {
const batchResponse = batchResponses[i];
for (let j = 0; j < batchResponse.length; j += 1) {
embeddings.push(batchResponse[j]);
}
}
return embeddings;
}
async embedQuery(text: string): Promise<number[]> {
const data = await this.runEmbedding([
this.stripNewLines ? text.replace(/\n/g, ' ') : text,
]);
return data[0];
}
private async runEmbedding(texts: string[]) {
const { pipeline } = await import('@xenova/transformers');
const pipe = await (this.pipelinePromise ??= pipeline(
'feature-extraction',
this.model,
));
return this.caller.call(async () => {
const output = await pipe(texts, { pooling: 'mean', normalize: true });
return output.tolist();
});
}
}

View File

@ -0,0 +1,43 @@
import { BaseOutputParser } from '@langchain/core/output_parsers';
interface LineListOutputParserArgs {
key?: string;
}
class LineListOutputParser extends BaseOutputParser<string[]> {
private key = 'questions';
constructor(args?: LineListOutputParserArgs) {
super();
this.key = args.key ?? this.key;
}
static lc_name() {
return 'LineListOutputParser';
}
lc_namespace = ['langchain', 'output_parsers', 'line_list_output_parser'];
async parse(text: string): Promise<string[]> {
const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/;
const startKeyIndex = text.indexOf(`<${this.key}>`);
const endKeyIndex = text.indexOf(`</${this.key}>`);
const questionsStartIndex =
startKeyIndex === -1 ? 0 : startKeyIndex + `<${this.key}>`.length;
const questionsEndIndex = endKeyIndex === -1 ? text.length : endKeyIndex;
const lines = text
.slice(questionsStartIndex, questionsEndIndex)
.trim()
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => line.replace(regex, ''));
return lines;
}
getFormatInstructions(): string {
throw new Error('Not implemented.');
}
}
export default LineListOutputParser;

View File

@ -1,6 +1,10 @@
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { ChatOllama } from '@langchain/community/chat_models/ollama';
import { VertexAI } from "@langchain/google-vertexai";
import { GoogleVertexAIEmbeddings } from "@langchain/community/embeddings/googlevertexai";
import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
import { HuggingFaceTransformersEmbeddings } from './huggingfaceTransformer';
import { hasGCPCredentials } from '../auth';
import {
getGroqApiKey,
getOllamaApiEndpoint,
@ -33,6 +37,11 @@ export const getAvailableChatModelProviders = async () => {
modelName: 'gpt-4-turbo',
temperature: 0.7,
}),
'GPT-4 omni': new ChatOpenAI({
openAIApiKey,
modelName: 'gpt-4o',
temperature: 0.7,
}),
};
} catch (err) {
logger.error(`Error loading OpenAI models: ${err}`);
@ -90,7 +99,11 @@ export const getAvailableChatModelProviders = async () => {
if (ollamaEndpoint) {
try {
const response = await fetch(`${ollamaEndpoint}/api/tags`);
const response = await fetch(`${ollamaEndpoint}/api/tags`, {
headers: {
'Content-Type': 'application/json',
},
});
const { models: ollamaModels } = (await response.json()) as any;
@ -107,6 +120,23 @@ export const getAvailableChatModelProviders = async () => {
}
}
if (await hasGCPCredentials()) {
try {
models['vertexai'] = {
'gemini-1.5-pro (preview-0409)': new VertexAI({
temperature: 0.7,
modelName: 'gemini-1.5-pro-preview-0409',
}),
'gemini-1.0-pro (Latest)': new VertexAI({
temperature: 0.7,
modelName: 'gemini-1.0-pro',
}),
};
} catch (err) {
logger.error(`Error loading VertexAI models: ${err}`);
}
}
models['custom_openai'] = {};
return models;
@ -137,7 +167,11 @@ export const getAvailableEmbeddingModelProviders = async () => {
if (ollamaEndpoint) {
try {
const response = await fetch(`${ollamaEndpoint}/api/tags`);
const response = await fetch(`${ollamaEndpoint}/api/tags`, {
headers: {
'Content-Type': 'application/json',
},
});
const { models: ollamaModels } = (await response.json()) as any;
@ -153,5 +187,31 @@ export const getAvailableEmbeddingModelProviders = async () => {
}
}
if (await hasGCPCredentials()) {
try {
models['vertexai'] = {
'Text Gecko default': new GoogleVertexAIEmbeddings(),
}
} catch (err) {
logger.error(`Error loading VertexAI embeddings: ${err}`);
}
}
try {
models['local'] = {
'BGE Small': new HuggingFaceTransformersEmbeddings({
modelName: 'Xenova/bge-small-en-v1.5',
}),
'GTE Small': new HuggingFaceTransformersEmbeddings({
modelName: 'Xenova/gte-small',
}),
'Bert Multilingual': new HuggingFaceTransformersEmbeddings({
modelName: 'Xenova/bert-base-multilingual-uncased',
}),
};
} catch (err) {
logger.error(`Error loading local embeddings: ${err}`);
}
return models;
};

View File

@ -20,8 +20,8 @@ router.post('/', async (req, res) => {
});
const chatModels = await getAvailableChatModelProviders();
const provider = chat_model_provider || Object.keys(chatModels)[0];
const chatModel = chat_model || Object.keys(chatModels[provider])[0];
const provider = chat_model_provider ?? Object.keys(chatModels)[0];
const chatModel = chat_model ?? Object.keys(chatModels[provider])[0];
let llm: BaseChatModel | undefined;

View File

@ -3,6 +3,7 @@ import imagesRouter from './images';
import videosRouter from './videos';
import configRouter from './config';
import modelsRouter from './models';
import suggestionsRouter from './suggestions';
const router = express.Router();
@ -10,5 +11,6 @@ router.use('/images', imagesRouter);
router.use('/videos', videosRouter);
router.use('/config', configRouter);
router.use('/models', modelsRouter);
router.use('/suggestions', suggestionsRouter);
export default router;

46
src/routes/suggestions.ts Normal file
View File

@ -0,0 +1,46 @@
import express from 'express';
import generateSuggestions from '../agents/suggestionGeneratorAgent';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getAvailableChatModelProviders } from '../lib/providers';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
import logger from '../utils/logger';
const router = express.Router();
router.post('/', async (req, res) => {
try {
let { chat_history, chat_model, chat_model_provider } = req.body;
chat_history = chat_history.map((msg: any) => {
if (msg.role === 'user') {
return new HumanMessage(msg.content);
} else if (msg.role === 'assistant') {
return new AIMessage(msg.content);
}
});
const chatModels = await getAvailableChatModelProviders();
const provider = chat_model_provider ?? Object.keys(chatModels)[0];
const chatModel = chat_model ?? Object.keys(chatModels[provider])[0];
let llm: BaseChatModel | undefined;
if (chatModels[provider] && chatModels[provider][chatModel]) {
llm = chatModels[provider][chatModel] as BaseChatModel | undefined;
}
if (!llm) {
res.status(500).json({ message: 'Invalid LLM model selected' });
return;
}
const suggestions = await generateSuggestions({ chat_history }, llm);
res.status(200).json({ suggestions: suggestions });
} catch (err) {
res.status(500).json({ message: 'An error has occurred.' });
logger.error(`Error in generating suggestions: ${err.message}`);
}
});
export default router;

View File

@ -20,8 +20,8 @@ router.post('/', async (req, res) => {
});
const chatModels = await getAvailableChatModelProviders();
const provider = chat_model_provider || Object.keys(chatModels)[0];
const chatModel = chat_model || Object.keys(chatModels[provider])[0];
const provider = chat_model_provider ?? Object.keys(chatModels)[0];
const chatModel = chat_model ?? Object.keys(chatModels[provider])[0];
let llm: BaseChatModel | undefined;

View File

@ -9,78 +9,108 @@ import type { Embeddings } from '@langchain/core/embeddings';
import type { IncomingMessage } from 'http';
import logger from '../utils/logger';
import { ChatOpenAI } from '@langchain/openai';
import { getAccessKey } from '../config';
import { checkAccessKey } from '../auth';
export const handleConnection = async (
ws: WebSocket,
request: IncomingMessage,
) => {
const searchParams = new URL(request.url, `http://${request.headers.host}`)
.searchParams;
try {
const searchParams = new URL(request.url, `http://${request.headers.host}`)
.searchParams;
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
getAvailableChatModelProviders(),
getAvailableEmbeddingModelProviders(),
]);
if (getAccessKey()) {
const securtyProtocolHeader = request.headers['sec-websocket-protocol'];
if (!checkAccessKey(securtyProtocolHeader)) {
ws.send(
JSON.stringify({
type: 'error',
data: 'Incorrect or missing authentication token.',
key: 'FAILED_AUTHORIZATION',
}),
);
ws.close();
}
}
const chatModelProvider =
searchParams.get('chatModelProvider') || Object.keys(chatModelProviders)[0];
const chatModel =
searchParams.get('chatModel') ||
Object.keys(chatModelProviders[chatModelProvider])[0];
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
getAvailableChatModelProviders(),
getAvailableEmbeddingModelProviders(),
]);
const embeddingModelProvider =
searchParams.get('embeddingModelProvider') ||
Object.keys(embeddingModelProviders)[0];
const embeddingModel =
searchParams.get('embeddingModel') ||
Object.keys(embeddingModelProviders[embeddingModelProvider])[0];
const chatModelProvider =
searchParams.get('chatModelProvider') ||
Object.keys(chatModelProviders)[0];
const chatModel =
searchParams.get('chatModel') ||
Object.keys(chatModelProviders[chatModelProvider])[0];
let llm: BaseChatModel | undefined;
let embeddings: Embeddings | undefined;
const embeddingModelProvider =
searchParams.get('embeddingModelProvider') ||
Object.keys(embeddingModelProviders)[0];
const embeddingModel =
searchParams.get('embeddingModel') ||
Object.keys(embeddingModelProviders[embeddingModelProvider])[0];
if (
chatModelProviders[chatModelProvider] &&
chatModelProviders[chatModelProvider][chatModel] &&
chatModelProvider != 'custom_openai'
) {
llm = chatModelProviders[chatModelProvider][chatModel] as
| BaseChatModel
| undefined;
} else if (chatModelProvider == 'custom_openai') {
llm = new ChatOpenAI({
modelName: chatModel,
openAIApiKey: searchParams.get('openAIApiKey'),
temperature: 0.7,
configuration: {
baseURL: searchParams.get('openAIBaseURL'),
},
});
}
let llm: BaseChatModel | undefined;
let embeddings: Embeddings | undefined;
if (
embeddingModelProviders[embeddingModelProvider] &&
embeddingModelProviders[embeddingModelProvider][embeddingModel]
) {
embeddings = embeddingModelProviders[embeddingModelProvider][
embeddingModel
] as Embeddings | undefined;
}
if (
chatModelProviders[chatModelProvider] &&
chatModelProviders[chatModelProvider][chatModel] &&
chatModelProvider != 'custom_openai'
) {
llm = chatModelProviders[chatModelProvider][chatModel] as
| BaseChatModel
| undefined;
} else if (chatModelProvider == 'custom_openai') {
llm = new ChatOpenAI({
modelName: chatModel,
openAIApiKey: searchParams.get('openAIApiKey'),
temperature: 0.7,
configuration: {
baseURL: searchParams.get('openAIBaseURL'),
},
});
}
if (!llm || !embeddings) {
if (
embeddingModelProviders[embeddingModelProvider] &&
embeddingModelProviders[embeddingModelProvider][embeddingModel]
) {
embeddings = embeddingModelProviders[embeddingModelProvider][
embeddingModel
] as Embeddings | undefined;
}
if (!llm || !embeddings) {
ws.send(
JSON.stringify({
type: 'error',
data: 'Invalid LLM or embeddings model selected, please refresh the page and try again.',
key: 'INVALID_MODEL_SELECTED',
}),
);
ws.close();
}
ws.on(
'message',
async (message) =>
await handleMessage(message.toString(), ws, llm, embeddings),
);
ws.on('close', () => logger.debug('Connection closed'));
} catch (err) {
ws.send(
JSON.stringify({
type: 'error',
data: 'Invalid LLM or embeddings model selected',
data: 'Internal server error.',
key: 'INTERNAL_SERVER_ERROR',
}),
);
ws.close();
logger.error(err);
}
ws.on(
'message',
async (message) =>
await handleMessage(message.toString(), ws, llm, embeddings),
);
ws.on('close', () => logger.debug('Connection closed'));
};

View File

@ -57,7 +57,13 @@ const handleEmitterEvents = (
});
emitter.on('error', (data) => {
const parsedData = JSON.parse(data);
ws.send(JSON.stringify({ type: 'error', data: parsedData.data }));
ws.send(
JSON.stringify({
type: 'error',
data: parsedData.data,
key: 'CHAIN_ERROR',
}),
);
});
};
@ -73,7 +79,11 @@ export const handleMessage = async (
if (!parsedMessage.content)
return ws.send(
JSON.stringify({ type: 'error', data: 'Invalid message format' }),
JSON.stringify({
type: 'error',
data: 'Invalid message format',
key: 'INVALID_FORMAT',
}),
);
const history: BaseMessage[] = parsedMessage.history.map((msg) => {
@ -99,11 +109,23 @@ export const handleMessage = async (
);
handleEmitterEvents(emitter, ws, id);
} else {
ws.send(JSON.stringify({ type: 'error', data: 'Invalid focus mode' }));
ws.send(
JSON.stringify({
type: 'error',
data: 'Invalid focus mode',
key: 'INVALID_FOCUS_MODE',
}),
);
}
}
} catch (err) {
ws.send(JSON.stringify({ type: 'error', data: 'Invalid message format' }));
ws.send(
JSON.stringify({
type: 'error',
data: 'Invalid message format',
key: 'INVALID_FORMAT',
}),
);
logger.error(`Failed to handle message: ${err}`);
}
};

View File

@ -1,7 +1,8 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "commonjs",
"module": "Node16",
"moduleResolution": "Node16",
"target": "ESNext",
"outDir": "dist",
"sourceMap": false,

View File

@ -3,6 +3,7 @@ import { Montserrat } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner';
const montserrat = Montserrat({
weight: ['300', '400', '500', '700'],
@ -26,6 +27,15 @@ export default function RootLayout({
<html className="h-full" lang="en">
<body className={cn('h-full', montserrat.className)}>
<Sidebar>{children}</Sidebar>
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast:
'bg-[#111111] text-white rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</body>
</html>
);

View File

@ -1,5 +1,6 @@
import ChatWindow from '@/components/ChatWindow';
import { Metadata } from 'next';
import { Suspense } from 'react';
export const metadata: Metadata = {
title: 'Chat - Perplexica',
@ -9,7 +10,9 @@ export const metadata: Metadata = {
const Home = () => {
return (
<div>
<ChatWindow />
<Suspense>
<ChatWindow />
</Suspense>
</div>
);
};

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import MessageInput from './MessageInput';
import { Message } from './ChatWindow';
import MessageBox from './MessageBox';
@ -53,7 +53,7 @@ const Chat = ({
const isLast = i === messages.length - 1;
return (
<>
<Fragment key={msg.id}>
<MessageBox
key={i}
message={msg}
@ -63,11 +63,12 @@ const Chat = ({
dividerRef={isLast ? dividerRef : undefined}
isLast={isLast}
rewrite={rewrite}
sendMessage={sendMessage}
/>
{!isLast && msg.role === 'assistant' && (
<div className="h-px w-full bg-[#1C1C1C]" />
)}
</>
</Fragment>
);
})}
{loading && !messageAppeared && <MessageBoxLoading />}

View File

@ -1,20 +1,26 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Document } from '@langchain/core/documents';
import Navbar from './Navbar';
import Chat from './Chat';
import EmptyChat from './EmptyChat';
import { toast } from 'sonner';
import { useSearchParams } from 'next/navigation';
import { getSuggestions } from '@/lib/actions';
import { clientFetch } from '@/lib/utils';
import { getAccessKey } from '@/lib/config';
export type Message = {
id: string;
createdAt: Date;
content: string;
role: 'user' | 'assistant';
suggestions?: string[];
sources?: Document[];
};
const useSocket = (url: string) => {
const useSocket = (url: string, setIsReady: (ready: boolean) => void) => {
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
@ -33,9 +39,11 @@ const useSocket = (url: string) => {
!embeddingModel ||
!embeddingModelProvider
) {
const providers = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/models`,
).then(async (res) => await res.json());
const providers = await clientFetch('/models', {
headers: {
'Content-Type': 'application/json',
},
}).then(async (res) => await res.json());
const chatModelProviders = providers.chatModelProviders;
const embeddingModelProviders = providers.embeddingModelProviders;
@ -44,13 +52,13 @@ const useSocket = (url: string) => {
!chatModelProviders ||
Object.keys(chatModelProviders).length === 0
)
return console.error('No chat models available');
return toast.error('No chat models available');
if (
!embeddingModelProviders ||
Object.keys(embeddingModelProviders).length === 0
)
return console.error('No embedding models available');
return toast.error('No embedding models available');
chatModelProvider = Object.keys(chatModelProviders)[0];
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
@ -91,10 +99,36 @@ const useSocket = (url: string) => {
wsURL.search = searchParams.toString();
const ws = new WebSocket(wsURL.toString());
let protocols: any[] = [];
const secretToken = getAccessKey();
if (secretToken) {
protocols = ['Authorization', `${secretToken}`];
}
const ws = new WebSocket(wsURL.toString(), protocols);
ws.onopen = () => {
console.log('[DEBUG] open');
setWs(ws);
};
const stateCheckInterval = setInterval(() => {
if (ws.readyState === 1) {
setIsReady(true);
clearInterval(stateCheckInterval);
}
}, 100);
setWs(ws);
ws.onmessage = (e) => {
const parsedData = JSON.parse(e.data);
if (parsedData.type === 'error') {
toast.error(parsedData.data);
if (parsedData.key === 'INVALID_MODEL_SELECTED') {
localStorage.clear();
}
}
};
};
@ -102,23 +136,32 @@ const useSocket = (url: string) => {
}
return () => {
1;
ws?.close();
console.log('[DEBUG] closed');
};
}, [ws, url]);
}, [ws, url, setIsReady]);
return ws;
};
const ChatWindow = () => {
const ws = useSocket(process.env.NEXT_PUBLIC_WS_URL!);
const searchParams = useSearchParams();
const initialMessage = searchParams.get('q');
const [isReady, setIsReady] = useState(false);
const ws = useSocket(process.env.NEXT_PUBLIC_WS_URL!, setIsReady);
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const messagesRef = useRef<Message[]>([]);
const [loading, setLoading] = useState(false);
const [messageAppeared, setMessageAppeared] = useState(false);
const [focusMode, setFocusMode] = useState('webSearch');
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
const sendMessage = async (message: string) => {
if (loading) return;
setLoading(true);
@ -147,9 +190,15 @@ const ChatWindow = () => {
},
]);
const messageHandler = (e: MessageEvent) => {
const messageHandler = async (e: MessageEvent) => {
const data = JSON.parse(e.data);
if (data.type === 'error') {
toast.error(data.data);
setLoading(false);
return;
}
if (data.type === 'sources') {
sources = data.data;
if (!added) {
@ -203,8 +252,28 @@ const ChatWindow = () => {
['human', message],
['assistant', recievedMessage],
]);
ws?.removeEventListener('message', messageHandler);
setLoading(false);
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
if (
lastMsg.role === 'assistant' &&
lastMsg.sources &&
lastMsg.sources.length > 0 &&
!lastMsg.suggestions
) {
const suggestions = await getSuggestions(messagesRef.current);
setMessages((prev) =>
prev.map((msg) => {
if (msg.id === lastMsg.id) {
return { ...msg, suggestions: suggestions };
}
return msg;
}),
);
}
}
};
@ -228,7 +297,14 @@ const ChatWindow = () => {
sendMessage(message.content);
};
return ws ? (
useEffect(() => {
if (isReady && initialMessage) {
sendMessage(initialMessage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady, initialMessage]);
return isReady ? (
<div>
{messages.length > 0 ? (
<>

View File

@ -1,7 +1,7 @@
import { ArrowRight } from 'lucide-react';
import { useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { Attach, CopilotToggle, Focus } from './MessageInputActions';
import { CopilotToggle, Focus } from './MessageInputActions';
const EmptyChatMessageInput = ({
sendMessage,

View File

@ -10,9 +10,10 @@ const Rewrite = ({
return (
<button
onClick={() => rewrite(messageId)}
className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white"
className="py-2 px-3 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white flex flex-row items-center space-x-1"
>
<ArrowLeftRight size={18} />
<p className="text-xs font-medium">Rewrite</p>
</button>
);
};

View File

@ -4,7 +4,15 @@
import React, { MutableRefObject, useEffect, useState } from 'react';
import { Message } from './ChatWindow';
import { cn } from '@/lib/utils';
import { BookCopy, Disc3, Share, Volume2, StopCircle } from 'lucide-react';
import {
BookCopy,
Disc3,
Share,
Volume2,
StopCircle,
Layers3,
Plus,
} from 'lucide-react';
import Markdown from 'markdown-to-jsx';
import Copy from './MessageActions/Copy';
import Rewrite from './MessageActions/Rewrite';
@ -21,6 +29,7 @@ const MessageBox = ({
dividerRef,
isLast,
rewrite,
sendMessage,
}: {
message: Message;
messageIndex: number;
@ -29,6 +38,7 @@ const MessageBox = ({
dividerRef?: MutableRefObject<HTMLDivElement | null>;
isLast: boolean;
rewrite: (messageId: string) => void;
sendMessage: (message: string) => void;
}) => {
const [parsedMessage, setParsedMessage] = useState(message.content);
const [speechMessage, setSpeechMessage] = useState(message.content);
@ -98,9 +108,9 @@ const MessageBox = ({
{loading && isLast ? null : (
<div className="flex flex-row items-center justify-between w-full text-white py-4 -mx-2">
<div className="flex flex-row items-center space-x-1">
<button className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
{/* <button className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
<Share size={18} />
</button>
</button> */}
<Rewrite rewrite={rewrite} messageId={message.id} />
</div>
<div className="flex flex-row items-center space-x-1">
@ -124,6 +134,42 @@ const MessageBox = ({
</div>
</div>
)}
{isLast &&
message.suggestions &&
message.suggestions.length > 0 &&
message.role === 'assistant' &&
!loading && (
<>
<div className="h-px w-full bg-[#1C1C1C]" />
<div className="flex flex-col space-y-3 text-white">
<div className="flex flex-row items-center space-x-2 mt-4">
<Layers3 />
<h3 className="text-xl font-medium">Related</h3>
</div>
<div className="flex flex-col space-y-3">
{message.suggestions.map((suggestion, i) => (
<div
className="flex flex-col space-y-3 text-sm"
key={i}
>
<div className="h-px w-full bg-[#1C1C1C]" />
<div
onClick={() => {
sendMessage(suggestion);
}}
className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center"
>
<p className="transition duration-200 hover:text-[#24A0ED]">
{suggestion}
</p>
<Plus size={20} className="text-[#24A0ED]" />
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">

View File

@ -4,6 +4,7 @@ import { useState } from 'react';
import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
import { clientFetch } from '@/lib/utils';
type Image = {
url: string;
@ -33,21 +34,18 @@ const SearchImages = ({
const chatModelProvider = localStorage.getItem('chatModelProvider');
const chatModel = localStorage.getItem('chatModel');
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/images`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
chat_history: chat_history,
chat_model_provider: chatModelProvider,
chat_model: chatModel,
}),
const res = await clientFetch('/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
);
body: JSON.stringify({
query: query,
chat_history: chat_history,
chat_model_provider: chatModelProvider,
chat_model: chatModel,
}),
});
const data = await res.json();

View File

@ -4,6 +4,7 @@ import { useState } from 'react';
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
import { clientFetch } from '@/lib/utils';
type Video = {
url: string;
@ -46,21 +47,18 @@ const Searchvideos = ({
const chatModelProvider = localStorage.getItem('chatModelProvider');
const chatModel = localStorage.getItem('chatModel');
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/videos`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
chat_history: chat_history,
chat_model_provider: chatModelProvider,
chat_model: chatModel,
}),
const res = await clientFetch('/videos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
);
body: JSON.stringify({
query: query,
chat_history: chat_history,
chat_model_provider: chatModelProvider,
chat_model: chatModel,
}),
});
const data = await res.json();

View File

@ -1,6 +1,7 @@
import { Dialog, Transition } from '@headlessui/react';
import { CloudUpload, RefreshCcw, RefreshCw } from 'lucide-react';
import React, { Fragment, useEffect, useState } from 'react';
import { clientFetch } from '@/lib/utils';
interface SettingsType {
chatModelProviders: {
@ -33,12 +34,8 @@ const SettingsDialog = ({
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<
string | null
>(null);
const [customOpenAIApiKey, setCustomOpenAIApiKey] = useState<string | null>(
null,
);
const [customOpenAIBaseURL, setCustomOpenAIBaseURL] = useState<string | null>(
null,
);
const [customOpenAIApiKey, setCustomOpenAIApiKey] = useState<string>('');
const [customOpenAIBaseURL, setCustomOpenAIBaseURL] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
@ -46,9 +43,54 @@ const SettingsDialog = ({
if (isOpen) {
const fetchConfig = async () => {
setIsLoading(true);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`);
const data = await res.json();
const res = await clientFetch('/config', {
headers: {
'Content-Type': 'application/json',
},
});
const data = (await res.json()) as SettingsType;
setConfig(data);
const chatModelProvidersKeys = Object.keys(
data.chatModelProviders || {},
);
const embeddingModelProvidersKeys = Object.keys(
data.embeddingModelProviders || {},
);
const defaultChatModelProvider =
chatModelProvidersKeys.length > 0 ? chatModelProvidersKeys[0] : '';
const defaultEmbeddingModelProvider =
embeddingModelProvidersKeys.length > 0
? embeddingModelProvidersKeys[0]
: '';
const chatModelProvider =
localStorage.getItem('chatModelProvider') ||
defaultChatModelProvider ||
'';
const chatModel =
localStorage.getItem('chatModel') ||
(data.chatModelProviders &&
data.chatModelProviders[chatModelProvider]?.[0]) ||
'';
const embeddingModelProvider =
localStorage.getItem('embeddingModelProvider') ||
defaultEmbeddingModelProvider ||
'';
const embeddingModel =
localStorage.getItem('embeddingModel') ||
(data.embeddingModelProviders &&
data.embeddingModelProviders[embeddingModelProvider]?.[0]) ||
'';
setSelectedChatModelProvider(chatModelProvider);
setSelectedChatModel(chatModel);
setSelectedEmbeddingModelProvider(embeddingModelProvider);
setSelectedEmbeddingModel(embeddingModel);
setCustomOpenAIApiKey(localStorage.getItem('openAIApiKey') || '');
setCustomOpenAIBaseURL(localStorage.getItem('openAIBaseURL') || '');
setIsLoading(false);
};
@ -57,22 +99,11 @@ const SettingsDialog = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
useEffect(() => {
setSelectedChatModelProvider(localStorage.getItem('chatModelProvider'));
setSelectedChatModel(localStorage.getItem('chatModel'));
setSelectedEmbeddingModelProvider(
localStorage.getItem('embeddingModelProvider'),
);
setSelectedEmbeddingModel(localStorage.getItem('embeddingModel'));
setCustomOpenAIApiKey(localStorage.getItem('openAIApiKey'));
setCustomOpenAIBaseURL(localStorage.getItem('openAIBaseUrl'));
}, []);
const handleSubmit = async () => {
setIsUpdating(true);
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, {
await clientFetch('/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -140,6 +171,7 @@ const SettingsDialog = ({
Chat model Provider
</p>
<select
value={selectedChatModelProvider ?? undefined}
onChange={(e) => {
setSelectedChatModelProvider(e.target.value);
setSelectedChatModel(
@ -150,13 +182,7 @@ const SettingsDialog = ({
>
{Object.keys(config.chatModelProviders).map(
(provider) => (
<option
key={provider}
value={provider}
selected={
provider === selectedChatModelProvider
}
>
<option key={provider} value={provider}>
{provider.charAt(0).toUpperCase() +
provider.slice(1)}
</option>
@ -170,6 +196,7 @@ const SettingsDialog = ({
<div className="flex flex-col space-y-1">
<p className="text-white/70 text-sm">Chat Model</p>
<select
value={selectedChatModel ?? undefined}
onChange={(e) =>
setSelectedChatModel(e.target.value)
}
@ -184,21 +211,17 @@ const SettingsDialog = ({
config.chatModelProviders[
selectedChatModelProvider
].map((model) => (
<option
key={model}
value={model}
selected={model === selectedChatModel}
>
<option key={model} value={model}>
{model}
</option>
))
) : (
<option value="" disabled selected>
<option value="" disabled>
No models available
</option>
)
) : (
<option value="" disabled selected>
<option value="" disabled>
Invalid provider, please check backend logs
</option>
)}
@ -222,7 +245,7 @@ const SettingsDialog = ({
</div>
<div className="flex flex-col space-y-1">
<p className="text-white/70 text-sm">
Custom OpenAI API Key (optional)
Custom OpenAI API Key
</p>
<input
type="text"
@ -251,12 +274,13 @@ const SettingsDialog = ({
</>
)}
{/* Embedding models */}
{config.chatModelProviders && (
{config.embeddingModelProviders && (
<div className="flex flex-col space-y-1">
<p className="text-white/70 text-sm">
Embedding model Provider
</p>
<select
value={selectedEmbeddingModelProvider ?? undefined}
onChange={(e) => {
setSelectedEmbeddingModelProvider(e.target.value);
setSelectedEmbeddingModel(
@ -267,13 +291,7 @@ const SettingsDialog = ({
>
{Object.keys(config.embeddingModelProviders).map(
(provider) => (
<option
key={provider}
value={provider}
selected={
provider === selectedEmbeddingModelProvider
}
>
<option key={provider} value={provider}>
{provider.charAt(0).toUpperCase() +
provider.slice(1)}
</option>
@ -286,6 +304,7 @@ const SettingsDialog = ({
<div className="flex flex-col space-y-1">
<p className="text-white/70 text-sm">Embedding Model</p>
<select
value={selectedEmbeddingModel ?? undefined}
onChange={(e) =>
setSelectedEmbeddingModel(e.target.value)
}
@ -300,11 +319,7 @@ const SettingsDialog = ({
config.embeddingModelProviders[
selectedEmbeddingModelProvider
].map((model) => (
<option
key={model}
value={model}
selected={model === selectedEmbeddingModel}
>
<option key={model} value={model}>
{model}
</option>
))

23
ui/lib/actions.ts Normal file
View File

@ -0,0 +1,23 @@
import { Message } from '@/components/ChatWindow';
import { clientFetch } from '@/lib/utils';
export const getSuggestions = async (chatHisory: Message[]) => {
const chatModel = localStorage.getItem('chatModel');
const chatModelProvider = localStorage.getItem('chatModelProvider');
const res = await clientFetch('/suggestions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_history: chatHisory,
chat_model: chatModel,
chat_model_provider: chatModelProvider,
}),
});
const data = (await res.json()) as { suggestions: string[] };
return data.suggestions;
};

22
ui/lib/config.ts Normal file
View File

@ -0,0 +1,22 @@
interface Config {
GENERAL: {
NEXT_PUBLIC_SUPER_SECRET_KEY: string;
NEXT_PUBLIC_API_URL: string;
NEXT_PUBLIC_WS_URL: string;
};
}
const loadEnv = () => {
return {
GENERAL: {
NEXT_PUBLIC_SUPER_SECRET_KEY: process.env.NEXT_PUBLIC_SUPER_SECRET_KEY!,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL!,
NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL!,
},
} as Config;
};
export const getAccessKey = () =>
loadEnv().GENERAL.NEXT_PUBLIC_SUPER_SECRET_KEY;
export const getBackendURL = () => loadEnv().GENERAL.NEXT_PUBLIC_API_URL;

View File

@ -1,5 +1,6 @@
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { getAccessKey, getBackendURL } from './config';
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes));
@ -19,3 +20,20 @@ export const formatTimeDifference = (date1: Date, date2: Date): string => {
else
return `${Math.floor(diffInSeconds / 31536000)} year${Math.floor(diffInSeconds / 31536000) !== 1 ? 's' : ''}`;
};
export const clientFetch = async (path: string, payload: any): Promise<any> => {
let headers = payload.headers;
const url = `${getBackendURL()}${path}`;
const secretToken = getAccessKey();
if (secretToken) {
if (headers == null) {
headers = {};
}
headers['Authorization'] = `Bearer ${secretToken}`;
payload.headers = headers;
}
return await fetch(url, payload);
};

View File

@ -1,6 +1,6 @@
{
"name": "perplexica-frontend",
"version": "1.3.0",
"version": "1.5.0",
"license": "MIT",
"author": "ItzCrazyKns",
"scripts": {
@ -24,6 +24,7 @@
"react-dom": "^18",
"react-text-to-speech": "^0.14.5",
"react-textarea-autosize": "^8.5.3",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2",
"yet-another-react-lightbox": "^3.17.2",
"zod": "^3.22.4"

View File

@ -2839,6 +2839,11 @@ slash@^3.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
sonner@^1.4.41:
version "1.4.41"
resolved "https://registry.yarnpkg.com/sonner/-/sonner-1.4.41.tgz#ff085ae4f4244713daf294959beaa3e90f842d2c"
integrity sha512-uG511ggnnsw6gcn/X+YKkWPo5ep9il9wYi3QJxHsYe7yTZ4+cOd1wuodOUmOpFuXL+/RE3R04LczdNCDygTDgQ==
source-map-js@^1.0.2, source-map-js@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"

660
yarn.lock

File diff suppressed because it is too large Load Diff