Compare commits
175 Commits
v1.11.0-rc
...
feat/confi
Author | SHA1 | Date | |
---|---|---|---|
|
3e03947b1b | ||
|
86143c83d8 | ||
|
2c9012e99c | ||
|
d43ef9e43d | ||
|
53a1b3bc56 | ||
|
f28ea8cee2 | ||
|
716629f6fe | ||
|
190b6aa79a | ||
|
97e542acf8 | ||
|
97bee75e39 | ||
|
16b31fe34f | ||
|
8a1052e82b | ||
|
43f23e21a3 | ||
|
5a7f45cace | ||
|
e02b9a5efc | ||
|
09dd8dba5a | ||
|
ca8b74b695 | ||
|
ac7cfac784 | ||
|
861d50674a | ||
|
d97fa708f1 | ||
|
0c7566bb87 | ||
|
0ff1be47bf | ||
|
768578951c | ||
|
9706079ed4 | ||
|
9219593ee1 | ||
|
36fdb6491d | ||
|
0d2cd4bb1e | ||
|
b67ca79e2a | ||
|
626cb646e2 | ||
|
410201b117 | ||
|
30fb1e312b | ||
|
cc5eea17e4 | ||
|
4ee3173368 | ||
|
6d61528347 | ||
|
c02e535f4c | ||
|
a375de73cc | ||
|
87226957f1 | ||
|
77743949c7 | ||
|
64c4514cad | ||
|
999553877d | ||
|
e45a9af9ff | ||
|
e7fbab12ed | ||
|
387da5dbdd | ||
|
3003d44544 | ||
|
f1e6aa9c1a | ||
|
f39638fe02 | ||
|
535c0b9897 | ||
|
47350b34ec | ||
|
7c97df98c7 | ||
|
b084c42aca | ||
|
fdfa2f3ea6 | ||
|
3323e7a0ed | ||
|
d4f9da34c6 | ||
|
10ed67c753 | ||
|
cf3cc4e638 | ||
|
46b9e41100 | ||
|
02adafbd4b | ||
|
f141d4719c | ||
|
f18e132d1b | ||
|
37317992b4 | ||
|
8b588824f2 | ||
|
a19cf00873 | ||
|
5cc11ac0bf | ||
|
d611ddaab9 | ||
|
3b41905abb | ||
|
b9071ceed7 | ||
|
bb5002de85 | ||
|
fc99653441 | ||
|
23dde9fa59 | ||
|
dde6c8d719 | ||
|
650c69e04f | ||
|
984163bbbc | ||
|
5f18fc1d22 | ||
|
b1426e8638 | ||
|
7337f3423d | ||
|
8f728a2518 | ||
|
bd8e3dfa2e | ||
|
9c6e42f7d8 | ||
|
fabb48cc2f | ||
|
c46b421219 | ||
|
8dc24c2d1a | ||
|
8afcdd044c | ||
|
5b12e99335 | ||
|
5b5e83a3a0 | ||
|
6dd33aa33c | ||
|
e705952503 | ||
|
b8e4152e77 | ||
|
c8ac9279bd | ||
|
6f367c34a8 | ||
|
328b12ffbe | ||
|
536ec24093 | ||
|
bb9eab7aa7 | ||
|
b0e8a33f1d | ||
|
a268ce345c | ||
|
7b46b815c1 | ||
|
d6b02db37a | ||
|
34fa52ad12 | ||
|
266c333b29 | ||
|
a6f3d98aef | ||
|
705ae464ad | ||
|
d8486e90bb | ||
|
238bcaff2b | ||
|
6f7c55b783 | ||
|
83a0cffe1b | ||
|
829ae59944 | ||
|
a546eb18a1 | ||
|
ff1ca56157 | ||
|
30725b5d6d | ||
|
8dc54efbdd | ||
|
72f26b4370 | ||
|
f680188905 | ||
|
0b15bfbe32 | ||
|
8fc7808654 | ||
|
0dc17286b9 | ||
|
3edd7d44dd | ||
|
1132997108 | ||
|
eadbedb713 | ||
|
37cd6d3ab5 | ||
|
88be3a045b | ||
|
45b51ab156 | ||
|
3bee01cfa7 | ||
|
567c6a8758 | ||
|
81a91da743 | ||
|
70a61ee1eb | ||
|
9d89a4413b | ||
|
6ea17d54c6 | ||
|
11a828b073 | ||
|
37022fb11e | ||
|
dd50d4927b | ||
|
fdaf3af3af | ||
|
3f2a8f862c | ||
|
58c7be6e95 | ||
|
829b4e7134 | ||
|
77870b39cc | ||
|
8e0ae9b867 | ||
|
543f1df5ce | ||
|
341aae4587 | ||
|
7f62907385 | ||
|
7c4aa683a2 | ||
|
b48b0eeb0e | ||
|
cddc793915 | ||
|
94e6db10bb | ||
|
65fc881356 | ||
|
26e1d5fec3 | ||
|
66be87b688 | ||
|
f7b4e32218 | ||
|
57407112fb | ||
|
b280cc2e01 | ||
|
e6ebf892c5 | ||
|
b754641058 | ||
|
722f4f760e | ||
|
01e04a209f | ||
|
0299fd1ea0 | ||
|
cf8dec53ca | ||
|
d5c012d748 | ||
|
2ccbd9a44c | ||
|
ccd89d48d9 | ||
|
87d788ddef | ||
|
809b625a34 | ||
|
95c753a549 | ||
|
0bb8b7ec5c | ||
|
c6d084f5dc | ||
|
0024ce36c8 | ||
|
c44e746807 | ||
|
b1826066f4 | ||
|
b0b8acc45b | ||
|
e2b9ffc072 | ||
|
68c43ea372 | ||
|
3b46baca4f | ||
|
772e461c08 | ||
|
5c6018a0f9 | ||
|
0b7989c3d3 | ||
|
8cfcc3e39c | ||
|
9eba4b7373 | ||
|
91306dc0c7 |
0
.assets/manifest.json
Normal file
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 2.1 MiB |
36
.github/workflows/docker-build.yaml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- canary
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -43,6 +44,19 @@ jobs:
|
||||
-t itzcrazykns1337/${IMAGE_NAME}:amd64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push AMD64 Canary Docker image
|
||||
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
|
||||
run: |
|
||||
DOCKERFILE=app.dockerfile
|
||||
IMAGE_NAME=perplexica
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push AMD64 release Docker image
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
@@ -91,6 +105,19 @@ jobs:
|
||||
-t itzcrazykns1337/${IMAGE_NAME}:arm64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push ARM64 Canary Docker image
|
||||
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
|
||||
run: |
|
||||
DOCKERFILE=app.dockerfile
|
||||
IMAGE_NAME=perplexica
|
||||
docker buildx build --platform linux/arm64 \
|
||||
--cache-from=type=registry,ref=itzcrazykns1337/${IMAGE_NAME}:canary-arm64 \
|
||||
--cache-to=type=inline \
|
||||
--provenance false \
|
||||
-f $DOCKERFILE \
|
||||
-t itzcrazykns1337/${IMAGE_NAME}:canary-arm64 \
|
||||
--push .
|
||||
|
||||
- name: Build and push ARM64 release Docker image
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
@@ -128,6 +155,15 @@ jobs:
|
||||
--amend itzcrazykns1337/${IMAGE_NAME}:arm64
|
||||
docker manifest push itzcrazykns1337/${IMAGE_NAME}:main
|
||||
|
||||
- name: Create and push multi-arch manifest for canary
|
||||
if: github.ref == 'refs/heads/canary' && github.event_name == 'push'
|
||||
run: |
|
||||
IMAGE_NAME=perplexica
|
||||
docker manifest create itzcrazykns1337/${IMAGE_NAME}:canary \
|
||||
--amend itzcrazykns1337/${IMAGE_NAME}:canary-amd64 \
|
||||
--amend itzcrazykns1337/${IMAGE_NAME}:canary-arm64
|
||||
docker manifest push itzcrazykns1337/${IMAGE_NAME}:canary
|
||||
|
||||
- name: Create and push multi-arch manifest for releases
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
|
2
.gitignore
vendored
@@ -37,3 +37,5 @@ Thumbs.db
|
||||
# Db
|
||||
db.sqlite
|
||||
/searxng
|
||||
|
||||
certificates
|
@@ -36,7 +36,7 @@ Before diving into coding, setting up your local environment is key. Here's what
|
||||
1. In the root directory, locate the `sample.config.toml` file.
|
||||
2. Rename it to `config.toml` and fill in the necessary configuration fields.
|
||||
3. Run `npm install` to install all dependencies.
|
||||
4. Run `npm run db:push` to set up the local sqlite database.
|
||||
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.
|
||||
|
||||
**Please note**: Docker configurations are present for setting up production environments, whereas `npm run dev` is used for development purposes.
|
||||
|
48
README.md
@@ -16,7 +16,7 @@
|
||||
|
||||
<hr/>
|
||||
|
||||
[](https://discord.gg/26aArMy8tT)
|
||||
[](https://discord.gg/26aArMy8tT)
|
||||
|
||||

|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- [Getting Started with Docker (Recommended)](#getting-started-with-docker-recommended)
|
||||
- [Non-Docker Installation](#non-docker-installation)
|
||||
- [Ollama Connection Errors](#ollama-connection-errors)
|
||||
- [Lemonade Connection Errors](#lemonade-connection-errors)
|
||||
- [Using as a Search Engine](#using-as-a-search-engine)
|
||||
- [Using Perplexica's API](#using-perplexicas-api)
|
||||
- [Expose Perplexica to a network](#expose-perplexica-to-network)
|
||||
@@ -53,7 +54,7 @@ Want to know more about its architecture and how it works? You can read it [here
|
||||
|
||||
## Features
|
||||
|
||||
- **Local LLMs**: You can make use local LLMs such as Llama3 and Mixtral using Ollama.
|
||||
- **Local LLMs**: You can utilize local LLMs such as Qwen, DeepSeek, Llama, and Mistral.
|
||||
- **Two Main Modes:**
|
||||
- **Copilot Mode:** (In development) Boosts search by generating different queries to find more relevant internet sources. Like normal search instead of just using the context by SearxNG, it visits the top matches and tries to find relevant sources to the user's query directly from the page.
|
||||
- **Normal Mode:** Processes your query and performs a web search.
|
||||
@@ -87,9 +88,14 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
|
||||
4. Rename the `sample.config.toml` file to `config.toml`. For Docker setups, you need only fill in the following fields:
|
||||
|
||||
- `OPENAI`: Your OpenAI API key. **You only need to fill this if you wish to use OpenAI's models**.
|
||||
- `CUSTOM_OPENAI`: Your OpenAI-API-compliant local server URL, model name, and API key. You should run your local server with host set to `0.0.0.0`, take note of which port number it is running on, and then use that port number to set `API_URL = http://host.docker.internal:PORT_NUMBER`. You must specify the model name, such as `MODEL_NAME = "unsloth/DeepSeek-R1-0528-Qwen3-8B-GGUF:Q4_K_XL"`. Finally, set `API_KEY` to the appropriate value. If you have not defined an API key, just put anything you want in-between the quotation marks: `API_KEY = "whatever-you-want-but-not-blank"` **You only need to configure these settings if you want to use a local OpenAI-compliant server, such as Llama.cpp's [`llama-server`](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md)**.
|
||||
- `OLLAMA`: Your Ollama API URL. You should enter it as `http://host.docker.internal:PORT_NUMBER`. If you installed Ollama on port 11434, use `http://host.docker.internal:11434`. For other ports, adjust accordingly. **You need to fill this if you wish to use Ollama's models instead of OpenAI's**.
|
||||
- `GROQ`: Your Groq API key. **You only need to fill this if you wish to use Groq's hosted models**.
|
||||
- `LEMONADE`: Your Lemonade API URL. Since Lemonade runs directly on your local machine (not in Docker), you should enter it as `http://host.docker.internal:PORT_NUMBER`. If you installed Lemonade on port 8000, use `http://host.docker.internal:8000`. For other ports, adjust accordingly. **You need to fill this if you wish to use Lemonade's models**.
|
||||
- `GROQ`: Your Groq API key. **You only need to fill this if you wish to use Groq's hosted models**.`
|
||||
- `ANTHROPIC`: Your Anthropic API key. **You only need to fill this if you wish to use Anthropic models**.
|
||||
- `Gemini`: Your Gemini API key. **You only need to fill this if you wish to use Google's models**.
|
||||
- `DEEPSEEK`: Your Deepseek API key. **Only needed if you want Deepseek models.**
|
||||
- `AIMLAPI`: Your AI/ML API key. **Only needed if you want to use AI/ML API models and embeddings.**
|
||||
|
||||
**Note**: You can change these after starting Perplexica from the settings dialog.
|
||||
|
||||
@@ -111,13 +117,23 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
|
||||
2. Clone the repository and rename the `sample.config.toml` file to `config.toml` in the root directory. Ensure you complete all required fields in this file.
|
||||
3. After populating the configuration run `npm i`.
|
||||
4. Install the dependencies and then execute `npm run build`.
|
||||
5. Finally, start the app by running `npm rum start`
|
||||
5. Finally, start the app by running `npm run start`
|
||||
|
||||
**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.
|
||||
|
||||
### Ollama Connection Errors
|
||||
### Troubleshooting
|
||||
|
||||
#### Local OpenAI-API-Compliant Servers
|
||||
|
||||
If Perplexica tells you that you haven't configured any chat model providers, ensure that:
|
||||
|
||||
1. Your server is running on `0.0.0.0` (not `127.0.0.1`) and on the same port you put in the API URL.
|
||||
2. You have specified the correct model name loaded by your local LLM server.
|
||||
3. You have specified the correct API key, or if one is not defined, you have put _something_ in the API key field and not left it empty.
|
||||
|
||||
#### Ollama Connection Errors
|
||||
|
||||
If you're encountering an Ollama connection error, it is likely due to the backend being unable to connect to Ollama's API. To fix this issue you can:
|
||||
|
||||
@@ -132,10 +148,29 @@ If you're encountering an Ollama connection error, it is likely due to the backe
|
||||
|
||||
3. **Linux Users - Expose Ollama to Network:**
|
||||
|
||||
- Inside `/etc/systemd/system/ollama.service`, you need to add `Environment="OLLAMA_HOST=0.0.0.0"`. Then restart Ollama by `systemctl restart ollama`. For more information see [Ollama docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#setting-environment-variables-on-linux)
|
||||
- Inside `/etc/systemd/system/ollama.service`, you need to add `Environment="OLLAMA_HOST=0.0.0.0:11434"`. (Change the port number if you are using a different one.) Then reload the systemd manager configuration with `systemctl daemon-reload`, and restart Ollama by `systemctl restart ollama`. For more information see [Ollama docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#setting-environment-variables-on-linux)
|
||||
|
||||
- Ensure that the port (default is 11434) is not blocked by your firewall.
|
||||
|
||||
#### Lemonade Connection Errors
|
||||
|
||||
If you're encountering a Lemonade connection error, it is likely due to the backend being unable to connect to Lemonade's API. To fix this issue you can:
|
||||
|
||||
1. **Check your Lemonade API URL:** Ensure that the API URL is correctly set in the settings menu.
|
||||
2. **Update API URL Based on OS:**
|
||||
|
||||
- **Windows:** Use `http://host.docker.internal:8000`
|
||||
- **Mac:** Use `http://host.docker.internal:8000`
|
||||
- **Linux:** Use `http://<private_ip_of_host>:8000`
|
||||
|
||||
Adjust the port number if you're using a different one.
|
||||
|
||||
3. **Ensure Lemonade Server is Running:**
|
||||
|
||||
- Make sure your Lemonade server is running and accessible on the configured port (default is 8000).
|
||||
- Verify that Lemonade is configured to accept connections from all interfaces (`0.0.0.0`), not just localhost (`127.0.0.1`).
|
||||
- Ensure that the port (default is 8000) is not blocked by your firewall.
|
||||
|
||||
## 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:
|
||||
@@ -160,6 +195,7 @@ Perplexica runs on Next.js and handles all API requests. It works right away on
|
||||
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
||||
[](https://repocloud.io/details/?app_id=267)
|
||||
[](https://template.run.claw.cloud/?referralCode=U11MRQ8U9RM4&openapp=system-fastdeploy%3FtemplateName%3Dperplexica)
|
||||
[](https://www.hostinger.com/vps/docker-hosting?compose_url=https://raw.githubusercontent.com/ItzCrazyKns/Perplexica/refs/heads/master/docker-compose.yaml)
|
||||
|
||||
## Upcoming Features
|
||||
|
||||
|
@@ -1,4 +1,6 @@
|
||||
FROM node:20.18.0-slim AS builder
|
||||
FROM node:24.5.0-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /home/perplexica
|
||||
|
||||
@@ -8,14 +10,14 @@ RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||
COPY tsconfig.json next.config.mjs next-env.d.ts postcss.config.js drizzle.config.ts tailwind.config.ts ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
RUN mkdir -p /home/perplexica/data
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn add --dev @vercel/ncc
|
||||
RUN yarn ncc build ./src/lib/db/migrate.ts -o migrator
|
||||
FROM node:24.5.0-slim
|
||||
|
||||
FROM node:20.18.0-slim
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip sqlite3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /home/perplexica
|
||||
|
||||
@@ -25,11 +27,11 @@ COPY --from=builder /home/perplexica/.next/static ./public/_next/static
|
||||
COPY --from=builder /home/perplexica/.next/standalone ./
|
||||
COPY --from=builder /home/perplexica/data ./data
|
||||
COPY drizzle ./drizzle
|
||||
COPY --from=builder /home/perplexica/migrator/build ./build
|
||||
COPY --from=builder /home/perplexica/migrator/index.js ./migrate.js
|
||||
|
||||
RUN mkdir /home/perplexica/uploads
|
||||
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
CMD ["./entrypoint.sh"]
|
||||
RUN sed -i 's/\r$//' ./entrypoint.sh || true
|
||||
|
||||
CMD ["/home/perplexica/entrypoint.sh"]
|
@@ -41,6 +41,6 @@ To update Perplexica to the latest version, follow these steps:
|
||||
3. Check for changes in the configuration files. If the `sample.config.toml` file contains new fields, delete your existing `config.toml` file, rename `sample.config.toml` to `config.toml`, and update the configuration accordingly.
|
||||
4. After populating the configuration run `npm i`.
|
||||
5. Install the dependencies and then execute `npm run build`.
|
||||
6. Finally, start the app by running `npm rum start`
|
||||
6. Finally, start the app by running `npm run start`
|
||||
|
||||
---
|
||||
|
1
drizzle/0001_wise_rockslide.sql
Normal file
@@ -0,0 +1 @@
|
||||
/* Do nothing */
|
125
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6dedf55f-0e44-478f-82cf-14a21ac686f8",
|
||||
"prevId": "ef3a044b-0f34-40b5-babb-2bb3a909ba27",
|
||||
"tables": {
|
||||
"chats": {
|
||||
"name": "chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"focusMode": {
|
||||
"name": "focusMode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"files": {
|
||||
"name": "files",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"chatId": {
|
||||
"name": "chatId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"messageId": {
|
||||
"name": "messageId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sources": {
|
||||
"name": "sources",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
@@ -8,6 +8,13 @@
|
||||
"when": 1748405503809,
|
||||
"tag": "0000_fuzzy_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1758863991284,
|
||||
"tag": "0001_wise_rockslide",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@@ -1,6 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
node migrate.js
|
||||
|
||||
exec node server.js
|
25
package.json
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"name": "perplexica-frontend",
|
||||
"version": "1.11.0-rc1",
|
||||
"version": "1.11.0-rc3",
|
||||
"license": "MIT",
|
||||
"author": "ItzCrazyKns",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "npm run db:push && next build",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format:write": "prettier . --write",
|
||||
"db:push": "drizzle-kit push"
|
||||
"format:write": "prettier . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@icons-pack/react-simple-icons": "^12.3.0",
|
||||
"@langchain/anthropic": "^0.3.15",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/core": "^0.3.42",
|
||||
"@langchain/google-genai": "^0.1.12",
|
||||
"@langchain/openai": "^0.0.25",
|
||||
"@langchain/anthropic": "^0.3.24",
|
||||
"@langchain/community": "^0.3.49",
|
||||
"@langchain/core": "^0.3.66",
|
||||
"@langchain/google-genai": "^0.2.15",
|
||||
"@langchain/groq": "^0.2.3",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@langchain/openai": "^0.6.2",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
@@ -27,11 +29,11 @@
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"clsx": "^2.1.0",
|
||||
"compute-cosine-similarity": "^1.1.0",
|
||||
"compute-dot": "^1.1.0",
|
||||
"drizzle-orm": "^0.40.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jspdf": "^3.0.1",
|
||||
"langchain": "^0.1.30",
|
||||
"langchain": "^0.3.30",
|
||||
"lucide-react": "^0.363.0",
|
||||
"mammoth": "^1.9.1",
|
||||
"markdown-to-jsx": "^7.7.2",
|
||||
@@ -63,6 +65,7 @@
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
BIN
public/fonts/pp-ed-ul.otf
Normal file
BIN
public/icon-100.png
Normal file
After Width: | Height: | Size: 916 B |
BIN
public/icon-50.png
Normal file
After Width: | Height: | Size: 515 B |
BIN
public/icon.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
public/screenshots/p1.png
Normal file
After Width: | Height: | Size: 183 KiB |
BIN
public/screenshots/p1_small.png
Normal file
After Width: | Height: | Size: 130 KiB |
BIN
public/screenshots/p2.png
Normal file
After Width: | Height: | Size: 627 KiB |
BIN
public/screenshots/p2_small.png
Normal file
After Width: | Height: | Size: 202 KiB |
@@ -25,8 +25,15 @@ API_URL = "" # Ollama API URL - http://host.docker.internal:11434
|
||||
[MODELS.DEEPSEEK]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.AIMLAPI]
|
||||
API_KEY = "" # Required to use AI/ML API chat and embedding models
|
||||
|
||||
[MODELS.LM_STUDIO]
|
||||
API_URL = "" # LM Studio API URL - http://host.docker.internal:1234
|
||||
|
||||
[MODELS.LEMONADE]
|
||||
API_URL = "" # Lemonade API URL - http://host.docker.internal:8000
|
||||
API_KEY = "" # Optional API key for Lemonade
|
||||
|
||||
[API_ENDPOINTS]
|
||||
SEARXNG = "" # SearxNG API URL - http://localhost:32768
|
||||
|
@@ -1,66 +1,104 @@
|
||||
import prompts from '@/lib/prompts';
|
||||
import MetaSearchAgent from '@/lib/search/metaSearchAgent';
|
||||
import crypto from 'crypto';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { EventEmitter } from 'stream';
|
||||
import {
|
||||
chatModelProviders,
|
||||
embeddingModelProviders,
|
||||
getAvailableChatModelProviders,
|
||||
getAvailableEmbeddingModelProviders,
|
||||
} from '@/lib/providers';
|
||||
import db from '@/lib/db';
|
||||
import { chats, messages as messagesSchema } from '@/lib/db/schema';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import { getFileDetails } from '@/lib/utils/files';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { searchHandlers } from '@/lib/search';
|
||||
import { z } from 'zod';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Message = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
content: string;
|
||||
};
|
||||
const messageSchema = z.object({
|
||||
messageId: z.string().min(1, 'Message ID is required'),
|
||||
chatId: z.string().min(1, 'Chat ID is required'),
|
||||
content: z.string().min(1, 'Message content is required'),
|
||||
});
|
||||
|
||||
type ChatModel = {
|
||||
provider: string;
|
||||
name: string;
|
||||
};
|
||||
const chatModelSchema: z.ZodType<ModelWithProvider> = z.object({
|
||||
providerId: z.string({
|
||||
errorMap: () => ({
|
||||
message: 'Chat model provider id must be provided',
|
||||
}),
|
||||
}),
|
||||
key: z.string({
|
||||
errorMap: () => ({
|
||||
message: 'Chat model key must be provided',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
type EmbeddingModel = {
|
||||
provider: string;
|
||||
name: string;
|
||||
};
|
||||
const embeddingModelSchema: z.ZodType<ModelWithProvider> = z.object({
|
||||
providerId: z.string({
|
||||
errorMap: () => ({
|
||||
message: 'Embedding model provider id must be provided',
|
||||
}),
|
||||
}),
|
||||
key: z.string({
|
||||
errorMap: () => ({
|
||||
message: 'Embedding model key must be provided',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
type Body = {
|
||||
message: Message;
|
||||
optimizationMode: 'speed' | 'balanced' | 'quality';
|
||||
focusMode: string;
|
||||
history: Array<[string, string]>;
|
||||
files: Array<string>;
|
||||
chatModel: ChatModel;
|
||||
embeddingModel: EmbeddingModel;
|
||||
systemInstructions: string;
|
||||
const bodySchema = z.object({
|
||||
message: messageSchema,
|
||||
optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
|
||||
errorMap: () => ({
|
||||
message: 'Optimization mode must be one of: speed, balanced, quality',
|
||||
}),
|
||||
}),
|
||||
focusMode: z.string().min(1, 'Focus mode is required'),
|
||||
history: z
|
||||
.array(
|
||||
z.tuple([z.string(), z.string()], {
|
||||
errorMap: () => ({
|
||||
message: 'History items must be tuples of two strings',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
files: z.array(z.string()).optional().default([]),
|
||||
chatModel: chatModelSchema,
|
||||
embeddingModel: embeddingModelSchema,
|
||||
systemInstructions: z.string().nullable().optional().default(''),
|
||||
});
|
||||
|
||||
type Message = z.infer<typeof messageSchema>;
|
||||
type Body = z.infer<typeof bodySchema>;
|
||||
|
||||
const safeValidateBody = (data: unknown) => {
|
||||
const result = bodySchema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.errors.map((e) => ({
|
||||
path: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
};
|
||||
|
||||
const handleEmitterEvents = async (
|
||||
stream: EventEmitter,
|
||||
writer: WritableStreamDefaultWriter,
|
||||
encoder: TextEncoder,
|
||||
aiMessageId: string,
|
||||
chatId: string,
|
||||
) => {
|
||||
let recievedMessage = '';
|
||||
let sources: any[] = [];
|
||||
const aiMessageId = crypto.randomBytes(7).toString('hex');
|
||||
|
||||
stream.on('data', (data) => {
|
||||
const parsedData = JSON.parse(data);
|
||||
@@ -87,7 +125,17 @@ const handleEmitterEvents = async (
|
||||
),
|
||||
);
|
||||
|
||||
sources = parsedData.data;
|
||||
const sourceMessageId = crypto.randomBytes(7).toString('hex');
|
||||
|
||||
db.insert(messagesSchema)
|
||||
.values({
|
||||
chatId: chatId,
|
||||
messageId: sourceMessageId,
|
||||
role: 'source',
|
||||
sources: parsedData.data,
|
||||
createdAt: new Date().toString(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
@@ -95,7 +143,6 @@ const handleEmitterEvents = async (
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'messageEnd',
|
||||
messageId: aiMessageId,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
@@ -107,10 +154,7 @@ const handleEmitterEvents = async (
|
||||
chatId: chatId,
|
||||
messageId: aiMessageId,
|
||||
role: 'assistant',
|
||||
metadata: JSON.stringify({
|
||||
createdAt: new Date(),
|
||||
...(sources && sources.length > 0 && { sources }),
|
||||
}),
|
||||
createdAt: new Date().toString(),
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
@@ -138,6 +182,8 @@ const handleHistorySave = async (
|
||||
where: eq(chats.id, message.chatId),
|
||||
});
|
||||
|
||||
const fileData = files.map(getFileDetails);
|
||||
|
||||
if (!chat) {
|
||||
await db
|
||||
.insert(chats)
|
||||
@@ -146,9 +192,15 @@ const handleHistorySave = async (
|
||||
title: message.content,
|
||||
createdAt: new Date().toString(),
|
||||
focusMode: focusMode,
|
||||
files: files.map(getFileDetails),
|
||||
files: fileData,
|
||||
})
|
||||
.execute();
|
||||
} else if (JSON.stringify(chat.files ?? []) != JSON.stringify(fileData)) {
|
||||
db.update(chats)
|
||||
.set({
|
||||
files: files.map(getFileDetails),
|
||||
})
|
||||
.where(eq(chats.id, message.chatId));
|
||||
}
|
||||
|
||||
const messageExists = await db.query.messages.findFirst({
|
||||
@@ -163,9 +215,7 @@ const handleHistorySave = async (
|
||||
chatId: message.chatId,
|
||||
messageId: humanMessageId,
|
||||
role: 'user',
|
||||
metadata: JSON.stringify({
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
createdAt: new Date().toString(),
|
||||
})
|
||||
.execute();
|
||||
} else {
|
||||
@@ -183,7 +233,17 @@ const handleHistorySave = async (
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
try {
|
||||
const body = (await req.json()) as Body;
|
||||
const reqBody = (await req.json()) as Body;
|
||||
|
||||
const parseBody = safeValidateBody(reqBody);
|
||||
if (!parseBody.success) {
|
||||
return Response.json(
|
||||
{ message: 'Invalid request body', error: parseBody.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = parseBody.data as Body;
|
||||
const { message } = body;
|
||||
|
||||
if (message.content === '') {
|
||||
@@ -195,59 +255,18 @@ export const POST = async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
|
||||
getAvailableChatModelProviders(),
|
||||
getAvailableEmbeddingModelProviders(),
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const [llm, embedding] = await Promise.all([
|
||||
registry.loadChatModel(body.chatModel.providerId, body.chatModel.key),
|
||||
registry.loadEmbeddingModel(
|
||||
body.embeddingModel.providerId,
|
||||
body.embeddingModel.key,
|
||||
),
|
||||
]);
|
||||
|
||||
const chatModelProvider =
|
||||
chatModelProviders[
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0]
|
||||
];
|
||||
const chatModel =
|
||||
chatModelProvider[
|
||||
body.chatModel?.name || Object.keys(chatModelProvider)[0]
|
||||
];
|
||||
|
||||
const embeddingProvider =
|
||||
embeddingModelProviders[
|
||||
body.embeddingModel?.provider || Object.keys(embeddingModelProviders)[0]
|
||||
];
|
||||
const embeddingModel =
|
||||
embeddingProvider[
|
||||
body.embeddingModel?.name || Object.keys(embeddingProvider)[0]
|
||||
];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
let embedding = embeddingModel.model;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
openAIApiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!embedding) {
|
||||
return Response.json(
|
||||
{ error: 'Invalid embedding model' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const humanMessageId =
|
||||
message.messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
const aiMessageId = crypto.randomBytes(7).toString('hex');
|
||||
|
||||
const history: BaseMessage[] = body.history.map((msg) => {
|
||||
if (msg[0] === 'human') {
|
||||
@@ -279,14 +298,14 @@ export const POST = async (req: Request) => {
|
||||
embedding,
|
||||
body.optimizationMode,
|
||||
body.files,
|
||||
body.systemInstructions,
|
||||
body.systemInstructions as string,
|
||||
);
|
||||
|
||||
const responseStream = new TransformStream();
|
||||
const writer = responseStream.writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
handleEmitterEvents(stream, writer, encoder, aiMessageId, message.chatId);
|
||||
handleEmitterEvents(stream, writer, encoder, message.chatId);
|
||||
handleHistorySave(message, humanMessageId, body.focusMode, body.files);
|
||||
|
||||
return new Response(responseStream.readable, {
|
||||
|
@@ -1,118 +1,76 @@
|
||||
import {
|
||||
getAnthropicApiKey,
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
getGeminiApiKey,
|
||||
getGroqApiKey,
|
||||
getOllamaApiEndpoint,
|
||||
getOpenaiApiKey,
|
||||
getDeepseekApiKey,
|
||||
getLMStudioApiEndpoint,
|
||||
updateConfig,
|
||||
} from '@/lib/config';
|
||||
import {
|
||||
getAvailableChatModelProviders,
|
||||
getAvailableEmbeddingModelProviders,
|
||||
} from '@/lib/providers';
|
||||
import configManager from '@/lib/config';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ConfigModelProvider } from '@/lib/config/types';
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
try {
|
||||
const config: Record<string, any> = {};
|
||||
|
||||
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
|
||||
getAvailableChatModelProviders(),
|
||||
getAvailableEmbeddingModelProviders(),
|
||||
]);
|
||||
|
||||
config['chatModelProviders'] = {};
|
||||
config['embeddingModelProviders'] = {};
|
||||
|
||||
for (const provider in chatModelProviders) {
|
||||
config['chatModelProviders'][provider] = Object.keys(
|
||||
chatModelProviders[provider],
|
||||
).map((model) => {
|
||||
return {
|
||||
name: model,
|
||||
displayName: chatModelProviders[provider][model].displayName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const provider in embeddingModelProviders) {
|
||||
config['embeddingModelProviders'][provider] = Object.keys(
|
||||
embeddingModelProviders[provider],
|
||||
).map((model) => {
|
||||
return {
|
||||
name: model,
|
||||
displayName: embeddingModelProviders[provider][model].displayName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
config['openaiApiKey'] = getOpenaiApiKey();
|
||||
config['ollamaApiUrl'] = getOllamaApiEndpoint();
|
||||
config['lmStudioApiUrl'] = getLMStudioApiEndpoint();
|
||||
config['anthropicApiKey'] = getAnthropicApiKey();
|
||||
config['groqApiKey'] = getGroqApiKey();
|
||||
config['geminiApiKey'] = getGeminiApiKey();
|
||||
config['deepseekApiKey'] = getDeepseekApiKey();
|
||||
config['customOpenaiApiUrl'] = getCustomOpenaiApiUrl();
|
||||
config['customOpenaiApiKey'] = getCustomOpenaiApiKey();
|
||||
config['customOpenaiModelName'] = getCustomOpenaiModelName();
|
||||
|
||||
return Response.json({ ...config }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('An error occurred while getting config:', err);
|
||||
return Response.json(
|
||||
{ message: 'An error occurred while getting config' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
type SaveConfigBody = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
export const GET = async (req: NextRequest) => {
|
||||
try {
|
||||
const config = await req.json();
|
||||
const values = configManager.getCurrentConfig();
|
||||
const fields = configManager.getUIConfigSections();
|
||||
|
||||
const updatedConfig = {
|
||||
MODELS: {
|
||||
OPENAI: {
|
||||
API_KEY: config.openaiApiKey,
|
||||
},
|
||||
GROQ: {
|
||||
API_KEY: config.groqApiKey,
|
||||
},
|
||||
ANTHROPIC: {
|
||||
API_KEY: config.anthropicApiKey,
|
||||
},
|
||||
GEMINI: {
|
||||
API_KEY: config.geminiApiKey,
|
||||
},
|
||||
OLLAMA: {
|
||||
API_URL: config.ollamaApiUrl,
|
||||
},
|
||||
DEEPSEEK: {
|
||||
API_KEY: config.deepseekApiKey,
|
||||
},
|
||||
LM_STUDIO: {
|
||||
API_URL: config.lmStudioApiUrl,
|
||||
},
|
||||
CUSTOM_OPENAI: {
|
||||
API_URL: config.customOpenaiApiUrl,
|
||||
API_KEY: config.customOpenaiApiKey,
|
||||
MODEL_NAME: config.customOpenaiModelName,
|
||||
},
|
||||
const modelRegistry = new ModelRegistry();
|
||||
const modelProviders = await modelRegistry.getActiveProviders();
|
||||
|
||||
values.modelProviders = values.modelProviders.map(
|
||||
(mp: ConfigModelProvider) => {
|
||||
const activeProvider = modelProviders.find((p) => p.id === mp.id);
|
||||
|
||||
return {
|
||||
...mp,
|
||||
chatModels: activeProvider?.chatModels ?? mp.chatModels,
|
||||
embeddingModels:
|
||||
activeProvider?.embeddingModels ?? mp.embeddingModels,
|
||||
};
|
||||
},
|
||||
};
|
||||
);
|
||||
|
||||
updateConfig(updatedConfig);
|
||||
|
||||
return Response.json({ message: 'Config updated' }, { status: 200 });
|
||||
return NextResponse.json({
|
||||
values,
|
||||
fields,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('An error occurred while updating config:', err);
|
||||
console.error('Error in getting config: ', err);
|
||||
return Response.json(
|
||||
{ message: 'An error occurred while updating config' },
|
||||
{ message: 'An error has occurred.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const body: SaveConfigBody = await req.json();
|
||||
|
||||
if (!body.key || !body.value) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Key and value are required.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
configManager.updateConfig(body.key, body.value);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Config updated successfully.',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error in getting config: ', err);
|
||||
return Response.json(
|
||||
{ message: 'An error has occurred.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
@@ -1,55 +1,77 @@
|
||||
import { searchSearxng } from '@/lib/searxng';
|
||||
|
||||
const articleWebsites = [
|
||||
'yahoo.com',
|
||||
'www.exchangewire.com',
|
||||
'businessinsider.com',
|
||||
/* 'wired.com',
|
||||
'mashable.com',
|
||||
'theverge.com',
|
||||
'gizmodo.com',
|
||||
'cnet.com',
|
||||
'venturebeat.com', */
|
||||
];
|
||||
const websitesForTopic = {
|
||||
tech: {
|
||||
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
|
||||
links: ['techcrunch.com', 'wired.com', 'theverge.com'],
|
||||
},
|
||||
finance: {
|
||||
query: ['finance news', 'economy', 'stock market', 'investing'],
|
||||
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com'],
|
||||
},
|
||||
art: {
|
||||
query: ['art news', 'culture', 'modern art', 'cultural events'],
|
||||
links: ['artnews.com', 'hyperallergic.com', 'theartnewspaper.com'],
|
||||
},
|
||||
sports: {
|
||||
query: ['sports news', 'latest sports', 'cricket football tennis'],
|
||||
links: ['espn.com', 'bbc.com/sport', 'skysports.com'],
|
||||
},
|
||||
entertainment: {
|
||||
query: ['entertainment news', 'movies', 'TV shows', 'celebrities'],
|
||||
links: ['hollywoodreporter.com', 'variety.com', 'deadline.com'],
|
||||
},
|
||||
};
|
||||
|
||||
const topics = ['AI', 'tech']; /* TODO: Add UI to customize this */
|
||||
type Topic = keyof typeof websitesForTopic;
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
try {
|
||||
const params = new URL(req.url).searchParams;
|
||||
|
||||
const mode: 'normal' | 'preview' =
|
||||
(params.get('mode') as 'normal' | 'preview') || 'normal';
|
||||
const topic: Topic = (params.get('topic') as Topic) || 'tech';
|
||||
|
||||
const selectedTopic = websitesForTopic[topic];
|
||||
|
||||
let data = [];
|
||||
|
||||
if (mode === 'normal') {
|
||||
const seenUrls = new Set();
|
||||
|
||||
data = (
|
||||
await Promise.all([
|
||||
...new Array(articleWebsites.length * topics.length)
|
||||
.fill(0)
|
||||
.map(async (_, i) => {
|
||||
await Promise.all(
|
||||
selectedTopic.links.flatMap((link) =>
|
||||
selectedTopic.query.map(async (query) => {
|
||||
return (
|
||||
await searchSearxng(
|
||||
`site:${articleWebsites[i % articleWebsites.length]} ${
|
||||
topics[i % topics.length]
|
||||
}`,
|
||||
{
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
},
|
||||
)
|
||||
await searchSearxng(`site:${link} ${query}`, {
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
language: 'en',
|
||||
})
|
||||
).results;
|
||||
}),
|
||||
])
|
||||
),
|
||||
)
|
||||
)
|
||||
.map((result) => result)
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const url = item.url?.toLowerCase().trim();
|
||||
if (seenUrls.has(url)) return false;
|
||||
seenUrls.add(url);
|
||||
return true;
|
||||
})
|
||||
.sort(() => Math.random() - 0.5);
|
||||
} else {
|
||||
data = (
|
||||
await searchSearxng(
|
||||
`site:${articleWebsites[Math.floor(Math.random() * articleWebsites.length)]} ${topics[Math.floor(Math.random() * topics.length)]}`,
|
||||
{ engines: ['bing news'], pageno: 1 },
|
||||
`site:${selectedTopic.links[Math.floor(Math.random() * selectedTopic.links.length)]} ${selectedTopic.query[Math.floor(Math.random() * selectedTopic.query.length)]}`,
|
||||
{
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
language: 'en',
|
||||
},
|
||||
)
|
||||
).results;
|
||||
}
|
||||
|
@@ -1,23 +1,12 @@
|
||||
import handleImageSearch from '@/lib/chains/imageSearchAgent';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { getAvailableChatModelProviders } from '@/lib/providers';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
|
||||
interface ChatModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface ImageSearchBody {
|
||||
query: string;
|
||||
chatHistory: any[];
|
||||
chatModel?: ChatModel;
|
||||
chatModel: ModelWithProvider;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -34,35 +23,12 @@ export const POST = async (req: Request) => {
|
||||
})
|
||||
.filter((msg) => msg !== undefined) as BaseMessage[];
|
||||
|
||||
const chatModelProviders = await getAvailableChatModelProviders();
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const chatModelProvider =
|
||||
chatModelProviders[
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0]
|
||||
];
|
||||
const chatModel =
|
||||
chatModelProvider[
|
||||
body.chatModel?.model || Object.keys(chatModelProvider)[0]
|
||||
];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
openAIApiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
const llm = await registry.loadChatModel(
|
||||
body.chatModel.providerId,
|
||||
body.chatModel.key,
|
||||
);
|
||||
|
||||
const images = await handleImageSearch(
|
||||
{
|
||||
|
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
getAvailableChatModelProviders,
|
||||
getAvailableEmbeddingModelProviders,
|
||||
} from '@/lib/providers';
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
try {
|
||||
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
|
||||
getAvailableChatModelProviders(),
|
||||
getAvailableEmbeddingModelProviders(),
|
||||
]);
|
||||
|
||||
Object.keys(chatModelProviders).forEach((provider) => {
|
||||
Object.keys(chatModelProviders[provider]).forEach((model) => {
|
||||
delete (chatModelProviders[provider][model] as { model?: unknown })
|
||||
.model;
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(embeddingModelProviders).forEach((provider) => {
|
||||
Object.keys(embeddingModelProviders[provider]).forEach((model) => {
|
||||
delete (embeddingModelProviders[provider][model] as { model?: unknown })
|
||||
.model;
|
||||
});
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
chatModelProviders,
|
||||
embeddingModelProviders,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while fetching models', err);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
94
src/app/api/providers/[id]/models/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { Model } from '@/lib/models/types';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const POST = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const body: Partial<Model> & { type: 'embedding' | 'chat' } =
|
||||
await req.json();
|
||||
|
||||
if (!body.key || !body.name) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Key and name must be provided',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
await registry.addProviderModel(id, body.type, body);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Model added successfully',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while adding provider model', err);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const body: { key: string; type: 'embedding' | 'chat' } = await req.json();
|
||||
|
||||
if (!body.key) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Key and name must be provided',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
await registry.removeProviderModel(id, body.type, body.key);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Model added successfully',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while deleting provider model', err);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
89
src/app/api/providers/[id]/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const DELETE = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Provider ID is required.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const registry = new ModelRegistry();
|
||||
await registry.removeProvider(id);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Provider deleted successfully.',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('An error occurred while deleting provider', err.message);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { name, config } = body;
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || !name || !config) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Missing required fields.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const updatedProvider = await registry.updateProvider(id, name, config);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
provider: updatedProvider,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('An error occurred while updating provider', err.message);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
74
src/app/api/providers/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
try {
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const activeProviders = await registry.getActiveProviders();
|
||||
|
||||
const filteredProviders = activeProviders.filter((p) => {
|
||||
return !p.chatModels.some((m) => m.key === 'error');
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
providers: filteredProviders,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while fetching providers', err);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { type, name, config } = body;
|
||||
|
||||
if (!type || !name || !config) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Missing required fields.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const newProvider = await registry.addProvider(type, name, config);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
provider: newProvider,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while creating provider', err);
|
||||
return Response.json(
|
||||
{
|
||||
message: 'An error has occurred.',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
@@ -1,36 +1,14 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { Embeddings } from '@langchain/core/embeddings';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import {
|
||||
getAvailableChatModelProviders,
|
||||
getAvailableEmbeddingModelProviders,
|
||||
} from '@/lib/providers';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { MetaSearchAgentType } from '@/lib/search/metaSearchAgent';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { searchHandlers } from '@/lib/search';
|
||||
|
||||
interface chatModel {
|
||||
provider: string;
|
||||
name: string;
|
||||
customOpenAIKey?: string;
|
||||
customOpenAIBaseURL?: string;
|
||||
}
|
||||
|
||||
interface embeddingModel {
|
||||
provider: string;
|
||||
name: string;
|
||||
}
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
|
||||
interface ChatRequestBody {
|
||||
optimizationMode: 'speed' | 'balanced';
|
||||
focusMode: string;
|
||||
chatModel?: chatModel;
|
||||
embeddingModel?: embeddingModel;
|
||||
chatModel: ModelWithProvider;
|
||||
embeddingModel: ModelWithProvider;
|
||||
query: string;
|
||||
history: Array<[string, string]>;
|
||||
stream?: boolean;
|
||||
@@ -58,61 +36,16 @@ export const POST = async (req: Request) => {
|
||||
: new AIMessage({ content: msg[1] });
|
||||
});
|
||||
|
||||
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
|
||||
getAvailableChatModelProviders(),
|
||||
getAvailableEmbeddingModelProviders(),
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const [llm, embeddings] = await Promise.all([
|
||||
registry.loadChatModel(body.chatModel.providerId, body.chatModel.key),
|
||||
registry.loadEmbeddingModel(
|
||||
body.embeddingModel.providerId,
|
||||
body.embeddingModel.key,
|
||||
),
|
||||
]);
|
||||
|
||||
const chatModelProvider =
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0];
|
||||
const chatModel =
|
||||
body.chatModel?.name ||
|
||||
Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
|
||||
const embeddingModelProvider =
|
||||
body.embeddingModel?.provider || Object.keys(embeddingModelProviders)[0];
|
||||
const embeddingModel =
|
||||
body.embeddingModel?.name ||
|
||||
Object.keys(embeddingModelProviders[embeddingModelProvider])[0];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
let embeddings: Embeddings | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
modelName: body.chatModel?.name || getCustomOpenaiModelName(),
|
||||
openAIApiKey:
|
||||
body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL:
|
||||
body.chatModel?.customOpenAIBaseURL || getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (
|
||||
chatModelProviders[chatModelProvider] &&
|
||||
chatModelProviders[chatModelProvider][chatModel]
|
||||
) {
|
||||
llm = chatModelProviders[chatModelProvider][chatModel]
|
||||
.model as unknown as BaseChatModel | undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
embeddingModelProviders[embeddingModelProvider] &&
|
||||
embeddingModelProviders[embeddingModelProvider][embeddingModel]
|
||||
) {
|
||||
embeddings = embeddingModelProviders[embeddingModelProvider][
|
||||
embeddingModel
|
||||
].model as Embeddings | undefined;
|
||||
}
|
||||
|
||||
if (!llm || !embeddings) {
|
||||
return Response.json(
|
||||
{ message: 'Invalid model selected' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const searchHandler: MetaSearchAgentType = searchHandlers[body.focusMode];
|
||||
|
||||
if (!searchHandler) {
|
||||
|
@@ -1,22 +1,12 @@
|
||||
import generateSuggestions from '@/lib/chains/suggestionGeneratorAgent';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { getAvailableChatModelProviders } from '@/lib/providers';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
|
||||
interface ChatModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface SuggestionsGenerationBody {
|
||||
chatHistory: any[];
|
||||
chatModel?: ChatModel;
|
||||
chatModel: ModelWithProvider;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -33,35 +23,12 @@ export const POST = async (req: Request) => {
|
||||
})
|
||||
.filter((msg) => msg !== undefined) as BaseMessage[];
|
||||
|
||||
const chatModelProviders = await getAvailableChatModelProviders();
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const chatModelProvider =
|
||||
chatModelProviders[
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0]
|
||||
];
|
||||
const chatModel =
|
||||
chatModelProvider[
|
||||
body.chatModel?.model || Object.keys(chatModelProvider)[0]
|
||||
];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
openAIApiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
const llm = await registry.loadChatModel(
|
||||
body.chatModel.providerId,
|
||||
body.chatModel.key,
|
||||
);
|
||||
|
||||
const suggestions = await generateSuggestions(
|
||||
{
|
||||
|
@@ -2,11 +2,11 @@ import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { getAvailableEmbeddingModelProviders } from '@/lib/providers';
|
||||
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
|
||||
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx';
|
||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
|
||||
import { Document } from 'langchain/document';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
|
||||
interface FileRes {
|
||||
fileName: string;
|
||||
@@ -30,8 +30,8 @@ export async function POST(req: Request) {
|
||||
const formData = await req.formData();
|
||||
|
||||
const files = formData.getAll('files') as File[];
|
||||
const embedding_model = formData.get('embedding_model');
|
||||
const embedding_model_provider = formData.get('embedding_model_provider');
|
||||
const embedding_model = formData.get('embedding_model_key') as string;
|
||||
const embedding_model_provider = formData.get('embedding_model_provider_id') as string;
|
||||
|
||||
if (!embedding_model || !embedding_model_provider) {
|
||||
return NextResponse.json(
|
||||
@@ -40,20 +40,9 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const embeddingModels = await getAvailableEmbeddingModelProviders();
|
||||
const provider =
|
||||
embedding_model_provider ?? Object.keys(embeddingModels)[0];
|
||||
const embeddingModel =
|
||||
embedding_model ?? Object.keys(embeddingModels[provider as string])[0];
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
let embeddingsModel =
|
||||
embeddingModels[provider as string]?.[embeddingModel as string]?.model;
|
||||
if (!embeddingsModel) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Invalid embedding model selected' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const model = await registry.loadEmbeddingModel(embedding_model_provider, embedding_model);
|
||||
|
||||
const processedFiles: FileRes[] = [];
|
||||
|
||||
@@ -98,7 +87,7 @@ export async function POST(req: Request) {
|
||||
}),
|
||||
);
|
||||
|
||||
const embeddings = await embeddingsModel.embedDocuments(
|
||||
const embeddings = await model.embedDocuments(
|
||||
splitted.map((doc) => doc.pageContent),
|
||||
);
|
||||
const embeddingsDataPath = filePath.replace(
|
||||
|
@@ -1,23 +1,12 @@
|
||||
import handleVideoSearch from '@/lib/chains/videoSearchAgent';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { getAvailableChatModelProviders } from '@/lib/providers';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
|
||||
interface ChatModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface VideoSearchBody {
|
||||
query: string;
|
||||
chatHistory: any[];
|
||||
chatModel?: ChatModel;
|
||||
chatModel: ModelWithProvider;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -34,35 +23,12 @@ export const POST = async (req: Request) => {
|
||||
})
|
||||
.filter((msg) => msg !== undefined) as BaseMessage[];
|
||||
|
||||
const chatModelProviders = await getAvailableChatModelProviders();
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const chatModelProvider =
|
||||
chatModelProviders[
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0]
|
||||
];
|
||||
const chatModel =
|
||||
chatModelProvider[
|
||||
body.chatModel?.model || Object.keys(chatModelProvider)[0]
|
||||
];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
openAIApiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
const llm = await registry.loadChatModel(
|
||||
body.chatModel.providerId,
|
||||
body.chatModel.key,
|
||||
);
|
||||
|
||||
const videos = await handleVideoSearch(
|
||||
{
|
||||
|
@@ -1,6 +1,10 @@
|
||||
export const POST = async (req: Request) => {
|
||||
try {
|
||||
const body: { lat: number; lng: number } = await req.json();
|
||||
const body: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
measureUnit: 'Imperial' | 'Metric';
|
||||
} = await req.json();
|
||||
|
||||
if (!body.lat || !body.lng) {
|
||||
return Response.json(
|
||||
@@ -12,7 +16,9 @@ export const POST = async (req: Request) => {
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto`,
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
|
||||
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
|
||||
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
@@ -33,12 +39,16 @@ export const POST = async (req: Request) => {
|
||||
humidity: number;
|
||||
windSpeed: number;
|
||||
icon: string;
|
||||
temperatureUnit: 'C' | 'F';
|
||||
windSpeedUnit: 'm/s' | 'mph';
|
||||
} = {
|
||||
temperature: data.current.temperature_2m,
|
||||
condition: '',
|
||||
humidity: data.current.relative_humidity_2m,
|
||||
windSpeed: data.current.wind_speed_10m,
|
||||
icon: '',
|
||||
temperatureUnit: body.measureUnit === 'Metric' ? 'C' : 'F',
|
||||
windSpeedUnit: body.measureUnit === 'Metric' ? 'm/s' : 'mph',
|
||||
};
|
||||
|
||||
const code = data.current.weather_code;
|
||||
|
@@ -1,9 +1,17 @@
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
const Page = ({ params }: { params: Promise<{ chatId: string }> }) => {
|
||||
const { chatId } = React.use(params);
|
||||
return <ChatWindow id={chatId} />;
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import { ChatProvider } from '@/lib/hooks/useChat';
|
||||
|
||||
const Page = () => {
|
||||
const { chatId }: { chatId: string } = useParams();
|
||||
return (
|
||||
<ChatProvider id={chatId}>
|
||||
<ChatWindow />
|
||||
</ChatProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
@@ -1,110 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { Globe2Icon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import SmallNewsCard from '@/components/Discover/SmallNewsCard';
|
||||
import MajorNewsCard from '@/components/Discover/MajorNewsCard';
|
||||
|
||||
interface Discover {
|
||||
export interface Discover {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
const topics: { key: string; display: string }[] = [
|
||||
{
|
||||
display: 'Tech & Science',
|
||||
key: 'tech',
|
||||
},
|
||||
{
|
||||
display: 'Finance',
|
||||
key: 'finance',
|
||||
},
|
||||
{
|
||||
display: 'Art & Culture',
|
||||
key: 'art',
|
||||
},
|
||||
{
|
||||
display: 'Sports',
|
||||
key: 'sports',
|
||||
},
|
||||
{
|
||||
display: 'Entertainment',
|
||||
key: 'entertainment',
|
||||
},
|
||||
];
|
||||
|
||||
const Page = () => {
|
||||
const [discover, setDiscover] = useState<Discover[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
|
||||
|
||||
const fetchArticles = async (topic: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/discover?topic=${topic}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
|
||||
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
|
||||
|
||||
setDiscover(data.blogs);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching data:', err.message);
|
||||
toast.error('Error fetching data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/discover`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
fetchArticles(activeTopic);
|
||||
}, [activeTopic]);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
|
||||
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
|
||||
|
||||
setDiscover(data.blogs);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching data:', err.message);
|
||||
toast.error('Error fetching data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="flex items-center">
|
||||
<Search />
|
||||
<h1 className="text-3xl font-medium p-2">Discover</h1>
|
||||
<div className="flex flex-col pt-10 border-b border-light-200/20 dark:border-dark-200/20 pb-6 px-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Globe2Icon size={45} className="mb-2.5" />
|
||||
<h1
|
||||
className="text-5xl font-normal p-2"
|
||||
style={{ fontFamily: 'PP Editorial, serif' }}
|
||||
>
|
||||
Discover
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
|
||||
{topics.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'border-[0.1px] rounded-full text-sm px-3 py-1 text-nowrap transition duration-200 cursor-pointer',
|
||||
activeTopic === t.key
|
||||
? 'text-cyan-700 dark:text-cyan-300 bg-cyan-300/20 border-cyan-700/60 dar:bg-cyan-300/30 dark:border-cyan-300/40'
|
||||
: 'border-black/30 dark:border-white/30 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:border-black/40 dark:hover:border-white/40 hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setActiveTopic(t.key)}
|
||||
>
|
||||
<span>{t.display}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 lg:pb-8 w-full justify-items-center lg:justify-items-start">
|
||||
{discover &&
|
||||
discover?.map((item, i) => (
|
||||
<Link
|
||||
href={`/?q=Summary: ${item.url}`}
|
||||
key={i}
|
||||
className="max-w-sm rounded-lg overflow-hidden bg-light-secondary dark:bg-dark-secondary hover:-translate-y-[1px] transition duration-200"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
className="object-cover w-full aspect-video"
|
||||
src={
|
||||
new URL(item.thumbnail).origin +
|
||||
new URL(item.thumbnail).pathname +
|
||||
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 pb-28 pt-5 lg:pb-8 w-full">
|
||||
<div className="block lg:hidden">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{discover?.map((item, i) => (
|
||||
<SmallNewsCard key={`mobile-${i}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
{discover &&
|
||||
discover.length > 0 &&
|
||||
(() => {
|
||||
const sections = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < discover.length) {
|
||||
if (sections.length > 0) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<MajorNewsCard
|
||||
key={`major-${index}`}
|
||||
item={discover[index]}
|
||||
isLeft={false}
|
||||
/>,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}-after`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
const smallCards = discover.slice(index, index + 3);
|
||||
sections.push(
|
||||
<div
|
||||
key={`small-group-${index}`}
|
||||
className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4"
|
||||
>
|
||||
{smallCards.map((item, i) => (
|
||||
<SmallNewsCard
|
||||
key={`small-${index + i}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
index += 3;
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}-after-small`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length - 1) {
|
||||
const twoMajorCards = discover.slice(index, index + 2);
|
||||
twoMajorCards.forEach((item, i) => {
|
||||
sections.push(
|
||||
<MajorNewsCard
|
||||
key={`double-${index + i}`}
|
||||
item={item}
|
||||
isLeft={i === 0}
|
||||
/>,
|
||||
);
|
||||
if (i === 0) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-double-${index + i}`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
});
|
||||
index += 2;
|
||||
} else if (index < discover.length) {
|
||||
sections.push(
|
||||
<MajorNewsCard
|
||||
key={`final-major-${index}`}
|
||||
item={discover[index]}
|
||||
isLeft={true}
|
||||
/>,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
sections.push(
|
||||
<hr
|
||||
key={`sep-${index}-after-major`}
|
||||
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (index < discover.length) {
|
||||
const smallCards = discover.slice(index, index + 3);
|
||||
sections.push(
|
||||
<div
|
||||
key={`small-group-2-${index}`}
|
||||
className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4"
|
||||
>
|
||||
{smallCards.map((item, i) => (
|
||||
<SmallNewsCard
|
||||
key={`small-2-${index + i}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
index += 3;
|
||||
}
|
||||
}
|
||||
alt={item.title}
|
||||
/>
|
||||
<div className="px-6 py-4">
|
||||
<div className="font-bold text-lg mb-2">
|
||||
{item.title.slice(0, 100)}...
|
||||
</div>
|
||||
<p className="text-black-70 dark:text-white/70 text-sm">
|
||||
{item.content.slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
return sections;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@@ -2,6 +2,14 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'PP Editorial';
|
||||
src: url('/fonts/pp-ed-ul.otf') format('opentype');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.overflow-hidden-scrollable {
|
||||
-ms-overflow-style: none;
|
||||
@@ -11,3 +19,20 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
select,
|
||||
textarea,
|
||||
input {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
54
src/app/manifest.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Perplexica - Chat with the internet',
|
||||
short_name: 'Perplexica',
|
||||
description:
|
||||
'Perplexica is an AI powered chatbot that is connected to the internet.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0a0a0a',
|
||||
theme_color: '#0a0a0a',
|
||||
screenshots: [
|
||||
{
|
||||
src: '/screenshots/p1.png',
|
||||
form_factor: 'wide',
|
||||
sizes: '2560x1600',
|
||||
},
|
||||
{
|
||||
src: '/screenshots/p2.png',
|
||||
form_factor: 'wide',
|
||||
sizes: '2560x1600',
|
||||
},
|
||||
{
|
||||
src: '/screenshots/p1_small.png',
|
||||
form_factor: 'narrow',
|
||||
sizes: '828x1792',
|
||||
},
|
||||
{
|
||||
src: '/screenshots/p2_small.png',
|
||||
form_factor: 'narrow',
|
||||
sizes: '828x1792',
|
||||
},
|
||||
],
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-50.png',
|
||||
sizes: '50x50',
|
||||
type: 'image/png' as const,
|
||||
},
|
||||
{
|
||||
src: '/icon-100.png',
|
||||
sizes: '100x100',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icon.png',
|
||||
sizes: '440x440',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
import { ChatProvider } from '@/lib/hooks/useChat';
|
||||
import { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
@@ -11,7 +12,9 @@ const Home = () => {
|
||||
return (
|
||||
<div>
|
||||
<Suspense>
|
||||
<ChatWindow />
|
||||
<ChatProvider>
|
||||
<ChatWindow />
|
||||
</ChatProvider>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,892 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Settings as SettingsIcon, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Switch } from '@headlessui/react';
|
||||
import ThemeSwitcher from '@/components/theme/Switcher';
|
||||
import { ImagesIcon, VideoIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { PROVIDER_METADATA } from '@/lib/providers';
|
||||
|
||||
interface SettingsType {
|
||||
chatModelProviders: {
|
||||
[key: string]: [Record<string, any>];
|
||||
};
|
||||
embeddingModelProviders: {
|
||||
[key: string]: [Record<string, any>];
|
||||
};
|
||||
openaiApiKey: string;
|
||||
groqApiKey: string;
|
||||
anthropicApiKey: string;
|
||||
geminiApiKey: string;
|
||||
ollamaApiUrl: string;
|
||||
lmStudioApiUrl: string;
|
||||
deepseekApiKey: string;
|
||||
customOpenaiApiKey: string;
|
||||
customOpenaiApiUrl: string;
|
||||
customOpenaiModelName: string;
|
||||
}
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
isSaving?: boolean;
|
||||
onSave?: (value: string) => void;
|
||||
}
|
||||
|
||||
const Input = ({ className, isSaving, onSave, ...restProps }: InputProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary w-full px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
|
||||
isSaving && 'pr-10',
|
||||
className,
|
||||
)}
|
||||
onBlur={(e) => onSave?.(e.target.value)}
|
||||
/>
|
||||
{isSaving && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2
|
||||
size={16}
|
||||
className="animate-spin text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextareaProps extends React.InputHTMLAttributes<HTMLTextAreaElement> {
|
||||
isSaving?: boolean;
|
||||
onSave?: (value: string) => void;
|
||||
}
|
||||
|
||||
const Textarea = ({
|
||||
className,
|
||||
isSaving,
|
||||
onSave,
|
||||
...restProps
|
||||
}: TextareaProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder="Any special instructions for the LLM"
|
||||
className="placeholder:text-sm text-sm w-full flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors"
|
||||
rows={4}
|
||||
onBlur={(e) => onSave?.(e.target.value)}
|
||||
{...restProps}
|
||||
/>
|
||||
{isSaving && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<Loader2
|
||||
size={16}
|
||||
className="animate-spin text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = ({
|
||||
className,
|
||||
options,
|
||||
...restProps
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
options: { value: string; label: string; disabled?: boolean }[];
|
||||
}) => {
|
||||
return (
|
||||
<select
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map(({ label, value, disabled }) => (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSection = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex flex-col space-y-4 p-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200">
|
||||
<h2 className="text-black/90 dark:text-white/90 font-medium">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Page = () => {
|
||||
const [config, setConfig] = useState<SettingsType | null>(null);
|
||||
const [chatModels, setChatModels] = useState<Record<string, any>>({});
|
||||
const [embeddingModels, setEmbeddingModels] = useState<Record<string, any>>(
|
||||
{},
|
||||
);
|
||||
const [selectedChatModelProvider, setSelectedChatModelProvider] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedEmbeddingModelProvider, setSelectedEmbeddingModelProvider] =
|
||||
useState<string | null>(null);
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [automaticImageSearch, setAutomaticImageSearch] = useState(false);
|
||||
const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false);
|
||||
const [systemInstructions, setSystemInstructions] = useState<string>('');
|
||||
const [savingStates, setSavingStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
const res = await fetch(`/api/config`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await res.json()) as SettingsType;
|
||||
setConfig(data);
|
||||
|
||||
const chatModelProvidersKeys = Object.keys(data.chatModelProviders || {});
|
||||
const embeddingModelProvidersKeys = Object.keys(
|
||||
data.embeddingModelProviders || {},
|
||||
);
|
||||
|
||||
const defaultChatModelProvider =
|
||||
chatModelProvidersKeys.length > 0 ? chatModelProvidersKeys[0] : '';
|
||||
const defaultEmbeddingModelProvider =
|
||||
embeddingModelProvidersKeys.length > 0
|
||||
? embeddingModelProvidersKeys[0]
|
||||
: '';
|
||||
|
||||
const chatModelProvider =
|
||||
localStorage.getItem('chatModelProvider') ||
|
||||
defaultChatModelProvider ||
|
||||
'';
|
||||
const chatModel =
|
||||
localStorage.getItem('chatModel') ||
|
||||
(data.chatModelProviders &&
|
||||
data.chatModelProviders[chatModelProvider]?.length > 0
|
||||
? data.chatModelProviders[chatModelProvider][0].name
|
||||
: undefined) ||
|
||||
'';
|
||||
const embeddingModelProvider =
|
||||
localStorage.getItem('embeddingModelProvider') ||
|
||||
defaultEmbeddingModelProvider ||
|
||||
'';
|
||||
const embeddingModel =
|
||||
localStorage.getItem('embeddingModel') ||
|
||||
(data.embeddingModelProviders &&
|
||||
data.embeddingModelProviders[embeddingModelProvider]?.[0].name) ||
|
||||
'';
|
||||
|
||||
setSelectedChatModelProvider(chatModelProvider);
|
||||
setSelectedChatModel(chatModel);
|
||||
setSelectedEmbeddingModelProvider(embeddingModelProvider);
|
||||
setSelectedEmbeddingModel(embeddingModel);
|
||||
setChatModels(data.chatModelProviders || {});
|
||||
setEmbeddingModels(data.embeddingModelProviders || {});
|
||||
|
||||
setAutomaticImageSearch(
|
||||
localStorage.getItem('autoImageSearch') === 'true',
|
||||
);
|
||||
setAutomaticVideoSearch(
|
||||
localStorage.getItem('autoVideoSearch') === 'true',
|
||||
);
|
||||
|
||||
setSystemInstructions(localStorage.getItem('systemInstructions')!);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const saveConfig = async (key: string, value: any) => {
|
||||
setSavingStates((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
const updatedConfig = {
|
||||
...config,
|
||||
[key]: value,
|
||||
} as SettingsType;
|
||||
|
||||
const response = await fetch(`/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updatedConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update config');
|
||||
}
|
||||
|
||||
setConfig(updatedConfig);
|
||||
|
||||
if (
|
||||
key.toLowerCase().includes('api') ||
|
||||
key.toLowerCase().includes('url')
|
||||
) {
|
||||
const res = await fetch(`/api/config`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch updated config');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
setChatModels(data.chatModelProviders || {});
|
||||
setEmbeddingModels(data.embeddingModelProviders || {});
|
||||
|
||||
const currentChatProvider = selectedChatModelProvider;
|
||||
const newChatProviders = Object.keys(data.chatModelProviders || {});
|
||||
|
||||
if (!currentChatProvider && newChatProviders.length > 0) {
|
||||
const firstProvider = newChatProviders[0];
|
||||
const firstModel = data.chatModelProviders[firstProvider]?.[0]?.name;
|
||||
|
||||
if (firstModel) {
|
||||
setSelectedChatModelProvider(firstProvider);
|
||||
setSelectedChatModel(firstModel);
|
||||
localStorage.setItem('chatModelProvider', firstProvider);
|
||||
localStorage.setItem('chatModel', firstModel);
|
||||
}
|
||||
} else if (
|
||||
currentChatProvider &&
|
||||
(!data.chatModelProviders ||
|
||||
!data.chatModelProviders[currentChatProvider] ||
|
||||
!Array.isArray(data.chatModelProviders[currentChatProvider]) ||
|
||||
data.chatModelProviders[currentChatProvider].length === 0)
|
||||
) {
|
||||
const firstValidProvider = Object.entries(
|
||||
data.chatModelProviders || {},
|
||||
).find(
|
||||
([_, models]) => Array.isArray(models) && models.length > 0,
|
||||
)?.[0];
|
||||
|
||||
if (firstValidProvider) {
|
||||
setSelectedChatModelProvider(firstValidProvider);
|
||||
setSelectedChatModel(
|
||||
data.chatModelProviders[firstValidProvider][0].name,
|
||||
);
|
||||
localStorage.setItem('chatModelProvider', firstValidProvider);
|
||||
localStorage.setItem(
|
||||
'chatModel',
|
||||
data.chatModelProviders[firstValidProvider][0].name,
|
||||
);
|
||||
} else {
|
||||
setSelectedChatModelProvider(null);
|
||||
setSelectedChatModel(null);
|
||||
localStorage.removeItem('chatModelProvider');
|
||||
localStorage.removeItem('chatModel');
|
||||
}
|
||||
}
|
||||
|
||||
const currentEmbeddingProvider = selectedEmbeddingModelProvider;
|
||||
const newEmbeddingProviders = Object.keys(
|
||||
data.embeddingModelProviders || {},
|
||||
);
|
||||
|
||||
if (!currentEmbeddingProvider && newEmbeddingProviders.length > 0) {
|
||||
const firstProvider = newEmbeddingProviders[0];
|
||||
const firstModel =
|
||||
data.embeddingModelProviders[firstProvider]?.[0]?.name;
|
||||
|
||||
if (firstModel) {
|
||||
setSelectedEmbeddingModelProvider(firstProvider);
|
||||
setSelectedEmbeddingModel(firstModel);
|
||||
localStorage.setItem('embeddingModelProvider', firstProvider);
|
||||
localStorage.setItem('embeddingModel', firstModel);
|
||||
}
|
||||
} else if (
|
||||
currentEmbeddingProvider &&
|
||||
(!data.embeddingModelProviders ||
|
||||
!data.embeddingModelProviders[currentEmbeddingProvider] ||
|
||||
!Array.isArray(
|
||||
data.embeddingModelProviders[currentEmbeddingProvider],
|
||||
) ||
|
||||
data.embeddingModelProviders[currentEmbeddingProvider].length === 0)
|
||||
) {
|
||||
const firstValidProvider = Object.entries(
|
||||
data.embeddingModelProviders || {},
|
||||
).find(
|
||||
([_, models]) => Array.isArray(models) && models.length > 0,
|
||||
)?.[0];
|
||||
|
||||
if (firstValidProvider) {
|
||||
setSelectedEmbeddingModelProvider(firstValidProvider);
|
||||
setSelectedEmbeddingModel(
|
||||
data.embeddingModelProviders[firstValidProvider][0].name,
|
||||
);
|
||||
localStorage.setItem('embeddingModelProvider', firstValidProvider);
|
||||
localStorage.setItem(
|
||||
'embeddingModel',
|
||||
data.embeddingModelProviders[firstValidProvider][0].name,
|
||||
);
|
||||
} else {
|
||||
setSelectedEmbeddingModelProvider(null);
|
||||
setSelectedEmbeddingModel(null);
|
||||
localStorage.removeItem('embeddingModelProvider');
|
||||
localStorage.removeItem('embeddingModel');
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(data);
|
||||
}
|
||||
|
||||
if (key === 'automaticImageSearch') {
|
||||
localStorage.setItem('autoImageSearch', value.toString());
|
||||
} else if (key === 'automaticVideoSearch') {
|
||||
localStorage.setItem('autoVideoSearch', value.toString());
|
||||
} else if (key === 'chatModelProvider') {
|
||||
localStorage.setItem('chatModelProvider', value);
|
||||
} else if (key === 'chatModel') {
|
||||
localStorage.setItem('chatModel', value);
|
||||
} else if (key === 'embeddingModelProvider') {
|
||||
localStorage.setItem('embeddingModelProvider', value);
|
||||
} else if (key === 'embeddingModel') {
|
||||
localStorage.setItem('embeddingModel', value);
|
||||
} else if (key === 'systemInstructions') {
|
||||
localStorage.setItem('systemInstructions', value);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save:', err);
|
||||
setConfig((prev) => ({ ...prev! }));
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setSavingStates((prev) => ({ ...prev, [key]: false }));
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="/" className="lg:hidden">
|
||||
<ArrowLeft className="text-black/70 dark:text-white/70" />
|
||||
</Link>
|
||||
<div className="flex flex-row space-x-0.5 items-center">
|
||||
<SettingsIcon size={23} />
|
||||
<h1 className="text-3xl font-medium p-2">Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-[50vh]">
|
||||
<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>
|
||||
) : (
|
||||
config && (
|
||||
<div className="flex flex-col space-y-6 pb-28 lg:pb-8">
|
||||
<SettingsSection title="Appearance">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Theme
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Automatic Search">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
|
||||
<ImagesIcon
|
||||
size={18}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
|
||||
Automatic Image Search
|
||||
</p>
|
||||
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
|
||||
Automatically search for relevant images in chat
|
||||
responses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={automaticImageSearch}
|
||||
onChange={(checked) => {
|
||||
setAutomaticImageSearch(checked);
|
||||
saveConfig('automaticImageSearch', checked);
|
||||
}}
|
||||
className={cn(
|
||||
automaticImageSearch
|
||||
? 'bg-[#24A0ED]'
|
||||
: 'bg-light-200 dark:bg-dark-200',
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
automaticImageSearch
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1',
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
|
||||
<VideoIcon
|
||||
size={18}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
|
||||
Automatic Video Search
|
||||
</p>
|
||||
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
|
||||
Automatically search for relevant videos in chat
|
||||
responses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={automaticVideoSearch}
|
||||
onChange={(checked) => {
|
||||
setAutomaticVideoSearch(checked);
|
||||
saveConfig('automaticVideoSearch', checked);
|
||||
}}
|
||||
className={cn(
|
||||
automaticVideoSearch
|
||||
? 'bg-[#24A0ED]'
|
||||
: 'bg-light-200 dark:bg-dark-200',
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
automaticVideoSearch
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1',
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="System Instructions">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Textarea
|
||||
value={systemInstructions}
|
||||
isSaving={savingStates['systemInstructions']}
|
||||
onChange={(e) => {
|
||||
setSystemInstructions(e.target.value);
|
||||
}}
|
||||
onSave={(value) => saveConfig('systemInstructions', value)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Model Settings">
|
||||
{config.chatModelProviders && (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Chat Model Provider
|
||||
</p>
|
||||
<Select
|
||||
value={selectedChatModelProvider ?? undefined}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedChatModelProvider(value);
|
||||
saveConfig('chatModelProvider', value);
|
||||
const firstModel =
|
||||
config.chatModelProviders[value]?.[0]?.name;
|
||||
if (firstModel) {
|
||||
setSelectedChatModel(firstModel);
|
||||
saveConfig('chatModel', firstModel);
|
||||
}
|
||||
}}
|
||||
options={Object.keys(config.chatModelProviders).map(
|
||||
(provider) => ({
|
||||
value: provider,
|
||||
label:
|
||||
(PROVIDER_METADATA as any)[provider]?.displayName ||
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1),
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedChatModelProvider &&
|
||||
selectedChatModelProvider != 'custom_openai' && (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Chat Model
|
||||
</p>
|
||||
<Select
|
||||
value={selectedChatModel ?? undefined}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedChatModel(value);
|
||||
saveConfig('chatModel', value);
|
||||
}}
|
||||
options={(() => {
|
||||
const chatModelProvider =
|
||||
config.chatModelProviders[
|
||||
selectedChatModelProvider
|
||||
];
|
||||
return chatModelProvider
|
||||
? chatModelProvider.length > 0
|
||||
? chatModelProvider.map((model) => ({
|
||||
value: model.name,
|
||||
label: model.displayName,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
value: '',
|
||||
label: 'No models available',
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: '',
|
||||
label:
|
||||
'Invalid provider, please check backend logs',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedChatModelProvider &&
|
||||
selectedChatModelProvider === 'custom_openai' && (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Model Name
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Model name"
|
||||
value={config.customOpenaiModelName}
|
||||
isSaving={savingStates['customOpenaiModelName']}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
customOpenaiModelName: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) =>
|
||||
saveConfig('customOpenaiModelName', value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Custom OpenAI API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Custom OpenAI API Key"
|
||||
value={config.customOpenaiApiKey}
|
||||
isSaving={savingStates['customOpenaiApiKey']}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
customOpenaiApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) =>
|
||||
saveConfig('customOpenaiApiKey', value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Custom OpenAI Base URL
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Custom OpenAI Base URL"
|
||||
value={config.customOpenaiApiUrl}
|
||||
isSaving={savingStates['customOpenaiApiUrl']}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
customOpenaiApiUrl: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) =>
|
||||
saveConfig('customOpenaiApiUrl', value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.embeddingModelProviders && (
|
||||
<div className="flex flex-col space-y-4 mt-4 pt-4 border-t border-light-200 dark:border-dark-200">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Embedding Model Provider
|
||||
</p>
|
||||
<Select
|
||||
value={selectedEmbeddingModelProvider ?? undefined}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedEmbeddingModelProvider(value);
|
||||
saveConfig('embeddingModelProvider', value);
|
||||
const firstModel =
|
||||
config.embeddingModelProviders[value]?.[0]?.name;
|
||||
if (firstModel) {
|
||||
setSelectedEmbeddingModel(firstModel);
|
||||
saveConfig('embeddingModel', firstModel);
|
||||
}
|
||||
}}
|
||||
options={Object.keys(config.embeddingModelProviders).map(
|
||||
(provider) => ({
|
||||
value: provider,
|
||||
label:
|
||||
(PROVIDER_METADATA as any)[provider]?.displayName ||
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1),
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedEmbeddingModelProvider && (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Embedding Model
|
||||
</p>
|
||||
<Select
|
||||
value={selectedEmbeddingModel ?? undefined}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedEmbeddingModel(value);
|
||||
saveConfig('embeddingModel', value);
|
||||
}}
|
||||
options={(() => {
|
||||
const embeddingModelProvider =
|
||||
config.embeddingModelProviders[
|
||||
selectedEmbeddingModelProvider
|
||||
];
|
||||
return embeddingModelProvider
|
||||
? embeddingModelProvider.length > 0
|
||||
? embeddingModelProvider.map((model) => ({
|
||||
value: model.name,
|
||||
label: model.displayName,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
value: '',
|
||||
label: 'No models available',
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: '',
|
||||
label:
|
||||
'Invalid provider, please check backend logs',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="API Keys">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
OpenAI API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="OpenAI API Key"
|
||||
value={config.openaiApiKey}
|
||||
isSaving={savingStates['openaiApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
openaiApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('openaiApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Ollama API URL
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Ollama API URL"
|
||||
value={config.ollamaApiUrl}
|
||||
isSaving={savingStates['ollamaApiUrl']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
ollamaApiUrl: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('ollamaApiUrl', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
GROQ API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="GROQ API Key"
|
||||
value={config.groqApiKey}
|
||||
isSaving={savingStates['groqApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
groqApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('groqApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Anthropic API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Anthropic API key"
|
||||
value={config.anthropicApiKey}
|
||||
isSaving={savingStates['anthropicApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
anthropicApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('anthropicApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Gemini API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Gemini API key"
|
||||
value={config.geminiApiKey}
|
||||
isSaving={savingStates['geminiApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
geminiApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('geminiApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Deepseek API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Deepseek API Key"
|
||||
value={config.deepseekApiKey}
|
||||
isSaving={savingStates['deepseekApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
deepseekApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('deepseekApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
LM Studio API URL
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="LM Studio API URL"
|
||||
value={config.lmStudioApiUrl}
|
||||
isSaving={savingStates['lmStudioApiUrl']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
lmStudioApiUrl: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('lmStudioApiUrl', value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@@ -2,31 +2,13 @@
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import MessageInput from './MessageInput';
|
||||
import { File, Message } from './ChatWindow';
|
||||
import MessageBox from './MessageBox';
|
||||
import MessageBoxLoading from './MessageBoxLoading';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const Chat = () => {
|
||||
const { sections, chatTurns, loading, messageAppeared } = useChat();
|
||||
|
||||
const Chat = ({
|
||||
loading,
|
||||
messages,
|
||||
sendMessage,
|
||||
messageAppeared,
|
||||
rewrite,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
messages: Message[];
|
||||
sendMessage: (message: string) => void;
|
||||
loading: boolean;
|
||||
messageAppeared: boolean;
|
||||
rewrite: (messageId: string) => void;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [dividerWidth, setDividerWidth] = useState(0);
|
||||
const dividerRef = useRef<HTMLDivElement | null>(null);
|
||||
const messageEnd = useRef<HTMLDivElement | null>(null);
|
||||
@@ -45,41 +27,36 @@ const Chat = ({
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateDividerWidth);
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scroll = () => {
|
||||
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (messages.length === 1) {
|
||||
document.title = `${messages[0].content.substring(0, 30)} - Perplexica`;
|
||||
if (chatTurns.length === 1) {
|
||||
document.title = `${chatTurns[0].content.substring(0, 30)} - Perplexica`;
|
||||
}
|
||||
|
||||
if (messages[messages.length - 1]?.role == 'user') {
|
||||
if (chatTurns[chatTurns.length - 1]?.role === 'user') {
|
||||
scroll();
|
||||
}
|
||||
}, [messages]);
|
||||
}, [chatTurns]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-32 sm:mx-4 md:mx-8">
|
||||
{messages.map((msg, i) => {
|
||||
const isLast = i === messages.length - 1;
|
||||
{sections.map((section, i) => {
|
||||
const isLast = i === sections.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={msg.messageId}>
|
||||
<Fragment key={section.userMessage.messageId}>
|
||||
<MessageBox
|
||||
key={i}
|
||||
message={msg}
|
||||
messageIndex={i}
|
||||
history={messages}
|
||||
loading={loading}
|
||||
section={section}
|
||||
sectionIndex={i}
|
||||
dividerRef={isLast ? dividerRef : undefined}
|
||||
isLast={isLast}
|
||||
rewrite={rewrite}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
{!isLast && msg.role === 'assistant' && (
|
||||
{!isLast && (
|
||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
)}
|
||||
</Fragment>
|
||||
@@ -92,14 +69,7 @@ const Chat = ({
|
||||
className="bottom-24 lg:bottom-10 fixed z-40"
|
||||
style={{ width: dividerWidth }}
|
||||
>
|
||||
<MessageInput
|
||||
loading={loading}
|
||||
sendMessage={sendMessage}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
<MessageInput />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -1,27 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Document } from '@langchain/core/documents';
|
||||
import Navbar from './Navbar';
|
||||
import Chat from './Chat';
|
||||
import EmptyChat from './EmptyChat';
|
||||
import crypto from 'crypto';
|
||||
import { toast } from 'sonner';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { getSuggestions } from '@/lib/actions';
|
||||
import { Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import NextError from 'next/error';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import Loader from './ui/Loader';
|
||||
|
||||
export type Message = {
|
||||
messageId: string;
|
||||
export interface BaseMessage {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AssistantMessage extends BaseMessage {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
suggestions?: string[];
|
||||
sources?: Document[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserMessage extends BaseMessage {
|
||||
role: 'user';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface SourceMessage extends BaseMessage {
|
||||
role: 'source';
|
||||
sources: Document[];
|
||||
}
|
||||
|
||||
export interface SuggestionMessage extends BaseMessage {
|
||||
role: 'suggestion';
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export type Message =
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| SourceMessage
|
||||
| SuggestionMessage;
|
||||
export type ChatTurn = UserMessage | AssistantMessage;
|
||||
|
||||
export interface File {
|
||||
fileName: string;
|
||||
@@ -29,512 +50,8 @@ export interface File {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
interface ChatModelProvider {
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
interface EmbeddingModelProvider {
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
const checkConfig = async (
|
||||
setChatModelProvider: (provider: ChatModelProvider) => void,
|
||||
setEmbeddingModelProvider: (provider: EmbeddingModelProvider) => void,
|
||||
setIsConfigReady: (ready: boolean) => void,
|
||||
setHasError: (hasError: boolean) => void,
|
||||
) => {
|
||||
try {
|
||||
let chatModel = localStorage.getItem('chatModel');
|
||||
let chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
let embeddingModel = localStorage.getItem('embeddingModel');
|
||||
let embeddingModelProvider = localStorage.getItem('embeddingModelProvider');
|
||||
|
||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
||||
|
||||
if (!autoImageSearch) {
|
||||
localStorage.setItem('autoImageSearch', 'true');
|
||||
}
|
||||
|
||||
if (!autoVideoSearch) {
|
||||
localStorage.setItem('autoVideoSearch', 'false');
|
||||
}
|
||||
|
||||
const providers = await fetch(`/api/models`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(async (res) => {
|
||||
if (!res.ok)
|
||||
throw new Error(
|
||||
`Failed to fetch models: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
if (
|
||||
!chatModel ||
|
||||
!chatModelProvider ||
|
||||
!embeddingModel ||
|
||||
!embeddingModelProvider
|
||||
) {
|
||||
if (!chatModel || !chatModelProvider) {
|
||||
const chatModelProviders = providers.chatModelProviders;
|
||||
|
||||
chatModelProvider =
|
||||
chatModelProvider || Object.keys(chatModelProviders)[0];
|
||||
|
||||
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
|
||||
if (!chatModelProviders || Object.keys(chatModelProviders).length === 0)
|
||||
return toast.error('No chat models available');
|
||||
}
|
||||
|
||||
if (!embeddingModel || !embeddingModelProvider) {
|
||||
const embeddingModelProviders = providers.embeddingModelProviders;
|
||||
|
||||
if (
|
||||
!embeddingModelProviders ||
|
||||
Object.keys(embeddingModelProviders).length === 0
|
||||
)
|
||||
return toast.error('No embedding models available');
|
||||
|
||||
embeddingModelProvider = Object.keys(embeddingModelProviders)[0];
|
||||
embeddingModel = Object.keys(
|
||||
embeddingModelProviders[embeddingModelProvider],
|
||||
)[0];
|
||||
}
|
||||
|
||||
localStorage.setItem('chatModel', chatModel!);
|
||||
localStorage.setItem('chatModelProvider', chatModelProvider);
|
||||
localStorage.setItem('embeddingModel', embeddingModel!);
|
||||
localStorage.setItem('embeddingModelProvider', embeddingModelProvider);
|
||||
} else {
|
||||
const chatModelProviders = providers.chatModelProviders;
|
||||
const embeddingModelProviders = providers.embeddingModelProviders;
|
||||
|
||||
if (
|
||||
Object.keys(chatModelProviders).length > 0 &&
|
||||
!chatModelProviders[chatModelProvider]
|
||||
) {
|
||||
const chatModelProvidersKeys = Object.keys(chatModelProviders);
|
||||
chatModelProvider =
|
||||
chatModelProvidersKeys.find(
|
||||
(key) => Object.keys(chatModelProviders[key]).length > 0,
|
||||
) || chatModelProvidersKeys[0];
|
||||
|
||||
localStorage.setItem('chatModelProvider', chatModelProvider);
|
||||
}
|
||||
|
||||
if (
|
||||
chatModelProvider &&
|
||||
!chatModelProviders[chatModelProvider][chatModel]
|
||||
) {
|
||||
chatModel = Object.keys(
|
||||
chatModelProviders[
|
||||
Object.keys(chatModelProviders[chatModelProvider]).length > 0
|
||||
? chatModelProvider
|
||||
: Object.keys(chatModelProviders)[0]
|
||||
],
|
||||
)[0];
|
||||
localStorage.setItem('chatModel', chatModel);
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(embeddingModelProviders).length > 0 &&
|
||||
!embeddingModelProviders[embeddingModelProvider]
|
||||
) {
|
||||
embeddingModelProvider = Object.keys(embeddingModelProviders)[0];
|
||||
localStorage.setItem('embeddingModelProvider', embeddingModelProvider);
|
||||
}
|
||||
|
||||
if (
|
||||
embeddingModelProvider &&
|
||||
!embeddingModelProviders[embeddingModelProvider][embeddingModel]
|
||||
) {
|
||||
embeddingModel = Object.keys(
|
||||
embeddingModelProviders[embeddingModelProvider],
|
||||
)[0];
|
||||
localStorage.setItem('embeddingModel', embeddingModel);
|
||||
}
|
||||
}
|
||||
|
||||
setChatModelProvider({
|
||||
name: chatModel!,
|
||||
provider: chatModelProvider,
|
||||
});
|
||||
|
||||
setEmbeddingModelProvider({
|
||||
name: embeddingModel!,
|
||||
provider: embeddingModelProvider,
|
||||
});
|
||||
|
||||
setIsConfigReady(true);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while checking the configuration:', err);
|
||||
setIsConfigReady(false);
|
||||
setHasError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async (
|
||||
chatId: string,
|
||||
setMessages: (messages: Message[]) => void,
|
||||
setIsMessagesLoaded: (loaded: boolean) => void,
|
||||
setChatHistory: (history: [string, string][]) => void,
|
||||
setFocusMode: (mode: string) => void,
|
||||
setNotFound: (notFound: boolean) => void,
|
||||
setFiles: (files: File[]) => void,
|
||||
setFileIds: (fileIds: string[]) => void,
|
||||
) => {
|
||||
const res = await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 404) {
|
||||
setNotFound(true);
|
||||
setIsMessagesLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const messages = data.messages.map((msg: any) => {
|
||||
return {
|
||||
...msg,
|
||||
...JSON.parse(msg.metadata),
|
||||
};
|
||||
}) as Message[];
|
||||
|
||||
setMessages(messages);
|
||||
|
||||
const history = messages.map((msg) => {
|
||||
return [msg.role, msg.content];
|
||||
}) as [string, string][];
|
||||
|
||||
console.debug(new Date(), 'app:messages_loaded');
|
||||
|
||||
document.title = messages[0].content;
|
||||
|
||||
const files = data.chat.files.map((file: any) => {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileExtension: file.name.split('.').pop(),
|
||||
fileId: file.fileId,
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(files);
|
||||
setFileIds(files.map((file: File) => file.fileId));
|
||||
|
||||
setChatHistory(history);
|
||||
setFocusMode(data.chat.focusMode);
|
||||
setIsMessagesLoaded(true);
|
||||
};
|
||||
|
||||
const ChatWindow = ({ id }: { id?: string }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const initialMessage = searchParams.get('q');
|
||||
|
||||
const [chatId, setChatId] = useState<string | undefined>(id);
|
||||
const [newChatCreated, setNewChatCreated] = useState(false);
|
||||
|
||||
const [chatModelProvider, setChatModelProvider] = useState<ChatModelProvider>(
|
||||
{
|
||||
name: '',
|
||||
provider: '',
|
||||
},
|
||||
);
|
||||
|
||||
const [embeddingModelProvider, setEmbeddingModelProvider] =
|
||||
useState<EmbeddingModelProvider>({
|
||||
name: '',
|
||||
provider: '',
|
||||
});
|
||||
|
||||
const [isConfigReady, setIsConfigReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkConfig(
|
||||
setChatModelProvider,
|
||||
setEmbeddingModelProvider,
|
||||
setIsConfigReady,
|
||||
setHasError,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messageAppeared, setMessageAppeared] = useState(false);
|
||||
|
||||
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||
|
||||
const [focusMode, setFocusMode] = useState('webSearch');
|
||||
const [optimizationMode, setOptimizationMode] = useState('speed');
|
||||
|
||||
const [isMessagesLoaded, setIsMessagesLoaded] = useState(false);
|
||||
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
chatId &&
|
||||
!newChatCreated &&
|
||||
!isMessagesLoaded &&
|
||||
messages.length === 0
|
||||
) {
|
||||
loadMessages(
|
||||
chatId,
|
||||
setMessages,
|
||||
setIsMessagesLoaded,
|
||||
setChatHistory,
|
||||
setFocusMode,
|
||||
setNotFound,
|
||||
setFiles,
|
||||
setFileIds,
|
||||
);
|
||||
} else if (!chatId) {
|
||||
setNewChatCreated(true);
|
||||
setIsMessagesLoaded(true);
|
||||
setChatId(crypto.randomBytes(20).toString('hex'));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMessagesLoaded && isConfigReady) {
|
||||
setIsReady(true);
|
||||
console.debug(new Date(), 'app:ready');
|
||||
} else {
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [isMessagesLoaded, isConfigReady]);
|
||||
|
||||
const sendMessage = async (message: string, messageId?: string) => {
|
||||
if (loading) return;
|
||||
if (!isConfigReady) {
|
||||
toast.error('Cannot send message before the configuration is ready');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessageAppeared(false);
|
||||
|
||||
let sources: Document[] | undefined = undefined;
|
||||
let recievedMessage = '';
|
||||
let added = false;
|
||||
|
||||
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{
|
||||
content: message,
|
||||
messageId: messageId,
|
||||
chatId: chatId!,
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const messageHandler = async (data: any) => {
|
||||
if (data.type === 'error') {
|
||||
toast.error(data.data);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'sources') {
|
||||
sources = data.data;
|
||||
if (!added) {
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{
|
||||
content: '',
|
||||
messageId: data.messageId,
|
||||
chatId: chatId!,
|
||||
role: 'assistant',
|
||||
sources: sources,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
added = true;
|
||||
}
|
||||
setMessageAppeared(true);
|
||||
}
|
||||
|
||||
if (data.type === 'message') {
|
||||
if (!added) {
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{
|
||||
content: data.data,
|
||||
messageId: data.messageId,
|
||||
chatId: chatId!,
|
||||
role: 'assistant',
|
||||
sources: sources,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
added = true;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (message.messageId === data.messageId) {
|
||||
return { ...message, content: message.content + data.data };
|
||||
}
|
||||
|
||||
return message;
|
||||
}),
|
||||
);
|
||||
|
||||
recievedMessage += data.data;
|
||||
setMessageAppeared(true);
|
||||
}
|
||||
|
||||
if (data.type === 'messageEnd') {
|
||||
setChatHistory((prevHistory) => [
|
||||
...prevHistory,
|
||||
['human', message],
|
||||
['assistant', recievedMessage],
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
|
||||
|
||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
||||
|
||||
if (autoImageSearch === 'true') {
|
||||
document
|
||||
.getElementById(`search-images-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
}
|
||||
|
||||
if (autoVideoSearch === 'true') {
|
||||
document
|
||||
.getElementById(`search-videos-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
}
|
||||
|
||||
if (
|
||||
lastMsg.role === 'assistant' &&
|
||||
lastMsg.sources &&
|
||||
lastMsg.sources.length > 0 &&
|
||||
!lastMsg.suggestions
|
||||
) {
|
||||
const suggestions = await getSuggestions(messagesRef.current);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.messageId === lastMsg.messageId) {
|
||||
return { ...msg, suggestions: suggestions };
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: message,
|
||||
message: {
|
||||
messageId: messageId,
|
||||
chatId: chatId!,
|
||||
content: message,
|
||||
},
|
||||
chatId: chatId!,
|
||||
files: fileIds,
|
||||
focusMode: focusMode,
|
||||
optimizationMode: optimizationMode,
|
||||
history: chatHistory,
|
||||
chatModel: {
|
||||
name: chatModelProvider.name,
|
||||
provider: chatModelProvider.provider,
|
||||
},
|
||||
embeddingModel: {
|
||||
name: embeddingModelProvider.name,
|
||||
provider: embeddingModelProvider.provider,
|
||||
},
|
||||
systemInstructions: localStorage.getItem('systemInstructions'),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No response body');
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
let partialChunk = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
partialChunk += decoder.decode(value, { stream: true });
|
||||
|
||||
try {
|
||||
const messages = partialChunk.split('\n');
|
||||
for (const msg of messages) {
|
||||
if (!msg.trim()) continue;
|
||||
const json = JSON.parse(msg);
|
||||
messageHandler(json);
|
||||
}
|
||||
partialChunk = '';
|
||||
} catch (error) {
|
||||
console.warn('Incomplete JSON, waiting for next chunk...');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const rewrite = (messageId: string) => {
|
||||
const index = messages.findIndex((msg) => msg.messageId === messageId);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
const message = messages[index - 1];
|
||||
|
||||
setMessages((prev) => {
|
||||
return [...prev.slice(0, messages.length > 2 ? index - 1 : 0)];
|
||||
});
|
||||
setChatHistory((prev) => {
|
||||
return [...prev.slice(0, messages.length > 2 ? index - 1 : 0)];
|
||||
});
|
||||
|
||||
sendMessage(message.content, message.messageId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && initialMessage && isConfigReady) {
|
||||
sendMessage(initialMessage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConfigReady, isReady, initialMessage]);
|
||||
|
||||
const ChatWindow = () => {
|
||||
const { hasError, isReady, notFound, messages } = useChat();
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -559,52 +76,17 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
<div>
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<Navbar chatId={chatId!} messages={messages} />
|
||||
<Chat
|
||||
loading={loading}
|
||||
messages={messages}
|
||||
sendMessage={sendMessage}
|
||||
messageAppeared={messageAppeared}
|
||||
rewrite={rewrite}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
<Navbar />
|
||||
<Chat />
|
||||
</>
|
||||
) : (
|
||||
<EmptyChat
|
||||
sendMessage={sendMessage}
|
||||
focusMode={focusMode}
|
||||
setFocusMode={setFocusMode}
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
<EmptyChat />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
19
src/components/Citation.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
const Citation = ({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Citation;
|
70
src/components/Discover/MajorNewsCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Discover } from '@/app/discover/page';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MajorNewsCard = ({
|
||||
item,
|
||||
isLeft = true,
|
||||
}: {
|
||||
item: Discover;
|
||||
isLeft?: boolean;
|
||||
}) => (
|
||||
<Link
|
||||
href={`/?q=Summary: ${item.url}`}
|
||||
className="w-full group flex flex-row items-stretch gap-6 h-60 py-3"
|
||||
target="_blank"
|
||||
>
|
||||
{isLeft ? (
|
||||
<>
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
|
||||
<img
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||
src={
|
||||
new URL(item.thumbnail).origin +
|
||||
new URL(item.thumbnail).pathname +
|
||||
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
|
||||
}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center flex-1 py-4">
|
||||
<h2
|
||||
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-cyan-500 dark:group-hover:text-cyan-300 transition duration-200"
|
||||
style={{ fontFamily: 'PP Editorial, serif' }}
|
||||
>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col justify-center flex-1 py-4">
|
||||
<h2
|
||||
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-cyan-500 dark:group-hover:text-cyan-300 transition duration-200"
|
||||
style={{ fontFamily: 'PP Editorial, serif' }}
|
||||
>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
|
||||
<img
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||
src={
|
||||
new URL(item.thumbnail).origin +
|
||||
new URL(item.thumbnail).pathname +
|
||||
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
|
||||
}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default MajorNewsCard;
|
32
src/components/Discover/SmallNewsCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Discover } from '@/app/discover/page';
|
||||
import Link from 'next/link';
|
||||
|
||||
const SmallNewsCard = ({ item }: { item: Discover }) => (
|
||||
<Link
|
||||
href={`/?q=Summary: ${item.url}`}
|
||||
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 group flex flex-col"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
<img
|
||||
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
|
||||
src={
|
||||
new URL(item.thumbnail).origin +
|
||||
new URL(item.thumbnail).pathname +
|
||||
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
|
||||
}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-sm mb-2 leading-tight line-clamp-2 group-hover:text-cyan-500 dark:group-hover:text-cyan-300 transition duration-200">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-black/60 dark:text-white/60 text-xs leading-relaxed line-clamp-2">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default SmallNewsCard;
|
@@ -1,32 +1,11 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import EmptyChatMessageInput from './EmptyChatMessageInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { File } from './ChatWindow';
|
||||
import Link from 'next/link';
|
||||
import WeatherWidget from './WeatherWidget';
|
||||
import NewsArticleWidget from './NewsArticleWidget';
|
||||
|
||||
const EmptyChat = ({
|
||||
sendMessage,
|
||||
focusMode,
|
||||
setFocusMode,
|
||||
optimizationMode,
|
||||
setOptimizationMode,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
sendMessage: (message: string) => void;
|
||||
focusMode: string;
|
||||
setFocusMode: (mode: string) => void;
|
||||
optimizationMode: string;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const EmptyChat = () => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
||||
@@ -34,26 +13,18 @@ const EmptyChat = ({
|
||||
<Settings className="cursor-pointer lg:hidden" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-8">
|
||||
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
|
||||
Research begins here.
|
||||
</h2>
|
||||
<EmptyChatMessageInput
|
||||
sendMessage={sendMessage}
|
||||
focusMode={focusMode}
|
||||
setFocusMode={setFocusMode}
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-4">
|
||||
<div className="flex flex-col items-center justify-center w-full space-y-8">
|
||||
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
|
||||
Research begins here.
|
||||
</h2>
|
||||
<EmptyChatMessageInput />
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-4 mt-2 sm:flex-row sm:justify-center">
|
||||
<div className="flex-1 max-w-xs">
|
||||
<div className="flex-1 w-full">
|
||||
<WeatherWidget />
|
||||
</div>
|
||||
<div className="flex-1 max-w-xs">
|
||||
<div className="flex-1 w-full">
|
||||
<NewsArticleWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,34 +1,17 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import CopilotToggle from './MessageInputActions/Copilot';
|
||||
import Focus from './MessageInputActions/Focus';
|
||||
import Optimization from './MessageInputActions/Optimization';
|
||||
import Attach from './MessageInputActions/Attach';
|
||||
import { File } from './ChatWindow';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||
import ModelSelector from './MessageInputActions/ModelSelector';
|
||||
|
||||
const EmptyChatMessageInput = ({
|
||||
sendMessage,
|
||||
focusMode,
|
||||
setFocusMode,
|
||||
optimizationMode,
|
||||
setOptimizationMode,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
sendMessage: (message: string) => void;
|
||||
focusMode: string;
|
||||
setFocusMode: (mode: string) => void;
|
||||
optimizationMode: string;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [copilotEnabled, setCopilotEnabled] = useState(false);
|
||||
const EmptyChatMessageInput = () => {
|
||||
const { sendMessage } = useChat();
|
||||
|
||||
/* const [copilotEnabled, setCopilotEnabled] = useState(false); */
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -73,34 +56,26 @@ const EmptyChatMessageInput = ({
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-5 pt-5 pb-2 rounded-lg w-full border border-light-200 dark:border-dark-200">
|
||||
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-3 pt-5 pb-3 rounded-2xl w-full 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">
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
minRows={2}
|
||||
className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
|
||||
className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
|
||||
placeholder="Ask anything..."
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between mt-4">
|
||||
<div className="flex flex-row items-center space-x-2 lg:space-x-4">
|
||||
<Focus focusMode={focusMode} setFocusMode={setFocusMode} />
|
||||
<Attach
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-1 sm:space-x-4">
|
||||
<Optimization
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
/>
|
||||
<Optimization />
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<ModelSelector />
|
||||
<Focus />
|
||||
<Attach />
|
||||
</div>
|
||||
<button
|
||||
disabled={message.trim().length === 0}
|
||||
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
|
||||
className="bg-sky-500 text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
|
||||
>
|
||||
<ArrowRight className="bg-background" size={17} />
|
||||
</button>
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import { Check, ClipboardList } from 'lucide-react';
|
||||
import { Message } from '../ChatWindow';
|
||||
import { useState } from 'react';
|
||||
import { Section } from '@/lib/hooks/useChat';
|
||||
|
||||
const Copy = ({
|
||||
message,
|
||||
section,
|
||||
initialMessage,
|
||||
}: {
|
||||
message: Message;
|
||||
section: Section;
|
||||
initialMessage: string;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -14,7 +15,7 @@ const Copy = ({
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const contentToCopy = `${initialMessage}${message.sources && message.sources.length > 0 && `\n\nCitations:\n${message.sources?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`).join(`\n`)}`}`;
|
||||
const contentToCopy = `${initialMessage}${section?.sourceMessage?.sources && section.sourceMessage.sources.length > 0 && `\n\nCitations:\n${section.sourceMessage.sources?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`).join(`\n`)}`}`;
|
||||
navigator.clipboard.writeText(contentToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
|
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React, { MutableRefObject, useEffect, useState } from 'react';
|
||||
import { Message } from './ChatWindow';
|
||||
import React, { MutableRefObject } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BookCopy,
|
||||
@@ -20,90 +19,37 @@ import SearchImages from './SearchImages';
|
||||
import SearchVideos from './SearchVideos';
|
||||
import { useSpeech } from 'react-text-to-speech';
|
||||
import ThinkBox from './ThinkBox';
|
||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||
import Citation from './Citation';
|
||||
|
||||
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
|
||||
return <ThinkBox content={children as string} />;
|
||||
const ThinkTagProcessor = ({
|
||||
children,
|
||||
thinkingEnded,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
thinkingEnded: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<ThinkBox content={children as string} thinkingEnded={thinkingEnded} />
|
||||
);
|
||||
};
|
||||
|
||||
const MessageBox = ({
|
||||
message,
|
||||
messageIndex,
|
||||
history,
|
||||
loading,
|
||||
section,
|
||||
sectionIndex,
|
||||
dividerRef,
|
||||
isLast,
|
||||
rewrite,
|
||||
sendMessage,
|
||||
}: {
|
||||
message: Message;
|
||||
messageIndex: number;
|
||||
history: Message[];
|
||||
loading: boolean;
|
||||
section: Section;
|
||||
sectionIndex: number;
|
||||
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isLast: boolean;
|
||||
rewrite: (messageId: string) => void;
|
||||
sendMessage: (message: string) => void;
|
||||
}) => {
|
||||
const [parsedMessage, setParsedMessage] = useState(message.content);
|
||||
const [speechMessage, setSpeechMessage] = useState(message.content);
|
||||
const { loading, chatTurns, sendMessage, rewrite } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
const citationRegex = /\[([^\]]+)\]/g;
|
||||
const regex = /\[(\d+)\]/g;
|
||||
let processedMessage = message.content;
|
||||
|
||||
if (message.role === 'assistant' && message.content.includes('<think>')) {
|
||||
const openThinkTag = processedMessage.match(/<think>/g)?.length || 0;
|
||||
const closeThinkTag = processedMessage.match(/<\/think>/g)?.length || 0;
|
||||
|
||||
if (openThinkTag > closeThinkTag) {
|
||||
processedMessage += '</think> <a> </a>'; // The extra <a> </a> is to prevent the the think component from looking bad
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
message.role === 'assistant' &&
|
||||
message?.sources &&
|
||||
message.sources.length > 0
|
||||
) {
|
||||
setParsedMessage(
|
||||
processedMessage.replace(
|
||||
citationRegex,
|
||||
(_, capturedContent: string) => {
|
||||
const numbers = capturedContent
|
||||
.split(',')
|
||||
.map((numStr) => numStr.trim());
|
||||
|
||||
const linksHtml = numbers
|
||||
.map((numStr) => {
|
||||
const number = parseInt(numStr);
|
||||
|
||||
if (isNaN(number) || number <= 0) {
|
||||
return `[${numStr}]`;
|
||||
}
|
||||
|
||||
const source = message.sources?.[number - 1];
|
||||
const url = source?.metadata?.url;
|
||||
|
||||
if (url) {
|
||||
return `<a href="${url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${numStr}</a>`;
|
||||
} else {
|
||||
return `[${numStr}]`;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
|
||||
return linksHtml;
|
||||
},
|
||||
),
|
||||
);
|
||||
setSpeechMessage(message.content.replace(regex, ''));
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeechMessage(message.content.replace(regex, ''));
|
||||
setParsedMessage(processedMessage);
|
||||
}, [message.content, message.sources, message.role]);
|
||||
const parsedMessage = section.parsedAssistantMessage || '';
|
||||
const speechMessage = section.speechMessage || '';
|
||||
const thinkingEnded = section.thinkingEnded;
|
||||
|
||||
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
||||
|
||||
@@ -111,33 +57,31 @@ const MessageBox = ({
|
||||
overrides: {
|
||||
think: {
|
||||
component: ThinkTagProcessor,
|
||||
props: {
|
||||
thinkingEnded: thinkingEnded,
|
||||
},
|
||||
},
|
||||
citation: {
|
||||
component: Citation,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{message.role === 'user' && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
messageIndex === 0 ? 'pt-16' : 'pt-8',
|
||||
'break-words',
|
||||
)}
|
||||
>
|
||||
<h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12">
|
||||
{message.content}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<div className={'w-full pt-8 break-words'}>
|
||||
<h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12">
|
||||
{section.userMessage.content}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{message.role === 'assistant' && (
|
||||
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
|
||||
<div
|
||||
ref={dividerRef}
|
||||
className="flex flex-col space-y-6 w-full lg:w-9/12"
|
||||
>
|
||||
{message.sources && message.sources.length > 0 && (
|
||||
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
|
||||
<div
|
||||
ref={dividerRef}
|
||||
className="flex flex-col space-y-6 w-full lg:w-9/12"
|
||||
>
|
||||
{section.sourceMessage &&
|
||||
section.sourceMessage.sources.length > 0 && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<BookCopy className="text-black dark:text-white" size={20} />
|
||||
@@ -145,10 +89,12 @@ const MessageBox = ({
|
||||
Sources
|
||||
</h3>
|
||||
</div>
|
||||
<MessageSources sources={message.sources} />
|
||||
<MessageSources sources={section.sourceMessage.sources} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2">
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{section.sourceMessage && (
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Disc3
|
||||
className={cn(
|
||||
@@ -161,100 +107,115 @@ const MessageBox = ({
|
||||
Answer
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Markdown
|
||||
className={cn(
|
||||
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark:text-white',
|
||||
{section.assistantMessage && (
|
||||
<>
|
||||
<Markdown
|
||||
className={cn(
|
||||
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark:text-white',
|
||||
)}
|
||||
options={markdownOverrides}
|
||||
>
|
||||
{parsedMessage}
|
||||
</Markdown>
|
||||
|
||||
{loading && isLast ? null : (
|
||||
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white py-4 -mx-2">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Rewrite
|
||||
rewrite={rewrite}
|
||||
messageId={section.assistantMessage.messageId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Copy
|
||||
initialMessage={section.assistantMessage.content}
|
||||
section={section}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (speechStatus === 'started') {
|
||||
stop();
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}}
|
||||
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{speechStatus === 'started' ? (
|
||||
<StopCircle size={18} />
|
||||
) : (
|
||||
<Volume2 size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
options={markdownOverrides}
|
||||
>
|
||||
{parsedMessage}
|
||||
</Markdown>
|
||||
{loading && isLast ? null : (
|
||||
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white py-4 -mx-2">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{/* <button className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black text-black dark:hover:text-white">
|
||||
<Share size={18} />
|
||||
</button> */}
|
||||
<Rewrite rewrite={rewrite} messageId={message.messageId} />
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Copy initialMessage={message.content} message={message} />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (speechStatus === 'started') {
|
||||
stop();
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}}
|
||||
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{speechStatus === 'started' ? (
|
||||
<StopCircle size={18} />
|
||||
) : (
|
||||
<Volume2 size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLast &&
|
||||
message.suggestions &&
|
||||
message.suggestions.length > 0 &&
|
||||
message.role === 'assistant' &&
|
||||
!loading && (
|
||||
<>
|
||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="flex flex-col space-y-3 text-black dark:text-white">
|
||||
<div className="flex flex-row items-center space-x-2 mt-4">
|
||||
<Layers3 />
|
||||
<h3 className="text-xl font-medium">Related</h3>
|
||||
|
||||
{isLast &&
|
||||
section.suggestions &&
|
||||
section.suggestions.length > 0 &&
|
||||
section.assistantMessage &&
|
||||
!loading && (
|
||||
<div className="mt-8 pt-6 border-t border-light-200/50 dark:border-dark-200/50">
|
||||
<div className="flex flex-row items-center space-x-2 mb-4">
|
||||
<Layers3
|
||||
className="text-black dark:text-white"
|
||||
size={20}
|
||||
/>
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
Related
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-3">
|
||||
{message.suggestions.map((suggestion, i) => (
|
||||
<div
|
||||
className="flex flex-col space-y-3 text-sm"
|
||||
key={i}
|
||||
>
|
||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div
|
||||
onClick={() => {
|
||||
sendMessage(suggestion);
|
||||
}}
|
||||
className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center"
|
||||
>
|
||||
<p className="transition duration-200 hover:text-[#24A0ED]">
|
||||
{suggestion}
|
||||
</p>
|
||||
<Plus
|
||||
size={20}
|
||||
className="text-[#24A0ED] flex-shrink-0"
|
||||
/>
|
||||
<div className="space-y-0">
|
||||
{section.suggestions.map(
|
||||
(suggestion: string, i: number) => (
|
||||
<div key={i}>
|
||||
{i > 0 && (
|
||||
<div className="h-px bg-light-200/40 dark:bg-dark-200/40 mx-3" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => sendMessage(suggestion)}
|
||||
className="group w-full px-3 py-4 text-left transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-black/70 dark:text-white/70 group-hover:text-[#24A0ED] transition-colors duration-200 leading-relaxed">
|
||||
{suggestion}
|
||||
</p>
|
||||
<Plus
|
||||
size={16}
|
||||
className="text-black/40 dark:text-white/40 group-hover:text-[#24A0ED] transition-colors duration-200 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
|
||||
<SearchImages
|
||||
query={history[messageIndex - 1].content}
|
||||
chatHistory={history.slice(0, messageIndex - 1)}
|
||||
messageId={message.messageId}
|
||||
/>
|
||||
<SearchVideos
|
||||
chatHistory={history.slice(0, messageIndex - 1)}
|
||||
query={history[messageIndex - 1].content}
|
||||
messageId={message.messageId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.assistantMessage && (
|
||||
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
|
||||
<SearchImages
|
||||
query={section.userMessage.content}
|
||||
chatHistory={chatTurns.slice(0, sectionIndex * 2)}
|
||||
messageId={section.assistantMessage.messageId}
|
||||
/>
|
||||
<SearchVideos
|
||||
chatHistory={chatTurns.slice(0, sectionIndex * 2)}
|
||||
query={section.userMessage.content}
|
||||
messageId={section.assistantMessage.messageId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -6,22 +6,11 @@ import Attach from './MessageInputActions/Attach';
|
||||
import CopilotToggle from './MessageInputActions/Copilot';
|
||||
import { File } from './ChatWindow';
|
||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const MessageInput = () => {
|
||||
const { loading, sendMessage } = useChat();
|
||||
|
||||
const MessageInput = ({
|
||||
sendMessage,
|
||||
loading,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
sendMessage: (message: string) => void;
|
||||
loading: boolean;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [copilotEnabled, setCopilotEnabled] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [textareaRows, setTextareaRows] = useState(1);
|
||||
@@ -75,18 +64,11 @@ const MessageInput = ({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary p-4 flex items-center overflow-hidden border border-light-200 dark:border-dark-200',
|
||||
mode === 'multi' ? 'flex-col rounded-lg' : 'flex-row rounded-full',
|
||||
'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',
|
||||
mode === 'multi' ? 'flex-col rounded-2xl' : 'flex-row rounded-full',
|
||||
)}
|
||||
>
|
||||
{mode === 'single' && (
|
||||
<AttachSmall
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
)}
|
||||
{mode === 'single' && <AttachSmall />}
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
@@ -113,12 +95,7 @@ const MessageInput = ({
|
||||
)}
|
||||
{mode === 'multi' && (
|
||||
<div className="flex flex-row items-center justify-between w-full pt-2">
|
||||
<AttachSmall
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
<AttachSmall />
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<CopilotToggle
|
||||
copilotEnabled={copilotEnabled}
|
||||
|
@@ -5,23 +5,21 @@ import {
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
|
||||
import {
|
||||
CopyPlus,
|
||||
File,
|
||||
Link,
|
||||
LoaderCircle,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { File as FileType } from '../ChatWindow';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const Attach = () => {
|
||||
const { files, setFiles, setFileIds, fileIds } = useChat();
|
||||
|
||||
const Attach = ({
|
||||
fileIds,
|
||||
setFileIds,
|
||||
showText,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
showText?: boolean;
|
||||
files: FileType[];
|
||||
setFiles: (files: FileType[]) => void;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
@@ -34,12 +32,12 @@ const Attach = ({
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProvider',
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModel');
|
||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||
|
||||
data.append('embedding_model_provider', embeddingModelProvider!);
|
||||
data.append('embedding_model', embeddingModel!);
|
||||
data.append('embedding_model_provider_id', embeddingModelProvider!);
|
||||
data.append('embedding_model_key', embeddingModel!);
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
@@ -54,42 +52,16 @@ const Attach = ({
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-between space-x-1">
|
||||
<LoaderCircle size={18} className="text-sky-400 animate-spin" />
|
||||
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
|
||||
Uploading..
|
||||
</p>
|
||||
<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" />
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-between space-x-1 p-2 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',
|
||||
files.length > 0 ? '-ml-2 lg:-ml-3' : '',
|
||||
)}
|
||||
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"
|
||||
>
|
||||
{files.length > 1 && (
|
||||
<>
|
||||
<File size={19} className="text-sky-400" />
|
||||
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
|
||||
{files.length} files
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{files.length === 1 && (
|
||||
<>
|
||||
<File size={18} className="text-sky-400" />
|
||||
<p className="text-sky-400 text-xs font-medium">
|
||||
{files[0].fileName.length > 10
|
||||
? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) +
|
||||
'...' +
|
||||
files[0].fileExtension
|
||||
: files[0].fileName}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<File size={16} className="text-sky-400" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
@@ -110,7 +82,7 @@ const Attach = ({
|
||||
<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"
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -120,7 +92,7 @@ const Attach = ({
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<Plus size={16} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
@@ -128,7 +100,7 @@ const Attach = ({
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
@@ -142,8 +114,11 @@ const Attach = ({
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
@@ -164,8 +139,7 @@ const Attach = ({
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white',
|
||||
showText ? '' : 'p-2',
|
||||
'flex items-center justify-center 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',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -176,8 +150,7 @@ const Attach = ({
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<CopyPlus size={showText ? 18 : undefined} />
|
||||
{showText && <p className="text-xs font-medium pl-[1px]">Attach</p>}
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
@@ -5,21 +5,21 @@ import {
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
|
||||
import {
|
||||
CopyPlus,
|
||||
File,
|
||||
LoaderCircle,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { File as FileType } from '../ChatWindow';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const AttachSmall = () => {
|
||||
const { files, setFiles, setFileIds, fileIds } = useChat();
|
||||
|
||||
const AttachSmall = ({
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: FileType[];
|
||||
setFiles: (files: FileType[]) => void;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
@@ -32,12 +32,12 @@ const AttachSmall = ({
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProvider',
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModel');
|
||||
const embeddingModel = localStorage.getItem('embeddingModelKey');
|
||||
|
||||
data.append('embedding_model_provider', embeddingModelProvider!);
|
||||
data.append('embedding_model', embeddingModel!);
|
||||
data.append('embedding_model_provider_id', embeddingModelProvider!);
|
||||
data.append('embedding_model_key', embeddingModel!);
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
@@ -52,7 +52,7 @@ const AttachSmall = ({
|
||||
};
|
||||
|
||||
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" />
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
@@ -114,8 +114,11 @@ const AttachSmall = ({
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
@@ -145,7 +148,7 @@ const AttachSmall = ({
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<CopyPlus size={20} />
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
@@ -15,19 +15,20 @@ import {
|
||||
} 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={20} />,
|
||||
icon: <Globe size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'academicSearch',
|
||||
title: 'Academic',
|
||||
description: 'Search in published academic papers',
|
||||
icon: <SwatchBook size={20} />,
|
||||
icon: <SwatchBook size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'writingAssistant',
|
||||
@@ -39,47 +40,38 @@ const focusModes = [
|
||||
key: 'wolframAlphaSearch',
|
||||
title: 'Wolfram Alpha',
|
||||
description: 'Computational knowledge engine',
|
||||
icon: <BadgePercent size={20} />,
|
||||
icon: <BadgePercent size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'youtubeSearch',
|
||||
title: 'Youtube',
|
||||
description: 'Search and watch videos',
|
||||
icon: <SiYoutube className="h-5 w-auto mr-0.5" />,
|
||||
icon: <SiYoutube className="h-[16px] w-auto mr-0.5" />,
|
||||
},
|
||||
{
|
||||
key: 'redditSearch',
|
||||
title: 'Reddit',
|
||||
description: 'Search for discussions and opinions',
|
||||
icon: <SiReddit className="h-5 w-auto mr-0.5" />,
|
||||
icon: <SiReddit className="h-[16px] w-auto mr-0.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const Focus = ({
|
||||
focusMode,
|
||||
setFocusMode,
|
||||
}: {
|
||||
focusMode: string;
|
||||
setFocusMode: (mode: string) => void;
|
||||
}) => {
|
||||
const Focus = () => {
|
||||
const { focusMode, setFocusMode } = useChat();
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg mt-[6.5px]">
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className=" 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"
|
||||
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}
|
||||
<p className="text-xs font-medium hidden lg:block">
|
||||
{focusModes.find((mode) => mode.key === focusMode)?.title}
|
||||
</p>
|
||||
<ChevronDown size={20} className="-translate-x-1" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<ScanEye size={20} />
|
||||
<p className="text-xs font-medium hidden lg:block">Focus</p>
|
||||
<Globe size={16} />
|
||||
</div>
|
||||
)}
|
||||
</PopoverButton>
|
||||
@@ -92,14 +84,14 @@ const Focus = ({
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] left-0">
|
||||
<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',
|
||||
'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',
|
||||
|
198
src/components/MessageInputActions/ModelSelector.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { Cpu, Loader2, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { MinimalProvider } from '@/lib/models/types';
|
||||
|
||||
const ModelSelector = () => {
|
||||
const [providers, setProviders] = useState<MinimalProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState<{
|
||||
providerId: string;
|
||||
modelKey: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch('/api/providers');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch providers');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setProviders(data.providers || []);
|
||||
|
||||
const savedProviderId = localStorage.getItem('chatModelProviderId');
|
||||
const savedModelKey = localStorage.getItem('chatModelKey');
|
||||
|
||||
if (savedProviderId && savedModelKey) {
|
||||
setSelectedModel({
|
||||
providerId: savedProviderId,
|
||||
modelKey: savedModelKey,
|
||||
});
|
||||
} else if (data.providers && data.providers.length > 0) {
|
||||
const firstProvider = data.providers.find(
|
||||
(p: MinimalProvider) => p.chatModels.length > 0,
|
||||
);
|
||||
if (firstProvider && firstProvider.chatModels[0]) {
|
||||
setSelectedModel({
|
||||
providerId: firstProvider.id,
|
||||
modelKey: firstProvider.chatModels[0].key,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading providers:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
const handleModelSelect = (providerId: string, modelKey: string) => {
|
||||
setSelectedModel({ providerId, modelKey });
|
||||
localStorage.setItem('chatModelProviderId', providerId);
|
||||
localStorage.setItem('chatModelKey', modelKey);
|
||||
};
|
||||
|
||||
const filteredProviders = providers
|
||||
.map((provider) => ({
|
||||
...provider,
|
||||
chatModels: provider.chatModels.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
provider.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((provider) => provider.chatModels.length > 0);
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<Cpu size={16} className="text-sky-500" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] -right-4">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-light-200 dark:border-dark-200">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg text-sm text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-sky-500/20 border border-transparent focus:border-sky-500/30 transition duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2
|
||||
className="animate-spin text-black/40 dark:text-white/40"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
) : filteredProviders.length === 0 ? (
|
||||
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
|
||||
{searchQuery
|
||||
? 'No models found'
|
||||
: 'No chat models configured'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filteredProviders.map((provider, providerIndex) => (
|
||||
<div key={provider.id}>
|
||||
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
|
||||
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-2 py-2 space-y-0.5">
|
||||
{provider.chatModels.map((model) => (
|
||||
<PopoverButton
|
||||
key={model.key}
|
||||
onClick={() =>
|
||||
handleModelSelect(provider.id, model.key)
|
||||
}
|
||||
className={cn(
|
||||
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
|
||||
selectedModel?.providerId === provider.id &&
|
||||
selectedModel?.modelKey === 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',
|
||||
selectedModel?.providerId === provider.id &&
|
||||
selectedModel?.modelKey === 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',
|
||||
selectedModel?.providerId === provider.id &&
|
||||
selectedModel?.modelKey === 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>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{providerIndex < filteredProviders.length - 1 && (
|
||||
<div className="h-px bg-light-200 dark:bg-dark-200" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelector;
|
@@ -7,19 +7,20 @@ import {
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
const OptimizationModes = [
|
||||
{
|
||||
key: 'speed',
|
||||
title: 'Speed',
|
||||
description: 'Prioritize speed and get the quickest possible answer.',
|
||||
icon: <Zap size={20} className="text-[#FF9800]" />,
|
||||
icon: <Zap size={16} className="text-[#FF9800]" />,
|
||||
},
|
||||
{
|
||||
key: 'balanced',
|
||||
title: 'Balanced',
|
||||
description: 'Find the right balance between speed and accuracy',
|
||||
icon: <Sliders size={20} className="text-[#4CAF50]" />,
|
||||
icon: <Sliders size={16} className="text-[#4CAF50]" />,
|
||||
},
|
||||
{
|
||||
key: 'quality',
|
||||
@@ -34,69 +35,69 @@ const OptimizationModes = [
|
||||
},
|
||||
];
|
||||
|
||||
const Optimization = ({
|
||||
optimizationMode,
|
||||
setOptimizationMode,
|
||||
}: {
|
||||
optimizationMode: string;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
}) => {
|
||||
const Optimization = () => {
|
||||
const { optimizationMode, setOptimizationMode } = useChat();
|
||||
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="p-2 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"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.icon
|
||||
}
|
||||
<p className="text-xs font-medium">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.title
|
||||
}
|
||||
</p>
|
||||
<ChevronDown size={20} />
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] right-0">
|
||||
<div className="flex flex-col gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
{OptimizationModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setOptimizationMode(mode.key)}
|
||||
key={i}
|
||||
disabled={mode.key === 'quality'}
|
||||
{({ open }) => (
|
||||
<>
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="p-2 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 focus:outline-none"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.icon
|
||||
}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
mode.key === 'quality' && 'opacity-50 cursor-not-allowed',
|
||||
open ? 'rotate-180' : 'rotate-0',
|
||||
'transition duration:200',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
|
||||
{mode.icon}
|
||||
<p className="text-sm font-medium">{mode.title}</p>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
/>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] left-0">
|
||||
<div className="flex flex-col gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
{OptimizationModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setOptimizationMode(mode.key)}
|
||||
key={i}
|
||||
disabled={mode.key === 'quality'}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
mode.key === 'quality' && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
|
||||
{mode.icon}
|
||||
<p className="text-sm font-medium">{mode.title}</p>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import jsPDF from 'jspdf';
|
||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||
|
||||
const downloadFile = (filename: string, content: string, type: string) => {
|
||||
const blob = new Blob([content], { type });
|
||||
@@ -25,19 +26,37 @@ const downloadFile = (filename: string, content: string, type: string) => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const exportAsMarkdown = (messages: Message[], title: string) => {
|
||||
const date = new Date(messages[0]?.createdAt || Date.now()).toLocaleString();
|
||||
const exportAsMarkdown = (sections: Section[], title: string) => {
|
||||
const date = new Date(
|
||||
sections[0]?.userMessage?.createdAt || Date.now(),
|
||||
).toLocaleString();
|
||||
let md = `# 💬 Chat Export: ${title}\n\n`;
|
||||
md += `*Exported on: ${date}*\n\n---\n`;
|
||||
messages.forEach((msg, idx) => {
|
||||
md += `\n---\n`;
|
||||
md += `**${msg.role === 'user' ? '🧑 User' : '🤖 Assistant'}**
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (section.userMessage) {
|
||||
md += `\n---\n`;
|
||||
md += `**🧑 User**
|
||||
`;
|
||||
md += `*${new Date(msg.createdAt).toLocaleString()}*\n\n`;
|
||||
md += `> ${msg.content.replace(/\n/g, '\n> ')}\n`;
|
||||
if (msg.sources && msg.sources.length > 0) {
|
||||
md += `*${new Date(section.userMessage.createdAt).toLocaleString()}*\n\n`;
|
||||
md += `> ${section.userMessage.content.replace(/\n/g, '\n> ')}\n`;
|
||||
}
|
||||
|
||||
if (section.assistantMessage) {
|
||||
md += `\n---\n`;
|
||||
md += `**🤖 Assistant**
|
||||
`;
|
||||
md += `*${new Date(section.assistantMessage.createdAt).toLocaleString()}*\n\n`;
|
||||
md += `> ${section.assistantMessage.content.replace(/\n/g, '\n> ')}\n`;
|
||||
}
|
||||
|
||||
if (
|
||||
section.sourceMessage &&
|
||||
section.sourceMessage.sources &&
|
||||
section.sourceMessage.sources.length > 0
|
||||
) {
|
||||
md += `\n**Citations:**\n`;
|
||||
msg.sources.forEach((src: any, i: number) => {
|
||||
section.sourceMessage.sources.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
md += `- [${i + 1}] [${url}](${url})\n`;
|
||||
});
|
||||
@@ -47,9 +66,11 @@ const exportAsMarkdown = (messages: Message[], title: string) => {
|
||||
downloadFile(`${title || 'chat'}.md`, md, 'text/markdown');
|
||||
};
|
||||
|
||||
const exportAsPDF = (messages: Message[], title: string) => {
|
||||
const exportAsPDF = (sections: Section[], title: string) => {
|
||||
const doc = new jsPDF();
|
||||
const date = new Date(messages[0]?.createdAt || Date.now()).toLocaleString();
|
||||
const date = new Date(
|
||||
sections[0]?.userMessage?.createdAt || Date.now(),
|
||||
).toLocaleString();
|
||||
let y = 15;
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
doc.setFontSize(18);
|
||||
@@ -63,92 +84,140 @@ const exportAsPDF = (messages: Message[], title: string) => {
|
||||
doc.line(10, y, 200, y);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
messages.forEach((msg, idx) => {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${msg.role === 'user' ? 'User' : 'Assistant'}`, 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(120);
|
||||
doc.text(`${new Date(msg.createdAt).toLocaleString()}`, 40, y);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
doc.setFontSize(12);
|
||||
const lines = doc.splitTextToSize(msg.content, 180);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (y > pageHeight - 20) {
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (section.userMessage) {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(lines[i], 12, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('User', 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(120);
|
||||
doc.text(
|
||||
`${new Date(section.userMessage.createdAt).toLocaleString()}`,
|
||||
40,
|
||||
y,
|
||||
);
|
||||
y += 6;
|
||||
}
|
||||
if (msg.sources && msg.sources.length > 0) {
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(80);
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text('Citations:', 12, y);
|
||||
y += 5;
|
||||
msg.sources.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
if (y > pageHeight - 15) {
|
||||
doc.setTextColor(30);
|
||||
doc.setFontSize(12);
|
||||
const userLines = doc.splitTextToSize(section.userMessage.content, 180);
|
||||
for (let i = 0; i < userLines.length; i++) {
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(`- [${i + 1}] ${url}`, 15, y);
|
||||
y += 5;
|
||||
});
|
||||
doc.text(userLines[i], 12, y);
|
||||
y += 6;
|
||||
}
|
||||
y += 6;
|
||||
doc.setDrawColor(230);
|
||||
if (y > pageHeight - 10) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.line(10, y, 200, y);
|
||||
y += 4;
|
||||
}
|
||||
|
||||
if (section.assistantMessage) {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Assistant', 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(120);
|
||||
doc.text(
|
||||
`${new Date(section.assistantMessage.createdAt).toLocaleString()}`,
|
||||
40,
|
||||
y,
|
||||
);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
doc.setFontSize(12);
|
||||
const assistantLines = doc.splitTextToSize(
|
||||
section.assistantMessage.content,
|
||||
180,
|
||||
);
|
||||
for (let i = 0; i < assistantLines.length; i++) {
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(assistantLines[i], 12, y);
|
||||
y += 6;
|
||||
}
|
||||
|
||||
if (
|
||||
section.sourceMessage &&
|
||||
section.sourceMessage.sources &&
|
||||
section.sourceMessage.sources.length > 0
|
||||
) {
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(80);
|
||||
if (y > pageHeight - 20) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text('Citations:', 12, y);
|
||||
y += 5;
|
||||
section.sourceMessage.sources.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
if (y > pageHeight - 15) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text(`- [${i + 1}] ${url}`, 15, y);
|
||||
y += 5;
|
||||
});
|
||||
doc.setTextColor(30);
|
||||
}
|
||||
y += 6;
|
||||
doc.setDrawColor(230);
|
||||
if (y > pageHeight - 10) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.line(10, y, 200, y);
|
||||
y += 4;
|
||||
}
|
||||
y += 6;
|
||||
doc.setDrawColor(230);
|
||||
if (y > pageHeight - 10) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.line(10, y, 200, y);
|
||||
y += 4;
|
||||
});
|
||||
doc.save(`${title || 'chat'}.pdf`);
|
||||
};
|
||||
|
||||
const Navbar = ({
|
||||
chatId,
|
||||
messages,
|
||||
}: {
|
||||
messages: Message[];
|
||||
chatId: string;
|
||||
}) => {
|
||||
const Navbar = () => {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [timeAgo, setTimeAgo] = useState<string>('');
|
||||
|
||||
const { sections, chatId } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
if (sections.length > 0 && sections[0].userMessage) {
|
||||
const newTitle =
|
||||
messages[0].content.length > 20
|
||||
? `${messages[0].content.substring(0, 20).trim()}...`
|
||||
: messages[0].content;
|
||||
sections[0].userMessage.content.length > 20
|
||||
? `${sections[0].userMessage.content.substring(0, 20).trim()}...`
|
||||
: sections[0].userMessage.content;
|
||||
setTitle(newTitle);
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
new Date(),
|
||||
messages[0].createdAt,
|
||||
sections[0].userMessage.createdAt,
|
||||
);
|
||||
setTimeAgo(newTimeAgo);
|
||||
}
|
||||
}, [messages]);
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (messages.length > 0) {
|
||||
if (sections.length > 0 && sections[0].userMessage) {
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
new Date(),
|
||||
messages[0].createdAt,
|
||||
sections[0].userMessage.createdAt,
|
||||
);
|
||||
setTimeAgo(newTimeAgo);
|
||||
}
|
||||
@@ -159,54 +228,91 @@ const Navbar = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-[104px] lg:pr-6 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm text-black dark:text-white/70 border-b bg-light-primary dark:bg-dark-primary border-light-100 dark:border-dark-200">
|
||||
<a
|
||||
href="/"
|
||||
className="active:scale-95 transition duration-100 cursor-pointer lg:hidden"
|
||||
>
|
||||
<Edit size={17} />
|
||||
</a>
|
||||
<div className="hidden lg:flex flex-row items-center justify-center space-x-2">
|
||||
<Clock size={17} />
|
||||
<p className="text-xs">{timeAgo} ago</p>
|
||||
</div>
|
||||
<p className="hidden lg:flex">{title}</p>
|
||||
<div className="sticky -mx-4 lg:mx-0 top-0 z-40 bg-light-primary/95 dark:bg-dark-primary/95 backdrop-blur-sm border-b border-light-200/50 dark:border-dark-200/30">
|
||||
<div className="px-4 lg:px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center min-w-0">
|
||||
<a
|
||||
href="/"
|
||||
className="lg:hidden mr-3 p-2 -ml-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
|
||||
>
|
||||
<Edit size={18} className="text-black/70 dark:text-white/70" />
|
||||
</a>
|
||||
<div className="hidden lg:flex items-center gap-2 text-black/50 dark:text-white/50 min-w-0">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs whitespace-nowrap">{timeAgo} ago</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="active:scale-95 transition duration-100 cursor-pointer p-2 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary">
|
||||
<Share size={17} />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute right-0 mt-2 w-64 rounded-xl shadow-xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 z-50">
|
||||
<div className="flex flex-col py-3 px-3 gap-2">
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
|
||||
onClick={() => exportAsMarkdown(messages, title || '')}
|
||||
>
|
||||
<FileText size={17} className="text-[#24A0ED]" />
|
||||
Export as Markdown
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
|
||||
onClick={() => exportAsPDF(messages, title || '')}
|
||||
>
|
||||
<FileDown size={17} className="text-[#24A0ED]" />
|
||||
Export as PDF
|
||||
</button>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
<DeleteChat redirect chatId={chatId} chats={[]} setChats={() => {}} />
|
||||
<div className="flex-1 mx-4 min-w-0">
|
||||
<h1 className="text-center text-sm font-medium text-black/80 dark:text-white/90 truncate">
|
||||
{title || 'New Conversation'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="p-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200">
|
||||
<Share size={16} className="text-black/60 dark:text-white/60" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
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 right-0 mt-2 w-64 origin-top-right rounded-2xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 shadow-xl shadow-black/10 dark:shadow-black/30 z-50">
|
||||
<div className="p-3">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs font-medium text-black/40 dark:text-white/40 uppercase tracking-wide">
|
||||
Export Chat
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
|
||||
onClick={() => exportAsMarkdown(sections, title || '')}
|
||||
>
|
||||
<FileText size={16} className="text-[#24A0ED]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-black dark:text-white">
|
||||
Markdown
|
||||
</p>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
.md format
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
|
||||
onClick={() => exportAsPDF(sections, title || '')}
|
||||
>
|
||||
<FileDown size={16} className="text-[#24A0ED]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-black dark:text-white">
|
||||
PDF
|
||||
</p>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
Document format
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
<DeleteChat
|
||||
redirect
|
||||
chatId={chatId!}
|
||||
chats={[]}
|
||||
setChats={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -27,38 +27,38 @@ const NewsArticleWidget = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary rounded-xl border border-light-200 dark:border-dark-200 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3 overflow-hidden">
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-stretch w-full h-24 min-h-[96px] max-h-[96px] p-0 overflow-hidden">
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-pulse flex flex-row items-center w-full h-full">
|
||||
<div className="rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 bg-light-200 dark:bg-dark-200 mr-3" />
|
||||
<div className="flex flex-col justify-center flex-1 h-full w-0 gap-2">
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200" />
|
||||
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200" />
|
||||
</div>
|
||||
<div className="animate-pulse flex flex-row items-stretch w-full h-full">
|
||||
<div className="w-24 min-w-24 max-w-24 h-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="flex flex-col justify-center flex-1 px-3 py-2 gap-2">
|
||||
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200" />
|
||||
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200" />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full text-xs text-red-400">Could not load news.</div>
|
||||
) : article ? (
|
||||
<a
|
||||
href={`/?q=Summary: ${article.url}`}
|
||||
className="flex flex-row items-center w-full h-full group"
|
||||
className="flex flex-row items-stretch w-full h-full relative overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
className="object-cover rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 border border-light-200 dark:border-dark-200 bg-light-200 dark:bg-dark-200 group-hover:opacity-90 transition"
|
||||
src={
|
||||
new URL(article.thumbnail).origin +
|
||||
new URL(article.thumbnail).pathname +
|
||||
`?id=${new URL(article.thumbnail).searchParams.get('id')}`
|
||||
}
|
||||
alt={article.title}
|
||||
/>
|
||||
<div className="flex flex-col justify-center flex-1 h-full pl-3 w-0">
|
||||
<div className="font-bold text-xs text-black dark:text-white leading-tight truncate overflow-hidden whitespace-nowrap">
|
||||
<div className="relative w-24 min-w-24 max-w-24 h-full overflow-hidden">
|
||||
<img
|
||||
className="object-cover w-full h-full bg-light-200 dark:bg-dark-200 group-hover:scale-110 transition-transform duration-300"
|
||||
src={
|
||||
new URL(article.thumbnail).origin +
|
||||
new URL(article.thumbnail).pathname +
|
||||
`?id=${new URL(article.thumbnail).searchParams.get('id')}`
|
||||
}
|
||||
alt={article.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center flex-1 px-3 py-2">
|
||||
<div className="font-semibold text-xs text-black dark:text-white leading-tight line-clamp-2 mb-1">
|
||||
{article.title}
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs leading-snug truncate overflow-hidden whitespace-nowrap">
|
||||
<p className="text-black/60 dark:text-white/60 text-[10px] leading-relaxed line-clamp-2">
|
||||
{article.content}
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -33,11 +33,10 @@ const SearchImages = ({
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
|
||||
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
|
||||
const customOpenAIKey = localStorage.getItem('openAIApiKey');
|
||||
const chatModelProvider = localStorage.getItem(
|
||||
'chatModelProviderId',
|
||||
);
|
||||
const chatModel = localStorage.getItem('chatModelKey');
|
||||
|
||||
const res = await fetch(`/api/images`, {
|
||||
method: 'POST',
|
||||
@@ -48,12 +47,8 @@ const SearchImages = ({
|
||||
query: query,
|
||||
chatHistory: chatHistory,
|
||||
chatModel: {
|
||||
provider: chatModelProvider,
|
||||
model: chatModel,
|
||||
...(chatModelProvider === 'custom_openai' && {
|
||||
customOpenAIBaseURL: customOpenAIBaseURL,
|
||||
customOpenAIKey: customOpenAIKey,
|
||||
}),
|
||||
providerId: chatModelProvider,
|
||||
key: chatModel,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@@ -48,11 +48,10 @@ const Searchvideos = ({
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
|
||||
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
|
||||
const customOpenAIKey = localStorage.getItem('openAIApiKey');
|
||||
const chatModelProvider = localStorage.getItem(
|
||||
'chatModelProviderId',
|
||||
);
|
||||
const chatModel = localStorage.getItem('chatModelKey');
|
||||
|
||||
const res = await fetch(`/api/videos`, {
|
||||
method: 'POST',
|
||||
@@ -63,12 +62,8 @@ const Searchvideos = ({
|
||||
query: query,
|
||||
chatHistory: chatHistory,
|
||||
chatModel: {
|
||||
provider: chatModelProvider,
|
||||
model: chatModel,
|
||||
...(chatModelProvider === 'custom_openai' && {
|
||||
customOpenAIBaseURL: customOpenAIBaseURL,
|
||||
customOpenAIKey: customOpenAIKey,
|
||||
}),
|
||||
providerId: chatModelProvider,
|
||||
key: chatModel,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
29
src/components/Settings/Sections/General.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import SettingsField from '../SettingsField';
|
||||
|
||||
const General = ({
|
||||
fields,
|
||||
values,
|
||||
}: {
|
||||
fields: UIConfigField[];
|
||||
values: Record<string, any>;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
|
||||
{fields.map((field) => (
|
||||
<SettingsField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={
|
||||
(field.scope === 'client'
|
||||
? localStorage.getItem(field.key)
|
||||
: values[field.key]) ?? field.default
|
||||
}
|
||||
dataAdd="general"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default General;
|
163
src/components/Settings/Sections/Models/AddModelDialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Dialog, DialogPanel } from '@headlessui/react';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ConfigModelProvider } from '@/lib/config/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const AddModel = ({
|
||||
providerId,
|
||||
setProviders,
|
||||
type,
|
||||
}: {
|
||||
providerId: string;
|
||||
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
|
||||
type: 'chat' | 'embedding';
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [modelKey, setModelKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${providerId}/models`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: modelName,
|
||||
key: modelKey,
|
||||
type: type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to add model');
|
||||
}
|
||||
|
||||
setProviders((prev) =>
|
||||
prev.map((provider) => {
|
||||
if (provider.id === providerId) {
|
||||
const newModel = { name: modelName, key: modelKey };
|
||||
return {
|
||||
...provider,
|
||||
chatModels:
|
||||
type === 'chat'
|
||||
? [...provider.chatModels, newModel]
|
||||
: provider.chatModels,
|
||||
embeddingModels:
|
||||
type === 'embedding'
|
||||
? [...provider.embeddingModels, newModel]
|
||||
: provider.embeddingModels,
|
||||
};
|
||||
}
|
||||
return provider;
|
||||
}),
|
||||
);
|
||||
|
||||
toast.success('Model added successfully.');
|
||||
setModelName('');
|
||||
setModelKey('');
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error adding model:', error);
|
||||
toast.error('Failed to add model.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-xs text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog
|
||||
static
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
className="relative z-[60]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
|
||||
>
|
||||
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h3 className="text-black/90 dark:text-white/90 font-medium">
|
||||
Add new {type === 'chat' ? 'chat' : 'embedding'} model
|
||||
</h3>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="flex flex-col space-y-4 flex-1">
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
Model name*
|
||||
</label>
|
||||
<input
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="e.g., GPT-4"
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
Model key*
|
||||
</label>
|
||||
<input
|
||||
value={modelKey}
|
||||
onChange={(e) => setModelKey(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="e.g., gpt-4"
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200 -mx-6 my-4" />
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-sky-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
'Add Model'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</motion.div>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModel;
|
216
src/components/Settings/Sections/Models/AddProviderDialog.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
Description,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/react';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ConfigModelProvider,
|
||||
ModelProviderUISection,
|
||||
StringUIConfigField,
|
||||
UIConfigField,
|
||||
} from '@/lib/config/types';
|
||||
import Select from '@/components/ui/Select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const AddProvider = ({
|
||||
modelProviders,
|
||||
setProviders,
|
||||
}: {
|
||||
modelProviders: ModelProviderUISection[];
|
||||
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<null | string>(
|
||||
modelProviders[0]?.key || null,
|
||||
);
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const providerConfigMap = useMemo(() => {
|
||||
const map: Record<string, { name: string; fields: UIConfigField[] }> = {};
|
||||
|
||||
modelProviders.forEach((p) => {
|
||||
map[p.key] = {
|
||||
name: p.name,
|
||||
fields: p.fields,
|
||||
};
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [modelProviders]);
|
||||
|
||||
const selectedProviderFields = useMemo(() => {
|
||||
if (!selectedProvider) return [];
|
||||
const providerFields = providerConfigMap[selectedProvider]?.fields || [];
|
||||
const config: Record<string, any> = {};
|
||||
|
||||
providerFields.forEach((field) => {
|
||||
config[field.key] = field.default || '';
|
||||
});
|
||||
|
||||
setConfig(config);
|
||||
|
||||
return providerFields;
|
||||
}, [selectedProvider, providerConfigMap]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/providers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: selectedProvider,
|
||||
name: name,
|
||||
config: config,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to add provider');
|
||||
}
|
||||
|
||||
const data: ConfigModelProvider = (await res.json()).provider;
|
||||
|
||||
setProviders((prev) => [...prev, data]);
|
||||
|
||||
toast.success('Provider added successfully.');
|
||||
} catch (error) {
|
||||
console.error('Error adding provider:', error);
|
||||
toast.error('Failed to add provider.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Add Provider</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog
|
||||
static
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
className="relative z-[60]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
|
||||
>
|
||||
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h3 className="text-black/90 dark:text-white/90 font-medium">
|
||||
Add new provider
|
||||
</h3>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
Select provider type
|
||||
</label>
|
||||
<Select
|
||||
value={selectedProvider ?? ''}
|
||||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||
options={Object.entries(providerConfigMap).map(
|
||||
([key, val]) => {
|
||||
return {
|
||||
label: val.name,
|
||||
value: key,
|
||||
};
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key="name"
|
||||
className="flex flex-col items-start space-y-2"
|
||||
>
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
Name*
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={'Provider Name'}
|
||||
type="text"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProviderFields.map((field: UIConfigField) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex flex-col items-start space-y-2"
|
||||
>
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
{field.name}
|
||||
{field.required && '*'}
|
||||
</label>
|
||||
<input
|
||||
value={config[field.key] ?? field.default ?? ''}
|
||||
onChange={(event) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[field.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={
|
||||
(field as StringUIConfigField).placeholder
|
||||
}
|
||||
type="text"
|
||||
required={field.required}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="px-6 py-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-sky-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
'Add Provider'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogPanel>
|
||||
</motion.div>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddProvider;
|
118
src/components/Settings/Sections/Models/DeleteProviderDialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Dialog, DialogPanel } from '@headlessui/react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ConfigModelProvider } from '@/lib/config/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const DeleteProvider = ({
|
||||
modelProvider,
|
||||
setProviders,
|
||||
}: {
|
||||
modelProvider: ConfigModelProvider;
|
||||
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${modelProvider.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to delete provider');
|
||||
}
|
||||
|
||||
setProviders((prev) => {
|
||||
return prev.filter((p) => p.id !== modelProvider.id);
|
||||
});
|
||||
|
||||
toast.success('Provider deleted successfully.');
|
||||
} catch (error) {
|
||||
console.error('Error deleting provider:', error);
|
||||
toast.error('Failed to delete provider.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
|
||||
title="Delete provider"
|
||||
>
|
||||
<Trash2
|
||||
size={14}
|
||||
className="text-black/60 dark:text-white/60 group-hover:text-red-500 group-hover:dark:text-red-400"
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog
|
||||
static
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
className="relative z-[60]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
|
||||
>
|
||||
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h3 className="text-black/90 dark:text-white/90 font-medium">
|
||||
Delete provider
|
||||
</h3>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<p className="text-SM text-black/60 dark:text-white/60">
|
||||
Are you sure you want to delete the provider "
|
||||
{modelProvider.name}"? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-6 flex justify-end space-x-2">
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-red-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</motion.div>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteProvider;
|
217
src/components/Settings/Sections/Models/ModelProvider.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { UIConfigField, ConfigModelProvider } from '@/lib/config/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertCircle, ChevronDown, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import AddModel from './AddModelDialog';
|
||||
import UpdateProvider from './UpdateProviderDialog';
|
||||
import DeleteProvider from './DeleteProviderDialog';
|
||||
|
||||
const ModelProvider = ({
|
||||
modelProvider,
|
||||
setProviders,
|
||||
fields,
|
||||
}: {
|
||||
modelProvider: ConfigModelProvider;
|
||||
fields: UIConfigField[];
|
||||
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleModelDelete = async (
|
||||
type: 'chat' | 'embedding',
|
||||
modelKey: string,
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${modelProvider.id}/models`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key: modelKey, type: type }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to delete model: ' + (await res.text()));
|
||||
}
|
||||
|
||||
setProviders(
|
||||
(prev) =>
|
||||
prev.map((provider) => {
|
||||
if (provider.id === modelProvider.id) {
|
||||
return {
|
||||
...provider,
|
||||
...(type === 'chat'
|
||||
? {
|
||||
chatModels: provider.chatModels.filter(
|
||||
(m) => m.key !== modelKey,
|
||||
),
|
||||
}
|
||||
: {
|
||||
embeddingModels: provider.embeddingModels.filter(
|
||||
(m) => m.key !== modelKey,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return provider;
|
||||
}) as ConfigModelProvider[],
|
||||
);
|
||||
|
||||
toast.success('Model deleted successfully.');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete model', err);
|
||||
toast.error('Failed to delete model.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelProvider.id}
|
||||
className="border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'group px-5 py-4 flex flex-row justify-between w-full cursor-pointer hover:bg-light-secondary hover:dark:bg-dark-secondary transition duration-200 items-center',
|
||||
!open && 'rounded-lg',
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<p className="text-black dark:text-white font-medium">
|
||||
{modelProvider.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-row items-center">
|
||||
<UpdateProvider
|
||||
fields={fields}
|
||||
modelProvider={modelProvider}
|
||||
setProviders={setProviders}
|
||||
/>
|
||||
<DeleteProvider
|
||||
modelProvider={modelProvider}
|
||||
setProviders={setProviders}
|
||||
/>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
open ? 'rotate-180' : '',
|
||||
'transition duration-200 text-black/70 dark:text-white/70 group-hover:text-sky-500',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="flex flex-col gap-y-4 px-5 py-4">
|
||||
{modelProvider.chatModels.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-row w-full justify-between items-center">
|
||||
<p className="text-xs text-black/70 dark:text-white/70">
|
||||
Chat models
|
||||
</p>
|
||||
<AddModel
|
||||
providerId={modelProvider.id}
|
||||
setProviders={setProviders}
|
||||
type="chat"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{modelProvider.chatModels.some((m) => m.key === 'error') ? (
|
||||
<div className="flex flex-row items-center gap-2 text-sm text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
|
||||
<AlertCircle size={16} className="shrink-0" />
|
||||
<span className="break-words">
|
||||
{
|
||||
modelProvider.chatModels.find(
|
||||
(m) => m.key === 'error',
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{modelProvider.chatModels.map((model, index) => (
|
||||
<div
|
||||
key={`${modelProvider.id}-chat-${model.key}-${index}`}
|
||||
className="flex flex-row items-center space-x-1 text-sm text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5"
|
||||
>
|
||||
<span>{model.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleModelDelete('chat', model.key);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelProvider.embeddingModels.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-row w-full justify-between items-center">
|
||||
<p className="text-xs text-black/70 dark:text-white/70">
|
||||
Embedding models
|
||||
</p>
|
||||
<AddModel
|
||||
providerId={modelProvider.id}
|
||||
setProviders={setProviders}
|
||||
type="embedding"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{modelProvider.embeddingModels.some(
|
||||
(m) => m.key === 'error',
|
||||
) ? (
|
||||
<div className="flex flex-row items-center gap-2 text-sm text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
|
||||
<AlertCircle size={16} className="shrink-0" />
|
||||
<span className="break-words">
|
||||
{
|
||||
modelProvider.embeddingModels.find(
|
||||
(m) => m.key === 'error',
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{modelProvider.embeddingModels.map((model, index) => (
|
||||
<div
|
||||
key={`${modelProvider.id}-embedding-${model.key}-${index}`}
|
||||
className="flex flex-row items-center space-x-1 text-sm text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5"
|
||||
>
|
||||
<span>{model.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleModelDelete('embedding', model.key);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelProvider;
|
44
src/components/Settings/Sections/Models/Section.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import AddProvider from './AddProviderDialog';
|
||||
import {
|
||||
ConfigModelProvider,
|
||||
ModelProviderUISection,
|
||||
UIConfigField,
|
||||
} from '@/lib/config/types';
|
||||
import ModelProvider from './ModelProvider';
|
||||
|
||||
const Models = ({
|
||||
fields,
|
||||
values,
|
||||
}: {
|
||||
fields: ModelProviderUISection[];
|
||||
values: ConfigModelProvider[];
|
||||
}) => {
|
||||
const [providers, setProviders] = useState<ConfigModelProvider[]>(values);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<p className="text-sm text-black/70 dark:text-white/70">
|
||||
Manage model provider
|
||||
</p>
|
||||
<AddProvider modelProviders={fields} setProviders={setProviders} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{providers.map((provider) => (
|
||||
<ModelProvider
|
||||
key={`provider-${provider.id}`}
|
||||
fields={
|
||||
(fields.find((f) => f.key === provider.type)?.fields ??
|
||||
[]) as UIConfigField[]
|
||||
}
|
||||
modelProvider={provider}
|
||||
setProviders={setProviders}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Models;
|
188
src/components/Settings/Sections/Models/UpdateProviderDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Dialog, DialogPanel } from '@headlessui/react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ConfigModelProvider,
|
||||
StringUIConfigField,
|
||||
UIConfigField,
|
||||
} from '@/lib/config/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const UpdateProvider = ({
|
||||
modelProvider,
|
||||
fields,
|
||||
setProviders,
|
||||
}: {
|
||||
fields: UIConfigField[];
|
||||
modelProvider: ConfigModelProvider;
|
||||
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [name, setName] = useState(modelProvider.name);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const config: Record<string, any> = {
|
||||
name: modelProvider.name,
|
||||
};
|
||||
|
||||
fields.forEach((field) => {
|
||||
config[field.key] =
|
||||
modelProvider.config[field.key] || field.default || '';
|
||||
});
|
||||
|
||||
setConfig(config);
|
||||
}, [fields]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${modelProvider.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
config: config,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to update provider');
|
||||
}
|
||||
|
||||
const data: ConfigModelProvider = (await res.json()).provider;
|
||||
|
||||
setProviders((prev) => {
|
||||
return prev.map((p) => {
|
||||
if (p.id === modelProvider.id) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return p;
|
||||
});
|
||||
});
|
||||
|
||||
toast.success('Provider updated successfully.');
|
||||
} catch (error) {
|
||||
console.error('Error updating provider:', error);
|
||||
toast.error('Failed to update provider.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
|
||||
>
|
||||
<Pencil
|
||||
size={14}
|
||||
className="text-black/60 dark:text-white/60 group-hover:text-black group-hover:dark:text-white"
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog
|
||||
static
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
className="relative z-[60]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
|
||||
>
|
||||
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h3 className="text-black/90 dark:text-white/90 font-medium">
|
||||
Update provider
|
||||
</h3>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div
|
||||
key="name"
|
||||
className="flex flex-col items-start space-y-2"
|
||||
>
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
Name*
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={'Provider Name'}
|
||||
type="text"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.map((field: UIConfigField) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex flex-col items-start space-y-2"
|
||||
>
|
||||
<label className="text-xs text-black/70 dark:text-white/70">
|
||||
{field.name}
|
||||
{field.required && '*'}
|
||||
</label>
|
||||
<input
|
||||
value={config[field.key] ?? field.default ?? ''}
|
||||
onChange={(event) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[field.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={
|
||||
(field as StringUIConfigField).placeholder
|
||||
}
|
||||
type="text"
|
||||
required={field.required}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-light-200 dark:border-dark-200" />
|
||||
<div className="px-6 py-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-sky-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
'Update Provider'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogPanel>
|
||||
</motion.div>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateProvider;
|
29
src/components/Settings/Sections/Search.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import SettingsField from '../SettingsField';
|
||||
|
||||
const Search = ({
|
||||
fields,
|
||||
values,
|
||||
}: {
|
||||
fields: UIConfigField[];
|
||||
values: Record<string, any>;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
|
||||
{fields.map((field) => (
|
||||
<SettingsField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={
|
||||
(field.scope === 'client'
|
||||
? localStorage.getItem(field.key)
|
||||
: values[field.key]) ?? field.default
|
||||
}
|
||||
dataAdd="search"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
24
src/components/Settings/SettingsButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import SettingsDialogue from './SettingsDialogue';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
const SettingsButton = () => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer active:scale-95"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Settings size={19} className="cursor-pointer" />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isOpen && <SettingsDialogue isOpen={isOpen} setIsOpen={setIsOpen} />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsButton;
|
152
src/components/Settings/SettingsDialogue.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Dialog, DialogPanel } from '@headlessui/react';
|
||||
import { BrainCog, ChevronLeft, Search, Settings } from 'lucide-react';
|
||||
import General from './Sections/General';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import Loader from '../ui/Loader';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Models from './Sections/Models/Section';
|
||||
import SearchSection from './Sections/Search';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
name: 'General',
|
||||
description: 'Adjust common settings.',
|
||||
icon: Settings,
|
||||
component: General,
|
||||
dataAdd: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Models',
|
||||
description: 'Configure model settings.',
|
||||
icon: BrainCog,
|
||||
component: Models,
|
||||
dataAdd: 'modelProviders',
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
description: 'Manage search settings.',
|
||||
icon: Search,
|
||||
component: SearchSection,
|
||||
dataAdd: 'search',
|
||||
},
|
||||
];
|
||||
|
||||
const SettingsDialogue = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (active: boolean) => void;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [activeSection, setActiveSection] = useState(sections[0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
setConfig(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching config:', error);
|
||||
toast.error('Failed to load configuration.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
className="relative z-50"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm h-screen"
|
||||
>
|
||||
<DialogPanel className="space-y-4 border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary backdrop-blur-lg rounded-xl h-[calc(100vh-2%)] w-[calc(100vw-2%)] md:h-[calc(100vh-7%)] md:w-[calc(100vw-7%)] lg:h-[calc(100vh-20%)] lg:w-[calc(100vw-30%)]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Loader />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 inset-0 h-full">
|
||||
<div className="w-[240px] border-r border-white-200 dark:border-dark-200 h-full px-3 pt-3 flex flex-col">
|
||||
<button
|
||||
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}
|
||||
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>
|
||||
</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.name === section.name
|
||||
? 'bg-light-200 dark:bg-dark-200 text-black/90 dark:text-white/90'
|
||||
: ' text-black/70 dark:text-white/70',
|
||||
)}
|
||||
onClick={() => setActiveSection(section)}
|
||||
>
|
||||
<section.icon size={17} />
|
||||
<p>{section.name}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{activeSection.component && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-light-200/60 px-6 pb-6 pt-8 dark:border-dark-200/60">
|
||||
<div className="flex flex-col">
|
||||
<h4 className="font-medium text-black dark:text-white">
|
||||
{activeSection.name}
|
||||
</h4>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
{activeSection.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<activeSection.component
|
||||
fields={config.fields[activeSection.dataAdd]}
|
||||
values={config.values[activeSection.dataAdd]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogPanel>
|
||||
</motion.div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDialogue;
|
194
src/components/Settings/SettingsField.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
SelectUIConfigField,
|
||||
StringUIConfigField,
|
||||
UIConfigField,
|
||||
} from '@/lib/config/types';
|
||||
import { useState } from 'react';
|
||||
import Select from '../ui/Select';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const SettingsSelect = ({
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
dataAdd,
|
||||
}: {
|
||||
field: SelectUIConfigField;
|
||||
value?: any;
|
||||
setValue: (value: any) => void;
|
||||
dataAdd: string;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const handleSave = async (newValue: any) => {
|
||||
setLoading(true);
|
||||
setValue(newValue);
|
||||
try {
|
||||
if (field.scope === 'client') {
|
||||
localStorage.setItem(field.key, newValue);
|
||||
if (field.key === 'theme') {
|
||||
setTheme(newValue);
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key: `${dataAdd}.${field.key}`,
|
||||
value: newValue,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to save config:', await res.text());
|
||||
throw new Error('Failed to save configuration');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving config:', error);
|
||||
toast.error('Failed to save configuration.');
|
||||
} finally {
|
||||
setTimeout(() => setLoading(false), 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h4 className="text-base text-black dark:text-white">{field.name}</h4>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(event) => handleSave(event.target.value)}
|
||||
options={field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.name,
|
||||
}))}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer capitalize pr-12"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsInput = ({
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
dataAdd,
|
||||
}: {
|
||||
field: StringUIConfigField;
|
||||
value?: any;
|
||||
setValue: (value: any) => void;
|
||||
dataAdd: string;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSave = async (newValue: any) => {
|
||||
setLoading(true);
|
||||
setValue(newValue);
|
||||
try {
|
||||
if (field.scope === 'client') {
|
||||
localStorage.setItem(field.key, newValue);
|
||||
} else {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key: `${dataAdd}.${field.key}`,
|
||||
value: newValue,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to save config:', await res.text());
|
||||
throw new Error('Failed to save configuration');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving config:', error);
|
||||
toast.error('Failed to save configuration.');
|
||||
} finally {
|
||||
setTimeout(() => setLoading(false), 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h4 className="text-base text-black dark:text-white">{field.name}</h4>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={(event) => handleSave(event.target.value)}
|
||||
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={field.placeholder}
|
||||
type="text"
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsField = ({
|
||||
field,
|
||||
value,
|
||||
dataAdd,
|
||||
}: {
|
||||
field: UIConfigField;
|
||||
value: any;
|
||||
dataAdd: string;
|
||||
}) => {
|
||||
const [val, setVal] = useState(value);
|
||||
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<SettingsSelect
|
||||
field={field}
|
||||
value={val}
|
||||
setValue={setVal}
|
||||
dataAdd={dataAdd}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
return (
|
||||
<SettingsInput
|
||||
field={field}
|
||||
value={val}
|
||||
setValue={setVal}
|
||||
dataAdd={dataAdd}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Unsupported field type: {field.type}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default SettingsField;
|
@@ -1,20 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BookOpenText, Home, Search, SquarePen, Settings } from 'lucide-react';
|
||||
import {
|
||||
BookOpenText,
|
||||
Home,
|
||||
Search,
|
||||
SquarePen,
|
||||
Settings,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSelectedLayoutSegments } from 'next/navigation';
|
||||
import React, { useState, type ReactNode } from 'react';
|
||||
import Layout from './Layout';
|
||||
import {
|
||||
Description,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/react';
|
||||
import SettingsButton from './Settings/SettingsButton';
|
||||
|
||||
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-y-3 w-full">{children}</div>
|
||||
);
|
||||
return <div className="flex flex-col items-center w-full">{children}</div>;
|
||||
};
|
||||
|
||||
const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(true);
|
||||
|
||||
const navLinks = [
|
||||
{
|
||||
@@ -39,10 +53,13 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-20 lg:flex-col">
|
||||
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-8">
|
||||
<a href="/">
|
||||
<SquarePen className="cursor-pointer" />
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[72px] lg:flex-col border-r border-light-200 dark:border-dark-200">
|
||||
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-8 shadow-sm shadow-light-200/10 dark:shadow-black/25">
|
||||
<a
|
||||
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 tansition duration-200"
|
||||
href="/"
|
||||
>
|
||||
<Plus size={19} className="cursor-pointer" />
|
||||
</a>
|
||||
<VerticalIconContainer>
|
||||
{navLinks.map((link, i) => (
|
||||
@@ -50,27 +67,45 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
key={i}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
'relative flex flex-row items-center justify-center cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 duration-150 transition w-full py-2 rounded-lg',
|
||||
'relative flex flex-col items-center justify-center space-y-0.5 cursor-pointer w-full py-2 rounded-lg',
|
||||
link.active
|
||||
? 'text-black dark:text-white'
|
||||
: 'text-black/70 dark:text-white/70',
|
||||
? 'text-black/70 dark:text-white/70 '
|
||||
: 'text-black/60 dark:text-white/60',
|
||||
)}
|
||||
>
|
||||
<link.icon />
|
||||
{link.active && (
|
||||
<div className="absolute right-0 -mr-2 h-full w-1 rounded-l-lg bg-black dark:bg-white" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
link.active && 'bg-light-200 dark:bg-dark-200',
|
||||
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
|
||||
)}
|
||||
>
|
||||
<link.icon
|
||||
size={25}
|
||||
className={cn(
|
||||
!link.active && 'group-hover:scale-105',
|
||||
'transition duration:200 m-1.5',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
link.active
|
||||
? 'text-black/80 dark:text-white/80'
|
||||
: 'text-black/60 dark:text-white/60',
|
||||
'text-[10px]',
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</VerticalIconContainer>
|
||||
|
||||
<Link href="/settings">
|
||||
<Settings className="cursor-pointer" />
|
||||
</Link>
|
||||
<SettingsButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-light-primary dark:bg-dark-primary px-4 py-4 shadow-sm lg:hidden">
|
||||
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-light-secondary dark:bg-dark-secondary px-4 py-4 shadow-sm lg:hidden">
|
||||
{navLinks.map((link, i) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
|
@@ -1,15 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
|
||||
|
||||
interface ThinkBoxProps {
|
||||
content: string;
|
||||
thinkingEnded: boolean;
|
||||
}
|
||||
|
||||
const ThinkBox = ({ content }: ThinkBoxProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const ThinkBox = ({ content, thinkingEnded }: ThinkBoxProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkingEnded) {
|
||||
setIsExpanded(false);
|
||||
} else {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [thinkingEnded]);
|
||||
|
||||
return (
|
||||
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">
|
||||
|
@@ -9,60 +9,74 @@ const WeatherWidget = () => {
|
||||
humidity: 0,
|
||||
windSpeed: 0,
|
||||
icon: '',
|
||||
temperatureUnit: 'C',
|
||||
windSpeedUnit: 'm/s',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const getApproxLocation = async () => {
|
||||
const res = await fetch('https://ipwhois.app/json/');
|
||||
const data = await res.json();
|
||||
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,
|
||||
};
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
city: data.city,
|
||||
};
|
||||
};
|
||||
|
||||
const getLocation = async (
|
||||
callback: (location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city: string;
|
||||
}) => void,
|
||||
) => {
|
||||
/*
|
||||
// Geolocation doesn't give city so we'll country using ipapi for now
|
||||
if (navigator.geolocation) {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'geolocation',
|
||||
})
|
||||
|
||||
if (result.state === 'granted') {
|
||||
navigator.geolocation.getCurrentPosition(position => {
|
||||
callback({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
})
|
||||
})
|
||||
} else if (result.state === 'prompt') {
|
||||
callback(await getApproxLocation())
|
||||
navigator.geolocation.getCurrentPosition(position => {})
|
||||
} else if (result.state === 'denied') {
|
||||
callback(await getApproxLocation())
|
||||
}
|
||||
} else {
|
||||
callback(await getApproxLocation())
|
||||
} */
|
||||
const getLocation = async (
|
||||
callback: (location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city: string;
|
||||
}) => void,
|
||||
) => {
|
||||
if (navigator.geolocation) {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'geolocation',
|
||||
});
|
||||
|
||||
if (result.state === 'granted') {
|
||||
navigator.geolocation.getCurrentPosition(async (position) => {
|
||||
const res = await fetch(
|
||||
`https://api-bdc.io/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=en`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
callback({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
city: data.locality,
|
||||
});
|
||||
});
|
||||
} else if (result.state === 'prompt') {
|
||||
callback(await getApproxLocation());
|
||||
navigator.geolocation.getCurrentPosition((position) => {});
|
||||
} else if (result.state === 'denied') {
|
||||
callback(await getApproxLocation());
|
||||
}
|
||||
} else {
|
||||
callback(await getApproxLocation());
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateWeather = async () => {
|
||||
getLocation(async (location) => {
|
||||
const res = await fetch(`/api/weather`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
lat: location.latitude,
|
||||
lng: location.longitude,
|
||||
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -81,13 +95,21 @@ const WeatherWidget = () => {
|
||||
humidity: data.humidity,
|
||||
windSpeed: data.windSpeed,
|
||||
icon: data.icon,
|
||||
temperatureUnit: data.temperatureUnit,
|
||||
windSpeedUnit: data.windSpeedUnit,
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateWeather();
|
||||
const intervalId = setInterval(updateWeather, 2 * 60 * 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary rounded-xl border border-light-200 dark:border-dark-200 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center w-16 min-w-16 max-w-16 h-full animate-pulse">
|
||||
@@ -115,25 +137,27 @@ const WeatherWidget = () => {
|
||||
className="h-10 w-auto"
|
||||
/>
|
||||
<span className="text-base font-semibold text-black dark:text-white">
|
||||
{data.temperature}°C
|
||||
{data.temperature}°{data.temperatureUnit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between flex-1 h-full py-1">
|
||||
<div className="flex flex-col justify-between flex-1 h-full py-2">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="text-xs font-medium text-black dark:text-white">
|
||||
<span className="text-sm font-semibold text-black dark:text-white">
|
||||
{data.location}
|
||||
</span>
|
||||
<span className="flex items-center text-xs text-black/60 dark:text-white/60">
|
||||
<span className="flex items-center text-xs text-black/60 dark:text-white/60 font-medium">
|
||||
<Wind className="w-3 h-3 mr-1" />
|
||||
{data.windSpeed} km/h
|
||||
{data.windSpeed} {data.windSpeedUnit}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-black/60 dark:text-white/60 mt-1">
|
||||
<span className="text-xs text-black/50 dark:text-white/50 italic">
|
||||
{data.condition}
|
||||
</span>
|
||||
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200 dark:border-dark-200 text-xs text-black/60 dark:text-white/60">
|
||||
<span>Humidity: {data.humidity}%</span>
|
||||
<span>Now</span>
|
||||
<div className="flex flex-row justify-between w-full mt-auto pt-2 border-t border-light-200/50 dark:border-dark-200/50 text-xs text-black/50 dark:text-white/50 font-medium">
|
||||
<span>Humidity {data.humidity}%</span>
|
||||
<span className="font-semibold text-black/70 dark:text-white/70">
|
||||
Now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
22
src/components/ui/Loader.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
const Loader = () => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
@@ -1,28 +1,50 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SelectHTMLAttributes } from 'react';
|
||||
import { Loader2, ChevronDown } from 'lucide-react';
|
||||
import { SelectHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: { value: string; label: string; disabled?: boolean }[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Select = ({ className, options, ...restProps }: SelectProps) => {
|
||||
return (
|
||||
<select
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map(({ label, value, disabled }) => {
|
||||
return (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, options, loading = false, disabled, ...restProps }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex w-full items-center',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<select
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm appearance-none w-full pr-10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map(({ label, value, disabled: optionDisabled }) => {
|
||||
return (
|
||||
<option key={value} value={value} disabled={optionDisabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-3 flex h-4 w-4 items-center justify-center text-black/50 dark:text-white/60">
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export default Select;
|
||||
|
13
src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
try {
|
||||
console.log('Running database migrations...');
|
||||
await import('./lib/db/migrate');
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to run database migrations:', error);
|
||||
}
|
||||
|
||||
await import('./lib/config/index');
|
||||
}
|
||||
};
|
@@ -1,11 +1,8 @@
|
||||
import { Message } from '@/components/ChatWindow';
|
||||
|
||||
export const getSuggestions = async (chatHisory: Message[]) => {
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
|
||||
const customOpenAIKey = localStorage.getItem('openAIApiKey');
|
||||
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
|
||||
export const getSuggestions = async (chatHistory: Message[]) => {
|
||||
const chatModel = localStorage.getItem('chatModelKey');
|
||||
const chatModelProvider = localStorage.getItem('chatModelProviderId');
|
||||
|
||||
const res = await fetch(`/api/suggestions`, {
|
||||
method: 'POST',
|
||||
@@ -13,14 +10,10 @@ export const getSuggestions = async (chatHisory: Message[]) => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chatHistory: chatHisory,
|
||||
chatHistory: chatHistory,
|
||||
chatModel: {
|
||||
provider: chatModelProvider,
|
||||
model: chatModel,
|
||||
...(chatModelProvider === 'custom_openai' && {
|
||||
customOpenAIKey,
|
||||
customOpenAIBaseURL,
|
||||
}),
|
||||
providerId: chatModelProvider,
|
||||
key: chatModel,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@@ -3,32 +3,18 @@ import {
|
||||
RunnableMap,
|
||||
RunnableLambda,
|
||||
} from '@langchain/core/runnables';
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import formatChatHistoryAsString from '../utils/formatHistory';
|
||||
import { BaseMessage } from '@langchain/core/messages';
|
||||
import { StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import { searchSearxng } from '../searxng';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import LineOutputParser from '../outputParsers/lineOutputParser';
|
||||
|
||||
const imageSearchChainPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search the web for images.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
|
||||
Example:
|
||||
1. Follow up question: What is a cat?
|
||||
Rephrased: A cat
|
||||
|
||||
2. Follow up question: What is a car? How does it works?
|
||||
Rephrased: Car working
|
||||
|
||||
3. Follow up question: How does an AC work?
|
||||
Rephrased: AC working
|
||||
|
||||
Conversation:
|
||||
{chat_history}
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
Output only the rephrased query wrapped in an XML <query> element. Do not include any explanation or additional text.
|
||||
`;
|
||||
|
||||
type ImageSearchChainInput = {
|
||||
@@ -54,12 +40,39 @@ const createImageSearchChain = (llm: BaseChatModel) => {
|
||||
return input.query;
|
||||
},
|
||||
}),
|
||||
PromptTemplate.fromTemplate(imageSearchChainPrompt),
|
||||
ChatPromptTemplate.fromMessages([
|
||||
['system', imageSearchChainPrompt],
|
||||
[
|
||||
'user',
|
||||
'<conversation>\n</conversation>\n<follow_up>\nWhat is a cat?\n</follow_up>',
|
||||
],
|
||||
['assistant', '<query>A cat</query>'],
|
||||
|
||||
[
|
||||
'user',
|
||||
'<conversation>\n</conversation>\n<follow_up>\nWhat is a car? How does it work?\n</follow_up>',
|
||||
],
|
||||
['assistant', '<query>Car working</query>'],
|
||||
[
|
||||
'user',
|
||||
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
|
||||
],
|
||||
['assistant', '<query>AC working</query>'],
|
||||
[
|
||||
'user',
|
||||
'<conversation>{chat_history}</conversation>\n<follow_up>\n{query}\n</follow_up>',
|
||||
],
|
||||
]),
|
||||
llm,
|
||||
strParser,
|
||||
RunnableLambda.from(async (input: string) => {
|
||||
input = input.replace(/<think>.*?<\/think>/g, '');
|
||||
const queryParser = new LineOutputParser({
|
||||
key: 'query',
|
||||
});
|
||||
|
||||
return await queryParser.parse(input);
|
||||
}),
|
||||
RunnableLambda.from(async (input: string) => {
|
||||
const res = await searchSearxng(input, {
|
||||
engines: ['bing images', 'google images'],
|
||||
});
|
||||
|
@@ -3,33 +3,19 @@ import {
|
||||
RunnableMap,
|
||||
RunnableLambda,
|
||||
} from '@langchain/core/runnables';
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import formatChatHistoryAsString from '../utils/formatHistory';
|
||||
import { BaseMessage } from '@langchain/core/messages';
|
||||
import { StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import { searchSearxng } from '../searxng';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import LineOutputParser from '../outputParsers/lineOutputParser';
|
||||
|
||||
const VideoSearchChainPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
|
||||
Example:
|
||||
1. Follow up question: How does a car work?
|
||||
Rephrased: How does a car work?
|
||||
|
||||
2. Follow up question: What is the theory of relativity?
|
||||
Rephrased: What is theory of relativity
|
||||
|
||||
3. Follow up question: How does an AC work?
|
||||
Rephrased: How does an AC work
|
||||
|
||||
Conversation:
|
||||
{chat_history}
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
`;
|
||||
const videoSearchChainPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
Output only the rephrased query wrapped in an XML <query> element. Do not include any explanation or additional text.
|
||||
`;
|
||||
|
||||
type VideoSearchChainInput = {
|
||||
chat_history: BaseMessage[];
|
||||
@@ -55,12 +41,37 @@ const createVideoSearchChain = (llm: BaseChatModel) => {
|
||||
return input.query;
|
||||
},
|
||||
}),
|
||||
PromptTemplate.fromTemplate(VideoSearchChainPrompt),
|
||||
ChatPromptTemplate.fromMessages([
|
||||
['system', videoSearchChainPrompt],
|
||||
[
|
||||
'user',
|
||||
'<conversation>\n</conversation>\n<follow_up>\nHow does a car work?\n</follow_up>',
|
||||
],
|
||||
['assistant', '<query>How does a car work?</query>'],
|
||||
[
|
||||
'user',
|
||||
'<conversation>\n</conversation>\n<follow_up>\nWhat is the theory of relativity?\n</follow_up>',
|
||||
],
|
||||
['assistant', '<query>Theory of relativity</query>'],
|
||||
[
|
||||
'user',
|
||||
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
|
||||
],
|
||||
['assistant', '<query>AC working</query>'],
|
||||
[
|
||||
'user',
|
||||
'<conversation>{chat_history}</conversation>\n<follow_up>\n{query}\n</follow_up>',
|
||||
],
|
||||
]),
|
||||
llm,
|
||||
strParser,
|
||||
RunnableLambda.from(async (input: string) => {
|
||||
input = input.replace(/<think>.*?<\/think>/g, '');
|
||||
|
||||
const queryParser = new LineOutputParser({
|
||||
key: 'query',
|
||||
});
|
||||
return await queryParser.parse(input);
|
||||
}),
|
||||
RunnableLambda.from(async (input: string) => {
|
||||
const res = await searchSearxng(input, {
|
||||
engines: ['youtube'],
|
||||
});
|
||||
@@ -92,8 +103,8 @@ const handleVideoSearch = (
|
||||
input: VideoSearchChainInput,
|
||||
llm: BaseChatModel,
|
||||
) => {
|
||||
const VideoSearchChain = createVideoSearchChain(llm);
|
||||
return VideoSearchChain.invoke(input);
|
||||
const videoSearchChain = createVideoSearchChain(llm);
|
||||
return videoSearchChain.invoke(input);
|
||||
};
|
||||
|
||||
export default handleVideoSearch;
|
||||
|
@@ -1,141 +0,0 @@
|
||||
import toml from '@iarna/toml';
|
||||
|
||||
// Use dynamic imports for Node.js modules to prevent client-side errors
|
||||
let fs: any;
|
||||
let path: any;
|
||||
if (typeof window === 'undefined') {
|
||||
// We're on the server
|
||||
fs = require('fs');
|
||||
path = require('path');
|
||||
}
|
||||
|
||||
const configFileName = 'config.toml';
|
||||
|
||||
interface Config {
|
||||
GENERAL: {
|
||||
SIMILARITY_MEASURE: string;
|
||||
KEEP_ALIVE: string;
|
||||
};
|
||||
MODELS: {
|
||||
OPENAI: {
|
||||
API_KEY: string;
|
||||
};
|
||||
GROQ: {
|
||||
API_KEY: string;
|
||||
};
|
||||
ANTHROPIC: {
|
||||
API_KEY: string;
|
||||
};
|
||||
GEMINI: {
|
||||
API_KEY: string;
|
||||
};
|
||||
OLLAMA: {
|
||||
API_URL: string;
|
||||
};
|
||||
DEEPSEEK: {
|
||||
API_KEY: string;
|
||||
};
|
||||
LM_STUDIO: {
|
||||
API_URL: string;
|
||||
};
|
||||
CUSTOM_OPENAI: {
|
||||
API_URL: string;
|
||||
API_KEY: string;
|
||||
MODEL_NAME: string;
|
||||
};
|
||||
};
|
||||
API_ENDPOINTS: {
|
||||
SEARXNG: string;
|
||||
};
|
||||
}
|
||||
|
||||
type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||
};
|
||||
|
||||
const loadConfig = () => {
|
||||
// Server-side only
|
||||
if (typeof window === 'undefined') {
|
||||
return toml.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), `${configFileName}`), 'utf-8'),
|
||||
) as any as Config;
|
||||
}
|
||||
|
||||
// Client-side fallback - settings will be loaded via API
|
||||
return {} as Config;
|
||||
};
|
||||
|
||||
export const getSimilarityMeasure = () =>
|
||||
loadConfig().GENERAL.SIMILARITY_MEASURE;
|
||||
|
||||
export const getKeepAlive = () => loadConfig().GENERAL.KEEP_ALIVE;
|
||||
|
||||
export const getOpenaiApiKey = () => loadConfig().MODELS.OPENAI.API_KEY;
|
||||
|
||||
export const getGroqApiKey = () => loadConfig().MODELS.GROQ.API_KEY;
|
||||
|
||||
export const getAnthropicApiKey = () => loadConfig().MODELS.ANTHROPIC.API_KEY;
|
||||
|
||||
export const getGeminiApiKey = () => loadConfig().MODELS.GEMINI.API_KEY;
|
||||
|
||||
export const getSearxngApiEndpoint = () =>
|
||||
process.env.SEARXNG_API_URL || loadConfig().API_ENDPOINTS.SEARXNG;
|
||||
|
||||
export const getOllamaApiEndpoint = () => loadConfig().MODELS.OLLAMA.API_URL;
|
||||
|
||||
export const getDeepseekApiKey = () => loadConfig().MODELS.DEEPSEEK.API_KEY;
|
||||
|
||||
export const getCustomOpenaiApiKey = () =>
|
||||
loadConfig().MODELS.CUSTOM_OPENAI.API_KEY;
|
||||
|
||||
export const getCustomOpenaiApiUrl = () =>
|
||||
loadConfig().MODELS.CUSTOM_OPENAI.API_URL;
|
||||
|
||||
export const getCustomOpenaiModelName = () =>
|
||||
loadConfig().MODELS.CUSTOM_OPENAI.MODEL_NAME;
|
||||
|
||||
export const getLMStudioApiEndpoint = () =>
|
||||
loadConfig().MODELS.LM_STUDIO.API_URL;
|
||||
|
||||
const mergeConfigs = (current: any, update: any): any => {
|
||||
if (update === null || update === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (typeof current !== 'object' || current === null) {
|
||||
return update;
|
||||
}
|
||||
|
||||
const result = { ...current };
|
||||
|
||||
for (const key in update) {
|
||||
if (Object.prototype.hasOwnProperty.call(update, key)) {
|
||||
const updateValue = update[key];
|
||||
|
||||
if (
|
||||
typeof updateValue === 'object' &&
|
||||
updateValue !== null &&
|
||||
typeof result[key] === 'object' &&
|
||||
result[key] !== null
|
||||
) {
|
||||
result[key] = mergeConfigs(result[key], updateValue);
|
||||
} else if (updateValue !== undefined) {
|
||||
result[key] = updateValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateConfig = (config: RecursivePartial<Config>) => {
|
||||
// Server-side only
|
||||
if (typeof window === 'undefined') {
|
||||
const currentConfig = loadConfig();
|
||||
const mergedConfig = mergeConfigs(currentConfig, config);
|
||||
fs.writeFileSync(
|
||||
path.join(path.join(process.cwd(), `${configFileName}`)),
|
||||
toml.stringify(mergedConfig),
|
||||
);
|
||||
}
|
||||
};
|
16
src/lib/config/clientRegistry.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
const getClientConfig = (key: string, defaultVal?: any) => {
|
||||
return localStorage.getItem(key) ?? defaultVal ?? undefined;
|
||||
};
|
||||
|
||||
export const getTheme = () => getClientConfig('theme', 'dark');
|
||||
|
||||
export const getAutoImageSearch = () =>
|
||||
Boolean(getClientConfig('autoImageSearch', 'true'));
|
||||
|
||||
export const getAutoVideoSearch = () =>
|
||||
Boolean(getClientConfig('autoVideoSearch', 'true'));
|
||||
|
||||
export const getSystemInstructions = () =>
|
||||
getClientConfig('systemInstructions', '');
|
331
src/lib/config/index.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'fs';
|
||||
import { Config, ConfigModelProvider, UIConfigSections } from './types';
|
||||
import { hashObj } from '../serverUtils';
|
||||
import { getModelProvidersUIConfigSection } from '../models/providers';
|
||||
|
||||
class ConfigManager {
|
||||
configPath: string = path.join(
|
||||
process.env.DATA_DIR || process.cwd(),
|
||||
'/data/config.json',
|
||||
);
|
||||
configVersion = 1;
|
||||
currentConfig: Config = {
|
||||
version: this.configVersion,
|
||||
setupComplete: false,
|
||||
general: {},
|
||||
modelProviders: [],
|
||||
search: {
|
||||
searxngURL: '',
|
||||
},
|
||||
};
|
||||
uiConfigSections: UIConfigSections = {
|
||||
general: [
|
||||
{
|
||||
name: 'Theme',
|
||||
key: 'theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
name: 'Light',
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
name: 'Dark',
|
||||
value: 'dark',
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
description: 'Choose between light and dark layouts for the app.',
|
||||
default: 'dark',
|
||||
scope: 'client',
|
||||
},
|
||||
],
|
||||
modelProviders: [],
|
||||
search: [
|
||||
{
|
||||
name: 'SearXNG URL',
|
||||
key: 'searxngURL',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'The URL of your SearXNG instance',
|
||||
placeholder: 'http://localhost:4000',
|
||||
default: '',
|
||||
scope: 'server',
|
||||
env: 'SEARXNG_API_URL',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.initializeConfig();
|
||||
this.initializeFromEnv();
|
||||
}
|
||||
|
||||
private saveConfig() {
|
||||
fs.writeFileSync(
|
||||
this.configPath,
|
||||
JSON.stringify(this.currentConfig, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
private initializeConfig() {
|
||||
const exists = fs.existsSync(this.configPath);
|
||||
if (!exists) {
|
||||
fs.writeFileSync(
|
||||
this.configPath,
|
||||
JSON.stringify(this.currentConfig, null, 2),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
this.currentConfig = JSON.parse(
|
||||
fs.readFileSync(this.configPath, 'utf-8'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
console.error(
|
||||
`Error parsing config file at ${this.configPath}:`,
|
||||
err,
|
||||
);
|
||||
console.log(
|
||||
'Loading default config and overwriting the existing file.',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
this.configPath,
|
||||
JSON.stringify(this.currentConfig, null, 2),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
console.log('Unknown error reading config file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentConfig = this.migrateConfig(this.currentConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private migrateConfig(config: Config): Config {
|
||||
/* TODO: Add migrations */
|
||||
return config;
|
||||
}
|
||||
|
||||
private initializeFromEnv() {
|
||||
/* providers section*/
|
||||
const providerConfigSections = getModelProvidersUIConfigSection();
|
||||
|
||||
this.uiConfigSections.modelProviders = providerConfigSections;
|
||||
|
||||
const newProviders: ConfigModelProvider[] = [];
|
||||
|
||||
providerConfigSections.forEach((provider) => {
|
||||
const newProvider: ConfigModelProvider & { required?: string[] } = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${provider.name} ${Math.floor(Math.random() * 1000)}`,
|
||||
type: provider.key,
|
||||
chatModels: [],
|
||||
embeddingModels: [],
|
||||
config: {},
|
||||
required: [],
|
||||
hash: '',
|
||||
};
|
||||
|
||||
provider.fields.forEach((field) => {
|
||||
newProvider.config[field.key] =
|
||||
process.env[field.env!] ||
|
||||
field.default ||
|
||||
''; /* Env var must exist for providers */
|
||||
|
||||
if (field.required) newProvider.required?.push(field.key);
|
||||
});
|
||||
|
||||
let configured = true;
|
||||
|
||||
newProvider.required?.forEach((r) => {
|
||||
if (!newProvider.config[r]) {
|
||||
configured = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (configured) {
|
||||
const hash = hashObj(newProvider.config);
|
||||
newProvider.hash = hash;
|
||||
delete newProvider.required;
|
||||
|
||||
const exists = this.currentConfig.modelProviders.find(
|
||||
(p) => p.hash === hash,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
newProviders.push(newProvider);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.currentConfig.modelProviders.push(...newProviders);
|
||||
|
||||
/* search section */
|
||||
this.uiConfigSections.search.forEach((f) => {
|
||||
if (f.env && !this.currentConfig.search[f.key]) {
|
||||
this.currentConfig.search[f.key] =
|
||||
process.env[f.env] ?? f.default ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
public getConfig(key: string, defaultValue?: any): any {
|
||||
const nested = key.split('.');
|
||||
let obj: any = this.currentConfig;
|
||||
|
||||
for (let i = 0; i < nested.length; i++) {
|
||||
const part = nested[i];
|
||||
if (obj == null) return defaultValue;
|
||||
|
||||
obj = obj[part];
|
||||
}
|
||||
|
||||
return obj === undefined ? defaultValue : obj;
|
||||
}
|
||||
|
||||
public updateConfig(key: string, val: any) {
|
||||
const parts = key.split('.');
|
||||
if (parts.length === 0) return;
|
||||
|
||||
let target: any = this.currentConfig;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (target[part] === null || typeof target[part] !== 'object') {
|
||||
target[part] = {};
|
||||
}
|
||||
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
const finalKey = parts[parts.length - 1];
|
||||
target[finalKey] = val;
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
public addModelProvider(type: string, name: string, config: any) {
|
||||
const newModelProvider: ConfigModelProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
chatModels: [],
|
||||
embeddingModels: [],
|
||||
hash: hashObj(config),
|
||||
};
|
||||
|
||||
this.currentConfig.modelProviders.push(newModelProvider);
|
||||
this.saveConfig();
|
||||
|
||||
return newModelProvider;
|
||||
}
|
||||
|
||||
public removeModelProvider(id: string) {
|
||||
const index = this.currentConfig.modelProviders.findIndex(
|
||||
(p) => p.id === id,
|
||||
);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
this.currentConfig.modelProviders =
|
||||
this.currentConfig.modelProviders.filter((p) => p.id !== id);
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
public async updateModelProvider(id: string, name: string, config: any) {
|
||||
const provider = this.currentConfig.modelProviders.find((p) => {
|
||||
return p.id === id;
|
||||
});
|
||||
|
||||
if (!provider) throw new Error('Provider not found');
|
||||
|
||||
provider.name = name;
|
||||
provider.config = config;
|
||||
|
||||
this.saveConfig();
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
public addProviderModel(
|
||||
providerId: string,
|
||||
type: 'embedding' | 'chat',
|
||||
model: any,
|
||||
) {
|
||||
const provider = this.currentConfig.modelProviders.find(
|
||||
(p) => p.id === providerId,
|
||||
);
|
||||
|
||||
if (!provider) throw new Error('Invalid provider id');
|
||||
|
||||
delete model.type;
|
||||
|
||||
if (type === 'chat') {
|
||||
provider.chatModels.push(model);
|
||||
} else {
|
||||
provider.embeddingModels.push(model);
|
||||
}
|
||||
|
||||
this.saveConfig();
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public removeProviderModel(
|
||||
providerId: string,
|
||||
type: 'embedding' | 'chat',
|
||||
modelKey: string,
|
||||
) {
|
||||
const provider = this.currentConfig.modelProviders.find(
|
||||
(p) => p.id === providerId,
|
||||
);
|
||||
|
||||
if (!provider) throw new Error('Invalid provider id');
|
||||
|
||||
if (type === 'chat') {
|
||||
provider.chatModels = provider.chatModels.filter(
|
||||
(m) => m.key !== modelKey,
|
||||
);
|
||||
} else {
|
||||
provider.embeddingModels = provider.embeddingModels.filter(
|
||||
(m) => m.key != modelKey,
|
||||
);
|
||||
}
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
public isSetupComplete() {
|
||||
return this.currentConfig.setupComplete;
|
||||
}
|
||||
|
||||
public markSetupComplete() {
|
||||
if (!this.currentConfig.setupComplete) {
|
||||
this.currentConfig.setupComplete = true;
|
||||
}
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
public getUIConfigSections(): UIConfigSections {
|
||||
return this.uiConfigSections;
|
||||
}
|
||||
|
||||
public getCurrentConfig(): Config {
|
||||
return JSON.parse(JSON.stringify(this.currentConfig));
|
||||
}
|
||||
}
|
||||
|
||||
const configManager = new ConfigManager();
|
||||
|
||||
export default configManager;
|
15
src/lib/config/serverRegistry.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import configManager from './index';
|
||||
import { ConfigModelProvider } from './types';
|
||||
|
||||
export const getConfiguredModelProviders = (): ConfigModelProvider[] => {
|
||||
return configManager.getConfig('modelProviders', []);
|
||||
};
|
||||
|
||||
export const getConfiguredModelProviderById = (
|
||||
id: string,
|
||||
): ConfigModelProvider | undefined => {
|
||||
return getConfiguredModelProviders().find((p) => p.id === id) ?? undefined;
|
||||
};
|
||||
|
||||
export const getSearxngURL = () =>
|
||||
configManager.getConfig('search.searxngURL', '');
|
90
src/lib/config/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Model } from '../models/types';
|
||||
|
||||
type BaseUIConfigField = {
|
||||
name: string;
|
||||
key: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
scope: 'client' | 'server';
|
||||
env?: string;
|
||||
};
|
||||
|
||||
type StringUIConfigField = BaseUIConfigField & {
|
||||
type: 'string';
|
||||
placeholder?: string;
|
||||
default?: string;
|
||||
};
|
||||
|
||||
type SelectUIConfigFieldOptions = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SelectUIConfigField = BaseUIConfigField & {
|
||||
type: 'select';
|
||||
default?: string;
|
||||
options: SelectUIConfigFieldOptions[];
|
||||
};
|
||||
|
||||
type PasswordUIConfigField = BaseUIConfigField & {
|
||||
type: 'password';
|
||||
placeholder?: string;
|
||||
default?: string;
|
||||
};
|
||||
|
||||
type UIConfigField =
|
||||
| StringUIConfigField
|
||||
| SelectUIConfigField
|
||||
| PasswordUIConfigField;
|
||||
|
||||
type ConfigModelProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
chatModels: Model[];
|
||||
embeddingModels: Model[];
|
||||
config: { [key: string]: any };
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type Config = {
|
||||
version: number;
|
||||
setupComplete: boolean;
|
||||
general: {
|
||||
[key: string]: any;
|
||||
};
|
||||
modelProviders: ConfigModelProvider[];
|
||||
search: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
type EnvMap = {
|
||||
[key: string]: {
|
||||
fieldKey: string;
|
||||
providerKey: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ModelProviderUISection = {
|
||||
name: string;
|
||||
key: string;
|
||||
fields: UIConfigField[];
|
||||
};
|
||||
|
||||
type UIConfigSections = {
|
||||
general: UIConfigField[];
|
||||
modelProviders: ModelProviderUISection[];
|
||||
search: UIConfigField[];
|
||||
};
|
||||
|
||||
export type {
|
||||
UIConfigField,
|
||||
Config,
|
||||
EnvMap,
|
||||
UIConfigSections,
|
||||
SelectUIConfigField,
|
||||
StringUIConfigField,
|
||||
ModelProviderUISection,
|
||||
ConfigModelProvider,
|
||||
};
|
@@ -1,5 +1,122 @@
|
||||
import db from './';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
migrate(db, { migrationsFolder: path.join(process.cwd(), 'drizzle') });
|
||||
const DATA_DIR = process.env.DATA_DIR || process.cwd();
|
||||
const dbPath = path.join(DATA_DIR, './data/db.sqlite');
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
const migrationsFolder = path.join(DATA_DIR, 'drizzle');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ran_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
run_on DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
function sanitizeSql(content: string) {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.filter(
|
||||
(l) => !l.trim().startsWith('-->') && !l.includes('statement-breakpoint'),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
fs.readdirSync(migrationsFolder)
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort()
|
||||
.forEach((file) => {
|
||||
const filePath = path.join(migrationsFolder, file);
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
content = sanitizeSql(content);
|
||||
|
||||
const migrationName = file.split('_')[0] || file;
|
||||
|
||||
const already = db
|
||||
.prepare('SELECT 1 FROM ran_migrations WHERE name = ?')
|
||||
.get(migrationName);
|
||||
if (already) {
|
||||
console.log(`Skipping already-applied migration: ${file}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (migrationName === '0001') {
|
||||
const messages = db
|
||||
.prepare(
|
||||
'SELECT id, type, metadata, content, chatId, messageId FROM messages',
|
||||
)
|
||||
.all();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages_with_sources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
chatId TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
messageId TEXT NOT NULL,
|
||||
content TEXT,
|
||||
sources TEXT DEFAULT '[]'
|
||||
);
|
||||
`);
|
||||
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO messages_with_sources (type, chatId, createdAt, messageId, content, sources)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
messages.forEach((msg: any) => {
|
||||
while (typeof msg.metadata === 'string') {
|
||||
msg.metadata = JSON.parse(msg.metadata || '{}');
|
||||
}
|
||||
if (msg.type === 'user') {
|
||||
insertMessage.run(
|
||||
'user',
|
||||
msg.chatId,
|
||||
msg.metadata['createdAt'],
|
||||
msg.messageId,
|
||||
msg.content,
|
||||
'[]',
|
||||
);
|
||||
} else if (msg.type === 'assistant') {
|
||||
insertMessage.run(
|
||||
'assistant',
|
||||
msg.chatId,
|
||||
msg.metadata['createdAt'],
|
||||
msg.messageId,
|
||||
msg.content,
|
||||
'[]',
|
||||
);
|
||||
const sources = msg.metadata['sources'] || '[]';
|
||||
if (sources && sources.length > 0) {
|
||||
insertMessage.run(
|
||||
'source',
|
||||
msg.chatId,
|
||||
msg.metadata['createdAt'],
|
||||
`${msg.messageId}-source`,
|
||||
'',
|
||||
JSON.stringify(sources),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
db.exec('DROP TABLE messages;');
|
||||
db.exec('ALTER TABLE messages_with_sources RENAME TO messages;');
|
||||
} else {
|
||||
db.exec(content);
|
||||
}
|
||||
|
||||
db.prepare('INSERT OR IGNORE INTO ran_migrations (name) VALUES (?)').run(
|
||||
migrationName,
|
||||
);
|
||||
console.log(`Applied migration: ${file}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to apply migration ${file}:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
@@ -1,15 +1,23 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core';
|
||||
import { Document } from 'langchain/document';
|
||||
|
||||
export const messages = sqliteTable('messages', {
|
||||
id: integer('id').primaryKey(),
|
||||
content: text('content').notNull(),
|
||||
role: text('type', { enum: ['assistant', 'user', 'source'] }).notNull(),
|
||||
chatId: text('chatId').notNull(),
|
||||
createdAt: text('createdAt')
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
messageId: text('messageId').notNull(),
|
||||
role: text('type', { enum: ['assistant', 'user'] }),
|
||||
metadata: text('metadata', {
|
||||
|
||||
content: text('content'),
|
||||
|
||||
sources: text('sources', {
|
||||
mode: 'json',
|
||||
}),
|
||||
})
|
||||
.$type<Document[]>()
|
||||
.default(sql`'[]'`),
|
||||
});
|
||||
|
||||
interface File {
|
||||
|
756
src/lib/hooks/useChat.tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AssistantMessage,
|
||||
ChatTurn,
|
||||
Message,
|
||||
SourceMessage,
|
||||
SuggestionMessage,
|
||||
UserMessage,
|
||||
} from '@/components/ChatWindow';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import crypto from 'crypto';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { getSuggestions } from '../actions';
|
||||
import { MinimalProvider } from '../models/types';
|
||||
|
||||
export type Section = {
|
||||
userMessage: UserMessage;
|
||||
assistantMessage: AssistantMessage | undefined;
|
||||
parsedAssistantMessage: string | undefined;
|
||||
speechMessage: string | undefined;
|
||||
sourceMessage: SourceMessage | undefined;
|
||||
thinkingEnded: boolean;
|
||||
suggestions?: string[];
|
||||
};
|
||||
|
||||
type ChatContext = {
|
||||
messages: Message[];
|
||||
chatTurns: ChatTurn[];
|
||||
sections: Section[];
|
||||
chatHistory: [string, string][];
|
||||
files: File[];
|
||||
fileIds: string[];
|
||||
focusMode: string;
|
||||
chatId: string | undefined;
|
||||
optimizationMode: string;
|
||||
isMessagesLoaded: boolean;
|
||||
loading: boolean;
|
||||
notFound: boolean;
|
||||
messageAppeared: boolean;
|
||||
isReady: boolean;
|
||||
hasError: boolean;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
setFocusMode: (mode: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
sendMessage: (
|
||||
message: string,
|
||||
messageId?: string,
|
||||
rewrite?: boolean,
|
||||
) => Promise<void>;
|
||||
rewrite: (messageId: string) => void;
|
||||
};
|
||||
|
||||
export interface File {
|
||||
fileName: string;
|
||||
fileExtension: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
interface ChatModelProvider {
|
||||
key: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
interface EmbeddingModelProvider {
|
||||
key: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
const checkConfig = async (
|
||||
setChatModelProvider: (provider: ChatModelProvider) => void,
|
||||
setEmbeddingModelProvider: (provider: EmbeddingModelProvider) => void,
|
||||
setIsConfigReady: (ready: boolean) => void,
|
||||
setHasError: (hasError: boolean) => void,
|
||||
) => {
|
||||
try {
|
||||
let chatModelKey = localStorage.getItem('chatModelKey');
|
||||
let chatModelProviderId = localStorage.getItem('chatModelProviderId');
|
||||
let embeddingModelKey = localStorage.getItem('embeddingModelKey');
|
||||
let embeddingModelProviderId = localStorage.getItem(
|
||||
'embeddingModelProviderId',
|
||||
);
|
||||
|
||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
||||
|
||||
if (!autoImageSearch) {
|
||||
localStorage.setItem('autoImageSearch', 'true');
|
||||
}
|
||||
|
||||
if (!autoVideoSearch) {
|
||||
localStorage.setItem('autoVideoSearch', 'false');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/providers`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Provider fetching failed with status code ${res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const providers: MinimalProvider[] = data.providers;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new Error(
|
||||
'No chat model providers found, please configure them in the settings page.',
|
||||
);
|
||||
}
|
||||
|
||||
const chatModelProvider =
|
||||
providers.find((p) => p.id === chatModelProviderId) ??
|
||||
providers.find((p) => p.chatModels.length > 0);
|
||||
|
||||
if (!chatModelProvider) {
|
||||
throw new Error(
|
||||
'No chat models found, pleae configure them in the settings page.',
|
||||
);
|
||||
}
|
||||
|
||||
chatModelProviderId = chatModelProvider.id;
|
||||
|
||||
const chatModel =
|
||||
chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ??
|
||||
chatModelProvider.chatModels[0];
|
||||
chatModelKey = chatModel.key;
|
||||
|
||||
const embeddingModelProvider =
|
||||
providers.find((p) => p.id === embeddingModelProviderId) ??
|
||||
providers.find((p) => p.embeddingModels.length > 0);
|
||||
|
||||
if (!embeddingModelProvider) {
|
||||
throw new Error(
|
||||
'No embedding models found, pleae configure them in the settings page.',
|
||||
);
|
||||
}
|
||||
|
||||
embeddingModelProviderId = embeddingModelProvider.id;
|
||||
|
||||
const embeddingModel =
|
||||
embeddingModelProvider.embeddingModels.find(
|
||||
(m) => m.key === embeddingModelKey,
|
||||
) ?? embeddingModelProvider.embeddingModels[0];
|
||||
embeddingModelKey = embeddingModel.key;
|
||||
|
||||
localStorage.setItem('chatModelKey', chatModelKey);
|
||||
localStorage.setItem('chatModelProviderId', chatModelProviderId);
|
||||
localStorage.setItem('embeddingModelKey', embeddingModelKey);
|
||||
localStorage.setItem('embeddingModelProviderId', embeddingModelProviderId);
|
||||
|
||||
setChatModelProvider({
|
||||
key: chatModelKey,
|
||||
providerId: chatModelProviderId,
|
||||
});
|
||||
|
||||
setEmbeddingModelProvider({
|
||||
key: embeddingModelKey,
|
||||
providerId: embeddingModelProviderId,
|
||||
});
|
||||
|
||||
setIsConfigReady(true);
|
||||
} catch (err: any) {
|
||||
console.error('An error occurred while checking the configuration:', err);
|
||||
toast.error(err.message);
|
||||
setIsConfigReady(false);
|
||||
setHasError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async (
|
||||
chatId: string,
|
||||
setMessages: (messages: Message[]) => void,
|
||||
setIsMessagesLoaded: (loaded: boolean) => void,
|
||||
setChatHistory: (history: [string, string][]) => void,
|
||||
setFocusMode: (mode: string) => void,
|
||||
setNotFound: (notFound: boolean) => void,
|
||||
setFiles: (files: File[]) => void,
|
||||
setFileIds: (fileIds: string[]) => void,
|
||||
) => {
|
||||
const res = await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 404) {
|
||||
setNotFound(true);
|
||||
setIsMessagesLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const messages = data.messages as Message[];
|
||||
|
||||
setMessages(messages);
|
||||
|
||||
const chatTurns = messages.filter(
|
||||
(msg): msg is ChatTurn => msg.role === 'user' || msg.role === 'assistant',
|
||||
);
|
||||
|
||||
const history = chatTurns.map((msg) => {
|
||||
return [msg.role, msg.content];
|
||||
}) as [string, string][];
|
||||
|
||||
console.debug(new Date(), 'app:messages_loaded');
|
||||
|
||||
if (chatTurns.length > 0) {
|
||||
document.title = chatTurns[0].content;
|
||||
}
|
||||
|
||||
const files = data.chat.files.map((file: any) => {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileExtension: file.name.split('.').pop(),
|
||||
fileId: file.fileId,
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(files);
|
||||
setFileIds(files.map((file: File) => file.fileId));
|
||||
|
||||
setChatHistory(history);
|
||||
setFocusMode(data.chat.focusMode);
|
||||
setIsMessagesLoaded(true);
|
||||
};
|
||||
|
||||
export const chatContext = createContext<ChatContext>({
|
||||
chatHistory: [],
|
||||
chatId: '',
|
||||
fileIds: [],
|
||||
files: [],
|
||||
focusMode: '',
|
||||
hasError: false,
|
||||
isMessagesLoaded: false,
|
||||
isReady: false,
|
||||
loading: false,
|
||||
messageAppeared: false,
|
||||
messages: [],
|
||||
chatTurns: [],
|
||||
sections: [],
|
||||
notFound: false,
|
||||
optimizationMode: '',
|
||||
rewrite: () => {},
|
||||
sendMessage: async () => {},
|
||||
setFileIds: () => {},
|
||||
setFiles: () => {},
|
||||
setFocusMode: () => {},
|
||||
setOptimizationMode: () => {},
|
||||
});
|
||||
|
||||
export const ChatProvider = ({
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const initialMessage = searchParams.get('q');
|
||||
|
||||
const [chatId, setChatId] = useState<string | undefined>(id);
|
||||
const [newChatCreated, setNewChatCreated] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messageAppeared, setMessageAppeared] = useState(false);
|
||||
|
||||
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||
|
||||
const [focusMode, setFocusMode] = useState('webSearch');
|
||||
const [optimizationMode, setOptimizationMode] = useState('speed');
|
||||
|
||||
const [isMessagesLoaded, setIsMessagesLoaded] = useState(false);
|
||||
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const [chatModelProvider, setChatModelProvider] = useState<ChatModelProvider>(
|
||||
{
|
||||
key: '',
|
||||
providerId: '',
|
||||
},
|
||||
);
|
||||
|
||||
const [embeddingModelProvider, setEmbeddingModelProvider] =
|
||||
useState<EmbeddingModelProvider>({
|
||||
key: '',
|
||||
providerId: '',
|
||||
});
|
||||
|
||||
const [isConfigReady, setIsConfigReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
|
||||
const chatTurns = useMemo((): ChatTurn[] => {
|
||||
return messages.filter(
|
||||
(msg): msg is ChatTurn => msg.role === 'user' || msg.role === 'assistant',
|
||||
);
|
||||
}, [messages]);
|
||||
|
||||
const sections = useMemo<Section[]>(() => {
|
||||
const sections: Section[] = [];
|
||||
|
||||
messages.forEach((msg, i) => {
|
||||
if (msg.role === 'user') {
|
||||
const nextUserMessageIndex = messages.findIndex(
|
||||
(m, j) => j > i && m.role === 'user',
|
||||
);
|
||||
|
||||
const aiMessage = messages.find(
|
||||
(m, j) =>
|
||||
j > i &&
|
||||
m.role === 'assistant' &&
|
||||
(nextUserMessageIndex === -1 || j < nextUserMessageIndex),
|
||||
) as AssistantMessage | undefined;
|
||||
|
||||
const sourceMessage = messages.find(
|
||||
(m, j) =>
|
||||
j > i &&
|
||||
m.role === 'source' &&
|
||||
m.sources &&
|
||||
(nextUserMessageIndex === -1 || j < nextUserMessageIndex),
|
||||
) as SourceMessage | undefined;
|
||||
|
||||
let thinkingEnded = false;
|
||||
let processedMessage = aiMessage?.content ?? '';
|
||||
let speechMessage = aiMessage?.content ?? '';
|
||||
let suggestions: string[] = [];
|
||||
|
||||
if (aiMessage) {
|
||||
const citationRegex = /\[([^\]]+)\]/g;
|
||||
const regex = /\[(\d+)\]/g;
|
||||
|
||||
if (processedMessage.includes('<think>')) {
|
||||
const openThinkTag =
|
||||
processedMessage.match(/<think>/g)?.length || 0;
|
||||
const closeThinkTag =
|
||||
processedMessage.match(/<\/think>/g)?.length || 0;
|
||||
|
||||
if (openThinkTag && !closeThinkTag) {
|
||||
processedMessage += '</think> <a> </a>';
|
||||
}
|
||||
}
|
||||
|
||||
if (aiMessage.content.includes('</think>')) {
|
||||
thinkingEnded = true;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceMessage &&
|
||||
sourceMessage.sources &&
|
||||
sourceMessage.sources.length > 0
|
||||
) {
|
||||
processedMessage = processedMessage.replace(
|
||||
citationRegex,
|
||||
(_, capturedContent: string) => {
|
||||
const numbers = capturedContent
|
||||
.split(',')
|
||||
.map((numStr) => numStr.trim());
|
||||
|
||||
const linksHtml = numbers
|
||||
.map((numStr) => {
|
||||
const number = parseInt(numStr);
|
||||
|
||||
if (isNaN(number) || number <= 0) {
|
||||
return `[${numStr}]`;
|
||||
}
|
||||
|
||||
const source = sourceMessage.sources?.[number - 1];
|
||||
const url = source?.metadata?.url;
|
||||
|
||||
if (url) {
|
||||
return `<citation href="${url}">${numStr}</citation>`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
|
||||
return linksHtml;
|
||||
},
|
||||
);
|
||||
speechMessage = aiMessage.content.replace(regex, '');
|
||||
} else {
|
||||
processedMessage = processedMessage.replace(regex, '');
|
||||
speechMessage = aiMessage.content.replace(regex, '');
|
||||
}
|
||||
|
||||
const suggestionMessage = messages.find(
|
||||
(m, j) =>
|
||||
j > i &&
|
||||
m.role === 'suggestion' &&
|
||||
(nextUserMessageIndex === -1 || j < nextUserMessageIndex),
|
||||
) as SuggestionMessage | undefined;
|
||||
|
||||
if (suggestionMessage && suggestionMessage.suggestions.length > 0) {
|
||||
suggestions = suggestionMessage.suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
sections.push({
|
||||
userMessage: msg,
|
||||
assistantMessage: aiMessage,
|
||||
sourceMessage: sourceMessage,
|
||||
parsedAssistantMessage: processedMessage,
|
||||
speechMessage,
|
||||
thinkingEnded,
|
||||
suggestions: suggestions,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sections;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
checkConfig(
|
||||
setChatModelProvider,
|
||||
setEmbeddingModelProvider,
|
||||
setIsConfigReady,
|
||||
setHasError,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
chatId &&
|
||||
!newChatCreated &&
|
||||
!isMessagesLoaded &&
|
||||
messages.length === 0
|
||||
) {
|
||||
loadMessages(
|
||||
chatId,
|
||||
setMessages,
|
||||
setIsMessagesLoaded,
|
||||
setChatHistory,
|
||||
setFocusMode,
|
||||
setNotFound,
|
||||
setFiles,
|
||||
setFileIds,
|
||||
);
|
||||
} else if (!chatId) {
|
||||
setNewChatCreated(true);
|
||||
setIsMessagesLoaded(true);
|
||||
setChatId(crypto.randomBytes(20).toString('hex'));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMessagesLoaded && isConfigReady) {
|
||||
setIsReady(true);
|
||||
console.debug(new Date(), 'app:ready');
|
||||
} else {
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [isMessagesLoaded, isConfigReady]);
|
||||
|
||||
const rewrite = (messageId: string) => {
|
||||
const index = messages.findIndex((msg) => msg.messageId === messageId);
|
||||
const chatTurnsIndex = chatTurns.findIndex(
|
||||
(msg) => msg.messageId === messageId,
|
||||
);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
const message = chatTurns[chatTurnsIndex - 1];
|
||||
|
||||
setMessages((prev) => {
|
||||
return [
|
||||
...prev.slice(0, messages.length > 2 ? messages.indexOf(message) : 0),
|
||||
];
|
||||
});
|
||||
setChatHistory((prev) => {
|
||||
return [...prev.slice(0, chatTurns.length > 2 ? chatTurnsIndex - 1 : 0)];
|
||||
});
|
||||
|
||||
sendMessage(message.content, message.messageId, true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && initialMessage && isConfigReady) {
|
||||
if (!isConfigReady) {
|
||||
toast.error('Cannot send message before the configuration is ready');
|
||||
return;
|
||||
}
|
||||
sendMessage(initialMessage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConfigReady, isReady, initialMessage]);
|
||||
|
||||
const sendMessage: ChatContext['sendMessage'] = async (
|
||||
message,
|
||||
messageId,
|
||||
rewrite = false,
|
||||
) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setMessageAppeared(false);
|
||||
|
||||
if (messages.length <= 1) {
|
||||
window.history.replaceState(null, '', `/c/${chatId}`);
|
||||
}
|
||||
|
||||
let recievedMessage = '';
|
||||
let added = false;
|
||||
|
||||
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{
|
||||
content: message,
|
||||
messageId: messageId,
|
||||
chatId: chatId!,
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const messageHandler = async (data: any) => {
|
||||
if (data.type === 'error') {
|
||||
toast.error(data.data);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'sources') {
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{
|
||||
messageId: data.messageId,
|
||||
chatId: chatId!,
|
||||
role: 'source',
|
||||
sources: data.data,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
if (data.data.length > 0) {
|
||||
setMessageAppeared(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'message') {
|
||||
if (!added) {
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
{
|
||||
content: data.data,
|
||||
messageId: data.messageId,
|
||||
chatId: chatId!,
|
||||
role: 'assistant',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
added = true;
|
||||
setMessageAppeared(true);
|
||||
} else {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (
|
||||
message.messageId === data.messageId &&
|
||||
message.role === 'assistant'
|
||||
) {
|
||||
return { ...message, content: message.content + data.data };
|
||||
}
|
||||
|
||||
return message;
|
||||
}),
|
||||
);
|
||||
}
|
||||
recievedMessage += data.data;
|
||||
}
|
||||
|
||||
if (data.type === 'messageEnd') {
|
||||
setChatHistory((prevHistory) => [
|
||||
...prevHistory,
|
||||
['human', message],
|
||||
['assistant', recievedMessage],
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
|
||||
|
||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
||||
|
||||
if (autoImageSearch === 'true') {
|
||||
document
|
||||
.getElementById(`search-images-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
}
|
||||
|
||||
if (autoVideoSearch === 'true') {
|
||||
document
|
||||
.getElementById(`search-videos-${lastMsg.messageId}`)
|
||||
?.click();
|
||||
}
|
||||
|
||||
/* Check if there are sources after message id's index and no suggestions */
|
||||
|
||||
const userMessageIndex = messagesRef.current.findIndex(
|
||||
(msg) => msg.messageId === messageId && msg.role === 'user',
|
||||
);
|
||||
|
||||
const sourceMessage = messagesRef.current.find(
|
||||
(msg, i) => i > userMessageIndex && msg.role === 'source',
|
||||
) as SourceMessage | undefined;
|
||||
|
||||
const suggestionMessageIndex = messagesRef.current.findIndex(
|
||||
(msg, i) => i > userMessageIndex && msg.role === 'suggestion',
|
||||
);
|
||||
|
||||
if (
|
||||
sourceMessage &&
|
||||
sourceMessage.sources.length > 0 &&
|
||||
suggestionMessageIndex == -1
|
||||
) {
|
||||
const suggestions = await getSuggestions(messagesRef.current);
|
||||
setMessages((prev) => {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
role: 'suggestion',
|
||||
suggestions: suggestions,
|
||||
chatId: chatId!,
|
||||
createdAt: new Date(),
|
||||
messageId: crypto.randomBytes(7).toString('hex'),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const messageIndex = messages.findIndex((m) => m.messageId === messageId);
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: message,
|
||||
message: {
|
||||
messageId: messageId,
|
||||
chatId: chatId!,
|
||||
content: message,
|
||||
},
|
||||
chatId: chatId!,
|
||||
files: fileIds,
|
||||
focusMode: focusMode,
|
||||
optimizationMode: optimizationMode,
|
||||
history: rewrite
|
||||
? chatHistory.slice(0, messageIndex === -1 ? undefined : messageIndex)
|
||||
: chatHistory,
|
||||
chatModel: {
|
||||
key: chatModelProvider.key,
|
||||
providerId: chatModelProvider.providerId,
|
||||
},
|
||||
embeddingModel: {
|
||||
key: embeddingModelProvider.key,
|
||||
providerId: embeddingModelProvider.providerId,
|
||||
},
|
||||
systemInstructions: localStorage.getItem('systemInstructions'),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No response body');
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
let partialChunk = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
partialChunk += decoder.decode(value, { stream: true });
|
||||
|
||||
try {
|
||||
const messages = partialChunk.split('\n');
|
||||
for (const msg of messages) {
|
||||
if (!msg.trim()) continue;
|
||||
const json = JSON.parse(msg);
|
||||
messageHandler(json);
|
||||
}
|
||||
partialChunk = '';
|
||||
} catch (error) {
|
||||
console.warn('Incomplete JSON, waiting for next chunk...');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<chatContext.Provider
|
||||
value={{
|
||||
messages,
|
||||
chatTurns,
|
||||
sections,
|
||||
chatHistory,
|
||||
files,
|
||||
fileIds,
|
||||
focusMode,
|
||||
chatId,
|
||||
hasError,
|
||||
isMessagesLoaded,
|
||||
isReady,
|
||||
loading,
|
||||
messageAppeared,
|
||||
notFound,
|
||||
optimizationMode,
|
||||
setFileIds,
|
||||
setFiles,
|
||||
setFocusMode,
|
||||
setOptimizationMode,
|
||||
rewrite,
|
||||
sendMessage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</chatContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useChat = () => {
|
||||
const ctx = useContext(chatContext);
|
||||
return ctx;
|
||||
};
|
45
src/lib/models/providers/baseProvider.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { Model, ModelList, ProviderMetadata } from '../types';
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
|
||||
abstract class BaseModelProvider<CONFIG> {
|
||||
constructor(
|
||||
protected id: string,
|
||||
protected name: string,
|
||||
protected config: CONFIG,
|
||||
) {}
|
||||
abstract getDefaultModels(): Promise<ModelList>;
|
||||
abstract getModelList(): Promise<ModelList>;
|
||||
abstract loadChatModel(modelName: string): Promise<BaseChatModel>;
|
||||
abstract loadEmbeddingModel(modelName: string): Promise<Embeddings>;
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
throw new Error('Method not Implemented.');
|
||||
}
|
||||
static parseAndValidate(raw: any): any {
|
||||
/* Static methods can't access class type parameters */
|
||||
throw new Error('Method not Implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderConstructor<CONFIG> = {
|
||||
new (id: string, name: string, config: CONFIG): BaseModelProvider<CONFIG>;
|
||||
parseAndValidate(raw: any): CONFIG;
|
||||
getProviderConfigFields: () => UIConfigField[];
|
||||
getProviderMetadata: () => ProviderMetadata;
|
||||
};
|
||||
|
||||
export const createProviderInstance = <P extends ProviderConstructor<any>>(
|
||||
Provider: P,
|
||||
id: string,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): InstanceType<P> => {
|
||||
const cfg = Provider.parseAndValidate(rawConfig);
|
||||
return new Provider(id, name, cfg) as InstanceType<P>;
|
||||
};
|
||||
|
||||
export default BaseModelProvider;
|
23
src/lib/models/providers/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ModelProviderUISection } from '@/lib/config/types';
|
||||
import { ProviderConstructor } from './baseProvider';
|
||||
import OpenAIProvider from './openai';
|
||||
import OllamaProvider from './ollama';
|
||||
|
||||
export const providers: Record<string, ProviderConstructor<any>> = {
|
||||
openai: OpenAIProvider,
|
||||
ollama: OllamaProvider,
|
||||
};
|
||||
|
||||
export const getModelProvidersUIConfigSection =
|
||||
(): ModelProviderUISection[] => {
|
||||
return Object.entries(providers).map(([k, p]) => {
|
||||
const configFields = p.getProviderConfigFields();
|
||||
const metadata = p.getProviderMetadata();
|
||||
|
||||
return {
|
||||
fields: configFields,
|
||||
key: k,
|
||||
name: metadata.name,
|
||||
};
|
||||
});
|
||||
};
|
137
src/lib/models/providers/ollama.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { Model, ModelList, ProviderMetadata } from '../types';
|
||||
import BaseModelProvider from './baseProvider';
|
||||
import { ChatOllama, OllamaEmbeddings } from '@langchain/ollama';
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
|
||||
interface OllamaConfig {
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'Base URL',
|
||||
key: 'baseURL',
|
||||
description: 'The base URL for the Ollama',
|
||||
required: true,
|
||||
placeholder: 'Ollama Base URL',
|
||||
default: process.env.DOCKER
|
||||
? 'http://host.docker.internal:11434'
|
||||
: 'http://localhost:11434',
|
||||
env: 'OLLAMA_BASE_URL',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class OllamaProvider extends BaseModelProvider<OllamaConfig> {
|
||||
constructor(id: string, name: string, config: OllamaConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
try {
|
||||
const res = await fetch(`${this.config.baseURL}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const models: Model[] = data.models.map((m: any) => {
|
||||
return {
|
||||
name: m.name,
|
||||
key: m.model,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
embedding: models,
|
||||
chat: models,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
throw new Error(
|
||||
'Error connecting to Ollama API. Please ensure the base URL is correct and the Ollama server is running.',
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseChatModel> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Ollama Chat Model. Invalid Model Selected',
|
||||
);
|
||||
}
|
||||
|
||||
return new ChatOllama({
|
||||
temperature: 0.7,
|
||||
model: key,
|
||||
baseUrl: this.config.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<Embeddings> {
|
||||
const modelList = await this.getModelList();
|
||||
const exists = modelList.embedding.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading Ollama Embedding Model. Invalid Model Selected.',
|
||||
);
|
||||
}
|
||||
|
||||
return new OllamaEmbeddings({
|
||||
model: key,
|
||||
baseUrl: this.config.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): OllamaConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.baseURL)
|
||||
throw new Error('Invalid config provided. Base URL must be provided');
|
||||
|
||||
return {
|
||||
baseURL: String(raw.baseURL),
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'ollama',
|
||||
name: 'Ollama',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OllamaProvider;
|
214
src/lib/models/providers/openai.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { Model, ModelList, ProviderMetadata } from '../types';
|
||||
import BaseModelProvider from './baseProvider';
|
||||
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
import { UIConfigField } from '@/lib/config/types';
|
||||
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
|
||||
|
||||
interface OpenAIConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
const defaultChatModels: Model[] = [
|
||||
{
|
||||
name: 'GPT-3.5 Turbo',
|
||||
key: 'gpt-3.5-turbo',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4',
|
||||
key: 'gpt-4',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4 turbo',
|
||||
key: 'gpt-4-turbo',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4 omni',
|
||||
key: 'gpt-4o',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o (2024-05-13)',
|
||||
key: 'gpt-4o-2024-05-13',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4 omni mini',
|
||||
key: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1 nano',
|
||||
key: 'gpt-4.1-nano',
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1 mini',
|
||||
key: 'gpt-4.1-mini',
|
||||
},
|
||||
{
|
||||
name: 'GPT 4.1',
|
||||
key: 'gpt-4.1',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5 nano',
|
||||
key: 'gpt-5-nano',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5',
|
||||
key: 'gpt-5',
|
||||
},
|
||||
{
|
||||
name: 'GPT 5 Mini',
|
||||
key: 'gpt-5-mini',
|
||||
},
|
||||
{
|
||||
name: 'o1',
|
||||
key: 'o1',
|
||||
},
|
||||
{
|
||||
name: 'o3',
|
||||
key: 'o3',
|
||||
},
|
||||
{
|
||||
name: 'o3 Mini',
|
||||
key: 'o3-mini',
|
||||
},
|
||||
{
|
||||
name: 'o4 Mini',
|
||||
key: 'o4-mini',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultEmbeddingModels: Model[] = [
|
||||
{
|
||||
name: 'Text Embedding 3 Small',
|
||||
key: 'text-embedding-3-small',
|
||||
},
|
||||
{
|
||||
name: 'Text Embedding 3 Large',
|
||||
key: 'text-embedding-3-large',
|
||||
},
|
||||
];
|
||||
|
||||
const providerConfigFields: UIConfigField[] = [
|
||||
{
|
||||
type: 'password',
|
||||
name: 'API Key',
|
||||
key: 'apiKey',
|
||||
description: 'Your OpenAI API key',
|
||||
required: true,
|
||||
placeholder: 'OpenAI API Key',
|
||||
env: 'OPENAI_API_KEY',
|
||||
scope: 'server',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'Base URL',
|
||||
key: 'baseURL',
|
||||
description: 'The base URL for the OpenAI API',
|
||||
required: true,
|
||||
placeholder: 'OpenAI Base URL',
|
||||
default: 'https://api.openai.com/v1',
|
||||
env: 'OPENAI_BASE_URL',
|
||||
scope: 'server',
|
||||
},
|
||||
];
|
||||
|
||||
class OpenAIProvider extends BaseModelProvider<OpenAIConfig> {
|
||||
constructor(id: string, name: string, config: OpenAIConfig) {
|
||||
super(id, name, config);
|
||||
}
|
||||
|
||||
async getDefaultModels(): Promise<ModelList> {
|
||||
if (this.config.baseURL === 'https://api.openai.com/v1') {
|
||||
return {
|
||||
embedding: defaultEmbeddingModels,
|
||||
chat: defaultChatModels,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
embedding: [],
|
||||
chat: [],
|
||||
};
|
||||
}
|
||||
|
||||
async getModelList(): Promise<ModelList> {
|
||||
const defaultModels = await this.getDefaultModels();
|
||||
const configProvider = getConfiguredModelProviderById(this.id)!;
|
||||
|
||||
return {
|
||||
embedding: [
|
||||
...defaultModels.embedding,
|
||||
...configProvider.embeddingModels,
|
||||
],
|
||||
chat: [...defaultModels.chat, ...configProvider.chatModels],
|
||||
};
|
||||
}
|
||||
|
||||
async loadChatModel(key: string): Promise<BaseChatModel> {
|
||||
const modelList = await this.getModelList();
|
||||
|
||||
const exists = modelList.chat.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading OpenAI Chat Model. Invalid Model Selected',
|
||||
);
|
||||
}
|
||||
|
||||
return new ChatOpenAI({
|
||||
apiKey: this.config.apiKey,
|
||||
temperature: 0.7,
|
||||
model: key,
|
||||
configuration: {
|
||||
baseURL: this.config.baseURL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(key: string): Promise<Embeddings> {
|
||||
const modelList = await this.getModelList();
|
||||
const exists = modelList.embedding.find((m) => m.key === key);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
'Error Loading OpenAI Embedding Model. Invalid Model Selected.',
|
||||
);
|
||||
}
|
||||
|
||||
return new OpenAIEmbeddings({
|
||||
apiKey: this.config.apiKey,
|
||||
model: key,
|
||||
configuration: {
|
||||
baseURL: this.config.baseURL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static parseAndValidate(raw: any): OpenAIConfig {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('Invalid config provided. Expected object');
|
||||
if (!raw.apiKey || !raw.baseURL)
|
||||
throw new Error(
|
||||
'Invalid config provided. API key and base URL must be provided',
|
||||
);
|
||||
|
||||
return {
|
||||
apiKey: String(raw.apiKey),
|
||||
baseURL: String(raw.baseURL),
|
||||
};
|
||||
}
|
||||
|
||||
static getProviderConfigFields(): UIConfigField[] {
|
||||
return providerConfigFields;
|
||||
}
|
||||
|
||||
static getProviderMetadata(): ProviderMetadata {
|
||||
return {
|
||||
key: 'openai',
|
||||
name: 'OpenAI',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenAIProvider;
|
223
src/lib/models/registry.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ConfigModelProvider } from '../config/types';
|
||||
import BaseModelProvider, {
|
||||
createProviderInstance,
|
||||
} from './providers/baseProvider';
|
||||
import { getConfiguredModelProviders } from '../config/serverRegistry';
|
||||
import { providers } from './providers';
|
||||
import { MinimalProvider, ModelList } from './types';
|
||||
import configManager from '../config';
|
||||
|
||||
class ModelRegistry {
|
||||
activeProviders: (ConfigModelProvider & {
|
||||
provider: BaseModelProvider<any>;
|
||||
})[] = [];
|
||||
|
||||
constructor() {
|
||||
this.initializeActiveProviders();
|
||||
}
|
||||
|
||||
private initializeActiveProviders() {
|
||||
const configuredProviders = getConfiguredModelProviders();
|
||||
|
||||
configuredProviders.forEach((p) => {
|
||||
try {
|
||||
const provider = providers[p.type];
|
||||
if (!provider) throw new Error('Invalid provider type');
|
||||
|
||||
this.activeProviders.push({
|
||||
...p,
|
||||
provider: createProviderInstance(provider, p.id, p.name, p.config),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to initialize provider. Type: ${p.type}, ID: ${p.id}, Config: ${JSON.stringify(p.config)}, Error: ${err}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveProviders() {
|
||||
const providers: MinimalProvider[] = [];
|
||||
|
||||
await Promise.all(
|
||||
this.activeProviders.map(async (p) => {
|
||||
let m: ModelList = { chat: [], embedding: [] };
|
||||
|
||||
try {
|
||||
m = await p.provider.getModelList();
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`Failed to get model list. Type: ${p.type}, ID: ${p.id}, Error: ${err.message}`,
|
||||
);
|
||||
|
||||
m = {
|
||||
chat: [
|
||||
{
|
||||
key: 'error',
|
||||
name: err.message,
|
||||
},
|
||||
],
|
||||
embedding: [],
|
||||
};
|
||||
}
|
||||
|
||||
providers.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
chatModels: m.chat,
|
||||
embeddingModels: m.embedding,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
async loadChatModel(providerId: string, modelName: string) {
|
||||
const provider = this.activeProviders.find((p) => p.id === providerId);
|
||||
|
||||
if (!provider) throw new Error('Invalid provider id');
|
||||
|
||||
const model = await provider.provider.loadChatModel(modelName);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
async loadEmbeddingModel(providerId: string, modelName: string) {
|
||||
const provider = this.activeProviders.find((p) => p.id === providerId);
|
||||
|
||||
if (!provider) throw new Error('Invalid provider id');
|
||||
|
||||
const model = await provider.provider.loadEmbeddingModel(modelName);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
async addProvider(
|
||||
type: string,
|
||||
name: string,
|
||||
config: Record<string, any>,
|
||||
): Promise<ConfigModelProvider> {
|
||||
const provider = providers[type];
|
||||
if (!provider) throw new Error('Invalid provider type');
|
||||
|
||||
const newProvider = configManager.addModelProvider(type, name, config);
|
||||
|
||||
const instance = createProviderInstance(
|
||||
provider,
|
||||
newProvider.id,
|
||||
newProvider.name,
|
||||
newProvider.config,
|
||||
);
|
||||
|
||||
let m: ModelList = { chat: [], embedding: [] };
|
||||
|
||||
try {
|
||||
m = await instance.getModelList();
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`Failed to get model list for newly added provider. Type: ${type}, ID: ${newProvider.id}, Error: ${err.message}`,
|
||||
);
|
||||
|
||||
m = {
|
||||
chat: [
|
||||
{
|
||||
key: 'error',
|
||||
name: err.message,
|
||||
},
|
||||
],
|
||||
embedding: [],
|
||||
};
|
||||
}
|
||||
|
||||
this.activeProviders.push({
|
||||
...newProvider,
|
||||
provider: instance,
|
||||
});
|
||||
|
||||
return {
|
||||
...newProvider,
|
||||
chatModels: m.chat || [],
|
||||
embeddingModels: m.embedding || [],
|
||||
};
|
||||
}
|
||||
|
||||
async removeProvider(providerId: string): Promise<void> {
|
||||
configManager.removeModelProvider(providerId);
|
||||
this.activeProviders = this.activeProviders.filter(
|
||||
(p) => p.id !== providerId,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async updateProvider(
|
||||
providerId: string,
|
||||
name: string,
|
||||
config: any,
|
||||
): Promise<ConfigModelProvider> {
|
||||
const updated = await configManager.updateModelProvider(
|
||||
providerId,
|
||||
name,
|
||||
config,
|
||||
);
|
||||
const instance = createProviderInstance(
|
||||
providers[updated.type],
|
||||
providerId,
|
||||
name,
|
||||
config,
|
||||
);
|
||||
|
||||
let m: ModelList = { chat: [], embedding: [] };
|
||||
|
||||
try {
|
||||
m = await instance.getModelList();
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`Failed to get model list for updated provider. Type: ${updated.type}, ID: ${updated.id}, Error: ${err.message}`,
|
||||
);
|
||||
|
||||
m = {
|
||||
chat: [
|
||||
{
|
||||
key: 'error',
|
||||
name: err.message,
|
||||
},
|
||||
],
|
||||
embedding: [],
|
||||
};
|
||||
}
|
||||
|
||||
this.activeProviders.push({
|
||||
...updated,
|
||||
provider: instance,
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
chatModels: m.chat || [],
|
||||
embeddingModels: m.embedding || [],
|
||||
};
|
||||
}
|
||||
|
||||
/* Using async here because maybe in the future we might want to add some validation?? */
|
||||
async addProviderModel(
|
||||
providerId: string,
|
||||
type: 'embedding' | 'chat',
|
||||
model: any,
|
||||
): Promise<any> {
|
||||
const addedModel = configManager.addProviderModel(providerId, type, model);
|
||||
return addedModel;
|
||||
}
|
||||
|
||||
async removeProviderModel(
|
||||
providerId: string,
|
||||
type: 'embedding' | 'chat',
|
||||
modelKey: string,
|
||||
): Promise<void> {
|
||||
configManager.removeProviderModel(providerId, type, modelKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export default ModelRegistry;
|
34
src/lib/models/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type Model = {
|
||||
name: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type ModelList = {
|
||||
embedding: Model[];
|
||||
chat: Model[];
|
||||
};
|
||||
|
||||
type ProviderMetadata = {
|
||||
name: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type MinimalProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
chatModels: Model[];
|
||||
embeddingModels: Model[];
|
||||
};
|
||||
|
||||
type ModelWithProvider = {
|
||||
key: string;
|
||||
providerId: string;
|
||||
};
|
||||
|
||||
export type {
|
||||
Model,
|
||||
ModelList,
|
||||
ProviderMetadata,
|
||||
MinimalProvider,
|
||||
ModelWithProvider,
|
||||
};
|
@@ -4,7 +4,7 @@ interface LineOutputParserArgs {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
class LineOutputParser extends BaseOutputParser<string> {
|
||||
class LineOutputParser extends BaseOutputParser<string | undefined> {
|
||||
private key = 'questions';
|
||||
|
||||
constructor(args?: LineOutputParserArgs) {
|
||||
@@ -18,7 +18,7 @@ class LineOutputParser extends BaseOutputParser<string> {
|
||||
|
||||
lc_namespace = ['langchain', 'output_parsers', 'line_output_parser'];
|
||||
|
||||
async parse(text: string): Promise<string> {
|
||||
async parse(text: string): Promise<string | undefined> {
|
||||
text = text.trim() || '';
|
||||
|
||||
const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/;
|
||||
@@ -26,7 +26,7 @@ class LineOutputParser extends BaseOutputParser<string> {
|
||||
const endKeyIndex = text.indexOf(`</${this.key}>`);
|
||||
|
||||
if (startKeyIndex === -1 || endKeyIndex === -1) {
|
||||
return '';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const questionsStartIndex =
|
||||
|
@@ -1,69 +0,0 @@
|
||||
export const academicSearchRetrieverPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
|
||||
If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
|
||||
|
||||
Example:
|
||||
1. Follow up question: How does stable diffusion work?
|
||||
Rephrased: Stable diffusion working
|
||||
|
||||
2. Follow up question: What is linear algebra?
|
||||
Rephrased: Linear algebra
|
||||
|
||||
3. Follow up question: What is the third law of thermodynamics?
|
||||
Rephrased: Third law of thermodynamics
|
||||
|
||||
Conversation:
|
||||
{chat_history}
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
`;
|
||||
|
||||
export const academicSearchResponsePrompt = `
|
||||
You are Perplexica, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
|
||||
|
||||
Your task is to provide answers that are:
|
||||
- **Informative and relevant**: Thoroughly address the user's query using the given context.
|
||||
- **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically.
|
||||
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights.
|
||||
- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included.
|
||||
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable.
|
||||
|
||||
### Formatting Instructions
|
||||
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate.
|
||||
- **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience.
|
||||
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability.
|
||||
- **Length and Depth**: Provide comprehensive coverage of the topic. Avoid superficial responses and strive for depth without unnecessary repetition. Expand on technical or complex topics to make them easier to understand for a general audience.
|
||||
- **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title.
|
||||
- **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate.
|
||||
|
||||
### Citation Requirements
|
||||
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
|
||||
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
|
||||
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
|
||||
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
|
||||
- Always prioritize credibility and accuracy by linking all statements back to their respective context sources.
|
||||
- Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation.
|
||||
|
||||
### Special Instructions
|
||||
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
|
||||
- If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search.
|
||||
- If no relevant information is found, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" Be transparent about limitations and suggest alternatives or ways to reframe the query.
|
||||
- You are set on focus mode 'Academic', this means you will be searching for academic papers and articles on the web.
|
||||
|
||||
### User instructions
|
||||
These instructions are shared to you by the user and not by the system. You will have to follow them but give them less priority than the above instructions. If the user has provided specific instructions or preferences, incorporate them into your response while adhering to the overall guidelines.
|
||||
{systemInstructions}
|
||||
|
||||
### Example Output
|
||||
- Begin with a brief introduction summarizing the event or query topic.
|
||||
- Follow with detailed sections under clear headings, covering all aspects of the query if possible.
|
||||
- Provide explanations or historical context as needed to enhance understanding.
|
||||
- End with a conclusion or overall perspective if relevant.
|
||||
|
||||
<context>
|
||||
{context}
|
||||
</context>
|
||||
|
||||
Current date & time in ISO format (UTC timezone) is: {date}.
|
||||
`;
|
@@ -1,32 +1,13 @@
|
||||
import {
|
||||
academicSearchResponsePrompt,
|
||||
academicSearchRetrieverPrompt,
|
||||
} from './academicSearch';
|
||||
import {
|
||||
redditSearchResponsePrompt,
|
||||
redditSearchRetrieverPrompt,
|
||||
} from './redditSearch';
|
||||
import { webSearchResponsePrompt, webSearchRetrieverPrompt } from './webSearch';
|
||||
import {
|
||||
wolframAlphaSearchResponsePrompt,
|
||||
wolframAlphaSearchRetrieverPrompt,
|
||||
} from './wolframAlpha';
|
||||
webSearchResponsePrompt,
|
||||
webSearchRetrieverFewShots,
|
||||
webSearchRetrieverPrompt,
|
||||
} from './webSearch';
|
||||
import { writingAssistantPrompt } from './writingAssistant';
|
||||
import {
|
||||
youtubeSearchResponsePrompt,
|
||||
youtubeSearchRetrieverPrompt,
|
||||
} from './youtubeSearch';
|
||||
|
||||
export default {
|
||||
webSearchResponsePrompt,
|
||||
webSearchRetrieverPrompt,
|
||||
academicSearchResponsePrompt,
|
||||
academicSearchRetrieverPrompt,
|
||||
redditSearchResponsePrompt,
|
||||
redditSearchRetrieverPrompt,
|
||||
wolframAlphaSearchResponsePrompt,
|
||||
wolframAlphaSearchRetrieverPrompt,
|
||||
webSearchRetrieverFewShots,
|
||||
writingAssistantPrompt,
|
||||
youtubeSearchResponsePrompt,
|
||||
youtubeSearchRetrieverPrompt,
|
||||
};
|
||||
|
@@ -1,69 +0,0 @@
|
||||
export const redditSearchRetrieverPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
|
||||
If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
|
||||
|
||||
Example:
|
||||
1. Follow up question: Which company is most likely to create an AGI
|
||||
Rephrased: Which company is most likely to create an AGI
|
||||
|
||||
2. Follow up question: Is Earth flat?
|
||||
Rephrased: Is Earth flat?
|
||||
|
||||
3. Follow up question: Is there life on Mars?
|
||||
Rephrased: Is there life on Mars?
|
||||
|
||||
Conversation:
|
||||
{chat_history}
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
`;
|
||||
|
||||
export const redditSearchResponsePrompt = `
|
||||
You are Perplexica, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
|
||||
|
||||
Your task is to provide answers that are:
|
||||
- **Informative and relevant**: Thoroughly address the user's query using the given context.
|
||||
- **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically.
|
||||
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights.
|
||||
- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included.
|
||||
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable.
|
||||
|
||||
### Formatting Instructions
|
||||
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate.
|
||||
- **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience.
|
||||
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability.
|
||||
- **Length and Depth**: Provide comprehensive coverage of the topic. Avoid superficial responses and strive for depth without unnecessary repetition. Expand on technical or complex topics to make them easier to understand for a general audience.
|
||||
- **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title.
|
||||
- **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate.
|
||||
|
||||
### Citation Requirements
|
||||
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
|
||||
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
|
||||
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
|
||||
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
|
||||
- Always prioritize credibility and accuracy by linking all statements back to their respective context sources.
|
||||
- Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation.
|
||||
|
||||
### Special Instructions
|
||||
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
|
||||
- If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search.
|
||||
- If no relevant information is found, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" Be transparent about limitations and suggest alternatives or ways to reframe the query.
|
||||
- You are set on focus mode 'Reddit', this means you will be searching for information, opinions and discussions on the web using Reddit.
|
||||
|
||||
### User instructions
|
||||
These instructions are shared to you by the user and not by the system. You will have to follow them but give them less priority than the above instructions. If the user has provided specific instructions or preferences, incorporate them into your response while adhering to the overall guidelines.
|
||||
{systemInstructions}
|
||||
|
||||
### Example Output
|
||||
- Begin with a brief introduction summarizing the event or query topic.
|
||||
- Follow with detailed sections under clear headings, covering all aspects of the query if possible.
|
||||
- Provide explanations or historical context as needed to enhance understanding.
|
||||
- End with a conclusion or overall perspective if relevant.
|
||||
|
||||
<context>
|
||||
{context}
|
||||
</context>
|
||||
|
||||
Current date & time in ISO format (UTC timezone) is: {date}.
|
||||
`;
|
@@ -1,65 +1,92 @@
|
||||
import { BaseMessageLike } from '@langchain/core/messages';
|
||||
|
||||
export const webSearchRetrieverPrompt = `
|
||||
You are an AI question rephraser. You will be given a conversation and a follow-up question, you will have to rephrase the follow up question so it is a standalone question and can be used by another LLM to search the web for information to answer it.
|
||||
If it is a simple writing task or a greeting (unless the greeting contains a question after it) like Hi, Hello, How are you, etc. than a question then you need to return \`not_needed\` as the response (This is because the LLM won't need to search the web for finding information on this topic).
|
||||
If the user asks some question from some URL or wants you to summarize a PDF or a webpage (via URL) you need to return the links inside the \`links\` XML block and the question inside the \`question\` XML block. If the user wants to you to summarize the webpage or the PDF you need to return \`summarize\` inside the \`question\` XML block in place of a question and the link to summarize in the \`links\` XML block.
|
||||
You must always return the rephrased question inside the \`question\` XML block, if there are no links in the follow-up question then don't insert a \`links\` XML block in your response.
|
||||
|
||||
There are several examples attached for your reference inside the below \`examples\` XML block
|
||||
**Note**: All user messages are individual entities and should be treated as such do not mix conversations.
|
||||
`;
|
||||
|
||||
<examples>
|
||||
1. Follow up question: What is the capital of France
|
||||
Rephrased question:\`
|
||||
<question>
|
||||
export const webSearchRetrieverFewShots: BaseMessageLike[] = [
|
||||
[
|
||||
'user',
|
||||
`<conversation>
|
||||
</conversation>
|
||||
<query>
|
||||
What is the capital of France
|
||||
</query>`,
|
||||
],
|
||||
[
|
||||
'assistant',
|
||||
`<question>
|
||||
Capital of france
|
||||
</question>
|
||||
\`
|
||||
|
||||
2. Hi, how are you?
|
||||
Rephrased question\`
|
||||
<question>
|
||||
</question>`,
|
||||
],
|
||||
[
|
||||
'user',
|
||||
`<conversation>
|
||||
</conversation>
|
||||
<query>
|
||||
Hi, how are you?
|
||||
</query>`,
|
||||
],
|
||||
[
|
||||
'assistant',
|
||||
`<question>
|
||||
not_needed
|
||||
</question>
|
||||
\`
|
||||
|
||||
3. Follow up question: What is Docker?
|
||||
Rephrased question: \`
|
||||
<question>
|
||||
</question>`,
|
||||
],
|
||||
[
|
||||
'user',
|
||||
`<conversation>
|
||||
</conversation>
|
||||
<query>
|
||||
What is Docker?
|
||||
</query>`,
|
||||
],
|
||||
[
|
||||
'assistant',
|
||||
`<question>
|
||||
What is Docker
|
||||
</question>`,
|
||||
],
|
||||
[
|
||||
'user',
|
||||
`<conversation>
|
||||
</conversation>
|
||||
<query>
|
||||
Can you tell me what is X from https://example.com
|
||||
</query>`,
|
||||
],
|
||||
[
|
||||
'assistant',
|
||||
`<question>
|
||||
What is X?
|
||||
</question>
|
||||
\`
|
||||
|
||||
4. Follow up question: Can you tell me what is X from https://example.com
|
||||
Rephrased question: \`
|
||||
<question>
|
||||
Can you tell me what is X?
|
||||
</question>
|
||||
|
||||
<links>
|
||||
https://example.com
|
||||
</links>
|
||||
\`
|
||||
|
||||
5. Follow up question: Summarize the content from https://example.com
|
||||
Rephrased question: \`
|
||||
<question>
|
||||
</links>`,
|
||||
],
|
||||
[
|
||||
'user',
|
||||
`<conversation>
|
||||
</conversation>
|
||||
<query>
|
||||
Summarize the content from https://example.com
|
||||
</query>`,
|
||||
],
|
||||
[
|
||||
'assistant',
|
||||
`<question>
|
||||
summarize
|
||||
</question>
|
||||
|
||||
<links>
|
||||
https://example.com
|
||||
</links>
|
||||
\`
|
||||
</examples>
|
||||
|
||||
Anything below is the part of the actual conversation and you need to use conversation and the follow-up question to rephrase the follow-up question as a standalone question based on the guidelines shared above.
|
||||
|
||||
<conversation>
|
||||
{chat_history}
|
||||
</conversation>
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
`;
|
||||
</links>`,
|
||||
],
|
||||
];
|
||||
|
||||
export const webSearchResponsePrompt = `
|
||||
You are Perplexica, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
|
||||
|
@@ -1,69 +0,0 @@
|
||||
export const wolframAlphaSearchRetrieverPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
|
||||
If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
|
||||
|
||||
Example:
|
||||
1. Follow up question: What is the atomic radius of S?
|
||||
Rephrased: Atomic radius of S
|
||||
|
||||
2. Follow up question: What is linear algebra?
|
||||
Rephrased: Linear algebra
|
||||
|
||||
3. Follow up question: What is the third law of thermodynamics?
|
||||
Rephrased: Third law of thermodynamics
|
||||
|
||||
Conversation:
|
||||
{chat_history}
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
`;
|
||||
|
||||
export const wolframAlphaSearchResponsePrompt = `
|
||||
You are Perplexica, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
|
||||
|
||||
Your task is to provide answers that are:
|
||||
- **Informative and relevant**: Thoroughly address the user's query using the given context.
|
||||
- **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically.
|
||||
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights.
|
||||
- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included.
|
||||
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable.
|
||||
|
||||
### Formatting Instructions
|
||||
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate.
|
||||
- **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience.
|
||||
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability.
|
||||
- **Length and Depth**: Provide comprehensive coverage of the topic. Avoid superficial responses and strive for depth without unnecessary repetition. Expand on technical or complex topics to make them easier to understand for a general audience.
|
||||
- **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title.
|
||||
- **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate.
|
||||
|
||||
### Citation Requirements
|
||||
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
|
||||
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
|
||||
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
|
||||
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
|
||||
- Always prioritize credibility and accuracy by linking all statements back to their respective context sources.
|
||||
- Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation.
|
||||
|
||||
### Special Instructions
|
||||
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
|
||||
- If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search.
|
||||
- If no relevant information is found, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" Be transparent about limitations and suggest alternatives or ways to reframe the query.
|
||||
- You are set on focus mode 'Wolfram Alpha', this means you will be searching for information on the web using Wolfram Alpha. It is a computational knowledge engine that can answer factual queries and perform computations.
|
||||
|
||||
### User instructions
|
||||
These instructions are shared to you by the user and not by the system. You will have to follow them but give them less priority than the above instructions. If the user has provided specific instructions or preferences, incorporate them into your response while adhering to the overall guidelines.
|
||||
{systemInstructions}
|
||||
|
||||
### Example Output
|
||||
- Begin with a brief introduction summarizing the event or query topic.
|
||||
- Follow with detailed sections under clear headings, covering all aspects of the query if possible.
|
||||
- Provide explanations or historical context as needed to enhance understanding.
|
||||
- End with a conclusion or overall perspective if relevant.
|
||||
|
||||
<context>
|
||||
{context}
|
||||
</context>
|
||||
|
||||
Current date & time in ISO format (UTC timezone) is: {date}.
|
||||
`;
|