diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 6b9e2e2..48a67ee 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -11,6 +11,13 @@ on: jobs: build-amd64: runs-on: ubuntu-latest + strategy: + matrix: + variant: + - name: full + dockerfile: Dockerfile + - name: slim + dockerfile: Dockerfile.slim steps: - name: Checkout code uses: actions/checkout@v3 @@ -31,47 +38,54 @@ jobs: id: version run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Build and push AMD64 Docker image + - name: Build and push AMD64 Docker image (master) if: github.ref == 'refs/heads/master' && github.event_name == 'push' run: | - DOCKERFILE=app.dockerfile - IMAGE_NAME=perplexica + DOCKERFILE=${{ matrix.variant.dockerfile }} + VARIANT=${{ matrix.variant.name }} docker buildx build --platform linux/amd64 \ - --cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:amd64 \ + --cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-amd64 \ --cache-to=type=inline \ --provenance false \ -f $DOCKERFILE \ - -t itzcrazykns1337/${IMAGE_NAME}:amd64 \ + -t itzcrazykns1337/perplexica:${VARIANT}-amd64 \ --push . - name: Build and push AMD64 Canary Docker image if: github.ref == 'refs/heads/canary' && github.event_name == 'push' run: | - DOCKERFILE=app.dockerfile - IMAGE_NAME=perplexica + DOCKERFILE=${{ matrix.variant.dockerfile }} + VARIANT=${{ matrix.variant.name }} docker buildx build --platform linux/amd64 \ - --cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \ + --cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \ --cache-to=type=inline \ --provenance false \ -f $DOCKERFILE \ - -t itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \ + -t itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \ --push . - name: Build and push AMD64 release Docker image if: github.event_name == 'release' run: | - DOCKERFILE=app.dockerfile - IMAGE_NAME=perplexica + DOCKERFILE=${{ matrix.variant.dockerfile }} + VARIANT=${{ matrix.variant.name }} docker buildx build --platform linux/amd64 \ - --cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }}-amd64 \ + --cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \ --cache-to=type=inline \ --provenance false \ -f $DOCKERFILE \ - -t itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }}-amd64 \ + -t itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \ --push . build-arm64: runs-on: ubuntu-24.04-arm + strategy: + matrix: + variant: + - name: full + dockerfile: Dockerfile + - name: slim + dockerfile: Dockerfile.slim steps: - name: Checkout code uses: actions/checkout@v3 @@ -92,48 +106,51 @@ jobs: id: version run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Build and push ARM64 Docker image + - name: Build and push ARM64 Docker image (master) if: github.ref == 'refs/heads/master' && github.event_name == 'push' run: | - DOCKERFILE=app.dockerfile - IMAGE_NAME=perplexica + DOCKERFILE=${{ matrix.variant.dockerfile }} + VARIANT=${{ matrix.variant.name }} docker buildx build --platform linux/arm64 \ - --cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:arm64 \ + --cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-arm64 \ --cache-to=type=inline \ --provenance false \ -f $DOCKERFILE \ - -t itzcrazykns1337/${IMAGE_NAME}:arm64 \ + -t itzcrazykns1337/perplexica:${VARIANT}-arm64 \ --push . - name: Build and push ARM64 Canary Docker image if: github.ref == 'refs/heads/canary' && github.event_name == 'push' run: | - DOCKERFILE=app.dockerfile - IMAGE_NAME=perplexica + DOCKERFILE=${{ matrix.variant.dockerfile }} + VARIANT=${{ matrix.variant.name }} docker buildx build --platform linux/arm64 \ - --cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:canary-arm64 \ + --cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 \ --cache-to=type=inline \ --provenance false \ -f $DOCKERFILE \ - -t itzcrazykns1337/${IMAGE_NAME}:canary-arm64 \ + -t itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 \ --push . - name: Build and push ARM64 release Docker image if: github.event_name == 'release' run: | - DOCKERFILE=app.dockerfile - IMAGE_NAME=perplexica + DOCKERFILE=${{ matrix.variant.dockerfile }} + VARIANT=${{ matrix.variant.name }} docker buildx build --platform linux/arm64 \ - --cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }}-arm64 \ + --cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \ --cache-to=type=inline \ --provenance false \ -f $DOCKERFILE \ - -t itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }}-arm64 \ + -t itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \ --push . manifest: needs: [build-amd64, build-arm64] runs-on: ubuntu-latest + strategy: + matrix: + variant: [full, slim] steps: - name: Log in to DockerHub uses: docker/login-action@v2 @@ -146,29 +163,55 @@ jobs: id: version run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Create and push multi-arch manifest for main + - name: Create and push manifest for main if: github.ref == 'refs/heads/master' && github.event_name == 'push' run: | - IMAGE_NAME=perplexica - docker manifest create itzcrazykns1337/${IMAGE_NAME}:main \ - --amend itzcrazykns1337/${IMAGE_NAME}:amd64 \ - --amend itzcrazykns1337/${IMAGE_NAME}:arm64 - docker manifest push itzcrazykns1337/${IMAGE_NAME}:main + VARIANT=${{ matrix.variant }} + docker manifest create itzcrazykns1337/perplexica:${VARIANT}-latest \ + --amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-arm64 + docker manifest push itzcrazykns1337/perplexica:${VARIANT}-latest - - name: Create and push multi-arch manifest for canary + if [ "$VARIANT" = "full" ]; then + docker manifest create itzcrazykns1337/perplexica:latest \ + --amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-arm64 + docker manifest push itzcrazykns1337/perplexica:latest + + docker manifest create itzcrazykns1337/perplexica:main \ + --amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-arm64 + docker manifest push itzcrazykns1337/perplexica:main + fi + + - name: Create and push manifest for canary if: github.ref == 'refs/heads/canary' && github.event_name == 'push' run: | - IMAGE_NAME=perplexica - docker manifest create itzcrazykns1337/${IMAGE_NAME}:canary \ - --amend itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \ - --amend itzcrazykns1337/${IMAGE_NAME}:canary-arm64 - docker manifest push itzcrazykns1337/${IMAGE_NAME}:canary + VARIANT=${{ matrix.variant }} + docker manifest create itzcrazykns1337/perplexica:${VARIANT}-canary \ + --amend itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 + docker manifest push itzcrazykns1337/perplexica:${VARIANT}-canary - - name: Create and push multi-arch manifest for releases + if [ "$VARIANT" = "full" ]; then + docker manifest create itzcrazykns1337/perplexica:canary \ + --amend itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 + docker manifest push itzcrazykns1337/perplexica:canary + fi + + - name: Create and push manifest for releases if: github.event_name == 'release' run: | - IMAGE_NAME=perplexica - docker manifest create itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }} \ - --amend itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }}-amd64 \ - --amend itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }}-arm64 - docker manifest push itzcrazykns1337/${IMAGE_NAME}:${{ env.RELEASE_VERSION }} + VARIANT=${{ matrix.variant }} + docker manifest create itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }} \ + --amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 + docker manifest push itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }} + + if [ "$VARIANT" = "full" ]; then + docker manifest create itzcrazykns1337/perplexica:${{ env.RELEASE_VERSION }} \ + --amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \ + --amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 + docker manifest push itzcrazykns1337/perplexica:${{ env.RELEASE_VERSION }} + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b219660 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +FROM node:24.5.0-slim AS builder + +RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/perplexica + +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --network-timeout 600000 + +COPY tsconfig.json next.config.mjs next-env.d.ts postcss.config.js drizzle.config.ts tailwind.config.ts ./ +COPY src ./src +COPY public ./public +COPY drizzle ./drizzle + +RUN mkdir -p /home/perplexica/data +RUN yarn build + +FROM node:24.5.0-slim + +RUN apt-get update && \ + apt-get install -y \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + sqlite3 \ + git \ + build-essential \ + libxslt-dev \ + zlib1g-dev \ + libffi-dev \ + libssl-dev \ + uwsgi \ + uwsgi-plugin-python3 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/perplexica + +COPY --from=builder /home/perplexica/public ./public +COPY --from=builder /home/perplexica/.next/static ./public/_next/static +COPY --from=builder /home/perplexica/.next/standalone ./ +COPY --from=builder /home/perplexica/data ./data +COPY drizzle ./drizzle + +RUN mkdir /home/perplexica/uploads + +RUN useradd --system --home-dir /usr/local/searxng --shell /bin/sh searxng + +WORKDIR /usr/local/searxng +RUN git clone https://github.com/searxng/searxng.git . && \ + python3 -m venv venv && \ + . venv/bin/activate && \ + pip install --upgrade pip setuptools wheel pyyaml && \ + pip install -r requirements.txt && \ + pip install uwsgi + +RUN mkdir -p /etc/searxng +COPY searxng/settings.yml /etc/searxng/settings.yml +COPY searxng/limiter.toml /etc/searxng/limiter.toml +COPY searxng/uwsgi.ini /etc/searxng/uwsgi.ini + +RUN chown -R searxng:searxng /usr/local/searxng /etc/searxng + +WORKDIR /home/perplexica +COPY entrypoint.sh ./entrypoint.sh +RUN chmod +x ./entrypoint.sh +RUN sed -i 's/\r$//' ./entrypoint.sh || true + +EXPOSE 3000 8080 + +ENV SEARXNG_API_URL=http://localhost:8080 + +CMD ["/home/perplexica/entrypoint.sh"] diff --git a/Dockerfile.slim b/Dockerfile.slim new file mode 100644 index 0000000..d44dea4 --- /dev/null +++ b/Dockerfile.slim @@ -0,0 +1,35 @@ +FROM node:24.5.0-slim AS builder + +RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/perplexica + +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --network-timeout 600000 + +COPY tsconfig.json next.config.mjs next-env.d.ts postcss.config.js drizzle.config.ts tailwind.config.ts ./ +COPY src ./src +COPY public ./public +COPY drizzle ./drizzle + +RUN mkdir -p /home/perplexica/data +RUN yarn build + +FROM node:24.5.0-slim + +RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/perplexica + +COPY --from=builder /home/perplexica/public ./public +COPY --from=builder /home/perplexica/.next/static ./public/_next/static + +COPY --from=builder /home/perplexica/.next/standalone ./ +COPY --from=builder /home/perplexica/data ./data +COPY drizzle ./drizzle + +RUN mkdir /home/perplexica/uploads + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 07125dc..ce2db8b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,35 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker. ### Getting Started with Docker (Recommended) +Perplexica can be easily run using Docker. Simply run the following command: + +```bash +docker run -p 3000:3000 --name perplexica itzcrazykns1337/perplexica:latest +``` + +This will pull and start the Perplexica container with the bundled SearxNG search engine. Once running, open your browser and navigate to http://localhost:3000. You can then configure your settings (API keys, models, etc.) directly in the setup screen. + +**Note**: The image includes both Perplexica and SearxNG, so no additional setup is required. + +#### Using Perplexica with Your Own SearxNG Instance + +If you already have SearxNG running, you can use the slim version of Perplexica: + +```bash +docker run -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 --name perplexica itzcrazykns1337/perplexica:slim-latest +``` + +**Important**: Make sure your SearxNG instance has: + +- JSON format enabled in the settings +- Wolfram Alpha search engine enabled + +Replace `http://your-searxng-url:8080` with your actual SearxNG URL. Then configure your AI provider settings in the setup screen at http://localhost:3000. + +#### Advanced Setup (Building from Source) + +If you prefer to build from source or need more control: + 1. Ensure Docker is installed and running on your system. 2. Clone the Perplexica repository: @@ -85,39 +114,46 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker. 3. After cloning, navigate to the directory containing the project files. -4. Rename the `sample.config.toml` file to `config.toml`. For Docker setups, you need only fill in the following fields: - - - `OPENAI`: Your OpenAI API key. **You only need to fill this if you wish to use OpenAI's models**. - - `CUSTOM_OPENAI`: Your OpenAI-API-compliant local server URL, model name, and API key. You should run your local server with host set to `0.0.0.0`, take note of which port number it is running on, and then use that port number to set `API_URL = http://host.docker.internal:PORT_NUMBER`. You must specify the model name, such as `MODEL_NAME = "unsloth/DeepSeek-R1-0528-Qwen3-8B-GGUF:Q4_K_XL"`. Finally, set `API_KEY` to the appropriate value. If you have not defined an API key, just put anything you want in-between the quotation marks: `API_KEY = "whatever-you-want-but-not-blank"` **You only need to configure these settings if you want to use a local OpenAI-compliant server, such as Llama.cpp's [`llama-server`](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md)**. - - `OLLAMA`: Your Ollama API URL. You should enter it as `http://host.docker.internal:PORT_NUMBER`. If you installed Ollama on port 11434, use `http://host.docker.internal:11434`. For other ports, adjust accordingly. **You need to fill this if you wish to use Ollama's models instead of OpenAI's**. - - `LEMONADE`: Your Lemonade API URL. Since Lemonade runs directly on your local machine (not in Docker), you should enter it as `http://host.docker.internal:PORT_NUMBER`. If you installed Lemonade on port 8000, use `http://host.docker.internal:8000`. For other ports, adjust accordingly. **You need to fill this if you wish to use Lemonade's models**. - - `GROQ`: Your Groq API key. **You only need to fill this if you wish to use Groq's hosted models**.` - - `ANTHROPIC`: Your Anthropic API key. **You only need to fill this if you wish to use Anthropic models**. - - `Gemini`: Your Gemini API key. **You only need to fill this if you wish to use Google's models**. - - `DEEPSEEK`: Your Deepseek API key. **Only needed if you want Deepseek models.** - - `AIMLAPI`: Your AI/ML API key. **Only needed if you want to use AI/ML API models and embeddings.** - - **Note**: You can change these after starting Perplexica from the settings dialog. - - - `SIMILARITY_MEASURE`: The similarity measure to use (This is filled by default; you can leave it as is if you are unsure about it.) - -5. Ensure you are in the directory containing the `docker-compose.yaml` file and execute: +4. Build and run using Docker: ```bash - docker compose up -d + docker build -t perplexica . + docker run -p 3000:3000 --name perplexica perplexica ``` -6. Wait a few minutes for the setup to complete. You can access Perplexica at http://localhost:3000 in your web browser. +5. Access Perplexica at http://localhost:3000 and configure your settings in the setup screen. **Note**: After the containers are built, you can start Perplexica directly from Docker without having to open a terminal. ### Non-Docker Installation -1. Install SearXNG and allow `JSON` format in the SearXNG settings. -2. Clone the repository and rename the `sample.config.toml` file to `config.toml` in the root directory. Ensure you complete all required fields in this file. -3. After populating the configuration run `npm i`. -4. Install the dependencies and then execute `npm run build`. -5. Finally, start the app by running `npm run start` +1. Install SearXNG and allow `JSON` format in the SearXNG settings. Make sure Wolfram Alpha search engine is also enabled. +2. Clone the repository: + + ```bash + git clone https://github.com/ItzCrazyKns/Perplexica.git + cd Perplexica + ``` + +3. Install dependencies: + + ```bash + npm i + ``` + +4. Build the application: + + ```bash + npm run build + ``` + +5. Start the application: + + ```bash + npm run start + ``` + +6. Open your browser and navigate to http://localhost:3000 to complete the setup and configure your settings (API keys, models, SearxNG URL, etc.) in the setup screen. **Note**: Using Docker is recommended as it simplifies the setup process, especially for managing environment variables and dependencies. diff --git a/app.dockerfile b/app.dockerfile index ab4f4cc..92358e7 100644 --- a/app.dockerfile +++ b/app.dockerfile @@ -15,9 +15,6 @@ COPY drizzle ./drizzle RUN mkdir -p /home/perplexica/data RUN yarn build -RUN yarn add --dev @vercel/ncc -RUN yarn ncc build ./src/lib/db/migrate.ts -o migrator - FROM node:24.5.0-slim RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/* @@ -30,8 +27,6 @@ COPY --from=builder /home/perplexica/.next/static ./public/_next/static COPY --from=builder /home/perplexica/.next/standalone ./ COPY --from=builder /home/perplexica/data ./data COPY drizzle ./drizzle -COPY --from=builder /home/perplexica/migrator/build ./build -COPY --from=builder /home/perplexica/migrator/index.js ./migrate.js RUN mkdir /home/perplexica/uploads diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index b32e0a9..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,35 +0,0 @@ -services: - searxng: - image: docker.io/searxng/searxng:latest - volumes: - - ./searxng:/etc/searxng:rw - ports: - - 4000:8080 - networks: - - perplexica-network - restart: unless-stopped - - app: - image: itzcrazykns1337/perplexica:main - build: - context: . - dockerfile: app.dockerfile - environment: - - SEARXNG_API_URL=http://searxng:8080 - - DATA_DIR=/home/perplexica - ports: - - 3000:3000 - networks: - - perplexica-network - volumes: - - backend-dbstore:/home/perplexica/data - - uploads:/home/perplexica/uploads - - ./config.toml:/home/perplexica/config.toml - restart: unless-stopped - -networks: - perplexica-network: - -volumes: - backend-dbstore: - uploads: diff --git a/docs/API/SEARCH.md b/docs/API/SEARCH.md index b67b62b..bf0db7a 100644 --- a/docs/API/SEARCH.md +++ b/docs/API/SEARCH.md @@ -4,11 +4,55 @@ Perplexica’s Search API makes it easy to use our AI-powered search engine. You can run different types of searches, pick the models you want to use, and get the most recent info. Follow the following headings to learn more about Perplexica's search API. -## Endpoint +## Endpoints -### **POST** `http://localhost:3000/api/search` +### Get Available Providers and Models -**Note**: Replace `3000` with any other port if you've changed the default PORT +Before making search requests, you'll need to get the available providers and their models. + +#### **GET** `/api/providers` + +**Full URL**: `http://localhost:3000/api/providers` + +Returns a list of all active providers with their available chat and embedding models. + +**Response Example:** +```json +{ + "providers": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "OpenAI", + "chatModels": [ + { + "name": "GPT 4 Omni Mini", + "key": "gpt-4o-mini" + }, + { + "name": "GPT 4 Omni", + "key": "gpt-4o" + } + ], + "embeddingModels": [ + { + "name": "Text Embedding 3 Large", + "key": "text-embedding-3-large" + } + ] + } + ] +} +``` + +Use the `id` field as the `providerId` and the `key` field from the models arrays when making search requests. + +### Search Query + +#### **POST** `/api/search` + +**Full URL**: `http://localhost:3000/api/search` + +**Note**: Replace `localhost:3000` with your Perplexica instance URL if running on a different host or port ### Request @@ -19,12 +63,12 @@ The API accepts a JSON object in the request body, where you define the focus mo ```json { "chatModel": { - "provider": "openai", - "name": "gpt-4o-mini" + "providerId": "550e8400-e29b-41d4-a716-446655440000", + "key": "gpt-4o-mini" }, "embeddingModel": { - "provider": "openai", - "name": "text-embedding-3-large" + "providerId": "550e8400-e29b-41d4-a716-446655440000", + "key": "text-embedding-3-large" }, "optimizationMode": "speed", "focusMode": "webSearch", @@ -38,20 +82,19 @@ The API accepts a JSON object in the request body, where you define the focus mo } ``` +**Note**: The `providerId` must be a valid UUID obtained from the `/api/providers` endpoint. The example above uses a sample UUID for demonstration. + ### Request Parameters -- **`chatModel`** (object, optional): Defines the chat model to be used for the query. For model details you can send a GET request at `http://localhost:3000/api/models`. Make sure to use the key value (For example "gpt-4o-mini" instead of the display name "GPT 4 omni mini"). +- **`chatModel`** (object, optional): Defines the chat model to be used for the query. To get available providers and models, send a GET request to `http://localhost:3000/api/providers`. - - `provider`: Specifies the provider for the chat model (e.g., `openai`, `ollama`). - - `name`: The specific model from the chosen provider (e.g., `gpt-4o-mini`). - - Optional fields for custom OpenAI configuration: - - `customOpenAIBaseURL`: If you’re using a custom OpenAI instance, provide the base URL. - - `customOpenAIKey`: The API key for a custom OpenAI instance. + - `providerId` (string): The UUID of the provider. You can get this from the `/api/providers` endpoint response. + - `key` (string): The model key/identifier (e.g., `gpt-4o-mini`, `llama3.1:latest`). Use the `key` value from the provider's `chatModels` array, not the display name. -- **`embeddingModel`** (object, optional): Defines the embedding model for similarity-based searching. For model details you can send a GET request at `http://localhost:3000/api/models`. Make sure to use the key value (For example "text-embedding-3-large" instead of the display name "Text Embedding 3 Large"). +- **`embeddingModel`** (object, optional): Defines the embedding model for similarity-based searching. To get available providers and models, send a GET request to `http://localhost:3000/api/providers`. - - `provider`: The provider for the embedding model (e.g., `openai`). - - `name`: The specific embedding model (e.g., `text-embedding-3-large`). + - `providerId` (string): The UUID of the embedding provider. You can get this from the `/api/providers` endpoint response. + - `key` (string): The embedding model key (e.g., `text-embedding-3-large`, `nomic-embed-text`). Use the `key` value from the provider's `embeddingModels` array, not the display name. - **`focusMode`** (string, required): Specifies which focus mode to use. Available modes: @@ -108,7 +151,7 @@ The response from the API includes both the final message and the sources used t #### Streaming Response (stream: true) -When streaming is enabled, the API returns a stream of newline-delimited JSON objects. Each line contains a complete, valid JSON object. The response has Content-Type: application/json. +When streaming is enabled, the API returns a stream of newline-delimited JSON objects using Server-Sent Events (SSE). Each line contains a complete, valid JSON object. The response has `Content-Type: text/event-stream`. Example of streamed response objects: diff --git a/docs/installation/UPDATING.md b/docs/installation/UPDATING.md index 66edf5c..daa1122 100644 --- a/docs/installation/UPDATING.md +++ b/docs/installation/UPDATING.md @@ -2,45 +2,80 @@ To update Perplexica to the latest version, follow these steps: -## For Docker users +## For Docker users (Using pre-built images) -1. Clone the latest version of Perplexica from GitHub: +Simply pull the latest image and restart your container: + +```bash +docker pull itzcrazykns1337/perplexica:latest +docker stop perplexica +docker rm perplexica +docker run -p 3000:3000 --name perplexica itzcrazykns1337/perplexica:latest +``` + +For slim version: + +```bash +docker pull itzcrazykns1337/perplexica:slim-latest +docker stop perplexica +docker rm perplexica +docker run -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 --name perplexica itzcrazykns1337/perplexica:slim-latest +``` + +Once updated, go to http://localhost:3000 and verify the latest changes. Your settings are preserved automatically. + +## For Docker users (Building from source) + +1. Navigate to your Perplexica directory and pull the latest changes: ```bash - git clone https://github.com/ItzCrazyKns/Perplexica.git + cd Perplexica + git pull origin master ``` -2. Navigate to the project directory. - -3. Check for changes in the configuration files. If the `sample.config.toml` file contains new fields, delete your existing `config.toml` file, rename `sample.config.toml` to `config.toml`, and update the configuration accordingly. - -4. Pull the latest images from the registry. +2. Rebuild the Docker image: ```bash - docker compose pull + docker build -t perplexica . ``` -5. Update and recreate the containers. +3. Stop and remove the old container, then start the new one: ```bash - docker compose up -d + docker stop perplexica + docker rm perplexica + docker run -p 3000:3000 -p 8080:8080 --name perplexica perplexica ``` -6. Once the command completes, go to http://localhost:3000 and verify the latest changes. +4. Once the command completes, go to http://localhost:3000 and verify the latest changes. ## For non-Docker users -1. Clone the latest version of Perplexica from GitHub: +1. Navigate to your Perplexica directory and pull the latest changes: ```bash - git clone https://github.com/ItzCrazyKns/Perplexica.git + cd Perplexica + git pull origin master ``` -2. Navigate to the project directory. +2. Install any new dependencies: -3. Check for changes in the configuration files. If the `sample.config.toml` file contains new fields, delete your existing `config.toml` file, rename `sample.config.toml` to `config.toml`, and update the configuration accordingly. -4. After populating the configuration run `npm i`. -5. Install the dependencies and then execute `npm run build`. -6. Finally, start the app by running `npm run start` + ```bash + npm i + ``` + +3. Rebuild the application: + + ```bash + npm run build + ``` + +4. Restart the application: + + ```bash + npm run start + ``` + +5. Go to http://localhost:3000 and verify the latest changes. Your settings are preserved automatically. --- diff --git a/entrypoint.sh b/entrypoint.sh index 9f9448a..48ee169 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,24 @@ #!/bin/sh set -e -node migrate.js +cd /usr/local/searxng +export SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml +# Start SearXNG in background with all output redirected to /dev/null +/usr/local/searxng/venv/bin/uwsgi \ + --http-socket 0.0.0.0:8080 \ + --ini /etc/searxng/uwsgi.ini \ + --virtualenv /usr/local/searxng/venv \ + --disable-logging > /dev/null 2>&1 & + +echo "Starting SearXNG..." +sleep 5 + +until curl -s http://localhost:8080 > /dev/null 2>&1; do + sleep 1 +done +echo "SearXNG started successfully" + +cd /home/perplexica +echo "Starting Perplexica..." exec node server.js \ No newline at end of file diff --git a/package.json b/package.json index 6a2debf..3bfe63f 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,39 @@ { "name": "perplexica-frontend", - "version": "1.11.0-rc3", + "version": "1.11.0", "license": "MIT", "author": "ItzCrazyKns", "scripts": { "dev": "next dev", - "build": "npm run db:migrate && next build", + "build": "next build", "start": "next start", "lint": "next lint", - "format:write": "prettier . --write", - "db:migrate": "node ./src/lib/db/migrate.ts" + "format:write": "prettier . --write" }, "dependencies": { "@headlessui/react": "^2.2.0", "@headlessui/tailwindcss": "^0.2.2", + "@huggingface/transformers": "^3.7.5", "@iarna/toml": "^2.2.5", "@icons-pack/react-simple-icons": "^12.3.0", - "@langchain/anthropic": "^0.3.24", - "@langchain/community": "^0.3.49", - "@langchain/core": "^0.3.66", - "@langchain/google-genai": "^0.2.15", - "@langchain/groq": "^0.2.3", - "@langchain/ollama": "^0.2.3", - "@langchain/openai": "^0.6.2", - "@langchain/textsplitters": "^0.1.0", + "@langchain/anthropic": "^1.0.0", + "@langchain/community": "^1.0.0", + "@langchain/core": "^1.0.1", + "@langchain/google-genai": "^1.0.0", + "@langchain/groq": "^1.0.0", + "@langchain/ollama": "^1.0.0", + "@langchain/openai": "^1.0.0", + "@langchain/textsplitters": "^1.0.0", "@tailwindcss/typography": "^0.5.12", - "@xenova/transformers": "^2.17.2", "axios": "^1.8.3", "better-sqlite3": "^11.9.1", "clsx": "^2.1.0", "compute-cosine-similarity": "^1.1.0", - "compute-dot": "^1.1.0", "drizzle-orm": "^0.40.1", + "framer-motion": "^12.23.24", "html-to-text": "^9.0.5", "jspdf": "^3.0.1", - "langchain": "^0.3.30", + "langchain": "^1.0.1", "lucide-react": "^0.363.0", "mammoth": "^1.9.1", "markdown-to-jsx": "^7.7.2", @@ -55,7 +54,7 @@ "@types/better-sqlite3": "^7.6.12", "@types/html-to-text": "^9.0.4", "@types/jspdf": "^2.0.0", - "@types/node": "^20", + "@types/node": "^24.8.1", "@types/pdf-parse": "^1.1.4", "@types/react": "^18", "@types/react-dom": "^18", @@ -66,6 +65,6 @@ "postcss": "^8", "prettier": "^3.2.5", "tailwindcss": "^3.3.0", - "typescript": "^5" + "typescript": "^5.9.3" } } diff --git a/sample.config.toml b/sample.config.toml deleted file mode 100644 index 90c69e7..0000000 --- a/sample.config.toml +++ /dev/null @@ -1,39 +0,0 @@ -[GENERAL] -SIMILARITY_MEASURE = "cosine" # "cosine" or "dot" -KEEP_ALIVE = "5m" # How long to keep Ollama models loaded into memory. (Instead of using -1 use "-1m") - -[MODELS.OPENAI] -API_KEY = "" - -[MODELS.GROQ] -API_KEY = "" - -[MODELS.ANTHROPIC] -API_KEY = "" - -[MODELS.GEMINI] -API_KEY = "" - -[MODELS.CUSTOM_OPENAI] -API_KEY = "" -API_URL = "" -MODEL_NAME = "" - -[MODELS.OLLAMA] -API_URL = "" # Ollama API URL - http://host.docker.internal:11434 - -[MODELS.DEEPSEEK] -API_KEY = "" - -[MODELS.AIMLAPI] -API_KEY = "" # Required to use AI/ML API chat and embedding models - -[MODELS.LM_STUDIO] -API_URL = "" # LM Studio API URL - http://host.docker.internal:1234 - -[MODELS.LEMONADE] -API_URL = "" # Lemonade API URL - http://host.docker.internal:8000 -API_KEY = "" # Optional API key for Lemonade - -[API_ENDPOINTS] -SEARXNG = "" # SearxNG API URL - http://localhost:32768 diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 7329299..25b8104 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,23 +1,14 @@ import crypto from 'crypto'; import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages'; import { EventEmitter } from 'stream'; -import { - getAvailableChatModelProviders, - getAvailableEmbeddingModelProviders, -} from '@/lib/providers'; import db from '@/lib/db'; import { chats, messages as messagesSchema } from '@/lib/db/schema'; import { and, eq, gt } from 'drizzle-orm'; import { getFileDetails } from '@/lib/utils/files'; -import { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import { ChatOpenAI } from '@langchain/openai'; -import { - getCustomOpenaiApiKey, - getCustomOpenaiApiUrl, - getCustomOpenaiModelName, -} from '@/lib/config'; import { searchHandlers } from '@/lib/search'; import { z } from 'zod'; +import ModelRegistry from '@/lib/models/registry'; +import { ModelWithProvider } from '@/lib/models/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -28,14 +19,30 @@ const messageSchema = z.object({ content: z.string().min(1, 'Message content is required'), }); -const chatModelSchema = z.object({ - provider: z.string().optional(), - name: z.string().optional(), +const chatModelSchema: z.ZodType = z.object({ + providerId: z.string({ + errorMap: () => ({ + message: 'Chat model provider id must be provided', + }), + }), + key: z.string({ + errorMap: () => ({ + message: 'Chat model key must be provided', + }), + }), }); -const embeddingModelSchema = z.object({ - provider: z.string().optional(), - name: z.string().optional(), +const embeddingModelSchema: z.ZodType = z.object({ + providerId: z.string({ + errorMap: () => ({ + message: 'Embedding model provider id must be provided', + }), + }), + key: z.string({ + errorMap: () => ({ + message: 'Embedding model key must be provided', + }), + }), }); const bodySchema = z.object({ @@ -57,8 +64,8 @@ const bodySchema = z.object({ .optional() .default([]), files: z.array(z.string()).optional().default([]), - chatModel: chatModelSchema.optional().default({}), - embeddingModel: embeddingModelSchema.optional().default({}), + chatModel: chatModelSchema, + embeddingModel: embeddingModelSchema, systemInstructions: z.string().nullable().optional().default(''), }); @@ -90,7 +97,7 @@ const handleEmitterEvents = async ( encoder: TextEncoder, chatId: string, ) => { - let recievedMessage = ''; + let receivedMessage = ''; const aiMessageId = crypto.randomBytes(7).toString('hex'); stream.on('data', (data) => { @@ -106,7 +113,7 @@ const handleEmitterEvents = async ( ), ); - recievedMessage += parsedData.data; + receivedMessage += parsedData.data; } else if (parsedData.type === 'sources') { writer.write( encoder.encode( @@ -143,7 +150,7 @@ const handleEmitterEvents = async ( db.insert(messagesSchema) .values({ - content: recievedMessage, + content: receivedMessage, chatId: chatId, messageId: aiMessageId, role: 'assistant', @@ -248,56 +255,16 @@ export const POST = async (req: Request) => { ); } - const [chatModelProviders, embeddingModelProviders] = await Promise.all([ - getAvailableChatModelProviders(), - getAvailableEmbeddingModelProviders(), + const registry = new ModelRegistry(); + + const [llm, embedding] = await Promise.all([ + registry.loadChatModel(body.chatModel.providerId, body.chatModel.key), + registry.loadEmbeddingModel( + body.embeddingModel.providerId, + body.embeddingModel.key, + ), ]); - const chatModelProvider = - chatModelProviders[ - body.chatModel?.provider || Object.keys(chatModelProviders)[0] - ]; - const chatModel = - chatModelProvider[ - body.chatModel?.name || Object.keys(chatModelProvider)[0] - ]; - - const embeddingProvider = - embeddingModelProviders[ - body.embeddingModel?.provider || Object.keys(embeddingModelProviders)[0] - ]; - const embeddingModel = - embeddingProvider[ - body.embeddingModel?.name || Object.keys(embeddingProvider)[0] - ]; - - let llm: BaseChatModel | undefined; - let embedding = embeddingModel.model; - - if (body.chatModel?.provider === 'custom_openai') { - llm = new ChatOpenAI({ - apiKey: getCustomOpenaiApiKey(), - modelName: getCustomOpenaiModelName(), - temperature: 0.7, - configuration: { - baseURL: getCustomOpenaiApiUrl(), - }, - }) as unknown as BaseChatModel; - } else if (chatModelProvider && chatModel) { - llm = chatModel.model; - } - - if (!llm) { - return Response.json({ error: 'Invalid chat model' }, { status: 400 }); - } - - if (!embedding) { - return Response.json( - { error: 'Invalid embedding model' }, - { status: 400 }, - ); - } - const humanMessageId = message.messageId ?? crypto.randomBytes(7).toString('hex'); diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index 5f66fdf..1e36137 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -1,134 +1,76 @@ -import { - getAnthropicApiKey, - getCustomOpenaiApiKey, - getCustomOpenaiApiUrl, - getCustomOpenaiModelName, - getGeminiApiKey, - getGroqApiKey, - getOllamaApiEndpoint, - getOpenaiApiKey, - getDeepseekApiKey, - getAimlApiKey, - getLMStudioApiEndpoint, - getLemonadeApiEndpoint, - getLemonadeApiKey, - updateConfig, - getOllamaApiKey, -} from '@/lib/config'; -import { - getAvailableChatModelProviders, - getAvailableEmbeddingModelProviders, -} from '@/lib/providers'; +import configManager from '@/lib/config'; +import ModelRegistry from '@/lib/models/registry'; +import { NextRequest, NextResponse } from 'next/server'; +import { ConfigModelProvider } from '@/lib/config/types'; -export const GET = async (req: Request) => { - try { - const config: Record = {}; - - const [chatModelProviders, embeddingModelProviders] = await Promise.all([ - getAvailableChatModelProviders(), - getAvailableEmbeddingModelProviders(), - ]); - - config['chatModelProviders'] = {}; - config['embeddingModelProviders'] = {}; - - for (const provider in chatModelProviders) { - config['chatModelProviders'][provider] = Object.keys( - chatModelProviders[provider], - ).map((model) => { - return { - name: model, - displayName: chatModelProviders[provider][model].displayName, - }; - }); - } - - for (const provider in embeddingModelProviders) { - config['embeddingModelProviders'][provider] = Object.keys( - embeddingModelProviders[provider], - ).map((model) => { - return { - name: model, - displayName: embeddingModelProviders[provider][model].displayName, - }; - }); - } - - config['openaiApiKey'] = getOpenaiApiKey(); - config['ollamaApiUrl'] = getOllamaApiEndpoint(); - config['ollamaApiKey'] = getOllamaApiKey(); - config['lmStudioApiUrl'] = getLMStudioApiEndpoint(); - config['lemonadeApiUrl'] = getLemonadeApiEndpoint(); - config['lemonadeApiKey'] = getLemonadeApiKey(); - config['anthropicApiKey'] = getAnthropicApiKey(); - config['groqApiKey'] = getGroqApiKey(); - config['geminiApiKey'] = getGeminiApiKey(); - config['deepseekApiKey'] = getDeepseekApiKey(); - config['aimlApiKey'] = getAimlApiKey(); - config['customOpenaiApiUrl'] = getCustomOpenaiApiUrl(); - config['customOpenaiApiKey'] = getCustomOpenaiApiKey(); - config['customOpenaiModelName'] = getCustomOpenaiModelName(); - - return Response.json({ ...config }, { status: 200 }); - } catch (err) { - console.error('An error occurred while getting config:', err); - return Response.json( - { message: 'An error occurred while getting config' }, - { status: 500 }, - ); - } +type SaveConfigBody = { + key: string; + value: string; }; -export const POST = async (req: Request) => { +export const GET = async (req: NextRequest) => { try { - const config = await req.json(); + const values = configManager.getCurrentConfig(); + const fields = configManager.getUIConfigSections(); - const updatedConfig = { - MODELS: { - OPENAI: { - API_KEY: config.openaiApiKey, - }, - GROQ: { - API_KEY: config.groqApiKey, - }, - ANTHROPIC: { - API_KEY: config.anthropicApiKey, - }, - GEMINI: { - API_KEY: config.geminiApiKey, - }, - OLLAMA: { - API_URL: config.ollamaApiUrl, - API_KEY: config.ollamaApiKey, - }, - DEEPSEEK: { - API_KEY: config.deepseekApiKey, - }, - AIMLAPI: { - API_KEY: config.aimlApiKey, - }, - LM_STUDIO: { - API_URL: config.lmStudioApiUrl, - }, - LEMONADE: { - API_URL: config.lemonadeApiUrl, - API_KEY: config.lemonadeApiKey, - }, - CUSTOM_OPENAI: { - API_URL: config.customOpenaiApiUrl, - API_KEY: config.customOpenaiApiKey, - MODEL_NAME: config.customOpenaiModelName, - }, + const modelRegistry = new ModelRegistry(); + const modelProviders = await modelRegistry.getActiveProviders(); + + values.modelProviders = values.modelProviders.map( + (mp: ConfigModelProvider) => { + const activeProvider = modelProviders.find((p) => p.id === mp.id); + + return { + ...mp, + chatModels: activeProvider?.chatModels ?? mp.chatModels, + embeddingModels: + activeProvider?.embeddingModels ?? mp.embeddingModels, + }; }, - }; + ); - updateConfig(updatedConfig); - - return Response.json({ message: 'Config updated' }, { status: 200 }); + return NextResponse.json({ + values, + fields, + }); } catch (err) { - console.error('An error occurred while updating config:', err); + console.error('Error in getting config: ', err); return Response.json( - { message: 'An error occurred while updating config' }, + { message: 'An error has occurred.' }, + { status: 500 }, + ); + } +}; + +export const POST = async (req: NextRequest) => { + try { + const body: SaveConfigBody = await req.json(); + + if (!body.key || !body.value) { + return Response.json( + { + message: 'Key and value are required.', + }, + { + status: 400, + }, + ); + } + + configManager.updateConfig(body.key, body.value); + + return Response.json( + { + message: 'Config updated successfully.', + }, + { + status: 200, + }, + ); + } catch (err) { + console.error('Error in getting config: ', err); + return Response.json( + { message: 'An error has occurred.' }, { status: 500 }, ); } diff --git a/src/app/api/config/setup-complete/route.ts b/src/app/api/config/setup-complete/route.ts new file mode 100644 index 0000000..0055fd3 --- /dev/null +++ b/src/app/api/config/setup-complete/route.ts @@ -0,0 +1,23 @@ +import configManager from '@/lib/config'; +import { NextRequest } from 'next/server'; + +export const POST = async (req: NextRequest) => { + try { + configManager.markSetupComplete(); + + return Response.json( + { + message: 'Setup marked as complete.', + }, + { + status: 200, + }, + ); + } catch (err) { + console.error('Error marking setup as complete: ', err); + return Response.json( + { message: 'An error has occurred.' }, + { status: 500 }, + ); + } +}; diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts index e02854d..d3416ca 100644 --- a/src/app/api/images/route.ts +++ b/src/app/api/images/route.ts @@ -1,23 +1,12 @@ import handleImageSearch from '@/lib/chains/imageSearchAgent'; -import { - getCustomOpenaiApiKey, - getCustomOpenaiApiUrl, - getCustomOpenaiModelName, -} from '@/lib/config'; -import { getAvailableChatModelProviders } from '@/lib/providers'; -import { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import ModelRegistry from '@/lib/models/registry'; +import { ModelWithProvider } from '@/lib/models/types'; import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages'; -import { ChatOpenAI } from '@langchain/openai'; - -interface ChatModel { - provider: string; - model: string; -} interface ImageSearchBody { query: string; chatHistory: any[]; - chatModel?: ChatModel; + chatModel: ModelWithProvider; } export const POST = async (req: Request) => { @@ -34,35 +23,12 @@ export const POST = async (req: Request) => { }) .filter((msg) => msg !== undefined) as BaseMessage[]; - const chatModelProviders = await getAvailableChatModelProviders(); + const registry = new ModelRegistry(); - const chatModelProvider = - chatModelProviders[ - body.chatModel?.provider || Object.keys(chatModelProviders)[0] - ]; - const chatModel = - chatModelProvider[ - body.chatModel?.model || Object.keys(chatModelProvider)[0] - ]; - - let llm: BaseChatModel | undefined; - - if (body.chatModel?.provider === 'custom_openai') { - llm = new ChatOpenAI({ - apiKey: getCustomOpenaiApiKey(), - modelName: getCustomOpenaiModelName(), - temperature: 0.7, - configuration: { - baseURL: getCustomOpenaiApiUrl(), - }, - }) as unknown as BaseChatModel; - } else if (chatModelProvider && chatModel) { - llm = chatModel.model; - } - - if (!llm) { - return Response.json({ error: 'Invalid chat model' }, { status: 400 }); - } + const llm = await registry.loadChatModel( + body.chatModel.providerId, + body.chatModel.key, + ); const images = await handleImageSearch( { diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts deleted file mode 100644 index 04a6949..0000000 --- a/src/app/api/models/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - getAvailableChatModelProviders, - getAvailableEmbeddingModelProviders, -} from '@/lib/providers'; - -export const GET = async (req: Request) => { - try { - const [chatModelProviders, embeddingModelProviders] = await Promise.all([ - getAvailableChatModelProviders(), - getAvailableEmbeddingModelProviders(), - ]); - - Object.keys(chatModelProviders).forEach((provider) => { - Object.keys(chatModelProviders[provider]).forEach((model) => { - delete (chatModelProviders[provider][model] as { model?: unknown }) - .model; - }); - }); - - Object.keys(embeddingModelProviders).forEach((provider) => { - Object.keys(embeddingModelProviders[provider]).forEach((model) => { - delete (embeddingModelProviders[provider][model] as { model?: unknown }) - .model; - }); - }); - - return Response.json( - { - chatModelProviders, - embeddingModelProviders, - }, - { - status: 200, - }, - ); - } catch (err) { - console.error('An error occurred while fetching models', err); - return Response.json( - { - message: 'An error has occurred.', - }, - { - status: 500, - }, - ); - } -}; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts new file mode 100644 index 0000000..5b4acc3 --- /dev/null +++ b/src/app/api/providers/[id]/models/route.ts @@ -0,0 +1,94 @@ +import ModelRegistry from '@/lib/models/registry'; +import { Model } from '@/lib/models/types'; +import { NextRequest } from 'next/server'; + +export const POST = async ( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const { id } = await params; + + const body: Partial & { type: 'embedding' | 'chat' } = + await req.json(); + + if (!body.key || !body.name) { + return Response.json( + { + message: 'Key and name must be provided', + }, + { + status: 400, + }, + ); + } + + const registry = new ModelRegistry(); + + await registry.addProviderModel(id, body.type, body); + + return Response.json( + { + message: 'Model added successfully', + }, + { + status: 200, + }, + ); + } catch (err) { + console.error('An error occurred while adding provider model', err); + return Response.json( + { + message: 'An error has occurred.', + }, + { + status: 500, + }, + ); + } +}; + +export const DELETE = async ( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const { id } = await params; + + const body: { key: string; type: 'embedding' | 'chat' } = await req.json(); + + if (!body.key) { + return Response.json( + { + message: 'Key and name must be provided', + }, + { + status: 400, + }, + ); + } + + const registry = new ModelRegistry(); + + await registry.removeProviderModel(id, body.type, body.key); + + return Response.json( + { + message: 'Model added successfully', + }, + { + status: 200, + }, + ); + } catch (err) { + console.error('An error occurred while deleting provider model', err); + return Response.json( + { + message: 'An error has occurred.', + }, + { + status: 500, + }, + ); + } +}; diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts new file mode 100644 index 0000000..489d73a --- /dev/null +++ b/src/app/api/providers/[id]/route.ts @@ -0,0 +1,89 @@ +import ModelRegistry from '@/lib/models/registry'; +import { NextRequest } from 'next/server'; + +export const DELETE = async ( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const { id } = await params; + + if (!id) { + return Response.json( + { + message: 'Provider ID is required.', + }, + { + status: 400, + }, + ); + } + + const registry = new ModelRegistry(); + await registry.removeProvider(id); + + return Response.json( + { + message: 'Provider deleted successfully.', + }, + { + status: 200, + }, + ); + } catch (err: any) { + console.error('An error occurred while deleting provider', err.message); + return Response.json( + { + message: 'An error has occurred.', + }, + { + status: 500, + }, + ); + } +}; + +export const PATCH = async ( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const body = await req.json(); + const { name, config } = body; + const { id } = await params; + + if (!id || !name || !config) { + return Response.json( + { + message: 'Missing required fields.', + }, + { + status: 400, + }, + ); + } + + const registry = new ModelRegistry(); + + const updatedProvider = await registry.updateProvider(id, name, config); + + return Response.json( + { + provider: updatedProvider, + }, + { + status: 200, + }, + ); + } catch (err: any) { + console.error('An error occurred while updating provider', err.message); + return Response.json( + { + message: 'An error has occurred.', + }, + { + status: 500, + }, + ); + } +}; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts new file mode 100644 index 0000000..53d6e60 --- /dev/null +++ b/src/app/api/providers/route.ts @@ -0,0 +1,74 @@ +import ModelRegistry from '@/lib/models/registry'; +import { NextRequest } from 'next/server'; + +export const GET = async (req: Request) => { + try { + const registry = new ModelRegistry(); + + const activeProviders = await registry.getActiveProviders(); + + const filteredProviders = activeProviders.filter((p) => { + return !p.chatModels.some((m) => m.key === 'error'); + }); + + return Response.json( + { + providers: filteredProviders, + }, + { + status: 200, + }, + ); + } catch (err) { + console.error('An error occurred while fetching providers', err); + return Response.json( + { + message: 'An error has occurred.', + }, + { + status: 500, + }, + ); + } +}; + +export const POST = async (req: NextRequest) => { + try { + const body = await req.json(); + const { type, name, config } = body; + + if (!type || !name || !config) { + return Response.json( + { + message: 'Missing required fields.', + }, + { + status: 400, + }, + ); + } + + const registry = new ModelRegistry(); + + const newProvider = await registry.addProvider(type, name, config); + + return Response.json( + { + provider: newProvider, + }, + { + status: 200, + }, + ); + } catch (err) { + console.error('An error occurred while creating provider', err); + return Response.json( + { + message: 'An error has occurred.', + }, + { + status: 500, + }, + ); + } +}; diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 5f752ec..bc7255f 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,36 +1,14 @@ -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import type { Embeddings } from '@langchain/core/embeddings'; -import { ChatOpenAI } from '@langchain/openai'; -import { - getAvailableChatModelProviders, - getAvailableEmbeddingModelProviders, -} from '@/lib/providers'; import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages'; import { MetaSearchAgentType } from '@/lib/search/metaSearchAgent'; -import { - getCustomOpenaiApiKey, - getCustomOpenaiApiUrl, - getCustomOpenaiModelName, -} from '@/lib/config'; import { searchHandlers } from '@/lib/search'; - -interface chatModel { - provider: string; - name: string; - customOpenAIKey?: string; - customOpenAIBaseURL?: string; -} - -interface embeddingModel { - provider: string; - name: string; -} +import ModelRegistry from '@/lib/models/registry'; +import { ModelWithProvider } from '@/lib/models/types'; interface ChatRequestBody { optimizationMode: 'speed' | 'balanced'; focusMode: string; - chatModel?: chatModel; - embeddingModel?: embeddingModel; + chatModel: ModelWithProvider; + embeddingModel: ModelWithProvider; query: string; history: Array<[string, string]>; stream?: boolean; @@ -58,60 +36,16 @@ export const POST = async (req: Request) => { : new AIMessage({ content: msg[1] }); }); - const [chatModelProviders, embeddingModelProviders] = await Promise.all([ - getAvailableChatModelProviders(), - getAvailableEmbeddingModelProviders(), + const registry = new ModelRegistry(); + + const [llm, embeddings] = await Promise.all([ + registry.loadChatModel(body.chatModel.providerId, body.chatModel.key), + registry.loadEmbeddingModel( + body.embeddingModel.providerId, + body.embeddingModel.key, + ), ]); - const chatModelProvider = - body.chatModel?.provider || Object.keys(chatModelProviders)[0]; - const chatModel = - body.chatModel?.name || - Object.keys(chatModelProviders[chatModelProvider])[0]; - - const embeddingModelProvider = - body.embeddingModel?.provider || Object.keys(embeddingModelProviders)[0]; - const embeddingModel = - body.embeddingModel?.name || - Object.keys(embeddingModelProviders[embeddingModelProvider])[0]; - - let llm: BaseChatModel | undefined; - let embeddings: Embeddings | undefined; - - if (body.chatModel?.provider === 'custom_openai') { - llm = new ChatOpenAI({ - modelName: body.chatModel?.name || getCustomOpenaiModelName(), - apiKey: body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(), - temperature: 0.7, - configuration: { - baseURL: - body.chatModel?.customOpenAIBaseURL || getCustomOpenaiApiUrl(), - }, - }) as unknown as BaseChatModel; - } else if ( - chatModelProviders[chatModelProvider] && - chatModelProviders[chatModelProvider][chatModel] - ) { - llm = chatModelProviders[chatModelProvider][chatModel] - .model as unknown as BaseChatModel | undefined; - } - - if ( - embeddingModelProviders[embeddingModelProvider] && - embeddingModelProviders[embeddingModelProvider][embeddingModel] - ) { - embeddings = embeddingModelProviders[embeddingModelProvider][ - embeddingModel - ].model as Embeddings | undefined; - } - - if (!llm || !embeddings) { - return Response.json( - { message: 'Invalid model selected' }, - { status: 400 }, - ); - } - const searchHandler: MetaSearchAgentType = searchHandlers[body.focusMode]; if (!searchHandler) { diff --git a/src/app/api/suggestions/route.ts b/src/app/api/suggestions/route.ts index 99179d2..d8312cf 100644 --- a/src/app/api/suggestions/route.ts +++ b/src/app/api/suggestions/route.ts @@ -1,22 +1,12 @@ import generateSuggestions from '@/lib/chains/suggestionGeneratorAgent'; -import { - getCustomOpenaiApiKey, - getCustomOpenaiApiUrl, - getCustomOpenaiModelName, -} from '@/lib/config'; -import { getAvailableChatModelProviders } from '@/lib/providers'; +import ModelRegistry from '@/lib/models/registry'; +import { ModelWithProvider } from '@/lib/models/types'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages'; -import { ChatOpenAI } from '@langchain/openai'; - -interface ChatModel { - provider: string; - model: string; -} interface SuggestionsGenerationBody { chatHistory: any[]; - chatModel?: ChatModel; + chatModel: ModelWithProvider; } export const POST = async (req: Request) => { @@ -33,35 +23,12 @@ export const POST = async (req: Request) => { }) .filter((msg) => msg !== undefined) as BaseMessage[]; - const chatModelProviders = await getAvailableChatModelProviders(); + const registry = new ModelRegistry(); - const chatModelProvider = - chatModelProviders[ - body.chatModel?.provider || Object.keys(chatModelProviders)[0] - ]; - const chatModel = - chatModelProvider[ - body.chatModel?.model || Object.keys(chatModelProvider)[0] - ]; - - let llm: BaseChatModel | undefined; - - if (body.chatModel?.provider === 'custom_openai') { - llm = new ChatOpenAI({ - apiKey: getCustomOpenaiApiKey(), - modelName: getCustomOpenaiModelName(), - temperature: 0.7, - configuration: { - baseURL: getCustomOpenaiApiUrl(), - }, - }) as unknown as BaseChatModel; - } else if (chatModelProvider && chatModel) { - llm = chatModel.model; - } - - if (!llm) { - return Response.json({ error: 'Invalid chat model' }, { status: 400 }); - } + const llm = await registry.loadChatModel( + body.chatModel.providerId, + body.chatModel.key, + ); const suggestions = await generateSuggestions( { diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index 9fbaf2d..2a275f4 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -2,11 +2,11 @@ import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; -import { getAvailableEmbeddingModelProviders } from '@/lib/providers'; import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'; import { DocxLoader } from '@langchain/community/document_loaders/fs/docx'; import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; -import { Document } from 'langchain/document'; +import { Document } from '@langchain/core/documents'; +import ModelRegistry from '@/lib/models/registry'; interface FileRes { fileName: string; @@ -30,8 +30,8 @@ export async function POST(req: Request) { const formData = await req.formData(); const files = formData.getAll('files') as File[]; - const embedding_model = formData.get('embedding_model'); - const embedding_model_provider = formData.get('embedding_model_provider'); + const embedding_model = formData.get('embedding_model_key') as string; + const embedding_model_provider = formData.get('embedding_model_provider_id') as string; if (!embedding_model || !embedding_model_provider) { return NextResponse.json( @@ -40,20 +40,9 @@ export async function POST(req: Request) { ); } - const embeddingModels = await getAvailableEmbeddingModelProviders(); - const provider = - embedding_model_provider ?? Object.keys(embeddingModels)[0]; - const embeddingModel = - embedding_model ?? Object.keys(embeddingModels[provider as string])[0]; + const registry = new ModelRegistry(); - let embeddingsModel = - embeddingModels[provider as string]?.[embeddingModel as string]?.model; - if (!embeddingsModel) { - return NextResponse.json( - { message: 'Invalid embedding model selected' }, - { status: 400 }, - ); - } + const model = await registry.loadEmbeddingModel(embedding_model_provider, embedding_model); const processedFiles: FileRes[] = []; @@ -98,7 +87,7 @@ export async function POST(req: Request) { }), ); - const embeddings = await embeddingsModel.embedDocuments( + const embeddings = await model.embedDocuments( splitted.map((doc) => doc.pageContent), ); const embeddingsDataPath = filePath.replace( diff --git a/src/app/api/videos/route.ts b/src/app/api/videos/route.ts index 7e8288b..02e5909 100644 --- a/src/app/api/videos/route.ts +++ b/src/app/api/videos/route.ts @@ -1,23 +1,12 @@ import handleVideoSearch from '@/lib/chains/videoSearchAgent'; -import { - getCustomOpenaiApiKey, - getCustomOpenaiApiUrl, - getCustomOpenaiModelName, -} from '@/lib/config'; -import { getAvailableChatModelProviders } from '@/lib/providers'; -import { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import ModelRegistry from '@/lib/models/registry'; +import { ModelWithProvider } from '@/lib/models/types'; import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages'; -import { ChatOpenAI } from '@langchain/openai'; - -interface ChatModel { - provider: string; - model: string; -} interface VideoSearchBody { query: string; chatHistory: any[]; - chatModel?: ChatModel; + chatModel: ModelWithProvider; } export const POST = async (req: Request) => { @@ -34,35 +23,12 @@ export const POST = async (req: Request) => { }) .filter((msg) => msg !== undefined) as BaseMessage[]; - const chatModelProviders = await getAvailableChatModelProviders(); + const registry = new ModelRegistry(); - const chatModelProvider = - chatModelProviders[ - body.chatModel?.provider || Object.keys(chatModelProviders)[0] - ]; - const chatModel = - chatModelProvider[ - body.chatModel?.model || Object.keys(chatModelProvider)[0] - ]; - - let llm: BaseChatModel | undefined; - - if (body.chatModel?.provider === 'custom_openai') { - llm = new ChatOpenAI({ - apiKey: getCustomOpenaiApiKey(), - modelName: getCustomOpenaiModelName(), - temperature: 0.7, - configuration: { - baseURL: getCustomOpenaiApiUrl(), - }, - }) as unknown as BaseChatModel; - } else if (chatModelProvider && chatModel) { - llm = chatModel.model; - } - - if (!llm) { - return Response.json({ error: 'Invalid chat model' }, { status: 400 }); - } + const llm = await registry.loadChatModel( + body.chatModel.providerId, + body.chatModel.key, + ); const videos = await handleVideoSearch( { diff --git a/src/app/globals.css b/src/app/globals.css index 639e515..3b95d06 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,7 +5,7 @@ @font-face { font-family: 'PP Editorial'; src: url('/fonts/pp-ed-ul.otf') format('opentype'); - font-weight: 200; + font-weight: 300; font-style: normal; font-display: swap; } @@ -18,6 +18,66 @@ .overflow-hidden-scrollable::-webkit-scrollbar { display: none; } + + * { + scrollbar-width: thin; + scrollbar-color: #e8edf1 transparent; /* light-200 */ + } + + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: #e8edf1; /* light-200 */ + border-radius: 3px; + transition: background 0.2s ease; + } + + *::-webkit-scrollbar-thumb:hover { + background: #d0d7de; /* light-300 */ + } + + @media (prefers-color-scheme: dark) { + * { + scrollbar-color: #21262d transparent; /* dark-200 */ + } + + *::-webkit-scrollbar-thumb { + background: #21262d; /* dark-200 */ + } + + *::-webkit-scrollbar-thumb:hover { + background: #30363d; /* dark-300 */ + } + } + + :root.dark *, + html.dark *, + body.dark * { + scrollbar-color: #21262d transparent; /* dark-200 */ + } + + :root.dark *::-webkit-scrollbar-thumb, + html.dark *::-webkit-scrollbar-thumb, + body.dark *::-webkit-scrollbar-thumb { + background: #21262d; /* dark-200 */ + } + + :root.dark *::-webkit-scrollbar-thumb:hover, + html.dark *::-webkit-scrollbar-thumb:hover, + body.dark *::-webkit-scrollbar-thumb:hover { + background: #30363d; /* dark-300 */ + } + + html { + scroll-behavior: smooth; + } } @layer utilities { @@ -25,6 +85,7 @@ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; + line-clamp: 2; overflow: hidden; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 684a99c..830d842 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,5 @@ +export const dynamic = 'force-dynamic'; + import type { Metadata } from 'next'; import { Montserrat } from 'next/font/google'; import './globals.css'; @@ -5,6 +7,8 @@ import { cn } from '@/lib/utils'; import Sidebar from '@/components/Sidebar'; import { Toaster } from 'sonner'; import ThemeProvider from '@/components/theme/Provider'; +import configManager from '@/lib/config'; +import SetupWizard from '@/components/Setup/SetupWizard'; const montserrat = Montserrat({ weight: ['300', '400', '500', '700'], @@ -24,20 +28,29 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const setupComplete = configManager.isSetupComplete(); + const configSections = configManager.getUIConfigSections(); + return ( - {children} - + {setupComplete ? ( + <> + {children} + + + ) : ( + + )} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx deleted file mode 100644 index 1af53f9..0000000 --- a/src/app/settings/page.tsx +++ /dev/null @@ -1,1007 +0,0 @@ -'use client'; - -import { Settings as SettingsIcon, ArrowLeft, Loader2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { cn } from '@/lib/utils'; -import { Switch } from '@headlessui/react'; -import ThemeSwitcher from '@/components/theme/Switcher'; -import { ImagesIcon, VideoIcon } from 'lucide-react'; -import Link from 'next/link'; -import { PROVIDER_METADATA } from '@/lib/providers'; - -interface SettingsType { - chatModelProviders: { - [key: string]: [Record]; - }; - embeddingModelProviders: { - [key: string]: [Record]; - }; - openaiApiKey: string; - groqApiKey: string; - anthropicApiKey: string; - geminiApiKey: string; - ollamaApiUrl: string; - ollamaApiKey: string; - lmStudioApiUrl: string; - lemonadeApiUrl: string; - lemonadeApiKey: string; - deepseekApiKey: string; - aimlApiKey: string; - customOpenaiApiKey: string; - customOpenaiApiUrl: string; - customOpenaiModelName: string; -} - -interface InputProps extends React.InputHTMLAttributes { - isSaving?: boolean; - onSave?: (value: string) => void; -} - -const Input = ({ className, isSaving, onSave, ...restProps }: InputProps) => { - return ( -
- onSave?.(e.target.value)} - /> - {isSaving && ( -
- -
- )} -
- ); -}; - -interface TextareaProps extends React.InputHTMLAttributes { - isSaving?: boolean; - onSave?: (value: string) => void; -} - -const Textarea = ({ - className, - isSaving, - onSave, - ...restProps -}: TextareaProps) => { - return ( -
-