mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-10-19 13:58:14 +00:00
Compare commits
124 Commits
develop/fu
...
feat/confi
Author | SHA1 | Date | |
---|---|---|---|
|
8f22d9f626 | ||
|
3564bcc48c | ||
|
e471cb5da1 | ||
|
836cfb80c8 | ||
|
9d0e2e7f7c | ||
|
df1ed5f0f9 | ||
|
f55c2371fe | ||
|
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 | ||
|
65fc881356 |
Binary file not shown.
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 2.1 MiB |
36
.github/workflows/docker-build.yaml
vendored
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: |
|
||||
|
@@ -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.
|
||||
|
39
README.md
39
README.md
@@ -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,8 +88,10 @@ 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.**
|
||||
@@ -120,7 +123,17 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
|
||||
|
||||
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:
|
||||
|
||||
@@ -139,6 +152,25 @@ If you're encountering an Ollama connection error, it is likely due to the backe
|
||||
|
||||
- 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:
|
||||
@@ -163,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"]
|
@@ -15,6 +15,7 @@ services:
|
||||
context: .
|
||||
dockerfile: app.dockerfile
|
||||
environment:
|
||||
- DOCKER=true
|
||||
- SEARXNG_API_URL=http://searxng:8080
|
||||
- DATA_DIR=/home/perplexica
|
||||
ports:
|
||||
@@ -24,7 +25,6 @@ services:
|
||||
volumes:
|
||||
- backend-dbstore:/home/perplexica/data
|
||||
- uploads:/home/perplexica/uploads
|
||||
- ./config.toml:/home/perplexica/config.toml
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
|
1
drizzle/0001_wise_rockslide.sql
Normal file
1
drizzle/0001_wise_rockslide.sql
Normal file
@@ -0,0 +1 @@
|
||||
/* Do nothing */
|
125
drizzle/meta/0001_snapshot.json
Normal file
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
|
16
package.json
16
package.json
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "perplexica-frontend",
|
||||
"version": "1.11.0-rc2",
|
||||
"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.24",
|
||||
@@ -20,7 +20,6 @@
|
||||
"@langchain/core": "^0.3.66",
|
||||
"@langchain/google-genai": "^0.2.15",
|
||||
"@langchain/groq": "^0.2.3",
|
||||
"@langchain/langgraph": "^0.4.0",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@langchain/openai": "^0.6.2",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
@@ -30,8 +29,8 @@
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"clsx": "^2.1.0",
|
||||
"compute-cosine-similarity": "^1.1.0",
|
||||
"compute-dot": "^1.1.0",
|
||||
"drizzle-orm": "^0.40.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jspdf": "^3.0.1",
|
||||
"langchain": "^0.3.30",
|
||||
@@ -43,7 +42,6 @@
|
||||
"pdf-parse": "^1.1.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-text-to-speech": "^0.14.5",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"sonner": "^1.4.41",
|
||||
@@ -60,7 +58,6 @@
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"eslint": "^8",
|
||||
@@ -68,6 +65,7 @@
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "5.8.3"
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
BIN
public/fonts/pp-ed-ul.otf
Normal file
BIN
public/fonts/pp-ed-ul.otf
Normal file
Binary file not shown.
@@ -1,35 +0,0 @@
|
||||
[GENERAL]
|
||||
SIMILARITY_MEASURE = "cosine" # "cosine" or "dot"
|
||||
KEEP_ALIVE = "5m" # How long to keep Ollama models loaded into memory. (Instead of using -1 use "-1m")
|
||||
|
||||
[MODELS.OPENAI]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.GROQ]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.ANTHROPIC]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.GEMINI]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.CUSTOM_OPENAI]
|
||||
API_KEY = ""
|
||||
API_URL = ""
|
||||
MODEL_NAME = ""
|
||||
|
||||
[MODELS.OLLAMA]
|
||||
API_URL = "" # Ollama API URL - http://host.docker.internal:11434
|
||||
|
||||
[MODELS.DEEPSEEK]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.AIMLAPI]
|
||||
API_KEY = "" # Required to use AI/ML API chat and embedding models
|
||||
|
||||
[MODELS.LM_STUDIO]
|
||||
API_URL = "" # LM Studio API URL - http://host.docker.internal:1234
|
||||
|
||||
[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({
|
||||
apiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!embedding) {
|
||||
return Response.json(
|
||||
{ error: 'Invalid embedding model' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const humanMessageId =
|
||||
message.messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
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,123 +1,76 @@
|
||||
import {
|
||||
getAnthropicApiKey,
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
getGeminiApiKey,
|
||||
getGroqApiKey,
|
||||
getOllamaApiEndpoint,
|
||||
getOpenaiApiKey,
|
||||
getDeepseekApiKey,
|
||||
getAimlApiKey,
|
||||
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) => {
|
||||
type SaveConfigBody = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
try {
|
||||
const config: Record<string, any> = {};
|
||||
const values = configManager.getCurrentConfig();
|
||||
const fields = configManager.getUIConfigSections();
|
||||
|
||||
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
|
||||
getAvailableChatModelProviders(),
|
||||
getAvailableEmbeddingModelProviders(),
|
||||
]);
|
||||
const modelRegistry = new ModelRegistry();
|
||||
const modelProviders = await modelRegistry.getActiveProviders();
|
||||
|
||||
config['chatModelProviders'] = {};
|
||||
config['embeddingModelProviders'] = {};
|
||||
values.modelProviders = values.modelProviders.map(
|
||||
(mp: ConfigModelProvider) => {
|
||||
const activeProvider = modelProviders.find((p) => p.id === mp.id);
|
||||
|
||||
for (const provider in chatModelProviders) {
|
||||
config['chatModelProviders'][provider] = Object.keys(
|
||||
chatModelProviders[provider],
|
||||
).map((model) => {
|
||||
return {
|
||||
name: model,
|
||||
displayName: chatModelProviders[provider][model].displayName,
|
||||
...mp,
|
||||
chatModels: activeProvider?.chatModels ?? mp.chatModels,
|
||||
embeddingModels:
|
||||
activeProvider?.embeddingModels ?? mp.embeddingModels,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
values,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
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['aimlApiKey'] = getAimlApiKey();
|
||||
config['customOpenaiApiUrl'] = getCustomOpenaiApiUrl();
|
||||
config['customOpenaiApiKey'] = getCustomOpenaiApiKey();
|
||||
config['customOpenaiModelName'] = getCustomOpenaiModelName();
|
||||
|
||||
return Response.json({ ...config }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('An error occurred while getting config:', err);
|
||||
console.error('Error in getting config: ', err);
|
||||
return Response.json(
|
||||
{ message: 'An error occurred while getting config' },
|
||||
{ message: 'An error has occurred.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const config = await req.json();
|
||||
const body: SaveConfigBody = await req.json();
|
||||
|
||||
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,
|
||||
},
|
||||
AIMLAPI: {
|
||||
API_KEY: config.aimlApiKey,
|
||||
},
|
||||
LM_STUDIO: {
|
||||
API_URL: config.lmStudioApiUrl,
|
||||
},
|
||||
CUSTOM_OPENAI: {
|
||||
API_URL: config.customOpenaiApiUrl,
|
||||
API_KEY: config.customOpenaiApiKey,
|
||||
MODEL_NAME: config.customOpenaiModelName,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
updateConfig(updatedConfig);
|
||||
|
||||
return Response.json({ message: 'Config updated' }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('An error occurred while updating config:', err);
|
||||
if (!body.key || !body.value) {
|
||||
return Response.json(
|
||||
{ message: 'An error occurred while updating config' },
|
||||
{
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
23
src/app/api/config/setup-complete/route.ts
Normal file
23
src/app/api/config/setup-complete/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import configManager from '@/lib/config';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
configManager.markSetupComplete();
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Setup marked as complete.',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error marking setup as complete: ', err);
|
||||
return Response.json(
|
||||
{ message: 'An error has occurred.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
@@ -1,23 +1,12 @@
|
||||
import handleImageSearch from '@/lib/chains/imageSearchAgent';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { getAvailableChatModelProviders } from '@/lib/providers';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
|
||||
interface ChatModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface ImageSearchBody {
|
||||
query: string;
|
||||
chatHistory: any[];
|
||||
chatModel?: ChatModel;
|
||||
chatModel: ModelWithProvider;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -34,35 +23,12 @@ export const POST = async (req: Request) => {
|
||||
})
|
||||
.filter((msg) => msg !== undefined) as BaseMessage[];
|
||||
|
||||
const chatModelProviders = await getAvailableChatModelProviders();
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const chatModelProvider =
|
||||
chatModelProviders[
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0]
|
||||
];
|
||||
const chatModel =
|
||||
chatModelProvider[
|
||||
body.chatModel?.model || Object.keys(chatModelProvider)[0]
|
||||
];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
apiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
const llm = await registry.loadChatModel(
|
||||
body.chatModel.providerId,
|
||||
body.chatModel.key,
|
||||
);
|
||||
|
||||
const images = await handleImageSearch(
|
||||
{
|
||||
|
@@ -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
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
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
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,60 +36,16 @@ export const POST = async (req: Request) => {
|
||||
: new AIMessage({ content: msg[1] });
|
||||
});
|
||||
|
||||
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
|
||||
getAvailableChatModelProviders(),
|
||||
getAvailableEmbeddingModelProviders(),
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const [llm, embeddings] = await Promise.all([
|
||||
registry.loadChatModel(body.chatModel.providerId, body.chatModel.key),
|
||||
registry.loadEmbeddingModel(
|
||||
body.embeddingModel.providerId,
|
||||
body.embeddingModel.key,
|
||||
),
|
||||
]);
|
||||
|
||||
const chatModelProvider =
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0];
|
||||
const chatModel =
|
||||
body.chatModel?.name ||
|
||||
Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
|
||||
const embeddingModelProvider =
|
||||
body.embeddingModel?.provider || Object.keys(embeddingModelProviders)[0];
|
||||
const embeddingModel =
|
||||
body.embeddingModel?.name ||
|
||||
Object.keys(embeddingModelProviders[embeddingModelProvider])[0];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
let embeddings: Embeddings | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
modelName: body.chatModel?.name || getCustomOpenaiModelName(),
|
||||
apiKey: body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL:
|
||||
body.chatModel?.customOpenAIBaseURL || getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (
|
||||
chatModelProviders[chatModelProvider] &&
|
||||
chatModelProviders[chatModelProvider][chatModel]
|
||||
) {
|
||||
llm = chatModelProviders[chatModelProvider][chatModel]
|
||||
.model as unknown as BaseChatModel | undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
embeddingModelProviders[embeddingModelProvider] &&
|
||||
embeddingModelProviders[embeddingModelProvider][embeddingModel]
|
||||
) {
|
||||
embeddings = embeddingModelProviders[embeddingModelProvider][
|
||||
embeddingModel
|
||||
].model as Embeddings | undefined;
|
||||
}
|
||||
|
||||
if (!llm || !embeddings) {
|
||||
return Response.json(
|
||||
{ message: 'Invalid model selected' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const searchHandler: MetaSearchAgentType = searchHandlers[body.focusMode];
|
||||
|
||||
if (!searchHandler) {
|
||||
|
@@ -1,22 +1,12 @@
|
||||
import generateSuggestions from '@/lib/chains/suggestionGeneratorAgent';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { getAvailableChatModelProviders } from '@/lib/providers';
|
||||
import ModelRegistry from '@/lib/models/registry';
|
||||
import { ModelWithProvider } from '@/lib/models/types';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
|
||||
interface ChatModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface SuggestionsGenerationBody {
|
||||
chatHistory: any[];
|
||||
chatModel?: ChatModel;
|
||||
chatModel: ModelWithProvider;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -33,35 +23,12 @@ export const POST = async (req: Request) => {
|
||||
})
|
||||
.filter((msg) => msg !== undefined) as BaseMessage[];
|
||||
|
||||
const chatModelProviders = await getAvailableChatModelProviders();
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const chatModelProvider =
|
||||
chatModelProviders[
|
||||
body.chatModel?.provider || Object.keys(chatModelProviders)[0]
|
||||
];
|
||||
const chatModel =
|
||||
chatModelProvider[
|
||||
body.chatModel?.model || Object.keys(chatModelProvider)[0]
|
||||
];
|
||||
|
||||
let llm: BaseChatModel | undefined;
|
||||
|
||||
if (body.chatModel?.provider === 'custom_openai') {
|
||||
llm = new ChatOpenAI({
|
||||
apiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
const llm = await registry.loadChatModel(
|
||||
body.chatModel.providerId,
|
||||
body.chatModel.key,
|
||||
);
|
||||
|
||||
const suggestions = await generateSuggestions(
|
||||
{
|
||||
|
@@ -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({
|
||||
apiKey: getCustomOpenaiApiKey(),
|
||||
modelName: getCustomOpenaiModelName(),
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: getCustomOpenaiApiUrl(),
|
||||
},
|
||||
}) as unknown as BaseChatModel;
|
||||
} else if (chatModelProvider && chatModel) {
|
||||
llm = chatModel.model;
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
return Response.json({ error: 'Invalid chat model' }, { status: 400 });
|
||||
}
|
||||
const llm = await registry.loadChatModel(
|
||||
body.chatModel.providerId,
|
||||
body.chatModel.key,
|
||||
);
|
||||
|
||||
const videos = await handleVideoSearch(
|
||||
{
|
||||
|
@@ -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,12 +1,13 @@
|
||||
'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;
|
||||
@@ -75,14 +76,17 @@ const Page = () => {
|
||||
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>
|
||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
|
||||
{topics.map((t, i) => (
|
||||
<div
|
||||
@@ -90,8 +94,8 @@ const Page = () => {
|
||||
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-300 bg-cyan-300/30 border-cyan-300/60'
|
||||
: 'border-white/30 text-white/70 hover:text-white hover:border-white/40 hover:bg-white/5',
|
||||
? '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)}
|
||||
>
|
||||
@@ -99,6 +103,8 @@ const Page = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||
@@ -120,35 +126,142 @@ const Page = () => {
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 pt-5 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')}`
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
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: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.overflow-hidden-scrollable {
|
||||
-ms-overflow-style: none;
|
||||
@@ -10,6 +18,76 @@
|
||||
.overflow-hidden-scrollable::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #e8edf1 transparent; /* light-200 */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #e8edf1; /* light-200 */
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #d0d7de; /* light-300 */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
* {
|
||||
scrollbar-color: #21262d transparent; /* dark-200 */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #21262d; /* dark-200 */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #30363d; /* dark-300 */
|
||||
}
|
||||
}
|
||||
|
||||
:root.dark *,
|
||||
html.dark *,
|
||||
body.dark * {
|
||||
scrollbar-color: #21262d transparent; /* dark-200 */
|
||||
}
|
||||
|
||||
:root.dark *::-webkit-scrollbar-thumb,
|
||||
html.dark *::-webkit-scrollbar-thumb,
|
||||
body.dark *::-webkit-scrollbar-thumb {
|
||||
background: #21262d; /* dark-200 */
|
||||
}
|
||||
|
||||
:root.dark *::-webkit-scrollbar-thumb:hover,
|
||||
html.dark *::-webkit-scrollbar-thumb:hover,
|
||||
body.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: #30363d; /* dark-300 */
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
|
@@ -1,3 +1,5 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
import './globals.css';
|
||||
@@ -5,6 +7,8 @@ import { cn } from '@/lib/utils';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { Toaster } from 'sonner';
|
||||
import ThemeProvider from '@/components/theme/Provider';
|
||||
import configManager from '@/lib/config';
|
||||
import SetupWizard from '@/components/Setup/SetupWizard';
|
||||
|
||||
const montserrat = Montserrat({
|
||||
weight: ['300', '400', '500', '700'],
|
||||
@@ -24,20 +28,29 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const setupComplete = configManager.isSetupComplete();
|
||||
const configSections = configManager.getUIConfigSections();
|
||||
|
||||
return (
|
||||
<html className="h-full" lang="en" suppressHydrationWarning>
|
||||
<body className={cn('h-full', montserrat.className)}>
|
||||
<ThemeProvider>
|
||||
{setupComplete ? (
|
||||
<>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast:
|
||||
'bg-light-primary dark:bg-dark-secondary dark:text-white/70 text-black-70 rounded-lg p-4 flex flex-row items-center space-x-2',
|
||||
'bg-light-secondary dark:bg-dark-secondary dark:text-white/70 text-black-70 rounded-lg p-4 flex flex-row items-center space-x-2',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SetupWizard configSections={configSections} />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -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>
|
||||
<ChatProvider>
|
||||
<ChatWindow />
|
||||
</ChatProvider>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,943 +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;
|
||||
aimlApiKey: 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 [measureUnit, setMeasureUnit] = useState<'Imperial' | 'Metric'>(
|
||||
'Metric',
|
||||
);
|
||||
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')!);
|
||||
|
||||
setMeasureUnit(
|
||||
localStorage.getItem('measureUnit')! as 'Imperial' | 'Metric',
|
||||
);
|
||||
|
||||
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);
|
||||
} else if (key === 'measureUnit') {
|
||||
localStorage.setItem('measureUnit', value.toString());
|
||||
}
|
||||
} 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="Preferences">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Theme
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Measurement Units
|
||||
</p>
|
||||
<Select
|
||||
value={measureUnit ?? undefined}
|
||||
onChange={(e) => {
|
||||
setMeasureUnit(e.target.value as 'Imperial' | 'Metric');
|
||||
saveConfig('measureUnit', e.target.value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: 'Metric',
|
||||
value: 'Metric',
|
||||
},
|
||||
{
|
||||
label: 'Imperial',
|
||||
value: 'Imperial',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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 ?? undefined}
|
||||
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">
|
||||
AI/ML API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="AI/ML API Key"
|
||||
value={config.aimlApiKey}
|
||||
isSaving={savingStates['aimlApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
aimlApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('aimlApiKey', 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,557 +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;
|
||||
const chatModelProvidersKeys = Object.keys(chatModelProviders);
|
||||
|
||||
if (!chatModelProviders || chatModelProvidersKeys.length === 0) {
|
||||
return toast.error('No chat models available');
|
||||
} else {
|
||||
chatModelProvider =
|
||||
chatModelProvidersKeys.find(
|
||||
(provider) =>
|
||||
Object.keys(chatModelProviders[provider]).length > 0,
|
||||
) || chatModelProvidersKeys[0];
|
||||
}
|
||||
|
||||
if (
|
||||
chatModelProvider === 'custom_openai' &&
|
||||
Object.keys(chatModelProviders[chatModelProvider]).length === 0
|
||||
) {
|
||||
toast.error(
|
||||
"Looks like you haven't configured any chat model providers. Please configure them from the settings page or the config file.",
|
||||
);
|
||||
return setHasError(true);
|
||||
}
|
||||
|
||||
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
}
|
||||
|
||||
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] ||
|
||||
Object.keys(chatModelProviders[chatModelProvider]).length === 0)
|
||||
) {
|
||||
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]
|
||||
) {
|
||||
if (
|
||||
chatModelProvider === 'custom_openai' &&
|
||||
Object.keys(chatModelProviders[chatModelProvider]).length === 0
|
||||
) {
|
||||
toast.error(
|
||||
"Looks like you haven't configured any chat model providers. Please configure them from the settings page or the config file.",
|
||||
);
|
||||
return setHasError(true);
|
||||
}
|
||||
|
||||
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,
|
||||
rewrite = false,
|
||||
) => {
|
||||
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);
|
||||
} else {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (message.messageId === data.messageId) {
|
||||
return { ...message, sources: sources };
|
||||
}
|
||||
|
||||
return message;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
} else {
|
||||
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 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: {
|
||||
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, true);
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -604,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
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
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
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;
|
@@ -5,27 +5,7 @@ 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">
|
||||
@@ -38,17 +18,7 @@ const EmptyChat = ({
|
||||
<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}
|
||||
/>
|
||||
<EmptyChatMessageInput />
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-4 mt-2 sm:flex-row sm:justify-center">
|
||||
<div className="flex-1 w-full">
|
||||
|
@@ -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
|
||||
/>
|
||||
<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>
|
||||
<div className="flex flex-row items-center space-x-1 sm:space-x-4">
|
||||
<Optimization
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
/>
|
||||
<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,8 +19,8 @@ import SearchImages from './SearchImages';
|
||||
import SearchVideos from './SearchVideos';
|
||||
import { useSpeech } from 'react-text-to-speech';
|
||||
import ThinkBox from './ThinkBox';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { SyntaxHighlightedCode } from './SyntaxHighlightedCode';
|
||||
import { useChat, Section } from '@/lib/hooks/useChat';
|
||||
import Citation from './Citation';
|
||||
|
||||
const ThinkTagProcessor = ({
|
||||
children,
|
||||
@@ -36,97 +35,21 @@ const ThinkTagProcessor = ({
|
||||
};
|
||||
|
||||
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 [thinkingEnded, setThinkingEnded] = useState(false);
|
||||
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.content.includes('</think>')) {
|
||||
setThinkingEnded(true);
|
||||
}
|
||||
|
||||
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 ``;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
|
||||
return linksHtml;
|
||||
},
|
||||
),
|
||||
);
|
||||
setSpeechMessage(message.content.replace(regex, ''));
|
||||
return;
|
||||
} else if (
|
||||
message.role === 'assistant' &&
|
||||
message?.sources &&
|
||||
message.sources.length === 0
|
||||
) {
|
||||
setParsedMessage(processedMessage.replace(regex, ''));
|
||||
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 });
|
||||
|
||||
@@ -138,35 +61,27 @@ const MessageBox = ({
|
||||
thinkingEnded: thinkingEnded,
|
||||
},
|
||||
},
|
||||
code: {
|
||||
component: SyntaxHighlightedCode,
|
||||
citation: {
|
||||
component: Citation,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{message.role === 'user' && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
messageIndex === 0 ? 'pt-16' : 'pt-8',
|
||||
'break-words',
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
{message.content}
|
||||
{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 && (
|
||||
{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} />
|
||||
@@ -174,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">
|
||||
{section.sourceMessage && (
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Disc3
|
||||
className={cn(
|
||||
@@ -190,7 +107,10 @@ const MessageBox = ({
|
||||
Answer
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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]',
|
||||
@@ -200,16 +120,20 @@ const MessageBox = ({
|
||||
>
|
||||
{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} />
|
||||
<Rewrite
|
||||
rewrite={rewrite}
|
||||
messageId={section.assistantMessage.messageId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<Copy initialMessage={message.content} message={message} />
|
||||
<Copy
|
||||
initialMessage={section.assistantMessage.content}
|
||||
section={section}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (speechStatus === 'started') {
|
||||
@@ -229,62 +153,70 @@ const MessageBox = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLast &&
|
||||
message.suggestions &&
|
||||
message.suggestions.length > 0 &&
|
||||
message.role === 'assistant' &&
|
||||
section.suggestions &&
|
||||
section.suggestions.length > 0 &&
|
||||
section.assistantMessage &&
|
||||
!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>
|
||||
<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="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="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]">
|
||||
<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={20}
|
||||
className="text-[#24A0ED] flex-shrink-0"
|
||||
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>
|
||||
|
||||
{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={history[messageIndex - 1].content}
|
||||
chatHistory={history.slice(0, messageIndex - 1)}
|
||||
messageId={message.messageId}
|
||||
query={section.userMessage.content}
|
||||
chatHistory={chatTurns.slice(0, sectionIndex * 2)}
|
||||
messageId={section.assistantMessage.messageId}
|
||||
/>
|
||||
<SearchVideos
|
||||
chatHistory={history.slice(0, messageIndex - 1)}
|
||||
query={history[messageIndex - 1].content}
|
||||
messageId={message.messageId}
|
||||
chatHistory={chatTurns.slice(0, sectionIndex * 2)}
|
||||
query={section.userMessage.content}
|
||||
messageId={section.assistantMessage.messageId}
|
||||
/>
|
||||
</div>
|
||||
</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
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-xs 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,31 +35,29 @@ 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">
|
||||
{({ 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"
|
||||
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
|
||||
}
|
||||
<p className="text-xs font-medium">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.title
|
||||
}
|
||||
</p>
|
||||
<ChevronDown size={20} />
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
open ? 'rotate-180' : 'rotate-0',
|
||||
'transition duration:200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
@@ -70,7 +69,7 @@ const Optimization = ({
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] right-0">
|
||||
<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
|
||||
@@ -78,7 +77,7 @@ const Optimization = ({
|
||||
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',
|
||||
'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',
|
||||
@@ -97,6 +96,8 @@ const Optimization = ({
|
||||
</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) => {
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (section.userMessage) {
|
||||
md += `\n---\n`;
|
||||
md += `**${msg.role === 'user' ? '🧑 User' : '🤖 Assistant'}**
|
||||
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,30 +84,81 @@ const exportAsPDF = (messages: Message[], title: string) => {
|
||||
doc.line(10, y, 200, y);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
messages.forEach((msg, idx) => {
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (section.userMessage) {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${msg.role === 'user' ? 'User' : 'Assistant'}`, 10, y);
|
||||
doc.text('User', 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(120);
|
||||
doc.text(`${new Date(msg.createdAt).toLocaleString()}`, 40, y);
|
||||
doc.text(
|
||||
`${new Date(section.userMessage.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++) {
|
||||
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(lines[i], 12, y);
|
||||
doc.text(userLines[i], 12, y);
|
||||
y += 6;
|
||||
}
|
||||
if (msg.sources && msg.sources.length > 0) {
|
||||
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) {
|
||||
@@ -95,7 +167,7 @@ const exportAsPDF = (messages: Message[], title: string) => {
|
||||
}
|
||||
doc.text('Citations:', 12, y);
|
||||
y += 5;
|
||||
msg.sources.forEach((src: any, i: number) => {
|
||||
section.sourceMessage.sources.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
if (y > pageHeight - 15) {
|
||||
doc.addPage();
|
||||
@@ -114,41 +186,38 @@ const exportAsPDF = (messages: Message[], title: string) => {
|
||||
}
|
||||
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">
|
||||
<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="active:scale-95 transition duration-100 cursor-pointer lg:hidden"
|
||||
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={17} />
|
||||
<Edit size={18} className="text-black/70 dark:text-white/70" />
|
||||
</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 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>
|
||||
<p className="hidden lg:flex">{title}</p>
|
||||
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<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="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 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-100"
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-75"
|
||||
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 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">
|
||||
<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="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 || '')}
|
||||
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={17} className="text-[#24A0ED]" />
|
||||
Export as Markdown
|
||||
<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="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 || '')}
|
||||
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={17} className="text-[#24A0ED]" />
|
||||
Export as PDF
|
||||
<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={() => {}} />
|
||||
<DeleteChat
|
||||
redirect
|
||||
chatId={chatId!}
|
||||
chats={[]}
|
||||
setChats={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -27,26 +27,25 @@ 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="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"
|
||||
>
|
||||
<div className="relative w-24 min-w-24 max-w-24 h-full overflow-hidden">
|
||||
<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"
|
||||
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 +
|
||||
@@ -54,11 +53,12 @@ const NewsArticleWidget = () => {
|
||||
}
|
||||
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>
|
||||
<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
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
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
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-3 md:px-4 py-1.5 md:py-2 rounded-lg text-xs sm: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 className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||
<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
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
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
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
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
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
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
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
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;
|
149
src/components/Setup/SetupConfig.tsx
Normal file
149
src/components/Setup/SetupConfig.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
ConfigModelProvider,
|
||||
UIConfigField,
|
||||
UIConfigSections,
|
||||
} from '@/lib/config/types';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, ArrowRight, Check } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import AddProvider from '../Settings/Sections/Models/AddProviderDialog';
|
||||
import ModelProvider from '../Settings/Sections/Models/ModelProvider';
|
||||
|
||||
const SetupConfig = ({
|
||||
configSections,
|
||||
setupState,
|
||||
setSetupState,
|
||||
}: {
|
||||
configSections: UIConfigSections;
|
||||
setupState: number;
|
||||
setSetupState: (state: number) => void;
|
||||
}) => {
|
||||
const [providers, setProviders] = useState<ConfigModelProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = 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 || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching providers:', error);
|
||||
toast.error('Failed to load providers');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (setupState === 2) {
|
||||
fetchProviders();
|
||||
}
|
||||
}, [setupState]);
|
||||
|
||||
const handleFinish = async () => {
|
||||
try {
|
||||
setIsFinishing(true);
|
||||
const res = await fetch('/api/config/setup-complete', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to complete setup');
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error completing setup:', error);
|
||||
toast.error('Failed to complete setup');
|
||||
setIsFinishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasProviders = providers.length > 0;
|
||||
|
||||
return (
|
||||
<div className="w-[95vw] md:w-[80vw] lg:w-[65vw] mx-auto px-2 sm:px-4 md:px-6 flex flex-col space-y-6">
|
||||
{setupState === 2 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, delay: 0.1 },
|
||||
}}
|
||||
className="w-full h-[calc(95vh-80px)] bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 rounded-xl shadow-sm flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 py-4 md:py-6">
|
||||
<div className="flex flex-row justify-between items-center mb-4 md:mb-6 pb-3 md:pb-4 border-b border-light-200 dark:border-dark-200">
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm font-medium text-black dark:text-white">
|
||||
Manage Providers
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-xs text-black/50 dark:text-white/50 mt-0.5">
|
||||
Add and configure your model providers
|
||||
</p>
|
||||
</div>
|
||||
<AddProvider
|
||||
modelProviders={configSections.modelProviders}
|
||||
setProviders={setProviders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 md:py-12">
|
||||
<p className="text-xs sm:text-sm text-black/50 dark:text-white/50">
|
||||
Loading providers...
|
||||
</p>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 md:py-12 text-center">
|
||||
<p className="text-xs sm:text-sm font-medium text-black/70 dark:text-white/70">
|
||||
No providers configured
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
providers.map((provider) => (
|
||||
<ModelProvider
|
||||
key={`provider-${provider.id}`}
|
||||
fields={
|
||||
(configSections.modelProviders.find(
|
||||
(f) => f.key === provider.type,
|
||||
)?.fields ?? []) as UIConfigField[]
|
||||
}
|
||||
modelProvider={provider}
|
||||
setProviders={setProviders}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center justify-between pt-2">
|
||||
{setupState === 2 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.5 },
|
||||
}}
|
||||
onClick={handleFinish}
|
||||
disabled={!hasProviders || isLoading || isFinishing}
|
||||
className="flex flex-row items-center gap-1.5 md:gap-2 px-3 md:px-5 py-2 md:py-2.5 rounded-lg bg-[#24A0ED] text-white hover:bg-[#1e8fd1] active:scale-95 transition-all duration-200 font-medium text-xs sm:text-sm disabled:bg-light-200 dark:disabled:bg-dark-200 disabled:text-black/40 dark:disabled:text-white/40 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
>
|
||||
<span>{isFinishing ? 'Finishing...' : 'Finish'}</span>
|
||||
<Check className="w-4 h-4 md:w-[18px] md:h-[18px]" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupConfig;
|
126
src/components/Setup/SetupWizard.tsx
Normal file
126
src/components/Setup/SetupWizard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UIConfigSections } from '@/lib/config/types';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import SetupConfig from './SetupConfig';
|
||||
|
||||
const SetupWizard = ({
|
||||
configSections,
|
||||
}: {
|
||||
configSections: UIConfigSections;
|
||||
}) => {
|
||||
const [showWelcome, setShowWelcome] = useState(true);
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [setupState, setSetupState] = useState(1);
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await delay(2500);
|
||||
setShowWelcome(false);
|
||||
await delay(600);
|
||||
setShowSetup(true);
|
||||
setSetupState(1);
|
||||
await delay(1500);
|
||||
setSetupState(2);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-light-primary dark:bg-dark-primary h-screen w-screen fixed inset-0 overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{showWelcome && (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute flex flex-col items-center justify-center h-full"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<motion.h2
|
||||
transition={{ duration: 0.6 }}
|
||||
initial={{ opacity: 0, translateY: '30px' }}
|
||||
animate={{ opacity: 1, translateY: '0px' }}
|
||||
className="text-4xl md:text-6xl xl:text-8xl font-normal font-['Instrument_Serif'] tracking-tight"
|
||||
>
|
||||
Welcome to{' '}
|
||||
<span className="text-[#24A0ED] italic font-['PP_Editorial']">
|
||||
Perplexica
|
||||
</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
transition={{ delay: 0.8, duration: 0.7 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-black/70 dark:text-white/70 text-sm md:text-lg xl:text-2xl mt-2"
|
||||
>
|
||||
<span className="font-light">Web search,</span>{' '}
|
||||
<span className="font-light font-['PP_Editorial'] italic">
|
||||
reimagined
|
||||
</span>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{
|
||||
opacity: 0.2,
|
||||
scale: 1,
|
||||
transition: { delay: 0.8, duration: 0.7 },
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 1.1, transition: { duration: 0.6 } }}
|
||||
className="bg-[#24A0ED] left-50 translate-x-[-50%] h-[250px] w-[250px] rounded-full relative z-40 blur-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showSetup && (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{setupState === 1 && (
|
||||
<motion.p
|
||||
key="setup-text"
|
||||
transition={{ duration: 0.6 }}
|
||||
initial={{ opacity: 0, translateY: '30px' }}
|
||||
animate={{ opacity: 1, translateY: '0px' }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
translateY: '-30px',
|
||||
transition: { duration: 0.6 },
|
||||
}}
|
||||
className="text-2xl md:text-4xl xl:text-6xl font-normal font-['Instrument_Serif'] tracking-tight"
|
||||
>
|
||||
Let us get{' '}
|
||||
<span className="text-[#24A0ED] italic font-['PP_Editorial']">
|
||||
Perplexica
|
||||
</span>{' '}
|
||||
set up for you
|
||||
</motion.p>
|
||||
)}
|
||||
{setupState > 1 && (
|
||||
<motion.div
|
||||
key="setup-config"
|
||||
initial={{ opacity: 0, translateY: '30px' }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
translateY: '0px',
|
||||
transition: { duration: 0.6 },
|
||||
}}
|
||||
>
|
||||
<SetupConfig
|
||||
configSections={configSections}
|
||||
setupState={setupState}
|
||||
setSetupState={setSetupState}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupWizard;
|
@@ -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,30 +0,0 @@
|
||||
import React, { HTMLProps } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
coldarkDark,
|
||||
coldarkCold,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
export const SyntaxHighlightedCode = (props: HTMLProps<HTMLDivElement>) => {
|
||||
const isDarkTheme = document.documentElement.classList.contains('dark');
|
||||
const language = props.className?.match(/lang-([a-zA-Z0-9_-]+)/)![1];
|
||||
|
||||
return language ? (
|
||||
<div className="not-prose">
|
||||
<SyntaxHighlighter
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
backgroundColor: isDarkTheme ? '#111111' : '#f3f3ee',
|
||||
}}
|
||||
language={language}
|
||||
style={isDarkTheme ? coldarkDark : coldarkCold}
|
||||
>
|
||||
{props.children as string}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className="inline bg-light-100 dark:bg-dark-100 px-2 py-1 rounded-lg text-sm not-prose">
|
||||
{props.children}
|
||||
</code>
|
||||
);
|
||||
};
|
@@ -15,7 +15,6 @@ const WeatherWidget = () => {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const getApproxLocation = async () => {
|
||||
const res = await fetch('https://ipwhois.app/json/');
|
||||
const data = await res.json();
|
||||
@@ -70,6 +69,7 @@ const WeatherWidget = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateWeather = async () => {
|
||||
getLocation(async (location) => {
|
||||
const res = await fetch(`/api/weather`, {
|
||||
method: 'POST',
|
||||
@@ -100,10 +100,16 @@ const WeatherWidget = () => {
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateWeather();
|
||||
const intervalId = setInterval(updateWeather, 30 * 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">
|
||||
@@ -134,22 +140,24 @@ const WeatherWidget = () => {
|
||||
{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} {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
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) => {
|
||||
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',
|
||||
'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 }) => {
|
||||
{options.map(({ label, value, disabled: optionDisabled }) => {
|
||||
return (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
<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
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@@ -1,146 +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;
|
||||
};
|
||||
AIMLAPI: {
|
||||
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 getAimlApiKey = () => loadConfig().MODELS.AIMLAPI.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
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
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
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
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
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
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
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,
|
||||
};
|
||||
});
|
||||
};
|
136
src/lib/models/providers/ollama.ts
Normal file
136
src/lib/models/providers/ollama.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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: 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
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
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
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,80 +1,103 @@
|
||||
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.
|
||||
|
||||
Your task is to provide answers that are:
|
||||
- **Informative and relevant**: Thoroughly address the user's query using the given search results.
|
||||
- **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 search results source(s) for each fact or detail included.
|
||||
- **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.
|
||||
|
||||
You have access to the following tools which you have to use to answer the user:
|
||||
1. **search_web**: Use this tool to search the web for information to answer the user's question.
|
||||
2. **summarize**: Use this tool to summarize a link.
|
||||
|
||||
### 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.
|
||||
@@ -84,13 +107,12 @@ export const webSearchResponsePrompt = `
|
||||
- **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 search results
|
||||
- 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 search results.
|
||||
- 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 search results sources.
|
||||
- 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.
|
||||
- Never return references to the citation or sources yourself, they're returned to the user internally.
|
||||
|
||||
### Special Instructions
|
||||
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
|
||||
@@ -104,8 +126,12 @@ export const webSearchResponsePrompt = `
|
||||
### 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 search results as needed to enhance understanding.
|
||||
- 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,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}.
|
||||
`;
|
@@ -10,4 +10,8 @@ However you do not need to cite it using the same number. You can use different
|
||||
### 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}
|
||||
|
||||
<context>
|
||||
{context}
|
||||
</context>
|
||||
`;
|
||||
|
@@ -1,69 +0,0 @@
|
||||
export const youtubeSearchRetrieverPrompt = `
|
||||
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 an A.C work?
|
||||
Rephrased: A.C working
|
||||
|
||||
2. Follow up question: Linear algebra explanation video
|
||||
Rephrased: What is linear algebra?
|
||||
|
||||
3. Follow up question: What is theory of relativity?
|
||||
Rephrased: What is theory of relativity?
|
||||
|
||||
Conversation:
|
||||
{chat_history}
|
||||
|
||||
Follow up question: {query}
|
||||
Rephrased question:
|
||||
`;
|
||||
|
||||
export const youtubeSearchResponsePrompt = `
|
||||
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 'Youtube', this means you will be searching for videos on the web using Youtube and providing information based on the video's transcrip
|
||||
|
||||
### 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,94 +0,0 @@
|
||||
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
|
||||
import { getAimlApiKey } from '../config';
|
||||
import { ChatModel, EmbeddingModel } from '.';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
import axios from 'axios';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'aimlapi',
|
||||
displayName: 'AI/ML API',
|
||||
};
|
||||
|
||||
interface AimlApiModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const API_URL = 'https://api.aimlapi.com';
|
||||
|
||||
export const loadAimlApiChatModels = async () => {
|
||||
const apiKey = getAimlApiKey();
|
||||
|
||||
if (!apiKey) return {};
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/models`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
response.data.data.forEach((model: AimlApiModel) => {
|
||||
if (model.type === 'chat-completion') {
|
||||
chatModels[model.id] = {
|
||||
displayName: model.name || model.id,
|
||||
model: new ChatOpenAI({
|
||||
apiKey: apiKey,
|
||||
modelName: model.id,
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: API_URL,
|
||||
},
|
||||
}) as unknown as BaseChatModel,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading AI/ML API models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAimlApiEmbeddingModels = async () => {
|
||||
const apiKey = getAimlApiKey();
|
||||
|
||||
if (!apiKey) return {};
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/models`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const embeddingModels: Record<string, EmbeddingModel> = {};
|
||||
|
||||
response.data.data.forEach((model: AimlApiModel) => {
|
||||
if (model.type === 'embedding') {
|
||||
embeddingModels[model.id] = {
|
||||
displayName: model.name || model.id,
|
||||
model: new OpenAIEmbeddings({
|
||||
apiKey: apiKey,
|
||||
modelName: model.id,
|
||||
configuration: {
|
||||
baseURL: API_URL,
|
||||
},
|
||||
}) as unknown as Embeddings,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return embeddingModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading AI/ML API embeddings models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
@@ -1,66 +0,0 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { ChatModel } from '.';
|
||||
import { getAnthropicApiKey } from '../config';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
};
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
const anthropicChatModels: Record<string, string>[] = [
|
||||
{
|
||||
displayName: 'Claude 3.7 Sonnet',
|
||||
key: 'claude-3-7-sonnet-20250219',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3.5 Haiku',
|
||||
key: 'claude-3-5-haiku-20241022',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3.5 Sonnet v2',
|
||||
key: 'claude-3-5-sonnet-20241022',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3.5 Sonnet',
|
||||
key: 'claude-3-5-sonnet-20240620',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3 Opus',
|
||||
key: 'claude-3-opus-20240229',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3 Sonnet',
|
||||
key: 'claude-3-sonnet-20240229',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3 Haiku',
|
||||
key: 'claude-3-haiku-20240307',
|
||||
},
|
||||
];
|
||||
|
||||
export const loadAnthropicChatModels = async () => {
|
||||
const anthropicApiKey = getAnthropicApiKey();
|
||||
|
||||
if (!anthropicApiKey) return {};
|
||||
|
||||
try {
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
anthropicChatModels.forEach((model) => {
|
||||
chatModels[model.key] = {
|
||||
displayName: model.displayName,
|
||||
model: new ChatAnthropic({
|
||||
apiKey: anthropicApiKey,
|
||||
modelName: model.key,
|
||||
temperature: 0.7,
|
||||
}) as unknown as BaseChatModel,
|
||||
};
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Anthropic models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
@@ -1,49 +0,0 @@
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { getDeepseekApiKey } from '../config';
|
||||
import { ChatModel } from '.';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'deepseek',
|
||||
displayName: 'Deepseek AI',
|
||||
};
|
||||
|
||||
const deepseekChatModels: Record<string, string>[] = [
|
||||
{
|
||||
displayName: 'Deepseek Chat (Deepseek V3)',
|
||||
key: 'deepseek-chat',
|
||||
},
|
||||
{
|
||||
displayName: 'Deepseek Reasoner (Deepseek R1)',
|
||||
key: 'deepseek-reasoner',
|
||||
},
|
||||
];
|
||||
|
||||
export const loadDeepseekChatModels = async () => {
|
||||
const deepseekApiKey = getDeepseekApiKey();
|
||||
|
||||
if (!deepseekApiKey) return {};
|
||||
|
||||
try {
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
deepseekChatModels.forEach((model) => {
|
||||
chatModels[model.key] = {
|
||||
displayName: model.displayName,
|
||||
model: new ChatOpenAI({
|
||||
apiKey: deepseekApiKey,
|
||||
modelName: model.key,
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
},
|
||||
}) as unknown as BaseChatModel,
|
||||
};
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Deepseek models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
@@ -1,110 +0,0 @@
|
||||
import {
|
||||
ChatGoogleGenerativeAI,
|
||||
GoogleGenerativeAIEmbeddings,
|
||||
} from '@langchain/google-genai';
|
||||
import { getGeminiApiKey } from '../config';
|
||||
import { ChatModel, EmbeddingModel } from '.';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'gemini',
|
||||
displayName: 'Google Gemini',
|
||||
};
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
|
||||
const geminiChatModels: Record<string, string>[] = [
|
||||
{
|
||||
displayName: 'Gemini 2.5 Flash',
|
||||
key: 'gemini-2.5-flash',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 2.5 Pro',
|
||||
key: 'gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 2.0 Flash',
|
||||
key: 'gemini-2.0-flash',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 2.0 Flash-Lite',
|
||||
key: 'gemini-2.0-flash-lite',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 2.0 Flash Thinking Experimental',
|
||||
key: 'gemini-2.0-flash-thinking-exp-01-21',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 1.5 Flash',
|
||||
key: 'gemini-1.5-flash',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 1.5 Flash-8B',
|
||||
key: 'gemini-1.5-flash-8b',
|
||||
},
|
||||
{
|
||||
displayName: 'Gemini 1.5 Pro',
|
||||
key: 'gemini-1.5-pro',
|
||||
},
|
||||
];
|
||||
|
||||
const geminiEmbeddingModels: Record<string, string>[] = [
|
||||
{
|
||||
displayName: 'Text Embedding 004',
|
||||
key: 'models/text-embedding-004',
|
||||
},
|
||||
{
|
||||
displayName: 'Embedding 001',
|
||||
key: 'models/embedding-001',
|
||||
},
|
||||
];
|
||||
|
||||
export const loadGeminiChatModels = async () => {
|
||||
const geminiApiKey = getGeminiApiKey();
|
||||
|
||||
if (!geminiApiKey) return {};
|
||||
|
||||
try {
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
geminiChatModels.forEach((model) => {
|
||||
chatModels[model.key] = {
|
||||
displayName: model.displayName,
|
||||
model: new ChatGoogleGenerativeAI({
|
||||
apiKey: geminiApiKey,
|
||||
model: model.key,
|
||||
temperature: 0.7,
|
||||
}) as unknown as BaseChatModel,
|
||||
};
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Gemini models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadGeminiEmbeddingModels = async () => {
|
||||
const geminiApiKey = getGeminiApiKey();
|
||||
|
||||
if (!geminiApiKey) return {};
|
||||
|
||||
try {
|
||||
const embeddingModels: Record<string, EmbeddingModel> = {};
|
||||
|
||||
geminiEmbeddingModels.forEach((model) => {
|
||||
embeddingModels[model.key] = {
|
||||
displayName: model.displayName,
|
||||
model: new GoogleGenerativeAIEmbeddings({
|
||||
apiKey: geminiApiKey,
|
||||
modelName: model.key,
|
||||
}) as unknown as Embeddings,
|
||||
};
|
||||
});
|
||||
|
||||
return embeddingModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Gemini embeddings models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
@@ -1,44 +0,0 @@
|
||||
import { ChatGroq } from '@langchain/groq';
|
||||
import { getGroqApiKey } from '../config';
|
||||
import { ChatModel } from '.';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'groq',
|
||||
displayName: 'Groq',
|
||||
};
|
||||
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
export const loadGroqChatModels = async () => {
|
||||
const groqApiKey = getGroqApiKey();
|
||||
if (!groqApiKey) return {};
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.groq.com/openai/v1/models', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `bearer ${groqApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const groqChatModels = (await res.json()).data;
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
groqChatModels.forEach((model: any) => {
|
||||
chatModels[model.id] = {
|
||||
displayName: model.id,
|
||||
model: new ChatGroq({
|
||||
apiKey: groqApiKey,
|
||||
model: model.id,
|
||||
temperature: 0.7,
|
||||
}) as unknown as BaseChatModel,
|
||||
};
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Groq models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
@@ -1,147 +0,0 @@
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import {
|
||||
loadOpenAIChatModels,
|
||||
loadOpenAIEmbeddingModels,
|
||||
PROVIDER_INFO as OpenAIInfo,
|
||||
PROVIDER_INFO,
|
||||
} from './openai';
|
||||
import {
|
||||
getCustomOpenaiApiKey,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiModelName,
|
||||
} from '../config';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import {
|
||||
loadOllamaChatModels,
|
||||
loadOllamaEmbeddingModels,
|
||||
PROVIDER_INFO as OllamaInfo,
|
||||
} from './ollama';
|
||||
import { loadGroqChatModels, PROVIDER_INFO as GroqInfo } from './groq';
|
||||
import {
|
||||
loadAnthropicChatModels,
|
||||
PROVIDER_INFO as AnthropicInfo,
|
||||
} from './anthropic';
|
||||
import {
|
||||
loadGeminiChatModels,
|
||||
loadGeminiEmbeddingModels,
|
||||
PROVIDER_INFO as GeminiInfo,
|
||||
} from './gemini';
|
||||
import {
|
||||
loadTransformersEmbeddingsModels,
|
||||
PROVIDER_INFO as TransformersInfo,
|
||||
} from './transformers';
|
||||
import {
|
||||
loadDeepseekChatModels,
|
||||
PROVIDER_INFO as DeepseekInfo,
|
||||
} from './deepseek';
|
||||
import {
|
||||
loadAimlApiChatModels,
|
||||
loadAimlApiEmbeddingModels,
|
||||
PROVIDER_INFO as AimlApiInfo,
|
||||
} from './aimlapi';
|
||||
import {
|
||||
loadLMStudioChatModels,
|
||||
loadLMStudioEmbeddingsModels,
|
||||
PROVIDER_INFO as LMStudioInfo,
|
||||
} from './lmstudio';
|
||||
|
||||
export const PROVIDER_METADATA = {
|
||||
openai: OpenAIInfo,
|
||||
ollama: OllamaInfo,
|
||||
groq: GroqInfo,
|
||||
anthropic: AnthropicInfo,
|
||||
gemini: GeminiInfo,
|
||||
transformers: TransformersInfo,
|
||||
deepseek: DeepseekInfo,
|
||||
aimlapi: AimlApiInfo,
|
||||
lmstudio: LMStudioInfo,
|
||||
custom_openai: {
|
||||
key: 'custom_openai',
|
||||
displayName: 'Custom OpenAI',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ChatModel {
|
||||
displayName: string;
|
||||
model: BaseChatModel;
|
||||
}
|
||||
|
||||
export interface EmbeddingModel {
|
||||
displayName: string;
|
||||
model: Embeddings;
|
||||
}
|
||||
|
||||
export const chatModelProviders: Record<
|
||||
string,
|
||||
() => Promise<Record<string, ChatModel>>
|
||||
> = {
|
||||
openai: loadOpenAIChatModels,
|
||||
ollama: loadOllamaChatModels,
|
||||
groq: loadGroqChatModels,
|
||||
anthropic: loadAnthropicChatModels,
|
||||
gemini: loadGeminiChatModels,
|
||||
deepseek: loadDeepseekChatModels,
|
||||
aimlapi: loadAimlApiChatModels,
|
||||
lmstudio: loadLMStudioChatModels,
|
||||
};
|
||||
|
||||
export const embeddingModelProviders: Record<
|
||||
string,
|
||||
() => Promise<Record<string, EmbeddingModel>>
|
||||
> = {
|
||||
openai: loadOpenAIEmbeddingModels,
|
||||
ollama: loadOllamaEmbeddingModels,
|
||||
gemini: loadGeminiEmbeddingModels,
|
||||
transformers: loadTransformersEmbeddingsModels,
|
||||
aimlapi: loadAimlApiEmbeddingModels,
|
||||
lmstudio: loadLMStudioEmbeddingsModels,
|
||||
};
|
||||
|
||||
export const getAvailableChatModelProviders = async () => {
|
||||
const models: Record<string, Record<string, ChatModel>> = {};
|
||||
|
||||
for (const provider in chatModelProviders) {
|
||||
const providerModels = await chatModelProviders[provider]();
|
||||
if (Object.keys(providerModels).length > 0) {
|
||||
models[provider] = providerModels;
|
||||
}
|
||||
}
|
||||
|
||||
const customOpenAiApiKey = getCustomOpenaiApiKey();
|
||||
const customOpenAiApiUrl = getCustomOpenaiApiUrl();
|
||||
const customOpenAiModelName = getCustomOpenaiModelName();
|
||||
|
||||
models['custom_openai'] = {
|
||||
...(customOpenAiApiKey && customOpenAiApiUrl && customOpenAiModelName
|
||||
? {
|
||||
[customOpenAiModelName]: {
|
||||
displayName: customOpenAiModelName,
|
||||
model: new ChatOpenAI({
|
||||
apiKey: customOpenAiApiKey,
|
||||
modelName: customOpenAiModelName,
|
||||
temperature: 0.7,
|
||||
configuration: {
|
||||
baseURL: customOpenAiApiUrl,
|
||||
},
|
||||
}) as unknown as BaseChatModel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
export const getAvailableEmbeddingModelProviders = async () => {
|
||||
const models: Record<string, Record<string, EmbeddingModel>> = {};
|
||||
|
||||
for (const provider in embeddingModelProviders) {
|
||||
const providerModels = await embeddingModelProviders[provider]();
|
||||
if (Object.keys(providerModels).length > 0) {
|
||||
models[provider] = providerModels;
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
};
|
@@ -1,100 +0,0 @@
|
||||
import { getKeepAlive, getLMStudioApiEndpoint } from '../config';
|
||||
import axios from 'axios';
|
||||
import { ChatModel, EmbeddingModel } from '.';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'lmstudio',
|
||||
displayName: 'LM Studio',
|
||||
};
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { OpenAIEmbeddings } from '@langchain/openai';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
|
||||
interface LMStudioModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const ensureV1Endpoint = (endpoint: string): string =>
|
||||
endpoint.endsWith('/v1') ? endpoint : `${endpoint}/v1`;
|
||||
|
||||
const checkServerAvailability = async (endpoint: string): Promise<boolean> => {
|
||||
try {
|
||||
await axios.get(`${ensureV1Endpoint(endpoint)}/models`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadLMStudioChatModels = async () => {
|
||||
const endpoint = getLMStudioApiEndpoint();
|
||||
|
||||
if (!endpoint) return {};
|
||||
if (!(await checkServerAvailability(endpoint))) return {};
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${ensureV1Endpoint(endpoint)}/models`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
response.data.data.forEach((model: LMStudioModel) => {
|
||||
chatModels[model.id] = {
|
||||
displayName: model.name || model.id,
|
||||
model: new ChatOpenAI({
|
||||
apiKey: 'lm-studio',
|
||||
configuration: {
|
||||
baseURL: ensureV1Endpoint(endpoint),
|
||||
},
|
||||
modelName: model.id,
|
||||
temperature: 0.7,
|
||||
streaming: true,
|
||||
maxRetries: 3,
|
||||
}) as unknown as BaseChatModel,
|
||||
};
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading LM Studio models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadLMStudioEmbeddingsModels = async () => {
|
||||
const endpoint = getLMStudioApiEndpoint();
|
||||
|
||||
if (!endpoint) return {};
|
||||
if (!(await checkServerAvailability(endpoint))) return {};
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${ensureV1Endpoint(endpoint)}/models`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const embeddingsModels: Record<string, EmbeddingModel> = {};
|
||||
|
||||
response.data.data.forEach((model: LMStudioModel) => {
|
||||
embeddingsModels[model.id] = {
|
||||
displayName: model.name || model.id,
|
||||
model: new OpenAIEmbeddings({
|
||||
apiKey: 'lm-studio',
|
||||
configuration: {
|
||||
baseURL: ensureV1Endpoint(endpoint),
|
||||
},
|
||||
modelName: model.id,
|
||||
}) as unknown as Embeddings,
|
||||
};
|
||||
});
|
||||
|
||||
return embeddingsModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading LM Studio embeddings model: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
@@ -1,78 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { getKeepAlive, getOllamaApiEndpoint } from '../config';
|
||||
import { ChatModel, EmbeddingModel } from '.';
|
||||
|
||||
export const PROVIDER_INFO = {
|
||||
key: 'ollama',
|
||||
displayName: 'Ollama',
|
||||
};
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import { OllamaEmbeddings } from '@langchain/ollama';
|
||||
|
||||
export const loadOllamaChatModels = async () => {
|
||||
const ollamaApiEndpoint = getOllamaApiEndpoint();
|
||||
|
||||
if (!ollamaApiEndpoint) return {};
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${ollamaApiEndpoint}/api/tags`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const { models } = res.data;
|
||||
|
||||
const chatModels: Record<string, ChatModel> = {};
|
||||
|
||||
models.forEach((model: any) => {
|
||||
chatModels[model.model] = {
|
||||
displayName: model.name,
|
||||
model: new ChatOllama({
|
||||
baseUrl: ollamaApiEndpoint,
|
||||
model: model.model,
|
||||
temperature: 0.7,
|
||||
keepAlive: getKeepAlive(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return chatModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Ollama models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadOllamaEmbeddingModels = async () => {
|
||||
const ollamaApiEndpoint = getOllamaApiEndpoint();
|
||||
|
||||
if (!ollamaApiEndpoint) return {};
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${ollamaApiEndpoint}/api/tags`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const { models } = res.data;
|
||||
|
||||
const embeddingModels: Record<string, EmbeddingModel> = {};
|
||||
|
||||
models.forEach((model: any) => {
|
||||
embeddingModels[model.model] = {
|
||||
displayName: model.name,
|
||||
model: new OllamaEmbeddings({
|
||||
baseUrl: ollamaApiEndpoint,
|
||||
model: model.model,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return embeddingModels;
|
||||
} catch (err) {
|
||||
console.error(`Error loading Ollama embeddings models: ${err}`);
|
||||
return {};
|
||||
}
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user