mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2026-04-10 13:54:28 +00:00
Compare commits
213 Commits
0a62c60da2
...
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 | ||
|
|
ca4809f0f2 | ||
|
|
3d1d164f68 | ||
|
|
a99702d837 | ||
|
|
60675955e4 | ||
|
|
a6ff94d030 | ||
|
|
748ee4d3c2 | ||
|
|
1f3bf8da32 | ||
|
|
8d471ac40e | ||
|
|
40b25a487b | ||
|
|
3949748bbd | ||
|
|
56e47d6c39 | ||
|
|
fd745577d6 | ||
|
|
86ea3cde7e | ||
|
|
aeb90cb137 | ||
|
|
6473e51fde | ||
|
|
c7c327a7bb | ||
|
|
0688630863 | ||
|
|
0b9e193ed1 | ||
|
|
8d1b04e05f | ||
|
|
ff4cf98b50 | ||
|
|
13ae0b9451 | ||
|
|
0cfa01422c | ||
|
|
fdaa2f0646 | ||
|
|
fc0c444b6a | ||
|
|
01b537ade1 | ||
|
|
3bffc72422 | ||
|
|
6016090f12 | ||
|
|
8aed9518a2 | ||
|
|
2df6250ba1 | ||
|
|
85f6c3b901 | ||
|
|
96001a9e26 | ||
|
|
331387efa4 | ||
|
|
d0e71e6482 | ||
|
|
e329820bc8 | ||
|
|
5174820554 | ||
|
|
1c3a5fe275 | ||
|
|
d0124b9f06 | ||
|
|
a14f3e9464 | ||
|
|
9afea48d31 | ||
|
|
2d82cd65d9 | ||
|
|
97838fd693 | ||
|
|
8ab675b119 | ||
|
|
5e3001756b | ||
|
|
4c4c1d1930 | ||
|
|
3c524b0f98 | ||
|
|
e99c8bdd50 | ||
|
|
574b3d55e2 | ||
|
|
f2f2af9451 | ||
|
|
65ef299d72 | ||
|
|
4fc810d976 | ||
|
|
a548fd694a | ||
|
|
2c61f47088 | ||
|
|
1c0e90c8e0 | ||
|
|
ee5d9172a4 | ||
|
|
c35b684dc5 | ||
|
|
046f159528 | ||
|
|
6899b49ca0 | ||
|
|
dbc2137efb | ||
|
|
1ea348ddb7 | ||
|
|
b8a7fb936f | ||
|
|
33c8f454a3 | ||
|
|
3e90305c12 | ||
|
|
41c879cd86 | ||
|
|
9b3833f933 | ||
|
|
610d06be36 | ||
|
|
7757bbd253 | ||
|
|
e2a371936b | ||
|
|
5901a965f7 | ||
|
|
6150784c27 | ||
|
|
cb30e2438a | ||
|
|
ead2a5b215 | ||
|
|
1df4d886ff | ||
|
|
2574287fa8 | ||
|
|
3005b379cf | ||
|
|
f83bd06e89 | ||
|
|
7544bbafaf | ||
|
|
9934c1dbe0 | ||
|
|
f767717d7f | ||
|
|
e88e1c627c |
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 }}
|
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||||
VARIANT=${{ matrix.variant.name }}
|
VARIANT=${{ matrix.variant.name }}
|
||||||
docker buildx build --platform linux/amd64 \
|
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 \
|
--cache-to=type=inline \
|
||||||
--provenance false \
|
--provenance false \
|
||||||
-f $DOCKERFILE \
|
-f $DOCKERFILE \
|
||||||
-t itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
-t itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
- name: Build and push AMD64 Canary Docker image
|
- name: Build and push AMD64 Canary Docker image
|
||||||
@@ -57,11 +57,11 @@ jobs:
|
|||||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||||
VARIANT=${{ matrix.variant.name }}
|
VARIANT=${{ matrix.variant.name }}
|
||||||
docker buildx build --platform linux/amd64 \
|
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 \
|
--cache-to=type=inline \
|
||||||
--provenance false \
|
--provenance false \
|
||||||
-f $DOCKERFILE \
|
-f $DOCKERFILE \
|
||||||
-t itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \
|
-t itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
- name: Build and push AMD64 release Docker image
|
- name: Build and push AMD64 release Docker image
|
||||||
@@ -70,11 +70,11 @@ jobs:
|
|||||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||||
VARIANT=${{ matrix.variant.name }}
|
VARIANT=${{ matrix.variant.name }}
|
||||||
docker buildx build --platform linux/amd64 \
|
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 \
|
--cache-to=type=inline \
|
||||||
--provenance false \
|
--provenance false \
|
||||||
-f $DOCKERFILE \
|
-f $DOCKERFILE \
|
||||||
-t itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
-t itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
build-arm64:
|
build-arm64:
|
||||||
@@ -112,11 +112,11 @@ jobs:
|
|||||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||||
VARIANT=${{ matrix.variant.name }}
|
VARIANT=${{ matrix.variant.name }}
|
||||||
docker buildx build --platform linux/arm64 \
|
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 \
|
--cache-to=type=inline \
|
||||||
--provenance false \
|
--provenance false \
|
||||||
-f $DOCKERFILE \
|
-f $DOCKERFILE \
|
||||||
-t itzcrazykns1337/perplexica:${VARIANT}-arm64 \
|
-t itzcrazykns1337/vane:${VARIANT}-arm64 \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
- name: Build and push ARM64 Canary Docker image
|
- name: Build and push ARM64 Canary Docker image
|
||||||
@@ -125,11 +125,11 @@ jobs:
|
|||||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||||
VARIANT=${{ matrix.variant.name }}
|
VARIANT=${{ matrix.variant.name }}
|
||||||
docker buildx build --platform linux/arm64 \
|
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 \
|
--cache-to=type=inline \
|
||||||
--provenance false \
|
--provenance false \
|
||||||
-f $DOCKERFILE \
|
-f $DOCKERFILE \
|
||||||
-t itzcrazykns1337/perplexica:${VARIANT}-canary-arm64 \
|
-t itzcrazykns1337/vane:${VARIANT}-canary-arm64 \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
- name: Build and push ARM64 release Docker image
|
- name: Build and push ARM64 release Docker image
|
||||||
@@ -138,11 +138,11 @@ jobs:
|
|||||||
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
DOCKERFILE=${{ matrix.variant.dockerfile }}
|
||||||
VARIANT=${{ matrix.variant.name }}
|
VARIANT=${{ matrix.variant.name }}
|
||||||
docker buildx build --platform linux/arm64 \
|
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 \
|
--cache-to=type=inline \
|
||||||
--provenance false \
|
--provenance false \
|
||||||
-f $DOCKERFILE \
|
-f $DOCKERFILE \
|
||||||
-t itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \
|
-t itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64 \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
manifest:
|
manifest:
|
||||||
@@ -167,51 +167,51 @@ jobs:
|
|||||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
VARIANT=${{ matrix.variant }}
|
VARIANT=${{ matrix.variant }}
|
||||||
docker manifest create itzcrazykns1337/perplexica:${VARIANT}-latest \
|
docker manifest create itzcrazykns1337/vane:${VARIANT}-latest \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:${VARIANT}-latest
|
docker manifest push itzcrazykns1337/vane:${VARIANT}-latest
|
||||||
|
|
||||||
if [ "$VARIANT" = "full" ]; then
|
if [ "$VARIANT" = "full" ]; then
|
||||||
docker manifest create itzcrazykns1337/perplexica:latest \
|
docker manifest create itzcrazykns1337/vane:latest \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:latest
|
docker manifest push itzcrazykns1337/vane:latest
|
||||||
|
|
||||||
docker manifest create itzcrazykns1337/perplexica:main \
|
docker manifest create itzcrazykns1337/vane:main \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:main
|
docker manifest push itzcrazykns1337/vane:main
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create and push manifest for canary
|
- name: Create and push manifest for canary
|
||||||
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
VARIANT=${{ matrix.variant }}
|
VARIANT=${{ matrix.variant }}
|
||||||
docker manifest create itzcrazykns1337/perplexica:${VARIANT}-canary \
|
docker manifest create itzcrazykns1337/vane:${VARIANT}-canary \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-canary-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-canary-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:${VARIANT}-canary
|
docker manifest push itzcrazykns1337/vane:${VARIANT}-canary
|
||||||
|
|
||||||
if [ "$VARIANT" = "full" ]; then
|
if [ "$VARIANT" = "full" ]; then
|
||||||
docker manifest create itzcrazykns1337/perplexica:canary \
|
docker manifest create itzcrazykns1337/vane:canary \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-canary-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-canary-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-canary-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-canary-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:canary
|
docker manifest push itzcrazykns1337/vane:canary
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create and push manifest for releases
|
- name: Create and push manifest for releases
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
run: |
|
run: |
|
||||||
VARIANT=${{ matrix.variant }}
|
VARIANT=${{ matrix.variant }}
|
||||||
docker manifest create itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }} \
|
docker manifest create itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }} \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}
|
docker manifest push itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}
|
||||||
|
|
||||||
if [ "$VARIANT" = "full" ]; then
|
if [ "$VARIANT" = "full" ]; then
|
||||||
docker manifest create itzcrazykns1337/perplexica:${{ env.RELEASE_VERSION }} \
|
docker manifest create itzcrazykns1337/vane:${{ env.RELEASE_VERSION }} \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-amd64 \
|
||||||
--amend itzcrazykns1337/perplexica:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64
|
--amend itzcrazykns1337/vane:${VARIANT}-${{ env.RELEASE_VERSION }}-arm64
|
||||||
docker manifest push itzcrazykns1337/perplexica:${{ env.RELEASE_VERSION }}
|
docker manifest push itzcrazykns1337/vane:${{ env.RELEASE_VERSION }}
|
||||||
fi
|
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
|
## Project Structure
|
||||||
|
|
||||||
Perplexica's codebase is organized as follows:
|
Vane's codebase is organized as follows:
|
||||||
|
|
||||||
- **UI Components and Pages**:
|
- **UI Components and Pages**:
|
||||||
- **Components (`src/components`)**: Reusable UI components.
|
- **Components (`src/components`)**: Reusable UI components.
|
||||||
- **Pages and Routes (`src/app`)**: Next.js app directory structure with page 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`).
|
- Main app routes include: home (`/`), chat (`/c`), discover (`/discover`), and library (`/library`).
|
||||||
- **API Routes (`src/app/api`)**: API endpoints implemented with Next.js API routes.
|
- **API Routes (`src/app/api`)**: Server endpoints implemented with Next.js route handlers.
|
||||||
- `/api/chat`: Handles chat interactions.
|
|
||||||
- `/api/search`: Provides direct access to Perplexica's search capabilities.
|
|
||||||
- Other endpoints for models, files, and suggestions.
|
|
||||||
- **Backend Logic (`src/lib`)**: Contains all the backend functionality including search, database, and API logic.
|
- **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.
|
- The search system lives in `src/lib/agents/search`.
|
||||||
- All of the focus modes are implemented using the Meta Search Agent class in `src/lib/search/metaSearchAgent.ts`.
|
- The search pipeline is split into classification, research, widgets, and writing.
|
||||||
- Database functionality is in `src/lib/db`.
|
- Database functionality is in `src/lib/db`.
|
||||||
- Chat model and embedding model providers are managed in `src/lib/providers`.
|
- Chat model and embedding model providers are in `src/lib/models/providers`, and models are loaded via `src/lib/models/registry.ts`.
|
||||||
- Prompt templates and LLM chain definitions are in `src/lib/prompts` and `src/lib/chains` respectively.
|
- 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
|
## 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
|
## Setting Up Your Environment
|
||||||
|
|
||||||
Before diving into coding, setting up your local environment is key. Here's what you need to do:
|
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.
|
1. Run `npm install` to install all dependencies.
|
||||||
2. Rename it to `config.toml` and fill in the necessary configuration fields.
|
2. Use `npm run dev` to start the application in development mode.
|
||||||
3. Run `npm install` to install all dependencies.
|
3. Open http://localhost:3000 and complete the setup in the UI (API keys, models, search backend URL, etc.).
|
||||||
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.
|
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.
|
**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.
|
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.
|
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/*
|
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 ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --frozen-lockfile --network-timeout 600000
|
RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||||
@@ -12,7 +12,7 @@ COPY src ./src
|
|||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY drizzle ./drizzle
|
COPY drizzle ./drizzle
|
||||||
|
|
||||||
RUN mkdir -p /home/perplexica/data
|
RUN mkdir -p /home/vane/data
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM node:24.5.0-slim
|
FROM node:24.5.0-slim
|
||||||
@@ -24,15 +24,18 @@ RUN apt-get update && apt-get install -y \
|
|||||||
curl sudo \
|
curl sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /home/perplexica
|
WORKDIR /home/vane
|
||||||
|
|
||||||
COPY --from=builder /home/perplexica/public ./public
|
COPY --from=builder /home/vane/public ./public
|
||||||
COPY --from=builder /home/perplexica/.next/static ./public/_next/static
|
COPY --from=builder /home/vane/.next/static ./public/_next/static
|
||||||
COPY --from=builder /home/perplexica/.next/standalone ./
|
COPY --from=builder /home/vane/.next/standalone ./
|
||||||
COPY --from=builder /home/perplexica/data ./data
|
COPY --from=builder /home/vane/data ./data
|
||||||
COPY drizzle ./drizzle
|
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 \
|
RUN useradd --shell /bin/bash --system \
|
||||||
--home-dir "/usr/local/searxng" \
|
--home-dir "/usr/local/searxng" \
|
||||||
@@ -54,13 +57,13 @@ RUN git clone "https://github.com/searxng/searxng" \
|
|||||||
"/usr/local/searxng/searxng-src"
|
"/usr/local/searxng/searxng-src"
|
||||||
|
|
||||||
RUN python3 -m venv "/usr/local/searxng/searx-pyenv"
|
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" && \
|
RUN cd "/usr/local/searxng/searxng-src" && \
|
||||||
"/usr/local/searxng/searx-pyenv/bin/pip" install --use-pep517 --no-build-isolation -e .
|
"/usr/local/searxng/searx-pyenv/bin/pip" install --use-pep517 --no-build-isolation -e .
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
WORKDIR /home/perplexica
|
WORKDIR /home/vane
|
||||||
COPY entrypoint.sh ./entrypoint.sh
|
COPY entrypoint.sh ./entrypoint.sh
|
||||||
RUN chmod +x ./entrypoint.sh
|
RUN chmod +x ./entrypoint.sh
|
||||||
RUN sed -i 's/\r$//' ./entrypoint.sh || true
|
RUN sed -i 's/\r$//' ./entrypoint.sh || true
|
||||||
@@ -71,4 +74,4 @@ EXPOSE 3000 8080
|
|||||||
|
|
||||||
ENV SEARXNG_API_URL=http://localhost: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/*
|
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 ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --frozen-lockfile --network-timeout 600000
|
RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||||
@@ -12,23 +12,23 @@ COPY src ./src
|
|||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY drizzle ./drizzle
|
COPY drizzle ./drizzle
|
||||||
|
|
||||||
RUN mkdir -p /home/perplexica/data
|
RUN mkdir -p /home/vane/data
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM node:24.5.0-slim
|
FROM node:24.5.0-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/*
|
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/vane/public ./public
|
||||||
COPY --from=builder /home/perplexica/.next/static ./public/_next/static
|
COPY --from=builder /home/vane/.next/static ./public/_next/static
|
||||||
|
|
||||||
COPY --from=builder /home/perplexica/.next/standalone ./
|
COPY --from=builder /home/vane/.next/standalone ./
|
||||||
COPY --from=builder /home/perplexica/data ./data
|
COPY --from=builder /home/vane/data ./data
|
||||||
COPY drizzle ./drizzle
|
COPY drizzle ./drizzle
|
||||||
|
|
||||||
RUN mkdir /home/perplexica/uploads
|
RUN mkdir /home/vane/uploads
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 ItzCrazyKns
|
Copyright (c) 2026 ItzCrazyKns
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -1,32 +1,34 @@
|
|||||||
# Perplexica 🔍
|
# Vane 🔍
|
||||||
|
|
||||||
[](https://github.com/ItzCrazyKns/Perplexica/stargazers)
|
[](https://github.com/ItzCrazyKns/Vane/stargazers)
|
||||||
[](https://github.com/ItzCrazyKns/Perplexica/network/members)
|
[](https://github.com/ItzCrazyKns/Vane/network/members)
|
||||||
[](https://github.com/ItzCrazyKns/Perplexica/watchers)
|
[](https://github.com/ItzCrazyKns/Vane/watchers)
|
||||||
[](https://hub.docker.com/r/itzcrazykns1337/perplexica)
|
[](https://hub.docker.com/r/itzcrazykns1337/vane)
|
||||||
[](https://github.com/ItzCrazyKns/Perplexica/blob/master/LICENSE)
|
[](https://github.com/ItzCrazyKns/Vane/blob/master/LICENSE)
|
||||||
[](https://github.com/ItzCrazyKns/Perplexica/commits/master)
|
[](https://github.com/ItzCrazyKns/Vane/commits/master)
|
||||||
[](https://discord.gg/26aArMy8tT)
|
[](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
|
## ✨ 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.
|
🤖 **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.
|
🔍 **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.
|
📷 **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.
|
🌐 **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.
|
🕒 **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
|
## 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">
|
<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%">
|
<img alt="Warp Terminal" src=".assets/sponsers/warp.png" width="100%">
|
||||||
</a>
|
</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.
|
Warp is revolutionizing development workflows with AI-powered features, modern UX, and blazing-fast performance. Used by developers at top companies worldwide.
|
||||||
|
|
||||||
@@ -61,9 +63,9 @@ We'd also like to thank the following partners for their generous support:
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td width="100" align="center">
|
||||||
<a href="https://dashboard.exa.ai" target="_blank">
|
<a href="https://dashboard.exa.ai" target="_blank">
|
||||||
<img src=".assets/sponsers/exa.png" alt="Exa" style="max-width: 8rem; max-height: 8rem; border-radius: .75rem;" />
|
<img src=".assets/sponsers/exa.png" alt="Exa" width="80" height="80" style="border-radius: .75rem;" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -74,26 +76,26 @@ We'd also like to thank the following partners for their generous support:
|
|||||||
|
|
||||||
## Installation
|
## 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)
|
### 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
|
```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
|
```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:
|
**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:
|
If you prefer to build from source or need more control:
|
||||||
|
|
||||||
1. Ensure Docker is installed and running on your system.
|
1. Ensure Docker is installed and running on your system.
|
||||||
2. Clone the Perplexica repository:
|
2. Clone the Vane repository:
|
||||||
|
|
||||||
```bash
|
```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.
|
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:
|
4. Build and run using Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t perplexica .
|
docker build -t vane .
|
||||||
docker run -d -p 3000:3000 -v perplexica-data:/home/perplexica/data -v perplexica-uploads:/home/perplexica/uploads --name perplexica perplexica
|
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
|
### Non-Docker Installation
|
||||||
|
|
||||||
@@ -133,8 +135,8 @@ If you prefer to build from source or need more control:
|
|||||||
2. Clone the repository:
|
2. Clone the repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ItzCrazyKns/Perplexica.git
|
git clone https://github.com/ItzCrazyKns/Vane.git
|
||||||
cd Perplexica
|
cd Vane
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Install dependencies:
|
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.
|
**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
|
### Troubleshooting
|
||||||
|
|
||||||
#### Local OpenAI-API-Compliant Servers
|
#### 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.
|
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.
|
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
|
## 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.
|
1. Open your browser's settings.
|
||||||
2. Navigate to the 'Search Engines' section.
|
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.
|
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 Perplexica directly from your browser's search bar.
|
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
|
## One-Click Deployment
|
||||||
|
|
||||||
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
||||||
[](https://repocloud.io/details/?app_id=267)
|
[](https://repocloud.io/details/?app_id=267)
|
||||||
[](https://template.run.claw.cloud/?referralCode=U11MRQ8U9RM4&openapp=system-fastdeploy%3FtemplateName%3Dperplexica)
|
[](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
|
## Upcoming Features
|
||||||
|
|
||||||
- [x] Add settings page
|
- [ ] Adding more widgets, integrations, search sources
|
||||||
- [x] Adding support for local LLMs
|
- [ ] Adding ability to create custom agents (name T.B.D.)
|
||||||
- [x] History Saving features
|
- [ ] Adding authentication
|
||||||
- [x] Introducing various Focus Modes
|
|
||||||
- [x] Adding API support
|
|
||||||
- [x] Adding Discover
|
|
||||||
- [ ] Finalizing Copilot Mode
|
|
||||||
|
|
||||||
## Support Us
|
## 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
|
### Donations
|
||||||
|
|
||||||
@@ -259,10 +257,10 @@ We also accept donations to help sustain our project. If you would like to contr
|
|||||||
|
|
||||||
## Contribution
|
## 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
|
## 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`.
|
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:
|
services:
|
||||||
perplexica:
|
vane:
|
||||||
image: itzcrazykns1337/perplexica:latest
|
image: itzcrazykns1337/vane:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- data:/home/perplexica/data
|
- data:/home/vane/data
|
||||||
- uploads:/home/perplexica/uploads
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
name: 'perplexica-data'
|
name: 'vane-data'
|
||||||
uploads:
|
|
||||||
name: 'perplexica-uploads'
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Perplexica Search API Documentation
|
# Vane Search API Documentation
|
||||||
|
|
||||||
## Overview
|
## 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
|
## 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`
|
**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
|
### 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
|
#### 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"
|
"key": "text-embedding-3-large"
|
||||||
},
|
},
|
||||||
"optimizationMode": "speed",
|
"optimizationMode": "speed",
|
||||||
"focusMode": "webSearch",
|
"sources": ["web"],
|
||||||
"query": "What is Perplexica",
|
"query": "What is Vane",
|
||||||
"history": [
|
"history": [
|
||||||
["human", "Hi, how are you?"],
|
["human", "Hi, how are you?"],
|
||||||
["assistant", "I am doing well, how can I help you today?"]
|
["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
|
"stream": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -87,24 +87,25 @@ The API accepts a JSON object in the request body, where you define the focus mo
|
|||||||
|
|
||||||
### Request Parameters
|
### 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.
|
- `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.
|
- `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.
|
- `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.
|
- `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:
|
- **`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.
|
- `speed`: Prioritize speed and return the fastest answer.
|
||||||
- `balanced`: Provide a balanced answer with good speed and reasonable quality.
|
- `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.
|
- **`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
|
```json
|
||||||
[
|
[
|
||||||
["human", "What is Perplexica?"],
|
["human", "What is Vane?"],
|
||||||
["assistant", "Perplexica is an AI-powered search engine..."]
|
["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
|
```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": [
|
"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": {
|
"metadata": {
|
||||||
"title": "What is Perplexica, and how does it function as an AI-powered search ...",
|
"title": "What is Vane, 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"
|
"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": {
|
"metadata": {
|
||||||
"title": "Sahar Mor's Post",
|
"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":"init","data":"Stream connected"}
|
||||||
{"type":"sources","data":[{"pageContent":"...","metadata":{"title":"...","url":"..."}},...]}
|
{"type":"sources","data":[{"content":"...","metadata":{"title":"...","url":"..."}},...]}
|
||||||
{"type":"response","data":"Perplexica is an "}
|
{"type":"response","data":"Vane is an "}
|
||||||
{"type":"response","data":"innovative, open-source "}
|
{"type":"response","data":"innovative, open-source "}
|
||||||
{"type":"response","data":"AI-powered search engine..."}
|
{"type":"response","data":"AI-powered search engine..."}
|
||||||
{"type":"done"}
|
{"type":"done"}
|
||||||
@@ -174,9 +175,9 @@ Clients should process each line as a separate JSON object. The different messag
|
|||||||
|
|
||||||
### Fields in the Response
|
### 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:
|
- **`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:
|
- `metadata`: Metadata about the source, including:
|
||||||
- `title`: The title of the webpage.
|
- `title`: The title of the webpage.
|
||||||
- `url`: The URL 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.
|
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.
|
- **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.
|
For a high level flow, see [WORKING.md](WORKING.md). For deeper implementation details, see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
||||||
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 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.
|
If you want implementation details, see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
||||||
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.
|
|
||||||
|
|
||||||
## 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)
|
## For Docker users (Using pre-built images)
|
||||||
|
|
||||||
Simply pull the latest image and restart your container:
|
Simply pull the latest image and restart your container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull itzcrazykns1337/perplexica:latest
|
docker pull itzcrazykns1337/vane:latest
|
||||||
docker stop perplexica
|
docker stop vane
|
||||||
docker rm perplexica
|
docker rm vane
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
For slim version:
|
For slim version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull itzcrazykns1337/perplexica:slim-latest
|
docker pull itzcrazykns1337/vane:slim-latest
|
||||||
docker stop perplexica
|
docker stop vane
|
||||||
docker rm perplexica
|
docker rm vane
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Once updated, go to http://localhost:3000 and verify the latest changes. Your settings are preserved automatically.
|
Once updated, go to http://localhost:3000 and verify the latest changes. Your settings are preserved automatically.
|
||||||
|
|
||||||
## For Docker users (Building from source)
|
## 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
|
```bash
|
||||||
cd Perplexica
|
cd Vane
|
||||||
git pull origin master
|
git pull origin master
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Rebuild the Docker image:
|
2. Rebuild the Docker image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t perplexica .
|
docker build -t vane .
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Stop and remove the old container, then start the new one:
|
3. Stop and remove the old container, then start the new one:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker stop perplexica
|
docker stop vane
|
||||||
docker rm perplexica
|
docker rm vane
|
||||||
docker run -p 3000:3000 -p 8080:8080 --name perplexica perplexica
|
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.
|
4. Once the command completes, go to http://localhost:3000 and verify the latest changes.
|
||||||
|
|
||||||
## For non-Docker users
|
## 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
|
```bash
|
||||||
cd Perplexica
|
cd Vane
|
||||||
git pull origin master
|
git pull origin master
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { defineConfig } from 'drizzle-kit';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
schema: './src/lib/db/schema.ts',
|
schema: './src/lib/db/schema.ts',
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: path.join(process.cwd(), 'data', 'db.sqlite'),
|
url: path.join(process.cwd(), 'data', 'db.sqlite'),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -1,15 +1 @@
|
|||||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
/* do nothing */
|
||||||
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;
|
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"focusMode": {
|
"sources": {
|
||||||
"name": "focusMode",
|
"name": "sources",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ else
|
|||||||
echo "SearXNG may not be fully ready, but continuing (PID: $SEARXNG_PID)"
|
echo "SearXNG may not be fully ready, but continuing (PID: $SEARXNG_PID)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd /home/perplexica
|
cd /home/vane
|
||||||
echo "Starting Perplexica..."
|
echo "Starting Vane..."
|
||||||
|
|
||||||
exec node server.js
|
exec node server.js
|
||||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import './.next/dev/types/routes.d.ts';
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import pkg from './package.json' with { type: 'json' };
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
@@ -8,7 +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;
|
export default nextConfig;
|
||||||
|
|||||||
56
package.json
56
package.json
@@ -1,78 +1,80 @@
|
|||||||
{
|
{
|
||||||
"name": "perplexica-frontend",
|
"name": "vane",
|
||||||
"version": "1.11.2",
|
"version": "1.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "ItzCrazyKns",
|
"author": "ItzCrazyKns",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format:write": "prettier . --write"
|
"format:write": "prettier . --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.34.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@headlessui/tailwindcss": "^0.2.2",
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
"@huggingface/transformers": "^3.7.5",
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"@iarna/toml": "^2.2.5",
|
|
||||||
"@icons-pack/react-simple-icons": "^12.3.0",
|
"@icons-pack/react-simple-icons": "^12.3.0",
|
||||||
"@langchain/anthropic": "^1.0.1",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@langchain/community": "^1.0.3",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@langchain/core": "^1.0.5",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@langchain/google-genai": "^1.0.1",
|
|
||||||
"@langchain/groq": "^1.0.1",
|
|
||||||
"@langchain/langgraph": "^1.0.1",
|
|
||||||
"@langchain/ollama": "^1.0.1",
|
|
||||||
"@langchain/openai": "^1.1.1",
|
|
||||||
"@langchain/textsplitters": "^1.0.0",
|
|
||||||
"@tailwindcss/typography": "^0.5.12",
|
"@tailwindcss/typography": "^0.5.12",
|
||||||
|
"@toolsycc/json-repair": "^0.1.22",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"compute-cosine-similarity": "^1.1.0",
|
"drizzle-orm": "^0.45.2",
|
||||||
"drizzle-orm": "^0.40.1",
|
"js-tiktoken": "^1.0.21",
|
||||||
"framer-motion": "^12.23.24",
|
"jsdom": "^29.0.1",
|
||||||
"html-to-text": "^9.0.5",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf": "^3.0.1",
|
|
||||||
"langchain": "^1.0.4",
|
|
||||||
"lightweight-charts": "^5.0.9",
|
"lightweight-charts": "^5.0.9",
|
||||||
"lucide-react": "^0.363.0",
|
"lucide-react": "^0.556.0",
|
||||||
"mammoth": "^1.9.1",
|
"mammoth": "^1.9.1",
|
||||||
"markdown-to-jsx": "^7.7.2",
|
"markdown-to-jsx": "^7.7.2",
|
||||||
"mathjs": "^15.1.0",
|
"mathjs": "^15.1.0",
|
||||||
"next": "^15.2.2",
|
"motion": "^12.23.26",
|
||||||
|
"next": "^16.0.7",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"officeparser": "^6.0.7",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.9.0",
|
"openai": "^6.9.0",
|
||||||
"partial-json": "^0.1.7",
|
"partial-json": "^0.1.7",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"playwright": "^1.59.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"react-text-to-speech": "^0.14.5",
|
"react-text-to-speech": "^0.14.5",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"rfc6902": "^5.1.2",
|
"rfc6902": "^5.1.2",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
"winston": "^3.17.0",
|
|
||||||
"yahoo-finance2": "^3.10.2",
|
"yahoo-finance2": "^3.10.2",
|
||||||
"yet-another-react-lightbox": "^3.17.2",
|
"yet-another-react-lightbox": "^3.17.2",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/jspdf": "^2.0.0",
|
"@types/jspdf": "^2.0.0",
|
||||||
"@types/node": "^24.8.1",
|
"@types/node": "^24.8.1",
|
||||||
"@types/pdf-parse": "^1.1.4",
|
"@types/pdf-parse": "^1.1.4",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"drizzle-kit": "^0.30.5",
|
"drizzle-kit": "^0.18.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.1.4",
|
"eslint-config-next": "^16.2.2",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.87"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import ModelRegistry from '@/lib/models/registry';
|
import ModelRegistry from '@/lib/models/registry';
|
||||||
import { ModelWithProvider } from '@/lib/models/types';
|
import { ModelWithProvider } from '@/lib/models/types';
|
||||||
import SearchAgent from '@/lib/agents/search';
|
import SearchAgent from '@/lib/agents/search';
|
||||||
import SessionManager from '@/lib/session';
|
import SessionManager from '@/lib/session';
|
||||||
import { ChatTurnMessage } from '@/lib/types';
|
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 runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -32,7 +36,7 @@ const bodySchema = z.object({
|
|||||||
optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
|
optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
|
||||||
message: 'Optimization mode must be one of: 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
|
history: z
|
||||||
.array(z.tuple([z.string(), z.string()]))
|
.array(z.tuple([z.string(), z.string()]))
|
||||||
.optional()
|
.optional()
|
||||||
@@ -43,7 +47,6 @@ const bodySchema = z.object({
|
|||||||
systemInstructions: z.string().nullable().optional().default(''),
|
systemInstructions: z.string().nullable().optional().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Message = z.infer<typeof messageSchema>;
|
|
||||||
type Body = z.infer<typeof bodySchema>;
|
type Body = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
const safeValidateBody = (data: unknown) => {
|
const safeValidateBody = (data: unknown) => {
|
||||||
@@ -65,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) => {
|
export const POST = async (req: Request) => {
|
||||||
try {
|
try {
|
||||||
const reqBody = (await req.json()) as Body;
|
const reqBody = (await req.json()) as Body;
|
||||||
@@ -121,95 +156,86 @@ export const POST = async (req: Request) => {
|
|||||||
const writer = responseStream.writable.getWriter();
|
const writer = responseStream.writable.getWriter();
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
let receivedMessage = '';
|
const disconnect = session.subscribe((event: string, data: any) => {
|
||||||
|
if (event === 'data') {
|
||||||
session.addListener('data', (data: any) => {
|
if (data.type === 'block') {
|
||||||
if (data.type === 'response') {
|
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(
|
writer.write(
|
||||||
encoder.encode(
|
encoder.encode(
|
||||||
JSON.stringify({
|
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,
|
data: data.data,
|
||||||
}) + '\n',
|
}) + '\n',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
receivedMessage += data.data;
|
writer.close();
|
||||||
} else if (data.type === 'sources') {
|
session.removeAllListeners();
|
||||||
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',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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, {
|
agent.searchAsync(session, {
|
||||||
chatHistory: history,
|
chatHistory: history,
|
||||||
followUp: message.content,
|
followUp: message.content,
|
||||||
|
chatId: body.message.chatId,
|
||||||
|
messageId: body.message.messageId,
|
||||||
config: {
|
config: {
|
||||||
llm,
|
llm,
|
||||||
embedding: embedding,
|
embedding: embedding,
|
||||||
sources: ['web'],
|
sources: body.sources as SearchSources[],
|
||||||
mode: body.optimizationMode,
|
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, {
|
return new Response(responseStream.readable, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export const POST = async (req: Request) => {
|
|||||||
|
|
||||||
const images = await searchImages(
|
const images = await searchImages(
|
||||||
{
|
{
|
||||||
chatHistory: body.chatHistory,
|
chatHistory: body.chatHistory.map(([role, content]) => ({
|
||||||
|
role: role === 'human' ? 'user' : 'assistant',
|
||||||
|
content,
|
||||||
|
})),
|
||||||
query: body.query,
|
query: body.query,
|
||||||
},
|
},
|
||||||
llm,
|
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 ModelRegistry from '@/lib/models/registry';
|
||||||
import { ModelWithProvider } from '@/lib/models/types';
|
import { ModelWithProvider } from '@/lib/models/types';
|
||||||
import SessionManager from '@/lib/session';
|
import SessionManager from '@/lib/session';
|
||||||
import SearchAgent from '@/lib/agents/search';
|
|
||||||
import { ChatTurnMessage } from '@/lib/types';
|
import { ChatTurnMessage } from '@/lib/types';
|
||||||
|
import { SearchSources } from '@/lib/agents/search/types';
|
||||||
|
import APISearchAgent from '@/lib/agents/search/api';
|
||||||
|
|
||||||
interface ChatRequestBody {
|
interface ChatRequestBody {
|
||||||
optimizationMode: 'speed' | 'balanced';
|
optimizationMode: 'speed' | 'balanced' | 'quality';
|
||||||
focusMode: string;
|
sources: SearchSources[];
|
||||||
chatModel: ModelWithProvider;
|
chatModel: ModelWithProvider;
|
||||||
embeddingModel: ModelWithProvider;
|
embeddingModel: ModelWithProvider;
|
||||||
query: string;
|
query: string;
|
||||||
@@ -19,15 +20,15 @@ export const POST = async (req: Request) => {
|
|||||||
try {
|
try {
|
||||||
const body: ChatRequestBody = await req.json();
|
const body: ChatRequestBody = await req.json();
|
||||||
|
|
||||||
if (!body.focusMode || !body.query) {
|
if (!body.sources || !body.query) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ message: 'Missing focus mode or query' },
|
{ message: 'Missing sources or query' },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.history = body.history || [];
|
body.history = body.history || [];
|
||||||
body.optimizationMode = body.optimizationMode || 'balanced';
|
body.optimizationMode = body.optimizationMode || 'speed';
|
||||||
body.stream = body.stream || false;
|
body.stream = body.stream || false;
|
||||||
|
|
||||||
const registry = new ModelRegistry();
|
const registry = new ModelRegistry();
|
||||||
@@ -48,17 +49,21 @@ export const POST = async (req: Request) => {
|
|||||||
|
|
||||||
const session = SessionManager.createSession();
|
const session = SessionManager.createSession();
|
||||||
|
|
||||||
const agent = new SearchAgent();
|
const agent = new APISearchAgent();
|
||||||
|
|
||||||
agent.searchAsync(session, {
|
agent.searchAsync(session, {
|
||||||
chatHistory: history,
|
chatHistory: history,
|
||||||
config: {
|
config: {
|
||||||
embedding: embeddings,
|
embedding: embeddings,
|
||||||
llm: llm,
|
llm: llm,
|
||||||
sources: ['web', 'discussions', 'academic'],
|
sources: body.sources,
|
||||||
mode: 'balanced',
|
mode: body.optimizationMode,
|
||||||
|
fileIds: [],
|
||||||
|
systemInstructions: body.systemInstructions || '',
|
||||||
},
|
},
|
||||||
followUp: body.query,
|
followUp: body.query,
|
||||||
|
chatId: crypto.randomUUID(),
|
||||||
|
messageId: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!body.stream) {
|
if (!body.stream) {
|
||||||
@@ -70,36 +75,37 @@ export const POST = async (req: Request) => {
|
|||||||
let message = '';
|
let message = '';
|
||||||
let sources: any[] = [];
|
let sources: any[] = [];
|
||||||
|
|
||||||
session.addListener('data', (data: string) => {
|
session.subscribe((event: string, data: Record<string, any>) => {
|
||||||
try {
|
if (event === 'data') {
|
||||||
const parsedData = JSON.parse(data);
|
try {
|
||||||
if (parsedData.type === 'response') {
|
if (data.type === 'response') {
|
||||||
message += parsedData.data;
|
message += data.data;
|
||||||
} else if (parsedData.type === 'sources') {
|
} else if (data.type === 'searchResults') {
|
||||||
sources = parsedData.data;
|
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(
|
reject(
|
||||||
Response.json(
|
Response.json(
|
||||||
{ message: 'Error parsing data' },
|
{ message: 'Search error', error: data },
|
||||||
{ status: 500 },
|
{ 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 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,54 +136,54 @@ export const POST = async (req: Request) => {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
session.addListener('data', (data: string) => {
|
session.subscribe((event: string, data: Record<string, any>) => {
|
||||||
if (signal.aborted) return;
|
if (event === 'data') {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(data);
|
if (data.type === 'response') {
|
||||||
|
controller.enqueue(
|
||||||
if (parsedData.type === 'response') {
|
encoder.encode(
|
||||||
controller.enqueue(
|
JSON.stringify({
|
||||||
encoder.encode(
|
type: 'response',
|
||||||
JSON.stringify({
|
data: data.data,
|
||||||
type: 'response',
|
}) + '\n',
|
||||||
data: parsedData.data,
|
),
|
||||||
}) + '\n',
|
);
|
||||||
),
|
} else if (data.type === 'searchResults') {
|
||||||
);
|
sources = data.data;
|
||||||
} else if (parsedData.type === 'sources') {
|
controller.enqueue(
|
||||||
sources = parsedData.data;
|
encoder.encode(
|
||||||
controller.enqueue(
|
JSON.stringify({
|
||||||
encoder.encode(
|
type: 'sources',
|
||||||
JSON.stringify({
|
data: sources,
|
||||||
type: 'sources',
|
}) + '\n',
|
||||||
data: sources,
|
),
|
||||||
}) + '\n',
|
);
|
||||||
),
|
}
|
||||||
);
|
} catch (error) {
|
||||||
|
controller.error(error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
controller.error(error);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
session.addListener('end', () => {
|
if (event === 'end') {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(
|
encoder.encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'done',
|
type: 'done',
|
||||||
}) + '\n',
|
}) + '\n',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
controller.close();
|
controller.close();
|
||||||
});
|
}
|
||||||
|
|
||||||
session.addListener('error', (error: any) => {
|
if (event === 'error') {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
controller.error(error);
|
controller.error(data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import generateSuggestions from '@/lib/agents/suggestions';
|
import generateSuggestions from '@/lib/agents/suggestions';
|
||||||
import ModelRegistry from '@/lib/models/registry';
|
import ModelRegistry from '@/lib/models/registry';
|
||||||
import { ModelWithProvider } from '@/lib/models/types';
|
import { ModelWithProvider } from '@/lib/models/types';
|
||||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
|
||||||
|
|
||||||
interface SuggestionsGenerationBody {
|
interface SuggestionsGenerationBody {
|
||||||
chatHistory: any[];
|
chatHistory: any[];
|
||||||
@@ -21,7 +20,10 @@ export const POST = async (req: Request) => {
|
|||||||
|
|
||||||
const suggestions = await generateSuggestions(
|
const suggestions = await generateSuggestions(
|
||||||
{
|
{
|
||||||
chatHistory: body.chatHistory,
|
chatHistory: body.chatHistory.map(([role, content]) => ({
|
||||||
|
role: role === 'human' ? 'user' : 'assistant',
|
||||||
|
content,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
llm,
|
llm,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
|
|
||||||
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx';
|
|
||||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
|
|
||||||
import { Document } from '@langchain/core/documents';
|
|
||||||
import ModelRegistry from '@/lib/models/registry';
|
import ModelRegistry from '@/lib/models/registry';
|
||||||
import { Chunk } from '@/lib/types';
|
import UploadManager from '@/lib/uploads/manager';
|
||||||
|
|
||||||
interface FileRes {
|
|
||||||
fileName: string;
|
|
||||||
fileExtension: string;
|
|
||||||
fileId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadDir = path.join(process.cwd(), 'uploads');
|
|
||||||
|
|
||||||
if (!fs.existsSync(uploadDir)) {
|
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitter = new RecursiveCharacterTextSplitter({
|
|
||||||
chunkSize: 500,
|
|
||||||
chunkOverlap: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
|
|
||||||
const files = formData.getAll('files') as File[];
|
const files = formData.getAll('files') as File[];
|
||||||
const embedding_model = formData.get('embedding_model_key') as string;
|
const embeddingModel = formData.get('embedding_model_key') as string;
|
||||||
const embedding_model_provider = formData.get('embedding_model_provider_id') as string;
|
const embeddingModelProvider = formData.get('embedding_model_provider_id') as string;
|
||||||
|
|
||||||
if (!embedding_model || !embedding_model_provider) {
|
if (!embeddingModel || !embeddingModelProvider) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Missing embedding model or provider' },
|
{ message: 'Missing embedding model or provider' },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -43,81 +19,13 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const registry = new ModelRegistry();
|
const registry = new ModelRegistry();
|
||||||
|
|
||||||
const model = await registry.loadEmbeddingModel(embedding_model_provider, embedding_model);
|
const model = await registry.loadEmbeddingModel(embeddingModelProvider, embeddingModel);
|
||||||
|
|
||||||
|
const uploadManager = new UploadManager({
|
||||||
|
embeddingModel: model,
|
||||||
|
})
|
||||||
|
|
||||||
const processedFiles: FileRes[] = [];
|
const processedFiles = await uploadManager.processFiles(files);
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
files.map(async (file: any) => {
|
|
||||||
const fileExtension = file.name.split('.').pop();
|
|
||||||
if (!['pdf', 'docx', 'txt'].includes(fileExtension!)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: 'File type not supported' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueFileName = `${crypto.randomBytes(16).toString('hex')}.${fileExtension}`;
|
|
||||||
const filePath = path.join(uploadDir, uniqueFileName);
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
fs.writeFileSync(filePath, new Uint8Array(buffer));
|
|
||||||
|
|
||||||
let docs: any[] = [];
|
|
||||||
if (fileExtension === 'pdf') {
|
|
||||||
const loader = new PDFLoader(filePath);
|
|
||||||
docs = await loader.load();
|
|
||||||
} else if (fileExtension === 'docx') {
|
|
||||||
const loader = new DocxLoader(filePath);
|
|
||||||
docs = await loader.load();
|
|
||||||
} else if (fileExtension === 'txt') {
|
|
||||||
const text = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
docs = [
|
|
||||||
new Document({ pageContent: text, metadata: { title: file.name } }),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitted = await splitter.splitDocuments(docs);
|
|
||||||
|
|
||||||
const extractedDataPath = filePath.replace(/\.\w+$/, '-extracted.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
extractedDataPath,
|
|
||||||
JSON.stringify({
|
|
||||||
title: file.name,
|
|
||||||
contents: splitted.map((doc) => doc.pageContent),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const chunks: Chunk[] = splitted.map((doc) => {
|
|
||||||
return {
|
|
||||||
content: doc.pageContent,
|
|
||||||
metadata: doc.metadata,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const embeddings = await model.embedChunks(
|
|
||||||
chunks
|
|
||||||
);
|
|
||||||
|
|
||||||
const embeddingsDataPath = filePath.replace(
|
|
||||||
/\.\w+$/,
|
|
||||||
'-embeddings.json',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
embeddingsDataPath,
|
|
||||||
JSON.stringify({
|
|
||||||
title: file.name,
|
|
||||||
embeddings,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
processedFiles.push({
|
|
||||||
fileName: file.name,
|
|
||||||
fileExtension: fileExtension,
|
|
||||||
fileId: uniqueFileName.replace(/\.\w+$/, ''),
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
files: processedFiles,
|
files: processedFiles,
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export const POST = async (req: Request) => {
|
|||||||
|
|
||||||
const videos = await handleVideoSearch(
|
const videos = await handleVideoSearch(
|
||||||
{
|
{
|
||||||
chatHistory: body.chatHistory,
|
chatHistory: body.chatHistory.map(([role, content]) => ({
|
||||||
|
role: role === 'human' ? 'user' : 'assistant',
|
||||||
|
content,
|
||||||
|
})),
|
||||||
query: body.query,
|
query: body.query,
|
||||||
},
|
},
|
||||||
llm,
|
llm,
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import ChatWindow from '@/components/ChatWindow';
|
import ChatWindow from '@/components/ChatWindow';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const Page = () => {
|
export default ChatWindow;
|
||||||
return <ChatWindow />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ const montserrat = Montserrat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Perplexica - Chat with the internet',
|
title: 'Vane - Direct your curiosity',
|
||||||
description:
|
description: 'Vane is an AI powered answering engine.',
|
||||||
'Perplexica is an AI powered chatbot that is connected to the internet.',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -34,7 +33,7 @@ export default function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html className="h-full" lang="en" suppressHydrationWarning>
|
<html className="h-full" lang="en" suppressHydrationWarning>
|
||||||
<body className={cn('h-full', montserrat.className)}>
|
<body className={cn('h-full antialiased', montserrat.className)}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{setupComplete ? (
|
{setupComplete ? (
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Metadata } from 'next';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Library - Perplexica',
|
title: 'Library - Vane',
|
||||||
};
|
};
|
||||||
|
|
||||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import DeleteChat from '@/components/DeleteChat';
|
import DeleteChat from '@/components/DeleteChat';
|
||||||
import { cn, formatTimeDifference } from '@/lib/utils';
|
import { formatTimeDifference } from '@/lib/utils';
|
||||||
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
|
import { BookOpenText, ClockIcon, FileText, Globe2Icon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ export interface Chat {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
focusMode: string;
|
sources: string[];
|
||||||
|
files: { fileId: string; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
@@ -37,74 +38,137 @@ const Page = () => {
|
|||||||
fetchChats();
|
fetchChats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return loading ? (
|
return (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col pt-4">
|
<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 items-center">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-3">
|
||||||
<BookOpenText />
|
<div className="flex items-center justify-center">
|
||||||
<h1 className="text-3xl font-medium p-2">Library</h1>
|
<BookOpenText size={45} className="mb-2.5" />
|
||||||
</div>
|
<div className="flex flex-col">
|
||||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
<h1
|
||||||
</div>
|
className="text-5xl font-normal p-2 pb-0"
|
||||||
{chats.length === 0 && (
|
style={{ fontFamily: 'PP Editorial, serif' }}
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
{chat.title}
|
Library
|
||||||
</Link>
|
</h1>
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
<div className="px-2 text-sm text-black/60 dark:text-white/60 text-center lg:text-left">
|
||||||
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
|
Past chats, sources, and uploads.
|
||||||
<ClockIcon size={15} />
|
|
||||||
<p className="text-xs">
|
|
||||||
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DeleteChat
|
|
||||||
chatId={chat.id}
|
|
||||||
chats={chats}
|
|
||||||
setChats={setChats}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { MetadataRoute } from 'next';
|
|||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'Perplexica - Chat with the internet',
|
name: 'Vane - Direct Your Curiosity',
|
||||||
short_name: 'Perplexica',
|
short_name: 'Vane',
|
||||||
description:
|
description: 'Vane is an AI powered answering engine.',
|
||||||
'Perplexica is an AI powered chatbot that is connected to the internet.',
|
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#0a0a0a',
|
background_color: '#0a0a0a',
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import ChatWindow from '@/components/ChatWindow';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Chat - Perplexica',
|
title: 'Chat - Vane',
|
||||||
description: 'Chat with the internet, chat with Perplexica.',
|
description: 'Chat with the internet, chat with Vane.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Brain, Search, FileText, ChevronDown, ChevronUp } from 'lucide-react';
|
import {
|
||||||
|
Brain,
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
BookSearch,
|
||||||
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
|
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
|
||||||
@@ -9,11 +16,17 @@ import { useChat } from '@/lib/hooks/useChat';
|
|||||||
const getStepIcon = (step: ResearchBlockSubStep) => {
|
const getStepIcon = (step: ResearchBlockSubStep) => {
|
||||||
if (step.type === 'reasoning') {
|
if (step.type === 'reasoning') {
|
||||||
return <Brain className="w-4 h-4" />;
|
return <Brain className="w-4 h-4" />;
|
||||||
} else if (step.type === 'searching') {
|
} else if (step.type === 'searching' || step.type === 'upload_searching') {
|
||||||
return <Search className="w-4 h-4" />;
|
return <Search className="w-4 h-4" />;
|
||||||
} else if (step.type === 'reading') {
|
} else if (
|
||||||
|
step.type === 'search_results' ||
|
||||||
|
step.type === 'upload_search_results'
|
||||||
|
) {
|
||||||
return <FileText className="w-4 h-4" />;
|
return <FileText className="w-4 h-4" />;
|
||||||
|
} else if (step.type === 'reading') {
|
||||||
|
return <BookSearch className="w-4 h-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,27 +37,39 @@ const getStepTitle = (
|
|||||||
if (step.type === 'reasoning') {
|
if (step.type === 'reasoning') {
|
||||||
return isStreaming && !step.reasoning ? 'Thinking...' : 'Thinking';
|
return isStreaming && !step.reasoning ? 'Thinking...' : 'Thinking';
|
||||||
} else if (step.type === 'searching') {
|
} else if (step.type === 'searching') {
|
||||||
return `Searching ${step.searching.length} ${step.searching.length === 1 ? 'query' : 'queries'}`;
|
const queries = Array.isArray(step.searching) ? step.searching : [];
|
||||||
} else if (step.type === 'reading') {
|
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'}`;
|
return `Found ${step.reading.length} ${step.reading.length === 1 ? 'result' : 'results'}`;
|
||||||
|
} else if (step.type === 'reading') {
|
||||||
|
return `Reading ${step.reading.length} ${step.reading.length === 1 ? 'source' : 'sources'}`;
|
||||||
|
} else if (step.type === 'upload_searching') {
|
||||||
|
return 'Scanning your uploaded documents';
|
||||||
|
} else if (step.type === 'upload_search_results') {
|
||||||
|
return `Reading ${step.results.length} ${step.results.length === 1 ? 'document' : 'documents'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Processing';
|
return 'Processing';
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantSteps = ({
|
const AssistantSteps = ({
|
||||||
block,
|
block,
|
||||||
status,
|
status,
|
||||||
|
isLast,
|
||||||
}: {
|
}: {
|
||||||
block: ResearchBlock;
|
block: ResearchBlock;
|
||||||
status: 'answering' | 'completed' | 'error';
|
status: 'answering' | 'completed' | 'error';
|
||||||
|
isLast: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(
|
||||||
|
isLast && status === 'answering' ? true : false,
|
||||||
|
);
|
||||||
const { researchEnded, loading } = useChat();
|
const { researchEnded, loading } = useChat();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (researchEnded) {
|
if (researchEnded && isLast) {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
} else if (status === 'answering') {
|
} else if (status === 'answering' && isLast) {
|
||||||
setIsExpanded(true);
|
setIsExpanded(true);
|
||||||
}
|
}
|
||||||
}, [researchEnded, status]);
|
}, [researchEnded, status]);
|
||||||
@@ -91,10 +116,9 @@ const AssistantSteps = ({
|
|||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.2, delay: 0 }}
|
transition={{ duration: 0.2, delay: 0 }}
|
||||||
className="flex gap-3"
|
className="flex gap-2"
|
||||||
>
|
>
|
||||||
{/* Timeline connector */}
|
<div className="flex flex-col items-center -mt-0.5">
|
||||||
<div className="flex flex-col items-center pt-0.5">
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
|
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -105,7 +129,6 @@ const AssistantSteps = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step content */}
|
|
||||||
<div className="flex-1 pb-1">
|
<div className="flex-1 pb-1">
|
||||||
<span className="text-sm font-medium text-black dark:text-white">
|
<span className="text-sm font-medium text-black dark:text-white">
|
||||||
{getStepTitle(step, isStreaming)}
|
{getStepTitle(step, isStreaming)}
|
||||||
@@ -138,6 +161,7 @@ const AssistantSteps = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step.type === 'searching' &&
|
{step.type === 'searching' &&
|
||||||
|
Array.isArray(step.searching) &&
|
||||||
step.searching.length > 0 && (
|
step.searching.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
{step.searching.map((query, idx) => (
|
{step.searching.map((query, idx) => (
|
||||||
@@ -151,37 +175,84 @@ const AssistantSteps = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step.type === 'reading' && step.reading.length > 0 && (
|
{(step.type === 'search_results' ||
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
step.type === 'reading') &&
|
||||||
{step.reading.slice(0, 4).map((result, idx) => {
|
step.reading.length > 0 && (
|
||||||
const url = result.metadata.url || '';
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
const title = result.metadata.title || 'Untitled';
|
{step.reading.slice(0, 4).map((result, idx) => {
|
||||||
const domain = url ? new URL(url).hostname : '';
|
const url = result.metadata.url || '';
|
||||||
const faviconUrl = domain
|
const title = result.metadata.title || 'Untitled';
|
||||||
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
|
const domain = url ? new URL(url).hostname : '';
|
||||||
: '';
|
const faviconUrl = domain
|
||||||
|
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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 && (
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-3 h-3 rounded-sm flex-shrink-0"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="line-clamp-1">{title}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step.type === 'upload_searching' &&
|
||||||
|
step.queries.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{step.queries.map((query, idx) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={idx}
|
||||||
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"
|
className="inline-flex items-center 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 && (
|
{query}
|
||||||
<img
|
|
||||||
src={faviconUrl}
|
|
||||||
alt=""
|
|
||||||
className="w-3 h-3 rounded-sm flex-shrink-0"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="line-clamp-1">{title}</span>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{step.type === 'upload_search_results' &&
|
||||||
|
step.results.length > 0 && (
|
||||||
|
<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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
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 flex-col justify-center">
|
||||||
|
<p className="text-[13px] text-black dark:text-white line-clamp-1">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const Chat = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (messages.length === 1) {
|
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) {
|
if (sections.length > lastScrolledRef.current) {
|
||||||
@@ -59,7 +59,7 @@ const Chat = () => {
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-32 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) => {
|
{sections.map((section, i) => {
|
||||||
const isLast = i === sections.length - 1;
|
const isLast = i === sections.length - 1;
|
||||||
|
|
||||||
@@ -81,9 +81,23 @@ const Chat = () => {
|
|||||||
<div ref={messageEnd} className="h-0" />
|
<div ref={messageEnd} className="h-0" />
|
||||||
{dividerWidth > 0 && (
|
{dividerWidth > 0 && (
|
||||||
<div
|
<div
|
||||||
className="bottom-24 lg:bottom-10 fixed z-40"
|
className="fixed z-40 bottom-24 lg:bottom-6"
|
||||||
style={{ width: dividerWidth }}
|
style={{ width: dividerWidth }}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] dark:hidden"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to top, #ffffff 0%, #ffffff 35%, rgba(255,255,255,0.95) 45%, rgba(255,255,255,0.85) 55%, rgba(255,255,255,0.7) 65%, rgba(255,255,255,0.5) 75%, rgba(255,255,255,0.3) 85%, rgba(255,255,255,0.1) 92%, transparent 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] hidden dark:block"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to top, #0d1117 0%, #0d1117 35%, rgba(13,17,23,0.95) 45%, rgba(13,17,23,0.85) 55%, rgba(13,17,23,0.7) 65%, rgba(13,17,23,0.5) 75%, rgba(13,17,23,0.3) 85%, rgba(13,17,23,0.1) 92%, transparent 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<MessageInput />
|
<MessageInput />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import EmptyChat from './EmptyChat';
|
|||||||
import NextError from 'next/error';
|
import NextError from 'next/error';
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
|
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
|
||||||
import { Block, Chunk } from '@/lib/types';
|
import { Block } from '@/lib/types';
|
||||||
|
import Loader from './ui/Loader';
|
||||||
|
|
||||||
export interface BaseMessage {
|
export interface BaseMessage {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@@ -21,35 +22,6 @@ export interface Message extends BaseMessage {
|
|||||||
status: 'answering' | 'completed' | 'error';
|
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 {
|
export interface File {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileExtension: string;
|
fileExtension: string;
|
||||||
@@ -62,7 +34,8 @@ export interface Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow = () => {
|
const ChatWindow = () => {
|
||||||
const { hasError, notFound, messages } = useChat();
|
const { hasError, notFound, messages, isReady } = useChat();
|
||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -78,18 +51,24 @@ const ChatWindow = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return notFound ? (
|
return isReady ? (
|
||||||
<NextError statusCode={404} />
|
notFound ? (
|
||||||
|
<NextError statusCode={404} />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<Chat />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyChat />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="flex items-center justify-center min-h-screen w-full">
|
||||||
{messages.length > 0 ? (
|
<Loader />
|
||||||
<>
|
|
||||||
<Navbar />
|
|
||||||
<Chat />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<EmptyChat />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Settings } from 'lucide-react';
|
import { Settings } from 'lucide-react';
|
||||||
import EmptyChatMessageInput from './EmptyChatMessageInput';
|
import EmptyChatMessageInput from './EmptyChatMessageInput';
|
||||||
import { File } from './ChatWindow';
|
import { File } from './ChatWindow';
|
||||||
@@ -5,8 +8,39 @@ import Link from 'next/link';
|
|||||||
import WeatherWidget from './WeatherWidget';
|
import WeatherWidget from './WeatherWidget';
|
||||||
import NewsArticleWidget from './NewsArticleWidget';
|
import NewsArticleWidget from './NewsArticleWidget';
|
||||||
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
|
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
|
||||||
|
import {
|
||||||
|
getShowNewsWidget,
|
||||||
|
getShowWeatherWidget,
|
||||||
|
} from '@/lib/config/clientRegistry';
|
||||||
|
|
||||||
const EmptyChat = () => {
|
const EmptyChat = () => {
|
||||||
|
const [showWeather, setShowWeather] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? getShowWeatherWidget() : true,
|
||||||
|
);
|
||||||
|
const [showNews, setShowNews] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? getShowNewsWidget() : true,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateWidgetVisibility = () => {
|
||||||
|
setShowWeather(getShowWeatherWidget());
|
||||||
|
setShowNews(getShowNewsWidget());
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidgetVisibility();
|
||||||
|
|
||||||
|
window.addEventListener('client-config-changed', updateWidgetVisibility);
|
||||||
|
window.addEventListener('storage', updateWidgetVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
'client-config-changed',
|
||||||
|
updateWidgetVisibility,
|
||||||
|
);
|
||||||
|
window.removeEventListener('storage', updateWidgetVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
||||||
@@ -19,14 +53,20 @@ const EmptyChat = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
<EmptyChatMessageInput />
|
<EmptyChatMessageInput />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-full gap-4 mt-2 sm:flex-row sm:justify-center">
|
{(showWeather || showNews) && (
|
||||||
<div className="flex-1 w-full">
|
<div className="flex flex-col w-full gap-4 mt-2 sm:flex-row sm:justify-center">
|
||||||
<WeatherWidget />
|
{showWeather && (
|
||||||
|
<div className="flex-1 w-full">
|
||||||
|
<WeatherWidget />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showNews && (
|
||||||
|
<div className="flex-1 w-full">
|
||||||
|
<NewsArticleWidget />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 w-full">
|
)}
|
||||||
<NewsArticleWidget />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import Focus from './MessageInputActions/Focus';
|
import Sources from './MessageInputActions/Sources';
|
||||||
import Optimization from './MessageInputActions/Optimization';
|
import Optimization from './MessageInputActions/Optimization';
|
||||||
import Attach from './MessageInputActions/Attach';
|
import Attach from './MessageInputActions/Attach';
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
@@ -68,8 +68,8 @@ const EmptyChatMessageInput = () => {
|
|||||||
<Optimization />
|
<Optimization />
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<div className="flex flex-row items-center space-x-2">
|
||||||
<div className="flex flex-row items-center space-x-1">
|
<div className="flex flex-row items-center space-x-1">
|
||||||
|
<Sources />
|
||||||
<ModelSelector />
|
<ModelSelector />
|
||||||
<Focus />
|
|
||||||
<Attach />
|
<Attach />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Check, ClipboardList } from 'lucide-react';
|
|||||||
import { Message } from '../ChatWindow';
|
import { Message } from '../ChatWindow';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Section } from '@/lib/hooks/useChat';
|
import { Section } from '@/lib/hooks/useChat';
|
||||||
|
import { SourceBlock } from '@/lib/types';
|
||||||
|
|
||||||
const Copy = ({
|
const Copy = ({
|
||||||
section,
|
section,
|
||||||
@@ -15,15 +16,25 @@ const Copy = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const sources = section.message.responseBlocks.filter(
|
||||||
|
(b) => b.type === 'source' && b.data.length > 0,
|
||||||
|
) as SourceBlock[];
|
||||||
|
|
||||||
const contentToCopy = `${initialMessage}${
|
const contentToCopy = `${initialMessage}${
|
||||||
section?.message.responseBlocks.filter((b) => b.type === 'source')
|
sources.length > 0
|
||||||
?.length > 0 &&
|
? `\n\nCitations:\n${sources
|
||||||
`\n\nCitations:\n${section.message.responseBlocks
|
.map((source) => source.data)
|
||||||
.filter((b) => b.type === 'source')
|
.flat()
|
||||||
?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`)
|
.map(
|
||||||
.join(`\n`)}`
|
(s, i) =>
|
||||||
|
`[${i + 1}] ${s.metadata.url.startsWith('file_id://') ? s.metadata.fileName || 'Uploaded File' : s.metadata.url}`,
|
||||||
|
)
|
||||||
|
.join(`\n`)}`
|
||||||
|
: ''
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
navigator.clipboard.writeText(contentToCopy);
|
navigator.clipboard.writeText(contentToCopy);
|
||||||
|
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1000);
|
setTimeout(() => setCopied(false), 1000);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
CornerDownRight,
|
CornerDownRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
import Markdown, { MarkdownToJSX, RuleType } from 'markdown-to-jsx';
|
||||||
import Copy from './MessageActions/Copy';
|
import Copy from './MessageActions/Copy';
|
||||||
import Rewrite from './MessageActions/Rewrite';
|
import Rewrite from './MessageActions/Rewrite';
|
||||||
import MessageSources from './MessageSources';
|
import MessageSources from './MessageSources';
|
||||||
@@ -21,10 +21,11 @@ import SearchVideos from './SearchVideos';
|
|||||||
import { useSpeech } from 'react-text-to-speech';
|
import { useSpeech } from 'react-text-to-speech';
|
||||||
import ThinkBox from './ThinkBox';
|
import ThinkBox from './ThinkBox';
|
||||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||||
import Citation from './Citation';
|
import Citation from './MessageRenderer/Citation';
|
||||||
import AssistantSteps from './AssistantSteps';
|
import AssistantSteps from './AssistantSteps';
|
||||||
import { ResearchBlock } from '@/lib/types';
|
import { ResearchBlock } from '@/lib/types';
|
||||||
import Renderer from './Widgets/Renderer';
|
import Renderer from './Widgets/Renderer';
|
||||||
|
import CodeBlock from './MessageRenderer/CodeBlock';
|
||||||
|
|
||||||
const ThinkTagProcessor = ({
|
const ThinkTagProcessor = ({
|
||||||
children,
|
children,
|
||||||
@@ -49,7 +50,14 @@ const MessageBox = ({
|
|||||||
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
isLast: boolean;
|
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 parsedMessage = section.parsedTextBlocks.join('\n\n');
|
||||||
const speechMessage = section.speechMessage || '';
|
const speechMessage = section.speechMessage || '';
|
||||||
@@ -67,6 +75,21 @@ const MessageBox = ({
|
|||||||
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
||||||
|
|
||||||
const markdownOverrides: MarkdownToJSX.Options = {
|
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: {
|
overrides: {
|
||||||
think: {
|
think: {
|
||||||
component: ThinkTagProcessor,
|
component: ThinkTagProcessor,
|
||||||
@@ -115,12 +138,11 @@ const MessageBox = ({
|
|||||||
<AssistantSteps
|
<AssistantSteps
|
||||||
block={researchBlock}
|
block={researchBlock}
|
||||||
status={section.message.status}
|
status={section.message.status}
|
||||||
|
isLast={isLast}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
|
|
||||||
|
|
||||||
{isLast &&
|
{isLast &&
|
||||||
loading &&
|
loading &&
|
||||||
!researchEnded &&
|
!researchEnded &&
|
||||||
@@ -135,6 +157,8 @@ const MessageBox = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
{sources.length > 0 && (
|
{sources.length > 0 && (
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<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"
|
className="group w-full py-4 text-left transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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
|
<CornerDownRight
|
||||||
size={17}
|
size={15}
|
||||||
className="group-hover:text-sky-400 transition-colors duration-200"
|
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">
|
<p className="text-sm text-black/70 dark:text-white/70 group-hover:text-sky-400 transition-colors duration-200 leading-relaxed">
|
||||||
{suggestion}
|
{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">
|
<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
|
<SearchImages
|
||||||
query={section.message.query}
|
query={section.message.query}
|
||||||
chatHistory={messages}
|
chatHistory={chatHistory}
|
||||||
messageId={section.message.messageId}
|
messageId={section.message.messageId}
|
||||||
/>
|
/>
|
||||||
<SearchVideos
|
<SearchVideos
|
||||||
chatHistory={messages}
|
chatHistory={chatHistory}
|
||||||
query={section.message.query}
|
query={section.message.query}
|
||||||
messageId={section.message.messageId}
|
messageId={section.message.messageId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import { cn } from '@/lib/utils';
|
|||||||
import { ArrowUp } from 'lucide-react';
|
import { ArrowUp } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import Attach from './MessageInputActions/Attach';
|
|
||||||
import CopilotToggle from './MessageInputActions/Copilot';
|
|
||||||
import { File } from './ChatWindow';
|
|
||||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
|
|
||||||
@@ -64,7 +61,7 @@ const MessageInput = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'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',
|
mode === 'multi' ? 'flex-col rounded-2xl' : 'flex-row rounded-full',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -80,11 +77,16 @@ const MessageInput = () => {
|
|||||||
placeholder="Ask a follow-up"
|
placeholder="Ask a follow-up"
|
||||||
/>
|
/>
|
||||||
{mode === 'single' && (
|
{mode === 'single' && (
|
||||||
<div className="flex flex-row items-center space-x-4">
|
<button
|
||||||
<CopilotToggle
|
disabled={message.trim().length === 0 || loading}
|
||||||
copilotEnabled={copilotEnabled}
|
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"
|
||||||
setCopilotEnabled={setCopilotEnabled}
|
>
|
||||||
/>
|
<ArrowUp className="bg-background" size={17} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{mode === 'multi' && (
|
||||||
|
<div className="flex flex-row items-center justify-between w-full pt-2">
|
||||||
|
<AttachSmall />
|
||||||
<button
|
<button
|
||||||
disabled={message.trim().length === 0 || loading}
|
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"
|
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"
|
||||||
@@ -93,23 +95,6 @@ const MessageInput = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Fragment, useRef, useState } from 'react';
|
import { Fragment, useRef, useState } from 'react';
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const Attach = () => {
|
const Attach = () => {
|
||||||
const { files, setFiles, setFileIds, fileIds } = useChat();
|
const { files, setFiles, setFileIds, fileIds } = useChat();
|
||||||
@@ -24,115 +27,152 @@ const Attach = () => {
|
|||||||
const fileInputRef = useRef<any>();
|
const fileInputRef = useRef<any>();
|
||||||
|
|
||||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setLoading(true);
|
const selectedFiles = e.target.files;
|
||||||
const data = new FormData();
|
|
||||||
|
|
||||||
for (let i = 0; i < e.target.files!.length; i++) {
|
if (!selectedFiles?.length) {
|
||||||
data.append('files', e.target.files![i]);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embeddingModelProvider = localStorage.getItem(
|
setLoading(true);
|
||||||
'embeddingModelProviderId',
|
|
||||||
);
|
|
||||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
|
||||||
|
|
||||||
data.append('embedding_model_provider_id', embeddingModelProvider!);
|
try {
|
||||||
data.append('embedding_model_key', embeddingModel!);
|
const data = new FormData();
|
||||||
|
|
||||||
const res = await fetch(`/api/uploads`, {
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
method: 'POST',
|
data.append('files', selectedFiles[i]);
|
||||||
body: data,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const resData = await res.json();
|
const embeddingModelProvider = localStorage.getItem(
|
||||||
|
'embeddingModelProviderId',
|
||||||
|
);
|
||||||
|
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||||
|
|
||||||
setFiles([...files, ...resData.files]);
|
if (!embeddingModelProvider || !embeddingModel) {
|
||||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
throw new Error('Please select an embedding model before uploading.');
|
||||||
setLoading(false);
|
}
|
||||||
|
|
||||||
|
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 ? (
|
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">
|
<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>
|
</div>
|
||||||
) : files.length > 0 ? (
|
) : files.length > 0 ? (
|
||||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||||
<PopoverButton
|
{({ open }) => (
|
||||||
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"
|
<PopoverButton
|
||||||
>
|
type="button"
|
||||||
<File size={16} className="text-sky-400" />
|
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"
|
||||||
</PopoverButton>
|
>
|
||||||
<Transition
|
<File size={16} className="text-sky-500" />
|
||||||
as={Fragment}
|
</PopoverButton>
|
||||||
enter="transition ease-out duration-150"
|
<AnimatePresence>
|
||||||
enterFrom="opacity-0 translate-y-1"
|
{open && (
|
||||||
enterTo="opacity-100 translate-y-0"
|
<PopoverPanel
|
||||||
leave="transition ease-in duration-150"
|
className="absolute z-10 w-64 md:w-[350px] right-0"
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
static
|
||||||
leaveTo="opacity-0 translate-y-1"
|
>
|
||||||
>
|
<motion.div
|
||||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0">
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
<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">
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||||
Attached files
|
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"
|
||||||
</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
|
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||||
type="file"
|
<h4 className="text-black/70 dark:text-white/70 text-sm">
|
||||||
onChange={handleChange}
|
Attached files
|
||||||
ref={fileInputRef}
|
</h4>
|
||||||
accept=".pdf,.docx,.txt"
|
<div className="flex flex-row items-center space-x-4">
|
||||||
multiple
|
<button
|
||||||
hidden
|
type="button"
|
||||||
/>
|
onClick={() => fileInputRef.current.click()}
|
||||||
<Plus size={16} />
|
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"
|
||||||
<p className="text-xs">Add</p>
|
>
|
||||||
</button>
|
<input
|
||||||
<button
|
type="file"
|
||||||
onClick={() => {
|
onChange={handleChange}
|
||||||
setFiles([]);
|
ref={fileInputRef}
|
||||||
setFileIds([]);
|
accept=".pdf,.docx,.txt"
|
||||||
}}
|
multiple
|
||||||
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"
|
hidden
|
||||||
>
|
/>
|
||||||
<Trash size={14} />
|
<Plus size={16} />
|
||||||
<p className="text-xs">Clear</p>
|
<p className="text-xs">Add</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
setFiles([]);
|
||||||
<div className="flex flex-col items-center">
|
setFileIds([]);
|
||||||
{files.map((file, i) => (
|
}}
|
||||||
<div
|
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"
|
||||||
key={i}
|
>
|
||||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
<Trash size={13} />
|
||||||
>
|
<p className="text-xs">Clear</p>
|
||||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
</button>
|
||||||
<File
|
</div>
|
||||||
size={16}
|
|
||||||
className="text-black/70 dark:text-white/70"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||||
{file.fileName.length > 25
|
<div className="flex flex-col items-center">
|
||||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
{files.map((file, i) => (
|
||||||
'...' +
|
<div
|
||||||
file.fileExtension
|
key={i}
|
||||||
: file.fileName}
|
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
|
||||||
))}
|
<File
|
||||||
</div>
|
size={16}
|
||||||
</div>
|
className="text-black/70 dark:text-white/70"
|
||||||
</PopoverPanel>
|
/>
|
||||||
</Transition>
|
</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>
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverButton,
|
PopoverButton,
|
||||||
PopoverPanel,
|
PopoverPanel,
|
||||||
Transition,
|
Transition,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import {
|
import { File, LoaderCircle, Paperclip, Plus, Trash } from 'lucide-react';
|
||||||
CopyPlus,
|
|
||||||
File,
|
|
||||||
LoaderCircle,
|
|
||||||
Paperclip,
|
|
||||||
Plus,
|
|
||||||
Trash,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Fragment, useRef, useState } from 'react';
|
import { Fragment, useRef, useState } from 'react';
|
||||||
import { File as FileType } from '../ChatWindow';
|
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const AttachSmall = () => {
|
const AttachSmall = () => {
|
||||||
const { files, setFiles, setFileIds, fileIds } = useChat();
|
const { files, setFiles, setFileIds, fileIds } = useChat();
|
||||||
@@ -24,115 +18,152 @@ const AttachSmall = () => {
|
|||||||
const fileInputRef = useRef<any>();
|
const fileInputRef = useRef<any>();
|
||||||
|
|
||||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setLoading(true);
|
const selectedFiles = e.target.files;
|
||||||
const data = new FormData();
|
|
||||||
|
|
||||||
for (let i = 0; i < e.target.files!.length; i++) {
|
if (!selectedFiles?.length) {
|
||||||
data.append('files', e.target.files![i]);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embeddingModelProvider = localStorage.getItem(
|
setLoading(true);
|
||||||
'embeddingModelProviderId',
|
|
||||||
);
|
|
||||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
|
||||||
|
|
||||||
data.append('embedding_model_provider_id', embeddingModelProvider!);
|
try {
|
||||||
data.append('embedding_model_key', embeddingModel!);
|
const data = new FormData();
|
||||||
|
|
||||||
const res = await fetch(`/api/uploads`, {
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
method: 'POST',
|
data.append('files', selectedFiles[i]);
|
||||||
body: data,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const resData = await res.json();
|
const embeddingModelProvider = localStorage.getItem(
|
||||||
|
'embeddingModelProviderId',
|
||||||
|
);
|
||||||
|
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||||
|
|
||||||
setFiles([...files, ...resData.files]);
|
if (!embeddingModelProvider || !embeddingModel) {
|
||||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
throw new Error('Please select an embedding model before uploading.');
|
||||||
setLoading(false);
|
}
|
||||||
|
|
||||||
|
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 ? (
|
return loading ? (
|
||||||
<div className="flex flex-row items-center justify-between space-x-1 p-1 ">
|
<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>
|
</div>
|
||||||
) : files.length > 0 ? (
|
) : files.length > 0 ? (
|
||||||
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
|
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||||
<PopoverButton
|
{({ open }) => (
|
||||||
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"
|
<PopoverButton
|
||||||
>
|
type="button"
|
||||||
<File size={20} className="text-sky-400" />
|
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"
|
||||||
</PopoverButton>
|
>
|
||||||
<Transition
|
<File size={20} className="text-sky-500" />
|
||||||
as={Fragment}
|
</PopoverButton>
|
||||||
enter="transition ease-out duration-150"
|
<AnimatePresence>
|
||||||
enterFrom="opacity-0 translate-y-1"
|
{open && (
|
||||||
enterTo="opacity-100 translate-y-0"
|
<PopoverPanel
|
||||||
leave="transition ease-in duration-150"
|
className="absolute z-10 w-64 md:w-[350px] bottom-14"
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
static
|
||||||
leaveTo="opacity-0 translate-y-1"
|
>
|
||||||
>
|
<motion.div
|
||||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] bottom-14 -ml-3">
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
<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">
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||||
Attached files
|
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"
|
||||||
</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
|
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||||
type="file"
|
<h4 className="text-black/70 dark:text-white/70 font-medium text-sm">
|
||||||
onChange={handleChange}
|
Attached files
|
||||||
ref={fileInputRef}
|
</h4>
|
||||||
accept=".pdf,.docx,.txt"
|
<div className="flex flex-row items-center space-x-4">
|
||||||
multiple
|
<button
|
||||||
hidden
|
type="button"
|
||||||
/>
|
onClick={() => fileInputRef.current.click()}
|
||||||
<Plus size={18} />
|
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"
|
||||||
<p className="text-xs">Add</p>
|
>
|
||||||
</button>
|
<input
|
||||||
<button
|
type="file"
|
||||||
onClick={() => {
|
onChange={handleChange}
|
||||||
setFiles([]);
|
ref={fileInputRef}
|
||||||
setFileIds([]);
|
accept=".pdf,.docx,.txt"
|
||||||
}}
|
multiple
|
||||||
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"
|
hidden
|
||||||
>
|
/>
|
||||||
<Trash size={14} />
|
<Plus size={16} />
|
||||||
<p className="text-xs">Clear</p>
|
<p className="text-xs">Add</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
setFiles([]);
|
||||||
<div className="flex flex-col items-center">
|
setFileIds([]);
|
||||||
{files.map((file, i) => (
|
}}
|
||||||
<div
|
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"
|
||||||
key={i}
|
>
|
||||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
<Trash size={13} />
|
||||||
>
|
<p className="text-xs">Clear</p>
|
||||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
</button>
|
||||||
<File
|
</div>
|
||||||
size={16}
|
|
||||||
className="text-black/70 dark:text-white/70"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||||
{file.fileName.length > 25
|
<div className="flex flex-col items-center">
|
||||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
{files.map((file, i) => (
|
||||||
'...' +
|
<div
|
||||||
file.fileExtension
|
key={i}
|
||||||
: file.fileName}
|
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
|
||||||
))}
|
<File
|
||||||
</div>
|
size={16}
|
||||||
</div>
|
className="text-black/70 dark:text-white/70"
|
||||||
</PopoverPanel>
|
/>
|
||||||
</Transition>
|
</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>
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,15 +2,11 @@
|
|||||||
|
|
||||||
import { Cpu, Loader2, Search } from 'lucide-react';
|
import { Cpu, Loader2, Search } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
Popover,
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
PopoverButton,
|
|
||||||
PopoverPanel,
|
|
||||||
Transition,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { MinimalProvider } from '@/lib/models/types';
|
import { MinimalProvider } from '@/lib/models/types';
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
const ModelSelector = () => {
|
const ModelSelector = () => {
|
||||||
const [providers, setProviders] = useState<MinimalProvider[]>([]);
|
const [providers, setProviders] = useState<MinimalProvider[]>([]);
|
||||||
@@ -79,119 +75,127 @@ const ModelSelector = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||||
<PopoverButton
|
{({ open }) => (
|
||||||
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"
|
<PopoverButton
|
||||||
>
|
type="button"
|
||||||
<Cpu size={16} className="text-sky-500" />
|
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"
|
||||||
</PopoverButton>
|
>
|
||||||
<Transition
|
<Cpu size={16} className="text-sky-500" />
|
||||||
as={Fragment}
|
</PopoverButton>
|
||||||
enter="transition ease-out duration-100"
|
<AnimatePresence>
|
||||||
enterFrom="opacity-0 translate-y-1"
|
{open && (
|
||||||
enterTo="opacity-100 translate-y-0"
|
<PopoverPanel
|
||||||
leave="transition ease-in duration-100"
|
className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] right-0"
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
static
|
||||||
leaveTo="opacity-0 translate-y-1"
|
>
|
||||||
>
|
<motion.div
|
||||||
<PopoverPanel className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] -right-4">
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
<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">
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<div className="p-4 border-b border-light-200 dark:border-dark-200">
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
<div className="relative">
|
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||||
<Search
|
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"
|
||||||
size={16}
|
>
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
<div className="p-2 border-b border-light-200 dark:border-dark-200">
|
||||||
/>
|
<div className="relative">
|
||||||
<input
|
<Search
|
||||||
type="text"
|
size={16}
|
||||||
placeholder="Search models..."
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
||||||
value={searchQuery}
|
/>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<input
|
||||||
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"
|
type="text"
|
||||||
/>
|
placeholder="Search models..."
|
||||||
</div>
|
value={searchQuery}
|
||||||
</div>
|
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">
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="animate-spin text-black/40 dark:text-white/40"
|
className="animate-spin text-black/40 dark:text-white/40"
|
||||||
size={24}
|
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>
|
</div>
|
||||||
|
) : filteredProviders.length === 0 ? (
|
||||||
<div className="flex flex-col px-2 py-2 space-y-0.5">
|
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
|
||||||
{provider.chatModels.map((model) => (
|
{searchQuery
|
||||||
<button
|
? 'No models found'
|
||||||
key={model.key}
|
: 'No chat models configured'}
|
||||||
onClick={() =>
|
</div>
|
||||||
handleModelSelect(provider.id, model.key)
|
) : (
|
||||||
}
|
<div className="flex flex-col">
|
||||||
type="button"
|
{filteredProviders.map((provider, providerIndex) => (
|
||||||
className={cn(
|
<div key={provider.id}>
|
||||||
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
|
<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">
|
||||||
chatModelProvider?.providerId === provider.id &&
|
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
|
||||||
chatModelProvider?.key === model.key
|
{provider.name}
|
||||||
? '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}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
{providerIndex < filteredProviders.length - 1 && (
|
</div>
|
||||||
<div className="h-px bg-light-200 dark:bg-dark-200" />
|
</motion.div>
|
||||||
)}
|
</PopoverPanel>
|
||||||
</div>
|
)}
|
||||||
))}
|
</AnimatePresence>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverPanel>
|
|
||||||
</Transition>
|
|
||||||
</Popover>
|
</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';
|
} from '@headlessui/react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { useChat } from '@/lib/hooks/useChat';
|
import { useChat } from '@/lib/hooks/useChat';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
const OptimizationModes = [
|
const OptimizationModes = [
|
||||||
{
|
{
|
||||||
@@ -60,40 +61,50 @@ const Optimization = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
<Transition
|
<AnimatePresence>
|
||||||
as={Fragment}
|
{open && (
|
||||||
enter="transition ease-out duration-150"
|
<PopoverPanel
|
||||||
enterFrom="opacity-0 translate-y-1"
|
className="absolute z-10 w-64 md:w-[250px] left-0"
|
||||||
enterTo="opacity-100 translate-y-0"
|
static
|
||||||
leave="transition ease-in duration-150"
|
>
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
<motion.div
|
||||||
leaveTo="opacity-0 translate-y-1"
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] left-0">
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
<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">
|
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||||
{OptimizationModes.map((mode, i) => (
|
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"
|
||||||
<PopoverButton
|
>
|
||||||
onClick={() => setOptimizationMode(mode.key)}
|
{OptimizationModes.map((mode, i) => (
|
||||||
key={i}
|
<PopoverButton
|
||||||
className={cn(
|
onClick={() => setOptimizationMode(mode.key)}
|
||||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
|
key={i}
|
||||||
optimizationMode === mode.key
|
className={cn(
|
||||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
|
||||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
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 className="flex flex-row justify-between w-full text-black dark:text-white">
|
||||||
</div>
|
<div className="flex flex-row space-x-1">
|
||||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
{mode.icon}
|
||||||
{mode.description}
|
<p className="text-xs font-medium">{mode.title}</p>
|
||||||
</p>
|
</div>
|
||||||
</PopoverButton>
|
{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">
|
||||||
</div>
|
Beta
|
||||||
</PopoverPanel>
|
</span>
|
||||||
</Transition>
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||||
|
{mode.description}
|
||||||
|
</p>
|
||||||
|
</PopoverButton>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</PopoverPanel>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</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;
|
||||||
@@ -37,7 +37,7 @@ const MessageSources = ({ sources }: { sources: Chunk[] }) => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="flex flex-row items-center space-x-1">
|
<div className="flex flex-row items-center space-x-1">
|
||||||
{source.metadata.url === 'File' ? (
|
{source.metadata.url.includes('file_id://') ? (
|
||||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
||||||
<File size={12} className="text-white/70" />
|
<File size={12} className="text-white/70" />
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,9 @@ const MessageSources = ({ sources }: { sources: Chunk[] }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||||
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
|
{source.metadata.url.includes('file_id://')
|
||||||
|
? 'Uploaded File'
|
||||||
|
: source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
|
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
|
||||||
|
|||||||
@@ -205,8 +205,9 @@ const Navbar = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sections.length > 0 && sections[0].message) {
|
if (sections.length > 0 && sections[0].message) {
|
||||||
const newTitle =
|
const newTitle =
|
||||||
sections[0].message.query.substring(0, 30) + '...' ||
|
sections[0].message.query.length > 30
|
||||||
'New Conversation';
|
? `${sections[0].message.query.substring(0, 30).trim()}...`
|
||||||
|
: sections[0].message.query || 'New Conversation';
|
||||||
|
|
||||||
setTitle(newTitle);
|
setTitle(newTitle);
|
||||||
const newTimeAgo = formatTimeDifference(
|
const newTimeAgo = formatTimeDifference(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SearchImages = ({
|
|||||||
messageId,
|
messageId,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
chatHistory: Message[];
|
chatHistory: [string, string][];
|
||||||
messageId: string;
|
messageId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [images, setImages] = useState<Image[] | null>(null);
|
const [images, setImages] = useState<Image[] | null>(null);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const Searchvideos = ({
|
|||||||
messageId,
|
messageId,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
chatHistory: Message[];
|
chatHistory: [string, string][];
|
||||||
messageId: string;
|
messageId: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [videos, setVideos] = useState<Video[] | null>(null);
|
const [videos, setVideos] = useState<Video[] | null>(null);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BrainCog,
|
BrainCog,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
Sliders,
|
Sliders,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
@@ -115,35 +116,52 @@ const SettingsDialogue = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 inset-0 h-full overflow-hidden">
|
<div className="flex flex-1 inset-0 h-full overflow-hidden">
|
||||||
<div className="hidden lg:flex flex-col w-[240px] border-r border-white-200 dark:border-dark-200 h-full px-3 pt-3 overflow-y-auto">
|
<div className="hidden lg:flex flex-col justify-between w-[240px] border-r border-white-200 dark:border-dark-200 h-full px-3 pt-3 overflow-y-auto">
|
||||||
<button
|
<div className="flex flex-col">
|
||||||
onClick={() => setIsOpen(false)}
|
<button
|
||||||
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg"
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg"
|
||||||
<ChevronLeft
|
>
|
||||||
size={18}
|
<ChevronLeft
|
||||||
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
|
size={18}
|
||||||
/>
|
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
|
||||||
<p className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70 text-[14px]">
|
/>
|
||||||
Back
|
<p className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70 text-[14px]">
|
||||||
|
Back
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start space-y-1 mt-8">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.dataAdd}
|
||||||
|
className={cn(
|
||||||
|
`flex flex-row items-center space-x-2 px-2 py-1.5 rounded-lg w-full text-sm hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200 active:scale-95`,
|
||||||
|
activeSection === section.key
|
||||||
|
? 'bg-light-200 dark:bg-dark-200 text-black/90 dark:text-white/90'
|
||||||
|
: ' text-black/70 dark:text-white/70',
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveSection(section.key)}
|
||||||
|
>
|
||||||
|
<section.icon size={17} />
|
||||||
|
<p>{section.name}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-1 py-[18px] px-2">
|
||||||
|
<p className="text-xs text-black/70 dark:text-white/70">
|
||||||
|
Version: {process.env.NEXT_PUBLIC_VERSION}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
<a
|
||||||
<div className="flex flex-col items-start space-y-1 mt-8">
|
href="https://github.com/itzcrazykns/vane"
|
||||||
{sections.map((section) => (
|
target="_blank"
|
||||||
<button
|
rel="noopener noreferrer"
|
||||||
key={section.dataAdd}
|
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"
|
||||||
className={cn(
|
>
|
||||||
`flex flex-row items-center space-x-2 px-2 py-1.5 rounded-lg w-full text-sm hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200 active:scale-95`,
|
<span>GitHub</span>
|
||||||
activeSection === section.key
|
<ExternalLink size={12} />
|
||||||
? 'bg-light-200 dark:bg-dark-200 text-black/90 dark:text-white/90'
|
</a>
|
||||||
: ' text-black/70 dark:text-white/70',
|
|
||||||
)}
|
|
||||||
onClick={() => setActiveSection(section.key)}
|
|
||||||
>
|
|
||||||
<section.icon size={17} />
|
|
||||||
<p>{section.name}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col overflow-hidden">
|
<div className="w-full flex flex-col overflow-hidden">
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { useTheme } from 'next-themes';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Switch } from '@headlessui/react';
|
import { Switch } from '@headlessui/react';
|
||||||
|
|
||||||
|
const emitClientConfigChanged = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('client-config-changed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const SettingsSelect = ({
|
const SettingsSelect = ({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
@@ -35,6 +41,7 @@ const SettingsSelect = ({
|
|||||||
if (field.key === 'theme') {
|
if (field.key === 'theme') {
|
||||||
setTheme(newValue);
|
setTheme(newValue);
|
||||||
}
|
}
|
||||||
|
emitClientConfigChanged();
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/config', {
|
const res = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -106,6 +113,7 @@ const SettingsInput = ({
|
|||||||
try {
|
try {
|
||||||
if (field.scope === 'client') {
|
if (field.scope === 'client') {
|
||||||
localStorage.setItem(field.key, newValue);
|
localStorage.setItem(field.key, newValue);
|
||||||
|
emitClientConfigChanged();
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/config', {
|
const res = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -182,6 +190,7 @@ const SettingsTextarea = ({
|
|||||||
try {
|
try {
|
||||||
if (field.scope === 'client') {
|
if (field.scope === 'client') {
|
||||||
localStorage.setItem(field.key, newValue);
|
localStorage.setItem(field.key, newValue);
|
||||||
|
emitClientConfigChanged();
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/config', {
|
const res = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -258,6 +267,7 @@ const SettingsSwitch = ({
|
|||||||
try {
|
try {
|
||||||
if (field.scope === 'client') {
|
if (field.scope === 'client') {
|
||||||
localStorage.setItem(field.key, String(newValue));
|
localStorage.setItem(field.key, String(newValue));
|
||||||
|
emitClientConfigChanged();
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/config', {
|
const res = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -300,7 +310,7 @@ const SettingsSwitch = ({
|
|||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={handleSave}
|
onChange={handleSave}
|
||||||
disabled={loading}
|
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
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ const SetupWizard = ({
|
|||||||
animate={{ opacity: 1, translateY: '0px' }}
|
animate={{ opacity: 1, translateY: '0px' }}
|
||||||
className="text-4xl md:text-6xl xl:text-8xl font-normal font-['Instrument_Serif'] tracking-tight"
|
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']">
|
<span className="text-[#24A0ED] italic font-['PP_Editorial']">
|
||||||
Perplexica
|
Vane
|
||||||
</span>
|
</span>
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p
|
<motion.p
|
||||||
@@ -91,9 +91,9 @@ const SetupWizard = ({
|
|||||||
}}
|
}}
|
||||||
className="text-2xl md:text-4xl xl:text-6xl font-normal font-['Instrument_Serif'] tracking-tight"
|
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']">
|
<span className="text-[#24A0ED] italic font-['PP_Editorial']">
|
||||||
Perplexica
|
Vane
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
set up for you
|
set up for you
|
||||||
</motion.p>
|
</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 { useEffect, useState } from 'react';
|
||||||
|
import { getApproxLocation } from '@/lib/actions';
|
||||||
|
|
||||||
const WeatherWidget = () => {
|
const WeatherWidget = () => {
|
||||||
const [data, setData] = useState({
|
const [data, setData] = useState({
|
||||||
@@ -15,17 +18,6 @@ const WeatherWidget = () => {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
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 (
|
const getLocation = async (
|
||||||
callback: (location: {
|
callback: (location: {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
@@ -91,7 +83,7 @@ const WeatherWidget = () => {
|
|||||||
setData({
|
setData({
|
||||||
temperature: data.temperature,
|
temperature: data.temperature,
|
||||||
condition: data.condition,
|
condition: data.condition,
|
||||||
location: 'Mars',
|
location: location.city,
|
||||||
humidity: data.humidity,
|
humidity: data.humidity,
|
||||||
windSpeed: data.windSpeed,
|
windSpeed: data.windSpeed,
|
||||||
icon: data.icon,
|
icon: data.icon,
|
||||||
|
|||||||
@@ -9,38 +9,30 @@ type CalculationWidgetProps = {
|
|||||||
|
|
||||||
const Calculation = ({ expression, result }: CalculationWidgetProps) => {
|
const Calculation = ({ expression, result }: CalculationWidgetProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden shadow-sm">
|
<div className="rounded-lg border border-light-200 dark:border-dark-200">
|
||||||
<div className="flex items-center gap-2 p-3 bg-light-100/50 dark:bg-dark-100/50 border-b border-light-200 dark:border-dark-200">
|
<div className="p-4 space-y-4">
|
||||||
<div className="rounded-full p-1.5 bg-light-100 dark:bg-dark-100">
|
<div className="space-y-2">
|
||||||
<Calculator className="w-4 h-4 text-black/70 dark:text-white/70" />
|
<div className="flex items-center gap-2 text-black/60 dark:text-white/70">
|
||||||
</div>
|
<Calculator className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium text-black dark:text-white">
|
<span className="text-xs uppercase font-semibold tracking-wide">
|
||||||
Calculation
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
|
||||||
<span className="text-xs text-black/50 dark:text-white/50 font-medium">
|
|
||||||
Expression
|
Expression
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-light-100 dark:bg-dark-100 rounded-md p-2.5 border border-light-200 dark:border-dark-200">
|
<div className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-3">
|
||||||
<code className="text-sm text-black dark:text-white font-mono break-all">
|
<code className="text-sm text-black dark:text-white font-mono break-all">
|
||||||
{expression}
|
{expression}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-2 text-black/60 dark:text-white/70">
|
||||||
<Equal className="w-3.5 h-3.5 text-black/50 dark:text-white/50" />
|
<Equal className="w-4 h-4" />
|
||||||
<span className="text-xs text-black/50 dark:text-white/50 font-medium">
|
<span className="text-xs uppercase font-semibold tracking-wide">
|
||||||
Result
|
Result
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gradient-to-br from-light-100 to-light-secondary dark:from-dark-100 dark:to-dark-secondary rounded-md p-4 border-2 border-light-200 dark:border-dark-200">
|
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-5">
|
||||||
<div className="text-4xl font-bold text-black dark:text-white font-mono tabular-nums">
|
<div className="text-4xl font-bold text-black dark:text-white font-mono tabular-nums">
|
||||||
{result.toLocaleString()}
|
{result.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -257,21 +257,21 @@ const Stock = (props: StockWidgetProps) => {
|
|||||||
const isPostMarket = props.marketState === 'POST';
|
const isPostMarket = props.marketState === 'POST';
|
||||||
|
|
||||||
const displayPrice = isPostMarket
|
const displayPrice = isPostMarket
|
||||||
? props.postMarketPrice ?? props.regularMarketPrice
|
? (props.postMarketPrice ?? props.regularMarketPrice)
|
||||||
: isPreMarket
|
: isPreMarket
|
||||||
? props.preMarketPrice ?? props.regularMarketPrice
|
? (props.preMarketPrice ?? props.regularMarketPrice)
|
||||||
: props.regularMarketPrice;
|
: props.regularMarketPrice;
|
||||||
|
|
||||||
const displayChange = isPostMarket
|
const displayChange = isPostMarket
|
||||||
? props.postMarketChange ?? props.regularMarketChange
|
? (props.postMarketChange ?? props.regularMarketChange)
|
||||||
: isPreMarket
|
: isPreMarket
|
||||||
? props.preMarketChange ?? props.regularMarketChange
|
? (props.preMarketChange ?? props.regularMarketChange)
|
||||||
: props.regularMarketChange;
|
: props.regularMarketChange;
|
||||||
|
|
||||||
const displayChangePercent = isPostMarket
|
const displayChangePercent = isPostMarket
|
||||||
? props.postMarketChangePercent ?? props.regularMarketChangePercent
|
? (props.postMarketChangePercent ?? props.regularMarketChangePercent)
|
||||||
: isPreMarket
|
: isPreMarket
|
||||||
? props.preMarketChangePercent ?? props.regularMarketChangePercent
|
? (props.preMarketChangePercent ?? props.regularMarketChangePercent)
|
||||||
: props.regularMarketChangePercent;
|
: props.regularMarketChangePercent;
|
||||||
|
|
||||||
const changeColor = isPositive
|
const changeColor = isPositive
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { getMeasurementUnit } from '@/lib/config/clientRegistry';
|
||||||
import { Wind, Droplets, Gauge } from 'lucide-react';
|
import { Wind, Droplets, Gauge } from 'lucide-react';
|
||||||
import { useMemo, useEffect, useState } from 'react';
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -226,6 +227,20 @@ const Weather = ({
|
|||||||
timezone,
|
timezone,
|
||||||
}: WeatherWidgetProps) => {
|
}: WeatherWidgetProps) => {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
const unit = getMeasurementUnit();
|
||||||
|
const isImperial = unit === 'imperial';
|
||||||
|
const tempUnitLabel = isImperial ? '°F' : '°C';
|
||||||
|
const windUnitLabel = isImperial ? 'mph' : 'km/h';
|
||||||
|
|
||||||
|
const formatTemp = (celsius: number) => {
|
||||||
|
if (!Number.isFinite(celsius)) return 0;
|
||||||
|
return Math.round(isImperial ? (celsius * 9) / 5 + 32 : celsius);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatWind = (speedKmh: number) => {
|
||||||
|
if (!Number.isFinite(speedKmh)) return 0;
|
||||||
|
return Math.round(isImperial ? speedKmh * 0.621371 : speedKmh);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkDarkMode = () => {
|
const checkDarkMode = () => {
|
||||||
@@ -266,14 +281,12 @@ const Weather = ({
|
|||||||
return {
|
return {
|
||||||
day: dayName,
|
day: dayName,
|
||||||
icon: info.icon,
|
icon: info.icon,
|
||||||
high: Math.round(daily.temperature_2m_max[idx + 1]),
|
high: formatTemp(daily.temperature_2m_max[idx + 1]),
|
||||||
low: Math.round(daily.temperature_2m_min[idx + 1]),
|
low: formatTemp(daily.temperature_2m_min[idx + 1]),
|
||||||
highF: Math.round((daily.temperature_2m_max[idx + 1] * 9) / 5 + 32),
|
|
||||||
lowF: Math.round((daily.temperature_2m_min[idx + 1] * 9) / 5 + 32),
|
|
||||||
precipitation: daily.precipitation_probability_max[idx + 1] || 0,
|
precipitation: daily.precipitation_probability_max[idx + 1] || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [daily, isDarkMode]);
|
}, [daily, isDarkMode, isImperial]);
|
||||||
|
|
||||||
if (!current || !daily || !daily.time || daily.time.length === 0) {
|
if (!current || !daily || !daily.time || daily.time.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -305,9 +318,9 @@ const Weather = ({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-4xl font-bold drop-shadow-md">
|
<span className="text-4xl font-bold drop-shadow-md">
|
||||||
{Math.round(current.temperature_2m)}°
|
{formatTemp(current.temperature_2m)}°
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg">F C</span>
|
<span className="text-lg">{tempUnitLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium drop-shadow mt-0.5">
|
<p className="text-sm font-medium drop-shadow mt-0.5">
|
||||||
{weatherInfo.description}
|
{weatherInfo.description}
|
||||||
@@ -316,8 +329,8 @@ const Weather = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs font-medium opacity-90">
|
<p className="text-xs font-medium opacity-90">
|
||||||
{Math.round(daily.temperature_2m_max[0])}°{' '}
|
{formatTemp(daily.temperature_2m_max[0])}°{' '}
|
||||||
{Math.round(daily.temperature_2m_min[0])}°
|
{formatTemp(daily.temperature_2m_min[0])}°
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +384,7 @@ const Weather = ({
|
|||||||
Wind
|
Wind
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{Math.round(current.wind_speed_10m)} km/h
|
{formatWind(current.wind_speed_10m)} {windUnitLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,7 +408,8 @@ const Weather = ({
|
|||||||
Feels Like
|
Feels Like
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{Math.round(current.apparent_temperature)}°C
|
{formatTemp(current.apparent_temperature)}
|
||||||
|
{tempUnitLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import { Message } from '@/components/ChatWindow';
|
|
||||||
|
|
||||||
export const getSuggestions = async (chatHistory: [string, string][]) => {
|
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 chatModel = localStorage.getItem('chatModelKey');
|
||||||
const chatModelProvider = localStorage.getItem('chatModelProviderId');
|
const chatModelProvider = localStorage.getItem('chatModelProviderId');
|
||||||
|
|
||||||
@@ -18,7 +8,7 @@ export const getSuggestions = async (chatHistory: [string, string][]) => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chatHistory: chatTurns,
|
chatHistory,
|
||||||
chatModel: {
|
chatModel: {
|
||||||
providerId: chatModelProvider,
|
providerId: chatModelProvider,
|
||||||
key: chatModel,
|
key: chatModel,
|
||||||
@@ -30,3 +20,17 @@ export const getSuggestions = async (chatHistory: [string, string][]) => {
|
|||||||
|
|
||||||
return data.suggestions;
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const searchImages = async (
|
|||||||
query: z.string().describe('The image search query.'),
|
query: z.string().describe('The image search query.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await llm.generateObject<z.infer<typeof schema>>({
|
const res = await llm.generateObject<typeof schema>({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const searchVideos = async (
|
|||||||
query: z.string().describe('The video search query.'),
|
query: z.string().describe('The video search query.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await llm.generateObject<z.infer<typeof schema>>({
|
const res = await llm.generateObject<typeof schema>({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
|
|||||||
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;
|
||||||
53
src/lib/agents/search/classifier.ts
Normal file
53
src/lib/agents/search/classifier.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
import { ClassifierInput } from './types';
|
||||||
|
import { classifierPrompt } from '@/lib/prompts/search/classifier';
|
||||||
|
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
classification: z.object({
|
||||||
|
skipSearch: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to skip the search step.'),
|
||||||
|
personalSearch: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to perform a personal search.'),
|
||||||
|
academicSearch: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to perform an academic search.'),
|
||||||
|
discussionSearch: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to perform a discussion search.'),
|
||||||
|
showWeatherWidget: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to show the weather widget.'),
|
||||||
|
showStockWidget: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to show the stock widget.'),
|
||||||
|
showCalculationWidget: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Indicates whether to show the calculation widget.'),
|
||||||
|
}),
|
||||||
|
standaloneFollowUp: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"A self-contained, context-independent reformulation of the user's question.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const classify = async (input: ClassifierInput) => {
|
||||||
|
const output = await input.llm.generateObject<typeof schema>({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: classifierPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_query>\n${input.query}\n</user_query>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import z from 'zod';
|
|
||||||
import { ClassifierInput, ClassifierOutput } from '../types';
|
|
||||||
import { WidgetRegistry } from '../widgets';
|
|
||||||
import { IntentRegistry } from './intents';
|
|
||||||
import { getClassifierPrompt } from '@/lib/prompts/search/classifier';
|
|
||||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
|
||||||
|
|
||||||
class Classifier {
|
|
||||||
async classify(input: ClassifierInput): Promise<ClassifierOutput> {
|
|
||||||
const availableIntents = IntentRegistry.getAvailableIntents({
|
|
||||||
sources: input.enabledSources,
|
|
||||||
});
|
|
||||||
|
|
||||||
const availableWidgets = WidgetRegistry.getAll();
|
|
||||||
|
|
||||||
const classificationSchema = z.object({
|
|
||||||
skipSearch: z
|
|
||||||
.boolean()
|
|
||||||
.describe(
|
|
||||||
'Set to true to SKIP search. Skip ONLY when: (1) widgets alone fully answer the query (e.g., weather, stocks, calculator), (2) simple greetings or writing tasks (NOT questions). Set to false for ANY question or information request.',
|
|
||||||
),
|
|
||||||
standaloneFollowUp: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"A self-contained, context-independent reformulation of the user's question. Must include all necessary context from chat history, replace pronouns with specific nouns, and be clear enough to answer without seeing the conversation. Keep the same complexity as the original question.",
|
|
||||||
),
|
|
||||||
intents: z
|
|
||||||
.array(z.enum(availableIntents.map((i) => i.name)))
|
|
||||||
.describe(
|
|
||||||
"The intent(s) that best describe how to fulfill the user's query. Can include multiple intents (e.g., ['web_search', 'widget_response'] for 'weather in NYC and recent news'). Always include at least one intent when applicable.",
|
|
||||||
),
|
|
||||||
widgets: z
|
|
||||||
.array(z.union(availableWidgets.map((w) => w.schema)))
|
|
||||||
.describe(
|
|
||||||
'Widgets that can display structured data to answer (fully or partially) the query. Include all applicable widgets regardless of skipSearch value.',
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const classifierPrompt = getClassifierPrompt({
|
|
||||||
intentDesc: IntentRegistry.getDescriptions({
|
|
||||||
sources: input.enabledSources,
|
|
||||||
}),
|
|
||||||
widgetDesc: WidgetRegistry.getDescriptions(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await input.llm.generateObject<
|
|
||||||
z.infer<typeof classificationSchema>
|
|
||||||
>({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: classifierPrompt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `<conversation>${formatChatHistoryAsString(input.chatHistory)}</conversation>\n\n<query>${input.query}</query>`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
schema: classificationSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.widgets = res.widgets.map((widgetConfig) => {
|
|
||||||
return {
|
|
||||||
type: widgetConfig.type,
|
|
||||||
params: widgetConfig,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return res as ClassifierOutput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Classifier;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Intent } from '../../types';
|
|
||||||
|
|
||||||
const description = `Use this intent to search for scholarly articles, research papers, scientific studies, and academic resources when the user explicitly requests credible, peer-reviewed, or authoritative information from academic sources.
|
|
||||||
|
|
||||||
#### When to use:
|
|
||||||
1. User explicitly mentions academic keywords: research papers, scientific studies, scholarly articles, peer-reviewed, journal articles.
|
|
||||||
2. User asks for scientific evidence or academic research on a topic.
|
|
||||||
3. User needs authoritative, citation-worthy sources for research or academic purposes.
|
|
||||||
|
|
||||||
#### When NOT to use:
|
|
||||||
1. General questions that don't specifically request academic sources - use 'web_search' instead.
|
|
||||||
2. User just wants general information without specifying academic sources.
|
|
||||||
3. Casual queries about facts or current events.
|
|
||||||
|
|
||||||
#### Example use cases:
|
|
||||||
1. "Find scientific papers on climate change effects"
|
|
||||||
- User explicitly wants scientific papers.
|
|
||||||
- Intent: ['academic_search'] with skipSearch: false
|
|
||||||
|
|
||||||
2. "What does the research say about meditation benefits?"
|
|
||||||
- User is asking for research-based information.
|
|
||||||
- Intent: ['academic_search', 'web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
3. "Show me peer-reviewed articles on CRISPR technology"
|
|
||||||
- User specifically wants peer-reviewed academic content.
|
|
||||||
- Intent: ['academic_search'] with skipSearch: false
|
|
||||||
|
|
||||||
4. "I need scholarly sources about renewable energy for my thesis"
|
|
||||||
- User explicitly needs scholarly/academic sources.
|
|
||||||
- Intent: ['academic_search'] with skipSearch: false
|
|
||||||
|
|
||||||
5. "Explain quantum computing" (WRONG to use academic_search alone)
|
|
||||||
- This is a general question, not specifically requesting academic papers.
|
|
||||||
- Correct intent: ['web_search'] with skipSearch: false
|
|
||||||
- Could combine: ['web_search', 'academic_search'] if you want both general and academic sources
|
|
||||||
|
|
||||||
6. "What's the latest study on sleep patterns?"
|
|
||||||
- User mentions "study" - combine academic and web search for comprehensive results.
|
|
||||||
- Intent: ['academic_search', 'web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
**IMPORTANT**: This intent can be combined with 'web_search' to provide both academic papers and general web information. Always set skipSearch to false when using this intent.
|
|
||||||
|
|
||||||
**NOTE**: This intent is only available if academic search sources are enabled in the configuration.`;
|
|
||||||
|
|
||||||
const academicSearchIntent: Intent = {
|
|
||||||
name: 'academic_search',
|
|
||||||
description,
|
|
||||||
requiresSearch: true,
|
|
||||||
enabled: (config) => config.sources.includes('academic'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default academicSearchIntent;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Intent } from '../../types';
|
|
||||||
|
|
||||||
const description = `Use this intent to search through discussion forums, community boards, and social platforms (Reddit, forums, etc.) when the user explicitly wants opinions, personal experiences, community discussions, or crowd-sourced information.
|
|
||||||
|
|
||||||
#### When to use:
|
|
||||||
1. User explicitly mentions: Reddit, forums, discussion boards, community opinions, "what do people think", "user experiences".
|
|
||||||
2. User is asking for opinions, reviews, or personal experiences about a product, service, or topic.
|
|
||||||
3. User wants to know what communities or people are saying about something.
|
|
||||||
|
|
||||||
#### When NOT to use:
|
|
||||||
1. General questions that don't specifically ask for opinions or discussions - use 'web_search' instead.
|
|
||||||
2. User wants factual information or official sources.
|
|
||||||
3. Casual queries about facts, news, or current events without requesting community input.
|
|
||||||
|
|
||||||
#### Example use cases:
|
|
||||||
1. "What do people on Reddit think about the new iPhone?"
|
|
||||||
- User explicitly wants Reddit/community opinions.
|
|
||||||
- Intent: ['discussions_search'] with skipSearch: false
|
|
||||||
|
|
||||||
2. "User experiences with Tesla Model 3"
|
|
||||||
- User is asking for personal experiences from users.
|
|
||||||
- Intent: ['discussions_search'] with skipSearch: false
|
|
||||||
|
|
||||||
3. "Best gaming laptop according to forums"
|
|
||||||
- User wants forum/community recommendations.
|
|
||||||
- Intent: ['discussions_search'] with skipSearch: false
|
|
||||||
|
|
||||||
4. "What are people saying about the new AI regulations?"
|
|
||||||
- User wants community discussions/opinions.
|
|
||||||
- Intent: ['discussions_search', 'web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
5. "Reviews and user opinions on the Framework laptop"
|
|
||||||
- Combines user opinions with general reviews.
|
|
||||||
- Intent: ['discussions_search', 'web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
6. "What's the price of iPhone 15?" (WRONG to use discussions_search)
|
|
||||||
- This is a factual question, not asking for opinions.
|
|
||||||
- Correct intent: ['web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
7. "Explain how OAuth works" (WRONG to use discussions_search)
|
|
||||||
- This is asking for information, not community opinions.
|
|
||||||
- Correct intent: ['web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
**IMPORTANT**: This intent can be combined with 'web_search' to provide both community discussions and official/factual information. Always set skipSearch to false when using this intent.
|
|
||||||
|
|
||||||
**NOTE**: This intent is only available if discussion search sources are enabled in the configuration.`;
|
|
||||||
|
|
||||||
const discussionSearchIntent: Intent = {
|
|
||||||
name: 'discussions_search',
|
|
||||||
description,
|
|
||||||
requiresSearch: true,
|
|
||||||
enabled: (config) => config.sources.includes('discussions'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default discussionSearchIntent;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import academicSearchIntent from './academicSearch';
|
|
||||||
import discussionSearchIntent from './discussionSearch';
|
|
||||||
import privateSearchIntent from './privateSearch';
|
|
||||||
import IntentRegistry from './registry';
|
|
||||||
import webSearchIntent from './webSearch';
|
|
||||||
import widgetResponseIntent from './widgetResponse';
|
|
||||||
import writingTaskIntent from './writingTask';
|
|
||||||
|
|
||||||
IntentRegistry.register(webSearchIntent);
|
|
||||||
IntentRegistry.register(academicSearchIntent);
|
|
||||||
IntentRegistry.register(discussionSearchIntent);
|
|
||||||
IntentRegistry.register(widgetResponseIntent);
|
|
||||||
IntentRegistry.register(writingTaskIntent);
|
|
||||||
IntentRegistry.register(privateSearchIntent);
|
|
||||||
|
|
||||||
export { IntentRegistry };
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Intent } from '../../types';
|
|
||||||
|
|
||||||
const description = `Use this intent to search through the user's uploaded documents or provided web page links when they ask questions about their personal files or specific URLs.
|
|
||||||
|
|
||||||
#### When to use:
|
|
||||||
1. User explicitly asks about uploaded documents ("tell me about the document I uploaded", "summarize this file").
|
|
||||||
2. User provides specific URLs/links and asks questions about them ("tell me about example.com", "what's on this page: url.com").
|
|
||||||
3. User references "my documents", "the file I shared", "this link" when files or URLs are available.
|
|
||||||
|
|
||||||
#### When NOT to use:
|
|
||||||
1. User asks generic questions like "summarize" without providing context or files (later the system will ask what they want summarized).
|
|
||||||
2. No files have been uploaded and no URLs provided - use web_search or other intents instead.
|
|
||||||
3. User is asking general questions unrelated to their uploaded content.
|
|
||||||
|
|
||||||
#### Example use cases:
|
|
||||||
1. "Tell me about the PDF I uploaded"
|
|
||||||
- Files are uploaded, user wants information from them.
|
|
||||||
- Intent: ['private_search'] with skipSearch: false
|
|
||||||
|
|
||||||
2. "What's the main point from example.com?"
|
|
||||||
- User provided a specific URL to analyze.
|
|
||||||
- Intent: ['private_search'] with skipSearch: false
|
|
||||||
|
|
||||||
3. "Summarize the research paper I shared"
|
|
||||||
- User references a shared document.
|
|
||||||
- Intent: ['private_search'] with skipSearch: false
|
|
||||||
|
|
||||||
4. "Summarize" (WRONG to use private_search if no files/URLs)
|
|
||||||
- No context provided, no files uploaded.
|
|
||||||
- Correct: Skip this intent, let the answer agent ask what to summarize
|
|
||||||
|
|
||||||
5. "What does my document say about climate change and also search the web for recent updates?"
|
|
||||||
- Combine private document search with web search.
|
|
||||||
- Intent: ['private_search', 'web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
**IMPORTANT**: Only use this intent if files are actually uploaded or URLs are explicitly provided in the query. Check the context for uploaded files before selecting this intent. Always set skipSearch to false when using this intent.
|
|
||||||
|
|
||||||
**NOTE**: This intent can be combined with other search intents when the user wants both personal document information and external sources.`;
|
|
||||||
|
|
||||||
const privateSearchIntent: Intent = {
|
|
||||||
name: 'private_search',
|
|
||||||
description,
|
|
||||||
enabled: (config) => true,
|
|
||||||
requiresSearch: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default privateSearchIntent;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Intent, SearchAgentConfig, SearchSources } from '../../types';
|
|
||||||
|
|
||||||
class IntentRegistry {
|
|
||||||
private static intents = new Map<string, Intent>();
|
|
||||||
|
|
||||||
static register(intent: Intent) {
|
|
||||||
this.intents.set(intent.name, intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
static get(name: string): Intent | undefined {
|
|
||||||
return this.intents.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getAvailableIntents(config: { sources: SearchSources[] }): Intent[] {
|
|
||||||
return Array.from(
|
|
||||||
this.intents.values().filter((intent) => intent.enabled(config)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDescriptions(config: { sources: SearchSources[] }): string {
|
|
||||||
const availableintents = this.getAvailableIntents(config);
|
|
||||||
|
|
||||||
return availableintents
|
|
||||||
.map(
|
|
||||||
(intent) => `-------\n\n###${intent.name}: ${intent.description}\n\n`,
|
|
||||||
)
|
|
||||||
.join('\n\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IntentRegistry;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Intent } from '../../types';
|
|
||||||
|
|
||||||
const description = `
|
|
||||||
Use this intent to find current information from the web when the user is asking a question or needs up-to-date information that cannot be provided by widgets or other intents.
|
|
||||||
|
|
||||||
#### When to use:
|
|
||||||
1. Simple user questions about current events, news, weather, or general knowledge that require the latest information and there is no specific better intent to use.
|
|
||||||
2. When the user explicitly requests information from the web or indicates they want the most recent data (and still there's no other better intent).
|
|
||||||
3. When no widgets can fully satisfy the user's request for information nor any other specialized search intent applies.
|
|
||||||
|
|
||||||
#### Examples use cases:
|
|
||||||
1. "What is the weather in San Francisco today? ALso tell me some popular events happening there this weekend."
|
|
||||||
- In this case, the weather widget can provide the current weather, but for popular events, a web search is needed. So the intent should include a 'web_search' & a 'widget_response'.
|
|
||||||
|
|
||||||
2. "Who won the Oscar for Best Picture in 2024?"
|
|
||||||
- This is a straightforward question that requires current information from the web.
|
|
||||||
|
|
||||||
3. "Give me the latest news on AI regulations."
|
|
||||||
- The user is asking for up-to-date news, which necessitates a web search.
|
|
||||||
|
|
||||||
**IMPORTANT**: If this intent is given then skip search should be false.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const webSearchIntent: Intent = {
|
|
||||||
name: 'web_search',
|
|
||||||
description: description,
|
|
||||||
requiresSearch: true,
|
|
||||||
enabled: (config) => config.sources.includes('web'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default webSearchIntent;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Intent } from '../../types';
|
|
||||||
|
|
||||||
const description = `Use this intent when the user's query can be fully or partially answered using specialized widgets that provide structured, real-time data (weather, stocks, calculations, and more).
|
|
||||||
|
|
||||||
#### When to use:
|
|
||||||
1. The user is asking for specific information that a widget can provide (current weather, stock prices, mathematical calculations, unit conversions, etc.).
|
|
||||||
2. A widget can completely answer the query without needing additional web search (use this intent alone and set skipSearch to true).
|
|
||||||
3. A widget can provide part of the answer, but additional information from web search or other sources is needed (combine with other intents like 'web_search' and set skipSearch to false).
|
|
||||||
|
|
||||||
#### Example use cases:
|
|
||||||
Note: These are just examples - there are several other widgets available for use depending on the user's query.
|
|
||||||
|
|
||||||
1. "What is the weather in New York?"
|
|
||||||
- The weather widget can fully answer this query.
|
|
||||||
- Intent: ['widget_response'] with skipSearch: true
|
|
||||||
- Widget: [{ type: 'weather', location: 'New York', lat: 0, lon: 0 }]
|
|
||||||
|
|
||||||
2. "What's the weather in San Francisco today? Also tell me some popular events happening there this weekend."
|
|
||||||
- Weather widget provides current conditions, but events require web search.
|
|
||||||
- Intent: ['web_search', 'widget_response'] with skipSearch: false
|
|
||||||
- Widget: [{ type: 'weather', location: 'San Francisco', lat: 0, lon: 0 }]
|
|
||||||
|
|
||||||
3. "Calculate 25% of 480"
|
|
||||||
- The calculator widget can fully answer this.
|
|
||||||
- Intent: ['widget_response'] with skipSearch: true
|
|
||||||
- Widget: [{ type: 'calculator', expression: '25% of 480' }]
|
|
||||||
|
|
||||||
4. "AAPL stock price and recent Apple news"
|
|
||||||
- Stock widget provides price, but news requires web search.
|
|
||||||
- Intent: ['web_search', 'widget_response'] with skipSearch: false
|
|
||||||
- Widget: [{ type: 'stock', symbol: 'AAPL' }]
|
|
||||||
|
|
||||||
5. "What's Tesla's stock doing and how does it compare to competitors?"
|
|
||||||
- Stock widget provides Tesla's price, but comparison analysis requires web search.
|
|
||||||
- Intent: ['web_search', 'widget_response'] with skipSearch: false
|
|
||||||
- Widget: [{ type: 'stock', symbol: 'TSLA' }]
|
|
||||||
|
|
||||||
**IMPORTANT**: Set skipSearch to true ONLY if the widget(s) can completely answer the user's query without any additional information. If the user asks for anything beyond what the widget provides (context, explanations, comparisons, related information), combine this intent with 'web_search' and set skipSearch to false.`;
|
|
||||||
|
|
||||||
const widgetResponseIntent: Intent = {
|
|
||||||
name: 'widget_response',
|
|
||||||
description,
|
|
||||||
requiresSearch: false,
|
|
||||||
enabled: (config) => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default widgetResponseIntent;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Intent } from '../../types';
|
|
||||||
|
|
||||||
const description = `Use this intent for simple writing or greeting tasks that do not require any external information or facts. This is ONLY for greetings and straightforward creative writing that needs no factual verification.
|
|
||||||
|
|
||||||
#### When to use:
|
|
||||||
1. User greetings or simple social interactions (hello, hi, thanks, goodbye).
|
|
||||||
2. Creative writing tasks that require NO factual information (poems, birthday messages, thank you notes).
|
|
||||||
3. Simple drafting tasks where the user provides all necessary information.
|
|
||||||
|
|
||||||
#### When NOT to use:
|
|
||||||
1. ANY question that starts with "what", "how", "why", "when", "where", "who" - these need web_search.
|
|
||||||
2. Requests for explanations, definitions, or information about anything.
|
|
||||||
3. Code-related questions or technical help - these need web_search.
|
|
||||||
4. Writing tasks that require facts, data, or current information.
|
|
||||||
5. When you're uncertain about any information needed - default to web_search.
|
|
||||||
|
|
||||||
#### Example use cases:
|
|
||||||
1. "Hello" or "Hi there"
|
|
||||||
- Simple greeting, no information needed.
|
|
||||||
- Intent: ['writing_task'] with skipSearch: true
|
|
||||||
|
|
||||||
2. "Write me a birthday message for my friend"
|
|
||||||
- Creative writing, no facts needed.
|
|
||||||
- Intent: ['writing_task'] with skipSearch: true
|
|
||||||
|
|
||||||
3. "Draft a thank you email for a job interview"
|
|
||||||
- Simple writing task, no external information required.
|
|
||||||
- Intent: ['writing_task'] with skipSearch: true
|
|
||||||
|
|
||||||
4. "What is React?" (WRONG to use writing_task)
|
|
||||||
- This is a QUESTION asking for information.
|
|
||||||
- Correct intent: ['web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
5. "How do I fix this error in Python?" (WRONG to use writing_task)
|
|
||||||
- This is asking for technical help.
|
|
||||||
- Correct intent: ['web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
6. "Write an email about the latest AI developments" (WRONG to use writing_task alone)
|
|
||||||
- This requires current information about AI developments.
|
|
||||||
- Correct intent: ['web_search'] with skipSearch: false
|
|
||||||
|
|
||||||
**CRITICAL RULE**: When in doubt, DO NOT use this intent. Default to web_search. This intent should be rare - only use it for greetings and purely creative writing tasks that need absolutely no facts or information.
|
|
||||||
|
|
||||||
**IMPORTANT**: If this intent is used alone, skipSearch should be true. Never combine this with other search intents unless you're absolutely certain both are needed.`;
|
|
||||||
|
|
||||||
const writingTaskIntent: Intent = {
|
|
||||||
name: 'writing_task',
|
|
||||||
description,
|
|
||||||
requiresSearch: false,
|
|
||||||
enabled: (config) => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default writingTaskIntent;
|
|
||||||
@@ -1,26 +1,69 @@
|
|||||||
import { ResearcherOutput, SearchAgentInput } from './types';
|
import { ResearcherOutput, SearchAgentInput } from './types';
|
||||||
import SessionManager from '@/lib/session';
|
import SessionManager from '@/lib/session';
|
||||||
import Classifier from './classifier';
|
import { classify } from './classifier';
|
||||||
import { WidgetRegistry } from './widgets';
|
|
||||||
import Researcher from './researcher';
|
import Researcher from './researcher';
|
||||||
import { getWriterPrompt } from '@/lib/prompts/search/writer';
|
import { getWriterPrompt } from '@/lib/prompts/search/writer';
|
||||||
import fs from 'fs';
|
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 {
|
class SearchAgent {
|
||||||
async searchAsync(session: SessionManager, input: SearchAgentInput) {
|
async searchAsync(session: SessionManager, input: SearchAgentInput) {
|
||||||
const classifier = new Classifier();
|
const exists = await db.query.messages.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(messages.chatId, input.chatId),
|
||||||
|
eq(messages.messageId, input.messageId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const classification = await classifier.classify({
|
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,
|
chatHistory: input.chatHistory,
|
||||||
enabledSources: input.config.sources,
|
enabledSources: input.config.sources,
|
||||||
query: input.followUp,
|
query: input.followUp,
|
||||||
llm: input.config.llm,
|
llm: input.config.llm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const widgetPromise = WidgetRegistry.executeAll(classification.widgets, {
|
const widgetPromise = WidgetExecutor.executeAll({
|
||||||
|
classification,
|
||||||
|
chatHistory: input.chatHistory,
|
||||||
|
followUp: input.followUp,
|
||||||
llm: input.config.llm,
|
llm: input.config.llm,
|
||||||
embedding: input.config.embedding,
|
|
||||||
session: session,
|
|
||||||
}).then((widgetOutputs) => {
|
}).then((widgetOutputs) => {
|
||||||
widgetOutputs.forEach((o) => {
|
widgetOutputs.forEach((o) => {
|
||||||
session.emitBlock({
|
session.emitBlock({
|
||||||
@@ -37,7 +80,7 @@ class SearchAgent {
|
|||||||
|
|
||||||
let searchPromise: Promise<ResearcherOutput> | null = null;
|
let searchPromise: Promise<ResearcherOutput> | null = null;
|
||||||
|
|
||||||
if (!classification.skipSearch) {
|
if (!classification.classification.skipSearch) {
|
||||||
const researcher = new Researcher();
|
const researcher = new Researcher();
|
||||||
searchPromise = researcher.research(session, {
|
searchPromise = researcher.research(session, {
|
||||||
chatHistory: input.chatHistory,
|
chatHistory: input.chatHistory,
|
||||||
@@ -56,22 +99,32 @@ class SearchAgent {
|
|||||||
type: 'researchComplete',
|
type: 'researchComplete',
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalContext =
|
let finalContext =
|
||||||
searchResults?.findings
|
'<Query to be answered without searching; Search not made>';
|
||||||
.filter((f) => f.type === 'search_results')
|
|
||||||
.flatMap((f) => f.results)
|
if (searchResults) {
|
||||||
.map((f) => `${f.metadata.title}: ${f.content}`)
|
finalContext = searchResults?.searchFindings
|
||||||
.join('\n') || '';
|
.map(
|
||||||
|
(f, index) =>
|
||||||
|
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
const widgetContext = widgetOutputs
|
const widgetContext = widgetOutputs
|
||||||
.map((o) => {
|
.map((o) => {
|
||||||
return `${o.type}: ${o.llmContext}`;
|
return `<result>${o.llmContext}</result>`;
|
||||||
})
|
})
|
||||||
.join('\n-------------\n');
|
.join('\n-------------\n');
|
||||||
|
|
||||||
const finalContextWithWidgets = `<search_results note="These are the search results and you can cite these">${finalContext}</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, you can use this information to answer the query but do not CITE this as a souce">${widgetContext}</widgets_result>`;
|
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 writerPrompt = getWriterPrompt(finalContextWithWidgets);
|
|
||||||
const answerStream = input.config.llm.streamText({
|
const answerStream = input.config.llm.streamText({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -86,18 +139,53 @@ class SearchAgent {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
let accumulatedText = '';
|
let responseBlockId = '';
|
||||||
|
|
||||||
for await (const chunk of answerStream) {
|
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', {
|
session.emitBlock(block);
|
||||||
type: 'response',
|
|
||||||
data: chunk.contentChunk,
|
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', {});
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { ResearchAction } from '../../types';
|
import { ResearchAction } from '../../types';
|
||||||
|
|
||||||
|
const actionDescription = `
|
||||||
|
Use this action ONLY when you have completed all necessary research and are ready to provide a final answer to the user. This indicates that you have gathered sufficient information from previous steps and are concluding the research process.
|
||||||
|
YOU MUST CALL THIS ACTION TO SIGNAL COMPLETION; DO NOT OUTPUT FINAL ANSWERS DIRECTLY TO THE USER.
|
||||||
|
IT WILL BE AUTOMATICALLY TRIGGERED IF MAXIMUM ITERATIONS ARE REACHED SO IF YOU'RE LOW ON ITERATIONS, DON'T CALL IT AND INSTEAD FOCUS ON GATHERING ESSENTIAL INFO FIRST.
|
||||||
|
`;
|
||||||
|
|
||||||
const doneAction: ResearchAction<any> = {
|
const doneAction: ResearchAction<any> = {
|
||||||
name: 'done',
|
name: 'done',
|
||||||
description:
|
schema: z.object({}),
|
||||||
"Indicates that the research process is complete and no further actions are needed. Use this action when you have gathered sufficient information to answer the user's query.",
|
getToolDescription: () =>
|
||||||
|
'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,
|
enabled: (_) => true,
|
||||||
schema: z.object({
|
|
||||||
type: z.literal('done'),
|
|
||||||
}),
|
|
||||||
execute: async (params, additionalConfig) => {
|
execute: async (params, additionalConfig) => {
|
||||||
return {
|
return {
|
||||||
type: 'done',
|
type: 'done',
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
import academicSearchAction from './search/academicSearch';
|
||||||
import doneAction from './done';
|
import doneAction from './done';
|
||||||
|
import planAction from './plan';
|
||||||
import ActionRegistry from './registry';
|
import ActionRegistry from './registry';
|
||||||
import webSearchAction from './webSearch';
|
import scrapeURLAction from './scrapeURL';
|
||||||
|
import socialSearchAction from './search/socialSearch';
|
||||||
|
import uploadsSearchAction from './uploadsSearch';
|
||||||
|
import webSearchAction from './search/webSearch';
|
||||||
|
|
||||||
ActionRegistry.register(webSearchAction);
|
ActionRegistry.register(webSearchAction);
|
||||||
ActionRegistry.register(doneAction);
|
ActionRegistry.register(doneAction);
|
||||||
|
ActionRegistry.register(planAction);
|
||||||
|
ActionRegistry.register(scrapeURLAction);
|
||||||
|
ActionRegistry.register(uploadsSearchAction);
|
||||||
|
ActionRegistry.register(academicSearchAction);
|
||||||
|
ActionRegistry.register(socialSearchAction);
|
||||||
|
|
||||||
export { ActionRegistry };
|
export { ActionRegistry };
|
||||||
|
|||||||
40
src/lib/agents/search/researcher/actions/plan.ts
Normal file
40
src/lib/agents/search/researcher/actions/plan.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
import { ResearchAction } from '../../types';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
plan: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'A concise natural-language plan in one short paragraph. Open with a short intent phrase (e.g., "Okay, the user wants to...", "Searching for...", "Looking into...") and lay out the steps you will take.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionDescription = `
|
||||||
|
Use this tool 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.
|
||||||
|
Make sure to not include reference to any tools or actions you might take, just the plan itself. The user isn't aware about tools, but they love to see your thought process.
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
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: '__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.',
|
||||||
|
getDescription: () => actionDescription,
|
||||||
|
enabled: (config) => config.mode !== 'speed',
|
||||||
|
execute: async (input, _) => {
|
||||||
|
return {
|
||||||
|
type: 'reasoning',
|
||||||
|
reasoning: input.plan,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default planAction;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { Tool, ToolCall } from '@/lib/models/types';
|
||||||
import {
|
import {
|
||||||
ActionConfig,
|
|
||||||
ActionOutput,
|
ActionOutput,
|
||||||
AdditionalConfig,
|
AdditionalConfig,
|
||||||
ClassifierOutput,
|
ClassifierOutput,
|
||||||
ResearchAction,
|
ResearchAction,
|
||||||
|
SearchAgentConfig,
|
||||||
|
SearchSources,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
class ActionRegistry {
|
class ActionRegistry {
|
||||||
@@ -19,26 +21,54 @@ class ActionRegistry {
|
|||||||
|
|
||||||
static getAvailableActions(config: {
|
static getAvailableActions(config: {
|
||||||
classification: ClassifierOutput;
|
classification: ClassifierOutput;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
sources: SearchSources[];
|
||||||
}): ResearchAction[] {
|
}): ResearchAction[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
this.actions.values().filter((action) => action.enabled(config)),
|
this.actions.values().filter((action) => action.enabled(config)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getAvailableActionTools(config: {
|
||||||
|
classification: ClassifierOutput;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
sources: SearchSources[];
|
||||||
|
}): Tool[] {
|
||||||
|
const availableActions = this.getAvailableActions(config);
|
||||||
|
|
||||||
|
return availableActions.map((action) => ({
|
||||||
|
name: action.name,
|
||||||
|
description: action.getToolDescription({ mode: config.mode }),
|
||||||
|
schema: action.schema,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
static getAvailableActionsDescriptions(config: {
|
static getAvailableActionsDescriptions(config: {
|
||||||
classification: ClassifierOutput;
|
classification: ClassifierOutput;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
sources: SearchSources[];
|
||||||
}): string {
|
}): string {
|
||||||
const availableActions = this.getAvailableActions(config);
|
const availableActions = this.getAvailableActions(config);
|
||||||
|
|
||||||
return availableActions
|
return availableActions
|
||||||
.map((action) => `------------\n##${action.name}\n${action.description}`)
|
.map(
|
||||||
|
(action) =>
|
||||||
|
`<tool name="${action.name}">\n${action.getDescription({ mode: config.mode })}\n</tool>`,
|
||||||
|
)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
static async execute(
|
static async execute(
|
||||||
name: string,
|
name: string,
|
||||||
params: any,
|
params: any,
|
||||||
additionalConfig: AdditionalConfig,
|
additionalConfig: AdditionalConfig & {
|
||||||
|
researchBlockId: string;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const action = this.actions.get(name);
|
const action = this.actions.get(name);
|
||||||
|
|
||||||
@@ -50,16 +80,20 @@ class ActionRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async executeAll(
|
static async executeAll(
|
||||||
actions: ActionConfig[],
|
actions: ToolCall[],
|
||||||
additionalConfig: AdditionalConfig,
|
additionalConfig: AdditionalConfig & {
|
||||||
|
researchBlockId: string;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
},
|
||||||
): Promise<ActionOutput[]> {
|
): Promise<ActionOutput[]> {
|
||||||
const results: ActionOutput[] = [];
|
const results: ActionOutput[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
actions.map(async (actionConfig) => {
|
actions.map(async (actionConfig) => {
|
||||||
const output = await this.execute(
|
const output = await this.execute(
|
||||||
actionConfig.type,
|
actionConfig.name,
|
||||||
actionConfig.params,
|
actionConfig.arguments,
|
||||||
additionalConfig,
|
additionalConfig,
|
||||||
);
|
);
|
||||||
results.push(output);
|
results.push(output);
|
||||||
|
|||||||
211
src/lib/agents/search/researcher/actions/scrapeURL.ts
Normal file
211
src/lib/agents/search/researcher/actions/scrapeURL.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
import { ResearchAction } from '../../types';
|
||||||
|
import { Chunk, ReadingResearchBlock } from '@/lib/types';
|
||||||
|
import Scraper from '@/lib/scraper';
|
||||||
|
import { splitText } from '@/lib/utils/splitText';
|
||||||
|
|
||||||
|
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.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionDescription = `
|
||||||
|
Use this tool to scrape and extract content from the provided URLs. This is useful when you the user has asked you to extract or summarize information from specific web pages. You can provide up to 3 URLs at a time. NEVER CALL THIS TOOL EXPLICITLY YOURSELF UNLESS INSTRUCTED TO DO SO BY THE USER.
|
||||||
|
You should only call this tool when the user has specifically requested information from certain web pages, never call this yourself to get extra information without user instruction.
|
||||||
|
|
||||||
|
For example, if the user says "Please summarize the content of https://example.com/article", you can call this tool with that URL to get the content and then provide the summary or "What does X mean according to https://example.com/page", you can call this tool with that URL to get the content and provide the explanation.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scrapeURLAction: ResearchAction<typeof schema> = {
|
||||||
|
name: 'scrape_url',
|
||||||
|
schema: schema,
|
||||||
|
getToolDescription: () =>
|
||||||
|
'Use this tool to scrape and extract content from the provided URLs. This is useful when you the user has asked you to extract or summarize information from specific web pages. You can provide up to 3 URLs at a time. NEVER CALL THIS TOOL EXPLICITLY YOURSELF UNLESS INSTRUCTED TO DO SO BY THE USER.',
|
||||||
|
getDescription: () => actionDescription,
|
||||||
|
enabled: (_) => true,
|
||||||
|
execute: async (params, additionalConfig) => {
|
||||||
|
params.urls = params.urls.slice(0, 3);
|
||||||
|
|
||||||
|
let readingBlockId = crypto.randomUUID();
|
||||||
|
let readingEmitted = false;
|
||||||
|
|
||||||
|
const researchBlock = additionalConfig.session.getBlock(
|
||||||
|
additionalConfig.researchBlockId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: Chunk[] = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
params.urls.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const scraped = await Scraper.scrape(url);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!readingEmitted &&
|
||||||
|
researchBlock &&
|
||||||
|
researchBlock.type === 'research'
|
||||||
|
) {
|
||||||
|
readingEmitted = true;
|
||||||
|
researchBlock.data.subSteps.push({
|
||||||
|
id: readingBlockId,
|
||||||
|
type: 'reading',
|
||||||
|
reading: [
|
||||||
|
{
|
||||||
|
content: '',
|
||||||
|
metadata: {
|
||||||
|
url,
|
||||||
|
title: scraped.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
additionalConfig.session.updateBlock(
|
||||||
|
additionalConfig.researchBlockId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/data/subSteps',
|
||||||
|
value: researchBlock.data.subSteps,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
readingEmitted &&
|
||||||
|
researchBlock &&
|
||||||
|
researchBlock.type === 'research'
|
||||||
|
) {
|
||||||
|
const subStepIndex = researchBlock.data.subSteps.findIndex(
|
||||||
|
(step: any) => step.id === readingBlockId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subStep = researchBlock.data.subSteps[
|
||||||
|
subStepIndex
|
||||||
|
] as ReadingResearchBlock;
|
||||||
|
|
||||||
|
subStep.reading.push({
|
||||||
|
content: '',
|
||||||
|
metadata: {
|
||||||
|
url,
|
||||||
|
title: scraped.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
additionalConfig.session.updateBlock(
|
||||||
|
additionalConfig.researchBlockId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/data/subSteps',
|
||||||
|
value: researchBlock.data.subSteps,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: accumulatedContent,
|
||||||
|
metadata: {
|
||||||
|
url,
|
||||||
|
title: scraped.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
content: `Failed to fetch content from ${url}: ${error}`,
|
||||||
|
metadata: {
|
||||||
|
url,
|
||||||
|
title: `Error scraping ${url}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'search_results',
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default scrapeURLAction;
|
||||||
@@ -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;
|
||||||
114
src/lib/agents/search/researcher/actions/search/webSearch.ts
Normal file
114
src/lib/agents/search/researcher/actions/search/webSearch.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
import { ResearchAction } from '../../../types';
|
||||||
|
import { ResearchBlock } from '@/lib/types';
|
||||||
|
import { executeSearch } from './baseSearch';
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
type: z.literal('web_search'),
|
||||||
|
queries: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe('An array of search queries to perform web searches for.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const speedModePrompt = `
|
||||||
|
Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
|
||||||
|
You are currently on speed mode, meaning you would only get to call this tool once. Make sure to prioritize the most important queries that are likely to get you the needed information in one go.
|
||||||
|
|
||||||
|
Your queries should be very targeted and specific to the information you need, avoid broad or generic queries.
|
||||||
|
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 the features of a new technology, you might use queries like "GPT-5.1 features", "GPT-5.1 release date", "GPT-5.1 improvements" rather than a broad query like "Tell me about GPT-5.1".
|
||||||
|
|
||||||
|
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.
|
||||||
|
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed information.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const balancedModePrompt = `
|
||||||
|
Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
|
||||||
|
|
||||||
|
You can call this tool several times if needed to gather enough information.
|
||||||
|
Start initially with broader queries to get an overview, then narrow down with more specific queries based on the results you receive.
|
||||||
|
|
||||||
|
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. __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. __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. __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.
|
||||||
|
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed information. You can call this tools, multiple times as needed.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const qualityModePrompt = `
|
||||||
|
Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
|
||||||
|
|
||||||
|
You have to call this tool several times to gather enough information unless the question is very simple (like greeting questions or basic facts).
|
||||||
|
Start initially with broader queries to get an overview, then narrow down with more specific queries based on the results you receive.
|
||||||
|
Never stop before at least 5-6 iterations of searches unless the user question is very simple.
|
||||||
|
|
||||||
|
Your queries shouldn't be sentences but rather keywords that are SEO friendly and can be used to search the web for information.
|
||||||
|
|
||||||
|
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.
|
||||||
|
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed information. You can call this tools, multiple times as needed.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const webSearchAction: ResearchAction<typeof actionSchema> = {
|
||||||
|
name: 'web_search',
|
||||||
|
schema: actionSchema,
|
||||||
|
getToolDescription: () =>
|
||||||
|
"Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.",
|
||||||
|
getDescription: (config) => {
|
||||||
|
let prompt = '';
|
||||||
|
|
||||||
|
switch (config.mode) {
|
||||||
|
case 'speed':
|
||||||
|
prompt = speedModePrompt;
|
||||||
|
break;
|
||||||
|
case 'balanced':
|
||||||
|
prompt = balancedModePrompt;
|
||||||
|
break;
|
||||||
|
case 'quality':
|
||||||
|
prompt = qualityModePrompt;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
prompt = speedModePrompt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
},
|
||||||
|
enabled: (config) =>
|
||||||
|
config.sources.includes('web') &&
|
||||||
|
config.classification.classification.skipSearch === false,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'search_results',
|
||||||
|
results: results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default webSearchAction;
|
||||||
102
src/lib/agents/search/researcher/actions/uploadsSearch.ts
Normal file
102
src/lib/agents/search/researcher/actions/uploadsSearch.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
import { ResearchAction } from '../../types';
|
||||||
|
import UploadStore from '@/lib/uploads/store';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
queries: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
'A list of queries to search in user uploaded files. Can be a maximum of 3 queries.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadsSearchAction: ResearchAction<typeof schema> = {
|
||||||
|
name: 'uploads_search',
|
||||||
|
enabled: (config) =>
|
||||||
|
(config.classification.classification.personalSearch &&
|
||||||
|
config.fileIds.length > 0) ||
|
||||||
|
config.fileIds.length > 0,
|
||||||
|
schema,
|
||||||
|
getToolDescription: () =>
|
||||||
|
`Use this tool to perform searches over the user's uploaded files. This is useful when you need to gather information from the user's documents to answer their questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.`,
|
||||||
|
getDescription: () => `
|
||||||
|
Use this tool to perform searches over the user's uploaded files. This is useful when you need to gather information from the user's documents to answer their questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
|
||||||
|
Always ensure that the queries you use are directly relevant to the user's request and pertain to the content of their uploaded files.
|
||||||
|
|
||||||
|
For example, if the user says "Please find information about X in my uploaded documents", you can call this tool with a query related to X to retrieve the relevant information from their files.
|
||||||
|
Never use this tool to search the web or for information that is not contained within the user's uploaded files.
|
||||||
|
`,
|
||||||
|
execute: async (input, additionalConfig) => {
|
||||||
|
input.queries = input.queries.slice(0, 3);
|
||||||
|
|
||||||
|
const researchBlock = additionalConfig.session.getBlock(
|
||||||
|
additionalConfig.researchBlockId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (researchBlock && researchBlock.type === 'research') {
|
||||||
|
researchBlock.data.subSteps.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'upload_searching',
|
||||||
|
queries: input.queries,
|
||||||
|
});
|
||||||
|
|
||||||
|
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/data/subSteps',
|
||||||
|
value: researchBlock.data.subSteps,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadStore = new UploadStore({
|
||||||
|
embeddingModel: additionalConfig.embedding,
|
||||||
|
fileIds: additionalConfig.fileIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await uploadStore.query(input.queries, 10);
|
||||||
|
|
||||||
|
const seenIds = new Map<string, number>();
|
||||||
|
|
||||||
|
const filteredSearchResults = results
|
||||||
|
.map((result, index) => {
|
||||||
|
if (result.metadata.url && !seenIds.has(result.metadata.url)) {
|
||||||
|
seenIds.set(result.metadata.url, index);
|
||||||
|
return result;
|
||||||
|
} else if (result.metadata.url && seenIds.has(result.metadata.url)) {
|
||||||
|
const existingIndex = seenIds.get(result.metadata.url)!;
|
||||||
|
const existingResult = results[existingIndex];
|
||||||
|
|
||||||
|
existingResult.content += `\n\n${result.content}`;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.filter((r) => r !== undefined);
|
||||||
|
|
||||||
|
if (researchBlock && researchBlock.type === 'research') {
|
||||||
|
researchBlock.data.subSteps.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'upload_search_results',
|
||||||
|
results: filteredSearchResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/data/subSteps',
|
||||||
|
value: researchBlock.data.subSteps,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'search_results',
|
||||||
|
results: filteredSearchResults,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default uploadsSearchAction;
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import z from 'zod';
|
|
||||||
import { ResearchAction } from '../../types';
|
|
||||||
import { searchSearxng } from '@/lib/searxng';
|
|
||||||
import { Chunk } from '@/lib/types';
|
|
||||||
|
|
||||||
const actionSchema = z.object({
|
|
||||||
type: z.literal('web_search'),
|
|
||||||
queries: z
|
|
||||||
.array(z.string())
|
|
||||||
.describe('An array of search queries to perform web searches for.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionDescription = `
|
|
||||||
You have to use this action aggressively to find relevant information from the web to answer user queries. You can combine this action with other actions to gather comprehensive data. Always ensure that you provide accurate and up-to-date information by leveraging web search results.
|
|
||||||
When this action is present, you must use it to obtain current information from the web.
|
|
||||||
|
|
||||||
### How to use:
|
|
||||||
1. For speed search mode, you can use this action once. Make sure to cover all aspects of the user's query in that single search.
|
|
||||||
2. If you're on quality mode, you'll get to use this action up to two times. Use the first search to gather general information, and the second search to fill in any gaps or get more specific details based on the initial findings.
|
|
||||||
3. If you're set on quality mode, then you will get to use this action multiple times to gather more information. Use your judgment to decide when additional searches are necessary to provide a thorough and accurate response.
|
|
||||||
|
|
||||||
Input: An array of search queries. Make sure the queries are relevant to the user's request and cover different aspects if necessary. You can include a maximum of 3 queries. Make sure the queries are SEO friendly and not sentences rather keywords which can be used to search a search engine like Google, Bing, etc.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const webSearchAction: ResearchAction<typeof actionSchema> = {
|
|
||||||
name: 'web_search',
|
|
||||||
description: actionDescription,
|
|
||||||
schema: actionSchema,
|
|
||||||
enabled: (config) => config.classification.intents.includes('web_search'),
|
|
||||||
execute: async (input, _) => {
|
|
||||||
let results: Chunk[] = [];
|
|
||||||
|
|
||||||
const search = async (q: string) => {
|
|
||||||
const res = await searchSearxng(q);
|
|
||||||
|
|
||||||
res.results.forEach((r) => {
|
|
||||||
results.push({
|
|
||||||
content: r.content || r.title,
|
|
||||||
metadata: {
|
|
||||||
title: r.title,
|
|
||||||
url: r.url,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(input.queries.map(search));
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'search_results',
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default webSearchAction;
|
|
||||||
@@ -1,46 +1,37 @@
|
|||||||
import z from 'zod';
|
import { ActionOutput, ResearcherInput, ResearcherOutput } from '../types';
|
||||||
import {
|
|
||||||
ActionConfig,
|
|
||||||
ActionOutput,
|
|
||||||
ResearcherInput,
|
|
||||||
ResearcherOutput,
|
|
||||||
} from '../types';
|
|
||||||
import { ActionRegistry } from './actions';
|
import { ActionRegistry } from './actions';
|
||||||
import { getResearcherPrompt } from '@/lib/prompts/search/researcher';
|
import { getResearcherPrompt } from '@/lib/prompts/search/researcher';
|
||||||
import SessionManager from '@/lib/session';
|
import SessionManager from '@/lib/session';
|
||||||
import { ReasoningResearchBlock } from '@/lib/types';
|
import { Message, ReasoningResearchBlock } from '@/lib/types';
|
||||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||||
|
import { ToolCall } from '@/lib/models/types';
|
||||||
|
|
||||||
class Researcher {
|
class Researcher {
|
||||||
async research(
|
async research(
|
||||||
session: SessionManager,
|
session: SessionManager,
|
||||||
input: ResearcherInput,
|
input: ResearcherInput,
|
||||||
): Promise<ResearcherOutput> {
|
): Promise<ResearcherOutput> {
|
||||||
let findings: string = '';
|
|
||||||
let actionOutput: ActionOutput[] = [];
|
let actionOutput: ActionOutput[] = [];
|
||||||
let maxIteration =
|
let maxIteration =
|
||||||
input.config.mode === 'speed'
|
input.config.mode === 'speed'
|
||||||
? 1
|
? 2
|
||||||
: input.config.mode === 'balanced'
|
: input.config.mode === 'balanced'
|
||||||
? 3
|
? 6
|
||||||
: 25;
|
: 25;
|
||||||
|
|
||||||
const availableActions = ActionRegistry.getAvailableActions({
|
const availableTools = ActionRegistry.getAvailableActionTools({
|
||||||
classification: input.classification,
|
classification: input.classification,
|
||||||
});
|
fileIds: input.config.fileIds,
|
||||||
|
mode: input.config.mode,
|
||||||
const schema = z.object({
|
sources: input.config.sources,
|
||||||
reasoning: z
|
|
||||||
.string()
|
|
||||||
.describe('The reasoning behind choosing the next action.'),
|
|
||||||
action: z
|
|
||||||
.union(availableActions.map((a) => a.schema))
|
|
||||||
.describe('The action to be performed next.'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableActionsDescription =
|
const availableActionsDescription =
|
||||||
ActionRegistry.getAvailableActionsDescriptions({
|
ActionRegistry.getAvailableActionsDescriptions({
|
||||||
classification: input.classification,
|
classification: input.classification,
|
||||||
|
fileIds: input.config.fileIds,
|
||||||
|
mode: input.config.mode,
|
||||||
|
sources: input.config.sources,
|
||||||
});
|
});
|
||||||
|
|
||||||
const researchBlockId = crypto.randomUUID();
|
const researchBlockId = crypto.randomUUID();
|
||||||
@@ -53,37 +44,36 @@ class Researcher {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agentMessageHistory: Message[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `
|
||||||
|
<conversation>
|
||||||
|
${formatChatHistoryAsString(input.chatHistory.slice(-10))}
|
||||||
|
User: ${input.followUp} (Standalone question: ${input.classification.standaloneFollowUp})
|
||||||
|
</conversation>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
for (let i = 0; i < maxIteration; i++) {
|
for (let i = 0; i < maxIteration; i++) {
|
||||||
const researcherPrompt = getResearcherPrompt(
|
const researcherPrompt = getResearcherPrompt(
|
||||||
availableActionsDescription,
|
availableActionsDescription,
|
||||||
input.config.mode,
|
input.config.mode,
|
||||||
i,
|
i,
|
||||||
maxIteration,
|
maxIteration,
|
||||||
|
input.config.fileIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const actionStream = input.config.llm.streamObject<
|
const actionStream = input.config.llm.streamText({
|
||||||
z.infer<typeof schema>
|
|
||||||
>({
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: researcherPrompt,
|
content: researcherPrompt,
|
||||||
},
|
},
|
||||||
{
|
...agentMessageHistory,
|
||||||
role: 'user',
|
|
||||||
content: `
|
|
||||||
<conversation>
|
|
||||||
${formatChatHistoryAsString(input.chatHistory.slice(-10))}
|
|
||||||
User: ${input.followUp} (Standalone question: ${input.classification.standaloneFollowUp})
|
|
||||||
</conversation>
|
|
||||||
|
|
||||||
<previous_actions>
|
|
||||||
${findings}
|
|
||||||
</previous_actions>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
schema,
|
tools: availableTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
const block = session.getBlock(researchBlockId);
|
const block = session.getBlock(researchBlockId);
|
||||||
@@ -91,43 +81,26 @@ class Researcher {
|
|||||||
let reasoningEmitted = false;
|
let reasoningEmitted = false;
|
||||||
let reasoningId = crypto.randomUUID();
|
let reasoningId = crypto.randomUUID();
|
||||||
|
|
||||||
let finalActionRes: any;
|
let finalToolCalls: ToolCall[] = [];
|
||||||
|
|
||||||
for await (const partialRes of actionStream) {
|
for await (const partialRes of actionStream) {
|
||||||
try {
|
if (partialRes.toolCallChunk.length > 0) {
|
||||||
if (
|
partialRes.toolCallChunk.forEach((tc) => {
|
||||||
partialRes.reasoning &&
|
if (
|
||||||
!reasoningEmitted &&
|
tc.name === '__reasoning_preamble' &&
|
||||||
block &&
|
tc.arguments['plan'] &&
|
||||||
block.type === 'research'
|
!reasoningEmitted &&
|
||||||
) {
|
block &&
|
||||||
reasoningEmitted = true;
|
block.type === 'research'
|
||||||
block.data.subSteps.push({
|
) {
|
||||||
id: reasoningId,
|
reasoningEmitted = true;
|
||||||
type: 'reasoning',
|
|
||||||
reasoning: partialRes.reasoning,
|
block.data.subSteps.push({
|
||||||
});
|
id: reasoningId,
|
||||||
session.updateBlock(researchBlockId, [
|
type: 'reasoning',
|
||||||
{
|
reasoning: tc.arguments['plan'],
|
||||||
op: 'replace',
|
});
|
||||||
path: '/data/subSteps',
|
|
||||||
value: block.data.subSteps,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else if (
|
|
||||||
partialRes.reasoning &&
|
|
||||||
reasoningEmitted &&
|
|
||||||
block &&
|
|
||||||
block.type === 'research'
|
|
||||||
) {
|
|
||||||
const subStepIndex = block.data.subSteps.findIndex(
|
|
||||||
(step: any) => step.id === reasoningId,
|
|
||||||
);
|
|
||||||
if (subStepIndex !== -1) {
|
|
||||||
const subStep = block.data.subSteps[
|
|
||||||
subStepIndex
|
|
||||||
] as ReasoningResearchBlock;
|
|
||||||
subStep.reasoning = partialRes.reasoning;
|
|
||||||
session.updateBlock(researchBlockId, [
|
session.updateBlock(researchBlockId, [
|
||||||
{
|
{
|
||||||
op: 'replace',
|
op: 'replace',
|
||||||
@@ -135,95 +108,114 @@ class Researcher {
|
|||||||
value: block.data.subSteps,
|
value: block.data.subSteps,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
} else if (
|
||||||
}
|
tc.name === '__reasoning_preamble' &&
|
||||||
|
tc.arguments['plan'] &&
|
||||||
|
reasoningEmitted &&
|
||||||
|
block &&
|
||||||
|
block.type === 'research'
|
||||||
|
) {
|
||||||
|
const subStepIndex = block.data.subSteps.findIndex(
|
||||||
|
(step: any) => step.id === reasoningId,
|
||||||
|
);
|
||||||
|
|
||||||
finalActionRes = partialRes;
|
if (subStepIndex !== -1) {
|
||||||
} catch (e) {
|
const subStep = block.data.subSteps[
|
||||||
// nothing
|
subStepIndex
|
||||||
|
] as ReasoningResearchBlock;
|
||||||
|
subStep.reasoning = tc.arguments['plan'];
|
||||||
|
session.updateBlock(researchBlockId, [
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/data/subSteps',
|
||||||
|
value: block.data.subSteps,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = finalToolCalls.findIndex(
|
||||||
|
(ftc) => ftc.id === tc.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
finalToolCalls[existingIndex].arguments = tc.arguments;
|
||||||
|
} else {
|
||||||
|
finalToolCalls.push(tc);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalActionRes.action.type === 'done') {
|
if (finalToolCalls.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionConfig: ActionConfig = {
|
if (finalToolCalls[finalToolCalls.length - 1].name === 'done') {
|
||||||
type: finalActionRes.action.type as string,
|
break;
|
||||||
params: finalActionRes.action,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const queries = actionConfig.params.queries || [];
|
agentMessageHistory.push({
|
||||||
if (block && block.type === 'research') {
|
role: 'assistant',
|
||||||
block.data.subSteps.push({
|
content: '',
|
||||||
id: crypto.randomUUID(),
|
tool_calls: finalToolCalls,
|
||||||
type: 'searching',
|
});
|
||||||
searching: queries,
|
|
||||||
|
const actionResults = await ActionRegistry.executeAll(finalToolCalls, {
|
||||||
|
llm: input.config.llm,
|
||||||
|
embedding: input.config.embedding,
|
||||||
|
session: session,
|
||||||
|
researchBlockId: researchBlockId,
|
||||||
|
fileIds: input.config.fileIds,
|
||||||
|
mode: input.config.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
actionOutput.push(...actionResults);
|
||||||
|
|
||||||
|
actionResults.forEach((action, i) => {
|
||||||
|
agentMessageHistory.push({
|
||||||
|
role: 'tool',
|
||||||
|
id: finalToolCalls[i].id,
|
||||||
|
name: finalToolCalls[i].name,
|
||||||
|
content: JSON.stringify(action),
|
||||||
});
|
});
|
||||||
session.updateBlock(researchBlockId, [
|
});
|
||||||
{ op: 'replace', path: '/data/subSteps', value: block.data.subSteps },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
findings += `\n---\nIteration ${i + 1}:\n`;
|
|
||||||
findings += 'Reasoning: ' + finalActionRes.reasoning + '\n';
|
|
||||||
findings += `Executing Action: ${actionConfig.type} with params ${JSON.stringify(actionConfig.params)}\n`;
|
|
||||||
|
|
||||||
const actionResult = await ActionRegistry.execute(
|
|
||||||
actionConfig.type,
|
|
||||||
actionConfig.params,
|
|
||||||
{
|
|
||||||
llm: input.config.llm,
|
|
||||||
embedding: input.config.embedding,
|
|
||||||
session: session,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
actionOutput.push(actionResult);
|
|
||||||
|
|
||||||
if (actionResult.type === 'search_results') {
|
|
||||||
if (block && block.type === 'research') {
|
|
||||||
block.data.subSteps.push({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type: 'reading',
|
|
||||||
reading: actionResult.results,
|
|
||||||
});
|
|
||||||
session.updateBlock(researchBlockId, [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
path: '/data/subSteps',
|
|
||||||
value: block.data.subSteps,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
findings += actionResult.results
|
|
||||||
.map(
|
|
||||||
(r) =>
|
|
||||||
`Title: ${r.metadata.title}\nURL: ${r.metadata.url}\nContent: ${r.content}\n`,
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
findings += '\n---------\n';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = actionOutput.filter(
|
const searchResults = actionOutput
|
||||||
(a) => a.type === 'search_results',
|
.filter((a) => a.type === 'search_results')
|
||||||
);
|
.flatMap((a) => a.results);
|
||||||
|
|
||||||
session.emit('data', {
|
const seenUrls = new Map<string, number>();
|
||||||
type: 'sources',
|
|
||||||
data: searchResults
|
const filteredSearchResults = searchResults
|
||||||
.flatMap((a) => a.results)
|
.map((result, index) => {
|
||||||
.map((r) => ({
|
if (result.metadata.url && !seenUrls.has(result.metadata.url)) {
|
||||||
content: r.content,
|
seenUrls.set(result.metadata.url, index);
|
||||||
metadata: r.metadata,
|
return result;
|
||||||
})),
|
} else if (result.metadata.url && seenUrls.has(result.metadata.url)) {
|
||||||
|
const existingIndex = seenUrls.get(result.metadata.url)!;
|
||||||
|
|
||||||
|
const existingResult = searchResults[existingIndex];
|
||||||
|
|
||||||
|
existingResult.content += `\n\n${result.content}`;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.filter((r) => r !== undefined);
|
||||||
|
|
||||||
|
session.emitBlock({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'source',
|
||||||
|
data: filteredSearchResults,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
findings: actionOutput,
|
findings: actionOutput,
|
||||||
|
searchFindings: filteredSearchResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,37 +8,32 @@ export type SearchSources = 'web' | 'discussions' | 'academic';
|
|||||||
|
|
||||||
export type SearchAgentConfig = {
|
export type SearchAgentConfig = {
|
||||||
sources: SearchSources[];
|
sources: SearchSources[];
|
||||||
|
fileIds: string[];
|
||||||
llm: BaseLLM<any>;
|
llm: BaseLLM<any>;
|
||||||
embedding: BaseEmbedding<any>;
|
embedding: BaseEmbedding<any>;
|
||||||
mode: 'speed' | 'balanced' | 'quality';
|
mode: 'speed' | 'balanced' | 'quality';
|
||||||
|
systemInstructions: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchAgentInput = {
|
export type SearchAgentInput = {
|
||||||
chatHistory: ChatTurnMessage[];
|
chatHistory: ChatTurnMessage[];
|
||||||
followUp: string;
|
followUp: string;
|
||||||
config: SearchAgentConfig;
|
config: SearchAgentConfig;
|
||||||
|
chatId: string;
|
||||||
|
messageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Intent {
|
export type WidgetInput = {
|
||||||
name: string;
|
chatHistory: ChatTurnMessage[];
|
||||||
description: string;
|
followUp: string;
|
||||||
requiresSearch: boolean;
|
classification: ClassifierOutput;
|
||||||
enabled: (config: { sources: SearchSources[] }) => boolean;
|
llm: BaseLLM<any>;
|
||||||
}
|
|
||||||
|
|
||||||
export type Widget<TSchema extends z.ZodObject<any> = z.ZodObject<any>> = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
schema: TSchema;
|
|
||||||
execute: (
|
|
||||||
params: z.infer<TSchema>,
|
|
||||||
additionalConfig: AdditionalConfig,
|
|
||||||
) => Promise<WidgetOutput>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WidgetConfig = {
|
export type Widget = {
|
||||||
type: string;
|
type: string;
|
||||||
params: Record<string, any>;
|
shouldExecute: (classification: ClassifierOutput) => boolean;
|
||||||
|
execute: (input: WidgetInput) => Promise<WidgetOutput | void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WidgetOutput = {
|
export type WidgetOutput = {
|
||||||
@@ -55,10 +50,16 @@ export type ClassifierInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ClassifierOutput = {
|
export type ClassifierOutput = {
|
||||||
skipSearch: boolean;
|
classification: {
|
||||||
|
skipSearch: boolean;
|
||||||
|
personalSearch: boolean;
|
||||||
|
academicSearch: boolean;
|
||||||
|
discussionSearch: boolean;
|
||||||
|
showWeatherWidget: boolean;
|
||||||
|
showStockWidget: boolean;
|
||||||
|
showCalculationWidget: boolean;
|
||||||
|
};
|
||||||
standaloneFollowUp: string;
|
standaloneFollowUp: string;
|
||||||
intents: string[];
|
|
||||||
widgets: WidgetConfig[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdditionalConfig = {
|
export type AdditionalConfig = {
|
||||||
@@ -76,6 +77,7 @@ export type ResearcherInput = {
|
|||||||
|
|
||||||
export type ResearcherOutput = {
|
export type ResearcherOutput = {
|
||||||
findings: ActionOutput[];
|
findings: ActionOutput[];
|
||||||
|
searchFindings: Chunk[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchActionOutput = {
|
export type SearchActionOutput = {
|
||||||
@@ -87,22 +89,35 @@ export type DoneActionOutput = {
|
|||||||
type: 'done';
|
type: 'done';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionOutput = SearchActionOutput | DoneActionOutput;
|
export type ReasoningResearchAction = {
|
||||||
|
type: 'reasoning';
|
||||||
|
reasoning: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutput =
|
||||||
|
| SearchActionOutput
|
||||||
|
| DoneActionOutput
|
||||||
|
| ReasoningResearchAction;
|
||||||
|
|
||||||
export interface ResearchAction<
|
export interface ResearchAction<
|
||||||
TSchema extends z.ZodObject<any> = z.ZodObject<any>,
|
TSchema extends z.ZodObject<any> = z.ZodObject<any>,
|
||||||
> {
|
> {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
|
||||||
schema: z.ZodObject<any>;
|
schema: z.ZodObject<any>;
|
||||||
enabled: (config: { classification: ClassifierOutput }) => boolean;
|
getToolDescription: (config: { mode: SearchAgentConfig['mode'] }) => string;
|
||||||
|
getDescription: (config: { mode: SearchAgentConfig['mode'] }) => string;
|
||||||
|
enabled: (config: {
|
||||||
|
classification: ClassifierOutput;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
sources: SearchSources[];
|
||||||
|
}) => boolean;
|
||||||
execute: (
|
execute: (
|
||||||
params: z.infer<TSchema>,
|
params: z.infer<TSchema>,
|
||||||
additionalConfig: AdditionalConfig,
|
additionalConfig: AdditionalConfig & {
|
||||||
|
researchBlockId: string;
|
||||||
|
fileIds: string[];
|
||||||
|
mode: SearchAgentConfig['mode'];
|
||||||
|
},
|
||||||
) => Promise<ActionOutput>;
|
) => Promise<ActionOutput>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActionConfig = {
|
|
||||||
type: string;
|
|
||||||
params: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,66 +1,70 @@
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { Widget } from '../types';
|
import { Widget } from '../types';
|
||||||
import { evaluate as mathEval } from 'mathjs';
|
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||||
|
import { exp, evaluate as mathEval } from 'mathjs';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.literal('calculation'),
|
|
||||||
expression: z
|
expression: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe('Mathematical expression to calculate or evaluate.'),
|
||||||
"A valid mathematical expression to be evaluated (e.g., '2 + 2', '3 * (4 + 5)').",
|
notPresent: z
|
||||||
),
|
.boolean()
|
||||||
|
.describe('Whether there is any need for the calculation widget.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const calculationWidget: Widget<typeof schema> = {
|
const system = `
|
||||||
name: 'calculation',
|
<role>
|
||||||
description: `Performs mathematical calculations and evaluates mathematical expressions. Supports arithmetic operations, algebraic equations, functions, and complex mathematical computations.
|
Assistant is a calculation expression extractor. You will recieve a user follow up and a conversation history.
|
||||||
|
Your task is to determine if there is a mathematical expression that needs to be calculated or evaluated. If there is, extract the expression and return it. If there is no need for any calculation, set notPresent to true.
|
||||||
|
</role>
|
||||||
|
|
||||||
**What it provides:**
|
<instructions>
|
||||||
- Evaluates mathematical expressions and returns computed results
|
Make sure that the extracted expression is valid and can be used to calculate the result with Math JS library (https://mathjs.org/). If the expression is not valid, set notPresent to true.
|
||||||
- Handles basic arithmetic (+, -, *, /)
|
If you feel like you cannot extract a valid expression, set notPresent to true.
|
||||||
- Supports functions (sqrt, sin, cos, log, etc.)
|
</instructions>
|
||||||
- Can process complex expressions with parentheses and order of operations
|
|
||||||
|
|
||||||
**When to use:**
|
<output_format>
|
||||||
- User asks to calculate, compute, or evaluate a mathematical expression
|
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||||
- Questions like "what is X", "calculate Y", "how much is Z" where X/Y/Z are math expressions
|
|
||||||
- Any request involving numbers and mathematical operations
|
|
||||||
|
|
||||||
**Example call:**
|
|
||||||
{
|
{
|
||||||
"type": "calculation",
|
"expression": string,
|
||||||
"expression": "25% of 480"
|
"notPresent": boolean
|
||||||
}
|
}
|
||||||
|
</output_format>
|
||||||
|
`;
|
||||||
|
|
||||||
{
|
const calculationWidget: Widget = {
|
||||||
"type": "calculation",
|
type: 'calculationWidget',
|
||||||
"expression": "sqrt(144) + 5 * 2"
|
shouldExecute: (classification) =>
|
||||||
}
|
classification.classification.showCalculationWidget,
|
||||||
|
execute: async (input) => {
|
||||||
**Important:** The expression must be valid mathematical syntax that can be evaluated by mathjs. Format percentages as "0.25 * 480" or "25% of 480". Do not include currency symbols, units, or non-mathematical text in the expression.`,
|
const output = await input.llm.generateObject<typeof schema>({
|
||||||
schema: schema,
|
messages: [
|
||||||
execute: async (params, _) => {
|
{
|
||||||
try {
|
role: 'system',
|
||||||
const result = mathEval(params.expression);
|
content: system,
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'calculation_result',
|
|
||||||
llmContext: `The result of the expression "${params.expression}" is ${result}.`,
|
|
||||||
data: {
|
|
||||||
expression: params.expression,
|
|
||||||
result: result,
|
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
} catch (error) {
|
role: 'user',
|
||||||
return {
|
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
|
||||||
type: 'calculation_result',
|
|
||||||
llmContext: 'Failed to evaluate mathematical expression.',
|
|
||||||
data: {
|
|
||||||
expression: params.expression,
|
|
||||||
result: `Error evaluating expression: ${error}`,
|
|
||||||
},
|
},
|
||||||
};
|
],
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (output.notPresent) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = mathEval(output.expression);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'calculation_result',
|
||||||
|
llmContext: `The result of the calculation for the expression "${output.expression}" is: ${result}`,
|
||||||
|
data: {
|
||||||
|
expression: output.expression,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
36
src/lib/agents/search/widgets/executor.ts
Normal file
36
src/lib/agents/search/widgets/executor.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Widget, WidgetInput, WidgetOutput } from '../types';
|
||||||
|
|
||||||
|
class WidgetExecutor {
|
||||||
|
static widgets = new Map<string, Widget>();
|
||||||
|
|
||||||
|
static register(widget: Widget) {
|
||||||
|
this.widgets.set(widget.type, widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getWidget(type: string): Widget | undefined {
|
||||||
|
return this.widgets.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async executeAll(input: WidgetInput): Promise<WidgetOutput[]> {
|
||||||
|
const results: WidgetOutput[] = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(this.widgets.values()).map(async (widget) => {
|
||||||
|
try {
|
||||||
|
if (widget.shouldExecute(input.classification)) {
|
||||||
|
const output = await widget.execute(input);
|
||||||
|
if (output) {
|
||||||
|
results.push(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Error executing widget ${widget.type}:`, e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetExecutor;
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import calculationWidget from './calculationWidget';
|
import calculationWidget from './calculationWidget';
|
||||||
import WidgetRegistry from './registry';
|
import WidgetExecutor from './executor';
|
||||||
import weatherWidget from './weatherWidget';
|
import weatherWidget from './weatherWidget';
|
||||||
import stockWidget from './stockWidget';
|
import stockWidget from './stockWidget';
|
||||||
|
|
||||||
WidgetRegistry.register(weatherWidget);
|
WidgetExecutor.register(weatherWidget);
|
||||||
WidgetRegistry.register(calculationWidget);
|
WidgetExecutor.register(calculationWidget);
|
||||||
WidgetRegistry.register(stockWidget);
|
WidgetExecutor.register(stockWidget);
|
||||||
|
|
||||||
export { WidgetRegistry };
|
export { WidgetExecutor };
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import {
|
|
||||||
AdditionalConfig,
|
|
||||||
SearchAgentConfig,
|
|
||||||
Widget,
|
|
||||||
WidgetConfig,
|
|
||||||
WidgetOutput,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
class WidgetRegistry {
|
|
||||||
private static widgets = new Map<string, Widget>();
|
|
||||||
|
|
||||||
static register(widget: Widget<any>) {
|
|
||||||
this.widgets.set(widget.name, widget);
|
|
||||||
}
|
|
||||||
|
|
||||||
static get(name: string): Widget | undefined {
|
|
||||||
return this.widgets.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getAll(): Widget[] {
|
|
||||||
return Array.from(this.widgets.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDescriptions(): string {
|
|
||||||
return Array.from(this.widgets.values())
|
|
||||||
.map((widget) => `${widget.name}: ${widget.description}`)
|
|
||||||
.join('\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
static async execute(
|
|
||||||
name: string,
|
|
||||||
params: any,
|
|
||||||
config: AdditionalConfig,
|
|
||||||
): Promise<WidgetOutput> {
|
|
||||||
const widget = this.get(name);
|
|
||||||
|
|
||||||
if (!widget) {
|
|
||||||
throw new Error(`Widget with name ${name} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return widget.execute(params, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async executeAll(
|
|
||||||
widgets: WidgetConfig[],
|
|
||||||
additionalConfig: AdditionalConfig,
|
|
||||||
): Promise<WidgetOutput[]> {
|
|
||||||
const results: WidgetOutput[] = [];
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
widgets.map(async (widgetConfig) => {
|
|
||||||
const output = await this.execute(
|
|
||||||
widgetConfig.type,
|
|
||||||
widgetConfig.params,
|
|
||||||
additionalConfig,
|
|
||||||
);
|
|
||||||
results.push(output);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WidgetRegistry;
|
|
||||||
@@ -1,80 +1,86 @@
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { Widget } from '../types';
|
import { Widget } from '../types';
|
||||||
import YahooFinance from 'yahoo-finance2';
|
import YahooFinance from 'yahoo-finance2';
|
||||||
|
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||||
|
|
||||||
const yf = new YahooFinance({
|
const yf = new YahooFinance({
|
||||||
suppressNotices: ['yahooSurvey'],
|
suppressNotices: ['yahooSurvey'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.literal('stock'),
|
name: z
|
||||||
ticker: z
|
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"The stock ticker symbol in uppercase (e.g., 'AAPL' for Apple Inc., 'TSLA' for Tesla, 'GOOGL' for Google). Use the primary exchange ticker.",
|
"The stock name for example Nvidia, Google, Apple, Microsoft etc. You can also return ticker if you're aware of it otherwise just use the name.",
|
||||||
),
|
),
|
||||||
comparisonTickers: z
|
comparisonNames: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.max(3)
|
.max(3)
|
||||||
.describe(
|
.describe(
|
||||||
"Optional array of up to 3 ticker symbols to compare against the base ticker (e.g., ['MSFT', 'GOOGL', 'META']). Charts will show percentage change comparison.",
|
"Optional array of up to 3 stock names to compare against the base name (e.g., ['Microsoft', 'GOOGL', 'Meta']). Charts will show percentage change comparison.",
|
||||||
),
|
),
|
||||||
|
notPresent: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Whether there is no need for the stock widget.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const stockWidget: Widget<typeof schema> = {
|
const systemPrompt = `
|
||||||
name: 'stock',
|
<role>
|
||||||
description: `Provides comprehensive real-time stock market data and financial information for any publicly traded company. Returns detailed quote data, market status, trading metrics, and company fundamentals.
|
You are a stock ticker/name extractor. You will receive a user follow up and a conversation history.
|
||||||
|
Your task is to determine if the user is asking about stock information and extract the stock name(s) they want data for.
|
||||||
|
</role>
|
||||||
|
|
||||||
You can set skipSearch to true if the stock widget can fully answer the user's query without needing additional web search.
|
<instructions>
|
||||||
|
- If the user is asking about a stock, extract the primary stock name or ticker.
|
||||||
|
- If the user wants to compare stocks, extract up to 3 comparison stock names in comparisonNames.
|
||||||
|
- You can use either stock names (e.g., "Nvidia", "Apple") or tickers (e.g., "NVDA", "AAPL").
|
||||||
|
- If you cannot determine a valid stock or the query is not stock-related, set notPresent to true.
|
||||||
|
- If no comparison is needed, set comparisonNames to an empty array.
|
||||||
|
</instructions>
|
||||||
|
|
||||||
**What it provides:**
|
<output_format>
|
||||||
- **Real-time Price Data**: Current price, previous close, open price, day's range (high/low)
|
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||||
- **Market Status**: Whether market is currently open or closed, trading sessions
|
|
||||||
- **Trading Metrics**: Volume, average volume, bid/ask prices and sizes
|
|
||||||
- **Performance**: Price changes (absolute and percentage), 52-week high/low range
|
|
||||||
- **Valuation**: Market capitalization, P/E ratio, earnings per share (EPS)
|
|
||||||
- **Dividends**: Dividend rate, dividend yield, ex-dividend date
|
|
||||||
- **Company Info**: Full company name, exchange, currency, sector/industry (when available)
|
|
||||||
- **Advanced Metrics**: Beta, trailing/forward P/E, book value, price-to-book ratio
|
|
||||||
- **Charts Data**: Historical price movements for visualization
|
|
||||||
- **Comparison**: Compare up to 3 stocks side-by-side with percentage-based performance visualization
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- User asks about a stock price ("What's AAPL stock price?", "How is Tesla doing?")
|
|
||||||
- Questions about company market performance ("Is Microsoft up or down today?")
|
|
||||||
- Requests for stock market data, trading info, or company valuation
|
|
||||||
- Queries about dividends, P/E ratio, market cap, or other financial metrics
|
|
||||||
- Any stock/equity-related question for a specific company
|
|
||||||
- Stock comparisons ("Compare AAPL vs MSFT", "How is TSLA doing vs RIVN and LCID?")
|
|
||||||
|
|
||||||
**Example calls:**
|
|
||||||
{
|
{
|
||||||
"type": "stock",
|
"name": string,
|
||||||
"ticker": "AAPL"
|
"comparisonNames": string[],
|
||||||
|
"notPresent": boolean
|
||||||
}
|
}
|
||||||
|
</output_format>
|
||||||
|
`;
|
||||||
|
|
||||||
{
|
const stockWidget: Widget = {
|
||||||
"type": "stock",
|
type: 'stockWidget',
|
||||||
"ticker": "TSLA",
|
shouldExecute: (classification) =>
|
||||||
"comparisonTickers": ["RIVN", "LCID"]
|
classification.classification.showStockWidget,
|
||||||
}
|
execute: async (input) => {
|
||||||
|
const output = await input.llm.generateObject<typeof schema>({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: systemPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
{
|
if (output.notPresent) {
|
||||||
"type": "stock",
|
return;
|
||||||
"ticker": "GOOGL",
|
}
|
||||||
"comparisonTickers": ["MSFT", "META", "AMZN"]
|
|
||||||
}
|
|
||||||
|
|
||||||
**Important:**
|
const params = output;
|
||||||
- Use the correct ticker symbol (uppercase preferred: AAPL not aapl)
|
|
||||||
- For companies with multiple share classes, use the most common one (e.g., GOOGL for Google Class A shares)
|
|
||||||
- The widget works for stocks listed on major exchanges (NYSE, NASDAQ, etc.)
|
|
||||||
- Returns comprehensive data; the UI will display relevant metrics based on availability
|
|
||||||
- Market data may be delayed by 15-20 minutes for free data sources during trading hours`,
|
|
||||||
schema: schema,
|
|
||||||
execute: async (params, _) => {
|
|
||||||
try {
|
try {
|
||||||
const ticker = params.ticker.toUpperCase();
|
const name = params.name;
|
||||||
|
|
||||||
|
const findings = await yf.search(name);
|
||||||
|
|
||||||
|
if (findings.quotes.length === 0)
|
||||||
|
throw new Error(`Failed to find quote for name/symbol: ${name}`);
|
||||||
|
|
||||||
|
const ticker = findings.quotes[0].symbol as string;
|
||||||
|
|
||||||
const quote: any = await yf.quote(ticker);
|
const quote: any = await yf.quote(ticker);
|
||||||
|
|
||||||
@@ -143,11 +149,16 @@ You can set skipSearch to true if the stock widget can fully answer the user's q
|
|||||||
}
|
}
|
||||||
|
|
||||||
let comparisonData: any = null;
|
let comparisonData: any = null;
|
||||||
if (params.comparisonTickers.length > 0) {
|
if (params.comparisonNames.length > 0) {
|
||||||
const comparisonPromises = params.comparisonTickers
|
const comparisonPromises = params.comparisonNames
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map(async (compTicker) => {
|
.map(async (compName) => {
|
||||||
try {
|
try {
|
||||||
|
const compFindings = await yf.search(compName);
|
||||||
|
|
||||||
|
if (compFindings.quotes.length === 0) return null;
|
||||||
|
|
||||||
|
const compTicker = compFindings.quotes[0].symbol as string;
|
||||||
const compQuote = await yf.quote(compTicker);
|
const compQuote = await yf.quote(compTicker);
|
||||||
const compCharts = await Promise.all([
|
const compCharts = await Promise.all([
|
||||||
yf
|
yf
|
||||||
@@ -204,7 +215,7 @@ You can set skipSearch to true if the stock widget can fully answer the user's q
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to fetch comparison ticker ${compTicker}:`,
|
`Failed to fetch comparison ticker ${compName}:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
@@ -286,123 +297,125 @@ You can set skipSearch to true if the stock widget can fully answer the user's q
|
|||||||
chartData: {
|
chartData: {
|
||||||
'1D': chart1D
|
'1D': chart1D
|
||||||
? {
|
? {
|
||||||
timestamps: chart1D.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chart1D.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chart1D.quotes.map((q: any) => q.close),
|
prices: chart1D.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'5D': chart5D
|
'5D': chart5D
|
||||||
? {
|
? {
|
||||||
timestamps: chart5D.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chart5D.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chart5D.quotes.map((q: any) => q.close),
|
prices: chart5D.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'1M': chart1M
|
'1M': chart1M
|
||||||
? {
|
? {
|
||||||
timestamps: chart1M.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chart1M.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chart1M.quotes.map((q: any) => q.close),
|
prices: chart1M.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'3M': chart3M
|
'3M': chart3M
|
||||||
? {
|
? {
|
||||||
timestamps: chart3M.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chart3M.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chart3M.quotes.map((q: any) => q.close),
|
prices: chart3M.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'6M': chart6M
|
'6M': chart6M
|
||||||
? {
|
? {
|
||||||
timestamps: chart6M.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chart6M.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chart6M.quotes.map((q: any) => q.close),
|
prices: chart6M.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'1Y': chart1Y
|
'1Y': chart1Y
|
||||||
? {
|
? {
|
||||||
timestamps: chart1Y.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chart1Y.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chart1Y.quotes.map((q: any) => q.close),
|
prices: chart1Y.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
MAX: chartMAX
|
MAX: chartMAX
|
||||||
? {
|
? {
|
||||||
timestamps: chartMAX.quotes.map((q: any) => q.date.getTime()),
|
timestamps: chartMAX.quotes.map((q: any) => q.date.getTime()),
|
||||||
prices: chartMAX.quotes.map((q: any) => q.close),
|
prices: chartMAX.quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
comparisonData: comparisonData
|
comparisonData: comparisonData
|
||||||
? comparisonData.map((comp: any) => ({
|
? comparisonData.map((comp: any) => ({
|
||||||
ticker: comp.ticker,
|
ticker: comp.ticker,
|
||||||
name: comp.name,
|
name: comp.name,
|
||||||
chartData: {
|
chartData: {
|
||||||
'1D': comp.charts[0]
|
'1D': comp.charts[0]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[0].quotes.map((q: any) =>
|
timestamps: comp.charts[0].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[0].quotes.map((q: any) => q.close),
|
prices: comp.charts[0].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'5D': comp.charts[1]
|
'5D': comp.charts[1]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[1].quotes.map((q: any) =>
|
timestamps: comp.charts[1].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[1].quotes.map((q: any) => q.close),
|
prices: comp.charts[1].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'1M': comp.charts[2]
|
'1M': comp.charts[2]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[2].quotes.map((q: any) =>
|
timestamps: comp.charts[2].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[2].quotes.map((q: any) => q.close),
|
prices: comp.charts[2].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'3M': comp.charts[3]
|
'3M': comp.charts[3]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[3].quotes.map((q: any) =>
|
timestamps: comp.charts[3].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[3].quotes.map((q: any) => q.close),
|
prices: comp.charts[3].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'6M': comp.charts[4]
|
'6M': comp.charts[4]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[4].quotes.map((q: any) =>
|
timestamps: comp.charts[4].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[4].quotes.map((q: any) => q.close),
|
prices: comp.charts[4].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
'1Y': comp.charts[5]
|
'1Y': comp.charts[5]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[5].quotes.map((q: any) =>
|
timestamps: comp.charts[5].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[5].quotes.map((q: any) => q.close),
|
prices: comp.charts[5].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
MAX: comp.charts[6]
|
MAX: comp.charts[6]
|
||||||
? {
|
? {
|
||||||
timestamps: comp.charts[6].quotes.map((q: any) =>
|
timestamps: comp.charts[6].quotes.map((q: any) =>
|
||||||
q.date.getTime(),
|
q.date.getTime(),
|
||||||
),
|
),
|
||||||
prices: comp.charts[6].quotes.map((q: any) => q.close),
|
prices: comp.charts[6].quotes.map((q: any) => q.close),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'stock',
|
type: 'stock',
|
||||||
llmContext: `Current price of ${stockData.shortName} (${stockData.symbol}) is ${stockData.regularMarketPrice} ${stockData.currency}. Other details: ${JSON.stringify({
|
llmContext: `Current price of ${stockData.shortName} (${stockData.symbol}) is ${stockData.regularMarketPrice} ${stockData.currency}. Other details: ${JSON.stringify(
|
||||||
marketState: stockData.marketState,
|
{
|
||||||
regularMarketChange: stockData.regularMarketChange,
|
marketState: stockData.marketState,
|
||||||
regularMarketChangePercent: stockData.regularMarketChangePercent,
|
regularMarketChange: stockData.regularMarketChange,
|
||||||
marketCap: stockData.marketCap,
|
regularMarketChangePercent: stockData.regularMarketChangePercent,
|
||||||
peRatio: stockData.trailingPE,
|
marketCap: stockData.marketCap,
|
||||||
dividendYield: stockData.dividendYield,
|
peRatio: stockData.trailingPE,
|
||||||
})}`,
|
dividendYield: stockData.dividendYield,
|
||||||
|
},
|
||||||
|
)}`,
|
||||||
data: stockData,
|
data: stockData,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -411,7 +424,7 @@ You can set skipSearch to true if the stock widget can fully answer the user's q
|
|||||||
llmContext: 'Failed to fetch stock data.',
|
llmContext: 'Failed to fetch stock data.',
|
||||||
data: {
|
data: {
|
||||||
error: `Error fetching stock data: ${error.message || error}`,
|
error: `Error fetching stock data: ${error.message || error}`,
|
||||||
ticker: params.ticker,
|
ticker: params.name,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { Widget } from '../types';
|
import { Widget } from '../types';
|
||||||
|
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||||
|
|
||||||
const WeatherWidgetSchema = z.object({
|
const schema = z.object({
|
||||||
type: z.literal('weather'),
|
|
||||||
location: z
|
location: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -18,38 +18,63 @@ const WeatherWidgetSchema = z.object({
|
|||||||
.describe(
|
.describe(
|
||||||
'Longitude coordinate in decimal degrees (e.g., -74.0060). Only use when location name is empty.',
|
'Longitude coordinate in decimal degrees (e.g., -74.0060). Only use when location name is empty.',
|
||||||
),
|
),
|
||||||
|
notPresent: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Whether there is no need for the weather widget.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const weatherWidget: Widget<typeof WeatherWidgetSchema> = {
|
const systemPrompt = `
|
||||||
name: 'weather',
|
<role>
|
||||||
description: `Provides comprehensive current weather information and forecasts for any location worldwide. Returns real-time weather data including temperature, conditions, humidity, wind, and multi-day forecasts.
|
You are a location extractor for weather queries. You will receive a user follow up and a conversation history.
|
||||||
|
Your task is to determine if the user is asking about weather and extract the location they want weather for.
|
||||||
|
</role>
|
||||||
|
|
||||||
You can set skipSearch to true if the weather widget can fully answer the user's query without needing additional web search.
|
<instructions>
|
||||||
|
- If the user is asking about weather, extract the location name OR coordinates (never both).
|
||||||
|
- If using location name, set lat and lon to 0.
|
||||||
|
- If using coordinates, set location to empty string.
|
||||||
|
- If you cannot determine a valid location or the query is not weather-related, set notPresent to true.
|
||||||
|
- Location should be specific (city, state/region, country) for best results.
|
||||||
|
- You have to give the location so that it can be used to fetch weather data, it cannot be left empty unless notPresent is true.
|
||||||
|
- Make sure to infer short forms of location names (e.g., "NYC" -> "New York City", "LA" -> "Los Angeles").
|
||||||
|
</instructions>
|
||||||
|
|
||||||
**What it provides:**
|
<output_format>
|
||||||
- Current weather conditions (temperature, feels-like, humidity, precipitation)
|
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||||
- Wind speed, direction, and gusts
|
|
||||||
- Weather codes/conditions (clear, cloudy, rainy, etc.)
|
|
||||||
- Hourly forecast for next 24 hours
|
|
||||||
- Daily forecast for next 7 days (high/low temps, precipitation probability)
|
|
||||||
- Timezone information
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- User asks about weather in a location ("weather in X", "is it raining in Y")
|
|
||||||
- Questions about temperature, conditions, or forecast
|
|
||||||
- Any weather-related query for a specific place
|
|
||||||
|
|
||||||
**Example call:**
|
|
||||||
{
|
{
|
||||||
"type": "weather",
|
"location": string,
|
||||||
"location": "San Francisco, CA, USA",
|
"lat": number,
|
||||||
"lat": 0,
|
"lon": number,
|
||||||
"lon": 0
|
"notPresent": boolean
|
||||||
}
|
}
|
||||||
|
</output_format>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const weatherWidget: Widget = {
|
||||||
|
type: 'weatherWidget',
|
||||||
|
shouldExecute: (classification) =>
|
||||||
|
classification.classification.showWeatherWidget,
|
||||||
|
execute: async (input) => {
|
||||||
|
const output = await input.llm.generateObject<typeof schema>({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: systemPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (output.notPresent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = output;
|
||||||
|
|
||||||
**Important:** Provide EITHER a location name OR latitude/longitude coordinates, never both. If using location name, set lat/lon to 0. Location should be specific (city, state/region, country) for best results.`,
|
|
||||||
schema: WeatherWidgetSchema,
|
|
||||||
execute: async (params, _) => {
|
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
params.location === '' &&
|
params.location === '' &&
|
||||||
@@ -65,7 +90,7 @@ You can set skipSearch to true if the weather widget can fully answer the user's
|
|||||||
|
|
||||||
const locationRes = await fetch(openStreetMapUrl, {
|
const locationRes = await fetch(openStreetMapUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Perplexica',
|
'User-Agent': 'Vane',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -84,7 +109,7 @@ You can set skipSearch to true if the weather widget can fully answer the user's
|
|||||||
`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`,
|
`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: {
|
headers: {
|
||||||
'User-Agent': 'Perplexica',
|
'User-Agent': 'Vane',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -94,7 +119,7 @@ You can set skipSearch to true if the weather widget can fully answer the user's
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
llmContext: `Weather in ${params.location} is ${weatherData.current}`,
|
llmContext: `Weather in ${params.location} is ${JSON.stringify(weatherData.current)}`,
|
||||||
data: {
|
data: {
|
||||||
location: params.location,
|
location: params.location,
|
||||||
latitude: location.lat,
|
latitude: location.lat,
|
||||||
@@ -118,7 +143,7 @@ You can set skipSearch to true if the weather widget can fully answer the user's
|
|||||||
`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`,
|
`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: {
|
headers: {
|
||||||
'User-Agent': 'Perplexica',
|
'User-Agent': 'Vane',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -127,7 +152,7 @@ You can set skipSearch to true if the weather widget can fully answer the user's
|
|||||||
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
|
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Perplexica',
|
'User-Agent': 'Vane',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,7 +164,7 @@ You can set skipSearch to true if the weather widget can fully answer the user's
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
llmContext: `Weather in ${locationData.display_name} is ${weatherData.current}`,
|
llmContext: `Weather in ${locationData.display_name} is ${JSON.stringify(weatherData.current)}`,
|
||||||
data: {
|
data: {
|
||||||
location: locationData.display_name,
|
location: locationData.display_name,
|
||||||
latitude: params.lat,
|
latitude: params.lat,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { suggestionGeneratorPrompt } from '@/lib/prompts/suggestions';
|
|||||||
import { ChatTurnMessage } from '@/lib/types';
|
import { ChatTurnMessage } from '@/lib/types';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import BaseLLM from '@/lib/models/base/llm';
|
import BaseLLM from '@/lib/models/base/llm';
|
||||||
import { i } from 'mathjs';
|
|
||||||
|
|
||||||
type SuggestionGeneratorInput = {
|
type SuggestionGeneratorInput = {
|
||||||
chatHistory: ChatTurnMessage[];
|
chatHistory: ChatTurnMessage[];
|
||||||
@@ -19,7 +18,7 @@ const generateSuggestions = async (
|
|||||||
input: SuggestionGeneratorInput,
|
input: SuggestionGeneratorInput,
|
||||||
llm: BaseLLM<any>,
|
llm: BaseLLM<any>,
|
||||||
) => {
|
) => {
|
||||||
const res = await llm.generateObject<z.infer<typeof schema>>({
|
const res = await llm.generateObject<typeof schema>({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
|
|||||||
@@ -11,3 +11,19 @@ export const getAutoMediaSearch = () =>
|
|||||||
|
|
||||||
export const getSystemInstructions = () =>
|
export const getSystemInstructions = () =>
|
||||||
getClientConfig('systemInstructions', '');
|
getClientConfig('systemInstructions', '');
|
||||||
|
|
||||||
|
export const getShowWeatherWidget = () =>
|
||||||
|
getClientConfig('showWeatherWidget', 'true') === 'true';
|
||||||
|
|
||||||
|
export const getShowNewsWidget = () =>
|
||||||
|
getClientConfig('showNewsWidget', 'true') === 'true';
|
||||||
|
|
||||||
|
export const getMeasurementUnit = () => {
|
||||||
|
const value =
|
||||||
|
getClientConfig('measureUnit') ??
|
||||||
|
getClientConfig('measurementUnit', 'metric');
|
||||||
|
|
||||||
|
if (typeof value !== 'string') return 'metric';
|
||||||
|
|
||||||
|
return value.toLowerCase();
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { Config, ConfigModelProvider, UIConfigSections } from './types';
|
import { Config, ConfigModelProvider, UIConfigSections } from './types';
|
||||||
import { hashObj } from '../serverUtils';
|
import { hashObj } from '../utils/hash';
|
||||||
import { getModelProvidersUIConfigSection } from '../models/providers';
|
import { getModelProvidersUIConfigSection } from '../models/providers';
|
||||||
|
|
||||||
class ConfigManager {
|
class ConfigManager {
|
||||||
@@ -69,6 +69,24 @@ class ConfigManager {
|
|||||||
default: true,
|
default: true,
|
||||||
scope: 'client',
|
scope: 'client',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Show weather widget',
|
||||||
|
key: 'showWeatherWidget',
|
||||||
|
type: 'switch',
|
||||||
|
required: false,
|
||||||
|
description: 'Display the weather card on the home screen.',
|
||||||
|
default: true,
|
||||||
|
scope: 'client',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Show news widget',
|
||||||
|
key: 'showNewsWidget',
|
||||||
|
type: 'switch',
|
||||||
|
required: false,
|
||||||
|
description: 'Display the recent news card on the home screen.',
|
||||||
|
default: true,
|
||||||
|
scope: 'client',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
personalization: [
|
personalization: [
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user