mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2026-04-10 22:04:27 +00:00
Compare commits
135 Commits
ca4809f0f2
...
v1.12.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8d883768 | ||
|
|
71790f346e | ||
|
|
40a7cdeb4b | ||
|
|
ebcf5384f3 | ||
|
|
a889fdc31e | ||
|
|
c3ee1988d2 | ||
|
|
5991416142 | ||
|
|
7a6fad95ef | ||
|
|
b868aa0287 | ||
|
|
65b2c3f234 | ||
|
|
cbe538cc36 | ||
|
|
f6a47fd3e1 | ||
|
|
690b11cc1c | ||
|
|
96e860a310 | ||
|
|
65475b418f | ||
|
|
acaa208a41 | ||
|
|
54f3a7d2b8 | ||
|
|
1a124b8b07 | ||
|
|
3098622cb0 | ||
|
|
3646495bdf | ||
|
|
476c4ec8c2 | ||
|
|
0e33641927 | ||
|
|
8c061f20a5 | ||
|
|
72ac815294 | ||
|
|
d16b7e271a | ||
|
|
58ed869b3d | ||
|
|
3fede054da | ||
|
|
21bd88787e | ||
|
|
b02f5aa37f | ||
|
|
f83f813bd7 | ||
|
|
0c101d9704 | ||
|
|
7ab23d6339 | ||
|
|
c42d2177b7 | ||
|
|
39c0f198b5 | ||
|
|
80d4f23765 | ||
|
|
a2f2ac532e | ||
|
|
1763ee9d1f | ||
|
|
86274326e9 | ||
|
|
d7b020e5bb | ||
|
|
d95ff9ccdd | ||
|
|
8347b798f3 | ||
|
|
a16472bcf3 | ||
|
|
3b8d8be676 | ||
|
|
b83f9bac78 | ||
|
|
bd7c563137 | ||
|
|
23b903db9a | ||
|
|
a98f0df83f | ||
|
|
164d528761 | ||
|
|
af4ec17117 | ||
|
|
1622e0893a | ||
|
|
55a4b9d436 | ||
|
|
b450d0e668 | ||
|
|
0987ee4370 | ||
|
|
d1bd22786d | ||
|
|
bb7b7170ca | ||
|
|
be7bd62a74 | ||
|
|
a691f3bab0 | ||
|
|
f1c9fa0e33 | ||
|
|
d872cf5009 | ||
|
|
fdef718980 | ||
|
|
19dde42f22 | ||
|
|
c9f6893d99 | ||
|
|
53e9859b6c | ||
|
|
53e39cd985 | ||
|
|
7f3f881964 | ||
|
|
9620e63e3f | ||
|
|
ec5ff6f4a8 | ||
|
|
0ace778b03 | ||
|
|
6919ad1a0f | ||
|
|
b5ba8c48c0 | ||
|
|
65fdecb122 | ||
|
|
5a44319d85 | ||
|
|
cc183cd0cd | ||
|
|
50ca7ac73a | ||
|
|
a31a4ab295 | ||
|
|
edba47aed8 | ||
|
|
ae132ebee8 | ||
|
|
60dd7a8108 | ||
|
|
f5e054f6ea | ||
|
|
452180356d | ||
|
|
0a9641a110 | ||
|
|
a2f2e17bbb | ||
|
|
e1afcbb787 | ||
|
|
fe2c1b8210 | ||
|
|
d40fcd57d9 | ||
|
|
86a43086cc | ||
|
|
9ce17edd4a | ||
|
|
c4349f3d5c | ||
|
|
d4c276ab93 | ||
|
|
6ae885e0ed | ||
|
|
dc74e7174f | ||
|
|
53697bb42e | ||
|
|
eca66f0b5f | ||
|
|
cf95ea0af7 | ||
|
|
24c32ed881 | ||
|
|
b47f522bf2 | ||
|
|
ea18c13326 | ||
|
|
b706434bac | ||
|
|
2c65bd916b | ||
|
|
c3b74a3fd0 | ||
|
|
5f04034650 | ||
|
|
5847379db0 | ||
|
|
8520ea6fe5 | ||
|
|
a6d4f47130 | ||
|
|
f278eb8bf1 | ||
|
|
0e176e0b78 | ||
|
|
8ba64be446 | ||
|
|
216332fb20 | ||
|
|
68a9e048ac | ||
|
|
13d6bcf113 | ||
|
|
94a24d4058 | ||
|
|
300cfa35c7 | ||
|
|
85273493a0 | ||
|
|
6e2345bd2d | ||
|
|
fdee29c93e | ||
|
|
21cb0f5fd9 | ||
|
|
a82b605c70 | ||
|
|
64683e3dec | ||
|
|
604774ef6e | ||
|
|
ac183a90e8 | ||
|
|
5511a276d4 | ||
|
|
473a04b6a5 | ||
|
|
491136822f | ||
|
|
6e086953b1 | ||
|
|
1961e4e707 | ||
|
|
249889f55a | ||
|
|
9b2c229e9c | ||
|
|
4bdb90e150 | ||
|
|
f9cc97ffb5 | ||
|
|
9dd670f46a | ||
|
|
bd3c5f895a | ||
|
|
e6c8a0aa6f | ||
|
|
b90b92079b | ||
|
|
a3065d58ef | ||
|
|
fdaa2f0646 |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
BIN
.assets/vane-screenshot.png
Normal file
BIN
.assets/vane-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
80
.github/workflows/docker-build.yaml
vendored
80
.github/workflows/docker-build.yaml
vendored
@@ -44,11 +44,11 @@ jobs:
|
||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||
VARIANT=${{ matrix.variant.name }}
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
||||
-t itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push AMD64 Canary Docker image
|
||||
@@ -57,11 +57,11 @@ jobs:
|
||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||
VARIANT=${{ matrix.variant.name }}
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \
|
||||
-t itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push AMD64 release Docker image
|
||||
@@ -70,11 +70,11 @@ jobs:
|
||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||
VARIANT=${{ matrix.variant.name }}
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||
-t itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||
--push .
|
||||
|
||||
build-arm64:
|
||||
@@ -112,11 +112,11 @@ jobs:
|
||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||
VARIANT=${{ matrix.variant.name }}
|
||||
docker buildx build --platform linux/arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/vane:${VARIANT}-arm64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/perplexica:${VARIANT}-arm64 \
|
||||
-t itzcrazykns1337/vane:${VARIANT}-arm64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push ARM64 Canary Docker image
|
||||
@@ -125,11 +125,11 @@ jobs:
|
||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||
VARIANT=${{ matrix.variant.name }}
|
||||
docker buildx build --platform linux/arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/vane:${VARIANT}-canary-arm64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 \
|
||||
-t itzcrazykns1337/vane:${VARIANT}-canary-arm64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push ARM64 release Docker image
|
||||
@@ -138,11 +138,11 @@ jobs:
|
||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||
VARIANT=${{ matrix.variant.name }}
|
||||
docker buildx build --platform linux/arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \
|
||||
-t itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \
|
||||
--push .
|
||||
|
||||
manifest:
|
||||
@@ -167,51 +167,51 @@ jobs:
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
run: |
|
||||
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
|
||||
docker manifest create itzcrazykns1337/vane:${VARIANT}-latest \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-arm64
|
||||
docker manifest push itzcrazykns1337/vane:${VARIANT}-latest
|
||||
|
||||
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/vane:latest \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-arm64
|
||||
docker manifest push itzcrazykns1337/vane:latest
|
||||
|
||||
docker manifest create itzcrazykns1337/perplexica:main \
|
||||
--amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
||||
--amend itzcrazykns1337/perplexica:${VARIANT}-arm64
|
||||
docker manifest push itzcrazykns1337/perplexica:main
|
||||
docker manifest create itzcrazykns1337/vane:main \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-arm64
|
||||
docker manifest push itzcrazykns1337/vane:main
|
||||
fi
|
||||
|
||||
- name: Create and push manifest for canary
|
||||
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
|
||||
run: |
|
||||
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
|
||||
docker manifest create itzcrazykns1337/vane:${VARIANT}-canary \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-canary-arm64
|
||||
docker manifest push itzcrazykns1337/vane:${VARIANT}-canary
|
||||
|
||||
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
|
||||
docker manifest create itzcrazykns1337/vane:canary \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-canary-arm64
|
||||
docker manifest push itzcrazykns1337/vane:canary
|
||||
fi
|
||||
|
||||
- name: Create and push manifest for releases
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
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 }}
|
||||
docker manifest create itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }} \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64
|
||||
docker manifest push itzcrazykns1337/vane:${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 }}
|
||||
docker manifest create itzcrazykns1337/vane:${{ env.RELEASE_VERSION }} \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64
|
||||
docker manifest push itzcrazykns1337/vane:${{ env.RELEASE_VERSION }}
|
||||
fi
|
||||
|
||||
@@ -1,43 +1,73 @@
|
||||
# How to Contribute to Perplexica
|
||||
# How to Contribute to Vane
|
||||
|
||||
Thanks for your interest in contributing to Perplexica! Your help makes this project better. This guide explains how to contribute effectively.
|
||||
Thanks for your interest in contributing to Vane! Your help makes this project better. This guide explains how to contribute effectively.
|
||||
|
||||
Perplexica is a modern AI chat application with advanced search capabilities.
|
||||
Vane is a modern AI chat application with advanced search capabilities.
|
||||
|
||||
## Project Structure
|
||||
|
||||
Perplexica's codebase is organized as follows:
|
||||
Vane's codebase is organized as follows:
|
||||
|
||||
- **UI Components and Pages**:
|
||||
- **Components (`src/components`)**: Reusable UI components.
|
||||
- **Pages and Routes (`src/app`)**: Next.js app directory structure with page components.
|
||||
- Main app routes include: home (`/`), chat (`/c`), discover (`/discover`), library (`/library`), and settings (`/settings`).
|
||||
- **API Routes (`src/app/api`)**: API endpoints implemented with Next.js API routes.
|
||||
- `/api/chat`: Handles chat interactions.
|
||||
- `/api/search`: Provides direct access to Perplexica's search capabilities.
|
||||
- Other endpoints for models, files, and suggestions.
|
||||
- Main app routes include: home (`/`), chat (`/c`), discover (`/discover`), and library (`/library`).
|
||||
- **API Routes (`src/app/api`)**: Server endpoints implemented with Next.js route handlers.
|
||||
- **Backend Logic (`src/lib`)**: Contains all the backend functionality including search, database, and API logic.
|
||||
- The search functionality is present inside `src/lib/search` directory.
|
||||
- All of the focus modes are implemented using the Meta Search Agent class in `src/lib/search/metaSearchAgent.ts`.
|
||||
- The search system lives in `src/lib/agents/search`.
|
||||
- The search pipeline is split into classification, research, widgets, and writing.
|
||||
- Database functionality is in `src/lib/db`.
|
||||
- Chat model and embedding model providers are managed in `src/lib/providers`.
|
||||
- Prompt templates and LLM chain definitions are in `src/lib/prompts` and `src/lib/chains` respectively.
|
||||
- Chat model and embedding model providers are in `src/lib/models/providers`, and models are loaded via `src/lib/models/registry.ts`.
|
||||
- Prompt templates are in `src/lib/prompts`.
|
||||
- SearXNG integration is in `src/lib/searxng.ts`.
|
||||
- Upload search lives in `src/lib/uploads`.
|
||||
|
||||
### Where to make changes
|
||||
|
||||
If you are not sure where to start, use this section as a map.
|
||||
|
||||
- **Search behavior and reasoning**
|
||||
|
||||
- `src/lib/agents/search` contains the core chat and search pipeline.
|
||||
- `classifier.ts` decides whether research is needed and what should run.
|
||||
- `researcher/` gathers information in the background.
|
||||
|
||||
- **Add or change a search capability**
|
||||
|
||||
- Research tools (web, academic, discussions, uploads, scraping) live in `src/lib/agents/search/researcher/actions`.
|
||||
- Tools are registered in `src/lib/agents/search/researcher/actions/index.ts`.
|
||||
|
||||
- **Add or change widgets**
|
||||
|
||||
- Widgets live in `src/lib/agents/search/widgets`.
|
||||
- Widgets run in parallel with research and show structured results in the UI.
|
||||
|
||||
- **Model integrations**
|
||||
|
||||
- Providers live in `src/lib/models/providers`.
|
||||
- Add new providers there and wire them into the model registry so they show up in the app.
|
||||
|
||||
- **Architecture docs**
|
||||
- High level overview: `docs/architecture/README.md`
|
||||
- High level flow: `docs/architecture/WORKING.md`
|
||||
|
||||
## API Documentation
|
||||
|
||||
Perplexica exposes several API endpoints for programmatic access, including:
|
||||
Vane includes API documentation for programmatic access.
|
||||
|
||||
- **Search API**: Access Perplexica's advanced search capabilities directly via the `/api/search` endpoint. For detailed documentation, see `docs/api/search.md`.
|
||||
- **Search API**: For detailed documentation, see `docs/API/SEARCH.md`.
|
||||
|
||||
## Setting Up Your Environment
|
||||
|
||||
Before diving into coding, setting up your local environment is key. Here's what you need to do:
|
||||
|
||||
1. In the root directory, locate the `sample.config.toml` file.
|
||||
2. Rename it to `config.toml` and fill in the necessary configuration fields.
|
||||
3. Run `npm install` to install all dependencies.
|
||||
4. Run `npm run db:migrate` to set up the local sqlite database.
|
||||
5. Use `npm run dev` to start the application in development mode.
|
||||
1. Run `npm install` to install all dependencies.
|
||||
2. Use `npm run dev` to start the application in development mode.
|
||||
3. Open http://localhost:3000 and complete the setup in the UI (API keys, models, search backend URL, etc.).
|
||||
|
||||
Database migrations are applied automatically on startup.
|
||||
|
||||
For full installation options (Docker and non Docker), see the installation guide in the repository README.
|
||||
|
||||
**Please note**: Docker configurations are present for setting up production environments, whereas `npm run dev` is used for development purposes.
|
||||
|
||||
@@ -49,4 +79,4 @@ Before committing changes:
|
||||
2. Always run `npm run format:write` to format your code according to the project's coding standards. This helps maintain consistency and code quality.
|
||||
3. We currently do not have a code of conduct, but it is in the works. In the meantime, please be mindful of how you engage with the project and its community.
|
||||
|
||||
Following these steps will help maintain the integrity of Perplexica's codebase and facilitate a smoother integration of your valuable contributions. Thank you for your support and commitment to improving Perplexica.
|
||||
Following these steps will help maintain the integrity of Vane's codebase and facilitate a smoother integration of your valuable contributions. Thank you for your support and commitment to improving Vane.
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -2,7 +2,7 @@ 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
|
||||
WORKDIR /home/vane
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||
@@ -12,7 +12,7 @@ COPY src ./src
|
||||
COPY public ./public
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
RUN mkdir -p /home/perplexica/data
|
||||
RUN mkdir -p /home/vane/data
|
||||
RUN yarn build
|
||||
|
||||
FROM node:24.5.0-slim
|
||||
@@ -24,15 +24,18 @@ RUN apt-get update && apt-get install -y \
|
||||
curl sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /home/perplexica
|
||||
WORKDIR /home/vane
|
||||
|
||||
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 --from=builder /home/vane/public ./public
|
||||
COPY --from=builder /home/vane/.next/static ./public/_next/static
|
||||
COPY --from=builder /home/vane/.next/standalone ./
|
||||
COPY --from=builder /home/vane/data ./data
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
RUN mkdir /home/perplexica/uploads
|
||||
RUN mkdir /home/vane/uploads
|
||||
|
||||
RUN yarn add playwright
|
||||
RUN yarn playwright install --with-deps --only-shell chromium
|
||||
|
||||
RUN useradd --shell /bin/bash --system \
|
||||
--home-dir "/usr/local/searxng" \
|
||||
@@ -54,13 +57,13 @@ RUN git clone "https://github.com/searxng/searxng" \
|
||||
"/usr/local/searxng/searxng-src"
|
||||
|
||||
RUN python3 -m venv "/usr/local/searxng/searx-pyenv"
|
||||
RUN "/usr/local/searxng/searx-pyenv/bin/pip" install --upgrade pip setuptools wheel pyyaml msgspec
|
||||
RUN "/usr/local/searxng/searx-pyenv/bin/pip" install --upgrade pip setuptools wheel pyyaml msgspec typing_extensions
|
||||
RUN cd "/usr/local/searxng/searxng-src" && \
|
||||
"/usr/local/searxng/searx-pyenv/bin/pip" install --use-pep517 --no-build-isolation -e .
|
||||
|
||||
USER root
|
||||
|
||||
WORKDIR /home/perplexica
|
||||
WORKDIR /home/vane
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
RUN sed -i 's/\r$//' ./entrypoint.sh || true
|
||||
@@ -71,4 +74,4 @@ EXPOSE 3000 8080
|
||||
|
||||
ENV SEARXNG_API_URL=http://localhost:8080
|
||||
|
||||
CMD ["/home/perplexica/entrypoint.sh"]
|
||||
CMD ["/home/vane/entrypoint.sh"]
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
WORKDIR /home/vane
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||
@@ -12,23 +12,23 @@ COPY src ./src
|
||||
COPY public ./public
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
RUN mkdir -p /home/perplexica/data
|
||||
RUN mkdir -p /home/vane/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
|
||||
WORKDIR /home/vane
|
||||
|
||||
COPY --from=builder /home/perplexica/public ./public
|
||||
COPY --from=builder /home/perplexica/.next/static ./public/_next/static
|
||||
COPY --from=builder /home/vane/public ./public
|
||||
COPY --from=builder /home/vane/.next/static ./public/_next/static
|
||||
|
||||
COPY --from=builder /home/perplexica/.next/standalone ./
|
||||
COPY --from=builder /home/perplexica/data ./data
|
||||
COPY --from=builder /home/vane/.next/standalone ./
|
||||
COPY --from=builder /home/vane/data ./data
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
RUN mkdir /home/perplexica/uploads
|
||||
RUN mkdir /home/vane/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ItzCrazyKns
|
||||
Copyright (c) 2026 ItzCrazyKns
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
104
README.md
104
README.md
@@ -1,32 +1,34 @@
|
||||
# Perplexica 🔍
|
||||
# Vane 🔍
|
||||
|
||||
[](https://github.com/ItzCrazyKns/Perplexica/stargazers)
|
||||
[](https://github.com/ItzCrazyKns/Perplexica/network/members)
|
||||
[](https://github.com/ItzCrazyKns/Perplexica/watchers)
|
||||
[](https://hub.docker.com/r/itzcrazykns1337/perplexica)
|
||||
[](https://github.com/ItzCrazyKns/Perplexica/blob/master/LICENSE)
|
||||
[](https://github.com/ItzCrazyKns/Perplexica/commits/master)
|
||||
[](https://github.com/ItzCrazyKns/Vane/stargazers)
|
||||
[](https://github.com/ItzCrazyKns/Vane/network/members)
|
||||
[](https://github.com/ItzCrazyKns/Vane/watchers)
|
||||
[](https://hub.docker.com/r/itzcrazykns1337/vane)
|
||||
[](https://github.com/ItzCrazyKns/Vane/blob/master/LICENSE)
|
||||
[](https://github.com/ItzCrazyKns/Vane/commits/master)
|
||||
[](https://discord.gg/26aArMy8tT)
|
||||
|
||||
Perplexica is a **privacy-focused AI answering engine** that runs entirely on your own hardware. It combines knowledge from the vast internet with support for **local LLMs** (Ollama) and cloud providers (OpenAI, Claude, Groq), delivering accurate answers with **cited sources** while keeping your searches completely private.
|
||||
Vane is a **privacy-focused AI answering engine** that runs entirely on your own hardware. It combines knowledge from the vast internet with support for **local LLMs** (Ollama) and cloud providers (OpenAI, Claude, Groq), delivering accurate answers with **cited sources** while keeping your searches completely private.
|
||||
|
||||

|
||||

|
||||
|
||||
Want to know more about its architecture and how it works? You can read it [here](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/README.md).
|
||||
Want to know more about its architecture and how it works? You can read it [here](https://github.com/ItzCrazyKns/Vane/tree/master/docs/architecture/README.md).
|
||||
|
||||
## ✨ Features
|
||||
|
||||
🤖 **Support for all major AI providers** - Use local LLMs through Ollama or connect to OpenAI, Anthropic Claude, Google Gemini, Groq, and more. Mix and match models based on your needs.
|
||||
|
||||
⚡ **Smart search modes** - Choose Balanced Mode for everyday searches, Fast Mode when you need quick answers, or wait for Quality Mode (coming soon) for deep research.
|
||||
⚡ **Smart search modes** - Choose Speed Mode when you need quick answers, Balanced Mode for everyday searches, or Quality Mode for deep research.
|
||||
|
||||
🎯 **Six specialized focus modes** - Get better results with modes designed for specific tasks: Academic papers, YouTube videos, Reddit discussions, Wolfram Alpha calculations, writing assistance, or general web search.
|
||||
🧭 **Pick your sources** - Search the web, discussions, or academic papers. More sources and integrations are in progress.
|
||||
|
||||
🧩 **Widgets** - Helpful UI cards that show up when relevant, like weather, calculations, stock prices, and other quick lookups.
|
||||
|
||||
🔍 **Web search powered by SearxNG** - Access multiple search engines while keeping your identity private. Support for Tavily and Exa coming soon for even better results.
|
||||
|
||||
📷 **Image and video search** - Find visual content alongside text results. Search isn't limited to just articles anymore.
|
||||
|
||||
📄 **File uploads** - Upload documents and ask questions about them. PDFs, text files, images - Perplexica understands them all.
|
||||
📄 **File uploads** - Upload documents and ask questions about them. PDFs, text files, images - Vane understands them all.
|
||||
|
||||
🌐 **Search specific domains** - Limit your search to specific websites when you know where to look. Perfect for technical documentation or research papers.
|
||||
|
||||
@@ -36,11 +38,11 @@ Want to know more about its architecture and how it works? You can read it [here
|
||||
|
||||
🕒 **Search history** - Every search is saved locally so you can revisit your discoveries anytime. Your research is never lost.
|
||||
|
||||
✨ **More coming soon** - We're actively developing new features based on community feedback. Join our Discord to help shape Perplexica's future!
|
||||
✨ **More coming soon** - We're actively developing new features based on community feedback. Join our Discord to help shape Vane's future!
|
||||
|
||||
## Sponsors
|
||||
|
||||
Perplexica's development is powered by the generous support of our sponsors. Their contributions help keep this project free, open-source, and accessible to everyone.
|
||||
Vane's development is powered by the generous support of our sponsors. Their contributions help keep this project free, open-source, and accessible to everyone.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -49,7 +51,7 @@ Perplexica's development is powered by the generous support of our sponsors. The
|
||||
<img alt="Warp Terminal" src=".assets/sponsers/warp.png" width="100%">
|
||||
</a>
|
||||
|
||||
### **✨ [Try Warp - The AI-Powered Terminal →](https://www.warp.dev/perplexica)**
|
||||
### **✨ [Try Warp - The AI-Powered Terminal →](https://www.warp.dev/vane)**
|
||||
|
||||
Warp is revolutionizing development workflows with AI-powered features, modern UX, and blazing-fast performance. Used by developers at top companies worldwide.
|
||||
|
||||
@@ -74,26 +76,26 @@ We'd also like to thank the following partners for their generous support:
|
||||
|
||||
## Installation
|
||||
|
||||
There are mainly 2 ways of installing Perplexica - With Docker, Without Docker. Using Docker is highly recommended.
|
||||
There are mainly 2 ways of installing Vane - With Docker, Without Docker. Using Docker is highly recommended.
|
||||
|
||||
### Getting Started with Docker (Recommended)
|
||||
|
||||
Perplexica can be easily run using Docker. Simply run the following command:
|
||||
Vane can be easily run using Docker. Simply run the following command:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -v perplexica-data:/home/perplexica/data -v perplexica-uploads:/home/perplexica/uploads --name perplexica itzcrazykns1337/perplexica:latest
|
||||
docker run -d -p 3000:3000 -v vane-data:/home/vane/data --name vane itzcrazykns1337/vane: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.
|
||||
This will pull and start the Vane 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. The `-v` flags create persistent volumes for your data and uploaded files.
|
||||
**Note**: The image includes both Vane and SearxNG, so no additional setup is required. The `-v` flags create persistent volumes for your data and uploaded files.
|
||||
|
||||
#### Using Perplexica with Your Own SearxNG Instance
|
||||
#### Using Vane with Your Own SearxNG Instance
|
||||
|
||||
If you already have SearxNG running, you can use the slim version of Perplexica:
|
||||
If you already have SearxNG running, you can use the slim version of Vane:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 -v perplexica-data:/home/perplexica/data -v perplexica-uploads:/home/perplexica/uploads --name perplexica itzcrazykns1337/perplexica:slim-latest
|
||||
docker run -d -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 -v vane-data:/home/vane/data --name vane itzcrazykns1337/vane:slim-latest
|
||||
```
|
||||
|
||||
**Important**: Make sure your SearxNG instance has:
|
||||
@@ -108,10 +110,10 @@ Replace `http://your-searxng-url:8080` with your actual SearxNG URL. Then config
|
||||
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:
|
||||
2. Clone the Vane repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ItzCrazyKns/Perplexica.git
|
||||
git clone https://github.com/ItzCrazyKns/Vane.git
|
||||
```
|
||||
|
||||
3. After cloning, navigate to the directory containing the project files.
|
||||
@@ -119,13 +121,13 @@ If you prefer to build from source or need more control:
|
||||
4. Build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t perplexica .
|
||||
docker run -d -p 3000:3000 -v perplexica-data:/home/perplexica/data -v perplexica-uploads:/home/perplexica/uploads --name perplexica perplexica
|
||||
docker build -t vane .
|
||||
docker run -d -p 3000:3000 -v vane-data:/home/vane/data --name vane vane
|
||||
```
|
||||
|
||||
5. Access Perplexica at http://localhost:3000 and configure your settings in the setup screen.
|
||||
5. Access Vane 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.
|
||||
**Note**: After the containers are built, you can start Vane directly from Docker without having to open a terminal.
|
||||
|
||||
### Non-Docker Installation
|
||||
|
||||
@@ -133,8 +135,8 @@ If you prefer to build from source or need more control:
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ItzCrazyKns/Perplexica.git
|
||||
cd Perplexica
|
||||
git clone https://github.com/ItzCrazyKns/Vane.git
|
||||
cd Vane
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
@@ -159,13 +161,13 @@ If you prefer to build from source or need more control:
|
||||
|
||||
**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 updating, etc.
|
||||
See the [installation documentation](https://github.com/ItzCrazyKns/Vane/tree/master/docs/installation) for more information like updating, etc.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Local OpenAI-API-Compliant Servers
|
||||
|
||||
If Perplexica tells you that you haven't configured any chat model providers, ensure that:
|
||||
If Vane tells you that you haven't configured any chat model providers, ensure that:
|
||||
|
||||
1. Your server is running on `0.0.0.0` (not `127.0.0.1`) and on the same port you put in the API URL.
|
||||
2. You have specified the correct model name loaded by your local LLM server.
|
||||
@@ -211,43 +213,39 @@ If you're encountering a Lemonade connection error, it is likely due to the back
|
||||
|
||||
## 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:
|
||||
If you wish to use Vane 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.
|
||||
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 Vane is not hosted locally.
|
||||
4. Click the add button. Now, you can use Vane directly from your browser's search bar.
|
||||
|
||||
## Using Perplexica's API
|
||||
## Using Vane's API
|
||||
|
||||
Perplexica also provides an API for developers looking to integrate its powerful search engine into their own applications. You can run searches, use multiple models and get answers to your queries.
|
||||
Vane also provides an API for developers looking to integrate its powerful search engine into their own applications. You can run searches, use multiple models and get answers to your queries.
|
||||
|
||||
For more details, check out the full documentation [here](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/API/SEARCH.md).
|
||||
For more details, check out the full documentation [here](https://github.com/ItzCrazyKns/Vane/tree/master/docs/API/SEARCH.md).
|
||||
|
||||
## Expose Perplexica to network
|
||||
## Expose Vane to network
|
||||
|
||||
Perplexica runs on Next.js and handles all API requests. It works right away on the same network and stays accessible even with port forwarding.
|
||||
Vane runs on Next.js and handles all API requests. It works right away on the same network and stays accessible even with port forwarding.
|
||||
|
||||
## One-Click Deployment
|
||||
|
||||
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
||||
[](https://repocloud.io/details/?app_id=267)
|
||||
[](https://template.run.claw.cloud/?referralCode=U11MRQ8U9RM4&openapp=system-fastdeploy%3FtemplateName%3Dperplexica)
|
||||
[](https://www.hostinger.com/vps/docker-hosting?compose_url=https://raw.githubusercontent.com/ItzCrazyKns/Perplexica/refs/heads/master/docker-compose.yaml)
|
||||
[](https://www.hostinger.com/vps/docker-hosting?compose_url=https://raw.githubusercontent.com/ItzCrazyKns/Vane/refs/heads/master/docker-compose.yaml)
|
||||
|
||||
## Upcoming Features
|
||||
|
||||
- [x] Add settings page
|
||||
- [x] Adding support for local LLMs
|
||||
- [x] History Saving features
|
||||
- [x] Introducing various Focus Modes
|
||||
- [x] Adding API support
|
||||
- [x] Adding Discover
|
||||
- [ ] Finalizing Copilot Mode
|
||||
- [ ] Adding more widgets, integrations, search sources
|
||||
- [ ] Adding ability to create custom agents (name T.B.D.)
|
||||
- [ ] Adding authentication
|
||||
|
||||
## 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 greatly appreciated.
|
||||
If you find Vane useful, consider giving us a star on GitHub. This helps more people discover Vane and supports the development of new features. Your support is greatly appreciated.
|
||||
|
||||
### Donations
|
||||
|
||||
@@ -259,10 +257,10 @@ We also accept donations to help sustain our project. If you would like to contr
|
||||
|
||||
## Contribution
|
||||
|
||||
Perplexica is built on the idea that AI and large language models should be easy for everyone to use. If you find bugs or have ideas, please share them in via GitHub Issues. For more information on contributing to Perplexica you can read the [CONTRIBUTING.md](CONTRIBUTING.md) file to learn more about Perplexica and how you can contribute to it.
|
||||
Vane is built on the idea that AI and large language models should be easy for everyone to use. If you find bugs or have ideas, please share them in via GitHub Issues. For more information on contributing to Vane you can read the [CONTRIBUTING.md](CONTRIBUTING.md) file to learn more about Vane and how you can contribute to it.
|
||||
|
||||
## Help and Support
|
||||
|
||||
If you have any questions or feedback, please feel free to reach out to us. You can create an issue on GitHub or join our Discord server. There, you can connect with other users, share your experiences and reviews, and receive more personalized help. [Click here](https://discord.gg/EFwsmQDgAu) to join the Discord server. To discuss matters outside of regular support, feel free to contact me on Discord at `itzcrazykns`.
|
||||
|
||||
Thank you for exploring Perplexica, the AI-powered search engine designed to enhance your search experience. We are constantly working to improve Perplexica and expand its capabilities. We value your feedback and contributions which help us make Perplexica even better. Don't forget to check back for updates and new features!
|
||||
Thank you for exploring Vane, the AI-powered search engine designed to enhance your search experience. We are constantly working to improve Vane and expand its capabilities. We value your feedback and contributions which help us make Vane even better. Don't forget to check back for updates and new features!
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
services:
|
||||
perplexica:
|
||||
image: itzcrazykns1337/perplexica:latest
|
||||
vane:
|
||||
image: itzcrazykns1337/vane:latest
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
volumes:
|
||||
- data:/home/perplexica/data
|
||||
- uploads:/home/perplexica/uploads
|
||||
- data:/home/vane/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: 'perplexica-data'
|
||||
uploads:
|
||||
name: 'perplexica-uploads'
|
||||
name: 'vane-data'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Perplexica Search API Documentation
|
||||
# Vane Search API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
Vane'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 Vane's search API.
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -53,11 +53,11 @@ Use the `id` field as the `providerId` and the `key` field from the models array
|
||||
|
||||
**Full URL**: `http://localhost:3000/api/search`
|
||||
|
||||
**Note**: Replace `localhost:3000` with your Perplexica instance URL if running on a different host or port
|
||||
**Note**: Replace `localhost:3000` with your Vane instance URL if running on a different host or port
|
||||
|
||||
### Request
|
||||
|
||||
The API accepts a JSON object in the request body, where you define the focus mode, chat models, embedding models, and your query.
|
||||
The API accepts a JSON object in the request body, where you define the enabled search `sources`, chat models, embedding models, and your query.
|
||||
|
||||
#### Request Body Structure
|
||||
|
||||
@@ -72,13 +72,13 @@ The API accepts a JSON object in the request body, where you define the focus mo
|
||||
"key": "text-embedding-3-large"
|
||||
},
|
||||
"optimizationMode": "speed",
|
||||
"focusMode": "webSearch",
|
||||
"query": "What is Perplexica",
|
||||
"sources": ["web"],
|
||||
"query": "What is Vane",
|
||||
"history": [
|
||||
["human", "Hi, how are you?"],
|
||||
["assistant", "I am doing well, how can I help you today?"]
|
||||
],
|
||||
"systemInstructions": "Focus on providing technical details about Perplexica's architecture.",
|
||||
"systemInstructions": "Focus on providing technical details about Vane's architecture.",
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
@@ -87,24 +87,25 @@ The API accepts a JSON object in the request body, where you define the focus mo
|
||||
|
||||
### Request Parameters
|
||||
|
||||
- **`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`.
|
||||
- **`chatModel`** (object, required): 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`.
|
||||
|
||||
- `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. To get available providers and models, send a GET request to `http://localhost:3000/api/providers`.
|
||||
- **`embeddingModel`** (object, required): Defines the embedding model for similarity-based searching. To get available providers and models, send a GET request to `http://localhost:3000/api/providers`.
|
||||
|
||||
- `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:
|
||||
- **`sources`** (array, required): Which search sources to enable. Available values:
|
||||
|
||||
- `webSearch`, `academicSearch`, `writingAssistant`, `wolframAlphaSearch`, `youtubeSearch`, `redditSearch`.
|
||||
- `web`, `academic`, `discussions`.
|
||||
|
||||
- **`optimizationMode`** (string, optional): Specifies the optimization mode to control the balance between performance and quality. Available modes:
|
||||
|
||||
- `speed`: Prioritize speed and return the fastest answer.
|
||||
- `balanced`: Provide a balanced answer with good speed and reasonable quality.
|
||||
- `quality`: Prioritize answer quality (may be slower).
|
||||
|
||||
- **`query`** (string, required): The search query or question.
|
||||
|
||||
@@ -114,8 +115,8 @@ The API accepts a JSON object in the request body, where you define the focus mo
|
||||
|
||||
```json
|
||||
[
|
||||
["human", "What is Perplexica?"],
|
||||
["assistant", "Perplexica is an AI-powered search engine..."]
|
||||
["human", "What is Vane?"],
|
||||
["assistant", "Vane is an AI-powered search engine..."]
|
||||
]
|
||||
```
|
||||
|
||||
@@ -129,20 +130,20 @@ The response from the API includes both the final message and the sources used t
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Perplexica is an innovative, open-source AI-powered search engine designed to enhance the way users search for information online. Here are some key features and characteristics of Perplexica:\n\n- **AI-Powered Technology**: It utilizes advanced machine learning algorithms to not only retrieve information but also to understand the context and intent behind user queries, providing more relevant results [1][5].\n\n- **Open-Source**: Being open-source, Perplexica offers flexibility and transparency, allowing users to explore its functionalities without the constraints of proprietary software [3][10].",
|
||||
"message": "Vane is an innovative, open-source AI-powered search engine designed to enhance the way users search for information online. Here are some key features and characteristics of Vane:\n\n- **AI-Powered Technology**: It utilizes advanced machine learning algorithms to not only retrieve information but also to understand the context and intent behind user queries, providing more relevant results [1][5].\n\n- **Open-Source**: Being open-source, Vane offers flexibility and transparency, allowing users to explore its functionalities without the constraints of proprietary software [3][10].",
|
||||
"sources": [
|
||||
{
|
||||
"pageContent": "Perplexica is an innovative, open-source AI-powered search engine designed to enhance the way users search for information online.",
|
||||
"content": "Vane is an innovative, open-source AI-powered search engine designed to enhance the way users search for information online.",
|
||||
"metadata": {
|
||||
"title": "What is Perplexica, and how does it function as an AI-powered search ...",
|
||||
"url": "https://askai.glarity.app/search/What-is-Perplexica--and-how-does-it-function-as-an-AI-powered-search-engine"
|
||||
"title": "What is Vane, and how does it function as an AI-powered search ...",
|
||||
"url": "https://askai.glarity.app/search/What-is-Vane--and-how-does-it-function-as-an-AI-powered-search-engine"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pageContent": "Perplexica is an open-source AI-powered search tool that dives deep into the internet to find precise answers.",
|
||||
"content": "Vane is an open-source AI-powered search tool that dives deep into the internet to find precise answers.",
|
||||
"metadata": {
|
||||
"title": "Sahar Mor's Post",
|
||||
"url": "https://www.linkedin.com/posts/sahar-mor_a-new-open-source-project-called-perplexica-activity-7204489745668694016-ncja"
|
||||
"url": "https://www.linkedin.com/posts/sahar-mor_a-new-open-source-project-called-vane-activity-7204489745668694016-ncja"
|
||||
}
|
||||
}
|
||||
....
|
||||
@@ -158,8 +159,8 @@ Example of streamed response objects:
|
||||
|
||||
```
|
||||
{"type":"init","data":"Stream connected"}
|
||||
{"type":"sources","data":[{"pageContent":"...","metadata":{"title":"...","url":"..."}},...]}
|
||||
{"type":"response","data":"Perplexica is an "}
|
||||
{"type":"sources","data":[{"content":"...","metadata":{"title":"...","url":"..."}},...]}
|
||||
{"type":"response","data":"Vane is an "}
|
||||
{"type":"response","data":"innovative, open-source "}
|
||||
{"type":"response","data":"AI-powered search engine..."}
|
||||
{"type":"done"}
|
||||
@@ -174,9 +175,9 @@ Clients should process each line as a separate JSON object. The different messag
|
||||
|
||||
### Fields in the Response
|
||||
|
||||
- **`message`** (string): The search result, generated based on the query and focus mode.
|
||||
- **`message`** (string): The search result, generated based on the query and enabled `sources`.
|
||||
- **`sources`** (array): A list of sources that were used to generate the search result. Each source includes:
|
||||
- `pageContent`: A snippet of the relevant content from the source.
|
||||
- `content`: A snippet of the relevant content from the source.
|
||||
- `metadata`: Metadata about the source, including:
|
||||
- `title`: The title of the webpage.
|
||||
- `url`: The URL of the webpage.
|
||||
@@ -185,5 +186,5 @@ Clients should process each line as a separate JSON object. The different messag
|
||||
|
||||
If an error occurs during the search process, the API will return an appropriate error message with an HTTP status code.
|
||||
|
||||
- **400**: If the request is malformed or missing required fields (e.g., no focus mode or query).
|
||||
- **400**: If the request is malformed or missing required fields (e.g., no `sources` or `query`).
|
||||
- **500**: If an internal server error occurs during the search.
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
# Perplexica's Architecture
|
||||
# Vane Architecture
|
||||
|
||||
Perplexica's architecture consists of the following key components:
|
||||
Vane is a Next.js application that combines an AI chat experience with search.
|
||||
|
||||
1. **User Interface**: A web-based interface that allows users to interact with Perplexica for searching images, videos, and much more.
|
||||
2. **Agent/Chains**: These components predict Perplexica's next actions, understand user queries, and decide whether a web search is necessary.
|
||||
3. **SearXNG**: A metadata search engine used by Perplexica to search the web for sources.
|
||||
4. **LLMs (Large Language Models)**: Utilized by agents and chains for tasks like understanding content, writing responses, and citing sources. Examples include Claude, GPTs, etc.
|
||||
5. **Embedding Models**: To improve the accuracy of search results, embedding models re-rank the results using similarity search algorithms such as cosine similarity and dot product distance.
|
||||
For a high level flow, see [WORKING.md](WORKING.md). For deeper implementation details, see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
||||
|
||||
For a more detailed explanation of how these components work together, see [WORKING.md](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/WORKING.md).
|
||||
## Key components
|
||||
|
||||
1. **User Interface**
|
||||
|
||||
- A web based UI that lets users chat, search, and view citations.
|
||||
|
||||
2. **API Routes**
|
||||
|
||||
- `POST /api/chat` powers the chat UI.
|
||||
- `POST /api/search` provides a programmatic search endpoint.
|
||||
- `GET /api/providers` lists available providers and model keys.
|
||||
|
||||
3. **Agents and Orchestration**
|
||||
|
||||
- The system classifies the question first.
|
||||
- It can run research and widgets in parallel.
|
||||
- It generates the final answer and includes citations.
|
||||
|
||||
4. **Search Backend**
|
||||
|
||||
- A meta search backend is used to fetch relevant web results when research is enabled.
|
||||
|
||||
5. **LLMs (Large Language Models)**
|
||||
|
||||
- Used for classification, writing answers, and producing citations.
|
||||
|
||||
6. **Embedding Models**
|
||||
|
||||
- Used for semantic search over user uploaded files.
|
||||
|
||||
7. **Storage**
|
||||
- Chats and messages are stored so conversations can be reloaded.
|
||||
|
||||
@@ -1,19 +1,72 @@
|
||||
# How does Perplexica work?
|
||||
# How Vane Works
|
||||
|
||||
Curious about how Perplexica works? Don't worry, we'll cover it here. Before we begin, make sure you've read about the architecture of Perplexica to ensure you understand what it's made up of. Haven't read it? You can read it [here](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/README.md).
|
||||
This is a high level overview of how Vane answers a question.
|
||||
|
||||
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:
|
||||
If you want a component level overview, see [README.md](README.md).
|
||||
|
||||
1. The message is sent to the `/api/chat` route 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 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.
|
||||
If you want implementation details, see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
||||
|
||||
## How are the answers cited?
|
||||
## What happens when you ask a question
|
||||
|
||||
The LLMs are prompted to do so. We've prompted them so well that they cite the answers themselves, and using some UI magic, we display it to the user.
|
||||
When you send a message in the UI, the app calls `POST /api/chat`.
|
||||
|
||||
## Image and Video Search
|
||||
At a high level, we do three things:
|
||||
|
||||
Image and video searches are conducted in a similar manner. A query is always generated first, then we search the web for images and videos that match the query. These results are then returned to the user.
|
||||
1. Classify the question and decide what to do next.
|
||||
2. Run research and widgets in parallel.
|
||||
3. Write the final answer and include citations.
|
||||
|
||||
## Classification
|
||||
|
||||
Before searching or answering, we run a classification step.
|
||||
|
||||
This step decides things like:
|
||||
|
||||
- Whether we should do research for this question
|
||||
- Whether we should show any widgets
|
||||
- How to rewrite the question into a clearer standalone form
|
||||
|
||||
## Widgets
|
||||
|
||||
Widgets are small, structured helpers that can run alongside research.
|
||||
|
||||
Examples include weather, stocks, and simple calculations.
|
||||
|
||||
If a widget is relevant, we show it in the UI while the answer is still being generated.
|
||||
|
||||
Widgets are helpful context for the answer, but they are not part of what the model should cite.
|
||||
|
||||
## Research
|
||||
|
||||
If research is needed, we gather information in the background while widgets can run.
|
||||
|
||||
Depending on configuration, research may include web lookup and searching user uploaded files.
|
||||
|
||||
## Answer generation
|
||||
|
||||
Once we have enough context, the chat model generates the final response.
|
||||
|
||||
You can control the tradeoff between speed and quality using `optimizationMode`:
|
||||
|
||||
- `speed`
|
||||
- `balanced`
|
||||
- `quality`
|
||||
|
||||
## How citations work
|
||||
|
||||
We prompt the model to cite the references it used. The UI then renders those citations alongside the supporting links.
|
||||
|
||||
## Search API
|
||||
|
||||
If you are integrating Vane into another product, you can call `POST /api/search`.
|
||||
|
||||
It returns:
|
||||
|
||||
- `message`: the generated answer
|
||||
- `sources`: supporting references used for the answer
|
||||
|
||||
You can also enable streaming by setting `stream: true`.
|
||||
|
||||
## Image and video search
|
||||
|
||||
Image and video search use separate endpoints (`POST /api/images` and `POST /api/videos`). We generate a focused query using the chat model, then fetch matching results from a search backend.
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
# Update Perplexica to the latest version
|
||||
# Update Vane to the latest version
|
||||
|
||||
To update Perplexica to the latest version, follow these steps:
|
||||
To update Vane to the latest version, follow these steps:
|
||||
|
||||
## For Docker users (Using pre-built images)
|
||||
|
||||
Simply pull the latest image and restart your container:
|
||||
|
||||
```bash
|
||||
docker pull itzcrazykns1337/perplexica:latest
|
||||
docker stop perplexica
|
||||
docker rm perplexica
|
||||
docker run -d -p 3000:3000 -v perplexica-data:/home/perplexica/data -v perplexica-uploads:/home/perplexica/uploads --name perplexica itzcrazykns1337/perplexica:latest
|
||||
docker pull itzcrazykns1337/vane:latest
|
||||
docker stop vane
|
||||
docker rm vane
|
||||
docker run -d -p 3000:3000 -v vane-data:/home/vane/data --name vane itzcrazykns1337/vane:latest
|
||||
```
|
||||
|
||||
For slim version:
|
||||
|
||||
```bash
|
||||
docker pull itzcrazykns1337/perplexica:slim-latest
|
||||
docker stop perplexica
|
||||
docker rm perplexica
|
||||
docker run -d -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 -v perplexica-data:/home/perplexica/data -v perplexica-uploads:/home/perplexica/uploads --name perplexica itzcrazykns1337/perplexica:slim-latest
|
||||
docker pull itzcrazykns1337/vane:slim-latest
|
||||
docker stop vane
|
||||
docker rm vane
|
||||
docker run -d -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 -v vane-data:/home/vane/data --name vane itzcrazykns1337/vane: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:
|
||||
1. Navigate to your Vane directory and pull the latest changes:
|
||||
|
||||
```bash
|
||||
cd Perplexica
|
||||
cd Vane
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
2. Rebuild the Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t perplexica .
|
||||
docker build -t vane .
|
||||
```
|
||||
|
||||
3. Stop and remove the old container, then start the new one:
|
||||
|
||||
```bash
|
||||
docker stop perplexica
|
||||
docker rm perplexica
|
||||
docker run -p 3000:3000 -p 8080:8080 --name perplexica perplexica
|
||||
docker stop vane
|
||||
docker rm vane
|
||||
docker run -p 3000:3000 -p 8080:8080 --name vane vane
|
||||
```
|
||||
|
||||
4. Once the command completes, go to http://localhost:3000 and verify the latest changes.
|
||||
|
||||
## For non-Docker users
|
||||
|
||||
1. Navigate to your Perplexica directory and pull the latest changes:
|
||||
1. Navigate to your Vane directory and pull the latest changes:
|
||||
|
||||
```bash
|
||||
cd Perplexica
|
||||
cd Vane
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
export default {
|
||||
dialect: 'sqlite',
|
||||
schema: './src/lib/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dbCredentials: {
|
||||
url: path.join(process.cwd(), 'data', 'db.sqlite'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,15 +1 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_messages` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`messageId` text NOT NULL,
|
||||
`chatId` text NOT NULL,
|
||||
`backendId` text NOT NULL,
|
||||
`query` text NOT NULL,
|
||||
`createdAt` text NOT NULL,
|
||||
`responseBlocks` text DEFAULT '[]',
|
||||
`status` text DEFAULT 'answering'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `messages`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_messages` RENAME TO `messages`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
/* do nothing */
|
||||
@@ -28,8 +28,8 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"focusMode": {
|
||||
"name": "focusMode",
|
||||
"sources": {
|
||||
"name": "sources",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
|
||||
@@ -26,7 +26,7 @@ else
|
||||
echo "SearXNG may not be fully ready, but continuing (PID: $SEARXNG_PID)"
|
||||
fi
|
||||
|
||||
cd /home/perplexica
|
||||
echo "Starting Perplexica..."
|
||||
cd /home/vane
|
||||
echo "Starting Vane..."
|
||||
|
||||
exec node server.js
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import './.next/dev/types/routes.d.ts';
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path';
|
||||
import pkg from './package.json' with { type: 'json' };
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
@@ -10,10 +11,25 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
serverExternalPackages: ['pdf-parse'],
|
||||
serverExternalPackages: [
|
||||
'pdf-parse',
|
||||
'playwright',
|
||||
'officeparser',
|
||||
'file-type',
|
||||
],
|
||||
outputFileTracingIncludes: {
|
||||
'/api/**': [
|
||||
'./node_modules/@napi-rs/canvas/**',
|
||||
'./node_modules/@napi-rs/canvas-linux-x64-gnu/**',
|
||||
'./node_modules/@napi-rs/canvas-linux-x64-musl/**',
|
||||
],
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_VERSION: pkg.version,
|
||||
},
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
37
package.json
37
package.json
@@ -1,67 +1,80 @@
|
||||
{
|
||||
"name": "perplexica-frontend",
|
||||
"version": "1.11.2",
|
||||
"name": "vane",
|
||||
"version": "1.12.2",
|
||||
"license": "MIT",
|
||||
"author": "ItzCrazyKns",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format:write": "prettier . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.34.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@huggingface/transformers": "^3.7.5",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@icons-pack/react-simple-icons": "^12.3.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"@types/jspdf": "^2.0.0",
|
||||
"@toolsycc/json-repair": "^0.1.22",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.8.3",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"clsx": "^2.1.0",
|
||||
"drizzle-orm": "^0.40.1",
|
||||
"framer-motion": "^12.23.25",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"jspdf": "^3.0.4",
|
||||
"jsdom": "^29.0.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"lightweight-charts": "^5.0.9",
|
||||
"lucide-react": "^0.556.0",
|
||||
"mammoth": "^1.9.1",
|
||||
"markdown-to-jsx": "^7.7.2",
|
||||
"mathjs": "^15.1.0",
|
||||
"motion": "^12.23.26",
|
||||
"next": "^16.0.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"officeparser": "^5.2.2",
|
||||
"officeparser": "^6.0.7",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.9.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"playwright": "^1.59.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-text-to-speech": "^0.14.5",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"rfc6902": "^5.1.2",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"turndown": "^7.2.2",
|
||||
"yahoo-finance2": "^3.10.2",
|
||||
"yet-another-react-lightbox": "^3.17.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/jspdf": "^2.0.0",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-kit": "^0.18.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.4",
|
||||
"eslint-config-next": "^16.2.2",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.87"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { ModelWithProvider } from '@/lib/models/types';
|
||||
import SearchAgent from '@/lib/agents/search';
|
||||
import SessionManager from '@/lib/session';
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
import { SearchSources } from '@/lib/agents/search/types';
|
||||
import db from '@/lib/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { chats } from '@/lib/db/schema';
|
||||
import UploadManager from '@/lib/uploads/manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -31,7 +36,7 @@ const bodySchema = z.object({
|
||||
optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
|
||||
message: 'Optimization mode must be one of: speed, balanced, quality',
|
||||
}),
|
||||
focusMode: z.string().min(1, 'Focus mode is required'),
|
||||
sources: z.array(z.string()).optional().default([]),
|
||||
history: z
|
||||
.array(z.tuple([z.string(), z.string()]))
|
||||
.optional()
|
||||
@@ -42,7 +47,6 @@ const bodySchema = z.object({
|
||||
systemInstructions: z.string().nullable().optional().default(''),
|
||||
});
|
||||
|
||||
type Message = z.infer<typeof messageSchema>;
|
||||
type Body = z.infer<typeof bodySchema>;
|
||||
|
||||
const safeValidateBody = (data: unknown) => {
|
||||
@@ -64,6 +68,38 @@ const safeValidateBody = (data: unknown) => {
|
||||
};
|
||||
};
|
||||
|
||||
const ensureChatExists = async (input: {
|
||||
id: string;
|
||||
sources: SearchSources[];
|
||||
query: string;
|
||||
fileIds: string[];
|
||||
}) => {
|
||||
try {
|
||||
const exists = await db.query.chats
|
||||
.findFirst({
|
||||
where: eq(chats.id, input.id),
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!exists) {
|
||||
await db.insert(chats).values({
|
||||
id: input.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
sources: input.sources,
|
||||
title: input.query,
|
||||
files: input.fileIds.map((id) => {
|
||||
return {
|
||||
fileId: id,
|
||||
name: UploadManager.getFile(id)?.name || 'Uploaded File',
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check/save chat:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
try {
|
||||
const reqBody = (await req.json()) as Body;
|
||||
@@ -120,96 +156,86 @@ export const POST = async (req: Request) => {
|
||||
const writer = responseStream.writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let receivedMessage = '';
|
||||
|
||||
session.addListener('data', (data: any) => {
|
||||
if (data.type === 'response') {
|
||||
const disconnect = session.subscribe((event: string, data: any) => {
|
||||
if (event === 'data') {
|
||||
if (data.type === 'block') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'block',
|
||||
block: data.block,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'updateBlock') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'updateBlock',
|
||||
blockId: data.blockId,
|
||||
patch: data.patch,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'researchComplete') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'researchComplete',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (event === 'end') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
type: 'messageEnd',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
session.removeAllListeners();
|
||||
} else if (event === 'error') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
data: data.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
receivedMessage += data.data;
|
||||
} else if (data.type === 'sources') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'sources',
|
||||
data: data.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'block') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'block',
|
||||
block: data.block,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'updateBlock') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'updateBlock',
|
||||
blockId: data.blockId,
|
||||
patch: data.patch,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'researchComplete') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'researchComplete',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
session.removeAllListeners();
|
||||
}
|
||||
});
|
||||
|
||||
session.addListener('end', () => {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'messageEnd',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
session.removeAllListeners();
|
||||
});
|
||||
|
||||
session.addListener('error', (data: any) => {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
data: data.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
session.removeAllListeners();
|
||||
});
|
||||
|
||||
agent.searchAsync(session, {
|
||||
chatHistory: history,
|
||||
followUp: message.content,
|
||||
chatId: body.message.chatId,
|
||||
messageId: body.message.messageId,
|
||||
config: {
|
||||
llm,
|
||||
embedding: embedding,
|
||||
sources: ['web'],
|
||||
sources: body.sources as SearchSources[],
|
||||
mode: body.optimizationMode,
|
||||
fileIds: body.files,
|
||||
systemInstructions: body.systemInstructions || 'None',
|
||||
},
|
||||
});
|
||||
|
||||
/* handleHistorySave(message, humanMessageId, body.focusMode, body.files); */
|
||||
ensureChatExists({
|
||||
id: body.message.chatId,
|
||||
sources: body.sources as SearchSources[],
|
||||
fileIds: body.files,
|
||||
query: body.message.content,
|
||||
});
|
||||
|
||||
req.signal.addEventListener('abort', () => {
|
||||
disconnect();
|
||||
writer.close();
|
||||
});
|
||||
|
||||
return new Response(responseStream.readable, {
|
||||
headers: {
|
||||
|
||||
@@ -21,7 +21,10 @@ export const POST = async (req: Request) => {
|
||||
|
||||
const images = await searchImages(
|
||||
{
|
||||
chatHistory: body.chatHistory,
|
||||
chatHistory: body.chatHistory.map(([role, content]) => ({
|
||||
role: role === 'human' ? 'user' : 'assistant',
|
||||
content,
|
||||
})),
|
||||
query: body.query,
|
||||
},
|
||||
llm,
|
||||
|
||||
93
src/app/api/reconnect/[id]/route.ts
Normal file
93
src/app/api/reconnect/[id]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import SessionManager from '@/lib/session';
|
||||
|
||||
export const POST = async (
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const session = SessionManager.getSession(id);
|
||||
|
||||
if (!session) {
|
||||
return Response.json({ message: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const responseStream = new TransformStream();
|
||||
const writer = responseStream.writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const disconnect = session.subscribe((event, data) => {
|
||||
if (event === 'data') {
|
||||
if (data.type === 'block') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'block',
|
||||
block: data.block,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'updateBlock') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'updateBlock',
|
||||
blockId: data.blockId,
|
||||
patch: data.patch,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'researchComplete') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'researchComplete',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (event === 'end') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'messageEnd',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
disconnect();
|
||||
} else if (event === 'error') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
data: data.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
writer.close();
|
||||
disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
req.signal.addEventListener('abort', () => {
|
||||
disconnect();
|
||||
writer.close();
|
||||
});
|
||||
|
||||
return new Response(responseStream.readable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
Connection: 'keep-alive',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in reconnecting to session stream: ', err);
|
||||
return Response.json(
|
||||
{ message: 'An error has occurred.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
import SessionManager from '@/lib/session';
|
||||
import SearchAgent from '@/lib/agents/search';
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
import { SearchSources } from '@/lib/agents/search/types';
|
||||
import APISearchAgent from '@/lib/agents/search/api';
|
||||
|
||||
interface ChatRequestBody {
|
||||
optimizationMode: 'speed' | 'balanced';
|
||||
focusMode: string;
|
||||
optimizationMode: 'speed' | 'balanced' | 'quality';
|
||||
sources: SearchSources[];
|
||||
chatModel: ModelWithProvider;
|
||||
embeddingModel: ModelWithProvider;
|
||||
query: string;
|
||||
@@ -19,15 +20,15 @@ export const POST = async (req: Request) => {
|
||||
try {
|
||||
const body: ChatRequestBody = await req.json();
|
||||
|
||||
if (!body.focusMode || !body.query) {
|
||||
if (!body.sources || !body.query) {
|
||||
return Response.json(
|
||||
{ message: 'Missing focus mode or query' },
|
||||
{ message: 'Missing sources or query' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
body.history = body.history || [];
|
||||
body.optimizationMode = body.optimizationMode || 'balanced';
|
||||
body.optimizationMode = body.optimizationMode || 'speed';
|
||||
body.stream = body.stream || false;
|
||||
|
||||
const registry = new ModelRegistry();
|
||||
@@ -48,18 +49,21 @@ export const POST = async (req: Request) => {
|
||||
|
||||
const session = SessionManager.createSession();
|
||||
|
||||
const agent = new SearchAgent();
|
||||
const agent = new APISearchAgent();
|
||||
|
||||
agent.searchAsync(session, {
|
||||
chatHistory: history,
|
||||
config: {
|
||||
embedding: embeddings,
|
||||
llm: llm,
|
||||
sources: ['web', 'discussions', 'academic'],
|
||||
mode: 'balanced',
|
||||
fileIds: []
|
||||
sources: body.sources,
|
||||
mode: body.optimizationMode,
|
||||
fileIds: [],
|
||||
systemInstructions: body.systemInstructions || '',
|
||||
},
|
||||
followUp: body.query,
|
||||
chatId: crypto.randomUUID(),
|
||||
messageId: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
if (!body.stream) {
|
||||
@@ -71,36 +75,37 @@ export const POST = async (req: Request) => {
|
||||
let message = '';
|
||||
let sources: any[] = [];
|
||||
|
||||
session.addListener('data', (data: string) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData.type === 'response') {
|
||||
message += parsedData.data;
|
||||
} else if (parsedData.type === 'sources') {
|
||||
sources = parsedData.data;
|
||||
session.subscribe((event: string, data: Record<string, any>) => {
|
||||
if (event === 'data') {
|
||||
try {
|
||||
if (data.type === 'response') {
|
||||
message += data.data;
|
||||
} else if (data.type === 'searchResults') {
|
||||
sources = data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
reject(
|
||||
Response.json(
|
||||
{ message: 'Error parsing data' },
|
||||
{ status: 500 },
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
if (event === 'end') {
|
||||
resolve(Response.json({ message, sources }, { status: 200 }));
|
||||
}
|
||||
|
||||
if (event === 'error') {
|
||||
reject(
|
||||
Response.json(
|
||||
{ message: 'Error parsing data' },
|
||||
{ message: 'Search error', error: data },
|
||||
{ status: 500 },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
session.addListener('end', () => {
|
||||
resolve(Response.json({ message, sources }, { status: 200 }));
|
||||
});
|
||||
|
||||
session.addListener('error', (error: any) => {
|
||||
reject(
|
||||
Response.json(
|
||||
{ message: 'Search error', error },
|
||||
{ status: 500 },
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -131,54 +136,54 @@ export const POST = async (req: Request) => {
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
session.addListener('data', (data: string) => {
|
||||
if (signal.aborted) return;
|
||||
session.subscribe((event: string, data: Record<string, any>) => {
|
||||
if (event === 'data') {
|
||||
if (signal.aborted) return;
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
|
||||
if (parsedData.type === 'response') {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'response',
|
||||
data: parsedData.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (parsedData.type === 'sources') {
|
||||
sources = parsedData.data;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'sources',
|
||||
data: sources,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (data.type === 'response') {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'response',
|
||||
data: data.data,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
} else if (data.type === 'searchResults') {
|
||||
sources = data.data;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'sources',
|
||||
data: sources,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
session.addListener('end', () => {
|
||||
if (signal.aborted) return;
|
||||
if (event === 'end') {
|
||||
if (signal.aborted) return;
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'done',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
});
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'done',
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
session.addListener('error', (error: any) => {
|
||||
if (signal.aborted) return;
|
||||
if (event === 'error') {
|
||||
if (signal.aborted) return;
|
||||
|
||||
controller.error(error);
|
||||
controller.error(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
|
||||
@@ -20,7 +20,10 @@ export const POST = async (req: Request) => {
|
||||
|
||||
const suggestions = await generateSuggestions(
|
||||
{
|
||||
chatHistory: body.chatHistory,
|
||||
chatHistory: body.chatHistory.map(([role, content]) => ({
|
||||
role: role === 'human' ? 'user' : 'assistant',
|
||||
content,
|
||||
})),
|
||||
},
|
||||
llm,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,10 @@ export const POST = async (req: Request) => {
|
||||
|
||||
const videos = await handleVideoSearch(
|
||||
{
|
||||
chatHistory: body.chatHistory,
|
||||
chatHistory: body.chatHistory.map(([role, content]) => ({
|
||||
role: role === 'human' ? 'user' : 'assistant',
|
||||
content,
|
||||
})),
|
||||
query: body.query,
|
||||
},
|
||||
llm,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
import React from 'react';
|
||||
|
||||
const Page = () => {
|
||||
return <ChatWindow />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default ChatWindow;
|
||||
|
||||
@@ -19,9 +19,8 @@ const montserrat = Montserrat({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Perplexica - Chat with the internet',
|
||||
description:
|
||||
'Perplexica is an AI powered chatbot that is connected to the internet.',
|
||||
title: 'Vane - Direct your curiosity',
|
||||
description: 'Vane is an AI powered answering engine.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -34,7 +33,7 @@ export default function RootLayout({
|
||||
|
||||
return (
|
||||
<html className="h-full" lang="en" suppressHydrationWarning>
|
||||
<body className={cn('h-full', montserrat.className)}>
|
||||
<body className={cn('h-full antialiased', montserrat.className)}>
|
||||
<ThemeProvider>
|
||||
{setupComplete ? (
|
||||
<ChatProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Metadata } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Library - Perplexica',
|
||||
title: 'Library - Vane',
|
||||
};
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import DeleteChat from '@/components/DeleteChat';
|
||||
import { cn, formatTimeDifference } from '@/lib/utils';
|
||||
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
|
||||
import { formatTimeDifference } from '@/lib/utils';
|
||||
import { BookOpenText, ClockIcon, FileText, Globe2Icon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
focusMode: string;
|
||||
sources: string[];
|
||||
files: { fileId: string; name: string }[];
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
@@ -37,74 +38,137 @@ const Page = () => {
|
||||
fetchChats();
|
||||
}, []);
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="flex items-center">
|
||||
<BookOpenText />
|
||||
<h1 className="text-3xl font-medium p-2">Library</h1>
|
||||
</div>
|
||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
||||
</div>
|
||||
{chats.length === 0 && (
|
||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
No chats found.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chats.length > 0 && (
|
||||
<div className="flex flex-col pb-20 lg:pb-2">
|
||||
{chats.map((chat, i) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-4 py-6',
|
||||
i !== chats.length - 1
|
||||
? 'border-b border-white-200 dark:border-dark-200'
|
||||
: '',
|
||||
)}
|
||||
key={i}
|
||||
>
|
||||
<Link
|
||||
href={`/c/${chat.id}`}
|
||||
className="text-black dark:text-white lg:text-xl font-medium truncate transition duration-200 hover:text-[#24A0ED] dark:hover:text-[#24A0ED] cursor-pointer"
|
||||
<div className="flex flex-col pt-10 border-b border-light-200/20 dark:border-dark-200/20 pb-6 px-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<BookOpenText size={45} className="mb-2.5" />
|
||||
<div className="flex flex-col">
|
||||
<h1
|
||||
className="text-5xl font-normal p-2 pb-0"
|
||||
style={{ fontFamily: 'PP Editorial, serif' }}
|
||||
>
|
||||
{chat.title}
|
||||
</Link>
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
|
||||
<ClockIcon size={15} />
|
||||
<p className="text-xs">
|
||||
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
||||
</p>
|
||||
</div>
|
||||
<DeleteChat
|
||||
chatId={chat.id}
|
||||
chats={chats}
|
||||
setChats={setChats}
|
||||
/>
|
||||
Library
|
||||
</h1>
|
||||
<div className="px-2 text-sm text-black/60 dark:text-white/60 text-center lg:text-left">
|
||||
Past chats, sources, and uploads.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center lg:justify-end gap-2 text-xs text-black/60 dark:text-white/60">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-black/20 dark:border-white/20 px-2 py-0.5">
|
||||
<BookOpenText size={14} />
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: `${chats.length} ${chats.length === 1 ? 'chat' : 'chats'}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-[60vh]">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-2 text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-2xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary">
|
||||
<BookOpenText className="text-black/70 dark:text-white/70" />
|
||||
</div>
|
||||
<p className="mt-2 text-black/70 dark:text-white/70 text-sm">
|
||||
No chats found.
|
||||
</p>
|
||||
<p className="mt-1 text-black/70 dark:text-white/70 text-sm">
|
||||
<Link href="/" className="text-sky-400">
|
||||
Start a new chat
|
||||
</Link>{' '}
|
||||
to see it listed here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-6 pb-28 px-2">
|
||||
<div className="rounded-2xl border border-light-200 dark:border-dark-200 overflow-hidden bg-light-primary dark:bg-dark-primary">
|
||||
{chats.map((chat, index) => {
|
||||
const sourcesLabel =
|
||||
chat.sources.length === 0
|
||||
? null
|
||||
: chat.sources.length <= 2
|
||||
? chat.sources
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(', ')
|
||||
: `${chat.sources
|
||||
.slice(0, 2)
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(', ')} + ${chat.sources.length - 2}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={
|
||||
'group flex flex-col gap-2 p-4 hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200 ' +
|
||||
(index !== chats.length - 1
|
||||
? 'border-b border-light-200 dark:border-dark-200'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
href={`/c/${chat.id}`}
|
||||
className="flex-1 text-black dark:text-white text-base lg:text-lg font-medium leading-snug line-clamp-2 group-hover:text-[#24A0ED] transition duration-200"
|
||||
title={chat.title}
|
||||
>
|
||||
{chat.title}
|
||||
</Link>
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<DeleteChat
|
||||
chatId={chat.id}
|
||||
chats={chats}
|
||||
setChats={setChats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-black/70 dark:text-white/70">
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<ClockIcon size={14} />
|
||||
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
||||
</span>
|
||||
|
||||
{sourcesLabel && (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-black/20 dark:border-white/20 rounded-full px-2 py-0.5">
|
||||
<Globe2Icon size={14} />
|
||||
{sourcesLabel}
|
||||
</span>
|
||||
)}
|
||||
{chat.files.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-black/20 dark:border-white/20 rounded-full px-2 py-0.5">
|
||||
<FileText size={14} />
|
||||
{chat.files.length}{' '}
|
||||
{chat.files.length === 1 ? 'file' : 'files'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Perplexica - Chat with the internet',
|
||||
short_name: 'Perplexica',
|
||||
description:
|
||||
'Perplexica is an AI powered chatbot that is connected to the internet.',
|
||||
name: 'Vane - Direct Your Curiosity',
|
||||
short_name: 'Vane',
|
||||
description: 'Vane is an AI powered answering engine.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0a0a0a',
|
||||
|
||||
@@ -2,8 +2,8 @@ import ChatWindow from '@/components/ChatWindow';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat - Perplexica',
|
||||
description: 'Chat with the internet, chat with Perplexica.',
|
||||
title: 'Chat - Vane',
|
||||
description: 'Chat with the internet, chat with Vane.',
|
||||
};
|
||||
|
||||
const Home = () => {
|
||||
|
||||
@@ -37,7 +37,8 @@ const getStepTitle = (
|
||||
if (step.type === 'reasoning') {
|
||||
return isStreaming && !step.reasoning ? 'Thinking...' : 'Thinking';
|
||||
} else if (step.type === 'searching') {
|
||||
return `Searching ${step.searching.length} ${step.searching.length === 1 ? 'query' : 'queries'}`;
|
||||
const queries = Array.isArray(step.searching) ? step.searching : [];
|
||||
return `Searching ${queries.length} ${queries.length === 1 ? 'query' : 'queries'}`;
|
||||
} else if (step.type === 'search_results') {
|
||||
return `Found ${step.reading.length} ${step.reading.length === 1 ? 'result' : 'results'}`;
|
||||
} else if (step.type === 'reading') {
|
||||
@@ -54,17 +55,21 @@ const getStepTitle = (
|
||||
const AssistantSteps = ({
|
||||
block,
|
||||
status,
|
||||
isLast,
|
||||
}: {
|
||||
block: ResearchBlock;
|
||||
status: 'answering' | 'completed' | 'error';
|
||||
isLast: boolean;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
isLast && status === 'answering' ? true : false,
|
||||
);
|
||||
const { researchEnded, loading } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (researchEnded) {
|
||||
if (researchEnded && isLast) {
|
||||
setIsExpanded(false);
|
||||
} else if (status === 'answering') {
|
||||
} else if (status === 'answering' && isLast) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [researchEnded, status]);
|
||||
@@ -156,6 +161,7 @@ const AssistantSteps = ({
|
||||
)}
|
||||
|
||||
{step.type === 'searching' &&
|
||||
Array.isArray(step.searching) &&
|
||||
step.searching.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{step.searching.map((query, idx) => (
|
||||
@@ -182,8 +188,10 @@ const AssistantSteps = ({
|
||||
: '';
|
||||
|
||||
return (
|
||||
<span
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
|
||||
>
|
||||
{faviconUrl && (
|
||||
@@ -197,7 +205,7 @@ const AssistantSteps = ({
|
||||
/>
|
||||
)}
|
||||
<span className="line-clamp-1">{title}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -219,33 +227,26 @@ const AssistantSteps = ({
|
||||
|
||||
{step.type === 'upload_search_results' &&
|
||||
step.results.length > 0 && (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div className="mt-1.5 grid gap-3 lg:grid-cols-3">
|
||||
{step.results.slice(0, 4).map((result, idx) => {
|
||||
const title =
|
||||
(result.metadata &&
|
||||
(result.metadata.title ||
|
||||
result.metadata.fileName)) ||
|
||||
'Untitled document';
|
||||
const snippet = (result.content || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 220);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex gap-3 items-start rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2"
|
||||
className="flex flex-row space-x-3 rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2 cursor-pointer"
|
||||
>
|
||||
<div className="mt-0.5 h-10 w-10 rounded-md bg-cyan-100 text-cyan-800 dark:bg-sky-500 dark:text-cyan-50 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-black dark:text-white line-clamp-1">
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className="text-[13px] text-black dark:text-white line-clamp-1">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-xs text-black/70 dark:text-white/70 mt-0.5 leading-relaxed line-clamp-3">
|
||||
{snippet || 'No preview available.'}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ const Chat = () => {
|
||||
};
|
||||
|
||||
if (messages.length === 1) {
|
||||
document.title = `${messages[0].query.substring(0, 30)} - Perplexica`;
|
||||
document.title = `${messages[0].query.substring(0, 30)} - Vane`;
|
||||
}
|
||||
|
||||
if (sections.length > lastScrolledRef.current) {
|
||||
@@ -59,7 +59,7 @@ const Chat = () => {
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6 pt-8 pb-28 sm:mx-4 md:mx-8">
|
||||
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-28 sm:mx-4 md:mx-8">
|
||||
{sections.map((section, i) => {
|
||||
const isLast = i === sections.length - 1;
|
||||
|
||||
@@ -80,7 +80,10 @@ const Chat = () => {
|
||||
{loading && !messageAppeared && <MessageBoxLoading />}
|
||||
<div ref={messageEnd} className="h-0" />
|
||||
{dividerWidth > 0 && (
|
||||
<div className="bottom-6 fixed z-40" style={{ width: dividerWidth }}>
|
||||
<div
|
||||
className="fixed z-40 bottom-24 lg:bottom-6"
|
||||
style={{ width: dividerWidth }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] dark:hidden"
|
||||
style={{
|
||||
|
||||
@@ -6,7 +6,8 @@ import EmptyChat from './EmptyChat';
|
||||
import NextError from 'next/error';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
|
||||
import { Block, Chunk } from '@/lib/types';
|
||||
import { Block } from '@/lib/types';
|
||||
import Loader from './ui/Loader';
|
||||
|
||||
export interface BaseMessage {
|
||||
chatId: string;
|
||||
@@ -21,35 +22,6 @@ export interface Message extends BaseMessage {
|
||||
status: 'answering' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
export interface UserMessage extends BaseMessage {
|
||||
role: 'user';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AssistantMessage extends BaseMessage {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export interface SourceMessage extends BaseMessage {
|
||||
role: 'source';
|
||||
sources: Chunk[];
|
||||
}
|
||||
|
||||
export interface SuggestionMessage extends BaseMessage {
|
||||
role: 'suggestion';
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export type LegacyMessage =
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| SourceMessage
|
||||
| SuggestionMessage;
|
||||
|
||||
export type ChatTurn = UserMessage | AssistantMessage;
|
||||
|
||||
export interface File {
|
||||
fileName: string;
|
||||
fileExtension: string;
|
||||
@@ -62,7 +34,8 @@ export interface Widget {
|
||||
}
|
||||
|
||||
const ChatWindow = () => {
|
||||
const { hasError, notFound, messages } = useChat();
|
||||
const { hasError, notFound, messages, isReady } = useChat();
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -78,18 +51,24 @@ const ChatWindow = () => {
|
||||
);
|
||||
}
|
||||
|
||||
return notFound ? (
|
||||
<NextError statusCode={404} />
|
||||
return isReady ? (
|
||||
notFound ? (
|
||||
<NextError statusCode={404} />
|
||||
) : (
|
||||
<div>
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<Navbar />
|
||||
<Chat />
|
||||
</>
|
||||
) : (
|
||||
<EmptyChat />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<Navbar />
|
||||
<Chat />
|
||||
</>
|
||||
) : (
|
||||
<EmptyChat />
|
||||
)}
|
||||
<div className="flex items-center justify-center min-h-screen w-full">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import Focus from './MessageInputActions/Focus';
|
||||
import Sources from './MessageInputActions/Sources';
|
||||
import Optimization from './MessageInputActions/Optimization';
|
||||
import Attach from './MessageInputActions/Attach';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
@@ -68,8 +68,8 @@ const EmptyChatMessageInput = () => {
|
||||
<Optimization />
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Sources />
|
||||
<ModelSelector />
|
||||
<Focus />
|
||||
<Attach />
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Check, ClipboardList } from 'lucide-react';
|
||||
import { Message } from '../ChatWindow';
|
||||
import { useState } from 'react';
|
||||
import { Section } from '@/lib/hooks/useChat';
|
||||
import { SourceBlock } from '@/lib/types';
|
||||
|
||||
const Copy = ({
|
||||
section,
|
||||
@@ -15,15 +16,25 @@ const Copy = ({
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const sources = section.message.responseBlocks.filter(
|
||||
(b) => b.type === 'source' && b.data.length > 0,
|
||||
) as SourceBlock[];
|
||||
|
||||
const contentToCopy = `${initialMessage}${
|
||||
section?.message.responseBlocks.filter((b) => b.type === 'source')
|
||||
?.length > 0 &&
|
||||
`\n\nCitations:\n${section.message.responseBlocks
|
||||
.filter((b) => b.type === 'source')
|
||||
?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`)
|
||||
.join(`\n`)}`
|
||||
sources.length > 0
|
||||
? `\n\nCitations:\n${sources
|
||||
.map((source) => source.data)
|
||||
.flat()
|
||||
.map(
|
||||
(s, i) =>
|
||||
`[${i + 1}] ${s.metadata.url.startsWith('file_id://') ? s.metadata.fileName || 'Uploaded File' : s.metadata.url}`,
|
||||
)
|
||||
.join(`\n`)}`
|
||||
: ''
|
||||
}`;
|
||||
|
||||
navigator.clipboard.writeText(contentToCopy);
|
||||
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
}}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Plus,
|
||||
CornerDownRight,
|
||||
} from 'lucide-react';
|
||||
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
||||
import Markdown, { MarkdownToJSX, RuleType } from 'markdown-to-jsx';
|
||||
import Copy from './MessageActions/Copy';
|
||||
import Rewrite from './MessageActions/Rewrite';
|
||||
import MessageSources from './MessageSources';
|
||||
@@ -21,10 +21,11 @@ import SearchVideos from './SearchVideos';
|
||||
import { useSpeech } from 'react-text-to-speech';
|
||||
import ThinkBox from './ThinkBox';
|
||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||
import Citation from './Citation';
|
||||
import Citation from './MessageRenderer/Citation';
|
||||
import AssistantSteps from './AssistantSteps';
|
||||
import { ResearchBlock } from '@/lib/types';
|
||||
import Renderer from './Widgets/Renderer';
|
||||
import CodeBlock from './MessageRenderer/CodeBlock';
|
||||
|
||||
const ThinkTagProcessor = ({
|
||||
children,
|
||||
@@ -49,7 +50,14 @@ const MessageBox = ({
|
||||
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isLast: boolean;
|
||||
}) => {
|
||||
const { loading, sendMessage, rewrite, messages, researchEnded } = useChat();
|
||||
const {
|
||||
loading,
|
||||
sendMessage,
|
||||
rewrite,
|
||||
messages,
|
||||
researchEnded,
|
||||
chatHistory,
|
||||
} = useChat();
|
||||
|
||||
const parsedMessage = section.parsedTextBlocks.join('\n\n');
|
||||
const speechMessage = section.speechMessage || '';
|
||||
@@ -67,6 +75,21 @@ const MessageBox = ({
|
||||
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
||||
|
||||
const markdownOverrides: MarkdownToJSX.Options = {
|
||||
renderRule(next, node, renderChildren, state) {
|
||||
if (node.type === RuleType.codeInline) {
|
||||
return `\`${node.text}\``;
|
||||
}
|
||||
|
||||
if (node.type === RuleType.codeBlock) {
|
||||
return (
|
||||
<CodeBlock key={state.key} language={node.lang || ''}>
|
||||
{node.text}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
overrides: {
|
||||
think: {
|
||||
component: ThinkTagProcessor,
|
||||
@@ -115,12 +138,11 @@ const MessageBox = ({
|
||||
<AssistantSteps
|
||||
block={researchBlock}
|
||||
status={section.message.status}
|
||||
isLast={isLast}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
|
||||
|
||||
{isLast &&
|
||||
loading &&
|
||||
!researchEnded &&
|
||||
@@ -135,6 +157,8 @@ const MessageBox = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{sources.length > 0 && (
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
@@ -218,10 +242,10 @@ const MessageBox = ({
|
||||
className="group w-full py-4 text-left transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-row space-x-3 items-center ">
|
||||
<div className="flex flex-row space-x-3 items-center">
|
||||
<CornerDownRight
|
||||
size={17}
|
||||
className="group-hover:text-sky-400 transition-colors duration-200"
|
||||
size={15}
|
||||
className="group-hover:text-sky-400 transition-colors duration-200 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-sm text-black/70 dark:text-white/70 group-hover:text-sky-400 transition-colors duration-200 leading-relaxed">
|
||||
{suggestion}
|
||||
@@ -248,11 +272,11 @@ const MessageBox = ({
|
||||
<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">
|
||||
<SearchImages
|
||||
query={section.message.query}
|
||||
chatHistory={messages}
|
||||
chatHistory={chatHistory}
|
||||
messageId={section.message.messageId}
|
||||
/>
|
||||
<SearchVideos
|
||||
chatHistory={messages}
|
||||
chatHistory={chatHistory}
|
||||
query={section.message.query}
|
||||
messageId={section.message.messageId}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cn } from '@/lib/utils';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import CopilotToggle from './MessageInputActions/Copilot';
|
||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
@@ -62,7 +61,7 @@ const MessageInput = () => {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative bg-light-secondary dark:bg-dark-secondary p-4 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300',
|
||||
'relative bg-light-secondary dark:bg-dark-secondary p-4 flex items-center overflow-visible border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300',
|
||||
mode === 'multi' ? 'flex-col rounded-2xl' : 'flex-row rounded-full',
|
||||
)}
|
||||
>
|
||||
@@ -78,11 +77,16 @@ const MessageInput = () => {
|
||||
placeholder="Ask a follow-up"
|
||||
/>
|
||||
{mode === 'single' && (
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<CopilotToggle
|
||||
copilotEnabled={copilotEnabled}
|
||||
setCopilotEnabled={setCopilotEnabled}
|
||||
/>
|
||||
<button
|
||||
disabled={message.trim().length === 0 || loading}
|
||||
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
|
||||
>
|
||||
<ArrowUp className="bg-background" size={17} />
|
||||
</button>
|
||||
)}
|
||||
{mode === 'multi' && (
|
||||
<div className="flex flex-row items-center justify-between w-full pt-2">
|
||||
<AttachSmall />
|
||||
<button
|
||||
disabled={message.trim().length === 0 || loading}
|
||||
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
|
||||
@@ -91,23 +95,6 @@ const MessageInput = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'multi' && (
|
||||
<div className="flex flex-row items-center justify-between w-full pt-2">
|
||||
<AttachSmall />
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<CopilotToggle
|
||||
copilotEnabled={copilotEnabled}
|
||||
setCopilotEnabled={setCopilotEnabled}
|
||||
/>
|
||||
<button
|
||||
disabled={message.trim().length === 0 || loading}
|
||||
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
|
||||
>
|
||||
<ArrowUp className="bg-background" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const Attach = () => {
|
||||
const { files, setFiles, setFileIds, fileIds } = useChat();
|
||||
@@ -24,115 +27,152 @@ const Attach = () => {
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true);
|
||||
const data = new FormData();
|
||||
const selectedFiles = e.target.files;
|
||||
|
||||
for (let i = 0; i < e.target.files!.length; i++) {
|
||||
data.append('files', e.target.files![i]);
|
||||
if (!selectedFiles?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||
setLoading(true);
|
||||
|
||||
data.append('embedding_model_provider_id', embeddingModelProvider!);
|
||||
data.append('embedding_model_key', embeddingModel!);
|
||||
try {
|
||||
const data = new FormData();
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
data.append('files', selectedFiles[i]);
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
||||
setLoading(false);
|
||||
if (!embeddingModelProvider || !embeddingModel) {
|
||||
throw new Error('Please select an embedding model before uploading.');
|
||||
}
|
||||
|
||||
data.append('embedding_model_provider_id', embeddingModelProvider);
|
||||
data.append('embedding_model_key', embeddingModel);
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
const resData = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(resData.message || 'Failed to upload file(s).');
|
||||
}
|
||||
|
||||
if (!Array.isArray(resData.files)) {
|
||||
throw new Error('Invalid upload response from server.');
|
||||
}
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([
|
||||
...fileIds,
|
||||
...resData.files.map((file: any) => file.fileId),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
toast(err?.message || 'Failed to upload file(s).');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 transition duration-200">
|
||||
<LoaderCircle size={16} className="text-sky-400 animate-spin" />
|
||||
<LoaderCircle size={16} className="text-sky-500 animate-spin" />
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={16} className="text-sky-400" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={16} className="text-sky-500" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-64 md:w-[350px] right-0"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-top-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={16} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black/70 dark:text-white/70 text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={16} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<Trash size={13} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName
|
||||
.replace(/\.\w+$/, '')
|
||||
.substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
CopyPlus,
|
||||
File,
|
||||
LoaderCircle,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
import { File, LoaderCircle, Paperclip, Plus, Trash } from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { File as FileType } from '../ChatWindow';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const AttachSmall = () => {
|
||||
const { files, setFiles, setFileIds, fileIds } = useChat();
|
||||
@@ -24,115 +18,152 @@ const AttachSmall = () => {
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true);
|
||||
const data = new FormData();
|
||||
const selectedFiles = e.target.files;
|
||||
|
||||
for (let i = 0; i < e.target.files!.length; i++) {
|
||||
data.append('files', e.target.files![i]);
|
||||
if (!selectedFiles?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||
setLoading(true);
|
||||
|
||||
data.append('embedding_model_provider_id', embeddingModelProvider!);
|
||||
data.append('embedding_model_key', embeddingModel!);
|
||||
try {
|
||||
const data = new FormData();
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
data.append('files', selectedFiles[i]);
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
||||
setLoading(false);
|
||||
if (!embeddingModelProvider || !embeddingModel) {
|
||||
throw new Error('Please select an embedding model before uploading.');
|
||||
}
|
||||
|
||||
data.append('embedding_model_provider_id', embeddingModelProvider);
|
||||
data.append('embedding_model_key', embeddingModel);
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
const resData = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(resData.message || 'Failed to upload file(s).');
|
||||
}
|
||||
|
||||
if (!Array.isArray(resData.files)) {
|
||||
throw new Error('Invalid upload response from server.');
|
||||
}
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([
|
||||
...fileIds,
|
||||
...resData.files.map((file: any) => file.fileId),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
toast(err?.message || 'Failed to upload file(s).');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-between space-x-1 p-1 ">
|
||||
<LoaderCircle size={20} className="text-sky-400 animate-spin" />
|
||||
<LoaderCircle size={20} className="text-sky-500 animate-spin" />
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={20} className="text-sky-400" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] bottom-14 -ml-3">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={20} className="text-sky-500" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-64 md:w-[350px] bottom-14"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-bottom-left bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black/70 dark:text-white/70 font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={16} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={13} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName
|
||||
.replace(/\.\w+$/, '')
|
||||
.substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
import { Cpu, Loader2, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MinimalProvider } from '@/lib/models/types';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
const ModelSelector = () => {
|
||||
const [providers, setProviders] = useState<MinimalProvider[]>([]);
|
||||
@@ -79,119 +75,127 @@ const ModelSelector = () => {
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<Cpu size={16} className="text-sky-500" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] -right-4">
|
||||
<div className="bg-light-primary dark:bg-dark-primary max-h-[300px] sm:max-w-none border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-light-200 dark:border-dark-200">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg placeholder:text-sm text-sm text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-sky-500/20 border border-transparent focus:border-sky-500/30 transition duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<Cpu size={16} className="text-sky-500" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] right-0"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-top-right bg-light-primary dark:bg-dark-primary max-h-[300px] sm:max-w-none border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-light-200 dark:border-dark-200">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg placeholder:text-xs placeholder:-translate-y-[1.5px] text-xs text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none border border-transparent transition duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2
|
||||
className="animate-spin text-black/40 dark:text-white/40"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
) : filteredProviders.length === 0 ? (
|
||||
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
|
||||
{searchQuery
|
||||
? 'No models found'
|
||||
: 'No chat models configured'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filteredProviders.map((provider, providerIndex) => (
|
||||
<div key={provider.id}>
|
||||
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2
|
||||
className="animate-spin text-black/40 dark:text-white/40"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-2 py-2 space-y-0.5">
|
||||
{provider.chatModels.map((model) => (
|
||||
<button
|
||||
key={model.key}
|
||||
onClick={() =>
|
||||
handleModelSelect(provider.id, model.key)
|
||||
}
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
|
||||
chatModelProvider?.providerId === provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
||||
<Cpu
|
||||
size={15}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'text-sky-500'
|
||||
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm truncate',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'text-sky-500 font-medium'
|
||||
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
) : filteredProviders.length === 0 ? (
|
||||
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
|
||||
{searchQuery
|
||||
? 'No models found'
|
||||
: 'No chat models configured'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filteredProviders.map((provider, providerIndex) => (
|
||||
<div key={provider.id}>
|
||||
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col px-2 py-2 space-y-0.5">
|
||||
{provider.chatModels.map((model) => (
|
||||
<button
|
||||
key={model.key}
|
||||
onClick={() =>
|
||||
handleModelSelect(provider.id, model.key)
|
||||
}
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
||||
<Cpu
|
||||
size={15}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'text-sky-500'
|
||||
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs truncate',
|
||||
chatModelProvider?.providerId ===
|
||||
provider.id &&
|
||||
chatModelProvider?.key === model.key
|
||||
? 'text-sky-500 font-medium'
|
||||
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{providerIndex < filteredProviders.length - 1 && (
|
||||
<div className="h-px bg-light-200 dark:bg-dark-200" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{providerIndex < filteredProviders.length - 1 && (
|
||||
<div className="h-px bg-light-200 dark:bg-dark-200" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Switch } from '@headlessui/react';
|
||||
|
||||
const CopilotToggle = ({
|
||||
copilotEnabled,
|
||||
setCopilotEnabled,
|
||||
}: {
|
||||
copilotEnabled: boolean;
|
||||
setCopilotEnabled: (enabled: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="group flex flex-row items-center space-x-1 active:scale-95 duration-200 transition cursor-pointer">
|
||||
<Switch
|
||||
checked={copilotEnabled}
|
||||
onChange={setCopilotEnabled}
|
||||
className="bg-light-secondary dark:bg-dark-secondary border border-light-200/70 dark:border-dark-200 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full"
|
||||
>
|
||||
<span className="sr-only">Copilot</span>
|
||||
<span
|
||||
className={cn(
|
||||
copilotEnabled
|
||||
? 'translate-x-6 bg-[#24A0ED]'
|
||||
: 'translate-x-1 bg-black/50 dark:bg-white/50',
|
||||
'inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<p
|
||||
onClick={() => setCopilotEnabled(!copilotEnabled)}
|
||||
className={cn(
|
||||
'text-xs font-medium transition-colors duration-150 ease-in-out',
|
||||
copilotEnabled
|
||||
? 'text-[#24A0ED]'
|
||||
: 'text-black/50 dark:text-white/50 group-hover:text-black dark:group-hover:text-white',
|
||||
)}
|
||||
>
|
||||
Copilot
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopilotToggle;
|
||||
@@ -1,123 +0,0 @@
|
||||
import {
|
||||
BadgePercent,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
Pencil,
|
||||
ScanEye,
|
||||
SwatchBook,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { SiReddit, SiYoutube } from '@icons-pack/react-simple-icons';
|
||||
import { Fragment } from 'react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const focusModes = [
|
||||
{
|
||||
key: 'webSearch',
|
||||
title: 'All',
|
||||
description: 'Searches across all of the internet',
|
||||
icon: <Globe size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'academicSearch',
|
||||
title: 'Academic',
|
||||
description: 'Search in published academic papers',
|
||||
icon: <SwatchBook size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'writingAssistant',
|
||||
title: 'Writing',
|
||||
description: 'Chat without searching the web',
|
||||
icon: <Pencil size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'wolframAlphaSearch',
|
||||
title: 'Wolfram Alpha',
|
||||
description: 'Computational knowledge engine',
|
||||
icon: <BadgePercent size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'youtubeSearch',
|
||||
title: 'Youtube',
|
||||
description: 'Search and watch videos',
|
||||
icon: <SiYoutube className="h-[16px] w-auto mr-0.5" />,
|
||||
},
|
||||
{
|
||||
key: 'redditSearch',
|
||||
title: 'Reddit',
|
||||
description: 'Search for discussions and opinions',
|
||||
icon: <SiReddit className="h-[16px] w-auto mr-0.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const Focus = () => {
|
||||
const { focusMode, setFocusMode } = useChat();
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{focusMode !== 'webSearch' ? (
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{focusModes.find((mode) => mode.key === focusMode)?.icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Globe size={16} />
|
||||
</div>
|
||||
)}
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] -right-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
{focusModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setFocusMode(mode.key)}
|
||||
key={i}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-2 duration-200 cursor-pointer transition focus:outline-none',
|
||||
focusMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1',
|
||||
focusMode === mode.key
|
||||
? 'text-[#24A0ED]'
|
||||
: 'text-black dark:text-white',
|
||||
)}
|
||||
>
|
||||
{mode.icon}
|
||||
<p className="text-sm font-medium">{mode.title}</p>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Focus;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
const OptimizationModes = [
|
||||
{
|
||||
@@ -60,40 +61,50 @@ const Optimization = () => {
|
||||
/>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] left-0">
|
||||
<div className="flex flex-col gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
{OptimizationModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setOptimizationMode(mode.key)}
|
||||
key={i}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
|
||||
{mode.icon}
|
||||
<p className="text-sm font-medium">{mode.title}</p>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-64 md:w-[250px] left-0"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-top-left flex flex-col space-y-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-2 max-h-[200px] md:max-h-none overflow-y-auto"
|
||||
>
|
||||
{OptimizationModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setOptimizationMode(mode.key)}
|
||||
key={i}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-between w-full text-black dark:text-white">
|
||||
<div className="flex flex-row space-x-1">
|
||||
{mode.icon}
|
||||
<p className="text-xs font-medium">{mode.title}</p>
|
||||
</div>
|
||||
{mode.key === 'quality' && (
|
||||
<span className="bg-sky-500/70 dark:bg-sky-500/40 border border-sky-600 px-1 rounded-full text-[10px] text-white">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
93
src/components/MessageInputActions/Sources.tsx
Normal file
93
src/components/MessageInputActions/Sources.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Switch,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
GlobeIcon,
|
||||
GraduationCapIcon,
|
||||
NetworkIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
const sourcesList = [
|
||||
{
|
||||
name: 'Web',
|
||||
key: 'web',
|
||||
icon: <GlobeIcon className="h-[16px] w-auto" />,
|
||||
},
|
||||
{
|
||||
name: 'Academic',
|
||||
key: 'academic',
|
||||
icon: <GraduationCapIcon className="h-[16px] w-auto" />,
|
||||
},
|
||||
{
|
||||
name: 'Social',
|
||||
key: 'discussions',
|
||||
icon: <NetworkIcon className="h-[16px] w-auto" />,
|
||||
},
|
||||
];
|
||||
|
||||
const Sources = () => {
|
||||
const { sources, setSources } = useChat();
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton className="flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white">
|
||||
<GlobeIcon className="h-[18px] w-auto" />
|
||||
</PopoverButton>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
static
|
||||
className="absolute z-10 w-64 md:w-[225px] right-0"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-top-right flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-1 max-h-[200px] md:max-h-none overflow-y-auto shadow-lg"
|
||||
>
|
||||
{sourcesList.map((source, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row justify-between hover:bg-light-100 hover:dark:bg-dark-100 rounded-md py-3 px-2 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!sources.includes(source.key)) {
|
||||
setSources([...sources, source.key]);
|
||||
} else {
|
||||
setSources(sources.filter((s) => s !== source.key));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row space-x-1.5 text-black/80 dark:text-white/80">
|
||||
{source.icon}
|
||||
<p className="text-xs">{source.name}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sources.includes(source.key)}
|
||||
className="group relative flex h-4 w-7 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-0.5 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-sky-500 dark:data-[checked]:bg-sky-500"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none inline-block size-3 translate-x-[1px] group-data-[checked]:translate-x-3 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sources;
|
||||
102
src/components/MessageRenderer/CodeBlock/CodeBlockDarkTheme.ts
Normal file
102
src/components/MessageRenderer/CodeBlock/CodeBlockDarkTheme.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const darkTheme = {
|
||||
'hljs-comment': {
|
||||
color: '#8b949e',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#8b949e',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-selector-class': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#ff7b72',
|
||||
},
|
||||
'hljs-number': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-params': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#f2cc60',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#58a6ff',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#7ee787',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#79c0ff',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#79c0ff',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#c297ff',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#c297ff',
|
||||
},
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: '0.75em',
|
||||
border: '1px solid #21262d',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
|
||||
export default darkTheme;
|
||||
102
src/components/MessageRenderer/CodeBlock/CodeBlockLightTheme.ts
Normal file
102
src/components/MessageRenderer/CodeBlock/CodeBlockLightTheme.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const lightTheme = {
|
||||
'hljs-comment': {
|
||||
color: '#6e7781',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#6e7781',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-selector-class': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#d73a49',
|
||||
},
|
||||
'hljs-number': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-params': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#b08800',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#0a64ae',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#22863a',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#005cc5',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#005cc5',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#6f42c1',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#6f42c1',
|
||||
},
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
background: '#ffffff',
|
||||
color: '#24292f',
|
||||
padding: '0.75em',
|
||||
border: '1px solid #e8edf1',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
|
||||
export default lightTheme;
|
||||
67
src/components/MessageRenderer/CodeBlock/index.tsx
Normal file
67
src/components/MessageRenderer/CodeBlock/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { CheckIcon, CopyIcon } from '@phosphor-icons/react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import darkTheme from './CodeBlockDarkTheme';
|
||||
import lightTheme from './CodeBlockLightTheme';
|
||||
|
||||
const SyntaxHighlighterComponent =
|
||||
SyntaxHighlighter as unknown as React.ComponentType<any>;
|
||||
|
||||
const CodeBlock = ({
|
||||
language,
|
||||
children,
|
||||
}: {
|
||||
language: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const syntaxTheme = useMemo(() => {
|
||||
if (!mounted) return lightTheme;
|
||||
return resolvedTheme === 'dark' ? darkTheme : lightTheme;
|
||||
}, [mounted, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(children as string);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className="absolute top-2 right-2 text-black/70 dark:text-white/70"
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
size={16}
|
||||
className="absolute top-2 right-2 transition duration-200 text-black/70 dark:text-white/70 hover:text-gray-800/70 hover:dark:text-gray-300/70"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<SyntaxHighlighterComponent
|
||||
language={language}
|
||||
style={syntaxTheme}
|
||||
showInlineLineNumbers
|
||||
>
|
||||
{children as string}
|
||||
</SyntaxHighlighterComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeBlock;
|
||||
@@ -205,8 +205,9 @@ const Navbar = () => {
|
||||
useEffect(() => {
|
||||
if (sections.length > 0 && sections[0].message) {
|
||||
const newTitle =
|
||||
sections[0].message.query.substring(0, 30) + '...' ||
|
||||
'New Conversation';
|
||||
sections[0].message.query.length > 30
|
||||
? `${sections[0].message.query.substring(0, 30).trim()}...`
|
||||
: sections[0].message.query || 'New Conversation';
|
||||
|
||||
setTitle(newTitle);
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
|
||||
@@ -17,7 +17,7 @@ const SearchImages = ({
|
||||
messageId,
|
||||
}: {
|
||||
query: string;
|
||||
chatHistory: Message[];
|
||||
chatHistory: [string, string][];
|
||||
messageId: string;
|
||||
}) => {
|
||||
const [images, setImages] = useState<Image[] | null>(null);
|
||||
|
||||
@@ -30,7 +30,7 @@ const Searchvideos = ({
|
||||
messageId,
|
||||
}: {
|
||||
query: string;
|
||||
chatHistory: Message[];
|
||||
chatHistory: [string, string][];
|
||||
messageId: string;
|
||||
}) => {
|
||||
const [videos, setVideos] = useState<Video[] | null>(null);
|
||||
|
||||
@@ -154,7 +154,7 @@ const SettingsDialogue = ({
|
||||
Version: {process.env.NEXT_PUBLIC_VERSION}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/itzcrazykns/perplexica"
|
||||
href="https://github.com/itzcrazykns/vane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-black/70 dark:text-white/70 flex flex-row space-x-1 items-center transition duration-200 hover:text-black/90 hover:dark:text-white/90"
|
||||
|
||||
@@ -310,7 +310,7 @@ const SettingsSwitch = ({
|
||||
checked={isChecked}
|
||||
onChange={handleSave}
|
||||
disabled={loading}
|
||||
className="group relative flex h-6 w-12 shrink-0 cursor-pointer rounded-full bg-white/10 p-1 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-sky-500"
|
||||
className="group relative flex h-6 w-12 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-1 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-sky-500 dark:data-[checked]:bg-sky-500"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -46,9 +46,9 @@ const SetupWizard = ({
|
||||
animate={{ opacity: 1, translateY: '0px' }}
|
||||
className="text-4xl md:text-6xl xl:text-8xl font-normal font-['Instrument_Serif'] tracking-tight"
|
||||
>
|
||||
Welcome to{' '}
|
||||
Welcome to
|
||||
<span className="text-[#24A0ED] italic font-['PP_Editorial']">
|
||||
Perplexica
|
||||
Vane
|
||||
</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
@@ -91,9 +91,9 @@ const SetupWizard = ({
|
||||
}}
|
||||
className="text-2xl md:text-4xl xl:text-6xl font-normal font-['Instrument_Serif'] tracking-tight"
|
||||
>
|
||||
Let us get{' '}
|
||||
Let us get
|
||||
<span className="text-[#24A0ED] italic font-['PP_Editorial']">
|
||||
Perplexica
|
||||
Vane
|
||||
</span>{' '}
|
||||
set up for you
|
||||
</motion.p>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Cloud, Sun, CloudRain, CloudSnow, Wind } from 'lucide-react';
|
||||
'use client';
|
||||
|
||||
import { Wind } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getApproxLocation } from '@/lib/actions';
|
||||
|
||||
const WeatherWidget = () => {
|
||||
const [data, setData] = useState({
|
||||
@@ -15,17 +18,6 @@ const WeatherWidget = () => {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const getApproxLocation = async () => {
|
||||
const res = await fetch('https://ipwhois.app/json/');
|
||||
const data = await res.json();
|
||||
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
city: data.city,
|
||||
};
|
||||
};
|
||||
|
||||
const getLocation = async (
|
||||
callback: (location: {
|
||||
latitude: number;
|
||||
@@ -91,7 +83,7 @@ const WeatherWidget = () => {
|
||||
setData({
|
||||
temperature: data.temperature,
|
||||
condition: data.condition,
|
||||
location: 'Mars',
|
||||
location: location.city,
|
||||
humidity: data.humidity,
|
||||
windSpeed: data.windSpeed,
|
||||
icon: data.icon,
|
||||
|
||||
@@ -257,21 +257,21 @@ const Stock = (props: StockWidgetProps) => {
|
||||
const isPostMarket = props.marketState === 'POST';
|
||||
|
||||
const displayPrice = isPostMarket
|
||||
? props.postMarketPrice ?? props.regularMarketPrice
|
||||
? (props.postMarketPrice ?? props.regularMarketPrice)
|
||||
: isPreMarket
|
||||
? props.preMarketPrice ?? props.regularMarketPrice
|
||||
? (props.preMarketPrice ?? props.regularMarketPrice)
|
||||
: props.regularMarketPrice;
|
||||
|
||||
const displayChange = isPostMarket
|
||||
? props.postMarketChange ?? props.regularMarketChange
|
||||
? (props.postMarketChange ?? props.regularMarketChange)
|
||||
: isPreMarket
|
||||
? props.preMarketChange ?? props.regularMarketChange
|
||||
? (props.preMarketChange ?? props.regularMarketChange)
|
||||
: props.regularMarketChange;
|
||||
|
||||
const displayChangePercent = isPostMarket
|
||||
? props.postMarketChangePercent ?? props.regularMarketChangePercent
|
||||
? (props.postMarketChangePercent ?? props.regularMarketChangePercent)
|
||||
: isPreMarket
|
||||
? props.preMarketChangePercent ?? props.regularMarketChangePercent
|
||||
? (props.preMarketChangePercent ?? props.regularMarketChangePercent)
|
||||
: props.regularMarketChangePercent;
|
||||
|
||||
const changeColor = isPositive
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import { Message } from '@/components/ChatWindow';
|
||||
|
||||
export const getSuggestions = async (chatHistory: [string, string][]) => {
|
||||
const chatTurns = chatHistory.map(([role, content]) => {
|
||||
if (role === 'human') {
|
||||
return { role: 'user', content };
|
||||
} else {
|
||||
return { role: 'assistant', content };
|
||||
}
|
||||
});
|
||||
|
||||
const chatModel = localStorage.getItem('chatModelKey');
|
||||
const chatModelProvider = localStorage.getItem('chatModelProviderId');
|
||||
|
||||
@@ -18,7 +8,7 @@ export const getSuggestions = async (chatHistory: [string, string][]) => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chatHistory: chatTurns,
|
||||
chatHistory,
|
||||
chatModel: {
|
||||
providerId: chatModelProvider,
|
||||
key: chatModel,
|
||||
@@ -30,3 +20,17 @@ export const getSuggestions = async (chatHistory: [string, string][]) => {
|
||||
|
||||
return data.suggestions;
|
||||
};
|
||||
|
||||
export const getApproxLocation = async () => {
|
||||
const res = await fetch('https://free.freeipapi.com/api/json', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
city: data.cityName,
|
||||
};
|
||||
};
|
||||
|
||||
102
src/lib/agents/search/api.ts
Normal file
102
src/lib/agents/search/api.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ResearcherOutput, SearchAgentInput } from './types';
|
||||
import SessionManager from '@/lib/session';
|
||||
import { classify } from './classifier';
|
||||
import Researcher from './researcher';
|
||||
import { getWriterPrompt } from '@/lib/prompts/search/writer';
|
||||
import { WidgetExecutor } from './widgets';
|
||||
|
||||
class APISearchAgent {
|
||||
async searchAsync(session: SessionManager, input: SearchAgentInput) {
|
||||
const classification = await classify({
|
||||
chatHistory: input.chatHistory,
|
||||
enabledSources: input.config.sources,
|
||||
query: input.followUp,
|
||||
llm: input.config.llm,
|
||||
});
|
||||
|
||||
const widgetPromise = WidgetExecutor.executeAll({
|
||||
classification,
|
||||
chatHistory: input.chatHistory,
|
||||
followUp: input.followUp,
|
||||
llm: input.config.llm,
|
||||
}).catch((err) => {
|
||||
console.error(`Error executing widgets: ${err}`);
|
||||
return [];
|
||||
});
|
||||
|
||||
let searchPromise: Promise<ResearcherOutput> | null = null;
|
||||
|
||||
if (!classification.classification.skipSearch) {
|
||||
const researcher = new Researcher();
|
||||
searchPromise = researcher.research(SessionManager.createSession(), {
|
||||
chatHistory: input.chatHistory,
|
||||
followUp: input.followUp,
|
||||
classification: classification,
|
||||
config: input.config,
|
||||
});
|
||||
}
|
||||
|
||||
const [widgetOutputs, searchResults] = await Promise.all([
|
||||
widgetPromise,
|
||||
searchPromise,
|
||||
]);
|
||||
|
||||
if (searchResults) {
|
||||
session.emit('data', {
|
||||
type: 'searchResults',
|
||||
data: searchResults.searchFindings,
|
||||
});
|
||||
}
|
||||
|
||||
session.emit('data', {
|
||||
type: 'researchComplete',
|
||||
});
|
||||
|
||||
const finalContext =
|
||||
searchResults?.searchFindings
|
||||
.map(
|
||||
(f, index) =>
|
||||
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
|
||||
)
|
||||
.join('\n') || '';
|
||||
|
||||
const widgetContext = widgetOutputs
|
||||
.map((o) => {
|
||||
return `<result>${o.llmContext}</result>`;
|
||||
})
|
||||
.join('\n-------------\n');
|
||||
|
||||
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
|
||||
|
||||
const writerPrompt = getWriterPrompt(
|
||||
finalContextWithWidgets,
|
||||
input.config.systemInstructions,
|
||||
input.config.mode,
|
||||
);
|
||||
|
||||
const answerStream = input.config.llm.streamText({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: writerPrompt,
|
||||
},
|
||||
...input.chatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: input.followUp,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for await (const chunk of answerStream) {
|
||||
session.emit('data', {
|
||||
type: 'response',
|
||||
data: chunk.contentChunk,
|
||||
});
|
||||
}
|
||||
|
||||
session.emit('end', {});
|
||||
}
|
||||
}
|
||||
|
||||
export default APISearchAgent;
|
||||
@@ -4,9 +4,54 @@ import { classify } from './classifier';
|
||||
import Researcher from './researcher';
|
||||
import { getWriterPrompt } from '@/lib/prompts/search/writer';
|
||||
import { WidgetExecutor } from './widgets';
|
||||
import db from '@/lib/db';
|
||||
import { messages } from '@/lib/db/schema';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import { TextBlock } from '@/lib/types';
|
||||
import { getTokenCount } from '@/lib/utils/splitText';
|
||||
|
||||
class SearchAgent {
|
||||
async searchAsync(session: SessionManager, input: SearchAgentInput) {
|
||||
const exists = await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.chatId, input.chatId),
|
||||
eq(messages.messageId, input.messageId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
await db.insert(messages).values({
|
||||
chatId: input.chatId,
|
||||
messageId: input.messageId,
|
||||
backendId: session.id,
|
||||
query: input.followUp,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'answering',
|
||||
responseBlocks: [],
|
||||
});
|
||||
} else {
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(
|
||||
and(eq(messages.chatId, input.chatId), gt(messages.id, exists.id)),
|
||||
)
|
||||
.execute();
|
||||
await db
|
||||
.update(messages)
|
||||
.set({
|
||||
status: 'answering',
|
||||
backendId: session.id,
|
||||
responseBlocks: [],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(messages.chatId, input.chatId),
|
||||
eq(messages.messageId, input.messageId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const classification = await classify({
|
||||
chatHistory: input.chatHistory,
|
||||
enabledSources: input.config.sources,
|
||||
@@ -54,13 +99,17 @@ class SearchAgent {
|
||||
type: 'researchComplete',
|
||||
});
|
||||
|
||||
const finalContext =
|
||||
searchResults?.searchFindings
|
||||
let finalContext =
|
||||
'<Query to be answered without searching; Search not made>';
|
||||
|
||||
if (searchResults) {
|
||||
finalContext = searchResults?.searchFindings
|
||||
.map(
|
||||
(f, index) =>
|
||||
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
|
||||
)
|
||||
.join('\n') || '';
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const widgetContext = widgetOutputs
|
||||
.map((o) => {
|
||||
@@ -70,7 +119,12 @@ class SearchAgent {
|
||||
|
||||
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
|
||||
|
||||
const writerPrompt = getWriterPrompt(finalContextWithWidgets);
|
||||
const writerPrompt = getWriterPrompt(
|
||||
finalContextWithWidgets,
|
||||
input.config.systemInstructions,
|
||||
input.config.mode,
|
||||
);
|
||||
|
||||
const answerStream = input.config.llm.streamText({
|
||||
messages: [
|
||||
{
|
||||
@@ -85,18 +139,53 @@ class SearchAgent {
|
||||
],
|
||||
});
|
||||
|
||||
let accumulatedText = '';
|
||||
let responseBlockId = '';
|
||||
|
||||
for await (const chunk of answerStream) {
|
||||
accumulatedText += chunk.contentChunk;
|
||||
if (!responseBlockId) {
|
||||
const block: TextBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'text',
|
||||
data: chunk.contentChunk,
|
||||
};
|
||||
|
||||
session.emit('data', {
|
||||
type: 'response',
|
||||
data: chunk.contentChunk,
|
||||
});
|
||||
session.emitBlock(block);
|
||||
|
||||
responseBlockId = block.id;
|
||||
} else {
|
||||
const block = session.getBlock(responseBlockId) as TextBlock | null;
|
||||
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
block.data += chunk.contentChunk;
|
||||
|
||||
session.updateBlock(block.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data',
|
||||
value: block.data,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
session.emit('end', {});
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({
|
||||
status: 'completed',
|
||||
responseBlocks: session.getAllBlocks(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(messages.chatId, input.chatId),
|
||||
eq(messages.messageId, input.messageId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const doneAction: ResearchAction<any> = {
|
||||
name: 'done',
|
||||
schema: z.object({}),
|
||||
getToolDescription: () =>
|
||||
'Only call this after 0_reasoning AND after any other needed tool calls when you truly have enough to answer. Do not call if information is still missing.',
|
||||
'Only call this after __reasoning_preamble AND after any other needed tool calls when you truly have enough to answer. Do not call if information is still missing.',
|
||||
getDescription: () => actionDescription,
|
||||
enabled: (_) => true,
|
||||
execute: async (params, additionalConfig) => {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import academicSearchAction from './search/academicSearch';
|
||||
import doneAction from './done';
|
||||
import planAction from './plan';
|
||||
import ActionRegistry from './registry';
|
||||
import scrapeURLAction from './scrapeURL';
|
||||
import socialSearchAction from './search/socialSearch';
|
||||
import uploadsSearchAction from './uploadsSearch';
|
||||
import webSearchAction from './webSearch';
|
||||
import webSearchAction from './search/webSearch';
|
||||
|
||||
ActionRegistry.register(webSearchAction);
|
||||
ActionRegistry.register(doneAction);
|
||||
ActionRegistry.register(planAction);
|
||||
ActionRegistry.register(scrapeURLAction);
|
||||
ActionRegistry.register(uploadsSearchAction);
|
||||
ActionRegistry.register(academicSearchAction);
|
||||
ActionRegistry.register(socialSearchAction);
|
||||
|
||||
export { ActionRegistry };
|
||||
|
||||
@@ -17,13 +17,13 @@ Here are some examples of good plans:
|
||||
<examples>
|
||||
- "Okay, the user wants to know the latest advancements in renewable energy. I will start by looking for recent articles and studies on this topic, then summarize the key points." -> "I have gathered enough information to provide a comprehensive answer."
|
||||
- "The user is asking about the health benefits of a Mediterranean diet. I will search for scientific studies and expert opinions on this diet, then compile the findings into a clear summary." -> "I have gathered information about the Mediterranean diet and its health benefits, I will now look up for any recent studies to ensure the information is current."
|
||||
<examples>
|
||||
</examples>
|
||||
|
||||
YOU CAN NEVER CALL ANY OTHER TOOL BEFORE CALLING THIS ONE FIRST, IF YOU DO, THAT CALL WOULD BE IGNORED.
|
||||
`;
|
||||
|
||||
const planAction: ResearchAction<typeof schema> = {
|
||||
name: '0_reasoning',
|
||||
name: '__reasoning_preamble',
|
||||
schema: schema,
|
||||
getToolDescription: () =>
|
||||
'Use this FIRST on every turn to state your plan in natural language before any other action. Keep it short, action-focused, and tailored to the current query.',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ClassifierOutput,
|
||||
ResearchAction,
|
||||
SearchAgentConfig,
|
||||
SearchSources,
|
||||
} from '../../types';
|
||||
|
||||
class ActionRegistry {
|
||||
@@ -22,6 +23,7 @@ class ActionRegistry {
|
||||
classification: ClassifierOutput;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
sources: SearchSources[];
|
||||
}): ResearchAction[] {
|
||||
return Array.from(
|
||||
this.actions.values().filter((action) => action.enabled(config)),
|
||||
@@ -32,6 +34,7 @@ class ActionRegistry {
|
||||
classification: ClassifierOutput;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
sources: SearchSources[];
|
||||
}): Tool[] {
|
||||
const availableActions = this.getAvailableActions(config);
|
||||
|
||||
@@ -46,6 +49,7 @@ class ActionRegistry {
|
||||
classification: ClassifierOutput;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
sources: SearchSources[];
|
||||
}): string {
|
||||
const availableActions = this.getAvailableActions(config);
|
||||
|
||||
@@ -63,6 +67,7 @@ class ActionRegistry {
|
||||
additionalConfig: AdditionalConfig & {
|
||||
researchBlockId: string;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
},
|
||||
) {
|
||||
const action = this.actions.get(name);
|
||||
@@ -79,6 +84,7 @@ class ActionRegistry {
|
||||
additionalConfig: AdditionalConfig & {
|
||||
researchBlockId: string;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
},
|
||||
): Promise<ActionOutput[]> {
|
||||
const results: ActionOutput[] = [];
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import z from 'zod';
|
||||
import { ResearchAction } from '../../types';
|
||||
import { Chunk, ReadingResearchBlock } from '@/lib/types';
|
||||
import TurnDown from 'turndown';
|
||||
import path from 'path';
|
||||
import Scraper from '@/lib/scraper';
|
||||
import { splitText } from '@/lib/utils/splitText';
|
||||
|
||||
const turndownService = new TurnDown();
|
||||
const extractorPrompt = `
|
||||
Assistant is an AI information extractor. Assistant will be shared with scraped information from a website along with the queries used to retrieve that information. Assistant's task is to extract relevant facts from the scraped data to answer the queries.
|
||||
|
||||
## Things to taken into consideration when extracting information:
|
||||
1. Relevance to the query: The extracted information must dynamically adjust based on the query's intent. If the query asks "What is [X]", you must extract the definition/identity. If the query asks for "[X] specs" or "features", you must provide deep, granular technical details.
|
||||
- Example: For "What is [Product]", extract the core definition. For "[Product] capabilities", extract every technical function mentioned.
|
||||
2. Concentrate on extracting factual information that can help in answering the question rather than opinions or commentary. Ignore marketing fluff like "best-in-class" or "seamless."
|
||||
3. Noise to signal ratio: If the scraped data is noisy (headers, footers, UI text), ignore it and extract only the high-value information.
|
||||
- Example: Discard "Click for more" or "Subscribe now" messages.
|
||||
4. Avoid using filler sentences or words; extract concise, telegram-style information.
|
||||
- Example: Change "The device features a weight of only 1.2kg" to "Weight: 1.2kg."
|
||||
5. Duplicate information: If a fact appears multiple times (e.g., in a paragraph and a technical table), merge the details into a single, high-density bullet point to avoid redundancy.
|
||||
6. Numerical Data Integrity: NEVER summarize or generalize numbers, benchmarks, or table data. Extract raw values exactly as they appear.
|
||||
- Example: Do not say "Improved coding scores." Say "LiveCodeBench v6: 80.0%."
|
||||
|
||||
## Example
|
||||
For example, if the query is "What are the health benefits of green tea?" and the scraped data contains various pieces of information about green tea, Assistant should focus on extracting factual information related to the health benefits of green tea such as "Green tea contains antioxidants which can help in reducing inflammation" and ignore irrelevant information such as "Green tea is a popular beverage worldwide".
|
||||
|
||||
It can also remove filler words to reduce the sentence to "Contains antioxidants; reduces inflammation."
|
||||
|
||||
For tables/numerical data extraction, Assistant should extract the raw numerical data or the content of the table without trying to summarize it to avoid losing important details. For example, if a table lists specific battery life hours for different modes, Assistant should list every mode and its corresponding hour count rather than giving a general average.
|
||||
|
||||
Make sure the extracted facts are in bullet points format to make it easier to read and understand.
|
||||
|
||||
## Output format
|
||||
Assistant should reply with a JSON object containing a key "extracted_facts" which is a string of the bulleted facts. Return only raw JSON without markdown formatting (no \`\`\`json blocks).
|
||||
|
||||
<example_output>
|
||||
{
|
||||
"extracted_facts": "- Fact 1\n- Fact 2\n- Fact 3"
|
||||
}
|
||||
</example_output>
|
||||
`;
|
||||
|
||||
const extractorSchema = z.object({
|
||||
extracted_facts: z
|
||||
.string()
|
||||
.describe(
|
||||
'The extracted facts that are relevant to the query and can help in answering the question should be listed here in a concise manner.',
|
||||
),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
urls: z.array(z.string()).describe('A list of URLs to scrape content from.'),
|
||||
@@ -39,11 +79,7 @@ const scrapeURLAction: ResearchAction<typeof schema> = {
|
||||
await Promise.all(
|
||||
params.urls.map(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const text = await res.text();
|
||||
|
||||
const title =
|
||||
text.match(/<title>(.*?)<\/title>/i)?.[1] || `Content from ${url}`;
|
||||
const scraped = await Scraper.scrape(url);
|
||||
|
||||
if (
|
||||
!readingEmitted &&
|
||||
@@ -59,7 +95,7 @@ const scrapeURLAction: ResearchAction<typeof schema> = {
|
||||
content: '',
|
||||
metadata: {
|
||||
url,
|
||||
title: title,
|
||||
title: scraped.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -92,7 +128,7 @@ const scrapeURLAction: ResearchAction<typeof schema> = {
|
||||
content: '',
|
||||
metadata: {
|
||||
url,
|
||||
title: title,
|
||||
title: scraped.title,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,13 +144,49 @@ const scrapeURLAction: ResearchAction<typeof schema> = {
|
||||
);
|
||||
}
|
||||
|
||||
const markdown = turndownService.turndown(text);
|
||||
const chunks = splitText(scraped.content, 4000, 500);
|
||||
|
||||
let accumulatedContent = '';
|
||||
|
||||
if (chunks.length > 1) {
|
||||
try {
|
||||
await Promise.all(
|
||||
chunks.map(async (chunk) => {
|
||||
const extracted = await additionalConfig.llm.generateObject<
|
||||
typeof extractorSchema
|
||||
>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: extractorPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<queries>Summarize</queries>\n<scraped_data>${chunk}</scraped_data>`,
|
||||
},
|
||||
],
|
||||
schema: extractorSchema,
|
||||
});
|
||||
|
||||
accumulatedContent += extracted.extracted_facts + '\n';
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'Error during extraction, falling back to raw content',
|
||||
err,
|
||||
);
|
||||
accumulatedContent = chunks[0];
|
||||
}
|
||||
} else {
|
||||
accumulatedContent = scraped.content;
|
||||
}
|
||||
|
||||
results.push({
|
||||
content: markdown,
|
||||
content: accumulatedContent,
|
||||
metadata: {
|
||||
url,
|
||||
title: title,
|
||||
title: scraped.title,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -122,7 +194,7 @@ const scrapeURLAction: ResearchAction<typeof schema> = {
|
||||
content: `Failed to fetch content from ${url}: ${error}`,
|
||||
metadata: {
|
||||
url,
|
||||
title: `Error fetching ${url}`,
|
||||
title: `Error scraping ${url}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import z from 'zod';
|
||||
import { ResearchAction } from '../../../types';
|
||||
import { ResearchBlock } from '@/lib/types';
|
||||
import { executeSearch } from './baseSearch';
|
||||
|
||||
const schema = z.object({
|
||||
queries: z.array(z.string()).describe('List of academic search queries'),
|
||||
});
|
||||
|
||||
const academicSearchDescription = `
|
||||
Use this tool to perform academic searches for scholarly articles, papers, and research studies relevant to the user's query. Provide a list of concise search queries that will help gather comprehensive academic information on the topic at hand.
|
||||
You can provide up to 3 queries at a time. Make sure the queries are specific and relevant to the user's needs.
|
||||
|
||||
For example, if the user is interested in recent advancements in renewable energy, your queries could be:
|
||||
1. "Recent advancements in renewable energy 2024"
|
||||
2. "Cutting-edge research on solar power technologies"
|
||||
3. "Innovations in wind energy systems"
|
||||
|
||||
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed academic information.
|
||||
`;
|
||||
|
||||
const academicSearchAction: ResearchAction<typeof schema> = {
|
||||
name: 'academic_search',
|
||||
schema: schema,
|
||||
getDescription: () => academicSearchDescription,
|
||||
getToolDescription: () =>
|
||||
"Use this tool to perform academic searches for scholarly articles, papers, and research studies relevant to the user's query. Provide a list of concise search queries that will help gather comprehensive academic information on the topic at hand.",
|
||||
enabled: (config) =>
|
||||
config.sources.includes('academic') &&
|
||||
config.classification.classification.skipSearch === false &&
|
||||
config.classification.classification.academicSearch === true,
|
||||
execute: async (input, additionalConfig) => {
|
||||
input.queries = (
|
||||
Array.isArray(input.queries) ? input.queries : [input.queries]
|
||||
).slice(0, 3);
|
||||
|
||||
const researchBlock = additionalConfig.session.getBlock(
|
||||
additionalConfig.researchBlockId,
|
||||
) as ResearchBlock | undefined;
|
||||
|
||||
if (!researchBlock) throw new Error('Failed to retrieve research block');
|
||||
|
||||
const results = await executeSearch({
|
||||
llm: additionalConfig.llm,
|
||||
embedding: additionalConfig.embedding,
|
||||
mode: additionalConfig.mode,
|
||||
queries: input.queries,
|
||||
researchBlock: researchBlock,
|
||||
session: additionalConfig.session,
|
||||
searchConfig: {
|
||||
engines: ['arxiv', 'google scholar', 'pubmed'],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'search_results',
|
||||
results: results,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default academicSearchAction;
|
||||
423
src/lib/agents/search/researcher/actions/search/baseSearch.ts
Normal file
423
src/lib/agents/search/researcher/actions/search/baseSearch.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import BaseEmbedding from '@/lib/models/base/embedding';
|
||||
import BaseLLM from '@/lib/models/base/llm';
|
||||
import { searchSearxng, SearxngSearchOptions } from '@/lib/searxng';
|
||||
import SessionManager from '@/lib/session';
|
||||
import { Chunk, ResearchBlock, SearchResultsResearchBlock } from '@/lib/types';
|
||||
import { SearchAgentConfig } from '../../../types';
|
||||
import computeSimilarity from '@/lib/utils/computeSimilarity';
|
||||
import z from 'zod';
|
||||
import Scraper from '@/lib/scraper';
|
||||
import { splitText } from '@/lib/utils/splitText';
|
||||
|
||||
export const executeSearch = async (input: {
|
||||
queries: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
searchConfig?: SearxngSearchOptions;
|
||||
researchBlock: ResearchBlock;
|
||||
session: InstanceType<typeof SessionManager>;
|
||||
llm: BaseLLM<any>;
|
||||
embedding: BaseEmbedding<any>;
|
||||
}) => {
|
||||
const researchBlock = input.researchBlock;
|
||||
|
||||
researchBlock.data.subSteps.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'searching',
|
||||
searching: input.queries,
|
||||
});
|
||||
|
||||
input.session.updateBlock(researchBlock.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
|
||||
if (input.mode === 'speed' || input.mode === 'balanced') {
|
||||
const searchResultsBlockId = crypto.randomUUID();
|
||||
let searchResultsEmitted = false;
|
||||
|
||||
const results: Chunk[] = [];
|
||||
|
||||
const search = async (q: string) => {
|
||||
const res = await searchSearxng(q, {
|
||||
...(input.searchConfig ? input.searchConfig : {}),
|
||||
});
|
||||
|
||||
let resultChunks: Chunk[] = [];
|
||||
|
||||
try {
|
||||
const queryEmbedding = (await input.embedding.embedText([q]))[0];
|
||||
|
||||
resultChunks = (
|
||||
await Promise.all(
|
||||
res.results.map(async (r) => {
|
||||
const content = r.content || r.title;
|
||||
const chunkEmbedding = (
|
||||
await input.embedding.embedText([content])
|
||||
)[0];
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
similarity: computeSimilarity(queryEmbedding, chunkEmbedding),
|
||||
embedding: chunkEmbedding,
|
||||
},
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter((c) => c.metadata.similarity > 0.5);
|
||||
} catch (err) {
|
||||
resultChunks = res.results.map((r) => {
|
||||
const content = r.content || r.title;
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
similarity: 1,
|
||||
embedding: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
results.push(...resultChunks);
|
||||
}
|
||||
|
||||
if (!searchResultsEmitted) {
|
||||
searchResultsEmitted = true;
|
||||
|
||||
researchBlock.data.subSteps.push({
|
||||
id: searchResultsBlockId,
|
||||
type: 'search_results',
|
||||
reading: resultChunks,
|
||||
});
|
||||
|
||||
input.session.updateBlock(researchBlock.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
} else if (searchResultsEmitted) {
|
||||
const subStepIndex = researchBlock.data.subSteps.findIndex(
|
||||
(step) => step.id === searchResultsBlockId,
|
||||
);
|
||||
|
||||
const subStep = researchBlock.data.subSteps[
|
||||
subStepIndex
|
||||
] as SearchResultsResearchBlock;
|
||||
|
||||
subStep.reading.push(...resultChunks);
|
||||
|
||||
input.session.updateBlock(researchBlock.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(input.queries.map(search));
|
||||
|
||||
results.sort((a, b) => b.metadata.similarity - a.metadata.similarity);
|
||||
|
||||
const uniqueSearchResultIndices: Set<number> = new Set();
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
let isDuplicate = false;
|
||||
|
||||
for (const indice of uniqueSearchResultIndices.keys()) {
|
||||
if (
|
||||
results[i].metadata.embedding.length === 0 ||
|
||||
results[indice].metadata.embedding.length === 0
|
||||
)
|
||||
continue;
|
||||
|
||||
const similarity = computeSimilarity(
|
||||
results[i].metadata.embedding,
|
||||
results[indice].metadata.embedding,
|
||||
);
|
||||
|
||||
if (similarity > 0.75) {
|
||||
isDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDuplicate) {
|
||||
uniqueSearchResultIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSearchResults = Array.from(uniqueSearchResultIndices.keys())
|
||||
.map((i) => {
|
||||
const uniqueResult = results[i];
|
||||
|
||||
delete uniqueResult.metadata.embedding;
|
||||
delete uniqueResult.metadata.similarity;
|
||||
|
||||
return uniqueResult;
|
||||
})
|
||||
.slice(0, 20);
|
||||
|
||||
return uniqueSearchResults;
|
||||
} else if (input.mode === 'quality') {
|
||||
const searchResultsBlockId = crypto.randomUUID();
|
||||
let searchResultsEmitted = false;
|
||||
|
||||
const searchResults: Chunk[] = [];
|
||||
|
||||
const search = async (q: string) => {
|
||||
const res = await searchSearxng(q, {
|
||||
...(input.searchConfig ? input.searchConfig : {}),
|
||||
});
|
||||
|
||||
let resultChunks: Chunk[] = [];
|
||||
|
||||
resultChunks = res.results.map((r) => {
|
||||
const content = r.content || r.title;
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
similarity: 1,
|
||||
embedding: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
searchResults.push(...resultChunks);
|
||||
|
||||
if (!searchResultsEmitted) {
|
||||
searchResultsEmitted = true;
|
||||
|
||||
researchBlock.data.subSteps.push({
|
||||
id: searchResultsBlockId,
|
||||
type: 'search_results',
|
||||
reading: resultChunks,
|
||||
});
|
||||
|
||||
input.session.updateBlock(researchBlock.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
} else if (searchResultsEmitted) {
|
||||
const subStepIndex = researchBlock.data.subSteps.findIndex(
|
||||
(step) => step.id === searchResultsBlockId,
|
||||
);
|
||||
|
||||
const subStep = researchBlock.data.subSteps[
|
||||
subStepIndex
|
||||
] as SearchResultsResearchBlock;
|
||||
|
||||
subStep.reading.push(...resultChunks);
|
||||
|
||||
input.session.updateBlock(researchBlock.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(input.queries.map(search));
|
||||
|
||||
const pickerPrompt = `
|
||||
Assistant is an AI search result picker. Assistant's task is to pick 2-3 of the most relevant search results based off the query which can be then scraped for information to answer the query.
|
||||
Assistant will be shared with the search results retrieved from a search engine along with the queries used to retrieve those results. Assistant will then pick maxiumum 3 of the most relevant search results based on the queries and the content of the search results. Assistant should only pick search results that are relevant to the query and can help in answering the question.
|
||||
|
||||
## Things to taken into consideration when picking the search results:
|
||||
1. Relevance to the query: The search results should be relevant to the query provided. Irrelevant results should be ignored.
|
||||
2. Content quality: The content of the search results should be of high quality and provide valuable information that can help in answering the question.
|
||||
3. Favour known and reputable sources: If there are search results from known and reputable sources that are relevant to the query, those should be prioritized.
|
||||
4. Diversity: If there are multiple search results that are relevant and of high quality, try to pick results that provide diverse perspectives or information to get a well-rounded understanding of the topic.
|
||||
5. Avoid picking search results that are too similar to each other in terms of content to maximize the amount of information gathered.
|
||||
6. Maximum 3 results: Assistant should pick a maximum of 3 search results. If there are more than 3 relevant and high-quality search results, pick the top 3 based on the above criteria. If the queries are very specific and there are only 1 or 2 relevant search results, it's okay to pick only those 1 or 2 results.
|
||||
7. Try to pick only one high quality result unless there are diverse perspective in multiple results then you can pick a maximum of 3.
|
||||
8. Analyze the title, the snippet and the URL to determine the relevant to query, quality of the content that might be present inside and the reputation of the source before picking the search result.
|
||||
|
||||
## Output format
|
||||
Assistant should output an array of indices corresponding to the search results that were picked based on the above criteria. The indices should be based on the order of the search results provided to Assistant. For example, if Assistant picks the 1st, 3rd, and 5th search results, Assistant should output [0, 2, 4].
|
||||
|
||||
<example_output>
|
||||
{
|
||||
"picked_indices": [0,2,4]
|
||||
}
|
||||
</example_output>
|
||||
`;
|
||||
|
||||
const pickerSchema = z.object({
|
||||
picked_indices: z
|
||||
.array(z.number())
|
||||
.describe(
|
||||
'The array of the picked indices to be scraped for answering',
|
||||
),
|
||||
});
|
||||
|
||||
const pickerResponse = await input.llm.generateObject<typeof pickerSchema>({
|
||||
schema: pickerSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: pickerPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<queries>${input.queries.join(', ')}</queries>\n<search_results>${searchResults.map((result, index) => `<result indice=${index}>${JSON.stringify(result)}</result>`).join('\n')}</search_results>`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const pickedIndices = pickerResponse.picked_indices.slice(0, 3);
|
||||
const pickedResults = pickedIndices
|
||||
.map((i) => searchResults[i])
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
const alreadyExtractedURLs: string[] = [];
|
||||
|
||||
researchBlock.data.subSteps.forEach((step) => {
|
||||
if (step.type === 'reading') {
|
||||
step.reading.forEach((chunk) => {
|
||||
alreadyExtractedURLs.push(chunk.metadata.url);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredResults = pickedResults.filter(
|
||||
(r) => !alreadyExtractedURLs.find((url) => url === r.metadata.url),
|
||||
);
|
||||
|
||||
if (filteredResults.length > 0) {
|
||||
researchBlock.data.subSteps.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'reading',
|
||||
reading: filteredResults,
|
||||
});
|
||||
|
||||
input.session.updateBlock(researchBlock.id, [
|
||||
{
|
||||
path: '/data/subSteps',
|
||||
op: 'replace',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const extractedFacts: Chunk[] = [];
|
||||
|
||||
const extractorPrompt = `
|
||||
Assistant is an AI information extractor. Assistant will be shared with scraped information from a website along with the queries used to retrieve that information. Assistant's task is to extract relevant facts from the scraped data to answer the queries.
|
||||
|
||||
## Things to taken into consideration when extracting information:
|
||||
1. Relevance to the query: The extracted information must dynamically adjust based on the query's intent. If the query asks "What is [X]", you must extract the definition/identity. If the query asks for "[X] specs" or "features", you must provide deep, granular technical details.
|
||||
- Example: For "What is [Product]", extract the core definition. For "[Product] capabilities", extract every technical function mentioned.
|
||||
2. Concentrate on extracting factual information that can help in answering the question rather than opinions or commentary. Ignore marketing fluff like "best-in-class" or "seamless."
|
||||
3. Noise to signal ratio: If the scraped data is noisy (headers, footers, UI text), ignore it and extract only the high-value information.
|
||||
- Example: Discard "Click for more" or "Subscribe now" messages.
|
||||
4. Avoid using filler sentences or words; extract concise, telegram-style information.
|
||||
- Example: Change "The device features a weight of only 1.2kg" to "Weight: 1.2kg."
|
||||
5. Duplicate information: If a fact appears multiple times (e.g., in a paragraph and a technical table), merge the details into a single, high-density bullet point to avoid redundancy.
|
||||
6. Numerical Data Integrity: NEVER summarize or generalize numbers, benchmarks, or table data. Extract raw values exactly as they appear.
|
||||
- Example: Do not say "Improved coding scores." Say "LiveCodeBench v6: 80.0%."
|
||||
|
||||
## Example
|
||||
For example, if the query is "What are the health benefits of green tea?" and the scraped data contains various pieces of information about green tea, Assistant should focus on extracting factual information related to the health benefits of green tea such as "Green tea contains antioxidants which can help in reducing inflammation" and ignore irrelevant information such as "Green tea is a popular beverage worldwide".
|
||||
|
||||
It can also remove filler words to reduce the sentence to "Contains antioxidants; reduces inflammation."
|
||||
|
||||
For tables/numerical data extraction, Assistant should extract the raw numerical data or the content of the table without trying to summarize it to avoid losing important details. For example, if a table lists specific battery life hours for different modes, Assistant should list every mode and its corresponding hour count rather than giving a general average.
|
||||
|
||||
Make sure the extracted facts are in bullet points format to make it easier to read and understand.
|
||||
|
||||
## Output format
|
||||
Assistant should reply with a JSON object containing a key "extracted_facts" which is a string of the bulleted facts. Return only raw JSON without markdown formatting (no \`\`\`json blocks).
|
||||
|
||||
<example_output>
|
||||
{
|
||||
"extracted_facts": "- Fact 1\n- Fact 2\n- Fact 3"
|
||||
}
|
||||
</example_output>
|
||||
`;
|
||||
|
||||
const extractorSchema = z.object({
|
||||
extracted_facts: z
|
||||
.string()
|
||||
.describe(
|
||||
'The extracted facts that are relevant to the query and can help in answering the question should be listed here in a concise manner.',
|
||||
),
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
filteredResults.map(async (result, i) => {
|
||||
try {
|
||||
const scrapedData = await Scraper.scrape(result.metadata.url).catch(
|
||||
(err) => {
|
||||
console.log('Error scraping data from', result.metadata.url, err);
|
||||
},
|
||||
);
|
||||
|
||||
if (!scrapedData) return;
|
||||
|
||||
let accumulatedContent = '';
|
||||
const chunks = splitText(scrapedData.content, 4000, 500);
|
||||
|
||||
await Promise.all(
|
||||
chunks.map(async (chunk) => {
|
||||
try {
|
||||
const extractorOutput = await input.llm.generateObject<
|
||||
typeof extractorSchema
|
||||
>({
|
||||
schema: extractorSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: extractorPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<queries>${input.queries.join(', ')}</queries>\n<scraped_data>${chunk}</scraped_data>`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
accumulatedContent += extractorOutput.extracted_facts + '\n';
|
||||
} catch (err) {
|
||||
console.log('Error extracting information from chunk', err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
extractedFacts.push({
|
||||
...result,
|
||||
content: accumulatedContent,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'Error scraping or extracting information from',
|
||||
result.metadata.url,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return extractedFacts;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import z from 'zod';
|
||||
import { ResearchAction } from '../../../types';
|
||||
import { ResearchBlock } from '@/lib/types';
|
||||
import { executeSearch } from './baseSearch';
|
||||
|
||||
const schema = z.object({
|
||||
queries: z.array(z.string()).describe('List of social search queries'),
|
||||
});
|
||||
|
||||
const socialSearchDescription = `
|
||||
Use this tool to perform social media searches for relevant posts, discussions, and trends related to the user's query. Provide a list of concise search queries that will help gather comprehensive social media information on the topic at hand.
|
||||
You can provide up to 3 queries at a time. Make sure the queries are specific and relevant to the user's needs.
|
||||
|
||||
For example, if the user is interested in public opinion on electric vehicles, your queries could be:
|
||||
1. "Electric vehicles public opinion 2024"
|
||||
2. "Social media discussions on EV adoption"
|
||||
3. "Trends in electric vehicle usage"
|
||||
|
||||
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed social media information.
|
||||
`;
|
||||
|
||||
const socialSearchAction: ResearchAction<typeof schema> = {
|
||||
name: 'social_search',
|
||||
schema: schema,
|
||||
getDescription: () => socialSearchDescription,
|
||||
getToolDescription: () =>
|
||||
"Use this tool to perform social media searches for relevant posts, discussions, and trends related to the user's query. Provide a list of concise search queries that will help gather comprehensive social media information on the topic at hand.",
|
||||
enabled: (config) =>
|
||||
config.sources.includes('discussions') &&
|
||||
config.classification.classification.skipSearch === false &&
|
||||
config.classification.classification.discussionSearch === true,
|
||||
execute: async (input, additionalConfig) => {
|
||||
input.queries = (
|
||||
Array.isArray(input.queries) ? input.queries : [input.queries]
|
||||
).slice(0, 3);
|
||||
|
||||
const researchBlock = additionalConfig.session.getBlock(
|
||||
additionalConfig.researchBlockId,
|
||||
) as ResearchBlock | undefined;
|
||||
|
||||
if (!researchBlock) throw new Error('Failed to retrieve research block');
|
||||
|
||||
const results = await executeSearch({
|
||||
llm: additionalConfig.llm,
|
||||
embedding: additionalConfig.embedding,
|
||||
mode: additionalConfig.mode,
|
||||
queries: input.queries,
|
||||
researchBlock: researchBlock,
|
||||
session: additionalConfig.session,
|
||||
searchConfig: {
|
||||
engines: ['reddit'],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'search_results',
|
||||
results: results,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default socialSearchAction;
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from 'zod';
|
||||
import { ResearchAction } from '../../types';
|
||||
import { searchSearxng } from '@/lib/searxng';
|
||||
import { Chunk, SearchResultsResearchBlock } from '@/lib/types';
|
||||
import { ResearchAction } from '../../../types';
|
||||
import { ResearchBlock } from '@/lib/types';
|
||||
import { executeSearch } from './baseSearch';
|
||||
|
||||
const actionSchema = z.object({
|
||||
type: z.literal('web_search'),
|
||||
@@ -32,11 +32,11 @@ Start initially with broader queries to get an overview, then narrow down with m
|
||||
Your queries shouldn't be sentences but rather keywords that are SEO friendly and can be used to search the web for information.
|
||||
|
||||
For example if the user is asking about Tesla, your actions should be like:
|
||||
1. 0_reasoning "The user is asking about Tesla. I will start with broader queries to get an overview of Tesla, then narrow down with more specific queries based on the results I receive." then
|
||||
1. __reasoning_preamble "The user is asking about Tesla. I will start with broader queries to get an overview of Tesla, then narrow down with more specific queries based on the results I receive." then
|
||||
2. web_search ["Tesla", "Tesla latest news", "Tesla stock price"] then
|
||||
3. 0_reasoning "Based on the previous search results, I will now narrow down my queries to focus on Tesla's recent developments and stock performance." then
|
||||
3. __reasoning_preamble "Based on the previous search results, I will now narrow down my queries to focus on Tesla's recent developments and stock performance." then
|
||||
4. web_search ["Tesla Q2 2025 earnings", "Tesla new model 2025", "Tesla stock analysis"] then done.
|
||||
5. 0_reasoning "I have gathered enough information to provide a comprehensive answer."
|
||||
5. __reasoning_preamble "I have gathered enough information to provide a comprehensive answer."
|
||||
6. done.
|
||||
|
||||
You can search for 3 queries in one go, make sure to utilize all 3 queries to maximize the information you can gather. If a question is simple, then split your queries to cover different aspects or related topics to get a comprehensive understanding.
|
||||
@@ -82,98 +82,31 @@ const webSearchAction: ResearchAction<typeof actionSchema> = {
|
||||
return prompt;
|
||||
},
|
||||
enabled: (config) =>
|
||||
config.sources.includes('web') &&
|
||||
config.classification.classification.skipSearch === false,
|
||||
execute: async (input, additionalConfig) => {
|
||||
input.queries = input.queries.slice(0, 3);
|
||||
input.queries = (
|
||||
Array.isArray(input.queries) ? input.queries : [input.queries]
|
||||
).slice(0, 3);
|
||||
|
||||
const researchBlock = additionalConfig.session.getBlock(
|
||||
additionalConfig.researchBlockId,
|
||||
);
|
||||
) as ResearchBlock | undefined;
|
||||
|
||||
if (researchBlock && researchBlock.type === 'research') {
|
||||
researchBlock.data.subSteps.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'searching',
|
||||
searching: input.queries,
|
||||
});
|
||||
if (!researchBlock) throw new Error('Failed to retrieve research block');
|
||||
|
||||
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const searchResultsBlockId = crypto.randomUUID();
|
||||
let searchResultsEmitted = false;
|
||||
|
||||
let results: Chunk[] = [];
|
||||
|
||||
const search = async (q: string) => {
|
||||
const res = await searchSearxng(q);
|
||||
|
||||
const resultChunks: Chunk[] = res.results.map((r) => ({
|
||||
content: r.content || r.title,
|
||||
metadata: {
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
},
|
||||
}));
|
||||
|
||||
results.push(...resultChunks);
|
||||
|
||||
if (
|
||||
!searchResultsEmitted &&
|
||||
researchBlock &&
|
||||
researchBlock.type === 'research'
|
||||
) {
|
||||
searchResultsEmitted = true;
|
||||
|
||||
researchBlock.data.subSteps.push({
|
||||
id: searchResultsBlockId,
|
||||
type: 'search_results',
|
||||
reading: resultChunks,
|
||||
});
|
||||
|
||||
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
} else if (
|
||||
searchResultsEmitted &&
|
||||
researchBlock &&
|
||||
researchBlock.type === 'research'
|
||||
) {
|
||||
const subStepIndex = researchBlock.data.subSteps.findIndex(
|
||||
(step) => step.id === searchResultsBlockId,
|
||||
);
|
||||
|
||||
const subStep = researchBlock.data.subSteps[
|
||||
subStepIndex
|
||||
] as SearchResultsResearchBlock;
|
||||
|
||||
subStep.reading.push(...resultChunks);
|
||||
|
||||
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data/subSteps',
|
||||
value: researchBlock.data.subSteps,
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(input.queries.map(search));
|
||||
const results = await executeSearch({
|
||||
llm: additionalConfig.llm,
|
||||
embedding: additionalConfig.embedding,
|
||||
mode: additionalConfig.mode,
|
||||
queries: input.queries,
|
||||
researchBlock: researchBlock,
|
||||
session: additionalConfig.session,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'search_results',
|
||||
results,
|
||||
results: results,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,7 @@ class Researcher {
|
||||
classification: input.classification,
|
||||
fileIds: input.config.fileIds,
|
||||
mode: input.config.mode,
|
||||
sources: input.config.sources,
|
||||
});
|
||||
|
||||
const availableActionsDescription =
|
||||
@@ -30,6 +31,7 @@ class Researcher {
|
||||
classification: input.classification,
|
||||
fileIds: input.config.fileIds,
|
||||
mode: input.config.mode,
|
||||
sources: input.config.sources,
|
||||
});
|
||||
|
||||
const researchBlockId = crypto.randomUUID();
|
||||
@@ -85,7 +87,7 @@ class Researcher {
|
||||
if (partialRes.toolCallChunk.length > 0) {
|
||||
partialRes.toolCallChunk.forEach((tc) => {
|
||||
if (
|
||||
tc.name === '0_reasoning' &&
|
||||
tc.name === '__reasoning_preamble' &&
|
||||
tc.arguments['plan'] &&
|
||||
!reasoningEmitted &&
|
||||
block &&
|
||||
@@ -107,7 +109,7 @@ class Researcher {
|
||||
},
|
||||
]);
|
||||
} else if (
|
||||
tc.name === '0_reasoning' &&
|
||||
tc.name === '__reasoning_preamble' &&
|
||||
tc.arguments['plan'] &&
|
||||
reasoningEmitted &&
|
||||
block &&
|
||||
@@ -165,6 +167,7 @@ class Researcher {
|
||||
session: session,
|
||||
researchBlockId: researchBlockId,
|
||||
fileIds: input.config.fileIds,
|
||||
mode: input.config.mode,
|
||||
});
|
||||
|
||||
actionOutput.push(...actionResults);
|
||||
@@ -204,8 +207,9 @@ class Researcher {
|
||||
})
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
session.emit('data', {
|
||||
type: 'sources',
|
||||
session.emitBlock({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'source',
|
||||
data: filteredSearchResults,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@ export type SearchAgentConfig = {
|
||||
llm: BaseLLM<any>;
|
||||
embedding: BaseEmbedding<any>;
|
||||
mode: 'speed' | 'balanced' | 'quality';
|
||||
systemInstructions: string;
|
||||
};
|
||||
|
||||
export type SearchAgentInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
followUp: string;
|
||||
config: SearchAgentConfig;
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export type WidgetInput = {
|
||||
@@ -107,12 +110,14 @@ export interface ResearchAction<
|
||||
classification: ClassifierOutput;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
sources: SearchSources[];
|
||||
}) => boolean;
|
||||
execute: (
|
||||
params: z.infer<TSchema>,
|
||||
additionalConfig: AdditionalConfig & {
|
||||
researchBlockId: string;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
},
|
||||
) => Promise<ActionOutput>;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ const calculationWidget: Widget = {
|
||||
schema,
|
||||
});
|
||||
|
||||
if (output.notPresent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = mathEval(output.expression);
|
||||
|
||||
return {
|
||||
|
||||
@@ -90,7 +90,7 @@ const weatherWidget: Widget = {
|
||||
|
||||
const locationRes = await fetch(openStreetMapUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Perplexica',
|
||||
'User-Agent': 'Vane',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
@@ -109,7 +109,7 @@ const weatherWidget: Widget = {
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Perplexica',
|
||||
'User-Agent': 'Vane',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
@@ -143,7 +143,7 @@ const weatherWidget: Widget = {
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${params.lat}&longitude=${params.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Perplexica',
|
||||
'User-Agent': 'Vane',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
@@ -152,7 +152,7 @@ const weatherWidget: Widget = {
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Perplexica',
|
||||
'User-Agent': 'Vane',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import { suggestionGeneratorPrompt } from '@/lib/prompts/suggestions';
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
import z from 'zod';
|
||||
import BaseLLM from '@/lib/models/base/llm';
|
||||
import { i } from 'mathjs';
|
||||
|
||||
type SuggestionGeneratorInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'fs';
|
||||
import { Config, ConfigModelProvider, UIConfigSections } from './types';
|
||||
import { hashObj } from '../serverUtils';
|
||||
import { hashObj } from '../utils/hash';
|
||||
import { getModelProvidersUIConfigSection } from '../models/providers';
|
||||
|
||||
class ConfigManager {
|
||||
|
||||
@@ -45,6 +45,7 @@ fs.readdirSync(migrationsFolder)
|
||||
const already = db
|
||||
.prepare('SELECT 1 FROM ran_migrations WHERE name = ?')
|
||||
.get(migrationName);
|
||||
|
||||
if (already) {
|
||||
console.log(`Skipping already-applied migration: ${file}`);
|
||||
return;
|
||||
@@ -113,6 +114,160 @@ fs.readdirSync(migrationsFolder)
|
||||
|
||||
db.exec('DROP TABLE messages;');
|
||||
db.exec('ALTER TABLE messages_with_sources RENAME TO messages;');
|
||||
} else if (migrationName === '0002') {
|
||||
/* Migrate chat */
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chats_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
sources TEXT DEFAULT '[]',
|
||||
files TEXT DEFAULT '[]'
|
||||
);
|
||||
`);
|
||||
|
||||
const chats = db
|
||||
.prepare('SELECT id, title, createdAt, files FROM chats')
|
||||
.all();
|
||||
|
||||
const insertChat = db.prepare(`
|
||||
INSERT INTO chats_new (id, title, createdAt, sources, files)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
chats.forEach((chat: any) => {
|
||||
let files = chat.files;
|
||||
while (typeof files === 'string') {
|
||||
files = JSON.parse(files || '[]');
|
||||
}
|
||||
|
||||
insertChat.run(
|
||||
chat.id,
|
||||
chat.title,
|
||||
chat.createdAt,
|
||||
'["web"]',
|
||||
JSON.stringify(files),
|
||||
);
|
||||
});
|
||||
|
||||
db.exec('DROP TABLE chats;');
|
||||
db.exec('ALTER TABLE chats_new RENAME TO chats;');
|
||||
|
||||
/* Migrate messages */
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
messageId TEXT NOT NULL,
|
||||
chatId TEXT NOT NULL,
|
||||
backendId TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
responseBlocks TEXT DEFAULT '[]',
|
||||
status TEXT DEFAULT 'answering'
|
||||
);
|
||||
`);
|
||||
|
||||
const messages = db
|
||||
.prepare(
|
||||
'SELECT id, messageId, chatId, type, content, createdAt, sources FROM messages ORDER BY id ASC',
|
||||
)
|
||||
.all();
|
||||
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO messages_new (messageId, chatId, backendId, query, createdAt, responseBlocks, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
let currentMessageData: {
|
||||
sources?: any[];
|
||||
response?: string;
|
||||
query?: string;
|
||||
messageId?: string;
|
||||
chatId?: string;
|
||||
createdAt?: string;
|
||||
} = {};
|
||||
let lastCompleted = true;
|
||||
|
||||
messages.forEach((msg: any) => {
|
||||
if (msg.type === 'user' && lastCompleted) {
|
||||
currentMessageData = {};
|
||||
currentMessageData.messageId = msg.messageId;
|
||||
currentMessageData.chatId = msg.chatId;
|
||||
currentMessageData.query = msg.content;
|
||||
currentMessageData.createdAt = msg.createdAt;
|
||||
lastCompleted = false;
|
||||
} else if (msg.type === 'source' && !lastCompleted) {
|
||||
let sources = msg.sources;
|
||||
|
||||
while (typeof sources === 'string') {
|
||||
sources = JSON.parse(sources || '[]');
|
||||
}
|
||||
|
||||
currentMessageData.sources = sources;
|
||||
} else if (msg.type === 'assistant' && !lastCompleted) {
|
||||
currentMessageData.response = msg.content;
|
||||
insertMessage.run(
|
||||
currentMessageData.messageId,
|
||||
currentMessageData.chatId,
|
||||
`${currentMessageData.messageId}-backend`,
|
||||
currentMessageData.query,
|
||||
currentMessageData.createdAt,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'text',
|
||||
data: currentMessageData.response || '',
|
||||
},
|
||||
...(currentMessageData.sources &&
|
||||
currentMessageData.sources.length > 0
|
||||
? [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'source',
|
||||
data: currentMessageData.sources,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
'completed',
|
||||
);
|
||||
|
||||
lastCompleted = true;
|
||||
} else if (msg.type === 'user' && !lastCompleted) {
|
||||
/* Message wasn't completed so we'll just create the record with empty response */
|
||||
insertMessage.run(
|
||||
currentMessageData.messageId,
|
||||
currentMessageData.chatId,
|
||||
`${currentMessageData.messageId}-backend`,
|
||||
currentMessageData.query,
|
||||
currentMessageData.createdAt,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'text',
|
||||
data: '',
|
||||
},
|
||||
...(currentMessageData.sources &&
|
||||
currentMessageData.sources.length > 0
|
||||
? [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'source',
|
||||
data: currentMessageData.sources,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
'completed',
|
||||
);
|
||||
|
||||
lastCompleted = true;
|
||||
}
|
||||
});
|
||||
|
||||
db.exec('DROP TABLE messages;');
|
||||
db.exec('ALTER TABLE messages_new RENAME TO messages;');
|
||||
} else {
|
||||
// Execute each statement separately
|
||||
statements.forEach((stmt) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core';
|
||||
import { Block } from '../types';
|
||||
import { SearchSources } from '../agents/search/types';
|
||||
|
||||
export const messages = sqliteTable('messages', {
|
||||
id: integer('id').primaryKey(),
|
||||
@@ -26,7 +27,11 @@ export const chats = sqliteTable('chats', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
createdAt: text('createdAt').notNull(),
|
||||
focusMode: text('focusMode').notNull(),
|
||||
sources: text('sources', {
|
||||
mode: 'json',
|
||||
})
|
||||
.$type<SearchSources[]>()
|
||||
.default(sql`'[]'`),
|
||||
files: text('files', { mode: 'json' })
|
||||
.$type<DBFile[]>()
|
||||
.default(sql`'[]'`),
|
||||
|
||||
@@ -34,7 +34,7 @@ type ChatContext = {
|
||||
chatHistory: [string, string][];
|
||||
files: File[];
|
||||
fileIds: string[];
|
||||
focusMode: string;
|
||||
sources: string[];
|
||||
chatId: string | undefined;
|
||||
optimizationMode: string;
|
||||
isMessagesLoaded: boolean;
|
||||
@@ -48,7 +48,7 @@ type ChatContext = {
|
||||
researchEnded: boolean;
|
||||
setResearchEnded: (ended: boolean) => void;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
setFocusMode: (mode: string) => void;
|
||||
setSources: (sources: string[]) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
sendMessage: (
|
||||
@@ -175,8 +175,8 @@ const loadMessages = async (
|
||||
chatId: string,
|
||||
setMessages: (messages: Message[]) => void,
|
||||
setIsMessagesLoaded: (loaded: boolean) => void,
|
||||
setChatHistory: (history: [string, string][]) => void,
|
||||
setFocusMode: (mode: string) => void,
|
||||
chatHistory: React.MutableRefObject<[string, string][]>,
|
||||
setSources: (sources: string[]) => void,
|
||||
setNotFound: (notFound: boolean) => void,
|
||||
setFiles: (files: File[]) => void,
|
||||
setFileIds: (fileIds: string[]) => void,
|
||||
@@ -233,8 +233,8 @@ const loadMessages = async (
|
||||
setFiles(files);
|
||||
setFileIds(files.map((file: File) => file.fileId));
|
||||
|
||||
setChatHistory(history);
|
||||
setFocusMode(data.chat.focusMode);
|
||||
chatHistory.current = history;
|
||||
setSources(data.chat.sources);
|
||||
setIsMessagesLoaded(true);
|
||||
};
|
||||
|
||||
@@ -243,7 +243,7 @@ export const chatContext = createContext<ChatContext>({
|
||||
chatId: '',
|
||||
fileIds: [],
|
||||
files: [],
|
||||
focusMode: '',
|
||||
sources: [],
|
||||
hasError: false,
|
||||
isMessagesLoaded: false,
|
||||
isReady: false,
|
||||
@@ -260,7 +260,7 @@ export const chatContext = createContext<ChatContext>({
|
||||
sendMessage: async () => {},
|
||||
setFileIds: () => {},
|
||||
setFiles: () => {},
|
||||
setFocusMode: () => {},
|
||||
setSources: () => {},
|
||||
setOptimizationMode: () => {},
|
||||
setChatModelProvider: () => {},
|
||||
setEmbeddingModelProvider: () => {},
|
||||
@@ -269,6 +269,7 @@ export const chatContext = createContext<ChatContext>({
|
||||
|
||||
export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const params: { chatId: string } = useParams();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const initialMessage = searchParams.get('q');
|
||||
|
||||
@@ -280,13 +281,13 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
const [researchEnded, setResearchEnded] = useState(false);
|
||||
|
||||
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
|
||||
const chatHistory = useRef<[string, string][]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||
|
||||
const [focusMode, setFocusMode] = useState('webSearch');
|
||||
const [sources, setSources] = useState<string[]>(['web']);
|
||||
const [optimizationMode, setOptimizationMode] = useState('speed');
|
||||
|
||||
const [isMessagesLoaded, setIsMessagesLoaded] = useState(false);
|
||||
@@ -401,6 +402,64 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const isReconnectingRef = useRef(false);
|
||||
const handledMessageEndRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const checkReconnect = async () => {
|
||||
if (isReconnectingRef.current) return;
|
||||
|
||||
setIsReady(true);
|
||||
console.debug(new Date(), 'app:ready');
|
||||
|
||||
if (messages.length > 0) {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
|
||||
if (lastMsg.status === 'answering') {
|
||||
setLoading(true);
|
||||
setResearchEnded(false);
|
||||
setMessageAppeared(false);
|
||||
|
||||
isReconnectingRef.current = true;
|
||||
|
||||
const res = await fetch(`/api/reconnect/${lastMsg.backendId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No response body');
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
let partialChunk = '';
|
||||
|
||||
const messageHandler = getMessageHandler(lastMsg);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
partialChunk += decoder.decode(value, { stream: true });
|
||||
|
||||
try {
|
||||
const messages = partialChunk.split('\n');
|
||||
for (const msg of messages) {
|
||||
if (!msg.trim()) continue;
|
||||
const json = JSON.parse(msg);
|
||||
messageHandler(json);
|
||||
}
|
||||
partialChunk = '';
|
||||
} catch (error) {
|
||||
console.warn('Incomplete JSON, waiting for next chunk...');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isReconnectingRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkConfig(
|
||||
setChatModelProvider,
|
||||
@@ -415,7 +474,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
if (params.chatId && params.chatId !== chatId) {
|
||||
setChatId(params.chatId);
|
||||
setMessages([]);
|
||||
setChatHistory([]);
|
||||
chatHistory.current = [];
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
setIsMessagesLoaded(false);
|
||||
@@ -435,8 +494,8 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
chatId,
|
||||
setMessages,
|
||||
setIsMessagesLoaded,
|
||||
setChatHistory,
|
||||
setFocusMode,
|
||||
chatHistory,
|
||||
setSources,
|
||||
setNotFound,
|
||||
setFiles,
|
||||
setFileIds,
|
||||
@@ -454,13 +513,15 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMessagesLoaded && isConfigReady) {
|
||||
if (isMessagesLoaded && isConfigReady && newChatCreated) {
|
||||
setIsReady(true);
|
||||
console.debug(new Date(), 'app:ready');
|
||||
} else if (isMessagesLoaded && isConfigReady && !newChatCreated) {
|
||||
checkReconnect();
|
||||
} else {
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [isMessagesLoaded, isConfigReady]);
|
||||
}, [isMessagesLoaded, isConfigReady, newChatCreated]);
|
||||
|
||||
const rewrite = (messageId: string) => {
|
||||
const index = messages.findIndex((msg) => msg.messageId === messageId);
|
||||
@@ -469,9 +530,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
setMessages((prev) => prev.slice(0, index));
|
||||
|
||||
setChatHistory((prev) => {
|
||||
return prev.slice(0, index * 2);
|
||||
});
|
||||
chatHistory.current = chatHistory.current.slice(0, index * 2);
|
||||
|
||||
const messageToRewrite = messages[index];
|
||||
sendMessage(messageToRewrite.query, messageToRewrite.messageId, true);
|
||||
@@ -488,38 +547,10 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConfigReady, isReady, initialMessage]);
|
||||
|
||||
const sendMessage: ChatContext['sendMessage'] = async (
|
||||
message,
|
||||
messageId,
|
||||
rewrite = false,
|
||||
) => {
|
||||
if (loading || !message) return;
|
||||
setLoading(true);
|
||||
setResearchEnded(false);
|
||||
setMessageAppeared(false);
|
||||
const getMessageHandler = (message: Message) => {
|
||||
const messageId = message.messageId;
|
||||
|
||||
if (messages.length <= 1) {
|
||||
window.history.replaceState(null, '', `/c/${chatId}`);
|
||||
}
|
||||
|
||||
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
const backendId = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const newMessage: Message = {
|
||||
messageId,
|
||||
chatId: chatId!,
|
||||
backendId,
|
||||
query: message,
|
||||
responseBlocks: [],
|
||||
status: 'answering',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, newMessage]);
|
||||
|
||||
const receivedTextRef = { current: '' };
|
||||
|
||||
const messageHandler = async (data: any) => {
|
||||
return async (data: any) => {
|
||||
if (data.type === 'error') {
|
||||
toast.error(data.data);
|
||||
setLoading(false);
|
||||
@@ -536,7 +567,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
if (data.type === 'researchComplete') {
|
||||
setResearchEnded(true);
|
||||
if (
|
||||
newMessage.responseBlocks.find(
|
||||
message.responseBlocks.find(
|
||||
(b) => b.type === 'source' && b.data.length > 0,
|
||||
)
|
||||
) {
|
||||
@@ -548,6 +579,20 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.messageId === messageId) {
|
||||
const exists = msg.responseBlocks.findIndex(
|
||||
(b) => b.id === data.block.id,
|
||||
);
|
||||
|
||||
if (exists !== -1) {
|
||||
const existingBlocks = [...msg.responseBlocks];
|
||||
existingBlocks[exists] = data.block;
|
||||
|
||||
return {
|
||||
...msg,
|
||||
responseBlocks: existingBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...msg,
|
||||
responseBlocks: [...msg.responseBlocks, data.block],
|
||||
@@ -556,6 +601,13 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return msg;
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
(data.block.type === 'source' && data.block.data.length > 0) ||
|
||||
data.block.type === 'text'
|
||||
) {
|
||||
setMessageAppeared(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'updateBlock') {
|
||||
@@ -577,75 +629,28 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'sources') {
|
||||
const sourceBlock: Block = {
|
||||
id: crypto.randomBytes(7).toString('hex'),
|
||||
type: 'source',
|
||||
data: data.data,
|
||||
};
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.messageId === messageId) {
|
||||
return {
|
||||
...msg,
|
||||
responseBlocks: [...msg.responseBlocks, sourceBlock],
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
);
|
||||
if (data.data.length > 0) {
|
||||
setMessageAppeared(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'message') {
|
||||
receivedTextRef.current += data.data;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.messageId === messageId) {
|
||||
const existingTextBlockIndex = msg.responseBlocks.findIndex(
|
||||
(b) => b.type === 'text',
|
||||
);
|
||||
|
||||
if (existingTextBlockIndex >= 0) {
|
||||
const updatedBlocks = [...msg.responseBlocks];
|
||||
const existingBlock = updatedBlocks[
|
||||
existingTextBlockIndex
|
||||
] as Block & { type: 'text' };
|
||||
updatedBlocks[existingTextBlockIndex] = {
|
||||
...existingBlock,
|
||||
data: existingBlock.data + data.data,
|
||||
};
|
||||
return { ...msg, responseBlocks: updatedBlocks };
|
||||
} else {
|
||||
const textBlock: Block = {
|
||||
id: crypto.randomBytes(7).toString('hex'),
|
||||
type: 'text',
|
||||
data: data.data,
|
||||
};
|
||||
return {
|
||||
...msg,
|
||||
responseBlocks: [...msg.responseBlocks, textBlock],
|
||||
};
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
);
|
||||
setMessageAppeared(true);
|
||||
}
|
||||
|
||||
if (data.type === 'messageEnd') {
|
||||
if (handledMessageEndRef.current.has(messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledMessageEndRef.current.add(messageId);
|
||||
|
||||
const currentMsg = messagesRef.current.find(
|
||||
(msg) => msg.messageId === messageId,
|
||||
);
|
||||
|
||||
const newHistory: [string, string][] = [
|
||||
...chatHistory,
|
||||
['human', message],
|
||||
['assistant', receivedTextRef.current],
|
||||
...chatHistory.current,
|
||||
['human', message.query],
|
||||
[
|
||||
'assistant',
|
||||
currentMsg?.responseBlocks.find((b) => b.type === 'text')?.data ||
|
||||
'',
|
||||
],
|
||||
];
|
||||
|
||||
setChatHistory(newHistory);
|
||||
chatHistory.current = newHistory;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
@@ -662,19 +667,18 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const autoMediaSearch = getAutoMediaSearch();
|
||||
|
||||
if (autoMediaSearch) {
|
||||
document
|
||||
.getElementById(`search-images-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
setTimeout(() => {
|
||||
document
|
||||
.getElementById(`search-images-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
|
||||
document
|
||||
.getElementById(`search-videos-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
document
|
||||
.getElementById(`search-videos-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Check if there are sources and no suggestions
|
||||
const currentMsg = messagesRef.current.find(
|
||||
(msg) => msg.messageId === messageId,
|
||||
);
|
||||
|
||||
const hasSourceBlocks = currentMsg?.responseBlocks.some(
|
||||
(block) => block.type === 'source' && block.data.length > 0,
|
||||
@@ -705,6 +709,36 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage: ChatContext['sendMessage'] = async (
|
||||
message,
|
||||
messageId,
|
||||
rewrite = false,
|
||||
) => {
|
||||
if (loading || !message) return;
|
||||
setLoading(true);
|
||||
setResearchEnded(false);
|
||||
setMessageAppeared(false);
|
||||
|
||||
if (messages.length <= 1) {
|
||||
window.history.replaceState(null, '', `/c/${chatId}`);
|
||||
}
|
||||
|
||||
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
const backendId = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const newMessage: Message = {
|
||||
messageId,
|
||||
chatId: chatId!,
|
||||
backendId,
|
||||
query: message,
|
||||
responseBlocks: [],
|
||||
status: 'answering',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, newMessage]);
|
||||
|
||||
const messageIndex = messages.findIndex((m) => m.messageId === messageId);
|
||||
|
||||
@@ -722,11 +756,14 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
},
|
||||
chatId: chatId!,
|
||||
files: fileIds,
|
||||
focusMode: focusMode,
|
||||
sources: sources,
|
||||
optimizationMode: optimizationMode,
|
||||
history: rewrite
|
||||
? chatHistory.slice(0, messageIndex === -1 ? undefined : messageIndex)
|
||||
: chatHistory,
|
||||
? chatHistory.current.slice(
|
||||
0,
|
||||
messageIndex === -1 ? undefined : messageIndex,
|
||||
)
|
||||
: chatHistory.current,
|
||||
chatModel: {
|
||||
key: chatModelProvider.key,
|
||||
providerId: chatModelProvider.providerId,
|
||||
@@ -746,6 +783,8 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
let partialChunk = '';
|
||||
|
||||
const messageHandler = getMessageHandler(newMessage);
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -771,10 +810,10 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
value={{
|
||||
messages,
|
||||
sections,
|
||||
chatHistory,
|
||||
chatHistory: chatHistory.current,
|
||||
files,
|
||||
fileIds,
|
||||
focusMode,
|
||||
sources,
|
||||
chatId,
|
||||
hasError,
|
||||
isMessagesLoaded,
|
||||
@@ -785,7 +824,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
optimizationMode,
|
||||
setFileIds,
|
||||
setFiles,
|
||||
setFocusMode,
|
||||
setSources,
|
||||
setOptimizationMode,
|
||||
rewrite,
|
||||
sendMessage,
|
||||
|
||||
5
src/lib/models/providers/anthropic/anthropicLLM.ts
Normal file
5
src/lib/models/providers/anthropic/anthropicLLM.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAILLM from '../openai/openaiLLM';
|
||||
|
||||
class AnthropicLLM extends OpenAILLM {}
|
||||
|
||||
export default AnthropicLLM;
|
||||
115
src/lib/models/providers/anthropic/index.ts
Normal file
115
src/lib/models/providers/anthropic/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
import { Model, ModelList, ProviderMetadata } from '../../types';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import BaseModelProvider from '../../base/provider';
|
||||
import BaseLLM from '../../base/llm';
|
||||
import AnthropicLLM from './anthropicLLM';
|
||||
|
||||
interface AnthropicConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'password',
|
||||
name: 'API Key',
|
||||
key: 'apiKey',
|
||||
description: 'Your Anthropic API key',
|
||||
required: true,
|
||||
placeholder: 'Anthropic API Key',
|
||||
env: 'ANTHROPIC_API_KEY',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class AnthropicProvider extends BaseModelProvider<AnthropicConfig> {
|
||||
constructor(id: string, name: string, config: AnthropicConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
const res = await fetch('https://api.anthropic.com/v1/models?limit=999', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-api-key': this.config.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Anthropic models: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()).data;
|
||||
|
||||
const models: Model[] = data.map((m: any) => {
|
||||
return {
|
||||
key: m.id,
|
||||
name: m.display_name,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
embedding: [],
|
||||
chat: models,
|
||||
};
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseLLM<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Anthropic Chat Model. Invalid Model Selected',
|
||||
);
|
||||
}
|
||||
|
||||
return new AnthropicLLM({
|
||||
apiKey: this.config.apiKey,
|
||||
model: key,
|
||||
baseURL: 'https://api.anthropic.com/v1',
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
|
||||
throw new Error('Anthropic provider does not support embedding models.');
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): AnthropicConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.apiKey)
|
||||
throw new Error('Invalid config provided. API key must be provided');
|
||||
|
||||
return {
|
||||
apiKey: String(raw.apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default AnthropicProvider;
|
||||
5
src/lib/models/providers/gemini/geminiEmbedding.ts
Normal file
5
src/lib/models/providers/gemini/geminiEmbedding.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAIEmbedding from '../openai/openaiEmbedding';
|
||||
|
||||
class GeminiEmbedding extends OpenAIEmbedding {}
|
||||
|
||||
export default GeminiEmbedding;
|
||||
5
src/lib/models/providers/gemini/geminiLLM.ts
Normal file
5
src/lib/models/providers/gemini/geminiLLM.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAILLM from '../openai/openaiLLM';
|
||||
|
||||
class GeminiLLM extends OpenAILLM {}
|
||||
|
||||
export default GeminiLLM;
|
||||
144
src/lib/models/providers/gemini/index.ts
Normal file
144
src/lib/models/providers/gemini/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
import { Model, ModelList, ProviderMetadata } from '../../types';
|
||||
import GeminiEmbedding from './geminiEmbedding';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import BaseModelProvider from '../../base/provider';
|
||||
import BaseLLM from '../../base/llm';
|
||||
import GeminiLLM from './geminiLLM';
|
||||
|
||||
interface GeminiConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'password',
|
||||
name: 'API Key',
|
||||
key: 'apiKey',
|
||||
description: 'Your Gemini API key',
|
||||
required: true,
|
||||
placeholder: 'Gemini API Key',
|
||||
env: 'GEMINI_API_KEY',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class GeminiProvider extends BaseModelProvider<GeminiConfig> {
|
||||
constructor(id: string, name: string, config: GeminiConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${this.config.apiKey}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
let defaultEmbeddingModels: Model[] = [];
|
||||
let defaultChatModels: Model[] = [];
|
||||
|
||||
data.models.forEach((m: any) => {
|
||||
if (
|
||||
m.supportedGenerationMethods.some(
|
||||
(genMethod: string) =>
|
||||
genMethod === 'embedText' || genMethod === 'embedContent',
|
||||
)
|
||||
) {
|
||||
defaultEmbeddingModels.push({
|
||||
key: m.name,
|
||||
name: m.displayName,
|
||||
});
|
||||
} else if (m.supportedGenerationMethods.includes('generateContent')) {
|
||||
defaultChatModels.push({
|
||||
key: m.name,
|
||||
name: m.displayName,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
embedding: defaultEmbeddingModels,
|
||||
chat: defaultChatModels,
|
||||
};
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseLLM<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Gemini Chat Model. Invalid Model Selected',
|
||||
);
|
||||
}
|
||||
|
||||
return new GeminiLLM({
|
||||
apiKey: this.config.apiKey,
|
||||
model: key,
|
||||
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
const exists = modelList.embedding.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Gemini Embedding Model. Invalid Model Selected.',
|
||||
);
|
||||
}
|
||||
|
||||
return new GeminiEmbedding({
|
||||
apiKey: this.config.apiKey,
|
||||
model: key,
|
||||
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
});
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): GeminiConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.apiKey)
|
||||
throw new Error('Invalid config provided. API key must be provided');
|
||||
|
||||
return {
|
||||
apiKey: String(raw.apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'gemini',
|
||||
name: 'Gemini',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default GeminiProvider;
|
||||
5
src/lib/models/providers/groq/groqLLM.ts
Normal file
5
src/lib/models/providers/groq/groqLLM.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAILLM from '../openai/openaiLLM';
|
||||
|
||||
class GroqLLM extends OpenAILLM {}
|
||||
|
||||
export default GroqLLM;
|
||||
113
src/lib/models/providers/groq/index.ts
Normal file
113
src/lib/models/providers/groq/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
import { Model, ModelList, ProviderMetadata } from '../../types';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import BaseModelProvider from '../../base/provider';
|
||||
import BaseLLM from '../../base/llm';
|
||||
import GroqLLM from './groqLLM';
|
||||
|
||||
interface GroqConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'password',
|
||||
name: 'API Key',
|
||||
key: 'apiKey',
|
||||
description: 'Your Groq API key',
|
||||
required: true,
|
||||
placeholder: 'Groq API Key',
|
||||
env: 'GROQ_API_KEY',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class GroqProvider extends BaseModelProvider<GroqConfig> {
|
||||
constructor(id: string, name: string, config: GroqConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
const res = await fetch(`https://api.groq.com/openai/v1/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const defaultChatModels: Model[] = [];
|
||||
|
||||
data.data.forEach((m: any) => {
|
||||
defaultChatModels.push({
|
||||
key: m.id,
|
||||
name: m.id,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
embedding: [],
|
||||
chat: defaultChatModels,
|
||||
};
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseLLM<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error('Error Loading Groq Chat Model. Invalid Model Selected');
|
||||
}
|
||||
|
||||
return new GroqLLM({
|
||||
apiKey: this.config.apiKey,
|
||||
model: key,
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
|
||||
throw new Error('Groq Provider does not support embedding models.');
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): GroqConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.apiKey)
|
||||
throw new Error('Invalid config provided. API key must be provided');
|
||||
|
||||
return {
|
||||
apiKey: String(raw.apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'groq',
|
||||
name: 'Groq',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default GroqProvider;
|
||||
@@ -2,10 +2,22 @@ import { ModelProviderUISection } from '@/lib/config/types';
|
||||
import { ProviderConstructor } from '../base/provider';
|
||||
import OpenAIProvider from './openai';
|
||||
import OllamaProvider from './ollama';
|
||||
import GeminiProvider from './gemini';
|
||||
import TransformersProvider from './transformers';
|
||||
import GroqProvider from './groq';
|
||||
import LemonadeProvider from './lemonade';
|
||||
import AnthropicProvider from './anthropic';
|
||||
import LMStudioProvider from './lmstudio';
|
||||
|
||||
export const providers: Record<string, ProviderConstructor<any>> = {
|
||||
openai: OpenAIProvider,
|
||||
ollama: OllamaProvider,
|
||||
gemini: GeminiProvider,
|
||||
transformers: TransformersProvider,
|
||||
groq: GroqProvider,
|
||||
lemonade: LemonadeProvider,
|
||||
anthropic: AnthropicProvider,
|
||||
lmstudio: LMStudioProvider,
|
||||
};
|
||||
|
||||
export const getModelProvidersUIConfigSection =
|
||||
|
||||
153
src/lib/models/providers/lemonade/index.ts
Normal file
153
src/lib/models/providers/lemonade/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
import BaseModelProvider from '../../base/provider';
|
||||
import { Model, ModelList, ProviderMetadata } from '../../types';
|
||||
import BaseLLM from '../../base/llm';
|
||||
import LemonadeLLM from './lemonadeLLM';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import LemonadeEmbedding from './lemonadeEmbedding';
|
||||
|
||||
interface LemonadeConfig {
|
||||
baseURL: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'Base URL',
|
||||
key: 'baseURL',
|
||||
description: 'The base URL for Lemonade API',
|
||||
required: true,
|
||||
placeholder: 'https://api.lemonade.ai/v1',
|
||||
env: 'LEMONADE_BASE_URL',
|
||||
scope: 'server',
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
name: 'API Key',
|
||||
key: 'apiKey',
|
||||
description: 'Your Lemonade API key (optional)',
|
||||
required: false,
|
||||
placeholder: 'Lemonade API Key',
|
||||
env: 'LEMONADE_API_KEY',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class LemonadeProvider extends BaseModelProvider<LemonadeConfig> {
|
||||
constructor(id: string, name: string, config: LemonadeConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
try {
|
||||
const res = await fetch(`${this.config.baseURL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.config.apiKey
|
||||
? { Authorization: `Bearer ${this.config.apiKey}` }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const models: Model[] = data.data
|
||||
.filter((m: any) => m.recipe === 'llamacpp')
|
||||
.map((m: any) => {
|
||||
return {
|
||||
name: m.id,
|
||||
key: m.id,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
embedding: models,
|
||||
chat: models,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
throw new Error(
|
||||
'Error connecting to Lemonade API. Please ensure the base URL is correct and the service is available.',
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseLLM<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Lemonade Chat Model. Invalid Model Selected',
|
||||
);
|
||||
}
|
||||
|
||||
return new LemonadeLLM({
|
||||
apiKey: this.config.apiKey || 'not-needed',
|
||||
model: key,
|
||||
baseURL: this.config.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
const exists = modelList.embedding.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Lemonade Embedding Model. Invalid Model Selected.',
|
||||
);
|
||||
}
|
||||
|
||||
return new LemonadeEmbedding({
|
||||
apiKey: this.config.apiKey || 'not-needed',
|
||||
model: key,
|
||||
baseURL: this.config.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): LemonadeConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.baseURL)
|
||||
throw new Error('Invalid config provided. Base URL must be provided');
|
||||
|
||||
return {
|
||||
baseURL: String(raw.baseURL),
|
||||
apiKey: raw.apiKey ? String(raw.apiKey) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'lemonade',
|
||||
name: 'Lemonade',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default LemonadeProvider;
|
||||
5
src/lib/models/providers/lemonade/lemonadeEmbedding.ts
Normal file
5
src/lib/models/providers/lemonade/lemonadeEmbedding.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAIEmbedding from '../openai/openaiEmbedding';
|
||||
|
||||
class LemonadeEmbedding extends OpenAIEmbedding {}
|
||||
|
||||
export default LemonadeEmbedding;
|
||||
5
src/lib/models/providers/lemonade/lemonadeLLM.ts
Normal file
5
src/lib/models/providers/lemonade/lemonadeLLM.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAILLM from '../openai/openaiLLM';
|
||||
|
||||
class LemonadeLLM extends OpenAILLM {}
|
||||
|
||||
export default LemonadeLLM;
|
||||
143
src/lib/models/providers/lmstudio/index.ts
Normal file
143
src/lib/models/providers/lmstudio/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
import BaseModelProvider from '../../base/provider';
|
||||
import { Model, ModelList, ProviderMetadata } from '../../types';
|
||||
import LMStudioLLM from './lmstudioLLM';
|
||||
import BaseLLM from '../../base/llm';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import LMStudioEmbedding from './lmstudioEmbedding';
|
||||
|
||||
interface LMStudioConfig {
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'Base URL',
|
||||
key: 'baseURL',
|
||||
description: 'The base URL for LM Studio server',
|
||||
required: true,
|
||||
placeholder: 'http://localhost:1234',
|
||||
env: 'LM_STUDIO_BASE_URL',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class LMStudioProvider extends BaseModelProvider<LMStudioConfig> {
|
||||
constructor(id: string, name: string, config: LMStudioConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
private normalizeBaseURL(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/, '');
|
||||
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
try {
|
||||
const baseURL = this.normalizeBaseURL(this.config.baseURL);
|
||||
|
||||
const res = await fetch(`${baseURL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const models: Model[] = data.data.map((m: any) => {
|
||||
return {
|
||||
name: m.id,
|
||||
key: m.id,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
embedding: models,
|
||||
chat: models,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
throw new Error(
|
||||
'Error connecting to LM Studio. Please ensure the base URL is correct and the LM Studio server is running.',
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseLLM<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading LM Studio Chat Model. Invalid Model Selected',
|
||||
);
|
||||
}
|
||||
|
||||
return new LMStudioLLM({
|
||||
apiKey: 'lm-studio',
|
||||
model: key,
|
||||
baseURL: this.normalizeBaseURL(this.config.baseURL),
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
const exists = modelList.embedding.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading LM Studio Embedding Model. Invalid Model Selected.',
|
||||
);
|
||||
}
|
||||
|
||||
return new LMStudioEmbedding({
|
||||
apiKey: 'lm-studio',
|
||||
model: key,
|
||||
baseURL: this.normalizeBaseURL(this.config.baseURL),
|
||||
});
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): LMStudioConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.baseURL)
|
||||
throw new Error('Invalid config provided. Base URL must be provided');
|
||||
|
||||
return {
|
||||
baseURL: String(raw.baseURL),
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'lmstudio',
|
||||
name: 'LM Studio',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default LMStudioProvider;
|
||||
5
src/lib/models/providers/lmstudio/lmstudioEmbedding.ts
Normal file
5
src/lib/models/providers/lmstudio/lmstudioEmbedding.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAIEmbedding from '../openai/openaiEmbedding';
|
||||
|
||||
class LMStudioEmbedding extends OpenAIEmbedding {}
|
||||
|
||||
export default LMStudioEmbedding;
|
||||
5
src/lib/models/providers/lmstudio/lmstudioLLM.ts
Normal file
5
src/lib/models/providers/lmstudio/lmstudioLLM.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import OpenAILLM from '../openai/openaiLLM';
|
||||
|
||||
class LMStudioLLM extends OpenAILLM {}
|
||||
|
||||
export default LMStudioLLM;
|
||||
@@ -11,6 +11,7 @@ import { Ollama, Tool as OllamaTool, Message as OllamaMessage } from 'ollama';
|
||||
import { parse } from 'partial-json';
|
||||
import crypto from 'crypto';
|
||||
import { Message } from '@/lib/types';
|
||||
import { repairJson } from '@toolsycc/json-repair';
|
||||
|
||||
type OllamaConfig = {
|
||||
baseURL: string;
|
||||
@@ -24,6 +25,9 @@ const reasoningModels = [
|
||||
'qwen3',
|
||||
'deepseek-v3.1',
|
||||
'magistral',
|
||||
'nemotron-3',
|
||||
'nemotron-cascade-2',
|
||||
'glm-4.7-flash',
|
||||
];
|
||||
|
||||
class OllamaLLM extends BaseLLM<OllamaConfig> {
|
||||
@@ -204,7 +208,13 @@ class OllamaLLM extends BaseLLM<OllamaConfig> {
|
||||
});
|
||||
|
||||
try {
|
||||
return input.schema.parse(JSON.parse(response.message.content)) as T;
|
||||
return input.schema.parse(
|
||||
JSON.parse(
|
||||
repairJson(response.message.content, {
|
||||
extractJson: true,
|
||||
}) as string,
|
||||
),
|
||||
) as T;
|
||||
} catch (err) {
|
||||
throw new Error(`Error parsing response from Ollama: ${err}`);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,22 @@ const defaultChatModels: Model[] = [
|
||||
name: 'GPT 5 Mini',
|
||||
key: 'gpt-5-mini',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5 Pro',
|
||||
key: 'gpt-5-pro',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5.1',
|
||||
key: 'gpt-5.1',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5.2',
|
||||
key: 'gpt-5.2',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5.2 Pro',
|
||||
key: 'gpt-5.2-pro',
|
||||
},
|
||||
{
|
||||
name: 'o1',
|
||||
key: 'o1',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ChatCompletionToolMessageParam,
|
||||
} from 'openai/resources/index.mjs';
|
||||
import { Message } from '@/lib/types';
|
||||
import { repairJson } from '@toolsycc/json-repair';
|
||||
|
||||
type OpenAIConfig = {
|
||||
apiKey: string;
|
||||
@@ -167,7 +168,7 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> {
|
||||
contentChunk: chunk.choices[0].delta.content || '',
|
||||
toolCallChunk:
|
||||
toolCalls?.map((tc) => {
|
||||
if (tc.type === 'function') {
|
||||
if (!recievedToolCalls[tc.index]) {
|
||||
const call = {
|
||||
name: tc.function?.name!,
|
||||
id: tc.id!,
|
||||
@@ -213,7 +214,13 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> {
|
||||
|
||||
if (response.choices && response.choices.length > 0) {
|
||||
try {
|
||||
return input.schema.parse(response.choices[0].message.parsed) as T;
|
||||
return input.schema.parse(
|
||||
JSON.parse(
|
||||
repairJson(response.choices[0].message.content!, {
|
||||
extractJson: true,
|
||||
}) as string,
|
||||
),
|
||||
) as T;
|
||||
} catch (err) {
|
||||
throw new Error(`Error parsing response from OpenAI: ${err}`);
|
||||
}
|
||||
|
||||
88
src/lib/models/providers/transformers/index.ts
Normal file
88
src/lib/models/providers/transformers/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
import { Model, ModelList, ProviderMetadata } from '../../types';
|
||||
import BaseModelProvider from '../../base/provider';
|
||||
import BaseLLM from '../../base/llm';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import TransformerEmbedding from './transformerEmbedding';
|
||||
|
||||
interface TransformersConfig {}
|
||||
|
||||
const defaultEmbeddingModels: Model[] = [
|
||||
{
|
||||
name: 'all-MiniLM-L6-v2',
|
||||
key: 'Xenova/all-MiniLM-L6-v2',
|
||||
},
|
||||
{
|
||||
name: 'mxbai-embed-large-v1',
|
||||
key: 'mixedbread-ai/mxbai-embed-large-v1',
|
||||
},
|
||||
{
|
||||
name: 'nomic-embed-text-v1',
|
||||
key: 'Xenova/nomic-embed-text-v1',
|
||||
},
|
||||
];
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [];
|
||||
|
||||
class TransformersProvider extends BaseModelProvider<TransformersConfig> {
|
||||
constructor(id: string, name: string, config: TransformersConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
return {
|
||||
embedding: [...defaultEmbeddingModels],
|
||||
chat: [],
|
||||
};
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseLLM<any>> {
|
||||
throw new Error('Transformers Provider does not support chat models.');
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
|
||||
const modelList = await this.getModelList();
|
||||
const exists = modelList.embedding.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading OpenAI Embedding Model. Invalid Model Selected.',
|
||||
);
|
||||
}
|
||||
|
||||
return new TransformerEmbedding({
|
||||
model: key,
|
||||
});
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): TransformersConfig {
|
||||
return {};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'transformers',
|
||||
name: 'Transformers',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default TransformersProvider;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Chunk } from '@/lib/types';
|
||||
import BaseEmbedding from '../../base/embedding';
|
||||
import { FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
|
||||
type TransformerConfig = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
class TransformerEmbedding extends BaseEmbedding<TransformerConfig> {
|
||||
private pipelinePromise: Promise<FeatureExtractionPipeline> | null = null;
|
||||
|
||||
constructor(protected config: TransformerConfig) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
async embedText(texts: string[]): Promise<number[][]> {
|
||||
return this.embed(texts);
|
||||
}
|
||||
|
||||
async embedChunks(chunks: Chunk[]): Promise<number[][]> {
|
||||
return this.embed(chunks.map((c) => c.content));
|
||||
}
|
||||
|
||||
private async embed(texts: string[]) {
|
||||
if (!this.pipelinePromise) {
|
||||
this.pipelinePromise = (async () => {
|
||||
const { pipeline } = await import('@huggingface/transformers');
|
||||
const result = await pipeline('feature-extraction', this.config.model, {
|
||||
dtype: 'fp32',
|
||||
});
|
||||
return result as FeatureExtractionPipeline;
|
||||
})();
|
||||
}
|
||||
|
||||
const pipe = await this.pipelinePromise;
|
||||
const output = await pipe(texts, { pooling: 'mean', normalize: true });
|
||||
return output.tolist() as number[][];
|
||||
}
|
||||
}
|
||||
|
||||
export default TransformerEmbedding;
|
||||
@@ -3,6 +3,7 @@ import { ChatTurnMessage } from '@/lib/types';
|
||||
export const imageSearchPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search the web for images.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
Make sure to make the querey standalone and not something very broad, use context from the answers in the conversation to make it specific so user can get best image search results.
|
||||
Output only the rephrased query in query key JSON format. Do not include any explanation or additional text.
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChatTurnMessage } from '@/lib/types';
|
||||
export const videoSearchPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
Make sure to make the querey standalone and not something very broad, use context from the answers in the conversation to make it specific so user can get best video search results.
|
||||
Output only the rephrased query in query key JSON format. Do not include any explanation or additional text.
|
||||
`;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user