upload local project
This commit is contained in:
commit
39773f096e
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Supabase Public
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
# Supabase Private
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# Ollama
|
||||||
|
NEXT_PUBLIC_OLLAMA_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# API Keys (Optional: Entering an API key here overrides the API keys globally for all users.)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GOOGLE_GEMINI_API_KEY=
|
||||||
|
MISTRAL_API_KEY=
|
||||||
|
GROQ_API_KEY=
|
||||||
|
PERPLEXITY_API_KEY=
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# OpenAI API Information
|
||||||
|
NEXT_PUBLIC_OPENAI_ORGANIZATION_ID=
|
||||||
|
|
||||||
|
# Azure API Information
|
||||||
|
AZURE_OPENAI_API_KEY=
|
||||||
|
AZURE_OPENAI_ENDPOINT=
|
||||||
|
AZURE_GPT_35_TURBO_NAME=
|
||||||
|
AZURE_GPT_45_VISION_NAME=
|
||||||
|
AZURE_GPT_45_TURBO_NAME=
|
||||||
|
AZURE_EMBEDDINGS_NAME=
|
||||||
|
|
||||||
|
# General Configuration (Optional)
|
||||||
|
EMAIL_DOMAIN_WHITELIST=
|
||||||
|
EMAIL_WHITELIST=
|
||||||
|
|
||||||
|
# File size limit for uploads in bytes
|
||||||
|
NEXT_PUBLIC_USER_FILE_SIZE_LIMIT=10485760
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/eslintrc",
|
||||||
|
"root": true,
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"prettier",
|
||||||
|
"plugin:tailwindcss/recommended"
|
||||||
|
],
|
||||||
|
"plugins": ["tailwindcss"],
|
||||||
|
"rules": {
|
||||||
|
"tailwindcss/no-custom-classname": "off"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"tailwindcss": {
|
||||||
|
"callees": ["cn", "cva"],
|
||||||
|
"config": "tailwind.config.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"parser": "@typescript-eslint/parser"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
.VSCodeCounter
|
||||||
|
tool-schemas
|
||||||
|
custom-prompts
|
||||||
|
|
||||||
|
sw.js
|
||||||
|
sw.js.map
|
||||||
|
workbox-*.js
|
||||||
|
workbox-*.js.map
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run lint:fix && npm run format:write && git add .
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
# Chatbot UI
|
||||||
|
|
||||||
|
The open-source AI chat app for everyone.
|
||||||
|
|
||||||
|
<img src="./public/readme/screenshot.png" alt="Chatbot UI" width="600">
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
View the latest demo [here](https://x.com/mckaywrigley/status/1738273242283151777?s=20).
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
Hey everyone! I've heard your feedback and am working hard on a big update.
|
||||||
|
|
||||||
|
Things like simpler deployment, better backend compatibility, and improved mobile layouts are on their way.
|
||||||
|
|
||||||
|
Be back soon.
|
||||||
|
|
||||||
|
-- Mckay
|
||||||
|
|
||||||
|
## Official Hosted Version
|
||||||
|
|
||||||
|
Use Chatbot UI without having to host it yourself!
|
||||||
|
|
||||||
|
Find the official hosted version of Chatbot UI [here](https://chatbotui.com).
|
||||||
|
|
||||||
|
## Sponsor
|
||||||
|
|
||||||
|
If you find Chatbot UI useful, please consider [sponsoring](https://github.com/sponsors/mckaywrigley) me to support my open-source work :)
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
We restrict "Issues" to actual issues related to the codebase.
|
||||||
|
|
||||||
|
We're getting excessive amounts of issues that amount to things like feature requests, cloud provider issues, etc.
|
||||||
|
|
||||||
|
If you are having issues with things like setup, please refer to the "Help" section in the "Discussions" tab above.
|
||||||
|
|
||||||
|
Issues unrelated to the codebase will likely be closed immediately.
|
||||||
|
|
||||||
|
## Discussions
|
||||||
|
|
||||||
|
We highly encourage you to participate in the "Discussions" tab above!
|
||||||
|
|
||||||
|
Discussions are a great place to ask questions, share ideas, and get help.
|
||||||
|
|
||||||
|
Odds are if you have a question, someone else has the same question.
|
||||||
|
|
||||||
|
## Legacy Code
|
||||||
|
|
||||||
|
Chatbot UI was recently updated to its 2.0 version.
|
||||||
|
|
||||||
|
The code for 1.0 can be found on the `legacy` branch.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
In your terminal at the root of your local Chatbot UI repository, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run update
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run a hosted instance you'll also need to run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db-push
|
||||||
|
```
|
||||||
|
|
||||||
|
to apply the latest migrations to your live database.
|
||||||
|
|
||||||
|
## Local Quickstart
|
||||||
|
|
||||||
|
Follow these steps to get your own Chatbot UI instance running locally.
|
||||||
|
|
||||||
|
You can watch the full video tutorial [here](https://www.youtube.com/watch?v=9Qq3-7-HNgw).
|
||||||
|
|
||||||
|
### 1. Clone the Repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/mckaywrigley/chatbot-ui.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Dependencies
|
||||||
|
|
||||||
|
Open a terminal in the root directory of your local Chatbot UI repository and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install Supabase & Run Locally
|
||||||
|
|
||||||
|
#### Why Supabase?
|
||||||
|
|
||||||
|
Previously, we used local browser storage to store data. However, this was not a good solution for a few reasons:
|
||||||
|
|
||||||
|
- Security issues
|
||||||
|
- Limited storage
|
||||||
|
- Limits multi-modal use cases
|
||||||
|
|
||||||
|
We now use Supabase because it's easy to use, it's open-source, it's Postgres, and it has a free tier for hosted instances.
|
||||||
|
|
||||||
|
We will support other providers in the future to give you more options.
|
||||||
|
|
||||||
|
#### 1. Install Docker
|
||||||
|
|
||||||
|
You will need to install Docker to run Supabase locally. You can download it [here](https://docs.docker.com/get-docker) for free.
|
||||||
|
|
||||||
|
#### 2. Install Supabase CLI
|
||||||
|
|
||||||
|
**MacOS/Linux**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install supabase/tap/supabase
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
|
||||||
|
scoop install supabase
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Start Supabase
|
||||||
|
|
||||||
|
In your terminal at the root of your local Chatbot UI repository, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Fill in Secrets
|
||||||
|
|
||||||
|
#### 1. Environment Variables
|
||||||
|
|
||||||
|
In your terminal at the root of your local Chatbot UI repository, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Get the required values by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase status
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Use `API URL` from `supabase status` for `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
|
|
||||||
|
Now go to your `.env.local` file and fill in the values.
|
||||||
|
|
||||||
|
If the environment variable is set, it will disable the input in the user settings.
|
||||||
|
|
||||||
|
#### 2. SQL Setup
|
||||||
|
|
||||||
|
In the 1st migration file `supabase/migrations/20240108234540_setup.sql` you will need to replace 2 values with the values you got above:
|
||||||
|
|
||||||
|
- `project_url` (line 53): `http://supabase_kong_chatbotui:8000` (default) can remain unchanged if you don't change your `project_id` in the `config.toml` file
|
||||||
|
- `service_role_key` (line 54): You got this value from running `supabase status`
|
||||||
|
|
||||||
|
This prevents issues with storage files not being deleted properly.
|
||||||
|
|
||||||
|
### 5. Install Ollama (optional for local models)
|
||||||
|
|
||||||
|
Follow the instructions [here](https://github.com/jmorganca/ollama#macos).
|
||||||
|
|
||||||
|
### 6. Run app locally
|
||||||
|
|
||||||
|
In your terminal at the root of your local Chatbot UI repository, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run chat
|
||||||
|
```
|
||||||
|
|
||||||
|
Your local instance of Chatbot UI should now be running at [http://localhost:3000](http://localhost:3000). Be sure to use a compatible node version (i.e. v18).
|
||||||
|
|
||||||
|
You can view your backend GUI at [http://localhost:54323/project/default/editor](http://localhost:54323/project/default/editor).
|
||||||
|
|
||||||
|
## Hosted Quickstart
|
||||||
|
|
||||||
|
Follow these steps to get your own Chatbot UI instance running in the cloud.
|
||||||
|
|
||||||
|
Video tutorial coming soon.
|
||||||
|
|
||||||
|
### 1. Follow Local Quickstart
|
||||||
|
|
||||||
|
Repeat steps 1-4 in "Local Quickstart" above.
|
||||||
|
|
||||||
|
You will want separate repositories for your local and hosted instances.
|
||||||
|
|
||||||
|
Create a new repository for your hosted instance of Chatbot UI on GitHub and push your code to it.
|
||||||
|
|
||||||
|
### 2. Setup Backend with Supabase
|
||||||
|
|
||||||
|
#### 1. Create a new project
|
||||||
|
|
||||||
|
Go to [Supabase](https://supabase.com/) and create a new project.
|
||||||
|
|
||||||
|
#### 2. Get Project Values
|
||||||
|
|
||||||
|
Once you are in the project dashboard, click on the "Project Settings" icon tab on the far bottom left.
|
||||||
|
|
||||||
|
Here you will get the values for the following environment variables:
|
||||||
|
|
||||||
|
- `Project Ref`: Found in "General settings" as "Reference ID"
|
||||||
|
|
||||||
|
- `Project ID`: Found in the URL of your project dashboard (Ex: https://supabase.com/dashboard/project/<YOUR_PROJECT_ID>/settings/general)
|
||||||
|
|
||||||
|
While still in "Settings" click on the "API" text tab on the left.
|
||||||
|
|
||||||
|
Here you will get the values for the following environment variables:
|
||||||
|
|
||||||
|
- `Project URL`: Found in "API Settings" as "Project URL"
|
||||||
|
|
||||||
|
- `Anon key`: Found in "Project API keys" as "anon public"
|
||||||
|
|
||||||
|
- `Service role key`: Found in "Project API keys" as "service_role" (Reminder: Treat this like a password!)
|
||||||
|
|
||||||
|
#### 3. Configure Auth
|
||||||
|
|
||||||
|
Next, click on the "Authentication" icon tab on the far left.
|
||||||
|
|
||||||
|
In the text tabs, click on "Providers" and make sure "Email" is enabled.
|
||||||
|
|
||||||
|
We recommend turning off "Confirm email" for your own personal instance.
|
||||||
|
|
||||||
|
#### 4. Connect to Hosted DB
|
||||||
|
|
||||||
|
Open up your repository for your hosted instance of Chatbot UI.
|
||||||
|
|
||||||
|
In the 1st migration file `supabase/migrations/20240108234540_setup.sql` you will need to replace 2 values with the values you got above:
|
||||||
|
|
||||||
|
- `project_url` (line 53): Use the `Project URL` value from above
|
||||||
|
- `service_role_key` (line 54): Use the `Service role key` value from above
|
||||||
|
|
||||||
|
Now, open a terminal in the root directory of your local Chatbot UI repository. We will execute a few commands here.
|
||||||
|
|
||||||
|
Login to Supabase by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase login
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, link your project by running the following command with the "Project ID" you got above:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase link --project-ref <project-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Your project should now be linked.
|
||||||
|
|
||||||
|
Finally, push your database to Supabase by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase db push
|
||||||
|
```
|
||||||
|
|
||||||
|
Your hosted database should now be set up!
|
||||||
|
|
||||||
|
### 3. Setup Frontend with Vercel
|
||||||
|
|
||||||
|
Go to [Vercel](https://vercel.com/) and create a new project.
|
||||||
|
|
||||||
|
In the setup page, import your GitHub repository for your hosted instance of Chatbot UI. Within the project Settings, in the "Build & Development Settings" section, switch Framework Preset to "Next.js".
|
||||||
|
|
||||||
|
In environment variables, add the following from the values you got above:
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
|
- `NEXT_PUBLIC_OLLAMA_URL` (only needed when using local Ollama models; default: `http://localhost:11434`)
|
||||||
|
|
||||||
|
You can also add API keys as environment variables.
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY`
|
||||||
|
- `AZURE_OPENAI_API_KEY`
|
||||||
|
- `AZURE_OPENAI_ENDPOINT`
|
||||||
|
- `AZURE_GPT_45_VISION_NAME`
|
||||||
|
|
||||||
|
For the full list of environment variables, refer to the '.env.local.example' file. If the environment variables are set for API keys, it will disable the input in the user settings.
|
||||||
|
|
||||||
|
Click "Deploy" and wait for your frontend to deploy.
|
||||||
|
|
||||||
|
Once deployed, you should be able to use your hosted instance of Chatbot UI via the URL Vercel gives you.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We are working on a guide for contributing.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Message Mckay on [Twitter/X](https://twitter.com/mckaywrigley)
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
import { openapiToFunctions } from "@/lib/openapi-conversion"
|
||||||
|
|
||||||
|
const validSchemaURL = JSON.stringify({
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: {
|
||||||
|
title: "Get weather data",
|
||||||
|
description: "Retrieves current weather data for a location.",
|
||||||
|
version: "v1.0.0"
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: "https://weather.example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
"/location": {
|
||||||
|
get: {
|
||||||
|
description: "Get temperature for a specific location",
|
||||||
|
operationId: "GetCurrentWeather",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "location",
|
||||||
|
in: "query",
|
||||||
|
description: "The city and state to retrieve the weather for",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/summary": {
|
||||||
|
get: {
|
||||||
|
description: "Get description of weather for a specific location",
|
||||||
|
operationId: "GetWeatherSummary",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "location",
|
||||||
|
in: "query",
|
||||||
|
description: "The city and state to retrieve the summary for",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractOpenapiData for url", () => {
|
||||||
|
it("should parse a valid OpenAPI url schema", async () => {
|
||||||
|
const { info, routes, functions } = await openapiToFunctions(
|
||||||
|
JSON.parse(validSchemaURL)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(info.title).toBe("Get weather data")
|
||||||
|
expect(info.description).toBe(
|
||||||
|
"Retrieves current weather data for a location."
|
||||||
|
)
|
||||||
|
expect(info.server).toBe("https://weather.example.com")
|
||||||
|
|
||||||
|
expect(routes).toHaveLength(2)
|
||||||
|
|
||||||
|
expect(functions).toHaveLength(2)
|
||||||
|
expect(functions[0].function.name).toBe("GetCurrentWeather")
|
||||||
|
expect(functions[1].function.name).toBe("GetWeatherSummary")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const validSchemaBody = JSON.stringify({
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: {
|
||||||
|
title: "Get weather data",
|
||||||
|
description: "Retrieves current weather data for a location.",
|
||||||
|
version: "v1.0.0"
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: "https://weather.example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
"/location": {
|
||||||
|
post: {
|
||||||
|
description: "Get temperature for a specific location",
|
||||||
|
operationId: "GetCurrentWeather",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
location: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"The city and state to retrieve the weather for",
|
||||||
|
example: "New York, NY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractOpenapiData for body", () => {
|
||||||
|
it("should parse a valid OpenAPI body schema", async () => {
|
||||||
|
const { info, routes, functions } = await openapiToFunctions(
|
||||||
|
JSON.parse(validSchemaBody)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(info.title).toBe("Get weather data")
|
||||||
|
expect(info.description).toBe(
|
||||||
|
"Retrieves current weather data for a location."
|
||||||
|
)
|
||||||
|
expect(info.server).toBe("https://weather.example.com")
|
||||||
|
|
||||||
|
expect(routes).toHaveLength(1)
|
||||||
|
expect(routes[0].path).toBe("/location")
|
||||||
|
expect(routes[0].method).toBe("post")
|
||||||
|
expect(routes[0].operationId).toBe("GetCurrentWeather")
|
||||||
|
|
||||||
|
expect(functions).toHaveLength(1)
|
||||||
|
expect(
|
||||||
|
functions[0].function.parameters.properties.requestBody.properties
|
||||||
|
.location.type
|
||||||
|
).toBe("string")
|
||||||
|
expect(
|
||||||
|
functions[0].function.parameters.properties.requestBody.properties
|
||||||
|
.location.description
|
||||||
|
).toBe("The city and state to retrieve the weather for")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const validSchemaBody2 = JSON.stringify({
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: {
|
||||||
|
title: "Polygon.io Stock and Crypto Data API",
|
||||||
|
description:
|
||||||
|
"API schema for accessing stock and crypto data from Polygon.io.",
|
||||||
|
version: "1.0.0"
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: "https://api.polygon.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
"/v1/open-close/{stocksTicker}/{date}": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Stock Daily Open and Close",
|
||||||
|
description: "Get the daily open and close for a specific stock.",
|
||||||
|
operationId: "getStockDailyOpenClose",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "stocksTicker",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string",
|
||||||
|
format: "date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v2/aggs/ticker/{stocksTicker}/prev": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Stock Previous Close",
|
||||||
|
description: "Get the previous closing data for a specific stock.",
|
||||||
|
operationId: "getStockPreviousClose",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "stocksTicker",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v3/trades/{stockTicker}": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Stock Trades",
|
||||||
|
description: "Retrieve trades for a specific stock.",
|
||||||
|
operationId: "getStockTrades",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "stockTicker",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v3/trades/{optionsTicker}": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Options Trades",
|
||||||
|
description: "Retrieve trades for a specific options ticker.",
|
||||||
|
operationId: "getOptionsTrades",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "optionsTicker",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v2/last/trade/{optionsTicker}": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Last Options Trade",
|
||||||
|
description: "Get the last trade for a specific options ticker.",
|
||||||
|
operationId: "getLastOptionsTrade",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "optionsTicker",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/open-close/crypto/{from}/{to}/{date}": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Crypto Daily Open and Close",
|
||||||
|
description:
|
||||||
|
"Get daily open and close data for a specific cryptocurrency.",
|
||||||
|
operationId: "getCryptoDailyOpenClose",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "from",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "to",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string",
|
||||||
|
format: "date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v2/aggs/ticker/{cryptoTicker}/prev": {
|
||||||
|
get: {
|
||||||
|
summary: "Get Crypto Previous Close",
|
||||||
|
description:
|
||||||
|
"Get the previous closing data for a specific cryptocurrency.",
|
||||||
|
operationId: "getCryptoPreviousClose",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "cryptoTicker",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
BearerAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "API Key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
BearerAuth: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractOpenapiData for body 2", () => {
|
||||||
|
it("should parse a valid OpenAPI body schema for body 2", async () => {
|
||||||
|
const { info, routes, functions } = await openapiToFunctions(
|
||||||
|
JSON.parse(validSchemaBody2)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(info.title).toBe("Polygon.io Stock and Crypto Data API")
|
||||||
|
expect(info.description).toBe(
|
||||||
|
"API schema for accessing stock and crypto data from Polygon.io."
|
||||||
|
)
|
||||||
|
expect(info.server).toBe("https://api.polygon.io")
|
||||||
|
|
||||||
|
expect(routes).toHaveLength(7)
|
||||||
|
expect(routes[0].path).toBe("/v1/open-close/{stocksTicker}/{date}")
|
||||||
|
expect(routes[0].method).toBe("get")
|
||||||
|
expect(routes[0].operationId).toBe("getStockDailyOpenClose")
|
||||||
|
|
||||||
|
expect(functions[0].function.parameters.properties).toHaveProperty(
|
||||||
|
"stocksTicker"
|
||||||
|
)
|
||||||
|
expect(functions[0].function.parameters.properties.stocksTicker.type).toBe(
|
||||||
|
"string"
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
functions[0].function.parameters.properties.stocksTicker
|
||||||
|
).toHaveProperty("required", true)
|
||||||
|
expect(functions[0].function.parameters.properties).toHaveProperty("date")
|
||||||
|
expect(functions[0].function.parameters.properties.date.type).toBe("string")
|
||||||
|
expect(functions[0].function.parameters.properties.date).toHaveProperty(
|
||||||
|
"format",
|
||||||
|
"date"
|
||||||
|
)
|
||||||
|
expect(functions[0].function.parameters.properties.date).toHaveProperty(
|
||||||
|
"required",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(routes[1].path).toBe("/v2/aggs/ticker/{stocksTicker}/prev")
|
||||||
|
expect(routes[1].method).toBe("get")
|
||||||
|
expect(routes[1].operationId).toBe("getStockPreviousClose")
|
||||||
|
expect(functions[1].function.parameters.properties).toHaveProperty(
|
||||||
|
"stocksTicker"
|
||||||
|
)
|
||||||
|
expect(functions[1].function.parameters.properties.stocksTicker.type).toBe(
|
||||||
|
"string"
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
functions[1].function.parameters.properties.stocksTicker
|
||||||
|
).toHaveProperty("required", true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
{
|
||||||
|
"name": "playwright-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "playwright-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.41.2",
|
||||||
|
"@types/node": "^20.11.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.41.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
|
||||||
|
"integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.41.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.11.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
|
||||||
|
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.41.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz",
|
||||||
|
"integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.41.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.41.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz",
|
||||||
|
"integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "playwright-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"integration": "playwright test",
|
||||||
|
"integration:open": "playwright test --ui",
|
||||||
|
"integration:codegen": "playwright codegen"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.41.2",
|
||||||
|
"@types/node": "^20.11.20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
// baseURL: 'http://127.0.0.1:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://127.0.0.1:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('start chatting is displayed', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/');
|
||||||
|
|
||||||
|
//expect the start chatting link to be visible
|
||||||
|
await expect (page.getByRole('link', { name: 'Start Chatting' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No password error message', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/login');
|
||||||
|
//fill in dummy email
|
||||||
|
await page.getByPlaceholder('you@example.com').fill('dummyemail@gmail.com');
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
//wait for netwrok to be idle
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
//validate that correct message is shown to the user
|
||||||
|
await expect(page.getByText('Invalid login credentials')).toBeVisible();
|
||||||
|
|
||||||
|
});
|
||||||
|
test('No password for signup', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/login');
|
||||||
|
|
||||||
|
await page.getByPlaceholder('you@example.com').fill('dummyEmail@Gmail.com');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
//validate appropriate error is thrown for missing password when signing up
|
||||||
|
await expect(page.getByText('Signup requires a valid')).toBeVisible();
|
||||||
|
});
|
||||||
|
test('invalid username for signup', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/login');
|
||||||
|
|
||||||
|
await page.getByPlaceholder('you@example.com').fill('dummyEmail');
|
||||||
|
await page.getByPlaceholder('••••••••').fill('dummypassword');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
//validate appropriate error is thrown for invalid username when signing up
|
||||||
|
await expect(page.getByText('Unable to validate email')).toBeVisible();
|
||||||
|
});
|
||||||
|
test('password reset message', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/login');
|
||||||
|
await page.getByPlaceholder('you@example.com').fill('demo@gmail.com');
|
||||||
|
await page.getByRole('button', { name: 'Reset' }).click();
|
||||||
|
//validate appropriate message is shown
|
||||||
|
await expect(page.getByText('Check email to reset password')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
//more tests can be added here
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChatUI } from "@/components/chat/chat-ui"
|
||||||
|
|
||||||
|
export default function ChatIDPage() {
|
||||||
|
return <ChatUI />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChatHelp } from "@/components/chat/chat-help"
|
||||||
|
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
|
||||||
|
import { ChatInput } from "@/components/chat/chat-input"
|
||||||
|
import { ChatSettings } from "@/components/chat/chat-settings"
|
||||||
|
import { ChatUI } from "@/components/chat/chat-ui"
|
||||||
|
import { QuickSettings } from "@/components/chat/quick-settings"
|
||||||
|
import { Brand } from "@/components/ui/brand"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { useContext } from "react"
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
useHotkey("o", () => handleNewChat())
|
||||||
|
useHotkey("l", () => {
|
||||||
|
handleFocusChatInput()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { chatMessages } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleNewChat, handleFocusChatInput } = useChatHandler()
|
||||||
|
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chatMessages.length === 0 ? (
|
||||||
|
<div className="relative flex h-full flex-col items-center justify-center">
|
||||||
|
<div className="top-50% left-50% -translate-x-50% -translate-y-50% absolute mb-20">
|
||||||
|
<Brand theme={theme === "dark" ? "dark" : "light"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute left-2 top-2">
|
||||||
|
<QuickSettings />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-2 top-2">
|
||||||
|
<ChatSettings />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex grow flex-col items-center justify-center" />
|
||||||
|
|
||||||
|
<div className="w-full min-w-[300px] items-end px-2 pb-3 pt-0 sm:w-[600px] sm:pb-8 sm:pt-5 md:w-[700px] lg:w-[700px] xl:w-[800px]">
|
||||||
|
<ChatInput />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-2 right-2 hidden md:block lg:bottom-4 lg:right-4">
|
||||||
|
<ChatHelp />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChatUI />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Dashboard } from "@/components/ui/dashboard"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getAssistantWorkspacesByWorkspaceId } from "@/db/assistants"
|
||||||
|
import { getChatsByWorkspaceId } from "@/db/chats"
|
||||||
|
import { getCollectionWorkspacesByWorkspaceId } from "@/db/collections"
|
||||||
|
import { getFileWorkspacesByWorkspaceId } from "@/db/files"
|
||||||
|
import { getFoldersByWorkspaceId } from "@/db/folders"
|
||||||
|
import { getModelWorkspacesByWorkspaceId } from "@/db/models"
|
||||||
|
import { getPresetWorkspacesByWorkspaceId } from "@/db/presets"
|
||||||
|
import { getPromptWorkspacesByWorkspaceId } from "@/db/prompts"
|
||||||
|
import { getAssistantImageFromStorage } from "@/db/storage/assistant-images"
|
||||||
|
import { getToolWorkspacesByWorkspaceId } from "@/db/tools"
|
||||||
|
import { getWorkspaceById } from "@/db/workspaces"
|
||||||
|
import { convertBlobToBase64 } from "@/lib/blob-to-b64"
|
||||||
|
import { supabase } from "@/lib/supabase/browser-client"
|
||||||
|
import { LLMID } from "@/types"
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { ReactNode, useContext, useEffect, useState } from "react"
|
||||||
|
import Loading from "../loading"
|
||||||
|
|
||||||
|
interface WorkspaceLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkspaceLayout({ children }: WorkspaceLayoutProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const params = useParams()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const workspaceId = params.workspaceid as string
|
||||||
|
|
||||||
|
const {
|
||||||
|
setChatSettings,
|
||||||
|
setAssistants,
|
||||||
|
setAssistantImages,
|
||||||
|
setChats,
|
||||||
|
setCollections,
|
||||||
|
setFolders,
|
||||||
|
setFiles,
|
||||||
|
setPresets,
|
||||||
|
setPrompts,
|
||||||
|
setTools,
|
||||||
|
setModels,
|
||||||
|
selectedWorkspace,
|
||||||
|
setSelectedWorkspace,
|
||||||
|
setSelectedChat,
|
||||||
|
setChatMessages,
|
||||||
|
setUserInput,
|
||||||
|
setIsGenerating,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
setChatFiles,
|
||||||
|
setChatImages,
|
||||||
|
setNewMessageFiles,
|
||||||
|
setNewMessageImages,
|
||||||
|
setShowFilesDisplay
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
const session = (await supabase.auth.getSession()).data.session
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return router.push("/login")
|
||||||
|
} else {
|
||||||
|
await fetchWorkspaceData(workspaceId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => await fetchWorkspaceData(workspaceId))()
|
||||||
|
|
||||||
|
setUserInput("")
|
||||||
|
setChatMessages([])
|
||||||
|
setSelectedChat(null)
|
||||||
|
|
||||||
|
setIsGenerating(false)
|
||||||
|
setFirstTokenReceived(false)
|
||||||
|
|
||||||
|
setChatFiles([])
|
||||||
|
setChatImages([])
|
||||||
|
setNewMessageFiles([])
|
||||||
|
setNewMessageImages([])
|
||||||
|
setShowFilesDisplay(false)
|
||||||
|
}, [workspaceId])
|
||||||
|
|
||||||
|
const fetchWorkspaceData = async (workspaceId: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const workspace = await getWorkspaceById(workspaceId)
|
||||||
|
setSelectedWorkspace(workspace)
|
||||||
|
|
||||||
|
const assistantData = await getAssistantWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setAssistants(assistantData.assistants)
|
||||||
|
|
||||||
|
for (const assistant of assistantData.assistants) {
|
||||||
|
let url = ""
|
||||||
|
|
||||||
|
if (assistant.image_path) {
|
||||||
|
url = (await getAssistantImageFromStorage(assistant.image_path)) || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const base64 = await convertBlobToBase64(blob)
|
||||||
|
|
||||||
|
setAssistantImages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
assistantId: assistant.id,
|
||||||
|
path: assistant.image_path,
|
||||||
|
base64,
|
||||||
|
url
|
||||||
|
}
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
setAssistantImages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
assistantId: assistant.id,
|
||||||
|
path: assistant.image_path,
|
||||||
|
base64: "",
|
||||||
|
url
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chats = await getChatsByWorkspaceId(workspaceId)
|
||||||
|
setChats(chats)
|
||||||
|
|
||||||
|
const collectionData =
|
||||||
|
await getCollectionWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setCollections(collectionData.collections)
|
||||||
|
|
||||||
|
const folders = await getFoldersByWorkspaceId(workspaceId)
|
||||||
|
setFolders(folders)
|
||||||
|
|
||||||
|
const fileData = await getFileWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setFiles(fileData.files)
|
||||||
|
|
||||||
|
const presetData = await getPresetWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setPresets(presetData.presets)
|
||||||
|
|
||||||
|
const promptData = await getPromptWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setPrompts(promptData.prompts)
|
||||||
|
|
||||||
|
const toolData = await getToolWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setTools(toolData.tools)
|
||||||
|
|
||||||
|
const modelData = await getModelWorkspacesByWorkspaceId(workspaceId)
|
||||||
|
setModels(modelData.models)
|
||||||
|
|
||||||
|
setChatSettings({
|
||||||
|
model: (searchParams.get("model") ||
|
||||||
|
workspace?.default_model ||
|
||||||
|
"gpt-4-1106-preview") as LLMID,
|
||||||
|
prompt:
|
||||||
|
workspace?.default_prompt ||
|
||||||
|
"You are a friendly, helpful AI assistant.",
|
||||||
|
temperature: workspace?.default_temperature || 0.5,
|
||||||
|
contextLength: workspace?.default_context_length || 4096,
|
||||||
|
includeProfileContext: workspace?.include_profile_context || true,
|
||||||
|
includeWorkspaceInstructions:
|
||||||
|
workspace?.include_workspace_instructions || true,
|
||||||
|
embeddingsProvider:
|
||||||
|
(workspace?.embeddings_provider as "openai" | "local") || "openai"
|
||||||
|
})
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dashboard>{children}</Dashboard>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { useContext } from "react"
|
||||||
|
|
||||||
|
export default function WorkspacePage() {
|
||||||
|
const { selectedWorkspace } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full flex-col items-center justify-center">
|
||||||
|
<div className="text-4xl">{selectedWorkspace?.name}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track:hover {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
|
||||||
|
--primary: 0 0% 9%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--ring: 0 0% 63.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 85.7% 97.3%;
|
||||||
|
|
||||||
|
--ring: 0 0% 14.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function HelpPage() {
|
||||||
|
return (
|
||||||
|
<div className="size-screen flex flex-col items-center justify-center">
|
||||||
|
<div className="text-4xl">Help under construction.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { GlobalState } from "@/components/utility/global-state"
|
||||||
|
import { Providers } from "@/components/utility/providers"
|
||||||
|
import TranslationsProvider from "@/components/utility/translations-provider"
|
||||||
|
import initTranslations from "@/lib/i18n"
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { createServerClient } from "@supabase/ssr"
|
||||||
|
import { Metadata, Viewport } from "next"
|
||||||
|
import { Inter } from "next/font/google"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { ReactNode } from "react"
|
||||||
|
import "./globals.css"
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] })
|
||||||
|
const APP_NAME = "Chatbot UI"
|
||||||
|
const APP_DEFAULT_TITLE = "Chatbot UI"
|
||||||
|
const APP_TITLE_TEMPLATE = "%s - Chatbot UI"
|
||||||
|
const APP_DESCRIPTION = "Chabot UI PWA!"
|
||||||
|
|
||||||
|
interface RootLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
params: {
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
applicationName: APP_NAME,
|
||||||
|
title: {
|
||||||
|
default: APP_DEFAULT_TITLE,
|
||||||
|
template: APP_TITLE_TEMPLATE
|
||||||
|
},
|
||||||
|
description: APP_DESCRIPTION,
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black",
|
||||||
|
title: APP_DEFAULT_TITLE
|
||||||
|
// startUpImage: [],
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
siteName: APP_NAME,
|
||||||
|
title: {
|
||||||
|
default: APP_DEFAULT_TITLE,
|
||||||
|
template: APP_TITLE_TEMPLATE
|
||||||
|
},
|
||||||
|
description: APP_DESCRIPTION
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: {
|
||||||
|
default: APP_DEFAULT_TITLE,
|
||||||
|
template: APP_TITLE_TEMPLATE
|
||||||
|
},
|
||||||
|
description: APP_DESCRIPTION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18nNamespaces = ["translation"]
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params: { locale }
|
||||||
|
}: RootLayoutProps) {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const supabase = createServerClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
get(name: string) {
|
||||||
|
return cookieStore.get(name)?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const session = (await supabase.auth.getSession()).data.session
|
||||||
|
|
||||||
|
const { t, resources } = await initTranslations(locale, i18nNamespaces)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<Providers attribute="class" defaultTheme="dark">
|
||||||
|
<TranslationsProvider
|
||||||
|
namespaces={i18nNamespaces}
|
||||||
|
locale={locale}
|
||||||
|
resources={resources}
|
||||||
|
>
|
||||||
|
<Toaster richColors position="top-center" duration={3000} />
|
||||||
|
<div className="bg-background text-foreground flex h-dvh flex-col items-center overflow-x-auto">
|
||||||
|
{session ? <GlobalState>{children}</GlobalState> : children}
|
||||||
|
</div>
|
||||||
|
</TranslationsProvider>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center">
|
||||||
|
<IconLoader2 className="mt-4 size-12 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { Brand } from "@/components/ui/brand"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { SubmitButton } from "@/components/ui/submit-button"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { createServerClient } from "@supabase/ssr"
|
||||||
|
import { get } from "@vercel/edge-config"
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { cookies, headers } from "next/headers"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Login"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Login({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: { message: string }
|
||||||
|
}) {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const supabase = createServerClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
get(name: string) {
|
||||||
|
return cookieStore.get(name)?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const session = (await supabase.auth.getSession()).data.session
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const { data: homeWorkspace, error } = await supabase
|
||||||
|
.from("workspaces")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.eq("is_home", true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!homeWorkspace) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(`/${homeWorkspace.id}/chat`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const signIn = async (formData: FormData) => {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
const email = formData.get("email") as string
|
||||||
|
const password = formData.get("password") as string
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const supabase = createClient(cookieStore)
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return redirect(`/login?message=${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: homeWorkspace, error: homeWorkspaceError } = await supabase
|
||||||
|
.from("workspaces")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", data.user.id)
|
||||||
|
.eq("is_home", true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!homeWorkspace) {
|
||||||
|
throw new Error(
|
||||||
|
homeWorkspaceError?.message || "An unexpected error occurred"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(`/${homeWorkspace.id}/chat`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnvVarOrEdgeConfigValue = async (name: string) => {
|
||||||
|
"use server"
|
||||||
|
if (process.env.EDGE_CONFIG) {
|
||||||
|
return await get<string>(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
const signUp = async (formData: FormData) => {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
const email = formData.get("email") as string
|
||||||
|
const password = formData.get("password") as string
|
||||||
|
|
||||||
|
const emailDomainWhitelistPatternsString = await getEnvVarOrEdgeConfigValue(
|
||||||
|
"EMAIL_DOMAIN_WHITELIST"
|
||||||
|
)
|
||||||
|
const emailDomainWhitelist = emailDomainWhitelistPatternsString?.trim()
|
||||||
|
? emailDomainWhitelistPatternsString?.split(",")
|
||||||
|
: []
|
||||||
|
const emailWhitelistPatternsString =
|
||||||
|
await getEnvVarOrEdgeConfigValue("EMAIL_WHITELIST")
|
||||||
|
const emailWhitelist = emailWhitelistPatternsString?.trim()
|
||||||
|
? emailWhitelistPatternsString?.split(",")
|
||||||
|
: []
|
||||||
|
|
||||||
|
// If there are whitelist patterns, check if the email is allowed to sign up
|
||||||
|
if (emailDomainWhitelist.length > 0 || emailWhitelist.length > 0) {
|
||||||
|
const domainMatch = emailDomainWhitelist?.includes(email.split("@")[1])
|
||||||
|
const emailMatch = emailWhitelist?.includes(email)
|
||||||
|
if (!domainMatch && !emailMatch) {
|
||||||
|
return redirect(
|
||||||
|
`/login?message=Email ${email} is not allowed to sign up.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const supabase = createClient(cookieStore)
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
// USE IF YOU WANT TO SEND EMAIL VERIFICATION, ALSO CHANGE TOML FILE
|
||||||
|
// emailRedirectTo: `${origin}/auth/callback`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
return redirect(`/login?message=${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/setup")
|
||||||
|
|
||||||
|
// USE IF YOU WANT TO SEND EMAIL VERIFICATION, ALSO CHANGE TOML FILE
|
||||||
|
// return redirect("/login?message=Check email to continue sign in process")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetPassword = async (formData: FormData) => {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
const origin = headers().get("origin")
|
||||||
|
const email = formData.get("email") as string
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const supabase = createClient(cookieStore)
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${origin}/auth/callback?next=/login/password`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return redirect(`/login?message=${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/login?message=Check email to reset password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-1 flex-col justify-center gap-2 px-8 sm:max-w-md">
|
||||||
|
<form
|
||||||
|
className="animate-in text-foreground flex w-full flex-1 flex-col justify-center gap-2"
|
||||||
|
action={signIn}
|
||||||
|
>
|
||||||
|
<Brand />
|
||||||
|
|
||||||
|
<Label className="text-md mt-4" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="mb-3 rounded-md border bg-inherit px-4 py-2"
|
||||||
|
name="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Label className="text-md" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="mb-6 rounded-md border bg-inherit px-4 py-2"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton className="mb-2 rounded-md bg-blue-700 px-4 py-2 text-white">
|
||||||
|
Login
|
||||||
|
</SubmitButton>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
formAction={signUp}
|
||||||
|
className="border-foreground/20 mb-2 rounded-md border px-4 py-2"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</SubmitButton>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-1 flex justify-center text-sm">
|
||||||
|
<span className="mr-1">Forgot your password?</span>
|
||||||
|
<button
|
||||||
|
formAction={handleResetPassword}
|
||||||
|
className="text-primary ml-1 underline hover:opacity-80"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchParams?.message && (
|
||||||
|
<p className="bg-foreground/10 text-foreground mt-4 p-4 text-center">
|
||||||
|
{searchParams.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChangePassword } from "@/components/utility/change-password"
|
||||||
|
import { supabase } from "@/lib/supabase/browser-client"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
const session = (await supabase.auth.getSession()).data.session
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
router.push("/login")
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChangePassword />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChatbotUISVG } from "@/components/icons/chatbotui-svg"
|
||||||
|
import { IconArrowRight } from "@tabler/icons-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<ChatbotUISVG theme={theme === "dark" ? "dark" : "light"} scale={0.3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-4xl font-bold">Chatbot UI</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className="mt-4 flex w-[200px] items-center justify-center rounded-md bg-blue-500 p-2 font-semibold"
|
||||||
|
href="/login"
|
||||||
|
>
|
||||||
|
Start Chatting
|
||||||
|
<IconArrowRight className="ml-1" size={20} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getProfileByUserId, updateProfile } from "@/db/profile"
|
||||||
|
import {
|
||||||
|
getHomeWorkspaceByUserId,
|
||||||
|
getWorkspacesByUserId
|
||||||
|
} from "@/db/workspaces"
|
||||||
|
import {
|
||||||
|
fetchHostedModels,
|
||||||
|
fetchOpenRouterModels
|
||||||
|
} from "@/lib/models/fetch-models"
|
||||||
|
import { supabase } from "@/lib/supabase/browser-client"
|
||||||
|
import { TablesUpdate } from "@/supabase/types"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useContext, useEffect, useState } from "react"
|
||||||
|
import { APIStep } from "../../../components/setup/api-step"
|
||||||
|
import { FinishStep } from "../../../components/setup/finish-step"
|
||||||
|
import { ProfileStep } from "../../../components/setup/profile-step"
|
||||||
|
import {
|
||||||
|
SETUP_STEP_COUNT,
|
||||||
|
StepContainer
|
||||||
|
} from "../../../components/setup/step-container"
|
||||||
|
|
||||||
|
export default function SetupPage() {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
setProfile,
|
||||||
|
setWorkspaces,
|
||||||
|
setSelectedWorkspace,
|
||||||
|
setEnvKeyMap,
|
||||||
|
setAvailableHostedModels,
|
||||||
|
setAvailableOpenRouterModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(1)
|
||||||
|
|
||||||
|
// Profile Step
|
||||||
|
const [displayName, setDisplayName] = useState("")
|
||||||
|
const [username, setUsername] = useState(profile?.username || "")
|
||||||
|
const [usernameAvailable, setUsernameAvailable] = useState(true)
|
||||||
|
|
||||||
|
// API Step
|
||||||
|
const [useAzureOpenai, setUseAzureOpenai] = useState(false)
|
||||||
|
const [openaiAPIKey, setOpenaiAPIKey] = useState("")
|
||||||
|
const [openaiOrgID, setOpenaiOrgID] = useState("")
|
||||||
|
const [azureOpenaiAPIKey, setAzureOpenaiAPIKey] = useState("")
|
||||||
|
const [azureOpenaiEndpoint, setAzureOpenaiEndpoint] = useState("")
|
||||||
|
const [azureOpenai35TurboID, setAzureOpenai35TurboID] = useState("")
|
||||||
|
const [azureOpenai45TurboID, setAzureOpenai45TurboID] = useState("")
|
||||||
|
const [azureOpenai45VisionID, setAzureOpenai45VisionID] = useState("")
|
||||||
|
const [azureOpenaiEmbeddingsID, setAzureOpenaiEmbeddingsID] = useState("")
|
||||||
|
const [anthropicAPIKey, setAnthropicAPIKey] = useState("")
|
||||||
|
const [googleGeminiAPIKey, setGoogleGeminiAPIKey] = useState("")
|
||||||
|
const [mistralAPIKey, setMistralAPIKey] = useState("")
|
||||||
|
const [groqAPIKey, setGroqAPIKey] = useState("")
|
||||||
|
const [perplexityAPIKey, setPerplexityAPIKey] = useState("")
|
||||||
|
const [openrouterAPIKey, setOpenrouterAPIKey] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
const session = (await supabase.auth.getSession()).data.session
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return router.push("/login")
|
||||||
|
} else {
|
||||||
|
const user = session.user
|
||||||
|
|
||||||
|
const profile = await getProfileByUserId(user.id)
|
||||||
|
setProfile(profile)
|
||||||
|
setUsername(profile.username)
|
||||||
|
|
||||||
|
if (!profile.has_onboarded) {
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
const data = await fetchHostedModels(profile)
|
||||||
|
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
setEnvKeyMap(data.envKeyMap)
|
||||||
|
setAvailableHostedModels(data.hostedModels)
|
||||||
|
|
||||||
|
if (profile["openrouter_api_key"] || data.envKeyMap["openrouter"]) {
|
||||||
|
const openRouterModels = await fetchOpenRouterModels()
|
||||||
|
if (!openRouterModels) return
|
||||||
|
setAvailableOpenRouterModels(openRouterModels)
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeWorkspaceId = await getHomeWorkspaceByUserId(
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
return router.push(`/${homeWorkspaceId}/chat`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleShouldProceed = (proceed: boolean) => {
|
||||||
|
if (proceed) {
|
||||||
|
if (currentStep === SETUP_STEP_COUNT) {
|
||||||
|
handleSaveSetupSetting()
|
||||||
|
} else {
|
||||||
|
setCurrentStep(currentStep + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentStep(currentStep - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSetupSetting = async () => {
|
||||||
|
const session = (await supabase.auth.getSession()).data.session
|
||||||
|
if (!session) {
|
||||||
|
return router.push("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.user
|
||||||
|
const profile = await getProfileByUserId(user.id)
|
||||||
|
|
||||||
|
const updateProfilePayload: TablesUpdate<"profiles"> = {
|
||||||
|
...profile,
|
||||||
|
has_onboarded: true,
|
||||||
|
display_name: displayName,
|
||||||
|
username,
|
||||||
|
openai_api_key: openaiAPIKey,
|
||||||
|
openai_organization_id: openaiOrgID,
|
||||||
|
anthropic_api_key: anthropicAPIKey,
|
||||||
|
google_gemini_api_key: googleGeminiAPIKey,
|
||||||
|
mistral_api_key: mistralAPIKey,
|
||||||
|
groq_api_key: groqAPIKey,
|
||||||
|
perplexity_api_key: perplexityAPIKey,
|
||||||
|
openrouter_api_key: openrouterAPIKey,
|
||||||
|
use_azure_openai: useAzureOpenai,
|
||||||
|
azure_openai_api_key: azureOpenaiAPIKey,
|
||||||
|
azure_openai_endpoint: azureOpenaiEndpoint,
|
||||||
|
azure_openai_35_turbo_id: azureOpenai35TurboID,
|
||||||
|
azure_openai_45_turbo_id: azureOpenai45TurboID,
|
||||||
|
azure_openai_45_vision_id: azureOpenai45VisionID,
|
||||||
|
azure_openai_embeddings_id: azureOpenaiEmbeddingsID
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProfile = await updateProfile(profile.id, updateProfilePayload)
|
||||||
|
setProfile(updatedProfile)
|
||||||
|
|
||||||
|
const workspaces = await getWorkspacesByUserId(profile.user_id)
|
||||||
|
const homeWorkspace = workspaces.find(w => w.is_home)
|
||||||
|
|
||||||
|
// There will always be a home workspace
|
||||||
|
setSelectedWorkspace(homeWorkspace!)
|
||||||
|
setWorkspaces(workspaces)
|
||||||
|
|
||||||
|
return router.push(`/${homeWorkspace?.id}/chat`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderStep = (stepNum: number) => {
|
||||||
|
switch (stepNum) {
|
||||||
|
// Profile Step
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<StepContainer
|
||||||
|
stepDescription="Let's create your profile."
|
||||||
|
stepNum={currentStep}
|
||||||
|
stepTitle="Welcome to Chatbot UI"
|
||||||
|
onShouldProceed={handleShouldProceed}
|
||||||
|
showNextButton={!!(username && usernameAvailable)}
|
||||||
|
showBackButton={false}
|
||||||
|
>
|
||||||
|
<ProfileStep
|
||||||
|
username={username}
|
||||||
|
usernameAvailable={usernameAvailable}
|
||||||
|
displayName={displayName}
|
||||||
|
onUsernameAvailableChange={setUsernameAvailable}
|
||||||
|
onUsernameChange={setUsername}
|
||||||
|
onDisplayNameChange={setDisplayName}
|
||||||
|
/>
|
||||||
|
</StepContainer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// API Step
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<StepContainer
|
||||||
|
stepDescription="Enter API keys for each service you'd like to use."
|
||||||
|
stepNum={currentStep}
|
||||||
|
stepTitle="Set API Keys (optional)"
|
||||||
|
onShouldProceed={handleShouldProceed}
|
||||||
|
showNextButton={true}
|
||||||
|
showBackButton={true}
|
||||||
|
>
|
||||||
|
<APIStep
|
||||||
|
openaiAPIKey={openaiAPIKey}
|
||||||
|
openaiOrgID={openaiOrgID}
|
||||||
|
azureOpenaiAPIKey={azureOpenaiAPIKey}
|
||||||
|
azureOpenaiEndpoint={azureOpenaiEndpoint}
|
||||||
|
azureOpenai35TurboID={azureOpenai35TurboID}
|
||||||
|
azureOpenai45TurboID={azureOpenai45TurboID}
|
||||||
|
azureOpenai45VisionID={azureOpenai45VisionID}
|
||||||
|
azureOpenaiEmbeddingsID={azureOpenaiEmbeddingsID}
|
||||||
|
anthropicAPIKey={anthropicAPIKey}
|
||||||
|
googleGeminiAPIKey={googleGeminiAPIKey}
|
||||||
|
mistralAPIKey={mistralAPIKey}
|
||||||
|
groqAPIKey={groqAPIKey}
|
||||||
|
perplexityAPIKey={perplexityAPIKey}
|
||||||
|
useAzureOpenai={useAzureOpenai}
|
||||||
|
onOpenaiAPIKeyChange={setOpenaiAPIKey}
|
||||||
|
onOpenaiOrgIDChange={setOpenaiOrgID}
|
||||||
|
onAzureOpenaiAPIKeyChange={setAzureOpenaiAPIKey}
|
||||||
|
onAzureOpenaiEndpointChange={setAzureOpenaiEndpoint}
|
||||||
|
onAzureOpenai35TurboIDChange={setAzureOpenai35TurboID}
|
||||||
|
onAzureOpenai45TurboIDChange={setAzureOpenai45TurboID}
|
||||||
|
onAzureOpenai45VisionIDChange={setAzureOpenai45VisionID}
|
||||||
|
onAzureOpenaiEmbeddingsIDChange={setAzureOpenaiEmbeddingsID}
|
||||||
|
onAnthropicAPIKeyChange={setAnthropicAPIKey}
|
||||||
|
onGoogleGeminiAPIKeyChange={setGoogleGeminiAPIKey}
|
||||||
|
onMistralAPIKeyChange={setMistralAPIKey}
|
||||||
|
onGroqAPIKeyChange={setGroqAPIKey}
|
||||||
|
onPerplexityAPIKeyChange={setPerplexityAPIKey}
|
||||||
|
onUseAzureOpenaiChange={setUseAzureOpenai}
|
||||||
|
openrouterAPIKey={openrouterAPIKey}
|
||||||
|
onOpenrouterAPIKeyChange={setOpenrouterAPIKey}
|
||||||
|
/>
|
||||||
|
</StepContainer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finish Step
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<StepContainer
|
||||||
|
stepDescription="You are all set up!"
|
||||||
|
stepNum={currentStep}
|
||||||
|
stepTitle="Setup Complete"
|
||||||
|
onShouldProceed={handleShouldProceed}
|
||||||
|
showNextButton={true}
|
||||||
|
showBackButton={true}
|
||||||
|
>
|
||||||
|
<FinishStep displayName={displayName} />
|
||||||
|
</StepContainer>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
{renderStep(currentStep)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ServerRuntime } from "next"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export const runtime: ServerRuntime = "edge"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
|
||||||
|
const myAssistants = await openai.beta.assistants.list({
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ assistants: myAssistants.data }), {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { getBase64FromDataURL, getMediaTypeFromDataURL } from "@/lib/utils"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import Anthropic from "@anthropic-ai/sdk"
|
||||||
|
import { AnthropicStream, StreamingTextResponse } from "ai"
|
||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.anthropic_api_key, "Anthropic")
|
||||||
|
|
||||||
|
let ANTHROPIC_FORMATTED_MESSAGES: any = messages.slice(1)
|
||||||
|
|
||||||
|
ANTHROPIC_FORMATTED_MESSAGES = ANTHROPIC_FORMATTED_MESSAGES?.map(
|
||||||
|
(message: any) => {
|
||||||
|
const messageContent =
|
||||||
|
typeof message?.content === "string"
|
||||||
|
? [message.content]
|
||||||
|
: message?.content
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: messageContent.map((content: any) => {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
// Handle the case where content is a string
|
||||||
|
return { type: "text", text: content }
|
||||||
|
} else if (
|
||||||
|
content?.type === "image_url" &&
|
||||||
|
content?.image_url?.url?.length
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: getMediaTypeFromDataURL(content.image_url.url),
|
||||||
|
data: getBase64FromDataURL(content.image_url.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const anthropic = new Anthropic({
|
||||||
|
apiKey: profile.anthropic_api_key || ""
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
model: chatSettings.model,
|
||||||
|
messages: ANTHROPIC_FORMATTED_MESSAGES,
|
||||||
|
temperature: chatSettings.temperature,
|
||||||
|
system: messages[0].content,
|
||||||
|
max_tokens:
|
||||||
|
CHAT_SETTING_LIMITS[chatSettings.model].MAX_TOKEN_OUTPUT_LENGTH,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = AnthropicStream(response)
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error parsing Anthropic API response:", error)
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
message:
|
||||||
|
"An error occurred while parsing the Anthropic API response"
|
||||||
|
}),
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error calling Anthropic API:", error)
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "An error occurred while calling the Anthropic API"
|
||||||
|
}),
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"Anthropic API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorCode === 401) {
|
||||||
|
errorMessage =
|
||||||
|
"Anthropic API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatAPIPayload } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as ChatAPIPayload
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
|
||||||
|
|
||||||
|
const ENDPOINT = profile.azure_openai_endpoint
|
||||||
|
const KEY = profile.azure_openai_api_key
|
||||||
|
|
||||||
|
let DEPLOYMENT_ID = ""
|
||||||
|
switch (chatSettings.model) {
|
||||||
|
case "gpt-3.5-turbo":
|
||||||
|
DEPLOYMENT_ID = profile.azure_openai_35_turbo_id || ""
|
||||||
|
break
|
||||||
|
case "gpt-4-turbo-preview":
|
||||||
|
DEPLOYMENT_ID = profile.azure_openai_45_turbo_id || ""
|
||||||
|
break
|
||||||
|
case "gpt-4-vision-preview":
|
||||||
|
DEPLOYMENT_ID = profile.azure_openai_45_vision_id || ""
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return new Response(JSON.stringify({ message: "Model not found" }), {
|
||||||
|
status: 400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ENDPOINT || !KEY || !DEPLOYMENT_ID) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ message: "Azure resources not found" }),
|
||||||
|
{
|
||||||
|
status: 400
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const azureOpenai = new OpenAI({
|
||||||
|
apiKey: KEY,
|
||||||
|
baseURL: `${ENDPOINT}/openai/deployments/${DEPLOYMENT_ID}`,
|
||||||
|
defaultQuery: { "api-version": "2023-12-01-preview" },
|
||||||
|
defaultHeaders: { "api-key": KEY }
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await azureOpenai.chat.completions.create({
|
||||||
|
model: DEPLOYMENT_ID as ChatCompletionCreateParamsBase["model"],
|
||||||
|
messages: messages as ChatCompletionCreateParamsBase["messages"],
|
||||||
|
temperature: chatSettings.temperature,
|
||||||
|
max_tokens: chatSettings.model === "gpt-4-vision-preview" ? 4096 : null, // TODO: Fix
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import { ServerRuntime } from "next"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
|
||||||
|
|
||||||
|
export const runtime: ServerRuntime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages, customModelId } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
customModelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseAdmin = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: customModel, error } = await supabaseAdmin
|
||||||
|
.from("models")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", customModelId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!customModel) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const custom = new OpenAI({
|
||||||
|
apiKey: customModel.api_key || "",
|
||||||
|
baseURL: customModel.base_url
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await custom.chat.completions.create({
|
||||||
|
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
|
||||||
|
messages: messages as ChatCompletionCreateParamsBase["messages"],
|
||||||
|
temperature: chatSettings.temperature,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"Custom API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorMessage.toLowerCase().includes("incorrect api key")) {
|
||||||
|
errorMessage =
|
||||||
|
"Custom API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { GoogleGenerativeAI } from "@google/generative-ai"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.google_gemini_api_key, "Google")
|
||||||
|
|
||||||
|
const genAI = new GoogleGenerativeAI(profile.google_gemini_api_key || "")
|
||||||
|
const googleModel = genAI.getGenerativeModel({ model: chatSettings.model })
|
||||||
|
|
||||||
|
const lastMessage = messages.pop()
|
||||||
|
|
||||||
|
const chat = googleModel.startChat({
|
||||||
|
history: messages,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: chatSettings.temperature
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await chat.sendMessageStream(lastMessage.parts)
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const readableStream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
for await (const chunk of response.stream) {
|
||||||
|
const chunkText = chunk.text()
|
||||||
|
controller.enqueue(encoder.encode(chunkText))
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(readableStream, {
|
||||||
|
headers: { "Content-Type": "text/plain" }
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"Google Gemini API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorMessage.toLowerCase().includes("api key not valid")) {
|
||||||
|
errorMessage =
|
||||||
|
"Google Gemini API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.groq_api_key, "G")
|
||||||
|
|
||||||
|
// Groq is compatible with the OpenAI SDK
|
||||||
|
const groq = new OpenAI({
|
||||||
|
apiKey: profile.groq_api_key || "",
|
||||||
|
baseURL: "https://api.groq.com/openai/v1"
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await groq.chat.completions.create({
|
||||||
|
model: chatSettings.model,
|
||||||
|
messages,
|
||||||
|
max_tokens:
|
||||||
|
CHAT_SETTING_LIMITS[chatSettings.model].MAX_TOKEN_OUTPUT_LENGTH,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert the response into a friendly text-stream.
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
// Respond with the stream
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"Groq API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorCode === 401) {
|
||||||
|
errorMessage =
|
||||||
|
"Groq API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.mistral_api_key, "Mistral")
|
||||||
|
|
||||||
|
// Mistral is compatible the OpenAI SDK
|
||||||
|
const mistral = new OpenAI({
|
||||||
|
apiKey: profile.mistral_api_key || "",
|
||||||
|
baseURL: "https://api.mistral.ai/v1"
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await mistral.chat.completions.create({
|
||||||
|
model: chatSettings.model,
|
||||||
|
messages,
|
||||||
|
max_tokens:
|
||||||
|
CHAT_SETTING_LIMITS[chatSettings.model].MAX_TOKEN_OUTPUT_LENGTH,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert the response into a friendly text-stream.
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
// Respond with the stream
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"Mistral API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorCode === 401) {
|
||||||
|
errorMessage =
|
||||||
|
"Mistral API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import { ServerRuntime } from "next"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
|
||||||
|
|
||||||
|
export const runtime: ServerRuntime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
|
||||||
|
messages: messages as ChatCompletionCreateParamsBase["messages"],
|
||||||
|
temperature: chatSettings.temperature,
|
||||||
|
max_tokens:
|
||||||
|
chatSettings.model === "gpt-4-vision-preview" ||
|
||||||
|
chatSettings.model === "gpt-4o"
|
||||||
|
? 4096
|
||||||
|
: null, // TODO: Fix
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"OpenAI API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorMessage.toLowerCase().includes("incorrect api key")) {
|
||||||
|
errorMessage =
|
||||||
|
"OpenAI API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import { ServerRuntime } from "next"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
|
||||||
|
|
||||||
|
export const runtime: ServerRuntime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.openrouter_api_key, "OpenRouter")
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: profile.openrouter_api_key || "",
|
||||||
|
baseURL: "https://openrouter.ai/api/v1"
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
|
||||||
|
messages: messages as ChatCompletionCreateParamsBase["messages"],
|
||||||
|
temperature: chatSettings.temperature,
|
||||||
|
max_tokens: undefined,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"OpenRouter API Key not found. Please set it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.perplexity_api_key, "Perplexity")
|
||||||
|
|
||||||
|
// Perplexity is compatible the OpenAI SDK
|
||||||
|
const perplexity = new OpenAI({
|
||||||
|
apiKey: profile.perplexity_api_key || "",
|
||||||
|
baseURL: "https://api.perplexity.ai/"
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await perplexity.chat.completions.create({
|
||||||
|
model: chatSettings.model,
|
||||||
|
messages,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response)
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = error.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes("api key not found")) {
|
||||||
|
errorMessage =
|
||||||
|
"Perplexity API Key not found. Please set it in your profile settings."
|
||||||
|
} else if (errorCode === 401) {
|
||||||
|
errorMessage =
|
||||||
|
"Perplexity API Key is incorrect. Please fix it in your profile settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { openapiToFunctions } from "@/lib/openapi-conversion"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { ChatSettings } from "@/types"
|
||||||
|
import { OpenAIStream, StreamingTextResponse } from "ai"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { chatSettings, messages, selectedTools } = json as {
|
||||||
|
chatSettings: ChatSettings
|
||||||
|
messages: any[]
|
||||||
|
selectedTools: Tables<"tools">[]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
|
||||||
|
let allTools: OpenAI.Chat.Completions.ChatCompletionTool[] = []
|
||||||
|
let allRouteMaps = {}
|
||||||
|
let schemaDetails = []
|
||||||
|
|
||||||
|
for (const selectedTool of selectedTools) {
|
||||||
|
try {
|
||||||
|
const convertedSchema = await openapiToFunctions(
|
||||||
|
JSON.parse(selectedTool.schema as string)
|
||||||
|
)
|
||||||
|
const tools = convertedSchema.functions || []
|
||||||
|
allTools = allTools.concat(tools)
|
||||||
|
|
||||||
|
const routeMap = convertedSchema.routes.reduce(
|
||||||
|
(map: Record<string, string>, route) => {
|
||||||
|
map[route.path.replace(/{(\w+)}/g, ":$1")] = route.operationId
|
||||||
|
return map
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
allRouteMaps = { ...allRouteMaps, ...routeMap }
|
||||||
|
|
||||||
|
schemaDetails.push({
|
||||||
|
title: convertedSchema.info.title,
|
||||||
|
description: convertedSchema.info.description,
|
||||||
|
url: convertedSchema.info.server,
|
||||||
|
headers: selectedTool.custom_headers,
|
||||||
|
routeMap,
|
||||||
|
requestInBody: convertedSchema.routes[0].requestInBody
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error converting schema", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstResponse = await openai.chat.completions.create({
|
||||||
|
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
|
||||||
|
messages,
|
||||||
|
tools: allTools.length > 0 ? allTools : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const message = firstResponse.choices[0].message
|
||||||
|
messages.push(message)
|
||||||
|
const toolCalls = message.tool_calls || []
|
||||||
|
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
return new Response(message.content, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const functionCall = toolCall.function
|
||||||
|
const functionName = functionCall.name
|
||||||
|
const argumentsString = toolCall.function.arguments.trim()
|
||||||
|
const parsedArgs = JSON.parse(argumentsString)
|
||||||
|
|
||||||
|
// Find the schema detail that contains the function name
|
||||||
|
const schemaDetail = schemaDetails.find(detail =>
|
||||||
|
Object.values(detail.routeMap).includes(functionName)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!schemaDetail) {
|
||||||
|
throw new Error(`Function ${functionName} not found in any schema`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathTemplate = Object.keys(schemaDetail.routeMap).find(
|
||||||
|
key => schemaDetail.routeMap[key] === functionName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!pathTemplate) {
|
||||||
|
throw new Error(`Path for function ${functionName} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = pathTemplate.replace(/:(\w+)/g, (_, paramName) => {
|
||||||
|
const value = parsedArgs.parameters[paramName]
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(
|
||||||
|
`Parameter ${paramName} not found for function ${functionName}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return encodeURIComponent(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
throw new Error(`Path for function ${functionName} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the request should be in the body or as a query
|
||||||
|
const isRequestInBody = schemaDetail.requestInBody
|
||||||
|
let data = {}
|
||||||
|
|
||||||
|
if (isRequestInBody) {
|
||||||
|
// If the type is set to body
|
||||||
|
let headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if custom headers are set
|
||||||
|
const customHeaders = schemaDetail.headers // Moved this line up to the loop
|
||||||
|
// Check if custom headers are set and are of type string
|
||||||
|
if (customHeaders && typeof customHeaders === "string") {
|
||||||
|
let parsedCustomHeaders = JSON.parse(customHeaders) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
...headers,
|
||||||
|
...parsedCustomHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullUrl = schemaDetail.url + path
|
||||||
|
|
||||||
|
const bodyContent = parsedArgs.requestBody || parsedArgs
|
||||||
|
|
||||||
|
const requestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(bodyContent) // Use the extracted requestBody or the entire parsedArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, requestInit)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
data = {
|
||||||
|
error: response.statusText
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await response.json()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the type is set to query
|
||||||
|
const queryParams = new URLSearchParams(
|
||||||
|
parsedArgs.parameters
|
||||||
|
).toString()
|
||||||
|
const fullUrl =
|
||||||
|
schemaDetail.url + path + (queryParams ? "?" + queryParams : "")
|
||||||
|
|
||||||
|
let headers = {}
|
||||||
|
|
||||||
|
// Check if custom headers are set
|
||||||
|
const customHeaders = schemaDetail.headers
|
||||||
|
if (customHeaders && typeof customHeaders === "string") {
|
||||||
|
headers = JSON.parse(customHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
data = {
|
||||||
|
error: response.statusText
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
role: "tool",
|
||||||
|
name: functionName,
|
||||||
|
content: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondResponse = await openai.chat.completions.create({
|
||||||
|
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
|
||||||
|
messages,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = OpenAIStream(secondResponse)
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error)
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { input } = json as {
|
||||||
|
input: string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4-1106-preview",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "Respond to the user."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: input
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens:
|
||||||
|
CHAT_SETTING_LIMITS["gpt-4-turbo-preview"].MAX_TOKEN_OUTPUT_LENGTH
|
||||||
|
// response_format: { type: "json_object" }
|
||||||
|
// stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.choices[0].message.content
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ content }), {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { isUsingEnvironmentKey } from "@/lib/envs"
|
||||||
|
import { createResponse } from "@/lib/server/server-utils"
|
||||||
|
import { EnvKey } from "@/types/key-type"
|
||||||
|
import { VALID_ENV_KEYS } from "@/types/valid-keys"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const envKeyMap: Record<string, VALID_ENV_KEYS> = {
|
||||||
|
azure: VALID_ENV_KEYS.AZURE_OPENAI_API_KEY,
|
||||||
|
openai: VALID_ENV_KEYS.OPENAI_API_KEY,
|
||||||
|
google: VALID_ENV_KEYS.GOOGLE_GEMINI_API_KEY,
|
||||||
|
anthropic: VALID_ENV_KEYS.ANTHROPIC_API_KEY,
|
||||||
|
mistral: VALID_ENV_KEYS.MISTRAL_API_KEY,
|
||||||
|
groq: VALID_ENV_KEYS.GROQ_API_KEY,
|
||||||
|
perplexity: VALID_ENV_KEYS.PERPLEXITY_API_KEY,
|
||||||
|
openrouter: VALID_ENV_KEYS.OPENROUTER_API_KEY,
|
||||||
|
|
||||||
|
openai_organization_id: VALID_ENV_KEYS.OPENAI_ORGANIZATION_ID,
|
||||||
|
|
||||||
|
azure_openai_endpoint: VALID_ENV_KEYS.AZURE_OPENAI_ENDPOINT,
|
||||||
|
azure_gpt_35_turbo_name: VALID_ENV_KEYS.AZURE_GPT_35_TURBO_NAME,
|
||||||
|
azure_gpt_45_vision_name: VALID_ENV_KEYS.AZURE_GPT_45_VISION_NAME,
|
||||||
|
azure_gpt_45_turbo_name: VALID_ENV_KEYS.AZURE_GPT_45_TURBO_NAME,
|
||||||
|
azure_embeddings_name: VALID_ENV_KEYS.AZURE_EMBEDDINGS_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUsingEnvKeyMap = Object.keys(envKeyMap).reduce<
|
||||||
|
Record<string, boolean>
|
||||||
|
>((acc, provider) => {
|
||||||
|
const key = envKeyMap[provider]
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
acc[provider] = isUsingEnvironmentKey(key as EnvKey)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return createResponse({ isUsingEnvKeyMap }, 200)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { generateLocalEmbedding } from "@/lib/generate-local-embedding"
|
||||||
|
import { processDocX } from "@/lib/retrieval/processing"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { FileItemChunk } from "@/types"
|
||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const json = await req.json()
|
||||||
|
const { text, fileId, embeddingsProvider, fileExtension } = json as {
|
||||||
|
text: string
|
||||||
|
fileId: string
|
||||||
|
embeddingsProvider: "openai" | "local"
|
||||||
|
fileExtension: string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseAdmin = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
if (embeddingsProvider === "openai") {
|
||||||
|
if (profile.use_azure_openai) {
|
||||||
|
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
|
||||||
|
} else {
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks: FileItemChunk[] = []
|
||||||
|
|
||||||
|
switch (fileExtension) {
|
||||||
|
case "docx":
|
||||||
|
chunks = await processDocX(text)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return new NextResponse("Unsupported file type", {
|
||||||
|
status: 400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let embeddings: any = []
|
||||||
|
|
||||||
|
let openai
|
||||||
|
if (profile.use_azure_openai) {
|
||||||
|
openai = new OpenAI({
|
||||||
|
apiKey: profile.azure_openai_api_key || "",
|
||||||
|
baseURL: `${profile.azure_openai_endpoint}/openai/deployments/${profile.azure_openai_embeddings_id}`,
|
||||||
|
defaultQuery: { "api-version": "2023-12-01-preview" },
|
||||||
|
defaultHeaders: { "api-key": profile.azure_openai_api_key }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddingsProvider === "openai") {
|
||||||
|
const response = await openai.embeddings.create({
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
input: chunks.map(chunk => chunk.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings = response.data.map((item: any) => {
|
||||||
|
return item.embedding
|
||||||
|
})
|
||||||
|
} else if (embeddingsProvider === "local") {
|
||||||
|
const embeddingPromises = chunks.map(async chunk => {
|
||||||
|
try {
|
||||||
|
return await generateLocalEmbedding(chunk.content)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error generating embedding for chunk: ${chunk}`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings = await Promise.all(embeddingPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
const file_items = chunks.map((chunk, index) => ({
|
||||||
|
file_id: fileId,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
content: chunk.content,
|
||||||
|
tokens: chunk.tokens,
|
||||||
|
openai_embedding:
|
||||||
|
embeddingsProvider === "openai"
|
||||||
|
? ((embeddings[index] || null) as any)
|
||||||
|
: null,
|
||||||
|
local_embedding:
|
||||||
|
embeddingsProvider === "local"
|
||||||
|
? ((embeddings[index] || null) as any)
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
|
||||||
|
await supabaseAdmin.from("file_items").upsert(file_items)
|
||||||
|
|
||||||
|
const totalTokens = file_items.reduce((acc, item) => acc + item.tokens, 0)
|
||||||
|
|
||||||
|
await supabaseAdmin
|
||||||
|
.from("files")
|
||||||
|
.update({ tokens: totalTokens })
|
||||||
|
.eq("id", fileId)
|
||||||
|
|
||||||
|
return new NextResponse("Embed Successful", {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error)
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { generateLocalEmbedding } from "@/lib/generate-local-embedding"
|
||||||
|
import {
|
||||||
|
processCSV,
|
||||||
|
processJSON,
|
||||||
|
processMarkdown,
|
||||||
|
processPdf,
|
||||||
|
processTxt
|
||||||
|
} from "@/lib/retrieval/processing"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { FileItemChunk } from "@/types"
|
||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const supabaseAdmin = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
|
||||||
|
const file_id = formData.get("file_id") as string
|
||||||
|
const embeddingsProvider = formData.get("embeddingsProvider") as string
|
||||||
|
|
||||||
|
const { data: fileMetadata, error: metadataError } = await supabaseAdmin
|
||||||
|
.from("files")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", file_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (metadataError) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to retrieve file metadata: ${metadataError.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileMetadata) {
|
||||||
|
throw new Error("File not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileMetadata.user_id !== profile.user_id) {
|
||||||
|
throw new Error("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: file, error: fileError } = await supabaseAdmin.storage
|
||||||
|
.from("files")
|
||||||
|
.download(fileMetadata.file_path)
|
||||||
|
|
||||||
|
if (fileError)
|
||||||
|
throw new Error(`Failed to retrieve file: ${fileError.message}`)
|
||||||
|
|
||||||
|
const fileBuffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
const blob = new Blob([fileBuffer])
|
||||||
|
const fileExtension = fileMetadata.name.split(".").pop()?.toLowerCase()
|
||||||
|
|
||||||
|
if (embeddingsProvider === "openai") {
|
||||||
|
try {
|
||||||
|
if (profile.use_azure_openai) {
|
||||||
|
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
|
||||||
|
} else {
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
error.message =
|
||||||
|
error.message +
|
||||||
|
", make sure it is configured or else use local embeddings"
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks: FileItemChunk[] = []
|
||||||
|
|
||||||
|
switch (fileExtension) {
|
||||||
|
case "csv":
|
||||||
|
chunks = await processCSV(blob)
|
||||||
|
break
|
||||||
|
case "json":
|
||||||
|
chunks = await processJSON(blob)
|
||||||
|
break
|
||||||
|
case "md":
|
||||||
|
chunks = await processMarkdown(blob)
|
||||||
|
break
|
||||||
|
case "pdf":
|
||||||
|
chunks = await processPdf(blob)
|
||||||
|
break
|
||||||
|
case "txt":
|
||||||
|
chunks = await processTxt(blob)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return new NextResponse("Unsupported file type", {
|
||||||
|
status: 400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let embeddings: any = []
|
||||||
|
|
||||||
|
let openai
|
||||||
|
if (profile.use_azure_openai) {
|
||||||
|
openai = new OpenAI({
|
||||||
|
apiKey: profile.azure_openai_api_key || "",
|
||||||
|
baseURL: `${profile.azure_openai_endpoint}/openai/deployments/${profile.azure_openai_embeddings_id}`,
|
||||||
|
defaultQuery: { "api-version": "2023-12-01-preview" },
|
||||||
|
defaultHeaders: { "api-key": profile.azure_openai_api_key }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddingsProvider === "openai") {
|
||||||
|
const response = await openai.embeddings.create({
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
input: chunks.map(chunk => chunk.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings = response.data.map((item: any) => {
|
||||||
|
return item.embedding
|
||||||
|
})
|
||||||
|
} else if (embeddingsProvider === "local") {
|
||||||
|
const embeddingPromises = chunks.map(async chunk => {
|
||||||
|
try {
|
||||||
|
return await generateLocalEmbedding(chunk.content)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error generating embedding for chunk: ${chunk}`, error)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings = await Promise.all(embeddingPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
const file_items = chunks.map((chunk, index) => ({
|
||||||
|
file_id,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
content: chunk.content,
|
||||||
|
tokens: chunk.tokens,
|
||||||
|
openai_embedding:
|
||||||
|
embeddingsProvider === "openai"
|
||||||
|
? ((embeddings[index] || null) as any)
|
||||||
|
: null,
|
||||||
|
local_embedding:
|
||||||
|
embeddingsProvider === "local"
|
||||||
|
? ((embeddings[index] || null) as any)
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
|
||||||
|
await supabaseAdmin.from("file_items").upsert(file_items)
|
||||||
|
|
||||||
|
const totalTokens = file_items.reduce((acc, item) => acc + item.tokens, 0)
|
||||||
|
|
||||||
|
await supabaseAdmin
|
||||||
|
.from("files")
|
||||||
|
.update({ tokens: totalTokens })
|
||||||
|
.eq("id", file_id)
|
||||||
|
|
||||||
|
return new NextResponse("Embed Successful", {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`Error in retrieval/process: ${error.stack}`)
|
||||||
|
const errorMessage = error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { generateLocalEmbedding } from "@/lib/generate-local-embedding"
|
||||||
|
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { userInput, fileIds, embeddingsProvider, sourceCount } = json as {
|
||||||
|
userInput: string
|
||||||
|
fileIds: string[]
|
||||||
|
embeddingsProvider: "openai" | "local"
|
||||||
|
sourceCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFileIds = [...new Set(fileIds)]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseAdmin = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
const profile = await getServerProfile()
|
||||||
|
|
||||||
|
if (embeddingsProvider === "openai") {
|
||||||
|
if (profile.use_azure_openai) {
|
||||||
|
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
|
||||||
|
} else {
|
||||||
|
checkApiKey(profile.openai_api_key, "OpenAI")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks: any[] = []
|
||||||
|
|
||||||
|
let openai
|
||||||
|
if (profile.use_azure_openai) {
|
||||||
|
openai = new OpenAI({
|
||||||
|
apiKey: profile.azure_openai_api_key || "",
|
||||||
|
baseURL: `${profile.azure_openai_endpoint}/openai/deployments/${profile.azure_openai_embeddings_id}`,
|
||||||
|
defaultQuery: { "api-version": "2023-12-01-preview" },
|
||||||
|
defaultHeaders: { "api-key": profile.azure_openai_api_key }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
openai = new OpenAI({
|
||||||
|
apiKey: profile.openai_api_key || "",
|
||||||
|
organization: profile.openai_organization_id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddingsProvider === "openai") {
|
||||||
|
const response = await openai.embeddings.create({
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
input: userInput
|
||||||
|
})
|
||||||
|
|
||||||
|
const openaiEmbedding = response.data.map(item => item.embedding)[0]
|
||||||
|
|
||||||
|
const { data: openaiFileItems, error: openaiError } =
|
||||||
|
await supabaseAdmin.rpc("match_file_items_openai", {
|
||||||
|
query_embedding: openaiEmbedding as any,
|
||||||
|
match_count: sourceCount,
|
||||||
|
file_ids: uniqueFileIds
|
||||||
|
})
|
||||||
|
|
||||||
|
if (openaiError) {
|
||||||
|
throw openaiError
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = openaiFileItems
|
||||||
|
} else if (embeddingsProvider === "local") {
|
||||||
|
const localEmbedding = await generateLocalEmbedding(userInput)
|
||||||
|
|
||||||
|
const { data: localFileItems, error: localFileItemsError } =
|
||||||
|
await supabaseAdmin.rpc("match_file_items_local", {
|
||||||
|
query_embedding: localEmbedding as any,
|
||||||
|
match_count: sourceCount,
|
||||||
|
file_ids: uniqueFileIds
|
||||||
|
})
|
||||||
|
|
||||||
|
if (localFileItemsError) {
|
||||||
|
throw localFileItemsError
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = localFileItems
|
||||||
|
}
|
||||||
|
|
||||||
|
const mostSimilarChunks = chunks?.sort(
|
||||||
|
(a, b) => b.similarity - a.similarity
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ results: mostSimilarChunks }), {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { username } = json as {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseAdmin = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: usernames, error } = await supabaseAdmin
|
||||||
|
.from("profiles")
|
||||||
|
.select("username")
|
||||||
|
.eq("username", username)
|
||||||
|
|
||||||
|
if (!usernames) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ isAvailable: !usernames.length }), {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Database } from "@/supabase/types"
|
||||||
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const json = await request.json()
|
||||||
|
const { userId } = json as {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseAdmin = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from("profiles")
|
||||||
|
.select("username")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ username: data.username }), {
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.error?.message || "An unexpected error occurred"
|
||||||
|
const errorCode = error.status || 500
|
||||||
|
return new Response(JSON.stringify({ message: errorMessage }), {
|
||||||
|
status: errorCode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const requestUrl = new URL(request.url)
|
||||||
|
const code = requestUrl.searchParams.get("code")
|
||||||
|
const next = requestUrl.searchParams.get("next")
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const supabase = createClient(cookieStore)
|
||||||
|
await supabase.auth.exchangeCodeForSession(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
return NextResponse.redirect(requestUrl.origin + next)
|
||||||
|
} else {
|
||||||
|
return NextResponse.redirect(requestUrl.origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "gray",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconRobotFace } from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, useContext, useEffect, useRef } from "react"
|
||||||
|
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
|
||||||
|
|
||||||
|
interface AssistantPickerProps {}
|
||||||
|
|
||||||
|
export const AssistantPicker: FC<AssistantPickerProps> = ({}) => {
|
||||||
|
const {
|
||||||
|
assistants,
|
||||||
|
assistantImages,
|
||||||
|
focusAssistant,
|
||||||
|
atCommand,
|
||||||
|
isAssistantPickerOpen,
|
||||||
|
setIsAssistantPickerOpen
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleSelectAssistant } = usePromptAndCommand()
|
||||||
|
|
||||||
|
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusAssistant && itemsRef.current[0]) {
|
||||||
|
itemsRef.current[0].focus()
|
||||||
|
}
|
||||||
|
}, [focusAssistant])
|
||||||
|
|
||||||
|
const filteredAssistants = assistants.filter(assistant =>
|
||||||
|
assistant.name.toLowerCase().includes(atCommand.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
setIsAssistantPickerOpen(isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callSelectAssistant = (assistant: Tables<"assistants">) => {
|
||||||
|
handleSelectAssistant(assistant)
|
||||||
|
handleOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKeyDownHandler =
|
||||||
|
(index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenChange(false)
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
callSelectAssistant(filteredAssistants[index])
|
||||||
|
} else if (
|
||||||
|
(e.key === "Tab" || e.key === "ArrowDown") &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
index === filteredAssistants.length - 1
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[0]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
|
||||||
|
// go to last element if arrow up is pressed on first element
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[itemsRef.current.length - 1]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
const prevIndex =
|
||||||
|
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
|
||||||
|
itemsRef.current[prevIndex]?.focus()
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
|
||||||
|
itemsRef.current[nextIndex]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isAssistantPickerOpen && (
|
||||||
|
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
|
||||||
|
{filteredAssistants.length === 0 ? (
|
||||||
|
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
|
||||||
|
No matching assistants.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredAssistants.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={ref => {
|
||||||
|
itemsRef.current[index] = ref
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center rounded p-2 focus:outline-none"
|
||||||
|
onClick={() =>
|
||||||
|
callSelectAssistant(item as Tables<"assistants">)
|
||||||
|
}
|
||||||
|
onKeyDown={getKeyDownHandler(index)}
|
||||||
|
>
|
||||||
|
{item.image_path ? (
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
assistantImages.find(
|
||||||
|
image => image.path === item.image_path
|
||||||
|
)?.url || ""
|
||||||
|
}
|
||||||
|
alt={item.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconRobotFace size={32} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-3 flex flex-col">
|
||||||
|
<div className="font-bold">{item.name}</div>
|
||||||
|
|
||||||
|
<div className="truncate text-sm opacity-80">
|
||||||
|
{item.description || "No description."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { FC, useContext } from "react"
|
||||||
|
import { AssistantPicker } from "./assistant-picker"
|
||||||
|
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
|
||||||
|
import { FilePicker } from "./file-picker"
|
||||||
|
import { PromptPicker } from "./prompt-picker"
|
||||||
|
import { ToolPicker } from "./tool-picker"
|
||||||
|
|
||||||
|
interface ChatCommandInputProps {}
|
||||||
|
|
||||||
|
export const ChatCommandInput: FC<ChatCommandInputProps> = ({}) => {
|
||||||
|
const {
|
||||||
|
newMessageFiles,
|
||||||
|
chatFiles,
|
||||||
|
slashCommand,
|
||||||
|
isFilePickerOpen,
|
||||||
|
setIsFilePickerOpen,
|
||||||
|
hashtagCommand,
|
||||||
|
focusPrompt,
|
||||||
|
focusFile
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleSelectUserFile, handleSelectUserCollection } =
|
||||||
|
usePromptAndCommand()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptPicker />
|
||||||
|
|
||||||
|
<FilePicker
|
||||||
|
isOpen={isFilePickerOpen}
|
||||||
|
searchQuery={hashtagCommand}
|
||||||
|
onOpenChange={setIsFilePickerOpen}
|
||||||
|
selectedFileIds={[...newMessageFiles, ...chatFiles].map(
|
||||||
|
file => file.id
|
||||||
|
)}
|
||||||
|
selectedCollectionIds={[]}
|
||||||
|
onSelectFile={handleSelectUserFile}
|
||||||
|
onSelectCollection={handleSelectUserCollection}
|
||||||
|
isFocused={focusFile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToolPicker />
|
||||||
|
|
||||||
|
<AssistantPicker />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getFileFromStorage } from "@/db/storage/files"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChatFile, MessageImage } from "@/types"
|
||||||
|
import {
|
||||||
|
IconCircleFilled,
|
||||||
|
IconFileFilled,
|
||||||
|
IconFileTypeCsv,
|
||||||
|
IconFileTypeDocx,
|
||||||
|
IconFileTypePdf,
|
||||||
|
IconFileTypeTxt,
|
||||||
|
IconJson,
|
||||||
|
IconLoader2,
|
||||||
|
IconMarkdown,
|
||||||
|
IconX
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, useContext, useState } from "react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { FilePreview } from "../ui/file-preview"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
import { ChatRetrievalSettings } from "./chat-retrieval-settings"
|
||||||
|
|
||||||
|
interface ChatFilesDisplayProps {}
|
||||||
|
|
||||||
|
export const ChatFilesDisplay: FC<ChatFilesDisplayProps> = ({}) => {
|
||||||
|
useHotkey("f", () => setShowFilesDisplay(prev => !prev))
|
||||||
|
useHotkey("e", () => setUseRetrieval(prev => !prev))
|
||||||
|
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
newMessageImages,
|
||||||
|
setNewMessageImages,
|
||||||
|
newMessageFiles,
|
||||||
|
setNewMessageFiles,
|
||||||
|
setShowFilesDisplay,
|
||||||
|
showFilesDisplay,
|
||||||
|
chatFiles,
|
||||||
|
chatImages,
|
||||||
|
setChatImages,
|
||||||
|
setChatFiles,
|
||||||
|
setUseRetrieval
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [selectedFile, setSelectedFile] = useState<ChatFile | null>(null)
|
||||||
|
const [selectedImage, setSelectedImage] = useState<MessageImage | null>(null)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
|
||||||
|
const messageImages = [
|
||||||
|
...newMessageImages.filter(
|
||||||
|
image =>
|
||||||
|
!chatImages.some(chatImage => chatImage.messageId === image.messageId)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
const combinedChatFiles = [
|
||||||
|
...newMessageFiles.filter(
|
||||||
|
file => !chatFiles.some(chatFile => chatFile.id === file.id)
|
||||||
|
),
|
||||||
|
...chatFiles
|
||||||
|
]
|
||||||
|
|
||||||
|
const combinedMessageFiles = [...messageImages, ...combinedChatFiles]
|
||||||
|
|
||||||
|
const getLinkAndView = async (file: ChatFile) => {
|
||||||
|
const fileRecord = files.find(f => f.id === file.id)
|
||||||
|
|
||||||
|
if (!fileRecord) return
|
||||||
|
|
||||||
|
const link = await getFileFromStorage(fileRecord.file_path)
|
||||||
|
window.open(link, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
return showFilesDisplay && combinedMessageFiles.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{showPreview && selectedImage && (
|
||||||
|
<FilePreview
|
||||||
|
type="image"
|
||||||
|
item={selectedImage}
|
||||||
|
isOpen={showPreview}
|
||||||
|
onOpenChange={(isOpen: boolean) => {
|
||||||
|
setShowPreview(isOpen)
|
||||||
|
setSelectedImage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPreview && selectedFile && (
|
||||||
|
<FilePreview
|
||||||
|
type="file"
|
||||||
|
item={selectedFile}
|
||||||
|
isOpen={showPreview}
|
||||||
|
onOpenChange={(isOpen: boolean) => {
|
||||||
|
setShowPreview(isOpen)
|
||||||
|
setSelectedFile(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex w-full items-center justify-center">
|
||||||
|
<Button
|
||||||
|
className="flex h-[32px] w-[140px] space-x-2"
|
||||||
|
onClick={() => setShowFilesDisplay(false)}
|
||||||
|
>
|
||||||
|
<RetrievalToggle />
|
||||||
|
|
||||||
|
<div>Hide files</div>
|
||||||
|
|
||||||
|
<div onClick={e => e.stopPropagation()}>
|
||||||
|
<ChatRetrievalSettings />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<div className="flex gap-2 overflow-auto pt-2">
|
||||||
|
{messageImages.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative flex h-[64px] cursor-pointer items-center space-x-4 rounded-xl hover:opacity-50"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="rounded"
|
||||||
|
// Force the image to be 56px by 56px
|
||||||
|
style={{
|
||||||
|
minWidth: "56px",
|
||||||
|
minHeight: "56px",
|
||||||
|
maxHeight: "56px",
|
||||||
|
maxWidth: "56px"
|
||||||
|
}}
|
||||||
|
src={image.base64} // Preview images will always be base64
|
||||||
|
alt="File image"
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedImage(image)
|
||||||
|
setShowPreview(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconX
|
||||||
|
className="bg-muted-foreground border-primary absolute right-[-6px] top-[-2px] flex size-5 cursor-pointer items-center justify-center rounded-full border-DEFAULT text-[10px] hover:border-red-500 hover:bg-white hover:text-red-500"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setNewMessageImages(
|
||||||
|
newMessageImages.filter(
|
||||||
|
f => f.messageId !== image.messageId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setChatImages(
|
||||||
|
chatImages.filter(f => f.messageId !== image.messageId)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{combinedChatFiles.map((file, index) =>
|
||||||
|
file.id === "loading" ? (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative flex h-[64px] items-center space-x-4 rounded-xl border-2 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="rounded bg-blue-500 p-2">
|
||||||
|
<IconLoader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="truncate text-sm">
|
||||||
|
<div className="truncate">{file.name}</div>
|
||||||
|
<div className="truncate opacity-50">{file.type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="relative flex h-[64px] cursor-pointer items-center space-x-4 rounded-xl border-2 px-4 py-3 hover:opacity-50"
|
||||||
|
onClick={() => getLinkAndView(file)}
|
||||||
|
>
|
||||||
|
<div className="rounded bg-blue-500 p-2">
|
||||||
|
{(() => {
|
||||||
|
let fileExtension = file.type.includes("/")
|
||||||
|
? file.type.split("/")[1]
|
||||||
|
: file.type
|
||||||
|
|
||||||
|
switch (fileExtension) {
|
||||||
|
case "pdf":
|
||||||
|
return <IconFileTypePdf />
|
||||||
|
case "markdown":
|
||||||
|
return <IconMarkdown />
|
||||||
|
case "txt":
|
||||||
|
return <IconFileTypeTxt />
|
||||||
|
case "json":
|
||||||
|
return <IconJson />
|
||||||
|
case "csv":
|
||||||
|
return <IconFileTypeCsv />
|
||||||
|
case "docx":
|
||||||
|
return <IconFileTypeDocx />
|
||||||
|
default:
|
||||||
|
return <IconFileFilled />
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="truncate text-sm">
|
||||||
|
<div className="truncate">{file.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconX
|
||||||
|
className="bg-muted-foreground border-primary absolute right-[-6px] top-[-6px] flex size-5 cursor-pointer items-center justify-center rounded-full border-DEFAULT text-[10px] hover:border-red-500 hover:bg-white hover:text-red-500"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setNewMessageFiles(
|
||||||
|
newMessageFiles.filter(f => f.id !== file.id)
|
||||||
|
)
|
||||||
|
setChatFiles(chatFiles.filter(f => f.id !== file.id))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
combinedMessageFiles.length > 0 && (
|
||||||
|
<div className="flex w-full items-center justify-center space-x-2">
|
||||||
|
<Button
|
||||||
|
className="flex h-[32px] w-[140px] space-x-2"
|
||||||
|
onClick={() => setShowFilesDisplay(true)}
|
||||||
|
>
|
||||||
|
<RetrievalToggle />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{" "}
|
||||||
|
View {combinedMessageFiles.length} file
|
||||||
|
{combinedMessageFiles.length > 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div onClick={e => e.stopPropagation()}>
|
||||||
|
<ChatRetrievalSettings />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RetrievalToggle = ({}) => {
|
||||||
|
const { useRetrieval, setUseRetrieval } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={0}
|
||||||
|
side="top"
|
||||||
|
display={
|
||||||
|
<div>
|
||||||
|
{useRetrieval
|
||||||
|
? "File retrieval is enabled on the selected files for this message. Click the indicator to disable."
|
||||||
|
: "Click the indicator to enable file retrieval for this message."}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
trigger={
|
||||||
|
<IconCircleFilled
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
useRetrieval ? "text-green-500" : "text-red-500",
|
||||||
|
useRetrieval ? "hover:text-green-200" : "hover:text-red-200"
|
||||||
|
)}
|
||||||
|
size={24}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setUseRetrieval(prev => !prev)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import {
|
||||||
|
IconBrandGithub,
|
||||||
|
IconBrandX,
|
||||||
|
IconHelpCircle,
|
||||||
|
IconQuestionMark
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import { Announcements } from "../utility/announcements"
|
||||||
|
|
||||||
|
interface ChatHelpProps {}
|
||||||
|
|
||||||
|
export const ChatHelp: FC<ChatHelpProps> = ({}) => {
|
||||||
|
useHotkey("/", () => setIsOpen(prevState => !prevState))
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconQuestionMark className="bg-primary text-secondary size-[24px] cursor-pointer rounded-full p-0.5 opacity-60 hover:opacity-50 lg:size-[30px] lg:p-1" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Link
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
href="https://twitter.com/ChatbotUI"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconBrandX />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
href="https://github.com/mckaywrigley/chatbot-ui"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconBrandGithub />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Announcements />
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
href="/help"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconHelpCircle size={24} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Show Help</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
/
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Show Workspaces</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
;
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex w-[300px] justify-between">
|
||||||
|
<div>New Chat</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
O
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Focus Chat</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Toggle Files</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
F
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Toggle Retrieval</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Open Settings</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
I
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Open Quick Settings</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
P
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="flex justify-between">
|
||||||
|
<div>Toggle Sidebar</div>
|
||||||
|
<div className="flex opacity-60">
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
|
||||||
|
S
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,511 @@
|
||||||
|
// Only used in use-chat-handler.tsx to keep it clean
|
||||||
|
|
||||||
|
import { createChatFiles } from "@/db/chat-files"
|
||||||
|
import { createChat } from "@/db/chats"
|
||||||
|
import { createMessageFileItems } from "@/db/message-file-items"
|
||||||
|
import { createMessages, updateMessage } from "@/db/messages"
|
||||||
|
import { uploadMessageImage } from "@/db/storage/message-images"
|
||||||
|
import {
|
||||||
|
buildFinalMessages,
|
||||||
|
adaptMessagesForGoogleGemini
|
||||||
|
} from "@/lib/build-prompt"
|
||||||
|
import { consumeReadableStream } from "@/lib/consume-stream"
|
||||||
|
import { Tables, TablesInsert } from "@/supabase/types"
|
||||||
|
import {
|
||||||
|
ChatFile,
|
||||||
|
ChatMessage,
|
||||||
|
ChatPayload,
|
||||||
|
ChatSettings,
|
||||||
|
LLM,
|
||||||
|
MessageImage
|
||||||
|
} from "@/types"
|
||||||
|
import React from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { v4 as uuidv4 } from "uuid"
|
||||||
|
|
||||||
|
export const validateChatSettings = (
|
||||||
|
chatSettings: ChatSettings | null,
|
||||||
|
modelData: LLM | undefined,
|
||||||
|
profile: Tables<"profiles"> | null,
|
||||||
|
selectedWorkspace: Tables<"workspaces"> | null,
|
||||||
|
messageContent: string
|
||||||
|
) => {
|
||||||
|
if (!chatSettings) {
|
||||||
|
throw new Error("Chat settings not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelData) {
|
||||||
|
throw new Error("Model not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error("Profile not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedWorkspace) {
|
||||||
|
throw new Error("Workspace not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!messageContent) {
|
||||||
|
throw new Error("Message content not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleRetrieval = async (
|
||||||
|
userInput: string,
|
||||||
|
newMessageFiles: ChatFile[],
|
||||||
|
chatFiles: ChatFile[],
|
||||||
|
embeddingsProvider: "openai" | "local",
|
||||||
|
sourceCount: number
|
||||||
|
) => {
|
||||||
|
const response = await fetch("/api/retrieval/retrieve", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
userInput,
|
||||||
|
fileIds: [...newMessageFiles, ...chatFiles].map(file => file.id),
|
||||||
|
embeddingsProvider,
|
||||||
|
sourceCount
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Error retrieving:", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = (await response.json()) as {
|
||||||
|
results: Tables<"file_items">[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTempMessages = (
|
||||||
|
messageContent: string,
|
||||||
|
chatMessages: ChatMessage[],
|
||||||
|
chatSettings: ChatSettings,
|
||||||
|
b64Images: string[],
|
||||||
|
isRegeneration: boolean,
|
||||||
|
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||||
|
selectedAssistant: Tables<"assistants"> | null
|
||||||
|
) => {
|
||||||
|
let tempUserChatMessage: ChatMessage = {
|
||||||
|
message: {
|
||||||
|
chat_id: "",
|
||||||
|
assistant_id: null,
|
||||||
|
content: messageContent,
|
||||||
|
created_at: "",
|
||||||
|
id: uuidv4(),
|
||||||
|
image_paths: b64Images,
|
||||||
|
model: chatSettings.model,
|
||||||
|
role: "user",
|
||||||
|
sequence_number: chatMessages.length,
|
||||||
|
updated_at: "",
|
||||||
|
user_id: ""
|
||||||
|
},
|
||||||
|
fileItems: []
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempAssistantChatMessage: ChatMessage = {
|
||||||
|
message: {
|
||||||
|
chat_id: "",
|
||||||
|
assistant_id: selectedAssistant?.id || null,
|
||||||
|
content: "",
|
||||||
|
created_at: "",
|
||||||
|
id: uuidv4(),
|
||||||
|
image_paths: [],
|
||||||
|
model: chatSettings.model,
|
||||||
|
role: "assistant",
|
||||||
|
sequence_number: chatMessages.length + 1,
|
||||||
|
updated_at: "",
|
||||||
|
user_id: ""
|
||||||
|
},
|
||||||
|
fileItems: []
|
||||||
|
}
|
||||||
|
|
||||||
|
let newMessages = []
|
||||||
|
|
||||||
|
if (isRegeneration) {
|
||||||
|
const lastMessageIndex = chatMessages.length - 1
|
||||||
|
chatMessages[lastMessageIndex].message.content = ""
|
||||||
|
newMessages = [...chatMessages]
|
||||||
|
} else {
|
||||||
|
newMessages = [
|
||||||
|
...chatMessages,
|
||||||
|
tempUserChatMessage,
|
||||||
|
tempAssistantChatMessage
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
setChatMessages(newMessages)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempUserChatMessage,
|
||||||
|
tempAssistantChatMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleLocalChat = async (
|
||||||
|
payload: ChatPayload,
|
||||||
|
profile: Tables<"profiles">,
|
||||||
|
chatSettings: ChatSettings,
|
||||||
|
tempAssistantMessage: ChatMessage,
|
||||||
|
isRegeneration: boolean,
|
||||||
|
newAbortController: AbortController,
|
||||||
|
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setFirstTokenReceived: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||||
|
setToolInUse: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
) => {
|
||||||
|
const formattedMessages = await buildFinalMessages(payload, profile, [])
|
||||||
|
|
||||||
|
// Ollama API: https://github.com/jmorganca/ollama/blob/main/docs/api.md
|
||||||
|
const response = await fetchChatResponse(
|
||||||
|
process.env.NEXT_PUBLIC_OLLAMA_URL + "/api/chat",
|
||||||
|
{
|
||||||
|
model: chatSettings.model,
|
||||||
|
messages: formattedMessages,
|
||||||
|
options: {
|
||||||
|
temperature: payload.chatSettings.temperature
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
newAbortController,
|
||||||
|
setIsGenerating,
|
||||||
|
setChatMessages
|
||||||
|
)
|
||||||
|
|
||||||
|
return await processResponse(
|
||||||
|
response,
|
||||||
|
isRegeneration
|
||||||
|
? payload.chatMessages[payload.chatMessages.length - 1]
|
||||||
|
: tempAssistantMessage,
|
||||||
|
false,
|
||||||
|
newAbortController,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
setChatMessages,
|
||||||
|
setToolInUse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleHostedChat = async (
|
||||||
|
payload: ChatPayload,
|
||||||
|
profile: Tables<"profiles">,
|
||||||
|
modelData: LLM,
|
||||||
|
tempAssistantChatMessage: ChatMessage,
|
||||||
|
isRegeneration: boolean,
|
||||||
|
newAbortController: AbortController,
|
||||||
|
newMessageImages: MessageImage[],
|
||||||
|
chatImages: MessageImage[],
|
||||||
|
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setFirstTokenReceived: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||||
|
setToolInUse: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
) => {
|
||||||
|
const provider =
|
||||||
|
modelData.provider === "openai" && profile.use_azure_openai
|
||||||
|
? "azure"
|
||||||
|
: modelData.provider
|
||||||
|
|
||||||
|
let draftMessages = await buildFinalMessages(payload, profile, chatImages)
|
||||||
|
|
||||||
|
let formattedMessages : any[] = []
|
||||||
|
if (provider === "google") {
|
||||||
|
formattedMessages = await adaptMessagesForGoogleGemini(payload, draftMessages)
|
||||||
|
} else {
|
||||||
|
formattedMessages = draftMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiEndpoint =
|
||||||
|
provider === "custom" ? "/api/chat/custom" : `/api/chat/${provider}`
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
chatSettings: payload.chatSettings,
|
||||||
|
messages: formattedMessages,
|
||||||
|
customModelId: provider === "custom" ? modelData.hostedId : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchChatResponse(
|
||||||
|
apiEndpoint,
|
||||||
|
requestBody,
|
||||||
|
true,
|
||||||
|
newAbortController,
|
||||||
|
setIsGenerating,
|
||||||
|
setChatMessages
|
||||||
|
)
|
||||||
|
|
||||||
|
return await processResponse(
|
||||||
|
response,
|
||||||
|
isRegeneration
|
||||||
|
? payload.chatMessages[payload.chatMessages.length - 1]
|
||||||
|
: tempAssistantChatMessage,
|
||||||
|
true,
|
||||||
|
newAbortController,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
setChatMessages,
|
||||||
|
setToolInUse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchChatResponse = async (
|
||||||
|
url: string,
|
||||||
|
body: object,
|
||||||
|
isHosted: boolean,
|
||||||
|
controller: AbortController,
|
||||||
|
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||||
|
) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404 && !isHosted) {
|
||||||
|
toast.error(
|
||||||
|
"Model not found. Make sure you have it downloaded via Ollama."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = await response.json()
|
||||||
|
|
||||||
|
toast.error(errorData.message)
|
||||||
|
|
||||||
|
setIsGenerating(false)
|
||||||
|
setChatMessages(prevMessages => prevMessages.slice(0, -2))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const processResponse = async (
|
||||||
|
response: Response,
|
||||||
|
lastChatMessage: ChatMessage,
|
||||||
|
isHosted: boolean,
|
||||||
|
controller: AbortController,
|
||||||
|
setFirstTokenReceived: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||||
|
setToolInUse: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
) => {
|
||||||
|
let fullText = ""
|
||||||
|
let contentToAdd = ""
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
await consumeReadableStream(
|
||||||
|
response.body,
|
||||||
|
chunk => {
|
||||||
|
setFirstTokenReceived(true)
|
||||||
|
setToolInUse("none")
|
||||||
|
|
||||||
|
try {
|
||||||
|
contentToAdd = isHosted
|
||||||
|
? chunk
|
||||||
|
: // Ollama's streaming endpoint returns new-line separated JSON
|
||||||
|
// objects. A chunk may have more than one of these objects, so we
|
||||||
|
// need to split the chunk by new-lines and handle each one
|
||||||
|
// separately.
|
||||||
|
chunk
|
||||||
|
.trimEnd()
|
||||||
|
.split("\n")
|
||||||
|
.reduce(
|
||||||
|
(acc, line) => acc + JSON.parse(line).message.content,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
fullText += contentToAdd
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing JSON:", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setChatMessages(prev =>
|
||||||
|
prev.map(chatMessage => {
|
||||||
|
if (chatMessage.message.id === lastChatMessage.message.id) {
|
||||||
|
const updatedChatMessage: ChatMessage = {
|
||||||
|
message: {
|
||||||
|
...chatMessage.message,
|
||||||
|
content: fullText
|
||||||
|
},
|
||||||
|
fileItems: chatMessage.fileItems
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
return chatMessage
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return fullText
|
||||||
|
} else {
|
||||||
|
throw new Error("Response body is null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleCreateChat = async (
|
||||||
|
chatSettings: ChatSettings,
|
||||||
|
profile: Tables<"profiles">,
|
||||||
|
selectedWorkspace: Tables<"workspaces">,
|
||||||
|
messageContent: string,
|
||||||
|
selectedAssistant: Tables<"assistants">,
|
||||||
|
newMessageFiles: ChatFile[],
|
||||||
|
setSelectedChat: React.Dispatch<React.SetStateAction<Tables<"chats"> | null>>,
|
||||||
|
setChats: React.Dispatch<React.SetStateAction<Tables<"chats">[]>>,
|
||||||
|
setChatFiles: React.Dispatch<React.SetStateAction<ChatFile[]>>
|
||||||
|
) => {
|
||||||
|
const createdChat = await createChat({
|
||||||
|
user_id: profile.user_id,
|
||||||
|
workspace_id: selectedWorkspace.id,
|
||||||
|
assistant_id: selectedAssistant?.id || null,
|
||||||
|
context_length: chatSettings.contextLength,
|
||||||
|
include_profile_context: chatSettings.includeProfileContext,
|
||||||
|
include_workspace_instructions: chatSettings.includeWorkspaceInstructions,
|
||||||
|
model: chatSettings.model,
|
||||||
|
name: messageContent.substring(0, 100),
|
||||||
|
prompt: chatSettings.prompt,
|
||||||
|
temperature: chatSettings.temperature,
|
||||||
|
embeddings_provider: chatSettings.embeddingsProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
setSelectedChat(createdChat)
|
||||||
|
setChats(chats => [createdChat, ...chats])
|
||||||
|
|
||||||
|
await createChatFiles(
|
||||||
|
newMessageFiles.map(file => ({
|
||||||
|
user_id: profile.user_id,
|
||||||
|
chat_id: createdChat.id,
|
||||||
|
file_id: file.id
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
setChatFiles(prev => [...prev, ...newMessageFiles])
|
||||||
|
|
||||||
|
return createdChat
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleCreateMessages = async (
|
||||||
|
chatMessages: ChatMessage[],
|
||||||
|
currentChat: Tables<"chats">,
|
||||||
|
profile: Tables<"profiles">,
|
||||||
|
modelData: LLM,
|
||||||
|
messageContent: string,
|
||||||
|
generatedText: string,
|
||||||
|
newMessageImages: MessageImage[],
|
||||||
|
isRegeneration: boolean,
|
||||||
|
retrievedFileItems: Tables<"file_items">[],
|
||||||
|
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||||
|
setChatFileItems: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"file_items">[]>
|
||||||
|
>,
|
||||||
|
setChatImages: React.Dispatch<React.SetStateAction<MessageImage[]>>,
|
||||||
|
selectedAssistant: Tables<"assistants"> | null
|
||||||
|
) => {
|
||||||
|
const finalUserMessage: TablesInsert<"messages"> = {
|
||||||
|
chat_id: currentChat.id,
|
||||||
|
assistant_id: null,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
content: messageContent,
|
||||||
|
model: modelData.modelId,
|
||||||
|
role: "user",
|
||||||
|
sequence_number: chatMessages.length,
|
||||||
|
image_paths: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalAssistantMessage: TablesInsert<"messages"> = {
|
||||||
|
chat_id: currentChat.id,
|
||||||
|
assistant_id: selectedAssistant?.id || null,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
content: generatedText,
|
||||||
|
model: modelData.modelId,
|
||||||
|
role: "assistant",
|
||||||
|
sequence_number: chatMessages.length + 1,
|
||||||
|
image_paths: []
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalChatMessages: ChatMessage[] = []
|
||||||
|
|
||||||
|
if (isRegeneration) {
|
||||||
|
const lastStartingMessage = chatMessages[chatMessages.length - 1].message
|
||||||
|
|
||||||
|
const updatedMessage = await updateMessage(lastStartingMessage.id, {
|
||||||
|
...lastStartingMessage,
|
||||||
|
content: generatedText
|
||||||
|
})
|
||||||
|
|
||||||
|
chatMessages[chatMessages.length - 1].message = updatedMessage
|
||||||
|
|
||||||
|
finalChatMessages = [...chatMessages]
|
||||||
|
|
||||||
|
setChatMessages(finalChatMessages)
|
||||||
|
} else {
|
||||||
|
const createdMessages = await createMessages([
|
||||||
|
finalUserMessage,
|
||||||
|
finalAssistantMessage
|
||||||
|
])
|
||||||
|
|
||||||
|
// Upload each image (stored in newMessageImages) for the user message to message_images bucket
|
||||||
|
const uploadPromises = newMessageImages
|
||||||
|
.filter(obj => obj.file !== null)
|
||||||
|
.map(obj => {
|
||||||
|
let filePath = `${profile.user_id}/${currentChat.id}/${
|
||||||
|
createdMessages[0].id
|
||||||
|
}/${uuidv4()}`
|
||||||
|
|
||||||
|
return uploadMessageImage(filePath, obj.file as File).catch(error => {
|
||||||
|
console.error(`Failed to upload image at ${filePath}:`, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const paths = (await Promise.all(uploadPromises)).filter(
|
||||||
|
Boolean
|
||||||
|
) as string[]
|
||||||
|
|
||||||
|
setChatImages(prevImages => [
|
||||||
|
...prevImages,
|
||||||
|
...newMessageImages.map((obj, index) => ({
|
||||||
|
...obj,
|
||||||
|
messageId: createdMessages[0].id,
|
||||||
|
path: paths[index]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
const updatedMessage = await updateMessage(createdMessages[0].id, {
|
||||||
|
...createdMessages[0],
|
||||||
|
image_paths: paths
|
||||||
|
})
|
||||||
|
|
||||||
|
const createdMessageFileItems = await createMessageFileItems(
|
||||||
|
retrievedFileItems.map(fileItem => {
|
||||||
|
return {
|
||||||
|
user_id: profile.user_id,
|
||||||
|
message_id: createdMessages[1].id,
|
||||||
|
file_item_id: fileItem.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
finalChatMessages = [
|
||||||
|
...chatMessages,
|
||||||
|
{
|
||||||
|
message: updatedMessage,
|
||||||
|
fileItems: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: createdMessages[1],
|
||||||
|
fileItems: retrievedFileItems.map(fileItem => fileItem.id)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
setChatFileItems(prevFileItems => {
|
||||||
|
const newFileItems = retrievedFileItems.filter(
|
||||||
|
fileItem => !prevFileItems.some(prevItem => prevItem.id === fileItem.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [...prevFileItems, ...newFileItems]
|
||||||
|
})
|
||||||
|
|
||||||
|
setChatMessages(finalChatMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getAssistantCollectionsByAssistantId } from "@/db/assistant-collections"
|
||||||
|
import { getAssistantFilesByAssistantId } from "@/db/assistant-files"
|
||||||
|
import { getAssistantToolsByAssistantId } from "@/db/assistant-tools"
|
||||||
|
import { updateChat } from "@/db/chats"
|
||||||
|
import { getCollectionFilesByCollectionId } from "@/db/collection-files"
|
||||||
|
import { deleteMessagesIncludingAndAfter } from "@/db/messages"
|
||||||
|
import { buildFinalMessages } from "@/lib/build-prompt"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { ChatMessage, ChatPayload, LLMID, ModelProvider } from "@/types"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useContext, useEffect, useRef } from "react"
|
||||||
|
import { LLM_LIST } from "../../../lib/models/llm/llm-list"
|
||||||
|
import {
|
||||||
|
createTempMessages,
|
||||||
|
handleCreateChat,
|
||||||
|
handleCreateMessages,
|
||||||
|
handleHostedChat,
|
||||||
|
handleLocalChat,
|
||||||
|
handleRetrieval,
|
||||||
|
processResponse,
|
||||||
|
validateChatSettings
|
||||||
|
} from "../chat-helpers"
|
||||||
|
|
||||||
|
export const useChatHandler = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const {
|
||||||
|
userInput,
|
||||||
|
chatFiles,
|
||||||
|
setUserInput,
|
||||||
|
setNewMessageImages,
|
||||||
|
profile,
|
||||||
|
setIsGenerating,
|
||||||
|
setChatMessages,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
selectedChat,
|
||||||
|
selectedWorkspace,
|
||||||
|
setSelectedChat,
|
||||||
|
setChats,
|
||||||
|
setSelectedTools,
|
||||||
|
availableLocalModels,
|
||||||
|
availableOpenRouterModels,
|
||||||
|
abortController,
|
||||||
|
setAbortController,
|
||||||
|
chatSettings,
|
||||||
|
newMessageImages,
|
||||||
|
selectedAssistant,
|
||||||
|
chatMessages,
|
||||||
|
chatImages,
|
||||||
|
setChatImages,
|
||||||
|
setChatFiles,
|
||||||
|
setNewMessageFiles,
|
||||||
|
setShowFilesDisplay,
|
||||||
|
newMessageFiles,
|
||||||
|
chatFileItems,
|
||||||
|
setChatFileItems,
|
||||||
|
setToolInUse,
|
||||||
|
useRetrieval,
|
||||||
|
sourceCount,
|
||||||
|
setIsPromptPickerOpen,
|
||||||
|
setIsFilePickerOpen,
|
||||||
|
selectedTools,
|
||||||
|
selectedPreset,
|
||||||
|
setChatSettings,
|
||||||
|
models,
|
||||||
|
isPromptPickerOpen,
|
||||||
|
isFilePickerOpen,
|
||||||
|
isToolPickerOpen
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const chatInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPromptPickerOpen || !isFilePickerOpen || !isToolPickerOpen) {
|
||||||
|
chatInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [isPromptPickerOpen, isFilePickerOpen, isToolPickerOpen])
|
||||||
|
|
||||||
|
const handleNewChat = async () => {
|
||||||
|
if (!selectedWorkspace) return
|
||||||
|
|
||||||
|
setUserInput("")
|
||||||
|
setChatMessages([])
|
||||||
|
setSelectedChat(null)
|
||||||
|
setChatFileItems([])
|
||||||
|
|
||||||
|
setIsGenerating(false)
|
||||||
|
setFirstTokenReceived(false)
|
||||||
|
|
||||||
|
setChatFiles([])
|
||||||
|
setChatImages([])
|
||||||
|
setNewMessageFiles([])
|
||||||
|
setNewMessageImages([])
|
||||||
|
setShowFilesDisplay(false)
|
||||||
|
setIsPromptPickerOpen(false)
|
||||||
|
setIsFilePickerOpen(false)
|
||||||
|
|
||||||
|
setSelectedTools([])
|
||||||
|
setToolInUse("none")
|
||||||
|
|
||||||
|
if (selectedAssistant) {
|
||||||
|
setChatSettings({
|
||||||
|
model: selectedAssistant.model as LLMID,
|
||||||
|
prompt: selectedAssistant.prompt,
|
||||||
|
temperature: selectedAssistant.temperature,
|
||||||
|
contextLength: selectedAssistant.context_length,
|
||||||
|
includeProfileContext: selectedAssistant.include_profile_context,
|
||||||
|
includeWorkspaceInstructions:
|
||||||
|
selectedAssistant.include_workspace_instructions,
|
||||||
|
embeddingsProvider: selectedAssistant.embeddings_provider as
|
||||||
|
| "openai"
|
||||||
|
| "local"
|
||||||
|
})
|
||||||
|
|
||||||
|
let allFiles = []
|
||||||
|
|
||||||
|
const assistantFiles = (
|
||||||
|
await getAssistantFilesByAssistantId(selectedAssistant.id)
|
||||||
|
).files
|
||||||
|
allFiles = [...assistantFiles]
|
||||||
|
const assistantCollections = (
|
||||||
|
await getAssistantCollectionsByAssistantId(selectedAssistant.id)
|
||||||
|
).collections
|
||||||
|
for (const collection of assistantCollections) {
|
||||||
|
const collectionFiles = (
|
||||||
|
await getCollectionFilesByCollectionId(collection.id)
|
||||||
|
).files
|
||||||
|
allFiles = [...allFiles, ...collectionFiles]
|
||||||
|
}
|
||||||
|
const assistantTools = (
|
||||||
|
await getAssistantToolsByAssistantId(selectedAssistant.id)
|
||||||
|
).tools
|
||||||
|
|
||||||
|
setSelectedTools(assistantTools)
|
||||||
|
setChatFiles(
|
||||||
|
allFiles.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
file: null
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allFiles.length > 0) setShowFilesDisplay(true)
|
||||||
|
} else if (selectedPreset) {
|
||||||
|
setChatSettings({
|
||||||
|
model: selectedPreset.model as LLMID,
|
||||||
|
prompt: selectedPreset.prompt,
|
||||||
|
temperature: selectedPreset.temperature,
|
||||||
|
contextLength: selectedPreset.context_length,
|
||||||
|
includeProfileContext: selectedPreset.include_profile_context,
|
||||||
|
includeWorkspaceInstructions:
|
||||||
|
selectedPreset.include_workspace_instructions,
|
||||||
|
embeddingsProvider: selectedPreset.embeddings_provider as
|
||||||
|
| "openai"
|
||||||
|
| "local"
|
||||||
|
})
|
||||||
|
} else if (selectedWorkspace) {
|
||||||
|
// setChatSettings({
|
||||||
|
// model: (selectedWorkspace.default_model ||
|
||||||
|
// "gpt-4-1106-preview") as LLMID,
|
||||||
|
// prompt:
|
||||||
|
// selectedWorkspace.default_prompt ||
|
||||||
|
// "You are a friendly, helpful AI assistant.",
|
||||||
|
// temperature: selectedWorkspace.default_temperature || 0.5,
|
||||||
|
// contextLength: selectedWorkspace.default_context_length || 4096,
|
||||||
|
// includeProfileContext:
|
||||||
|
// selectedWorkspace.include_profile_context || true,
|
||||||
|
// includeWorkspaceInstructions:
|
||||||
|
// selectedWorkspace.include_workspace_instructions || true,
|
||||||
|
// embeddingsProvider:
|
||||||
|
// (selectedWorkspace.embeddings_provider as "openai" | "local") ||
|
||||||
|
// "openai"
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
|
||||||
|
return router.push(`/${selectedWorkspace.id}/chat`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocusChatInput = () => {
|
||||||
|
chatInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStopMessage = () => {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendMessage = async (
|
||||||
|
messageContent: string,
|
||||||
|
chatMessages: ChatMessage[],
|
||||||
|
isRegeneration: boolean
|
||||||
|
) => {
|
||||||
|
const startingInput = messageContent
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUserInput("")
|
||||||
|
setIsGenerating(true)
|
||||||
|
setIsPromptPickerOpen(false)
|
||||||
|
setIsFilePickerOpen(false)
|
||||||
|
setNewMessageImages([])
|
||||||
|
|
||||||
|
const newAbortController = new AbortController()
|
||||||
|
setAbortController(newAbortController)
|
||||||
|
|
||||||
|
const modelData = [
|
||||||
|
...models.map(model => ({
|
||||||
|
modelId: model.model_id as LLMID,
|
||||||
|
modelName: model.name,
|
||||||
|
provider: "custom" as ModelProvider,
|
||||||
|
hostedId: model.id,
|
||||||
|
platformLink: "",
|
||||||
|
imageInput: false
|
||||||
|
})),
|
||||||
|
...LLM_LIST,
|
||||||
|
...availableLocalModels,
|
||||||
|
...availableOpenRouterModels
|
||||||
|
].find(llm => llm.modelId === chatSettings?.model)
|
||||||
|
|
||||||
|
validateChatSettings(
|
||||||
|
chatSettings,
|
||||||
|
modelData,
|
||||||
|
profile,
|
||||||
|
selectedWorkspace,
|
||||||
|
messageContent
|
||||||
|
)
|
||||||
|
|
||||||
|
let currentChat = selectedChat ? { ...selectedChat } : null
|
||||||
|
|
||||||
|
const b64Images = newMessageImages.map(image => image.base64)
|
||||||
|
|
||||||
|
let retrievedFileItems: Tables<"file_items">[] = []
|
||||||
|
|
||||||
|
if (
|
||||||
|
(newMessageFiles.length > 0 || chatFiles.length > 0) &&
|
||||||
|
useRetrieval
|
||||||
|
) {
|
||||||
|
setToolInUse("retrieval")
|
||||||
|
|
||||||
|
retrievedFileItems = await handleRetrieval(
|
||||||
|
userInput,
|
||||||
|
newMessageFiles,
|
||||||
|
chatFiles,
|
||||||
|
chatSettings!.embeddingsProvider,
|
||||||
|
sourceCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tempUserChatMessage, tempAssistantChatMessage } =
|
||||||
|
createTempMessages(
|
||||||
|
messageContent,
|
||||||
|
chatMessages,
|
||||||
|
chatSettings!,
|
||||||
|
b64Images,
|
||||||
|
isRegeneration,
|
||||||
|
setChatMessages,
|
||||||
|
selectedAssistant
|
||||||
|
)
|
||||||
|
|
||||||
|
let payload: ChatPayload = {
|
||||||
|
chatSettings: chatSettings!,
|
||||||
|
workspaceInstructions: selectedWorkspace!.instructions || "",
|
||||||
|
chatMessages: isRegeneration
|
||||||
|
? [...chatMessages]
|
||||||
|
: [...chatMessages, tempUserChatMessage],
|
||||||
|
assistant: selectedChat?.assistant_id ? selectedAssistant : null,
|
||||||
|
messageFileItems: retrievedFileItems,
|
||||||
|
chatFileItems: chatFileItems
|
||||||
|
}
|
||||||
|
|
||||||
|
let generatedText = ""
|
||||||
|
|
||||||
|
if (selectedTools.length > 0) {
|
||||||
|
setToolInUse("Tools")
|
||||||
|
|
||||||
|
const formattedMessages = await buildFinalMessages(
|
||||||
|
payload,
|
||||||
|
profile!,
|
||||||
|
chatImages
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await fetch("/api/chat/tools", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chatSettings: payload.chatSettings,
|
||||||
|
messages: formattedMessages,
|
||||||
|
selectedTools
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
setToolInUse("none")
|
||||||
|
|
||||||
|
generatedText = await processResponse(
|
||||||
|
response,
|
||||||
|
isRegeneration
|
||||||
|
? payload.chatMessages[payload.chatMessages.length - 1]
|
||||||
|
: tempAssistantChatMessage,
|
||||||
|
true,
|
||||||
|
newAbortController,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
setChatMessages,
|
||||||
|
setToolInUse
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (modelData!.provider === "ollama") {
|
||||||
|
generatedText = await handleLocalChat(
|
||||||
|
payload,
|
||||||
|
profile!,
|
||||||
|
chatSettings!,
|
||||||
|
tempAssistantChatMessage,
|
||||||
|
isRegeneration,
|
||||||
|
newAbortController,
|
||||||
|
setIsGenerating,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
setChatMessages,
|
||||||
|
setToolInUse
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
generatedText = await handleHostedChat(
|
||||||
|
payload,
|
||||||
|
profile!,
|
||||||
|
modelData!,
|
||||||
|
tempAssistantChatMessage,
|
||||||
|
isRegeneration,
|
||||||
|
newAbortController,
|
||||||
|
newMessageImages,
|
||||||
|
chatImages,
|
||||||
|
setIsGenerating,
|
||||||
|
setFirstTokenReceived,
|
||||||
|
setChatMessages,
|
||||||
|
setToolInUse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentChat) {
|
||||||
|
currentChat = await handleCreateChat(
|
||||||
|
chatSettings!,
|
||||||
|
profile!,
|
||||||
|
selectedWorkspace!,
|
||||||
|
messageContent,
|
||||||
|
selectedAssistant!,
|
||||||
|
newMessageFiles,
|
||||||
|
setSelectedChat,
|
||||||
|
setChats,
|
||||||
|
setChatFiles
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const updatedChat = await updateChat(currentChat.id, {
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
setChats(prevChats => {
|
||||||
|
const updatedChats = prevChats.map(prevChat =>
|
||||||
|
prevChat.id === updatedChat.id ? updatedChat : prevChat
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedChats
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleCreateMessages(
|
||||||
|
chatMessages,
|
||||||
|
currentChat,
|
||||||
|
profile!,
|
||||||
|
modelData!,
|
||||||
|
messageContent,
|
||||||
|
generatedText,
|
||||||
|
newMessageImages,
|
||||||
|
isRegeneration,
|
||||||
|
retrievedFileItems,
|
||||||
|
setChatMessages,
|
||||||
|
setChatFileItems,
|
||||||
|
setChatImages,
|
||||||
|
selectedAssistant
|
||||||
|
)
|
||||||
|
|
||||||
|
setIsGenerating(false)
|
||||||
|
setFirstTokenReceived(false)
|
||||||
|
} catch (error) {
|
||||||
|
setIsGenerating(false)
|
||||||
|
setFirstTokenReceived(false)
|
||||||
|
setUserInput(startingInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendEdit = async (
|
||||||
|
editedContent: string,
|
||||||
|
sequenceNumber: number
|
||||||
|
) => {
|
||||||
|
if (!selectedChat) return
|
||||||
|
|
||||||
|
await deleteMessagesIncludingAndAfter(
|
||||||
|
selectedChat.user_id,
|
||||||
|
selectedChat.id,
|
||||||
|
sequenceNumber
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredMessages = chatMessages.filter(
|
||||||
|
chatMessage => chatMessage.message.sequence_number < sequenceNumber
|
||||||
|
)
|
||||||
|
|
||||||
|
setChatMessages(filteredMessages)
|
||||||
|
|
||||||
|
handleSendMessage(editedContent, filteredMessages, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chatInputRef,
|
||||||
|
prompt,
|
||||||
|
handleNewChat,
|
||||||
|
handleSendMessage,
|
||||||
|
handleFocusChatInput,
|
||||||
|
handleStopMessage,
|
||||||
|
handleSendEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for handling chat history in the chat component.
|
||||||
|
* It provides functions to set the new message content to the previous or next user message in the chat history.
|
||||||
|
*
|
||||||
|
* @returns An object containing the following functions:
|
||||||
|
* - setNewMessageContentToPreviousUserMessage: Sets the new message content to the previous user message.
|
||||||
|
* - setNewMessageContentToNextUserMessage: Sets the new message content to the next user message in the chat history.
|
||||||
|
*/
|
||||||
|
export const useChatHistoryHandler = () => {
|
||||||
|
const { setUserInput, chatMessages, isGenerating } =
|
||||||
|
useContext(ChatbotUIContext)
|
||||||
|
const userRoleString = "user"
|
||||||
|
|
||||||
|
const [messageHistoryIndex, setMessageHistoryIndex] = useState<number>(
|
||||||
|
chatMessages.length
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If messages get deleted the history index pointed could be out of bounds
|
||||||
|
if (!isGenerating && messageHistoryIndex > chatMessages.length)
|
||||||
|
setMessageHistoryIndex(chatMessages.length)
|
||||||
|
}, [chatMessages, isGenerating, messageHistoryIndex])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the new message content to the previous user message.
|
||||||
|
*/
|
||||||
|
const setNewMessageContentToPreviousUserMessage = () => {
|
||||||
|
let tempIndex = messageHistoryIndex
|
||||||
|
while (
|
||||||
|
tempIndex > 0 &&
|
||||||
|
chatMessages[tempIndex - 1].message.role !== userRoleString
|
||||||
|
) {
|
||||||
|
tempIndex--
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousUserMessage =
|
||||||
|
chatMessages.length > 0 && tempIndex > 0
|
||||||
|
? chatMessages[tempIndex - 1]
|
||||||
|
: null
|
||||||
|
if (previousUserMessage) {
|
||||||
|
setUserInput(previousUserMessage.message.content)
|
||||||
|
setMessageHistoryIndex(tempIndex - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the new message content to the next user message in the chat history.
|
||||||
|
* If there is a next user message, it updates the user input and message history index accordingly.
|
||||||
|
* If there is no next user message, it resets the user input and sets the message history index to the end of the chat history.
|
||||||
|
*/
|
||||||
|
const setNewMessageContentToNextUserMessage = () => {
|
||||||
|
let tempIndex = messageHistoryIndex
|
||||||
|
while (
|
||||||
|
tempIndex < chatMessages.length - 1 &&
|
||||||
|
chatMessages[tempIndex + 1].message.role !== userRoleString
|
||||||
|
) {
|
||||||
|
tempIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUserMessage =
|
||||||
|
chatMessages.length > 0 && tempIndex < chatMessages.length - 1
|
||||||
|
? chatMessages[tempIndex + 1]
|
||||||
|
: null
|
||||||
|
setUserInput(nextUserMessage?.message.content || "")
|
||||||
|
setMessageHistoryIndex(
|
||||||
|
nextUserMessage ? tempIndex + 1 : chatMessages.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setNewMessageContentToPreviousUserMessage,
|
||||||
|
setNewMessageContentToNextUserMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getAssistantCollectionsByAssistantId } from "@/db/assistant-collections"
|
||||||
|
import { getAssistantFilesByAssistantId } from "@/db/assistant-files"
|
||||||
|
import { getAssistantToolsByAssistantId } from "@/db/assistant-tools"
|
||||||
|
import { getCollectionFilesByCollectionId } from "@/db/collection-files"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { LLMID } from "@/types"
|
||||||
|
import { useContext } from "react"
|
||||||
|
|
||||||
|
export const usePromptAndCommand = () => {
|
||||||
|
const {
|
||||||
|
chatFiles,
|
||||||
|
setNewMessageFiles,
|
||||||
|
userInput,
|
||||||
|
setUserInput,
|
||||||
|
setShowFilesDisplay,
|
||||||
|
setIsPromptPickerOpen,
|
||||||
|
setIsFilePickerOpen,
|
||||||
|
setSlashCommand,
|
||||||
|
setHashtagCommand,
|
||||||
|
setUseRetrieval,
|
||||||
|
setToolCommand,
|
||||||
|
setIsToolPickerOpen,
|
||||||
|
setSelectedTools,
|
||||||
|
setAtCommand,
|
||||||
|
setIsAssistantPickerOpen,
|
||||||
|
setSelectedAssistant,
|
||||||
|
setChatSettings,
|
||||||
|
setChatFiles
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const handleInputChange = (value: string) => {
|
||||||
|
const atTextRegex = /@([^ ]*)$/
|
||||||
|
const slashTextRegex = /\/([^ ]*)$/
|
||||||
|
const hashtagTextRegex = /#([^ ]*)$/
|
||||||
|
const toolTextRegex = /!([^ ]*)$/
|
||||||
|
const atMatch = value.match(atTextRegex)
|
||||||
|
const slashMatch = value.match(slashTextRegex)
|
||||||
|
const hashtagMatch = value.match(hashtagTextRegex)
|
||||||
|
const toolMatch = value.match(toolTextRegex)
|
||||||
|
|
||||||
|
if (atMatch) {
|
||||||
|
setIsAssistantPickerOpen(true)
|
||||||
|
setAtCommand(atMatch[1])
|
||||||
|
} else if (slashMatch) {
|
||||||
|
setIsPromptPickerOpen(true)
|
||||||
|
setSlashCommand(slashMatch[1])
|
||||||
|
} else if (hashtagMatch) {
|
||||||
|
setIsFilePickerOpen(true)
|
||||||
|
setHashtagCommand(hashtagMatch[1])
|
||||||
|
} else if (toolMatch) {
|
||||||
|
setIsToolPickerOpen(true)
|
||||||
|
setToolCommand(toolMatch[1])
|
||||||
|
} else {
|
||||||
|
setIsPromptPickerOpen(false)
|
||||||
|
setIsFilePickerOpen(false)
|
||||||
|
setIsToolPickerOpen(false)
|
||||||
|
setIsAssistantPickerOpen(false)
|
||||||
|
setSlashCommand("")
|
||||||
|
setHashtagCommand("")
|
||||||
|
setToolCommand("")
|
||||||
|
setAtCommand("")
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserInput(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectPrompt = (prompt: Tables<"prompts">) => {
|
||||||
|
setIsPromptPickerOpen(false)
|
||||||
|
setUserInput(userInput.replace(/\/[^ ]*$/, "") + prompt.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectUserFile = async (file: Tables<"files">) => {
|
||||||
|
setShowFilesDisplay(true)
|
||||||
|
setIsFilePickerOpen(false)
|
||||||
|
setUseRetrieval(true)
|
||||||
|
|
||||||
|
setNewMessageFiles(prev => {
|
||||||
|
const fileAlreadySelected =
|
||||||
|
prev.some(prevFile => prevFile.id === file.id) ||
|
||||||
|
chatFiles.some(chatFile => chatFile.id === file.id)
|
||||||
|
|
||||||
|
if (!fileAlreadySelected) {
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
file: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
|
setUserInput(userInput.replace(/#[^ ]*$/, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectUserCollection = async (
|
||||||
|
collection: Tables<"collections">
|
||||||
|
) => {
|
||||||
|
setShowFilesDisplay(true)
|
||||||
|
setIsFilePickerOpen(false)
|
||||||
|
setUseRetrieval(true)
|
||||||
|
|
||||||
|
const collectionFiles = await getCollectionFilesByCollectionId(
|
||||||
|
collection.id
|
||||||
|
)
|
||||||
|
|
||||||
|
setNewMessageFiles(prev => {
|
||||||
|
const newFiles = collectionFiles.files
|
||||||
|
.filter(
|
||||||
|
file =>
|
||||||
|
!prev.some(prevFile => prevFile.id === file.id) &&
|
||||||
|
!chatFiles.some(chatFile => chatFile.id === file.id)
|
||||||
|
)
|
||||||
|
.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
file: null
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...prev, ...newFiles]
|
||||||
|
})
|
||||||
|
|
||||||
|
setUserInput(userInput.replace(/#[^ ]*$/, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectTool = (tool: Tables<"tools">) => {
|
||||||
|
setIsToolPickerOpen(false)
|
||||||
|
setUserInput(userInput.replace(/![^ ]*$/, ""))
|
||||||
|
setSelectedTools(prev => [...prev, tool])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAssistant = async (assistant: Tables<"assistants">) => {
|
||||||
|
setIsAssistantPickerOpen(false)
|
||||||
|
setUserInput(userInput.replace(/@[^ ]*$/, ""))
|
||||||
|
setSelectedAssistant(assistant)
|
||||||
|
|
||||||
|
setChatSettings({
|
||||||
|
model: assistant.model as LLMID,
|
||||||
|
prompt: assistant.prompt,
|
||||||
|
temperature: assistant.temperature,
|
||||||
|
contextLength: assistant.context_length,
|
||||||
|
includeProfileContext: assistant.include_profile_context,
|
||||||
|
includeWorkspaceInstructions: assistant.include_workspace_instructions,
|
||||||
|
embeddingsProvider: assistant.embeddings_provider as "openai" | "local"
|
||||||
|
})
|
||||||
|
|
||||||
|
let allFiles = []
|
||||||
|
|
||||||
|
const assistantFiles = (await getAssistantFilesByAssistantId(assistant.id))
|
||||||
|
.files
|
||||||
|
allFiles = [...assistantFiles]
|
||||||
|
const assistantCollections = (
|
||||||
|
await getAssistantCollectionsByAssistantId(assistant.id)
|
||||||
|
).collections
|
||||||
|
for (const collection of assistantCollections) {
|
||||||
|
const collectionFiles = (
|
||||||
|
await getCollectionFilesByCollectionId(collection.id)
|
||||||
|
).files
|
||||||
|
allFiles = [...allFiles, ...collectionFiles]
|
||||||
|
}
|
||||||
|
const assistantTools = (await getAssistantToolsByAssistantId(assistant.id))
|
||||||
|
.tools
|
||||||
|
|
||||||
|
setSelectedTools(assistantTools)
|
||||||
|
setChatFiles(
|
||||||
|
allFiles.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
file: null
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allFiles.length > 0) setShowFilesDisplay(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleInputChange,
|
||||||
|
handleSelectPrompt,
|
||||||
|
handleSelectUserFile,
|
||||||
|
handleSelectUserCollection,
|
||||||
|
handleSelectTool,
|
||||||
|
handleSelectAssistant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import {
|
||||||
|
type UIEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
export const useScroll = () => {
|
||||||
|
const { isGenerating, chatMessages } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const messagesStartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isAutoScrolling = useRef(false)
|
||||||
|
|
||||||
|
const [isAtTop, setIsAtTop] = useState(false)
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
|
const [userScrolled, setUserScrolled] = useState(false)
|
||||||
|
const [isOverflowing, setIsOverflowing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserScrolled(false)
|
||||||
|
|
||||||
|
if (!isGenerating && userScrolled) {
|
||||||
|
setUserScrolled(false)
|
||||||
|
}
|
||||||
|
}, [isGenerating])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGenerating && !userScrolled) {
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}, [chatMessages])
|
||||||
|
|
||||||
|
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback(e => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
const bottom =
|
||||||
|
Math.round(target.scrollHeight) - Math.round(target.scrollTop) ===
|
||||||
|
Math.round(target.clientHeight)
|
||||||
|
setIsAtBottom(bottom)
|
||||||
|
|
||||||
|
const top = target.scrollTop === 0
|
||||||
|
setIsAtTop(top)
|
||||||
|
|
||||||
|
if (!bottom && !isAutoScrolling.current) {
|
||||||
|
setUserScrolled(true)
|
||||||
|
} else {
|
||||||
|
setUserScrolled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOverflow = target.scrollHeight > target.clientHeight
|
||||||
|
setIsOverflowing(isOverflow)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToTop = useCallback(() => {
|
||||||
|
if (messagesStartRef.current) {
|
||||||
|
messagesStartRef.current.scrollIntoView({ behavior: "instant" })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
isAutoScrolling.current = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (messagesEndRef.current) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
|
||||||
|
}
|
||||||
|
|
||||||
|
isAutoScrolling.current = false
|
||||||
|
}, 100)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesStartRef,
|
||||||
|
messagesEndRef,
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom,
|
||||||
|
userScrolled,
|
||||||
|
isOverflowing,
|
||||||
|
handleScroll,
|
||||||
|
scrollToTop,
|
||||||
|
scrollToBottom,
|
||||||
|
setIsAtBottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { createDocXFile, createFile } from "@/db/files"
|
||||||
|
import { LLM_LIST } from "@/lib/models/llm/llm-list"
|
||||||
|
import mammoth from "mammoth"
|
||||||
|
import { useContext, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_TYPES = [
|
||||||
|
"text/csv",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/json",
|
||||||
|
"text/markdown",
|
||||||
|
"application/pdf",
|
||||||
|
"text/plain"
|
||||||
|
].join(",")
|
||||||
|
|
||||||
|
export const useSelectFileHandler = () => {
|
||||||
|
const {
|
||||||
|
selectedWorkspace,
|
||||||
|
profile,
|
||||||
|
chatSettings,
|
||||||
|
setNewMessageImages,
|
||||||
|
setNewMessageFiles,
|
||||||
|
setShowFilesDisplay,
|
||||||
|
setFiles,
|
||||||
|
setUseRetrieval
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [filesToAccept, setFilesToAccept] = useState(ACCEPTED_FILE_TYPES)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleFilesToAccept()
|
||||||
|
}, [chatSettings?.model])
|
||||||
|
|
||||||
|
const handleFilesToAccept = () => {
|
||||||
|
const model = chatSettings?.model
|
||||||
|
const FULL_MODEL = LLM_LIST.find(llm => llm.modelId === model)
|
||||||
|
|
||||||
|
if (!FULL_MODEL) return
|
||||||
|
|
||||||
|
setFilesToAccept(
|
||||||
|
FULL_MODEL.imageInput
|
||||||
|
? `${ACCEPTED_FILE_TYPES},image/*`
|
||||||
|
: ACCEPTED_FILE_TYPES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectDeviceFile = async (file: File) => {
|
||||||
|
if (!profile || !selectedWorkspace || !chatSettings) return
|
||||||
|
|
||||||
|
setShowFilesDisplay(true)
|
||||||
|
setUseRetrieval(true)
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
let simplifiedFileType = file.type.split("/")[1]
|
||||||
|
|
||||||
|
let reader = new FileReader()
|
||||||
|
|
||||||
|
if (file.type.includes("image")) {
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} else if (ACCEPTED_FILE_TYPES.split(",").includes(file.type)) {
|
||||||
|
if (simplifiedFileType.includes("vnd.adobe.pdf")) {
|
||||||
|
simplifiedFileType = "pdf"
|
||||||
|
} else if (
|
||||||
|
simplifiedFileType.includes(
|
||||||
|
"vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||||
|
"docx"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
simplifiedFileType = "docx"
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewMessageFiles(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: "loading",
|
||||||
|
name: file.name,
|
||||||
|
type: simplifiedFileType,
|
||||||
|
file: file
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Handle docx files
|
||||||
|
if (
|
||||||
|
file.type.includes(
|
||||||
|
"vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||||
|
"docx"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const result = await mammoth.extractRawText({
|
||||||
|
arrayBuffer
|
||||||
|
})
|
||||||
|
|
||||||
|
const createdFile = await createDocXFile(
|
||||||
|
result.value,
|
||||||
|
file,
|
||||||
|
{
|
||||||
|
user_id: profile.user_id,
|
||||||
|
description: "",
|
||||||
|
file_path: "",
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
tokens: 0,
|
||||||
|
type: simplifiedFileType
|
||||||
|
},
|
||||||
|
selectedWorkspace.id,
|
||||||
|
chatSettings.embeddingsProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
setFiles(prev => [...prev, createdFile])
|
||||||
|
|
||||||
|
setNewMessageFiles(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === "loading"
|
||||||
|
? {
|
||||||
|
id: createdFile.id,
|
||||||
|
name: createdFile.name,
|
||||||
|
type: createdFile.type,
|
||||||
|
file: file
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
reader.onloadend = null
|
||||||
|
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Use readAsArrayBuffer for PDFs and readAsText for other types
|
||||||
|
file.type.includes("pdf")
|
||||||
|
? reader.readAsArrayBuffer(file)
|
||||||
|
: reader.readAsText(file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.onloadend = async function () {
|
||||||
|
try {
|
||||||
|
if (file.type.includes("image")) {
|
||||||
|
// Create a temp url for the image file
|
||||||
|
const imageUrl = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
// This is a temporary image for display purposes in the chat input
|
||||||
|
setNewMessageImages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
messageId: "temp",
|
||||||
|
path: "",
|
||||||
|
base64: reader.result, // base64 image
|
||||||
|
url: imageUrl,
|
||||||
|
file
|
||||||
|
}
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
const createdFile = await createFile(
|
||||||
|
file,
|
||||||
|
{
|
||||||
|
user_id: profile.user_id,
|
||||||
|
description: "",
|
||||||
|
file_path: "",
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
tokens: 0,
|
||||||
|
type: simplifiedFileType
|
||||||
|
},
|
||||||
|
selectedWorkspace.id,
|
||||||
|
chatSettings.embeddingsProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
setFiles(prev => [...prev, createdFile])
|
||||||
|
|
||||||
|
setNewMessageFiles(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === "loading"
|
||||||
|
? {
|
||||||
|
id: createdFile.id,
|
||||||
|
name: createdFile.name,
|
||||||
|
type: createdFile.type,
|
||||||
|
file: file
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("Failed to upload. " + error?.message, {
|
||||||
|
duration: 10000
|
||||||
|
})
|
||||||
|
setNewMessageImages(prev =>
|
||||||
|
prev.filter(img => img.messageId !== "temp")
|
||||||
|
)
|
||||||
|
setNewMessageFiles(prev => prev.filter(file => file.id !== "loading"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSelectDeviceFile,
|
||||||
|
filesToAccept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { LLM_LIST } from "@/lib/models/llm/llm-list"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
IconBolt,
|
||||||
|
IconCirclePlus,
|
||||||
|
IconPlayerStopFilled,
|
||||||
|
IconSend
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { TextareaAutosize } from "../ui/textarea-autosize"
|
||||||
|
import { ChatCommandInput } from "./chat-command-input"
|
||||||
|
import { ChatFilesDisplay } from "./chat-files-display"
|
||||||
|
import { useChatHandler } from "./chat-hooks/use-chat-handler"
|
||||||
|
import { useChatHistoryHandler } from "./chat-hooks/use-chat-history"
|
||||||
|
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
|
||||||
|
import { useSelectFileHandler } from "./chat-hooks/use-select-file-handler"
|
||||||
|
|
||||||
|
interface ChatInputProps {}
|
||||||
|
|
||||||
|
export const ChatInput: FC<ChatInputProps> = ({}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useHotkey("l", () => {
|
||||||
|
handleFocusChatInput()
|
||||||
|
})
|
||||||
|
|
||||||
|
const [isTyping, setIsTyping] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAssistantPickerOpen,
|
||||||
|
focusAssistant,
|
||||||
|
setFocusAssistant,
|
||||||
|
userInput,
|
||||||
|
chatMessages,
|
||||||
|
isGenerating,
|
||||||
|
selectedPreset,
|
||||||
|
selectedAssistant,
|
||||||
|
focusPrompt,
|
||||||
|
setFocusPrompt,
|
||||||
|
focusFile,
|
||||||
|
focusTool,
|
||||||
|
setFocusTool,
|
||||||
|
isToolPickerOpen,
|
||||||
|
isPromptPickerOpen,
|
||||||
|
setIsPromptPickerOpen,
|
||||||
|
isFilePickerOpen,
|
||||||
|
setFocusFile,
|
||||||
|
chatSettings,
|
||||||
|
selectedTools,
|
||||||
|
setSelectedTools,
|
||||||
|
assistantImages
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const {
|
||||||
|
chatInputRef,
|
||||||
|
handleSendMessage,
|
||||||
|
handleStopMessage,
|
||||||
|
handleFocusChatInput
|
||||||
|
} = useChatHandler()
|
||||||
|
|
||||||
|
const { handleInputChange } = usePromptAndCommand()
|
||||||
|
|
||||||
|
const { filesToAccept, handleSelectDeviceFile } = useSelectFileHandler()
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNewMessageContentToNextUserMessage,
|
||||||
|
setNewMessageContentToPreviousUserMessage
|
||||||
|
} = useChatHistoryHandler()
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
handleFocusChatInput()
|
||||||
|
}, 200) // FIX: hacky
|
||||||
|
}, [selectedPreset, selectedAssistant])
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (!isTyping && event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsPromptPickerOpen(false)
|
||||||
|
handleSendMessage(userInput, chatMessages, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidate conditions to avoid TypeScript error
|
||||||
|
if (
|
||||||
|
isPromptPickerOpen ||
|
||||||
|
isFilePickerOpen ||
|
||||||
|
isToolPickerOpen ||
|
||||||
|
isAssistantPickerOpen
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
event.key === "Tab" ||
|
||||||
|
event.key === "ArrowUp" ||
|
||||||
|
event.key === "ArrowDown"
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
// Toggle focus based on picker type
|
||||||
|
if (isPromptPickerOpen) setFocusPrompt(!focusPrompt)
|
||||||
|
if (isFilePickerOpen) setFocusFile(!focusFile)
|
||||||
|
if (isToolPickerOpen) setFocusTool(!focusTool)
|
||||||
|
if (isAssistantPickerOpen) setFocusAssistant(!focusAssistant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp" && event.shiftKey && event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
setNewMessageContentToPreviousUserMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown" && event.shiftKey && event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
setNewMessageContentToNextUserMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
//use shift+ctrl+up and shift+ctrl+down to navigate through chat history
|
||||||
|
if (event.key === "ArrowUp" && event.shiftKey && event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
setNewMessageContentToPreviousUserMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown" && event.shiftKey && event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
setNewMessageContentToNextUserMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAssistantPickerOpen &&
|
||||||
|
(event.key === "Tab" ||
|
||||||
|
event.key === "ArrowUp" ||
|
||||||
|
event.key === "ArrowDown")
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
setFocusAssistant(!focusAssistant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event: React.ClipboardEvent) => {
|
||||||
|
const imagesAllowed = LLM_LIST.find(
|
||||||
|
llm => llm.modelId === chatSettings?.model
|
||||||
|
)?.imageInput
|
||||||
|
|
||||||
|
const items = event.clipboardData.items
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.indexOf("image") === 0) {
|
||||||
|
if (!imagesAllowed) {
|
||||||
|
toast.error(
|
||||||
|
`Images are not supported for this model. Use models like GPT-4 Vision instead.`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) return
|
||||||
|
handleSelectDeviceFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col flex-wrap justify-center gap-2">
|
||||||
|
<ChatFilesDisplay />
|
||||||
|
|
||||||
|
{selectedTools &&
|
||||||
|
selectedTools.map((tool, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex justify-center"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedTools(
|
||||||
|
selectedTools.filter(
|
||||||
|
selectedTool => selectedTool.id !== tool.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex cursor-pointer items-center justify-center space-x-1 rounded-lg bg-purple-600 px-3 py-1 hover:opacity-50">
|
||||||
|
<IconBolt size={20} />
|
||||||
|
|
||||||
|
<div>{tool.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedAssistant && (
|
||||||
|
<div className="border-primary mx-auto flex w-fit items-center space-x-2 rounded-lg border p-1.5">
|
||||||
|
{selectedAssistant.image_path && (
|
||||||
|
<Image
|
||||||
|
className="rounded"
|
||||||
|
src={
|
||||||
|
assistantImages.find(
|
||||||
|
img => img.path === selectedAssistant.image_path
|
||||||
|
)?.base64
|
||||||
|
}
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
alt={selectedAssistant.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
Talking to {selectedAssistant.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-input relative mt-3 flex min-h-[60px] w-full items-center justify-center rounded-xl border-2">
|
||||||
|
<div className="absolute bottom-[76px] left-0 max-h-[300px] w-full overflow-auto rounded-xl dark:border-none">
|
||||||
|
<ChatCommandInput />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<IconCirclePlus
|
||||||
|
className="absolute bottom-[12px] left-3 cursor-pointer p-1 hover:opacity-50"
|
||||||
|
size={32}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hidden input to select files from device */}
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
onChange={e => {
|
||||||
|
if (!e.target.files) return
|
||||||
|
handleSelectDeviceFile(e.target.files[0])
|
||||||
|
}}
|
||||||
|
accept={filesToAccept}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
|
||||||
|
<TextareaAutosize
|
||||||
|
textareaRef={chatInputRef}
|
||||||
|
className="ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring text-md flex w-full resize-none rounded-md border-none bg-transparent px-14 py-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder={t(
|
||||||
|
// `Ask anything. Type "@" for assistants, "/" for prompts, "#" for files, and "!" for tools.`
|
||||||
|
`Ask anything. Type @ / # !`
|
||||||
|
)}
|
||||||
|
onValueChange={handleInputChange}
|
||||||
|
value={userInput}
|
||||||
|
minRows={1}
|
||||||
|
maxRows={18}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCompositionStart={() => setIsTyping(true)}
|
||||||
|
onCompositionEnd={() => setIsTyping(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[14px] right-3 cursor-pointer hover:opacity-50">
|
||||||
|
{isGenerating ? (
|
||||||
|
<IconPlayerStopFilled
|
||||||
|
className="hover:bg-background animate-pulse rounded bg-transparent p-1"
|
||||||
|
onClick={handleStopMessage}
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconSend
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-secondary rounded p-1",
|
||||||
|
!userInput && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!userInput) return
|
||||||
|
|
||||||
|
handleSendMessage(userInput, chatMessages, false)
|
||||||
|
}}
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { FC, useContext, useState } from "react"
|
||||||
|
import { Message } from "../messages/message"
|
||||||
|
|
||||||
|
interface ChatMessagesProps {}
|
||||||
|
|
||||||
|
export const ChatMessages: FC<ChatMessagesProps> = ({}) => {
|
||||||
|
const { chatMessages, chatFileItems } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleSendEdit } = useChatHandler()
|
||||||
|
|
||||||
|
const [editingMessage, setEditingMessage] = useState<Tables<"messages">>()
|
||||||
|
|
||||||
|
return chatMessages
|
||||||
|
.sort((a, b) => a.message.sequence_number - b.message.sequence_number)
|
||||||
|
.map((chatMessage, index, array) => {
|
||||||
|
const messageFileItems = chatFileItems.filter(
|
||||||
|
(chatFileItem, _, self) =>
|
||||||
|
chatMessage.fileItems.includes(chatFileItem.id) &&
|
||||||
|
self.findIndex(item => item.id === chatFileItem.id) === _
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={chatMessage.message.sequence_number}
|
||||||
|
message={chatMessage.message}
|
||||||
|
fileItems={messageFileItems}
|
||||||
|
isEditing={editingMessage?.id === chatMessage.message.id}
|
||||||
|
isLast={index === array.length - 1}
|
||||||
|
onStartEdit={setEditingMessage}
|
||||||
|
onCancelEdit={() => setEditingMessage(undefined)}
|
||||||
|
onSubmitEdit={handleSendEdit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { IconAdjustmentsHorizontal } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useState } from "react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTrigger
|
||||||
|
} from "../ui/dialog"
|
||||||
|
import { Label } from "../ui/label"
|
||||||
|
import { Slider } from "../ui/slider"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
|
||||||
|
interface ChatRetrievalSettingsProps {}
|
||||||
|
|
||||||
|
export const ChatRetrievalSettings: FC<ChatRetrievalSettingsProps> = ({}) => {
|
||||||
|
const { sourceCount, setSourceCount } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger>
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={0}
|
||||||
|
side="top"
|
||||||
|
display={<div>Adjust retrieval settings.</div>}
|
||||||
|
trigger={
|
||||||
|
<IconAdjustmentsHorizontal
|
||||||
|
className="cursor-pointer pt-[4px] hover:opacity-50"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="flex items-center space-x-1">
|
||||||
|
<div>Source Count:</div>
|
||||||
|
|
||||||
|
<div>{sourceCount}</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={[sourceCount]}
|
||||||
|
onValueChange={values => {
|
||||||
|
setSourceCount(values[0])
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" onClick={() => setIsOpen(false)}>
|
||||||
|
Save & Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import {
|
||||||
|
IconCircleArrowDownFilled,
|
||||||
|
IconCircleArrowUpFilled
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface ChatScrollButtonsProps {
|
||||||
|
isAtTop: boolean
|
||||||
|
isAtBottom: boolean
|
||||||
|
isOverflowing: boolean
|
||||||
|
scrollToTop: () => void
|
||||||
|
scrollToBottom: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatScrollButtons: FC<ChatScrollButtonsProps> = ({
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom,
|
||||||
|
isOverflowing,
|
||||||
|
scrollToTop,
|
||||||
|
scrollToBottom
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isAtTop && isOverflowing && (
|
||||||
|
<IconCircleArrowUpFilled
|
||||||
|
className="cursor-pointer opacity-50 hover:opacity-100"
|
||||||
|
size={32}
|
||||||
|
onClick={scrollToTop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAtBottom && isOverflowing && (
|
||||||
|
<IconCircleArrowDownFilled
|
||||||
|
className="cursor-pointer opacity-50 hover:opacity-100"
|
||||||
|
size={32}
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { IconInfoCircle, IconMessagePlus } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext } from "react"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
|
||||||
|
interface ChatSecondaryButtonsProps {}
|
||||||
|
|
||||||
|
export const ChatSecondaryButtons: FC<ChatSecondaryButtonsProps> = ({}) => {
|
||||||
|
const { selectedChat } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleNewChat } = useChatHandler()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedChat && (
|
||||||
|
<>
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={200}
|
||||||
|
display={
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold">Chat Info</div>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-2 max-w-xs space-y-2 sm:max-w-sm md:max-w-md lg:max-w-lg">
|
||||||
|
<div>Model: {selectedChat.model}</div>
|
||||||
|
<div>Prompt: {selectedChat.prompt}</div>
|
||||||
|
|
||||||
|
<div>Temperature: {selectedChat.temperature}</div>
|
||||||
|
<div>Context Length: {selectedChat.context_length}</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Profile Context:{" "}
|
||||||
|
{selectedChat.include_profile_context
|
||||||
|
? "Enabled"
|
||||||
|
: "Disabled"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{" "}
|
||||||
|
Workspace Instructions:{" "}
|
||||||
|
{selectedChat.include_workspace_instructions
|
||||||
|
? "Enabled"
|
||||||
|
: "Disabled"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Embeddings Provider: {selectedChat.embeddings_provider}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
trigger={
|
||||||
|
<div className="mt-1">
|
||||||
|
<IconInfoCircle
|
||||||
|
className="cursor-default hover:opacity-50"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={200}
|
||||||
|
display={<div>Start a new chat</div>}
|
||||||
|
trigger={
|
||||||
|
<div className="mt-1">
|
||||||
|
<IconMessagePlus
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
size={24}
|
||||||
|
onClick={handleNewChat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { LLMID, ModelProvider } from "@/types"
|
||||||
|
import { IconAdjustmentsHorizontal } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useRef } from "react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { ChatSettingsForm } from "../ui/chat-settings-form"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"
|
||||||
|
|
||||||
|
interface ChatSettingsProps {}
|
||||||
|
|
||||||
|
export const ChatSettings: FC<ChatSettingsProps> = ({}) => {
|
||||||
|
useHotkey("i", () => handleClick())
|
||||||
|
|
||||||
|
const {
|
||||||
|
chatSettings,
|
||||||
|
setChatSettings,
|
||||||
|
models,
|
||||||
|
availableHostedModels,
|
||||||
|
availableLocalModels,
|
||||||
|
availableOpenRouterModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
buttonRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatSettings) return
|
||||||
|
|
||||||
|
setChatSettings({
|
||||||
|
...chatSettings,
|
||||||
|
temperature: Math.min(
|
||||||
|
chatSettings.temperature,
|
||||||
|
CHAT_SETTING_LIMITS[chatSettings.model]?.MAX_TEMPERATURE || 1
|
||||||
|
),
|
||||||
|
contextLength: Math.min(
|
||||||
|
chatSettings.contextLength,
|
||||||
|
CHAT_SETTING_LIMITS[chatSettings.model]?.MAX_CONTEXT_LENGTH || 4096
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [chatSettings?.model])
|
||||||
|
|
||||||
|
if (!chatSettings) return null
|
||||||
|
|
||||||
|
const allModels = [
|
||||||
|
...models.map(model => ({
|
||||||
|
modelId: model.model_id as LLMID,
|
||||||
|
modelName: model.name,
|
||||||
|
provider: "custom" as ModelProvider,
|
||||||
|
hostedId: model.id,
|
||||||
|
platformLink: "",
|
||||||
|
imageInput: false
|
||||||
|
})),
|
||||||
|
...availableHostedModels,
|
||||||
|
...availableLocalModels,
|
||||||
|
...availableOpenRouterModels
|
||||||
|
]
|
||||||
|
|
||||||
|
const fullModel = allModels.find(llm => llm.modelId === chatSettings.model)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div className="max-w-[120px] truncate text-lg sm:max-w-[300px] lg:max-w-[500px]">
|
||||||
|
{fullModel?.modelName || chatSettings.model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconAdjustmentsHorizontal size={28} />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="bg-background border-input relative flex max-h-[calc(100vh-60px)] w-[300px] flex-col space-y-4 overflow-auto rounded-lg border-2 p-6 sm:w-[350px] md:w-[400px] lg:w-[500px] dark:border-none"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<ChatSettingsForm
|
||||||
|
chatSettings={chatSettings}
|
||||||
|
onChangeChatSettings={setChatSettings}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
import Loading from "@/app/[locale]/loading"
|
||||||
|
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getAssistantToolsByAssistantId } from "@/db/assistant-tools"
|
||||||
|
import { getChatFilesByChatId } from "@/db/chat-files"
|
||||||
|
import { getChatById } from "@/db/chats"
|
||||||
|
import { getMessageFileItemsByMessageId } from "@/db/message-file-items"
|
||||||
|
import { getMessagesByChatId } from "@/db/messages"
|
||||||
|
import { getMessageImageFromStorage } from "@/db/storage/message-images"
|
||||||
|
import { convertBlobToBase64 } from "@/lib/blob-to-b64"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { LLMID, MessageImage } from "@/types"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { FC, useContext, useEffect, useState } from "react"
|
||||||
|
import { ChatHelp } from "./chat-help"
|
||||||
|
import { useScroll } from "./chat-hooks/use-scroll"
|
||||||
|
import { ChatInput } from "./chat-input"
|
||||||
|
import { ChatMessages } from "./chat-messages"
|
||||||
|
import { ChatScrollButtons } from "./chat-scroll-buttons"
|
||||||
|
import { ChatSecondaryButtons } from "./chat-secondary-buttons"
|
||||||
|
|
||||||
|
interface ChatUIProps {}
|
||||||
|
|
||||||
|
export const ChatUI: FC<ChatUIProps> = ({}) => {
|
||||||
|
useHotkey("o", () => handleNewChat())
|
||||||
|
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
const {
|
||||||
|
setChatMessages,
|
||||||
|
selectedChat,
|
||||||
|
setSelectedChat,
|
||||||
|
setChatSettings,
|
||||||
|
setChatImages,
|
||||||
|
assistants,
|
||||||
|
setSelectedAssistant,
|
||||||
|
setChatFileItems,
|
||||||
|
setChatFiles,
|
||||||
|
setShowFilesDisplay,
|
||||||
|
setUseRetrieval,
|
||||||
|
setSelectedTools
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleNewChat, handleFocusChatInput } = useChatHandler()
|
||||||
|
|
||||||
|
const {
|
||||||
|
messagesStartRef,
|
||||||
|
messagesEndRef,
|
||||||
|
handleScroll,
|
||||||
|
scrollToBottom,
|
||||||
|
setIsAtBottom,
|
||||||
|
isAtTop,
|
||||||
|
isAtBottom,
|
||||||
|
isOverflowing,
|
||||||
|
scrollToTop
|
||||||
|
} = useScroll()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await fetchMessages()
|
||||||
|
await fetchChat()
|
||||||
|
|
||||||
|
scrollToBottom()
|
||||||
|
setIsAtBottom(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.chatid) {
|
||||||
|
fetchData().then(() => {
|
||||||
|
handleFocusChatInput()
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
const fetchedMessages = await getMessagesByChatId(params.chatid as string)
|
||||||
|
|
||||||
|
const imagePromises: Promise<MessageImage>[] = fetchedMessages.flatMap(
|
||||||
|
message =>
|
||||||
|
message.image_paths
|
||||||
|
? message.image_paths.map(async imagePath => {
|
||||||
|
const url = await getMessageImageFromStorage(imagePath)
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const base64 = await convertBlobToBase64(blob)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: message.id,
|
||||||
|
path: imagePath,
|
||||||
|
base64,
|
||||||
|
url,
|
||||||
|
file: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: message.id,
|
||||||
|
path: imagePath,
|
||||||
|
base64: "",
|
||||||
|
url,
|
||||||
|
file: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
const images: MessageImage[] = await Promise.all(imagePromises.flat())
|
||||||
|
setChatImages(images)
|
||||||
|
|
||||||
|
const messageFileItemPromises = fetchedMessages.map(
|
||||||
|
async message => await getMessageFileItemsByMessageId(message.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const messageFileItems = await Promise.all(messageFileItemPromises)
|
||||||
|
|
||||||
|
const uniqueFileItems = messageFileItems.flatMap(item => item.file_items)
|
||||||
|
setChatFileItems(uniqueFileItems)
|
||||||
|
|
||||||
|
const chatFiles = await getChatFilesByChatId(params.chatid as string)
|
||||||
|
|
||||||
|
setChatFiles(
|
||||||
|
chatFiles.files.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
file: null
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
setUseRetrieval(true)
|
||||||
|
setShowFilesDisplay(true)
|
||||||
|
|
||||||
|
const fetchedChatMessages = fetchedMessages.map(message => {
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
fileItems: messageFileItems
|
||||||
|
.filter(messageFileItem => messageFileItem.id === message.id)
|
||||||
|
.flatMap(messageFileItem =>
|
||||||
|
messageFileItem.file_items.map(fileItem => fileItem.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setChatMessages(fetchedChatMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchChat = async () => {
|
||||||
|
const chat = await getChatById(params.chatid as string)
|
||||||
|
if (!chat) return
|
||||||
|
|
||||||
|
if (chat.assistant_id) {
|
||||||
|
const assistant = assistants.find(
|
||||||
|
assistant => assistant.id === chat.assistant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (assistant) {
|
||||||
|
setSelectedAssistant(assistant)
|
||||||
|
|
||||||
|
const assistantTools = (
|
||||||
|
await getAssistantToolsByAssistantId(assistant.id)
|
||||||
|
).tools
|
||||||
|
setSelectedTools(assistantTools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedChat(chat)
|
||||||
|
setChatSettings({
|
||||||
|
model: chat.model as LLMID,
|
||||||
|
prompt: chat.prompt,
|
||||||
|
temperature: chat.temperature,
|
||||||
|
contextLength: chat.context_length,
|
||||||
|
includeProfileContext: chat.include_profile_context,
|
||||||
|
includeWorkspaceInstructions: chat.include_workspace_instructions,
|
||||||
|
embeddingsProvider: chat.embeddings_provider as "openai" | "local"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full flex-col items-center">
|
||||||
|
<div className="absolute left-4 top-2.5 flex justify-center">
|
||||||
|
<ChatScrollButtons
|
||||||
|
isAtTop={isAtTop}
|
||||||
|
isAtBottom={isAtBottom}
|
||||||
|
isOverflowing={isOverflowing}
|
||||||
|
scrollToTop={scrollToTop}
|
||||||
|
scrollToBottom={scrollToBottom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-4 top-1 flex h-[40px] items-center space-x-2">
|
||||||
|
<ChatSecondaryButtons />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-secondary flex max-h-[50px] min-h-[50px] w-full items-center justify-center border-b-2 font-bold">
|
||||||
|
<div className="max-w-[200px] truncate sm:max-w-[400px] md:max-w-[500px] lg:max-w-[600px] xl:max-w-[700px]">
|
||||||
|
{selectedChat?.name || "Chat"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex size-full flex-col overflow-auto border-b"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div ref={messagesStartRef} />
|
||||||
|
|
||||||
|
<ChatMessages />
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full min-w-[300px] items-end px-2 pb-3 pt-0 sm:w-[600px] sm:pb-8 sm:pt-5 md:w-[700px] lg:w-[700px] xl:w-[800px]">
|
||||||
|
<ChatInput />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-2 right-2 hidden md:block lg:bottom-4 lg:right-4">
|
||||||
|
<ChatHelp />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconBooks } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useRef } from "react"
|
||||||
|
import { FileIcon } from "../ui/file-icon"
|
||||||
|
|
||||||
|
interface FilePickerProps {
|
||||||
|
isOpen: boolean
|
||||||
|
searchQuery: string
|
||||||
|
onOpenChange: (isOpen: boolean) => void
|
||||||
|
selectedFileIds: string[]
|
||||||
|
selectedCollectionIds: string[]
|
||||||
|
onSelectFile: (file: Tables<"files">) => void
|
||||||
|
onSelectCollection: (collection: Tables<"collections">) => void
|
||||||
|
isFocused: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilePicker: FC<FilePickerProps> = ({
|
||||||
|
isOpen,
|
||||||
|
searchQuery,
|
||||||
|
onOpenChange,
|
||||||
|
selectedFileIds,
|
||||||
|
selectedCollectionIds,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectCollection,
|
||||||
|
isFocused
|
||||||
|
}) => {
|
||||||
|
const { files, collections, setIsFilePickerOpen } =
|
||||||
|
useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFocused && itemsRef.current[0]) {
|
||||||
|
itemsRef.current[0].focus()
|
||||||
|
}
|
||||||
|
}, [isFocused])
|
||||||
|
|
||||||
|
const filteredFiles = files.filter(
|
||||||
|
file =>
|
||||||
|
file.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||||
|
!selectedFileIds.includes(file.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredCollections = collections.filter(
|
||||||
|
collection =>
|
||||||
|
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||||
|
!selectedCollectionIds.includes(collection.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
onOpenChange(isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectFile = (file: Tables<"files">) => {
|
||||||
|
onSelectFile(file)
|
||||||
|
handleOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectCollection = (collection: Tables<"collections">) => {
|
||||||
|
onSelectCollection(collection)
|
||||||
|
handleOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKeyDownHandler =
|
||||||
|
(index: number, type: "file" | "collection", item: any) =>
|
||||||
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsFilePickerOpen(false)
|
||||||
|
} else if (e.key === "Backspace") {
|
||||||
|
e.preventDefault()
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (type === "file") {
|
||||||
|
handleSelectFile(item)
|
||||||
|
} else {
|
||||||
|
handleSelectCollection(item)
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
(e.key === "Tab" || e.key === "ArrowDown") &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
index === filteredFiles.length + filteredCollections.length - 1
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[0]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
|
||||||
|
// go to last element if arrow up is pressed on first element
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[itemsRef.current.length - 1]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
const prevIndex =
|
||||||
|
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
|
||||||
|
itemsRef.current[prevIndex]?.focus()
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
|
||||||
|
itemsRef.current[nextIndex]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
|
||||||
|
{filteredFiles.length === 0 && filteredCollections.length === 0 ? (
|
||||||
|
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
|
||||||
|
No matching files.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{[...filteredFiles, ...filteredCollections].map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={ref => {
|
||||||
|
itemsRef.current[index] = ref
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center rounded p-2 focus:outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
if ("type" in item) {
|
||||||
|
handleSelectFile(item as Tables<"files">)
|
||||||
|
} else {
|
||||||
|
handleSelectCollection(item)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e =>
|
||||||
|
getKeyDownHandler(
|
||||||
|
index,
|
||||||
|
"type" in item ? "file" : "collection",
|
||||||
|
item
|
||||||
|
)(e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"type" in item ? (
|
||||||
|
<FileIcon type={(item as Tables<"files">).type} size={32} />
|
||||||
|
) : (
|
||||||
|
<IconBooks size={32} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-3 flex flex-col">
|
||||||
|
<div className="font-bold">{item.name}</div>
|
||||||
|
|
||||||
|
<div className="truncate text-sm opacity-80">
|
||||||
|
{item.description || "No description."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"
|
||||||
|
import { Label } from "../ui/label"
|
||||||
|
import { TextareaAutosize } from "../ui/textarea-autosize"
|
||||||
|
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
|
||||||
|
|
||||||
|
interface PromptPickerProps {}
|
||||||
|
|
||||||
|
export const PromptPicker: FC<PromptPickerProps> = ({}) => {
|
||||||
|
const {
|
||||||
|
prompts,
|
||||||
|
isPromptPickerOpen,
|
||||||
|
setIsPromptPickerOpen,
|
||||||
|
focusPrompt,
|
||||||
|
slashCommand
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleSelectPrompt } = usePromptAndCommand()
|
||||||
|
|
||||||
|
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
const [promptVariables, setPromptVariables] = useState<
|
||||||
|
{
|
||||||
|
promptId: string
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}[]
|
||||||
|
>([])
|
||||||
|
const [showPromptVariables, setShowPromptVariables] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusPrompt && itemsRef.current[0]) {
|
||||||
|
itemsRef.current[0].focus()
|
||||||
|
}
|
||||||
|
}, [focusPrompt])
|
||||||
|
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
|
||||||
|
const filteredPrompts = prompts.filter(prompt =>
|
||||||
|
prompt.name.toLowerCase().includes(slashCommand.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
setIsPromptPickerOpen(isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callSelectPrompt = (prompt: Tables<"prompts">) => {
|
||||||
|
const regex = /\{\{.*?\}\}/g
|
||||||
|
const matches = prompt.content.match(regex)
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
const newPromptVariables = matches.map(match => ({
|
||||||
|
promptId: prompt.id,
|
||||||
|
name: match.replace(/\{\{|\}\}/g, ""),
|
||||||
|
value: ""
|
||||||
|
}))
|
||||||
|
|
||||||
|
setPromptVariables(newPromptVariables)
|
||||||
|
setShowPromptVariables(true)
|
||||||
|
} else {
|
||||||
|
handleSelectPrompt(prompt)
|
||||||
|
handleOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKeyDownHandler =
|
||||||
|
(index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenChange(false)
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
callSelectPrompt(filteredPrompts[index])
|
||||||
|
} else if (
|
||||||
|
(e.key === "Tab" || e.key === "ArrowDown") &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
index === filteredPrompts.length - 1
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[0]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
|
||||||
|
// go to last element if arrow up is pressed on first element
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[itemsRef.current.length - 1]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
const prevIndex =
|
||||||
|
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
|
||||||
|
itemsRef.current[prevIndex]?.focus()
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
|
||||||
|
itemsRef.current[nextIndex]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitPromptVariables = () => {
|
||||||
|
const newPromptContent = promptVariables.reduce(
|
||||||
|
(prevContent, variable) =>
|
||||||
|
prevContent.replace(
|
||||||
|
new RegExp(`\\{\\{${variable.name}\\}\\}`, "g"),
|
||||||
|
variable.value
|
||||||
|
),
|
||||||
|
prompts.find(prompt => prompt.id === promptVariables[0].promptId)
|
||||||
|
?.content || ""
|
||||||
|
)
|
||||||
|
|
||||||
|
const newPrompt: any = {
|
||||||
|
...prompts.find(prompt => prompt.id === promptVariables[0].promptId),
|
||||||
|
content: newPromptContent
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelectPrompt(newPrompt)
|
||||||
|
handleOpenChange(false)
|
||||||
|
setShowPromptVariables(false)
|
||||||
|
setPromptVariables([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelPromptVariables = () => {
|
||||||
|
setShowPromptVariables(false)
|
||||||
|
setPromptVariables([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydownPromptVariables = (
|
||||||
|
e: React.KeyboardEvent<HTMLDivElement>
|
||||||
|
) => {
|
||||||
|
if (!isTyping && e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmitPromptVariables()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isPromptPickerOpen && (
|
||||||
|
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
|
||||||
|
{showPromptVariables ? (
|
||||||
|
<Dialog
|
||||||
|
open={showPromptVariables}
|
||||||
|
onOpenChange={setShowPromptVariables}
|
||||||
|
>
|
||||||
|
<DialogContent onKeyDown={handleKeydownPromptVariables}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Enter Prompt Variables</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-6">
|
||||||
|
{promptVariables.map((variable, index) => (
|
||||||
|
<div key={index} className="flex flex-col space-y-2">
|
||||||
|
<Label>{variable.name}</Label>
|
||||||
|
|
||||||
|
<TextareaAutosize
|
||||||
|
placeholder={`Enter a value for ${variable.name}...`}
|
||||||
|
value={variable.value}
|
||||||
|
onValueChange={value => {
|
||||||
|
const newPromptVariables = [...promptVariables]
|
||||||
|
newPromptVariables[index].value = value
|
||||||
|
setPromptVariables(newPromptVariables)
|
||||||
|
}}
|
||||||
|
minRows={3}
|
||||||
|
maxRows={5}
|
||||||
|
onCompositionStart={() => setIsTyping(true)}
|
||||||
|
onCompositionEnd={() => setIsTyping(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelPromptVariables}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" onClick={handleSubmitPromptVariables}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : filteredPrompts.length === 0 ? (
|
||||||
|
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
|
||||||
|
No matching prompts.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredPrompts.map((prompt, index) => (
|
||||||
|
<div
|
||||||
|
key={prompt.id}
|
||||||
|
ref={ref => {
|
||||||
|
itemsRef.current[index] = ref
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
className="hover:bg-accent focus:bg-accent flex cursor-pointer flex-col rounded p-2 focus:outline-none"
|
||||||
|
onClick={() => callSelectPrompt(prompt)}
|
||||||
|
onKeyDown={getKeyDownHandler(index)}
|
||||||
|
>
|
||||||
|
<div className="font-bold">{prompt.name}</div>
|
||||||
|
|
||||||
|
<div className="truncate text-sm opacity-80">
|
||||||
|
{prompt.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { LLM_LIST } from "@/lib/models/llm/llm-list"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconCircleCheckFilled, IconRobotFace } from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { ModelIcon } from "../models/model-icon"
|
||||||
|
import { DropdownMenuItem } from "../ui/dropdown-menu"
|
||||||
|
|
||||||
|
interface QuickSettingOptionProps {
|
||||||
|
contentType: "presets" | "assistants"
|
||||||
|
isSelected: boolean
|
||||||
|
item: Tables<"presets"> | Tables<"assistants">
|
||||||
|
onSelect: () => void
|
||||||
|
image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickSettingOption: FC<QuickSettingOptionProps> = ({
|
||||||
|
contentType,
|
||||||
|
isSelected,
|
||||||
|
item,
|
||||||
|
onSelect,
|
||||||
|
image
|
||||||
|
}) => {
|
||||||
|
const modelDetails = LLM_LIST.find(model => model.modelId === item.model)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
tabIndex={0}
|
||||||
|
className="cursor-pointer items-center"
|
||||||
|
onSelect={onSelect}
|
||||||
|
>
|
||||||
|
<div className="w-[32px]">
|
||||||
|
{contentType === "presets" ? (
|
||||||
|
<ModelIcon
|
||||||
|
provider={modelDetails?.provider || "custom"}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
) : image ? (
|
||||||
|
<Image
|
||||||
|
style={{ width: "32px", height: "32px" }}
|
||||||
|
className="rounded"
|
||||||
|
src={image}
|
||||||
|
alt="Assistant"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconRobotFace
|
||||||
|
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-4 flex grow flex-col space-y-1">
|
||||||
|
<div className="text-md font-bold">{item.name}</div>
|
||||||
|
|
||||||
|
{item.description && (
|
||||||
|
<div className="text-sm font-light">{item.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[40px]">
|
||||||
|
{isSelected ? (
|
||||||
|
<IconCircleCheckFilled className="ml-4" size={20} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { getAssistantCollectionsByAssistantId } from "@/db/assistant-collections"
|
||||||
|
import { getAssistantFilesByAssistantId } from "@/db/assistant-files"
|
||||||
|
import { getAssistantToolsByAssistantId } from "@/db/assistant-tools"
|
||||||
|
import { getCollectionFilesByCollectionId } from "@/db/collection-files"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { LLM_LIST } from "@/lib/models/llm/llm-list"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { LLMID } from "@/types"
|
||||||
|
import { IconChevronDown, IconRobotFace } from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { ModelIcon } from "../models/model-icon"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { QuickSettingOption } from "./quick-setting-option"
|
||||||
|
import { set } from "date-fns"
|
||||||
|
|
||||||
|
interface QuickSettingsProps {}
|
||||||
|
|
||||||
|
export const QuickSettings: FC<QuickSettingsProps> = ({}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useHotkey("p", () => setIsOpen(prevState => !prevState))
|
||||||
|
|
||||||
|
const {
|
||||||
|
presets,
|
||||||
|
assistants,
|
||||||
|
selectedAssistant,
|
||||||
|
selectedPreset,
|
||||||
|
chatSettings,
|
||||||
|
setSelectedPreset,
|
||||||
|
setSelectedAssistant,
|
||||||
|
setChatSettings,
|
||||||
|
assistantImages,
|
||||||
|
setChatFiles,
|
||||||
|
setSelectedTools,
|
||||||
|
setShowFilesDisplay,
|
||||||
|
selectedWorkspace
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, 100) // FIX: hacky
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSelectQuickSetting = async (
|
||||||
|
item: Tables<"presets"> | Tables<"assistants"> | null,
|
||||||
|
contentType: "presets" | "assistants" | "remove"
|
||||||
|
) => {
|
||||||
|
console.log({ item, contentType })
|
||||||
|
if (contentType === "assistants" && item) {
|
||||||
|
setSelectedAssistant(item as Tables<"assistants">)
|
||||||
|
setLoading(true)
|
||||||
|
let allFiles = []
|
||||||
|
const assistantFiles = (await getAssistantFilesByAssistantId(item.id))
|
||||||
|
.files
|
||||||
|
allFiles = [...assistantFiles]
|
||||||
|
const assistantCollections = (
|
||||||
|
await getAssistantCollectionsByAssistantId(item.id)
|
||||||
|
).collections
|
||||||
|
for (const collection of assistantCollections) {
|
||||||
|
const collectionFiles = (
|
||||||
|
await getCollectionFilesByCollectionId(collection.id)
|
||||||
|
).files
|
||||||
|
allFiles = [...allFiles, ...collectionFiles]
|
||||||
|
}
|
||||||
|
const assistantTools = (await getAssistantToolsByAssistantId(item.id))
|
||||||
|
.tools
|
||||||
|
setSelectedTools(assistantTools)
|
||||||
|
setChatFiles(
|
||||||
|
allFiles.map(file => ({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
file: null
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (allFiles.length > 0) setShowFilesDisplay(true)
|
||||||
|
setLoading(false)
|
||||||
|
setSelectedPreset(null)
|
||||||
|
} else if (contentType === "presets" && item) {
|
||||||
|
setSelectedPreset(item as Tables<"presets">)
|
||||||
|
setSelectedAssistant(null)
|
||||||
|
setChatFiles([])
|
||||||
|
setSelectedTools([])
|
||||||
|
} else {
|
||||||
|
setSelectedPreset(null)
|
||||||
|
setSelectedAssistant(null)
|
||||||
|
setChatFiles([])
|
||||||
|
setSelectedTools([])
|
||||||
|
if (selectedWorkspace) {
|
||||||
|
setChatSettings({
|
||||||
|
model: selectedWorkspace.default_model as LLMID,
|
||||||
|
prompt: selectedWorkspace.default_prompt,
|
||||||
|
temperature: selectedWorkspace.default_temperature,
|
||||||
|
contextLength: selectedWorkspace.default_context_length,
|
||||||
|
includeProfileContext: selectedWorkspace.include_profile_context,
|
||||||
|
includeWorkspaceInstructions:
|
||||||
|
selectedWorkspace.include_workspace_instructions,
|
||||||
|
embeddingsProvider: selectedWorkspace.embeddings_provider as
|
||||||
|
| "openai"
|
||||||
|
| "local"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setChatSettings({
|
||||||
|
model: item.model as LLMID,
|
||||||
|
prompt: item.prompt,
|
||||||
|
temperature: item.temperature,
|
||||||
|
contextLength: item.context_length,
|
||||||
|
includeProfileContext: item.include_profile_context,
|
||||||
|
includeWorkspaceInstructions: item.include_workspace_instructions,
|
||||||
|
embeddingsProvider: item.embeddings_provider as "openai" | "local"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfModified = () => {
|
||||||
|
if (!chatSettings) return false
|
||||||
|
|
||||||
|
if (selectedPreset) {
|
||||||
|
return (
|
||||||
|
selectedPreset.include_profile_context !==
|
||||||
|
chatSettings?.includeProfileContext ||
|
||||||
|
selectedPreset.include_workspace_instructions !==
|
||||||
|
chatSettings.includeWorkspaceInstructions ||
|
||||||
|
selectedPreset.context_length !== chatSettings.contextLength ||
|
||||||
|
selectedPreset.model !== chatSettings.model ||
|
||||||
|
selectedPreset.prompt !== chatSettings.prompt ||
|
||||||
|
selectedPreset.temperature !== chatSettings.temperature
|
||||||
|
)
|
||||||
|
} else if (selectedAssistant) {
|
||||||
|
return (
|
||||||
|
selectedAssistant.include_profile_context !==
|
||||||
|
chatSettings.includeProfileContext ||
|
||||||
|
selectedAssistant.include_workspace_instructions !==
|
||||||
|
chatSettings.includeWorkspaceInstructions ||
|
||||||
|
selectedAssistant.context_length !== chatSettings.contextLength ||
|
||||||
|
selectedAssistant.model !== chatSettings.model ||
|
||||||
|
selectedAssistant.prompt !== chatSettings.prompt ||
|
||||||
|
selectedAssistant.temperature !== chatSettings.temperature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isModified = checkIfModified()
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...presets.map(preset => ({ ...preset, contentType: "presets" })),
|
||||||
|
...assistants.map(assistant => ({
|
||||||
|
...assistant,
|
||||||
|
contentType: "assistants"
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedAssistantImage = selectedPreset
|
||||||
|
? ""
|
||||||
|
: assistantImages.find(
|
||||||
|
image => image.path === selectedAssistant?.image_path
|
||||||
|
)?.base64 || ""
|
||||||
|
|
||||||
|
const modelDetails = LLM_LIST.find(
|
||||||
|
model => model.modelId === selectedPreset?.model
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={isOpen => {
|
||||||
|
setIsOpen(isOpen)
|
||||||
|
setSearch("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild className="max-w-[400px]" disabled={loading}>
|
||||||
|
<Button variant="ghost" className="flex space-x-3 text-lg">
|
||||||
|
{selectedPreset && (
|
||||||
|
<ModelIcon
|
||||||
|
provider={modelDetails?.provider || "custom"}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedAssistant &&
|
||||||
|
(selectedAssistantImage ? (
|
||||||
|
<Image
|
||||||
|
className="rounded"
|
||||||
|
src={selectedAssistantImage}
|
||||||
|
alt="Assistant"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconRobotFace
|
||||||
|
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse">Loading assistant...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden text-ellipsis">
|
||||||
|
{isModified &&
|
||||||
|
(selectedPreset || selectedAssistant) &&
|
||||||
|
"Modified "}
|
||||||
|
|
||||||
|
{selectedPreset?.name ||
|
||||||
|
selectedAssistant?.name ||
|
||||||
|
t("Quick Settings")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconChevronDown className="ml-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="min-w-[300px] max-w-[500px] space-y-4"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
{presets.length === 0 && assistants.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">No items found.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!(selectedPreset || selectedAssistant) && (
|
||||||
|
<QuickSettingOption
|
||||||
|
contentType={selectedPreset ? "presets" : "assistants"}
|
||||||
|
isSelected={true}
|
||||||
|
item={
|
||||||
|
selectedPreset ||
|
||||||
|
(selectedAssistant as
|
||||||
|
| Tables<"presets">
|
||||||
|
| Tables<"assistants">)
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelectQuickSetting(null, "remove")
|
||||||
|
}}
|
||||||
|
image={selectedPreset ? "" : selectedAssistantImage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items
|
||||||
|
.filter(
|
||||||
|
item =>
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
|
item.id !== selectedPreset?.id &&
|
||||||
|
item.id !== selectedAssistant?.id
|
||||||
|
)
|
||||||
|
.map(({ contentType, ...item }) => (
|
||||||
|
<QuickSettingOption
|
||||||
|
key={item.id}
|
||||||
|
contentType={contentType as "presets" | "assistants"}
|
||||||
|
isSelected={false}
|
||||||
|
item={item}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelectQuickSetting(
|
||||||
|
item,
|
||||||
|
contentType as "presets" | "assistants"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
image={
|
||||||
|
contentType === "assistants"
|
||||||
|
? assistantImages.find(
|
||||||
|
image =>
|
||||||
|
image.path ===
|
||||||
|
(item as Tables<"assistants">).image_path
|
||||||
|
)?.base64 || ""
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconBolt } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useRef } from "react"
|
||||||
|
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
|
||||||
|
|
||||||
|
interface ToolPickerProps {}
|
||||||
|
|
||||||
|
export const ToolPicker: FC<ToolPickerProps> = ({}) => {
|
||||||
|
const {
|
||||||
|
tools,
|
||||||
|
focusTool,
|
||||||
|
toolCommand,
|
||||||
|
isToolPickerOpen,
|
||||||
|
setIsToolPickerOpen
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleSelectTool } = usePromptAndCommand()
|
||||||
|
|
||||||
|
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusTool && itemsRef.current[0]) {
|
||||||
|
itemsRef.current[0].focus()
|
||||||
|
}
|
||||||
|
}, [focusTool])
|
||||||
|
|
||||||
|
const filteredTools = tools.filter(tool =>
|
||||||
|
tool.name.toLowerCase().includes(toolCommand.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
setIsToolPickerOpen(isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callSelectTool = (tool: Tables<"tools">) => {
|
||||||
|
handleSelectTool(tool)
|
||||||
|
handleOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKeyDownHandler =
|
||||||
|
(index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenChange(false)
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
callSelectTool(filteredTools[index])
|
||||||
|
} else if (
|
||||||
|
(e.key === "Tab" || e.key === "ArrowDown") &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
index === filteredTools.length - 1
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[0]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
|
||||||
|
// go to last element if arrow up is pressed on first element
|
||||||
|
e.preventDefault()
|
||||||
|
itemsRef.current[itemsRef.current.length - 1]?.focus()
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
const prevIndex =
|
||||||
|
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
|
||||||
|
itemsRef.current[prevIndex]?.focus()
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
|
||||||
|
itemsRef.current[nextIndex]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isToolPickerOpen && (
|
||||||
|
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
|
||||||
|
{filteredTools.length === 0 ? (
|
||||||
|
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
|
||||||
|
No matching tools.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredTools.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={ref => {
|
||||||
|
itemsRef.current[index] = ref
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center rounded p-2 focus:outline-none"
|
||||||
|
onClick={() => callSelectTool(item as Tables<"tools">)}
|
||||||
|
onKeyDown={getKeyDownHandler(index)}
|
||||||
|
>
|
||||||
|
<IconBolt size={32} />
|
||||||
|
|
||||||
|
<div className="ml-3 flex flex-col">
|
||||||
|
<div className="font-bold">{item.name}</div>
|
||||||
|
|
||||||
|
<div className="truncate text-sm opacity-80">
|
||||||
|
{item.description || "No description."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface AnthropicSVGProps {
|
||||||
|
height?: number
|
||||||
|
width?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnthropicSVG: FC<AnthropicSVGProps> = ({
|
||||||
|
height = 40,
|
||||||
|
width = 40,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox="0 0 24 16"
|
||||||
|
overflow="visible"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
style={{
|
||||||
|
transform: "translateX(13px) rotateZ(0deg)",
|
||||||
|
transformOrigin: "4.775px 7.73501px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d=" M0,0 C0,0 6.1677093505859375,15.470022201538086 6.1677093505859375,15.470022201538086 C6.1677093505859375,15.470022201538086 9.550004005432129,15.470022201538086 9.550004005432129,15.470022201538086 C9.550004005432129,15.470022201538086 3.382294178009033,0 3.382294178009033,0 C3.382294178009033,0 0,0 0,0 C0,0 0,0 0,0z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style={{ transform: "none", transformOrigin: "7.935px 7.73501px" }}
|
||||||
|
opacity="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d=" M5.824605464935303,9.348296165466309 C5.824605464935303,9.348296165466309 7.93500280380249,3.911694288253784 7.93500280380249,3.911694288253784 C7.93500280380249,3.911694288253784 10.045400619506836,9.348296165466309 10.045400619506836,9.348296165466309 C10.045400619506836,9.348296165466309 5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309 C5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309z M6.166755199432373,0 C6.166755199432373,0 0,15.470022201538086 0,15.470022201538086 C0,15.470022201538086 3.4480772018432617,15.470022201538086 3.4480772018432617,15.470022201538086 C3.4480772018432617,15.470022201538086 4.709278583526611,12.22130012512207 4.709278583526611,12.22130012512207 C4.709278583526611,12.22130012512207 11.16093635559082,12.22130012512207 11.16093635559082,12.22130012512207 C11.16093635559082,12.22130012512207 12.421928405761719,15.470022201538086 12.421928405761719,15.470022201538086 C12.421928405761719,15.470022201538086 15.87000560760498,15.470022201538086 15.87000560760498,15.470022201538086 C15.87000560760498,15.470022201538086 9.703250885009766,0 9.703250885009766,0 C9.703250885009766,0 6.166755199432373,0 6.166755199432373,0 C6.166755199432373,0 6.166755199432373,0 6.166755199432373,0z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface ChatbotUISVGProps {
|
||||||
|
theme: "dark" | "light"
|
||||||
|
scale?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatbotUISVG: FC<ChatbotUISVGProps> = ({ theme, scale = 1 }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={189 * scale}
|
||||||
|
height={194 * scale}
|
||||||
|
viewBox="0 0 189 194"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="12.5"
|
||||||
|
y="12.5"
|
||||||
|
width="164"
|
||||||
|
height="127"
|
||||||
|
rx="37.5"
|
||||||
|
fill={`${theme === "dark" ? "#000" : "#fff"}`}
|
||||||
|
stroke={`${theme === "dark" ? "#fff" : "#000"}`}
|
||||||
|
strokeWidth="25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M72.7643 143.457C77.2953 143.443 79.508 148.98 76.2146 152.092L42.7738 183.69C39.5361 186.749 34.2157 184.366 34.3419 179.914L35.2341 148.422C35.3106 145.723 37.5158 143.572 40.2158 143.564L72.7643 143.457Z"
|
||||||
|
fill={`${theme === "dark" ? "#fff" : "#000"}`}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M59.6722 51.6H75.5122V84C75.5122 86.016 76.0162 87.672 77.0242 88.968C78.0802 90.216 79.6882 90.84 81.8482 90.84C84.0082 90.84 85.5922 90.216 86.6002 88.968C87.6562 87.672 88.1842 86.016 88.1842 84V51.6H104.024V85.44C104.024 89.04 103.424 92.088 102.224 94.584C101.072 97.032 99.4642 99.024 97.4002 100.56C95.3362 102.048 92.9602 103.128 90.2722 103.8C87.6322 104.52 84.8242 104.88 81.8482 104.88C78.8722 104.88 76.0402 104.52 73.3522 103.8C70.7122 103.128 68.3602 102.048 66.2962 100.56C64.2322 99.024 62.6002 97.032 61.4002 94.584C60.2482 92.088 59.6722 89.04 59.6722 85.44V51.6ZM113.751 51.6H129.951V102H113.751V51.6Z"
|
||||||
|
fill={`${theme === "dark" ? "#fff" : "#000"}`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface GoogleSVGProps {
|
||||||
|
height?: number
|
||||||
|
width?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GoogleSVG: FC<GoogleSVGProps> = ({
|
||||||
|
height = 40,
|
||||||
|
width = 40,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#FFC107"
|
||||||
|
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#FF3D00"
|
||||||
|
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#4CAF50"
|
||||||
|
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#1976D2"
|
||||||
|
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface OpenAISVGProps {
|
||||||
|
height?: number
|
||||||
|
width?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenAISVG: FC<OpenAISVGProps> = ({
|
||||||
|
height = 40,
|
||||||
|
width = 40,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox="0 0 41 41"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { IconCheck, IconCopy, IconEdit, IconRepeat } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useState } from "react"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
|
||||||
|
export const MESSAGE_ICON_SIZE = 18
|
||||||
|
|
||||||
|
interface MessageActionsProps {
|
||||||
|
isAssistant: boolean
|
||||||
|
isLast: boolean
|
||||||
|
isEditing: boolean
|
||||||
|
isHovering: boolean
|
||||||
|
onCopy: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
onRegenerate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageActions: FC<MessageActionsProps> = ({
|
||||||
|
isAssistant,
|
||||||
|
isLast,
|
||||||
|
isEditing,
|
||||||
|
isHovering,
|
||||||
|
onCopy,
|
||||||
|
onEdit,
|
||||||
|
onRegenerate
|
||||||
|
}) => {
|
||||||
|
const { isGenerating } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [showCheckmark, setShowCheckmark] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
onCopy()
|
||||||
|
setShowCheckmark(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForkChat = async () => {}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showCheckmark) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowCheckmark(false)
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [showCheckmark])
|
||||||
|
|
||||||
|
return (isLast && isGenerating) || isEditing ? null : (
|
||||||
|
<div className="text-muted-foreground flex items-center space-x-2">
|
||||||
|
{/* {((isAssistant && isHovering) || isLast) && (
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={1000}
|
||||||
|
side="bottom"
|
||||||
|
display={<div>Fork Chat</div>}
|
||||||
|
trigger={
|
||||||
|
<IconGitFork
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
size={MESSAGE_ICON_SIZE}
|
||||||
|
onClick={handleForkChat}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{!isAssistant && isHovering && (
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={1000}
|
||||||
|
side="bottom"
|
||||||
|
display={<div>Edit</div>}
|
||||||
|
trigger={
|
||||||
|
<IconEdit
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
size={MESSAGE_ICON_SIZE}
|
||||||
|
onClick={onEdit}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isHovering || isLast) && (
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={1000}
|
||||||
|
side="bottom"
|
||||||
|
display={<div>Copy</div>}
|
||||||
|
trigger={
|
||||||
|
showCheckmark ? (
|
||||||
|
<IconCheck size={MESSAGE_ICON_SIZE} />
|
||||||
|
) : (
|
||||||
|
<IconCopy
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
size={MESSAGE_ICON_SIZE}
|
||||||
|
onClick={handleCopy}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLast && (
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={1000}
|
||||||
|
side="bottom"
|
||||||
|
display={<div>Regenerate</div>}
|
||||||
|
trigger={
|
||||||
|
<IconRepeat
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
size={MESSAGE_ICON_SIZE}
|
||||||
|
onClick={onRegenerate}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* {1 > 0 && isAssistant && <MessageReplies />} */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"
|
||||||
|
import { IconCheck, IconCopy, IconDownload } from "@tabler/icons-react"
|
||||||
|
import { FC, memo } from "react"
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||||
|
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"
|
||||||
|
|
||||||
|
interface MessageCodeBlockProps {
|
||||||
|
language: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface languageMap {
|
||||||
|
[key: string]: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const programmingLanguages: languageMap = {
|
||||||
|
javascript: ".js",
|
||||||
|
python: ".py",
|
||||||
|
java: ".java",
|
||||||
|
c: ".c",
|
||||||
|
cpp: ".cpp",
|
||||||
|
"c++": ".cpp",
|
||||||
|
"c#": ".cs",
|
||||||
|
ruby: ".rb",
|
||||||
|
php: ".php",
|
||||||
|
swift: ".swift",
|
||||||
|
"objective-c": ".m",
|
||||||
|
kotlin: ".kt",
|
||||||
|
typescript: ".ts",
|
||||||
|
go: ".go",
|
||||||
|
perl: ".pl",
|
||||||
|
rust: ".rs",
|
||||||
|
scala: ".scala",
|
||||||
|
haskell: ".hs",
|
||||||
|
lua: ".lua",
|
||||||
|
shell: ".sh",
|
||||||
|
sql: ".sql",
|
||||||
|
html: ".html",
|
||||||
|
css: ".css"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateRandomString = (length: number, lowercase = false) => {
|
||||||
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789" // excluding similar looking characters like Z, 2, I, 1, O, 0
|
||||||
|
let result = ""
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
}
|
||||||
|
return lowercase ? result.toLowerCase() : result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageCodeBlock: FC<MessageCodeBlockProps> = memo(
|
||||||
|
({ language, value }) => {
|
||||||
|
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
|
||||||
|
|
||||||
|
const downloadAsFile = () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fileExtension = programmingLanguages[language] || ".file"
|
||||||
|
const suggestedFileName = `file-${generateRandomString(
|
||||||
|
3,
|
||||||
|
true
|
||||||
|
)}${fileExtension}`
|
||||||
|
const fileName = window.prompt("Enter file name" || "", suggestedFileName)
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([value], { type: "text/plain" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.download = fileName
|
||||||
|
link.href = url
|
||||||
|
link.style.display = "none"
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCopy = () => {
|
||||||
|
if (isCopied) return
|
||||||
|
copyToClipboard(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="codeblock relative w-full bg-zinc-950 font-sans">
|
||||||
|
<div className="flex w-full items-center justify-between bg-zinc-700 px-4 text-white">
|
||||||
|
<span className="text-xs lowercase">{language}</span>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
|
||||||
|
onClick={downloadAsFile}
|
||||||
|
>
|
||||||
|
<IconDownload size={16} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-xs hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
|
||||||
|
onClick={onCopy}
|
||||||
|
>
|
||||||
|
{isCopied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={language}
|
||||||
|
style={oneDark}
|
||||||
|
// showLineNumbers
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
width: "100%",
|
||||||
|
background: "transparent"
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
fontSize: "14px",
|
||||||
|
fontFamily: "var(--font-mono)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MessageCodeBlock.displayName = "MessageCodeBlock"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { FC, memo } from "react"
|
||||||
|
import ReactMarkdown, { Options } from "react-markdown"
|
||||||
|
|
||||||
|
export const MessageMarkdownMemoized: FC<Options> = memo(
|
||||||
|
ReactMarkdown,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.children === nextProps.children &&
|
||||||
|
prevProps.className === nextProps.className
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import React, { FC } from "react"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
import remarkMath from "remark-math"
|
||||||
|
import { MessageCodeBlock } from "./message-codeblock"
|
||||||
|
import { MessageMarkdownMemoized } from "./message-markdown-memoized"
|
||||||
|
|
||||||
|
interface MessageMarkdownProps {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageMarkdown: FC<MessageMarkdownProps> = ({ content }) => {
|
||||||
|
return (
|
||||||
|
<MessageMarkdownMemoized
|
||||||
|
className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 min-w-full space-y-6 break-words"
|
||||||
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
|
components={{
|
||||||
|
p({ children }) {
|
||||||
|
return <p className="mb-2 last:mb-0">{children}</p>
|
||||||
|
},
|
||||||
|
img({ node, ...props }) {
|
||||||
|
return <img className="max-w-[67%]" {...props} />
|
||||||
|
},
|
||||||
|
code({ node, className, children, ...props }) {
|
||||||
|
const childArray = React.Children.toArray(children)
|
||||||
|
const firstChild = childArray[0] as React.ReactElement
|
||||||
|
const firstChildAsString = React.isValidElement(firstChild)
|
||||||
|
? (firstChild as React.ReactElement).props.children
|
||||||
|
: firstChild
|
||||||
|
|
||||||
|
if (firstChildAsString === "▍") {
|
||||||
|
return <span className="mt-1 animate-pulse cursor-default">▍</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof firstChildAsString === "string") {
|
||||||
|
childArray[0] = firstChildAsString.replace("`▍`", "▍")
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /language-(\w+)/.exec(className || "")
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof firstChildAsString === "string" &&
|
||||||
|
!firstChildAsString.includes("\n")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{childArray}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageCodeBlock
|
||||||
|
key={Math.random()}
|
||||||
|
language={(match && match[1]) || ""}
|
||||||
|
value={String(childArray).replace(/\n$/, "")}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</MessageMarkdownMemoized>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { IconMessage } from "@tabler/icons-react"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger
|
||||||
|
} from "../ui/sheet"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
import { MESSAGE_ICON_SIZE } from "./message-actions"
|
||||||
|
|
||||||
|
interface MessageRepliesProps {}
|
||||||
|
|
||||||
|
export const MessageReplies: FC<MessageRepliesProps> = ({}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={1000}
|
||||||
|
side="bottom"
|
||||||
|
display={<div>View Replies</div>}
|
||||||
|
trigger={
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer hover:opacity-50"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<IconMessage size={MESSAGE_ICON_SIZE} />
|
||||||
|
<div className="notification-indicator absolute right-[-4px] top-[-4px] flex size-3 items-center justify-center rounded-full bg-red-600 text-[8px] text-white">
|
||||||
|
{1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SheetTrigger>
|
||||||
|
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Are you sure absolutely sure?</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
This action cannot be undone. This will permanently delete your
|
||||||
|
account and remove your data from our servers.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { LLM_LIST } from "@/lib/models/llm/llm-list"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { LLM, LLMID, MessageImage, ModelProvider } from "@/types"
|
||||||
|
import {
|
||||||
|
IconBolt,
|
||||||
|
IconCaretDownFilled,
|
||||||
|
IconCaretRightFilled,
|
||||||
|
IconCircleFilled,
|
||||||
|
IconFileText,
|
||||||
|
IconMoodSmile,
|
||||||
|
IconPencil
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
import { ModelIcon } from "../models/model-icon"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { FileIcon } from "../ui/file-icon"
|
||||||
|
import { FilePreview } from "../ui/file-preview"
|
||||||
|
import { TextareaAutosize } from "../ui/textarea-autosize"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
import { MessageActions } from "./message-actions"
|
||||||
|
import { MessageMarkdown } from "./message-markdown"
|
||||||
|
|
||||||
|
const ICON_SIZE = 32
|
||||||
|
|
||||||
|
interface MessageProps {
|
||||||
|
message: Tables<"messages">
|
||||||
|
fileItems: Tables<"file_items">[]
|
||||||
|
isEditing: boolean
|
||||||
|
isLast: boolean
|
||||||
|
onStartEdit: (message: Tables<"messages">) => void
|
||||||
|
onCancelEdit: () => void
|
||||||
|
onSubmitEdit: (value: string, sequenceNumber: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Message: FC<MessageProps> = ({
|
||||||
|
message,
|
||||||
|
fileItems,
|
||||||
|
isEditing,
|
||||||
|
isLast,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSubmitEdit
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
assistants,
|
||||||
|
profile,
|
||||||
|
isGenerating,
|
||||||
|
setIsGenerating,
|
||||||
|
firstTokenReceived,
|
||||||
|
availableLocalModels,
|
||||||
|
availableOpenRouterModels,
|
||||||
|
chatMessages,
|
||||||
|
selectedAssistant,
|
||||||
|
chatImages,
|
||||||
|
assistantImages,
|
||||||
|
toolInUse,
|
||||||
|
files,
|
||||||
|
models
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const { handleSendMessage } = useChatHandler()
|
||||||
|
|
||||||
|
const editInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const [isHovering, setIsHovering] = useState(false)
|
||||||
|
const [editedMessage, setEditedMessage] = useState(message.content)
|
||||||
|
|
||||||
|
const [showImagePreview, setShowImagePreview] = useState(false)
|
||||||
|
const [selectedImage, setSelectedImage] = useState<MessageImage | null>(null)
|
||||||
|
|
||||||
|
const [showFileItemPreview, setShowFileItemPreview] = useState(false)
|
||||||
|
const [selectedFileItem, setSelectedFileItem] =
|
||||||
|
useState<Tables<"file_items"> | null>(null)
|
||||||
|
|
||||||
|
const [viewSources, setViewSources] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(message.content)
|
||||||
|
} else {
|
||||||
|
const textArea = document.createElement("textarea")
|
||||||
|
textArea.value = message.content
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.focus()
|
||||||
|
textArea.select()
|
||||||
|
document.execCommand("copy")
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendEdit = () => {
|
||||||
|
onSubmitEdit(editedMessage, message.sequence_number)
|
||||||
|
onCancelEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (isEditing && event.key === "Enter" && event.metaKey) {
|
||||||
|
handleSendEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
await handleSendMessage(
|
||||||
|
editedMessage || chatMessages[chatMessages.length - 2].message.content,
|
||||||
|
chatMessages,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEdit = () => {
|
||||||
|
onStartEdit(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditedMessage(message.content)
|
||||||
|
|
||||||
|
if (isEditing && editInputRef.current) {
|
||||||
|
const input = editInputRef.current
|
||||||
|
input.focus()
|
||||||
|
input.setSelectionRange(input.value.length, input.value.length)
|
||||||
|
}
|
||||||
|
}, [isEditing])
|
||||||
|
|
||||||
|
const MODEL_DATA = [
|
||||||
|
...models.map(model => ({
|
||||||
|
modelId: model.model_id as LLMID,
|
||||||
|
modelName: model.name,
|
||||||
|
provider: "custom" as ModelProvider,
|
||||||
|
hostedId: model.id,
|
||||||
|
platformLink: "",
|
||||||
|
imageInput: false
|
||||||
|
})),
|
||||||
|
...LLM_LIST,
|
||||||
|
...availableLocalModels,
|
||||||
|
...availableOpenRouterModels
|
||||||
|
].find(llm => llm.modelId === message.model) as LLM
|
||||||
|
|
||||||
|
const messageAssistantImage = assistantImages.find(
|
||||||
|
image => image.assistantId === message.assistant_id
|
||||||
|
)?.base64
|
||||||
|
|
||||||
|
const selectedAssistantImage = assistantImages.find(
|
||||||
|
image => image.path === selectedAssistant?.image_path
|
||||||
|
)?.base64
|
||||||
|
|
||||||
|
const modelDetails = LLM_LIST.find(model => model.modelId === message.model)
|
||||||
|
|
||||||
|
const fileAccumulator: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
const fileSummary = fileItems.reduce((acc, fileItem) => {
|
||||||
|
const parentFile = files.find(file => file.id === fileItem.file_id)
|
||||||
|
if (parentFile) {
|
||||||
|
if (!acc[parentFile.id]) {
|
||||||
|
acc[parentFile.id] = {
|
||||||
|
id: parentFile.id,
|
||||||
|
name: parentFile.name,
|
||||||
|
count: 1,
|
||||||
|
type: parentFile.type,
|
||||||
|
description: parentFile.description
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acc[parentFile.id].count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, fileAccumulator)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full justify-center",
|
||||||
|
message.role === "user" ? "" : "bg-secondary"
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="relative flex w-full flex-col p-6 sm:w-[550px] sm:px-0 md:w-[650px] lg:w-[650px] xl:w-[700px]">
|
||||||
|
<div className="absolute right-5 top-7 sm:right-0">
|
||||||
|
<MessageActions
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onEdit={handleStartEdit}
|
||||||
|
isAssistant={message.role === "assistant"}
|
||||||
|
isLast={isLast}
|
||||||
|
isEditing={isEditing}
|
||||||
|
isHovering={isHovering}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{message.role === "system" ? (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<IconPencil
|
||||||
|
className="border-primary bg-primary text-secondary rounded border-DEFAULT p-1"
|
||||||
|
size={ICON_SIZE}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-lg font-semibold">Prompt</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{message.role === "assistant" ? (
|
||||||
|
messageAssistantImage ? (
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: `${ICON_SIZE}px`,
|
||||||
|
height: `${ICON_SIZE}px`
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
src={messageAssistantImage}
|
||||||
|
alt="assistant image"
|
||||||
|
height={ICON_SIZE}
|
||||||
|
width={ICON_SIZE}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WithTooltip
|
||||||
|
display={<div>{MODEL_DATA?.modelName}</div>}
|
||||||
|
trigger={
|
||||||
|
<ModelIcon
|
||||||
|
provider={modelDetails?.provider || "custom"}
|
||||||
|
height={ICON_SIZE}
|
||||||
|
width={ICON_SIZE}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : profile?.image_url ? (
|
||||||
|
<Image
|
||||||
|
className={`size-[32px] rounded`}
|
||||||
|
src={profile?.image_url}
|
||||||
|
height={32}
|
||||||
|
width={32}
|
||||||
|
alt="user image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconMoodSmile
|
||||||
|
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
|
||||||
|
size={ICON_SIZE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="font-semibold">
|
||||||
|
{message.role === "assistant"
|
||||||
|
? message.assistant_id
|
||||||
|
? assistants.find(
|
||||||
|
assistant => assistant.id === message.assistant_id
|
||||||
|
)?.name
|
||||||
|
: selectedAssistant
|
||||||
|
? selectedAssistant?.name
|
||||||
|
: MODEL_DATA?.modelName
|
||||||
|
: profile?.display_name ?? profile?.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!firstTokenReceived &&
|
||||||
|
isGenerating &&
|
||||||
|
isLast &&
|
||||||
|
message.role === "assistant" ? (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
switch (toolInUse) {
|
||||||
|
case "none":
|
||||||
|
return (
|
||||||
|
<IconCircleFilled className="animate-pulse" size={20} />
|
||||||
|
)
|
||||||
|
case "retrieval":
|
||||||
|
return (
|
||||||
|
<div className="flex animate-pulse items-center space-x-2">
|
||||||
|
<IconFileText size={20} />
|
||||||
|
|
||||||
|
<div>Searching files...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex animate-pulse items-center space-x-2">
|
||||||
|
<IconBolt size={20} />
|
||||||
|
|
||||||
|
<div>Using {toolInUse}...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
) : isEditing ? (
|
||||||
|
<TextareaAutosize
|
||||||
|
textareaRef={editInputRef}
|
||||||
|
className="text-md"
|
||||||
|
value={editedMessage}
|
||||||
|
onValueChange={setEditedMessage}
|
||||||
|
maxRows={20}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MessageMarkdown content={message.content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileItems.length > 0 && (
|
||||||
|
<div className="border-primary mt-6 border-t pt-4 font-bold">
|
||||||
|
{!viewSources ? (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center text-lg hover:opacity-50"
|
||||||
|
onClick={() => setViewSources(true)}
|
||||||
|
>
|
||||||
|
{fileItems.length}
|
||||||
|
{fileItems.length > 1 ? " Sources " : " Source "}
|
||||||
|
from {Object.keys(fileSummary).length}{" "}
|
||||||
|
{Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "}
|
||||||
|
<IconCaretRightFilled className="ml-1" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center text-lg hover:opacity-50"
|
||||||
|
onClick={() => setViewSources(false)}
|
||||||
|
>
|
||||||
|
{fileItems.length}
|
||||||
|
{fileItems.length > 1 ? " Sources " : " Source "}
|
||||||
|
from {Object.keys(fileSummary).length}{" "}
|
||||||
|
{Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "}
|
||||||
|
<IconCaretDownFilled className="ml-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-4">
|
||||||
|
{Object.values(fileSummary).map((file, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div>
|
||||||
|
<FileIcon type={file.type} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="truncate">{file.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileItems
|
||||||
|
.filter(fileItem => {
|
||||||
|
const parentFile = files.find(
|
||||||
|
parentFile => parentFile.id === fileItem.file_id
|
||||||
|
)
|
||||||
|
return parentFile?.id === file.id
|
||||||
|
})
|
||||||
|
.map((fileItem, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="ml-8 mt-1.5 flex cursor-pointer items-center space-x-2 hover:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFileItem(fileItem)
|
||||||
|
setShowFileItemPreview(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-normal">
|
||||||
|
<span className="mr-1 text-lg font-bold">-</span>{" "}
|
||||||
|
{fileItem.content.substring(0, 200)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{message.image_paths.map((path, index) => {
|
||||||
|
const item = chatImages.find(image => image.path === path)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
key={index}
|
||||||
|
className="cursor-pointer rounded hover:opacity-50"
|
||||||
|
src={path.startsWith("data") ? path : item?.base64}
|
||||||
|
alt="message image"
|
||||||
|
width={300}
|
||||||
|
height={300}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedImage({
|
||||||
|
messageId: message.id,
|
||||||
|
path,
|
||||||
|
base64: path.startsWith("data") ? path : item?.base64 || "",
|
||||||
|
url: path.startsWith("data") ? "" : item?.url || "",
|
||||||
|
file: null
|
||||||
|
})
|
||||||
|
|
||||||
|
setShowImagePreview(true)
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isEditing && (
|
||||||
|
<div className="mt-4 flex justify-center space-x-2">
|
||||||
|
<Button size="sm" onClick={handleSendEdit}>
|
||||||
|
Save & Send
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" onClick={onCancelEdit}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showImagePreview && selectedImage && (
|
||||||
|
<FilePreview
|
||||||
|
type="image"
|
||||||
|
item={selectedImage}
|
||||||
|
isOpen={showImagePreview}
|
||||||
|
onOpenChange={(isOpen: boolean) => {
|
||||||
|
setShowImagePreview(isOpen)
|
||||||
|
setSelectedImage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFileItemPreview && selectedFileItem && (
|
||||||
|
<FilePreview
|
||||||
|
type="file_item"
|
||||||
|
item={selectedFileItem}
|
||||||
|
isOpen={showFileItemPreview}
|
||||||
|
onOpenChange={(isOpen: boolean) => {
|
||||||
|
setShowFileItemPreview(isOpen)
|
||||||
|
setSelectedFileItem(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import mistral from "@/public/providers/mistral.png"
|
||||||
|
import groq from "@/public/providers/groq.png"
|
||||||
|
import perplexity from "@/public/providers/perplexity.png"
|
||||||
|
import { ModelProvider } from "@/types"
|
||||||
|
import { IconSparkles } from "@tabler/icons-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, HTMLAttributes } from "react"
|
||||||
|
import { AnthropicSVG } from "../icons/anthropic-svg"
|
||||||
|
import { GoogleSVG } from "../icons/google-svg"
|
||||||
|
import { OpenAISVG } from "../icons/openai-svg"
|
||||||
|
|
||||||
|
interface ModelIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
provider: ModelProvider
|
||||||
|
height: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelIcon: FC<ModelIconProps> = ({
|
||||||
|
provider,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
switch (provider as ModelProvider) {
|
||||||
|
case "openai":
|
||||||
|
return (
|
||||||
|
<OpenAISVG
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm bg-white p-1 text-black",
|
||||||
|
props.className,
|
||||||
|
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
|
||||||
|
)}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "mistral":
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm p-1",
|
||||||
|
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
|
||||||
|
)}
|
||||||
|
src={mistral.src}
|
||||||
|
alt="Mistral"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "groq":
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm p-0",
|
||||||
|
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
|
||||||
|
)}
|
||||||
|
src={groq.src}
|
||||||
|
alt="Groq"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "anthropic":
|
||||||
|
return (
|
||||||
|
<AnthropicSVG
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm bg-white p-1 text-black",
|
||||||
|
props.className,
|
||||||
|
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
|
||||||
|
)}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "google":
|
||||||
|
return (
|
||||||
|
<GoogleSVG
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm bg-white p-1 text-black",
|
||||||
|
props.className,
|
||||||
|
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
|
||||||
|
)}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "perplexity":
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm p-1",
|
||||||
|
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
|
||||||
|
)}
|
||||||
|
src={perplexity.src}
|
||||||
|
alt="Mistral"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return <IconSparkles size={width} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { LLM } from "@/types"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { ModelIcon } from "./model-icon"
|
||||||
|
import { IconInfoCircle } from "@tabler/icons-react"
|
||||||
|
import { WithTooltip } from "../ui/with-tooltip"
|
||||||
|
|
||||||
|
interface ModelOptionProps {
|
||||||
|
model: LLM
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelOption: FC<ModelOptionProps> = ({ model, onSelect }) => {
|
||||||
|
return (
|
||||||
|
<WithTooltip
|
||||||
|
display={
|
||||||
|
<div>
|
||||||
|
{model.provider !== "ollama" && model.pricing && (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Input Cost:</span>{" "}
|
||||||
|
{model.pricing.inputCost} {model.pricing.currency} per{" "}
|
||||||
|
{model.pricing.unit}
|
||||||
|
</div>
|
||||||
|
{model.pricing.outputCost && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Output Cost:</span>{" "}
|
||||||
|
{model.pricing.outputCost} {model.pricing.currency} per{" "}
|
||||||
|
{model.pricing.unit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
side="bottom"
|
||||||
|
trigger={
|
||||||
|
<div
|
||||||
|
className="hover:bg-accent flex w-full cursor-pointer justify-start space-x-3 truncate rounded p-2 hover:opacity-50"
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ModelIcon provider={model.provider} width={28} height={28} />
|
||||||
|
<div className="text-sm font-semibold">{model.modelName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { LLM, LLMID, ModelProvider } from "@/types"
|
||||||
|
import { IconCheck, IconChevronDown } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
|
import { ModelIcon } from "./model-icon"
|
||||||
|
import { ModelOption } from "./model-option"
|
||||||
|
|
||||||
|
interface ModelSelectProps {
|
||||||
|
selectedModelId: string
|
||||||
|
onSelectModel: (modelId: LLMID) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelect: FC<ModelSelectProps> = ({
|
||||||
|
selectedModelId,
|
||||||
|
onSelectModel
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
models,
|
||||||
|
availableHostedModels,
|
||||||
|
availableLocalModels,
|
||||||
|
availableOpenRouterModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [tab, setTab] = useState<"hosted" | "local">("hosted")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, 100) // FIX: hacky
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSelectModel = (modelId: LLMID) => {
|
||||||
|
onSelectModel(modelId)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allModels = [
|
||||||
|
...models.map(model => ({
|
||||||
|
modelId: model.model_id as LLMID,
|
||||||
|
modelName: model.name,
|
||||||
|
provider: "custom" as ModelProvider,
|
||||||
|
hostedId: model.id,
|
||||||
|
platformLink: "",
|
||||||
|
imageInput: false
|
||||||
|
})),
|
||||||
|
...availableHostedModels,
|
||||||
|
...availableLocalModels,
|
||||||
|
...availableOpenRouterModels
|
||||||
|
]
|
||||||
|
|
||||||
|
const groupedModels = allModels.reduce<Record<string, LLM[]>>(
|
||||||
|
(groups, model) => {
|
||||||
|
const key = model.provider
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = []
|
||||||
|
}
|
||||||
|
groups[key].push(model)
|
||||||
|
return groups
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedModel = allModels.find(
|
||||||
|
model => model.modelId === selectedModelId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!profile) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={isOpen => {
|
||||||
|
setIsOpen(isOpen)
|
||||||
|
setSearch("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="bg-background w-full justify-start border-2 px-3 py-5"
|
||||||
|
asChild
|
||||||
|
disabled={allModels.length === 0}
|
||||||
|
>
|
||||||
|
{allModels.length === 0 ? (
|
||||||
|
<div className="rounded text-sm font-bold">
|
||||||
|
Unlock models by entering API keys in your profile settings.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{selectedModel ? (
|
||||||
|
<>
|
||||||
|
<ModelIcon
|
||||||
|
provider={selectedModel?.provider}
|
||||||
|
width={26}
|
||||||
|
height={26}
|
||||||
|
/>
|
||||||
|
<div className="ml-2 flex items-center">
|
||||||
|
{selectedModel?.modelName}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center">Select a model</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconChevronDown />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="space-y-2 overflow-auto p-2"
|
||||||
|
style={{ width: triggerRef.current?.offsetWidth }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Tabs value={tab} onValueChange={(value: any) => setTab(value)}>
|
||||||
|
{availableLocalModels.length > 0 && (
|
||||||
|
<TabsList defaultValue="hosted" className="grid grid-cols-2">
|
||||||
|
<TabsTrigger value="hosted">Hosted</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="local">Local</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search models..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-auto">
|
||||||
|
{Object.entries(groupedModels).map(([provider, models]) => {
|
||||||
|
const filteredModels = models
|
||||||
|
.filter(model => {
|
||||||
|
if (tab === "hosted") return model.provider !== "ollama"
|
||||||
|
if (tab === "local") return model.provider === "ollama"
|
||||||
|
if (tab === "openrouter") return model.provider === "openrouter"
|
||||||
|
})
|
||||||
|
.filter(model =>
|
||||||
|
model.modelName.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.provider.localeCompare(b.provider))
|
||||||
|
|
||||||
|
if (filteredModels.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={provider}>
|
||||||
|
<div className="mb-1 ml-2 text-xs font-bold tracking-wide opacity-50">
|
||||||
|
{provider === "openai" && profile.use_azure_openai
|
||||||
|
? "AZURE OPENAI"
|
||||||
|
: provider.toLocaleUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
{filteredModels.map(model => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.modelId}
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
{selectedModelId === model.modelId && (
|
||||||
|
<IconCheck className="ml-2" size={32} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModelOption
|
||||||
|
key={model.modelId}
|
||||||
|
model={model}
|
||||||
|
onSelect={() => handleSelectModel(model.modelId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
|
||||||
|
interface APIStepProps {
|
||||||
|
openaiAPIKey: string
|
||||||
|
openaiOrgID: string
|
||||||
|
azureOpenaiAPIKey: string
|
||||||
|
azureOpenaiEndpoint: string
|
||||||
|
azureOpenai35TurboID: string
|
||||||
|
azureOpenai45TurboID: string
|
||||||
|
azureOpenai45VisionID: string
|
||||||
|
azureOpenaiEmbeddingsID: string
|
||||||
|
anthropicAPIKey: string
|
||||||
|
googleGeminiAPIKey: string
|
||||||
|
mistralAPIKey: string
|
||||||
|
groqAPIKey: string
|
||||||
|
perplexityAPIKey: string
|
||||||
|
useAzureOpenai: boolean
|
||||||
|
openrouterAPIKey: string
|
||||||
|
onOpenrouterAPIKeyChange: (value: string) => void
|
||||||
|
onOpenaiAPIKeyChange: (value: string) => void
|
||||||
|
onOpenaiOrgIDChange: (value: string) => void
|
||||||
|
onAzureOpenaiAPIKeyChange: (value: string) => void
|
||||||
|
onAzureOpenaiEndpointChange: (value: string) => void
|
||||||
|
onAzureOpenai35TurboIDChange: (value: string) => void
|
||||||
|
onAzureOpenai45TurboIDChange: (value: string) => void
|
||||||
|
onAzureOpenai45VisionIDChange: (value: string) => void
|
||||||
|
onAzureOpenaiEmbeddingsIDChange: (value: string) => void
|
||||||
|
onAnthropicAPIKeyChange: (value: string) => void
|
||||||
|
onGoogleGeminiAPIKeyChange: (value: string) => void
|
||||||
|
onMistralAPIKeyChange: (value: string) => void
|
||||||
|
onGroqAPIKeyChange: (value: string) => void
|
||||||
|
onPerplexityAPIKeyChange: (value: string) => void
|
||||||
|
onUseAzureOpenaiChange: (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APIStep: FC<APIStepProps> = ({
|
||||||
|
openaiAPIKey,
|
||||||
|
openaiOrgID,
|
||||||
|
azureOpenaiAPIKey,
|
||||||
|
azureOpenaiEndpoint,
|
||||||
|
azureOpenai35TurboID,
|
||||||
|
azureOpenai45TurboID,
|
||||||
|
azureOpenai45VisionID,
|
||||||
|
azureOpenaiEmbeddingsID,
|
||||||
|
anthropicAPIKey,
|
||||||
|
googleGeminiAPIKey,
|
||||||
|
mistralAPIKey,
|
||||||
|
groqAPIKey,
|
||||||
|
perplexityAPIKey,
|
||||||
|
openrouterAPIKey,
|
||||||
|
useAzureOpenai,
|
||||||
|
onOpenaiAPIKeyChange,
|
||||||
|
onOpenaiOrgIDChange,
|
||||||
|
onAzureOpenaiAPIKeyChange,
|
||||||
|
onAzureOpenaiEndpointChange,
|
||||||
|
onAzureOpenai35TurboIDChange,
|
||||||
|
onAzureOpenai45TurboIDChange,
|
||||||
|
onAzureOpenai45VisionIDChange,
|
||||||
|
onAzureOpenaiEmbeddingsIDChange,
|
||||||
|
onAnthropicAPIKeyChange,
|
||||||
|
onGoogleGeminiAPIKeyChange,
|
||||||
|
onMistralAPIKeyChange,
|
||||||
|
onGroqAPIKeyChange,
|
||||||
|
onPerplexityAPIKeyChange,
|
||||||
|
onUseAzureOpenaiChange,
|
||||||
|
onOpenrouterAPIKeyChange
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
<Label className="flex items-center">
|
||||||
|
<div>
|
||||||
|
{useAzureOpenai ? "Azure OpenAI API Key" : "OpenAI API Key"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="ml-3 h-[18px] w-[150px] text-[11px]"
|
||||||
|
onClick={() => onUseAzureOpenaiChange(!useAzureOpenai)}
|
||||||
|
>
|
||||||
|
{useAzureOpenai
|
||||||
|
? "Switch To Standard OpenAI"
|
||||||
|
: "Switch To Azure OpenAI"}
|
||||||
|
</Button>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
useAzureOpenai ? "Azure OpenAI API Key" : "OpenAI API Key"
|
||||||
|
}
|
||||||
|
type="password"
|
||||||
|
value={useAzureOpenai ? azureOpenaiAPIKey : openaiAPIKey}
|
||||||
|
onChange={e =>
|
||||||
|
useAzureOpenai
|
||||||
|
? onAzureOpenaiAPIKeyChange(e.target.value)
|
||||||
|
: onOpenaiAPIKeyChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-8 space-y-3">
|
||||||
|
{useAzureOpenai ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Azure OpenAI Endpoint</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="https://your-endpoint.openai.azure.com"
|
||||||
|
type="password"
|
||||||
|
value={azureOpenaiEndpoint}
|
||||||
|
onChange={e => onAzureOpenaiEndpointChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Azure OpenAI GPT-3.5 Turbo ID</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Azure OpenAI GPT-3.5 Turbo ID"
|
||||||
|
type="password"
|
||||||
|
value={azureOpenai35TurboID}
|
||||||
|
onChange={e => onAzureOpenai35TurboIDChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Azure OpenAI GPT-4.5 Turbo ID</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Azure OpenAI GPT-4.5 Turbo ID"
|
||||||
|
type="password"
|
||||||
|
value={azureOpenai45TurboID}
|
||||||
|
onChange={e => onAzureOpenai45TurboIDChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Azure OpenAI GPT-4.5 Vision ID</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Azure OpenAI GPT-4.5 Vision ID"
|
||||||
|
type="password"
|
||||||
|
value={azureOpenai45VisionID}
|
||||||
|
onChange={e => onAzureOpenai45VisionIDChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Azure OpenAI Embeddings ID</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Azure OpenAI Embeddings ID"
|
||||||
|
type="password"
|
||||||
|
value={azureOpenaiEmbeddingsID}
|
||||||
|
onChange={e => onAzureOpenaiEmbeddingsIDChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>OpenAI Organization ID</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="OpenAI Organization ID (optional)"
|
||||||
|
type="password"
|
||||||
|
value={openaiOrgID}
|
||||||
|
onChange={e => onOpenaiOrgIDChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Anthropic API Key</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Anthropic API Key"
|
||||||
|
type="password"
|
||||||
|
value={anthropicAPIKey}
|
||||||
|
onChange={e => onAnthropicAPIKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Google Gemini API Key</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Google Gemini API Key"
|
||||||
|
type="password"
|
||||||
|
value={googleGeminiAPIKey}
|
||||||
|
onChange={e => onGoogleGeminiAPIKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Mistral API Key</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Mistral API Key"
|
||||||
|
type="password"
|
||||||
|
value={mistralAPIKey}
|
||||||
|
onChange={e => onMistralAPIKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Groq API Key</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Groq API Key"
|
||||||
|
type="password"
|
||||||
|
value={groqAPIKey}
|
||||||
|
onChange={e => onGroqAPIKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Perplexity API Key</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Perplexity API Key"
|
||||||
|
type="password"
|
||||||
|
value={perplexityAPIKey}
|
||||||
|
onChange={e => onPerplexityAPIKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>OpenRouter API Key</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="OpenRouter API Key"
|
||||||
|
type="password"
|
||||||
|
value={openrouterAPIKey}
|
||||||
|
onChange={e => onOpenrouterAPIKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface FinishStepProps {
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FinishStep: FC<FinishStepProps> = ({ displayName }) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
Welcome to Chatbot UI
|
||||||
|
{displayName.length > 0 ? `, ${displayName.split(" ")[0]}` : null}!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>Click next to start chatting.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
PROFILE_DISPLAY_NAME_MAX,
|
||||||
|
PROFILE_USERNAME_MAX,
|
||||||
|
PROFILE_USERNAME_MIN
|
||||||
|
} from "@/db/limits"
|
||||||
|
import {
|
||||||
|
IconCircleCheckFilled,
|
||||||
|
IconCircleXFilled,
|
||||||
|
IconLoader2
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { FC, useCallback, useState } from "react"
|
||||||
|
import { LimitDisplay } from "../ui/limit-display"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface ProfileStepProps {
|
||||||
|
username: string
|
||||||
|
usernameAvailable: boolean
|
||||||
|
displayName: string
|
||||||
|
onUsernameAvailableChange: (isAvailable: boolean) => void
|
||||||
|
onUsernameChange: (username: string) => void
|
||||||
|
onDisplayNameChange: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileStep: FC<ProfileStepProps> = ({
|
||||||
|
username,
|
||||||
|
usernameAvailable,
|
||||||
|
displayName,
|
||||||
|
onUsernameAvailableChange,
|
||||||
|
onUsernameChange,
|
||||||
|
onDisplayNameChange
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const debounce = (func: (...args: any[]) => void, wait: number) => {
|
||||||
|
let timeout: NodeJS.Timeout | null
|
||||||
|
|
||||||
|
return (...args: any[]) => {
|
||||||
|
const later = () => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
func(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(later, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkUsernameAvailability = useCallback(
|
||||||
|
debounce(async (username: string) => {
|
||||||
|
if (!username) return
|
||||||
|
|
||||||
|
if (username.length < PROFILE_USERNAME_MIN) {
|
||||||
|
onUsernameAvailableChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length > PROFILE_USERNAME_MAX) {
|
||||||
|
onUsernameAvailableChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameRegex = /^[a-zA-Z0-9_]+$/
|
||||||
|
if (!usernameRegex.test(username)) {
|
||||||
|
onUsernameAvailableChange(false)
|
||||||
|
toast.error(
|
||||||
|
"Username must be letters, numbers, or underscores only - no other characters or spacing allowed."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const response = await fetch(`/api/username/available`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const isAvailable = data.isAvailable
|
||||||
|
|
||||||
|
onUsernameAvailableChange(isAvailable)
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}, 500),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
|
||||||
|
<div className="text-xs">
|
||||||
|
{usernameAvailable ? (
|
||||||
|
<div className="text-green-500">AVAILABLE</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-red-500">UNAVAILABLE</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="pr-10"
|
||||||
|
placeholder="username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => {
|
||||||
|
onUsernameChange(e.target.value)
|
||||||
|
checkUsernameAvailability(e.target.value)
|
||||||
|
}}
|
||||||
|
minLength={PROFILE_USERNAME_MIN}
|
||||||
|
maxLength={PROFILE_USERNAME_MAX}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
|
{loading ? (
|
||||||
|
<IconLoader2 className="animate-spin" />
|
||||||
|
) : usernameAvailable ? (
|
||||||
|
<IconCircleCheckFilled className="text-green-500" />
|
||||||
|
) : (
|
||||||
|
<IconCircleXFilled className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LimitDisplay used={username.length} limit={PROFILE_USERNAME_MAX} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Chat Display Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Your Name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={e => onDisplayNameChange(e.target.value)}
|
||||||
|
maxLength={PROFILE_DISPLAY_NAME_MAX}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LimitDisplay
|
||||||
|
used={displayName.length}
|
||||||
|
limit={PROFILE_DISPLAY_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { FC, useRef } from "react"
|
||||||
|
|
||||||
|
export const SETUP_STEP_COUNT = 3
|
||||||
|
|
||||||
|
interface StepContainerProps {
|
||||||
|
stepDescription: string
|
||||||
|
stepNum: number
|
||||||
|
stepTitle: string
|
||||||
|
onShouldProceed: (shouldProceed: boolean) => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
showBackButton?: boolean
|
||||||
|
showNextButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepContainer: FC<StepContainerProps> = ({
|
||||||
|
stepDescription,
|
||||||
|
stepNum,
|
||||||
|
stepTitle,
|
||||||
|
onShouldProceed,
|
||||||
|
children,
|
||||||
|
showBackButton = false,
|
||||||
|
showNextButton = true
|
||||||
|
}) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
buttonRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="max-h-[calc(100vh-60px)] w-[600px] overflow-auto"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex justify-between">
|
||||||
|
<div>{stepTitle}</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
{stepNum} / {SETUP_STEP_COUNT}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<CardDescription>{stepDescription}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">{children}</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
{showBackButton && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onShouldProceed(false)}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{showNextButton && (
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShouldProceed(true)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { createAssistantCollections } from "@/db/assistant-collections"
|
||||||
|
import { createAssistantFiles } from "@/db/assistant-files"
|
||||||
|
import { createAssistantTools } from "@/db/assistant-tools"
|
||||||
|
import { createAssistant, updateAssistant } from "@/db/assistants"
|
||||||
|
import { createChat } from "@/db/chats"
|
||||||
|
import { createCollectionFiles } from "@/db/collection-files"
|
||||||
|
import { createCollection } from "@/db/collections"
|
||||||
|
import { createFileBasedOnExtension } from "@/db/files"
|
||||||
|
import { createModel } from "@/db/models"
|
||||||
|
import { createPreset } from "@/db/presets"
|
||||||
|
import { createPrompt } from "@/db/prompts"
|
||||||
|
import {
|
||||||
|
getAssistantImageFromStorage,
|
||||||
|
uploadAssistantImage
|
||||||
|
} from "@/db/storage/assistant-images"
|
||||||
|
import { createTool } from "@/db/tools"
|
||||||
|
import { convertBlobToBase64 } from "@/lib/blob-to-b64"
|
||||||
|
import { Tables, TablesInsert } from "@/supabase/types"
|
||||||
|
import { ContentType } from "@/types"
|
||||||
|
import { FC, useContext, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface SidebarCreateItemProps {
|
||||||
|
isOpen: boolean
|
||||||
|
isTyping: boolean
|
||||||
|
onOpenChange: (isOpen: boolean) => void
|
||||||
|
contentType: ContentType
|
||||||
|
renderInputs: () => JSX.Element
|
||||||
|
createState: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarCreateItem: FC<SidebarCreateItemProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
contentType,
|
||||||
|
renderInputs,
|
||||||
|
createState,
|
||||||
|
isTyping
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
selectedWorkspace,
|
||||||
|
setChats,
|
||||||
|
setPresets,
|
||||||
|
setPrompts,
|
||||||
|
setFiles,
|
||||||
|
setCollections,
|
||||||
|
setAssistants,
|
||||||
|
setAssistantImages,
|
||||||
|
setTools,
|
||||||
|
setModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const createFunctions = {
|
||||||
|
chats: createChat,
|
||||||
|
presets: createPreset,
|
||||||
|
prompts: createPrompt,
|
||||||
|
files: async (
|
||||||
|
createState: { file: File } & TablesInsert<"files">,
|
||||||
|
workspaceId: string
|
||||||
|
) => {
|
||||||
|
if (!selectedWorkspace) return
|
||||||
|
|
||||||
|
const { file, ...rest } = createState
|
||||||
|
|
||||||
|
const createdFile = await createFileBasedOnExtension(
|
||||||
|
file,
|
||||||
|
rest,
|
||||||
|
workspaceId,
|
||||||
|
selectedWorkspace.embeddings_provider as "openai" | "local"
|
||||||
|
)
|
||||||
|
|
||||||
|
return createdFile
|
||||||
|
},
|
||||||
|
collections: async (
|
||||||
|
createState: {
|
||||||
|
image: File
|
||||||
|
collectionFiles: TablesInsert<"collection_files">[]
|
||||||
|
} & Tables<"collections">,
|
||||||
|
workspaceId: string
|
||||||
|
) => {
|
||||||
|
const { collectionFiles, ...rest } = createState
|
||||||
|
|
||||||
|
const createdCollection = await createCollection(rest, workspaceId)
|
||||||
|
|
||||||
|
const finalCollectionFiles = collectionFiles.map(collectionFile => ({
|
||||||
|
...collectionFile,
|
||||||
|
collection_id: createdCollection.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
await createCollectionFiles(finalCollectionFiles)
|
||||||
|
|
||||||
|
return createdCollection
|
||||||
|
},
|
||||||
|
assistants: async (
|
||||||
|
createState: {
|
||||||
|
image: File
|
||||||
|
files: Tables<"files">[]
|
||||||
|
collections: Tables<"collections">[]
|
||||||
|
tools: Tables<"tools">[]
|
||||||
|
} & Tables<"assistants">,
|
||||||
|
workspaceId: string
|
||||||
|
) => {
|
||||||
|
const { image, files, collections, tools, ...rest } = createState
|
||||||
|
|
||||||
|
const createdAssistant = await createAssistant(rest, workspaceId)
|
||||||
|
|
||||||
|
let updatedAssistant = createdAssistant
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
const filePath = await uploadAssistantImage(createdAssistant, image)
|
||||||
|
|
||||||
|
updatedAssistant = await updateAssistant(createdAssistant.id, {
|
||||||
|
image_path: filePath
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = (await getAssistantImageFromStorage(filePath)) || ""
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const base64 = await convertBlobToBase64(blob)
|
||||||
|
|
||||||
|
setAssistantImages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
assistantId: updatedAssistant.id,
|
||||||
|
path: filePath,
|
||||||
|
base64,
|
||||||
|
url
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantFiles = files.map(file => ({
|
||||||
|
user_id: rest.user_id,
|
||||||
|
assistant_id: createdAssistant.id,
|
||||||
|
file_id: file.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
const assistantCollections = collections.map(collection => ({
|
||||||
|
user_id: rest.user_id,
|
||||||
|
assistant_id: createdAssistant.id,
|
||||||
|
collection_id: collection.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
const assistantTools = tools.map(tool => ({
|
||||||
|
user_id: rest.user_id,
|
||||||
|
assistant_id: createdAssistant.id,
|
||||||
|
tool_id: tool.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
await createAssistantFiles(assistantFiles)
|
||||||
|
await createAssistantCollections(assistantCollections)
|
||||||
|
await createAssistantTools(assistantTools)
|
||||||
|
|
||||||
|
return updatedAssistant
|
||||||
|
},
|
||||||
|
tools: createTool,
|
||||||
|
models: createModel
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateUpdateFunctions = {
|
||||||
|
chats: setChats,
|
||||||
|
presets: setPresets,
|
||||||
|
prompts: setPrompts,
|
||||||
|
files: setFiles,
|
||||||
|
collections: setCollections,
|
||||||
|
assistants: setAssistants,
|
||||||
|
tools: setTools,
|
||||||
|
models: setModels
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
if (!selectedWorkspace) return
|
||||||
|
if (isTyping) return // Prevent creation while typing
|
||||||
|
|
||||||
|
const createFunction = createFunctions[contentType]
|
||||||
|
const setStateFunction = stateUpdateFunctions[contentType]
|
||||||
|
|
||||||
|
if (!createFunction || !setStateFunction) return
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
|
||||||
|
const newItem = await createFunction(createState, selectedWorkspace.id)
|
||||||
|
|
||||||
|
setStateFunction((prevItems: any) => [...prevItems, newItem])
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
setCreating(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Error creating ${contentType.slice(0, -1)}. ${error}.`)
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (!isTyping && e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
className="flex min-w-[450px] flex-col justify-between overflow-auto"
|
||||||
|
side="left"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="grow overflow-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="text-2xl font-bold">
|
||||||
|
Create{" "}
|
||||||
|
{contentType.charAt(0).toUpperCase() + contentType.slice(1, -1)}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">{renderInputs()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="mt-2 flex justify-between">
|
||||||
|
<div className="flex grow justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
disabled={creating}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button disabled={creating} ref={buttonRef} onClick={handleCreate}>
|
||||||
|
{creating ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { deleteAssistant } from "@/db/assistants"
|
||||||
|
import { deleteChat } from "@/db/chats"
|
||||||
|
import { deleteCollection } from "@/db/collections"
|
||||||
|
import { deleteFile } from "@/db/files"
|
||||||
|
import { deleteModel } from "@/db/models"
|
||||||
|
import { deletePreset } from "@/db/presets"
|
||||||
|
import { deletePrompt } from "@/db/prompts"
|
||||||
|
import { deleteFileFromStorage } from "@/db/storage/files"
|
||||||
|
import { deleteTool } from "@/db/tools"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { ContentType, DataItemType } from "@/types"
|
||||||
|
import { FC, useContext, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface SidebarDeleteItemProps {
|
||||||
|
item: DataItemType
|
||||||
|
contentType: ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarDeleteItem: FC<SidebarDeleteItemProps> = ({
|
||||||
|
item,
|
||||||
|
contentType
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
setChats,
|
||||||
|
setPresets,
|
||||||
|
setPrompts,
|
||||||
|
setFiles,
|
||||||
|
setCollections,
|
||||||
|
setAssistants,
|
||||||
|
setTools,
|
||||||
|
setModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
|
|
||||||
|
const deleteFunctions = {
|
||||||
|
chats: async (chat: Tables<"chats">) => {
|
||||||
|
await deleteChat(chat.id)
|
||||||
|
},
|
||||||
|
presets: async (preset: Tables<"presets">) => {
|
||||||
|
await deletePreset(preset.id)
|
||||||
|
},
|
||||||
|
prompts: async (prompt: Tables<"prompts">) => {
|
||||||
|
await deletePrompt(prompt.id)
|
||||||
|
},
|
||||||
|
files: async (file: Tables<"files">) => {
|
||||||
|
await deleteFileFromStorage(file.file_path)
|
||||||
|
await deleteFile(file.id)
|
||||||
|
},
|
||||||
|
collections: async (collection: Tables<"collections">) => {
|
||||||
|
await deleteCollection(collection.id)
|
||||||
|
},
|
||||||
|
assistants: async (assistant: Tables<"assistants">) => {
|
||||||
|
await deleteAssistant(assistant.id)
|
||||||
|
setChats(prevState =>
|
||||||
|
prevState.filter(chat => chat.assistant_id !== assistant.id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
tools: async (tool: Tables<"tools">) => {
|
||||||
|
await deleteTool(tool.id)
|
||||||
|
},
|
||||||
|
models: async (model: Tables<"models">) => {
|
||||||
|
await deleteModel(model.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateUpdateFunctions = {
|
||||||
|
chats: setChats,
|
||||||
|
presets: setPresets,
|
||||||
|
prompts: setPrompts,
|
||||||
|
files: setFiles,
|
||||||
|
collections: setCollections,
|
||||||
|
assistants: setAssistants,
|
||||||
|
tools: setTools,
|
||||||
|
models: setModels
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const deleteFunction = deleteFunctions[contentType]
|
||||||
|
const setStateFunction = stateUpdateFunctions[contentType]
|
||||||
|
|
||||||
|
if (!deleteFunction || !setStateFunction) return
|
||||||
|
|
||||||
|
await deleteFunction(item as any)
|
||||||
|
|
||||||
|
setStateFunction((prevItems: any) =>
|
||||||
|
prevItems.filter((prevItem: any) => prevItem.id !== item.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
setShowDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.stopPropagation()
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="text-red-500" variant="ghost">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent onKeyDown={handleKeyDown}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {contentType.slice(0, -1)}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete {item.name}?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button ref={buttonRef} variant="destructive" onClick={handleDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { createChat } from "@/db/chats"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { ContentType, DataItemType } from "@/types"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { FC, useContext, useRef, useState } from "react"
|
||||||
|
import { SidebarUpdateItem } from "./sidebar-update-item"
|
||||||
|
|
||||||
|
interface SidebarItemProps {
|
||||||
|
item: DataItemType
|
||||||
|
isTyping: boolean
|
||||||
|
contentType: ContentType
|
||||||
|
icon: React.ReactNode
|
||||||
|
updateState: any
|
||||||
|
renderInputs: (renderState: any) => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarItem: FC<SidebarItemProps> = ({
|
||||||
|
item,
|
||||||
|
contentType,
|
||||||
|
updateState,
|
||||||
|
renderInputs,
|
||||||
|
icon,
|
||||||
|
isTyping
|
||||||
|
}) => {
|
||||||
|
const { selectedWorkspace, setChats, setSelectedAssistant } =
|
||||||
|
useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [isHovering, setIsHovering] = useState(false)
|
||||||
|
|
||||||
|
const actionMap = {
|
||||||
|
chats: async (item: any) => {},
|
||||||
|
presets: async (item: any) => {},
|
||||||
|
prompts: async (item: any) => {},
|
||||||
|
files: async (item: any) => {},
|
||||||
|
collections: async (item: any) => {},
|
||||||
|
assistants: async (assistant: Tables<"assistants">) => {
|
||||||
|
if (!selectedWorkspace) return
|
||||||
|
|
||||||
|
const createdChat = await createChat({
|
||||||
|
user_id: assistant.user_id,
|
||||||
|
workspace_id: selectedWorkspace.id,
|
||||||
|
assistant_id: assistant.id,
|
||||||
|
context_length: assistant.context_length,
|
||||||
|
include_profile_context: assistant.include_profile_context,
|
||||||
|
include_workspace_instructions:
|
||||||
|
assistant.include_workspace_instructions,
|
||||||
|
model: assistant.model,
|
||||||
|
name: `Chat with ${assistant.name}`,
|
||||||
|
prompt: assistant.prompt,
|
||||||
|
temperature: assistant.temperature,
|
||||||
|
embeddings_provider: assistant.embeddings_provider
|
||||||
|
})
|
||||||
|
|
||||||
|
setChats(prevState => [createdChat, ...prevState])
|
||||||
|
setSelectedAssistant(assistant)
|
||||||
|
|
||||||
|
return router.push(`/${selectedWorkspace.id}/chat/${createdChat.id}`)
|
||||||
|
},
|
||||||
|
tools: async (item: any) => {},
|
||||||
|
models: async (item: any) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.stopPropagation()
|
||||||
|
itemRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const handleClickAction = async (
|
||||||
|
// e: React.MouseEvent<SVGSVGElement, MouseEvent>
|
||||||
|
// ) => {
|
||||||
|
// e.stopPropagation()
|
||||||
|
|
||||||
|
// const action = actionMap[contentType]
|
||||||
|
|
||||||
|
// await action(item as any)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarUpdateItem
|
||||||
|
item={item}
|
||||||
|
isTyping={isTyping}
|
||||||
|
contentType={contentType}
|
||||||
|
updateState={updateState}
|
||||||
|
renderInputs={renderInputs}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={itemRef}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent flex w-full cursor-pointer items-center rounded p-2 hover:opacity-50 focus:outline-none"
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
|
||||||
|
<div className="ml-3 flex-1 truncate text-sm font-semibold">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TODO */}
|
||||||
|
{/* {isHovering && (
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={1000}
|
||||||
|
display={<div>Start chat with {contentType.slice(0, -1)}</div>}
|
||||||
|
trigger={
|
||||||
|
<IconSquarePlus
|
||||||
|
className="cursor-pointer hover:text-blue-500"
|
||||||
|
size={20}
|
||||||
|
onClick={handleClickAction}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</SidebarUpdateItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,680 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { AssignWorkspaces } from "@/components/workspace/assign-workspaces"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import {
|
||||||
|
createAssistantCollection,
|
||||||
|
deleteAssistantCollection,
|
||||||
|
getAssistantCollectionsByAssistantId
|
||||||
|
} from "@/db/assistant-collections"
|
||||||
|
import {
|
||||||
|
createAssistantFile,
|
||||||
|
deleteAssistantFile,
|
||||||
|
getAssistantFilesByAssistantId
|
||||||
|
} from "@/db/assistant-files"
|
||||||
|
import {
|
||||||
|
createAssistantTool,
|
||||||
|
deleteAssistantTool,
|
||||||
|
getAssistantToolsByAssistantId
|
||||||
|
} from "@/db/assistant-tools"
|
||||||
|
import {
|
||||||
|
createAssistantWorkspaces,
|
||||||
|
deleteAssistantWorkspace,
|
||||||
|
getAssistantWorkspacesByAssistantId,
|
||||||
|
updateAssistant
|
||||||
|
} from "@/db/assistants"
|
||||||
|
import { updateChat } from "@/db/chats"
|
||||||
|
import {
|
||||||
|
createCollectionFile,
|
||||||
|
deleteCollectionFile,
|
||||||
|
getCollectionFilesByCollectionId
|
||||||
|
} from "@/db/collection-files"
|
||||||
|
import {
|
||||||
|
createCollectionWorkspaces,
|
||||||
|
deleteCollectionWorkspace,
|
||||||
|
getCollectionWorkspacesByCollectionId,
|
||||||
|
updateCollection
|
||||||
|
} from "@/db/collections"
|
||||||
|
import {
|
||||||
|
createFileWorkspaces,
|
||||||
|
deleteFileWorkspace,
|
||||||
|
getFileWorkspacesByFileId,
|
||||||
|
updateFile
|
||||||
|
} from "@/db/files"
|
||||||
|
import {
|
||||||
|
createModelWorkspaces,
|
||||||
|
deleteModelWorkspace,
|
||||||
|
getModelWorkspacesByModelId,
|
||||||
|
updateModel
|
||||||
|
} from "@/db/models"
|
||||||
|
import {
|
||||||
|
createPresetWorkspaces,
|
||||||
|
deletePresetWorkspace,
|
||||||
|
getPresetWorkspacesByPresetId,
|
||||||
|
updatePreset
|
||||||
|
} from "@/db/presets"
|
||||||
|
import {
|
||||||
|
createPromptWorkspaces,
|
||||||
|
deletePromptWorkspace,
|
||||||
|
getPromptWorkspacesByPromptId,
|
||||||
|
updatePrompt
|
||||||
|
} from "@/db/prompts"
|
||||||
|
import {
|
||||||
|
getAssistantImageFromStorage,
|
||||||
|
uploadAssistantImage
|
||||||
|
} from "@/db/storage/assistant-images"
|
||||||
|
import {
|
||||||
|
createToolWorkspaces,
|
||||||
|
deleteToolWorkspace,
|
||||||
|
getToolWorkspacesByToolId,
|
||||||
|
updateTool
|
||||||
|
} from "@/db/tools"
|
||||||
|
import { convertBlobToBase64 } from "@/lib/blob-to-b64"
|
||||||
|
import { Tables, TablesUpdate } from "@/supabase/types"
|
||||||
|
import { CollectionFile, ContentType, DataItemType } from "@/types"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
import profile from "react-syntax-highlighter/dist/esm/languages/hljs/profile"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { SidebarDeleteItem } from "./sidebar-delete-item"
|
||||||
|
|
||||||
|
interface SidebarUpdateItemProps {
|
||||||
|
isTyping: boolean
|
||||||
|
item: DataItemType
|
||||||
|
contentType: ContentType
|
||||||
|
children: React.ReactNode
|
||||||
|
renderInputs: (renderState: any) => JSX.Element
|
||||||
|
updateState: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarUpdateItem: FC<SidebarUpdateItemProps> = ({
|
||||||
|
item,
|
||||||
|
contentType,
|
||||||
|
children,
|
||||||
|
renderInputs,
|
||||||
|
updateState,
|
||||||
|
isTyping
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
workspaces,
|
||||||
|
selectedWorkspace,
|
||||||
|
setChats,
|
||||||
|
setPresets,
|
||||||
|
setPrompts,
|
||||||
|
setFiles,
|
||||||
|
setCollections,
|
||||||
|
setAssistants,
|
||||||
|
setTools,
|
||||||
|
setModels,
|
||||||
|
setAssistantImages
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [startingWorkspaces, setStartingWorkspaces] = useState<
|
||||||
|
Tables<"workspaces">[]
|
||||||
|
>([])
|
||||||
|
const [selectedWorkspaces, setSelectedWorkspaces] = useState<
|
||||||
|
Tables<"workspaces">[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
// Collections Render State
|
||||||
|
const [startingCollectionFiles, setStartingCollectionFiles] = useState<
|
||||||
|
CollectionFile[]
|
||||||
|
>([])
|
||||||
|
const [selectedCollectionFiles, setSelectedCollectionFiles] = useState<
|
||||||
|
CollectionFile[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
// Assistants Render State
|
||||||
|
const [startingAssistantFiles, setStartingAssistantFiles] = useState<
|
||||||
|
Tables<"files">[]
|
||||||
|
>([])
|
||||||
|
const [startingAssistantCollections, setStartingAssistantCollections] =
|
||||||
|
useState<Tables<"collections">[]>([])
|
||||||
|
const [startingAssistantTools, setStartingAssistantTools] = useState<
|
||||||
|
Tables<"tools">[]
|
||||||
|
>([])
|
||||||
|
const [selectedAssistantFiles, setSelectedAssistantFiles] = useState<
|
||||||
|
Tables<"files">[]
|
||||||
|
>([])
|
||||||
|
const [selectedAssistantCollections, setSelectedAssistantCollections] =
|
||||||
|
useState<Tables<"collections">[]>([])
|
||||||
|
const [selectedAssistantTools, setSelectedAssistantTools] = useState<
|
||||||
|
Tables<"tools">[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (workspaces.length > 1) {
|
||||||
|
const workspaces = await fetchSelectedWorkspaces()
|
||||||
|
setStartingWorkspaces(workspaces)
|
||||||
|
setSelectedWorkspaces(workspaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDataFunction = fetchDataFunctions[contentType]
|
||||||
|
if (!fetchDataFunction) return
|
||||||
|
await fetchDataFunction(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const renderState = {
|
||||||
|
chats: null,
|
||||||
|
presets: null,
|
||||||
|
prompts: null,
|
||||||
|
files: null,
|
||||||
|
collections: {
|
||||||
|
startingCollectionFiles,
|
||||||
|
setStartingCollectionFiles,
|
||||||
|
selectedCollectionFiles,
|
||||||
|
setSelectedCollectionFiles
|
||||||
|
},
|
||||||
|
assistants: {
|
||||||
|
startingAssistantFiles,
|
||||||
|
setStartingAssistantFiles,
|
||||||
|
startingAssistantCollections,
|
||||||
|
setStartingAssistantCollections,
|
||||||
|
startingAssistantTools,
|
||||||
|
setStartingAssistantTools,
|
||||||
|
selectedAssistantFiles,
|
||||||
|
setSelectedAssistantFiles,
|
||||||
|
selectedAssistantCollections,
|
||||||
|
setSelectedAssistantCollections,
|
||||||
|
selectedAssistantTools,
|
||||||
|
setSelectedAssistantTools
|
||||||
|
},
|
||||||
|
tools: null,
|
||||||
|
models: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDataFunctions = {
|
||||||
|
chats: null,
|
||||||
|
presets: null,
|
||||||
|
prompts: null,
|
||||||
|
files: null,
|
||||||
|
collections: async (collectionId: string) => {
|
||||||
|
const collectionFiles =
|
||||||
|
await getCollectionFilesByCollectionId(collectionId)
|
||||||
|
setStartingCollectionFiles(collectionFiles.files)
|
||||||
|
setSelectedCollectionFiles([])
|
||||||
|
},
|
||||||
|
assistants: async (assistantId: string) => {
|
||||||
|
const assistantFiles = await getAssistantFilesByAssistantId(assistantId)
|
||||||
|
setStartingAssistantFiles(assistantFiles.files)
|
||||||
|
|
||||||
|
const assistantCollections =
|
||||||
|
await getAssistantCollectionsByAssistantId(assistantId)
|
||||||
|
setStartingAssistantCollections(assistantCollections.collections)
|
||||||
|
|
||||||
|
const assistantTools = await getAssistantToolsByAssistantId(assistantId)
|
||||||
|
setStartingAssistantTools(assistantTools.tools)
|
||||||
|
|
||||||
|
setSelectedAssistantFiles([])
|
||||||
|
setSelectedAssistantCollections([])
|
||||||
|
setSelectedAssistantTools([])
|
||||||
|
},
|
||||||
|
tools: null,
|
||||||
|
models: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWorkpaceFunctions = {
|
||||||
|
chats: null,
|
||||||
|
presets: async (presetId: string) => {
|
||||||
|
const item = await getPresetWorkspacesByPresetId(presetId)
|
||||||
|
return item.workspaces
|
||||||
|
},
|
||||||
|
prompts: async (promptId: string) => {
|
||||||
|
const item = await getPromptWorkspacesByPromptId(promptId)
|
||||||
|
return item.workspaces
|
||||||
|
},
|
||||||
|
files: async (fileId: string) => {
|
||||||
|
const item = await getFileWorkspacesByFileId(fileId)
|
||||||
|
return item.workspaces
|
||||||
|
},
|
||||||
|
collections: async (collectionId: string) => {
|
||||||
|
const item = await getCollectionWorkspacesByCollectionId(collectionId)
|
||||||
|
return item.workspaces
|
||||||
|
},
|
||||||
|
assistants: async (assistantId: string) => {
|
||||||
|
const item = await getAssistantWorkspacesByAssistantId(assistantId)
|
||||||
|
return item.workspaces
|
||||||
|
},
|
||||||
|
tools: async (toolId: string) => {
|
||||||
|
const item = await getToolWorkspacesByToolId(toolId)
|
||||||
|
return item.workspaces
|
||||||
|
},
|
||||||
|
models: async (modelId: string) => {
|
||||||
|
const item = await getModelWorkspacesByModelId(modelId)
|
||||||
|
return item.workspaces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSelectedWorkspaces = async () => {
|
||||||
|
const fetchFunction = fetchWorkpaceFunctions[contentType]
|
||||||
|
|
||||||
|
if (!fetchFunction) return []
|
||||||
|
|
||||||
|
const workspaces = await fetchFunction(item.id)
|
||||||
|
|
||||||
|
return workspaces
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWorkspaceUpdates = async (
|
||||||
|
startingWorkspaces: Tables<"workspaces">[],
|
||||||
|
selectedWorkspaces: Tables<"workspaces">[],
|
||||||
|
itemId: string,
|
||||||
|
deleteWorkspaceFn: (
|
||||||
|
itemId: string,
|
||||||
|
workspaceId: string
|
||||||
|
) => Promise<boolean>,
|
||||||
|
createWorkspaceFn: (
|
||||||
|
workspaces: { user_id: string; item_id: string; workspace_id: string }[]
|
||||||
|
) => Promise<void>,
|
||||||
|
itemIdKey: string
|
||||||
|
) => {
|
||||||
|
if (!selectedWorkspace) return
|
||||||
|
|
||||||
|
const deleteList = startingWorkspaces.filter(
|
||||||
|
startingWorkspace =>
|
||||||
|
!selectedWorkspaces.some(
|
||||||
|
selectedWorkspace => selectedWorkspace.id === startingWorkspace.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const workspace of deleteList) {
|
||||||
|
await deleteWorkspaceFn(itemId, workspace.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteList.map(w => w.id).includes(selectedWorkspace.id)) {
|
||||||
|
const setStateFunction = stateUpdateFunctions[contentType]
|
||||||
|
|
||||||
|
if (setStateFunction) {
|
||||||
|
setStateFunction((prevItems: any) =>
|
||||||
|
prevItems.filter((prevItem: any) => prevItem.id !== item.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createList = selectedWorkspaces.filter(
|
||||||
|
selectedWorkspace =>
|
||||||
|
!startingWorkspaces.some(
|
||||||
|
startingWorkspace => startingWorkspace.id === selectedWorkspace.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await createWorkspaceFn(
|
||||||
|
createList.map(workspace => {
|
||||||
|
return {
|
||||||
|
user_id: workspace.user_id,
|
||||||
|
[itemIdKey]: itemId,
|
||||||
|
workspace_id: workspace.id
|
||||||
|
} as any
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFunctions = {
|
||||||
|
chats: updateChat,
|
||||||
|
presets: async (presetId: string, updateState: TablesUpdate<"presets">) => {
|
||||||
|
const updatedPreset = await updatePreset(presetId, updateState)
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
presetId,
|
||||||
|
deletePresetWorkspace,
|
||||||
|
createPresetWorkspaces as any,
|
||||||
|
"preset_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedPreset
|
||||||
|
},
|
||||||
|
prompts: async (promptId: string, updateState: TablesUpdate<"prompts">) => {
|
||||||
|
const updatedPrompt = await updatePrompt(promptId, updateState)
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
promptId,
|
||||||
|
deletePromptWorkspace,
|
||||||
|
createPromptWorkspaces as any,
|
||||||
|
"prompt_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedPrompt
|
||||||
|
},
|
||||||
|
files: async (fileId: string, updateState: TablesUpdate<"files">) => {
|
||||||
|
const updatedFile = await updateFile(fileId, updateState)
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
fileId,
|
||||||
|
deleteFileWorkspace,
|
||||||
|
createFileWorkspaces as any,
|
||||||
|
"file_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedFile
|
||||||
|
},
|
||||||
|
collections: async (
|
||||||
|
collectionId: string,
|
||||||
|
updateState: TablesUpdate<"assistants">
|
||||||
|
) => {
|
||||||
|
if (!profile) return
|
||||||
|
|
||||||
|
const { ...rest } = updateState
|
||||||
|
|
||||||
|
const filesToAdd = selectedCollectionFiles.filter(
|
||||||
|
selectedFile =>
|
||||||
|
!startingCollectionFiles.some(
|
||||||
|
startingFile => startingFile.id === selectedFile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filesToRemove = startingCollectionFiles.filter(startingFile =>
|
||||||
|
selectedCollectionFiles.some(
|
||||||
|
selectedFile => selectedFile.id === startingFile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const file of filesToAdd) {
|
||||||
|
await createCollectionFile({
|
||||||
|
user_id: item.user_id,
|
||||||
|
collection_id: collectionId,
|
||||||
|
file_id: file.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of filesToRemove) {
|
||||||
|
await deleteCollectionFile(collectionId, file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCollection = await updateCollection(collectionId, rest)
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
collectionId,
|
||||||
|
deleteCollectionWorkspace,
|
||||||
|
createCollectionWorkspaces as any,
|
||||||
|
"collection_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedCollection
|
||||||
|
},
|
||||||
|
assistants: async (
|
||||||
|
assistantId: string,
|
||||||
|
updateState: {
|
||||||
|
assistantId: string
|
||||||
|
image: File
|
||||||
|
} & TablesUpdate<"assistants">
|
||||||
|
) => {
|
||||||
|
const { image, ...rest } = updateState
|
||||||
|
|
||||||
|
const filesToAdd = selectedAssistantFiles.filter(
|
||||||
|
selectedFile =>
|
||||||
|
!startingAssistantFiles.some(
|
||||||
|
startingFile => startingFile.id === selectedFile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filesToRemove = startingAssistantFiles.filter(startingFile =>
|
||||||
|
selectedAssistantFiles.some(
|
||||||
|
selectedFile => selectedFile.id === startingFile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const file of filesToAdd) {
|
||||||
|
await createAssistantFile({
|
||||||
|
user_id: item.user_id,
|
||||||
|
assistant_id: assistantId,
|
||||||
|
file_id: file.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of filesToRemove) {
|
||||||
|
await deleteAssistantFile(assistantId, file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionsToAdd = selectedAssistantCollections.filter(
|
||||||
|
selectedCollection =>
|
||||||
|
!startingAssistantCollections.some(
|
||||||
|
startingCollection =>
|
||||||
|
startingCollection.id === selectedCollection.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const collectionsToRemove = startingAssistantCollections.filter(
|
||||||
|
startingCollection =>
|
||||||
|
selectedAssistantCollections.some(
|
||||||
|
selectedCollection =>
|
||||||
|
selectedCollection.id === startingCollection.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const collection of collectionsToAdd) {
|
||||||
|
await createAssistantCollection({
|
||||||
|
user_id: item.user_id,
|
||||||
|
assistant_id: assistantId,
|
||||||
|
collection_id: collection.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const collection of collectionsToRemove) {
|
||||||
|
await deleteAssistantCollection(assistantId, collection.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsToAdd = selectedAssistantTools.filter(
|
||||||
|
selectedTool =>
|
||||||
|
!startingAssistantTools.some(
|
||||||
|
startingTool => startingTool.id === selectedTool.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolsToRemove = startingAssistantTools.filter(startingTool =>
|
||||||
|
selectedAssistantTools.some(
|
||||||
|
selectedTool => selectedTool.id === startingTool.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const tool of toolsToAdd) {
|
||||||
|
await createAssistantTool({
|
||||||
|
user_id: item.user_id,
|
||||||
|
assistant_id: assistantId,
|
||||||
|
tool_id: tool.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of toolsToRemove) {
|
||||||
|
await deleteAssistantTool(assistantId, tool.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedAssistant = await updateAssistant(assistantId, rest)
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
const filePath = await uploadAssistantImage(updatedAssistant, image)
|
||||||
|
|
||||||
|
updatedAssistant = await updateAssistant(assistantId, {
|
||||||
|
image_path: filePath
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = (await getAssistantImageFromStorage(filePath)) || ""
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const base64 = await convertBlobToBase64(blob)
|
||||||
|
|
||||||
|
setAssistantImages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
assistantId: updatedAssistant.id,
|
||||||
|
path: filePath,
|
||||||
|
base64,
|
||||||
|
url
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
assistantId,
|
||||||
|
deleteAssistantWorkspace,
|
||||||
|
createAssistantWorkspaces as any,
|
||||||
|
"assistant_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedAssistant
|
||||||
|
},
|
||||||
|
tools: async (toolId: string, updateState: TablesUpdate<"tools">) => {
|
||||||
|
const updatedTool = await updateTool(toolId, updateState)
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
toolId,
|
||||||
|
deleteToolWorkspace,
|
||||||
|
createToolWorkspaces as any,
|
||||||
|
"tool_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedTool
|
||||||
|
},
|
||||||
|
models: async (modelId: string, updateState: TablesUpdate<"models">) => {
|
||||||
|
const updatedModel = await updateModel(modelId, updateState)
|
||||||
|
|
||||||
|
await handleWorkspaceUpdates(
|
||||||
|
startingWorkspaces,
|
||||||
|
selectedWorkspaces,
|
||||||
|
modelId,
|
||||||
|
deleteModelWorkspace,
|
||||||
|
createModelWorkspaces as any,
|
||||||
|
"model_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateUpdateFunctions = {
|
||||||
|
chats: setChats,
|
||||||
|
presets: setPresets,
|
||||||
|
prompts: setPrompts,
|
||||||
|
files: setFiles,
|
||||||
|
collections: setCollections,
|
||||||
|
assistants: setAssistants,
|
||||||
|
tools: setTools,
|
||||||
|
models: setModels
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const updateFunction = updateFunctions[contentType]
|
||||||
|
const setStateFunction = stateUpdateFunctions[contentType]
|
||||||
|
|
||||||
|
if (!updateFunction || !setStateFunction) return
|
||||||
|
if (isTyping) return // Prevent update while typing
|
||||||
|
|
||||||
|
const updatedItem = await updateFunction(item.id, updateState)
|
||||||
|
|
||||||
|
setStateFunction((prevItems: any) =>
|
||||||
|
prevItems.map((prevItem: any) =>
|
||||||
|
prevItem.id === item.id ? updatedItem : prevItem
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
setIsOpen(false)
|
||||||
|
|
||||||
|
toast.success(`${contentType.slice(0, -1)} updated successfully`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Error updating ${contentType.slice(0, -1)}. ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectWorkspace = (workspace: Tables<"workspaces">) => {
|
||||||
|
setSelectedWorkspaces(prevState => {
|
||||||
|
const isWorkspaceAlreadySelected = prevState.find(
|
||||||
|
selectedWorkspace => selectedWorkspace.id === workspace.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isWorkspaceAlreadySelected) {
|
||||||
|
return prevState.filter(
|
||||||
|
selectedWorkspace => selectedWorkspace.id !== workspace.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return [...prevState, workspace]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (!isTyping && e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||||
|
|
||||||
|
<SheetContent
|
||||||
|
className="flex min-w-[450px] flex-col justify-between"
|
||||||
|
side="left"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="grow overflow-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="text-2xl font-bold">
|
||||||
|
Edit {contentType.slice(0, -1)}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{workspaces.length > 1 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Assigned Workspaces</Label>
|
||||||
|
|
||||||
|
<AssignWorkspaces
|
||||||
|
selectedWorkspaces={selectedWorkspaces}
|
||||||
|
onSelectWorkspace={handleSelectWorkspace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderInputs(renderState[contentType])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="mt-2 flex justify-between">
|
||||||
|
<SidebarDeleteItem item={item} contentType={contentType} />
|
||||||
|
|
||||||
|
<div className="flex grow justify-end space-x-2">
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button ref={buttonRef} onClick={handleUpdate}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { ChatSettingsForm } from "@/components/ui/chat-settings-form"
|
||||||
|
import ImagePicker from "@/components/ui/image-picker"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { ASSISTANT_DESCRIPTION_MAX, ASSISTANT_NAME_MAX } from "@/db/limits"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconRobotFace } from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { FC, useContext, useEffect, useState } from "react"
|
||||||
|
import profile from "react-syntax-highlighter/dist/esm/languages/hljs/profile"
|
||||||
|
import { SidebarItem } from "../all/sidebar-display-item"
|
||||||
|
import { AssistantRetrievalSelect } from "./assistant-retrieval-select"
|
||||||
|
import { AssistantToolSelect } from "./assistant-tool-select"
|
||||||
|
|
||||||
|
interface AssistantItemProps {
|
||||||
|
assistant: Tables<"assistants">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssistantItem: FC<AssistantItemProps> = ({ assistant }) => {
|
||||||
|
const { selectedWorkspace, assistantImages } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [name, setName] = useState(assistant.name)
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [description, setDescription] = useState(assistant.description)
|
||||||
|
const [assistantChatSettings, setAssistantChatSettings] = useState({
|
||||||
|
model: assistant.model,
|
||||||
|
prompt: assistant.prompt,
|
||||||
|
temperature: assistant.temperature,
|
||||||
|
contextLength: assistant.context_length,
|
||||||
|
includeProfileContext: assistant.include_profile_context,
|
||||||
|
includeWorkspaceInstructions: assistant.include_workspace_instructions
|
||||||
|
})
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null)
|
||||||
|
const [imageLink, setImageLink] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const assistantImage =
|
||||||
|
assistantImages.find(image => image.path === assistant.image_path)
|
||||||
|
?.base64 || ""
|
||||||
|
setImageLink(assistantImage)
|
||||||
|
}, [assistant, assistantImages])
|
||||||
|
|
||||||
|
const handleFileSelect = (
|
||||||
|
file: Tables<"files">,
|
||||||
|
setSelectedAssistantFiles: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"files">[]>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
setSelectedAssistantFiles(prevState => {
|
||||||
|
const isFileAlreadySelected = prevState.find(
|
||||||
|
selectedFile => selectedFile.id === file.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isFileAlreadySelected) {
|
||||||
|
return prevState.filter(selectedFile => selectedFile.id !== file.id)
|
||||||
|
} else {
|
||||||
|
return [...prevState, file]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCollectionSelect = (
|
||||||
|
collection: Tables<"collections">,
|
||||||
|
setSelectedAssistantCollections: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"collections">[]>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
setSelectedAssistantCollections(prevState => {
|
||||||
|
const isCollectionAlreadySelected = prevState.find(
|
||||||
|
selectedCollection => selectedCollection.id === collection.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isCollectionAlreadySelected) {
|
||||||
|
return prevState.filter(
|
||||||
|
selectedCollection => selectedCollection.id !== collection.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return [...prevState, collection]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToolSelect = (
|
||||||
|
tool: Tables<"tools">,
|
||||||
|
setSelectedAssistantTools: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"tools">[]>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
setSelectedAssistantTools(prevState => {
|
||||||
|
const isToolAlreadySelected = prevState.find(
|
||||||
|
selectedTool => selectedTool.id === tool.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isToolAlreadySelected) {
|
||||||
|
return prevState.filter(selectedTool => selectedTool.id !== tool.id)
|
||||||
|
} else {
|
||||||
|
return [...prevState, tool]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) return null
|
||||||
|
if (!selectedWorkspace) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
item={assistant}
|
||||||
|
contentType="assistants"
|
||||||
|
isTyping={isTyping}
|
||||||
|
icon={
|
||||||
|
imageLink ? (
|
||||||
|
<Image
|
||||||
|
style={{ width: "30px", height: "30px" }}
|
||||||
|
className="rounded"
|
||||||
|
src={imageLink}
|
||||||
|
alt={assistant.name}
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconRobotFace
|
||||||
|
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateState={{
|
||||||
|
image: selectedImage,
|
||||||
|
user_id: assistant.user_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
include_profile_context: assistantChatSettings.includeProfileContext,
|
||||||
|
include_workspace_instructions:
|
||||||
|
assistantChatSettings.includeWorkspaceInstructions,
|
||||||
|
context_length: assistantChatSettings.contextLength,
|
||||||
|
model: assistantChatSettings.model,
|
||||||
|
image_path: assistant.image_path,
|
||||||
|
prompt: assistantChatSettings.prompt,
|
||||||
|
temperature: assistantChatSettings.temperature
|
||||||
|
}}
|
||||||
|
renderInputs={(renderState: {
|
||||||
|
startingAssistantFiles: Tables<"files">[]
|
||||||
|
setStartingAssistantFiles: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"files">[]>
|
||||||
|
>
|
||||||
|
selectedAssistantFiles: Tables<"files">[]
|
||||||
|
setSelectedAssistantFiles: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"files">[]>
|
||||||
|
>
|
||||||
|
startingAssistantCollections: Tables<"collections">[]
|
||||||
|
setStartingAssistantCollections: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"collections">[]>
|
||||||
|
>
|
||||||
|
selectedAssistantCollections: Tables<"collections">[]
|
||||||
|
setSelectedAssistantCollections: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"collections">[]>
|
||||||
|
>
|
||||||
|
startingAssistantTools: Tables<"tools">[]
|
||||||
|
setStartingAssistantTools: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"tools">[]>
|
||||||
|
>
|
||||||
|
selectedAssistantTools: Tables<"tools">[]
|
||||||
|
setSelectedAssistantTools: React.Dispatch<
|
||||||
|
React.SetStateAction<Tables<"tools">[]>
|
||||||
|
>
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Assistant name..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={ASSISTANT_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Assistant description..."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={ASSISTANT_DESCRIPTION_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Image</Label>
|
||||||
|
|
||||||
|
<ImagePicker
|
||||||
|
src={imageLink}
|
||||||
|
image={selectedImage}
|
||||||
|
onSrcChange={setImageLink}
|
||||||
|
onImageChange={setSelectedImage}
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatSettingsForm
|
||||||
|
chatSettings={assistantChatSettings as any}
|
||||||
|
onChangeChatSettings={setAssistantChatSettings}
|
||||||
|
useAdvancedDropdown={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label>Files & Collections</Label>
|
||||||
|
|
||||||
|
<AssistantRetrievalSelect
|
||||||
|
selectedAssistantRetrievalItems={
|
||||||
|
[
|
||||||
|
...renderState.selectedAssistantFiles,
|
||||||
|
...renderState.selectedAssistantCollections
|
||||||
|
].length === 0
|
||||||
|
? [
|
||||||
|
...renderState.startingAssistantFiles,
|
||||||
|
...renderState.startingAssistantCollections
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
...renderState.startingAssistantFiles.filter(
|
||||||
|
startingFile =>
|
||||||
|
![
|
||||||
|
...renderState.selectedAssistantFiles,
|
||||||
|
...renderState.selectedAssistantCollections
|
||||||
|
].some(
|
||||||
|
selectedFile => selectedFile.id === startingFile.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...renderState.selectedAssistantFiles.filter(
|
||||||
|
selectedFile =>
|
||||||
|
!renderState.startingAssistantFiles.some(
|
||||||
|
startingFile => startingFile.id === selectedFile.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...renderState.startingAssistantCollections.filter(
|
||||||
|
startingCollection =>
|
||||||
|
![
|
||||||
|
...renderState.selectedAssistantFiles,
|
||||||
|
...renderState.selectedAssistantCollections
|
||||||
|
].some(
|
||||||
|
selectedCollection =>
|
||||||
|
selectedCollection.id === startingCollection.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...renderState.selectedAssistantCollections.filter(
|
||||||
|
selectedCollection =>
|
||||||
|
!renderState.startingAssistantCollections.some(
|
||||||
|
startingCollection =>
|
||||||
|
startingCollection.id === selectedCollection.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onAssistantRetrievalItemsSelect={item =>
|
||||||
|
"type" in item
|
||||||
|
? handleFileSelect(
|
||||||
|
item,
|
||||||
|
renderState.setSelectedAssistantFiles
|
||||||
|
)
|
||||||
|
: handleCollectionSelect(
|
||||||
|
item,
|
||||||
|
renderState.setSelectedAssistantCollections
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tools</Label>
|
||||||
|
|
||||||
|
<AssistantToolSelect
|
||||||
|
selectedAssistantTools={
|
||||||
|
renderState.selectedAssistantTools.length === 0
|
||||||
|
? renderState.startingAssistantTools
|
||||||
|
: [
|
||||||
|
...renderState.startingAssistantTools.filter(
|
||||||
|
startingTool =>
|
||||||
|
!renderState.selectedAssistantTools.some(
|
||||||
|
selectedTool => selectedTool.id === startingTool.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...renderState.selectedAssistantTools.filter(
|
||||||
|
selectedTool =>
|
||||||
|
!renderState.startingAssistantTools.some(
|
||||||
|
startingTool => startingTool.id === selectedTool.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onAssistantToolsSelect={tool =>
|
||||||
|
handleToolSelect(tool, renderState.setSelectedAssistantTools)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import {
|
||||||
|
IconBooks,
|
||||||
|
IconChevronDown,
|
||||||
|
IconCircleCheckFilled
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { FileIcon } from "lucide-react"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface AssistantRetrievalSelectProps {
|
||||||
|
selectedAssistantRetrievalItems: Tables<"files">[] | Tables<"collections">[]
|
||||||
|
onAssistantRetrievalItemsSelect: (
|
||||||
|
item: Tables<"files"> | Tables<"collections">
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssistantRetrievalSelect: FC<AssistantRetrievalSelectProps> = ({
|
||||||
|
selectedAssistantRetrievalItems,
|
||||||
|
onAssistantRetrievalItemsSelect
|
||||||
|
}) => {
|
||||||
|
const { files, collections } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, 100) // FIX: hacky
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleItemSelect = (item: Tables<"files"> | Tables<"collections">) => {
|
||||||
|
onAssistantRetrievalItemsSelect(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files || !collections) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={isOpen => {
|
||||||
|
setIsOpen(isOpen)
|
||||||
|
setSearch("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="bg-background w-full justify-start border-2 px-3 py-5"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="ml-2 flex items-center">
|
||||||
|
{selectedAssistantRetrievalItems.length} files selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconChevronDown />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: triggerRef.current?.offsetWidth }}
|
||||||
|
className="space-y-2 overflow-auto p-2"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Search files..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedAssistantRetrievalItems
|
||||||
|
.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(item => (
|
||||||
|
<AssistantRetrievalItemOption
|
||||||
|
key={item.id}
|
||||||
|
contentType={
|
||||||
|
item.hasOwnProperty("type") ? "files" : "collections"
|
||||||
|
}
|
||||||
|
item={item as Tables<"files"> | Tables<"collections">}
|
||||||
|
selected={selectedAssistantRetrievalItems.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === item.id
|
||||||
|
)}
|
||||||
|
onSelect={handleItemSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{files
|
||||||
|
.filter(
|
||||||
|
file =>
|
||||||
|
!selectedAssistantRetrievalItems.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === file.id
|
||||||
|
) && file.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(file => (
|
||||||
|
<AssistantRetrievalItemOption
|
||||||
|
key={file.id}
|
||||||
|
item={file}
|
||||||
|
contentType="files"
|
||||||
|
selected={selectedAssistantRetrievalItems.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === file.id
|
||||||
|
)}
|
||||||
|
onSelect={handleItemSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{collections
|
||||||
|
.filter(
|
||||||
|
collection =>
|
||||||
|
!selectedAssistantRetrievalItems.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === collection.id
|
||||||
|
) && collection.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(collection => (
|
||||||
|
<AssistantRetrievalItemOption
|
||||||
|
key={collection.id}
|
||||||
|
contentType="collections"
|
||||||
|
item={collection}
|
||||||
|
selected={selectedAssistantRetrievalItems.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === collection.id
|
||||||
|
)}
|
||||||
|
onSelect={handleItemSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantRetrievalOptionItemProps {
|
||||||
|
contentType: "files" | "collections"
|
||||||
|
item: Tables<"files"> | Tables<"collections">
|
||||||
|
selected: boolean
|
||||||
|
onSelect: (item: Tables<"files"> | Tables<"collections">) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantRetrievalItemOption: FC<AssistantRetrievalOptionItemProps> = ({
|
||||||
|
contentType,
|
||||||
|
item,
|
||||||
|
selected,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const handleSelect = () => {
|
||||||
|
onSelect(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center justify-between py-0.5 hover:opacity-50"
|
||||||
|
onClick={handleSelect}
|
||||||
|
>
|
||||||
|
<div className="flex grow items-center truncate">
|
||||||
|
{contentType === "files" ? (
|
||||||
|
<div className="mr-2 min-w-[24px]">
|
||||||
|
<FileIcon type={(item as Tables<"files">).type} size={24} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mr-2 min-w-[24px]">
|
||||||
|
<IconBooks size={24} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="truncate">{item.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<IconCircleCheckFilled size={20} className="min-w-[30px] flex-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import {
|
||||||
|
IconBolt,
|
||||||
|
IconChevronDown,
|
||||||
|
IconCircleCheckFilled
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface AssistantToolSelectProps {
|
||||||
|
selectedAssistantTools: Tables<"tools">[]
|
||||||
|
onAssistantToolsSelect: (tool: Tables<"tools">) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssistantToolSelect: FC<AssistantToolSelectProps> = ({
|
||||||
|
selectedAssistantTools,
|
||||||
|
onAssistantToolsSelect
|
||||||
|
}) => {
|
||||||
|
const { tools } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, 100) // FIX: hacky
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleToolSelect = (tool: Tables<"tools">) => {
|
||||||
|
onAssistantToolsSelect(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tools) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={isOpen => {
|
||||||
|
setIsOpen(isOpen)
|
||||||
|
setSearch("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="bg-background w-full justify-start border-2 px-3 py-5"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="ml-2 flex items-center">
|
||||||
|
{selectedAssistantTools.length} tools selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconChevronDown />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: triggerRef.current?.offsetWidth }}
|
||||||
|
className="space-y-2 overflow-auto p-2"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Search tools..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedAssistantTools
|
||||||
|
.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(tool => (
|
||||||
|
<AssistantToolItem
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
selected={selectedAssistantTools.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === tool.id
|
||||||
|
)}
|
||||||
|
onSelect={handleToolSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tools
|
||||||
|
.filter(
|
||||||
|
tool =>
|
||||||
|
!selectedAssistantTools.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === tool.id
|
||||||
|
) && tool.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(tool => (
|
||||||
|
<AssistantToolItem
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
selected={selectedAssistantTools.some(
|
||||||
|
selectedAssistantRetrieval =>
|
||||||
|
selectedAssistantRetrieval.id === tool.id
|
||||||
|
)}
|
||||||
|
onSelect={handleToolSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantToolItemProps {
|
||||||
|
tool: Tables<"tools">
|
||||||
|
selected: boolean
|
||||||
|
onSelect: (tool: Tables<"tools">) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantToolItem: FC<AssistantToolItemProps> = ({
|
||||||
|
tool,
|
||||||
|
selected,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const handleSelect = () => {
|
||||||
|
onSelect(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center justify-between py-0.5 hover:opacity-50"
|
||||||
|
onClick={handleSelect}
|
||||||
|
>
|
||||||
|
<div className="flex grow items-center truncate">
|
||||||
|
<div className="mr-2 min-w-[24px]">
|
||||||
|
<IconBolt size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="truncate">{tool.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<IconCircleCheckFilled size={20} className="min-w-[30px] flex-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item"
|
||||||
|
import { ChatSettingsForm } from "@/components/ui/chat-settings-form"
|
||||||
|
import ImagePicker from "@/components/ui/image-picker"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { ASSISTANT_DESCRIPTION_MAX, ASSISTANT_NAME_MAX } from "@/db/limits"
|
||||||
|
import { Tables, TablesInsert } from "@/supabase/types"
|
||||||
|
import { FC, useContext, useEffect, useState } from "react"
|
||||||
|
import { AssistantRetrievalSelect } from "./assistant-retrieval-select"
|
||||||
|
import { AssistantToolSelect } from "./assistant-tool-select"
|
||||||
|
|
||||||
|
interface CreateAssistantProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (isOpen: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateAssistant: FC<CreateAssistantProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange
|
||||||
|
}) => {
|
||||||
|
const { profile, selectedWorkspace } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [assistantChatSettings, setAssistantChatSettings] = useState({
|
||||||
|
model: selectedWorkspace?.default_model,
|
||||||
|
prompt: selectedWorkspace?.default_prompt,
|
||||||
|
temperature: selectedWorkspace?.default_temperature,
|
||||||
|
contextLength: selectedWorkspace?.default_context_length,
|
||||||
|
includeProfileContext: false,
|
||||||
|
includeWorkspaceInstructions: false,
|
||||||
|
embeddingsProvider: selectedWorkspace?.embeddings_provider
|
||||||
|
})
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null)
|
||||||
|
const [imageLink, setImageLink] = useState("")
|
||||||
|
const [selectedAssistantRetrievalItems, setSelectedAssistantRetrievalItems] =
|
||||||
|
useState<Tables<"files">[] | Tables<"collections">[]>([])
|
||||||
|
const [selectedAssistantToolItems, setSelectedAssistantToolItems] = useState<
|
||||||
|
Tables<"tools">[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAssistantChatSettings(prevSettings => {
|
||||||
|
const previousPrompt = prevSettings.prompt || ""
|
||||||
|
const previousPromptParts = previousPrompt.split(". ")
|
||||||
|
|
||||||
|
previousPromptParts[0] = name ? `You are ${name}` : ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevSettings,
|
||||||
|
prompt: previousPromptParts.join(". ")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
const handleRetrievalItemSelect = (
|
||||||
|
item: Tables<"files"> | Tables<"collections">
|
||||||
|
) => {
|
||||||
|
setSelectedAssistantRetrievalItems(prevState => {
|
||||||
|
const isItemAlreadySelected = prevState.find(
|
||||||
|
selectedItem => selectedItem.id === item.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isItemAlreadySelected) {
|
||||||
|
return prevState.filter(selectedItem => selectedItem.id !== item.id)
|
||||||
|
} else {
|
||||||
|
return [...prevState, item]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToolSelect = (item: Tables<"tools">) => {
|
||||||
|
setSelectedAssistantToolItems(prevState => {
|
||||||
|
const isItemAlreadySelected = prevState.find(
|
||||||
|
selectedItem => selectedItem.id === item.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isItemAlreadySelected) {
|
||||||
|
return prevState.filter(selectedItem => selectedItem.id !== item.id)
|
||||||
|
} else {
|
||||||
|
return [...prevState, item]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfModelIsToolCompatible = () => {
|
||||||
|
if (!assistantChatSettings.model) return false
|
||||||
|
|
||||||
|
const compatibleModels = [
|
||||||
|
"gpt-4-turbo-preview",
|
||||||
|
"gpt-4-vision-preview",
|
||||||
|
"gpt-3.5-turbo-1106",
|
||||||
|
"gpt-4"
|
||||||
|
]
|
||||||
|
const isModelCompatible = compatibleModels.includes(
|
||||||
|
assistantChatSettings.model
|
||||||
|
)
|
||||||
|
|
||||||
|
return isModelCompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) return null
|
||||||
|
if (!selectedWorkspace) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarCreateItem
|
||||||
|
contentType="assistants"
|
||||||
|
createState={
|
||||||
|
{
|
||||||
|
image: selectedImage,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
include_profile_context: assistantChatSettings.includeProfileContext,
|
||||||
|
include_workspace_instructions:
|
||||||
|
assistantChatSettings.includeWorkspaceInstructions,
|
||||||
|
context_length: assistantChatSettings.contextLength,
|
||||||
|
model: assistantChatSettings.model,
|
||||||
|
image_path: "",
|
||||||
|
prompt: assistantChatSettings.prompt,
|
||||||
|
temperature: assistantChatSettings.temperature,
|
||||||
|
embeddings_provider: assistantChatSettings.embeddingsProvider,
|
||||||
|
files: selectedAssistantRetrievalItems.filter(item =>
|
||||||
|
item.hasOwnProperty("type")
|
||||||
|
) as Tables<"files">[],
|
||||||
|
collections: selectedAssistantRetrievalItems.filter(
|
||||||
|
item => !item.hasOwnProperty("type")
|
||||||
|
) as Tables<"collections">[],
|
||||||
|
tools: selectedAssistantToolItems
|
||||||
|
} as TablesInsert<"assistants">
|
||||||
|
}
|
||||||
|
isOpen={isOpen}
|
||||||
|
isTyping={isTyping}
|
||||||
|
renderInputs={() => (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Assistant name..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={ASSISTANT_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Assistant description..."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={ASSISTANT_DESCRIPTION_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label className="flex space-x-1">
|
||||||
|
<div>Image</div>
|
||||||
|
|
||||||
|
<div className="text-xs">(optional)</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<ImagePicker
|
||||||
|
src={imageLink}
|
||||||
|
image={selectedImage}
|
||||||
|
onSrcChange={setImageLink}
|
||||||
|
onImageChange={setSelectedImage}
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatSettingsForm
|
||||||
|
chatSettings={assistantChatSettings as any}
|
||||||
|
onChangeChatSettings={setAssistantChatSettings}
|
||||||
|
useAdvancedDropdown={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label>Files & Collections</Label>
|
||||||
|
|
||||||
|
<AssistantRetrievalSelect
|
||||||
|
selectedAssistantRetrievalItems={selectedAssistantRetrievalItems}
|
||||||
|
onAssistantRetrievalItemsSelect={handleRetrievalItemSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkIfModelIsToolCompatible() ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tools</Label>
|
||||||
|
|
||||||
|
<AssistantToolSelect
|
||||||
|
selectedAssistantTools={selectedAssistantToolItems}
|
||||||
|
onAssistantToolsSelect={handleToolSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-1 font-semibold">
|
||||||
|
Model is not compatible with tools.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { ModelIcon } from "@/components/models/model-icon"
|
||||||
|
import { WithTooltip } from "@/components/ui/with-tooltip"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { LLM_LIST } from "@/lib/models/llm/llm-list"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { LLM } from "@/types"
|
||||||
|
import { IconRobotFace } from "@tabler/icons-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { FC, useContext, useRef } from "react"
|
||||||
|
import { DeleteChat } from "./delete-chat"
|
||||||
|
import { UpdateChat } from "./update-chat"
|
||||||
|
|
||||||
|
interface ChatItemProps {
|
||||||
|
chat: Tables<"chats">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatItem: FC<ChatItemProps> = ({ chat }) => {
|
||||||
|
const {
|
||||||
|
selectedWorkspace,
|
||||||
|
selectedChat,
|
||||||
|
availableLocalModels,
|
||||||
|
assistantImages,
|
||||||
|
availableOpenRouterModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams()
|
||||||
|
const isActive = params.chatid === chat.id || selectedChat?.id === chat.id
|
||||||
|
|
||||||
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!selectedWorkspace) return
|
||||||
|
return router.push(`/${selectedWorkspace.id}/chat/${chat.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.stopPropagation()
|
||||||
|
itemRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_DATA = [
|
||||||
|
...LLM_LIST,
|
||||||
|
...availableLocalModels,
|
||||||
|
...availableOpenRouterModels
|
||||||
|
].find(llm => llm.modelId === chat.model) as LLM
|
||||||
|
|
||||||
|
const assistantImage = assistantImages.find(
|
||||||
|
image => image.assistantId === chat.assistant_id
|
||||||
|
)?.base64
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={itemRef}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent focus:bg-accent group flex w-full cursor-pointer items-center rounded p-2 hover:opacity-50 focus:outline-none",
|
||||||
|
isActive && "bg-accent"
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{chat.assistant_id ? (
|
||||||
|
assistantImage ? (
|
||||||
|
<Image
|
||||||
|
style={{ width: "30px", height: "30px" }}
|
||||||
|
className="rounded"
|
||||||
|
src={assistantImage}
|
||||||
|
alt="Assistant image"
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconRobotFace
|
||||||
|
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<WithTooltip
|
||||||
|
delayDuration={200}
|
||||||
|
display={<div>{MODEL_DATA?.modelName}</div>}
|
||||||
|
trigger={
|
||||||
|
<ModelIcon provider={MODEL_DATA?.provider} height={30} width={30} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-3 flex-1 truncate text-sm font-semibold">
|
||||||
|
{chat.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
className={`ml-2 flex space-x-2 ${!isActive && "w-11 opacity-0 group-hover:opacity-100"}`}
|
||||||
|
>
|
||||||
|
<UpdateChat chat={chat} />
|
||||||
|
|
||||||
|
<DeleteChat chat={chat} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { deleteChat } from "@/db/chats"
|
||||||
|
import useHotkey from "@/lib/hooks/use-hotkey"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconTrash } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface DeleteChatProps {
|
||||||
|
chat: Tables<"chats">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteChat: FC<DeleteChatProps> = ({ chat }) => {
|
||||||
|
useHotkey("Backspace", () => setShowChatDialog(true))
|
||||||
|
|
||||||
|
const { setChats } = useContext(ChatbotUIContext)
|
||||||
|
const { handleNewChat } = useChatHandler()
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [showChatDialog, setShowChatDialog] = useState(false)
|
||||||
|
|
||||||
|
const handleDeleteChat = async () => {
|
||||||
|
await deleteChat(chat.id)
|
||||||
|
|
||||||
|
setChats(prevState => prevState.filter(c => c.id !== chat.id))
|
||||||
|
|
||||||
|
setShowChatDialog(false)
|
||||||
|
|
||||||
|
handleNewChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showChatDialog} onOpenChange={setShowChatDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<IconTrash className="hover:opacity-50" size={18} />
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent onKeyDown={handleKeyDown}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {chat.name}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this chat?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowChatDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteChat}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { updateChat } from "@/db/chats"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { IconEdit } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface UpdateChatProps {
|
||||||
|
chat: Tables<"chats">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateChat: FC<UpdateChatProps> = ({ chat }) => {
|
||||||
|
const { setChats } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [showChatDialog, setShowChatDialog] = useState(false)
|
||||||
|
const [name, setName] = useState(chat.name)
|
||||||
|
|
||||||
|
const handleUpdateChat = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const updatedChat = await updateChat(chat.id, {
|
||||||
|
name
|
||||||
|
})
|
||||||
|
setChats(prevState =>
|
||||||
|
prevState.map(c => (c.id === chat.id ? updatedChat : c))
|
||||||
|
)
|
||||||
|
|
||||||
|
setShowChatDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showChatDialog} onOpenChange={setShowChatDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<IconEdit className="hover:opacity-50" size={18} />
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent onKeyDown={handleKeyDown}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Chat</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowChatDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button ref={buttonRef} onClick={handleUpdateChat}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { FileIcon } from "@/components/ui/file-icon"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { CollectionFile } from "@/types"
|
||||||
|
import { IconChevronDown, IconCircleCheckFilled } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
interface CollectionFileSelectProps {
|
||||||
|
selectedCollectionFiles: CollectionFile[]
|
||||||
|
onCollectionFileSelect: (file: CollectionFile) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollectionFileSelect: FC<CollectionFileSelectProps> = ({
|
||||||
|
selectedCollectionFiles,
|
||||||
|
onCollectionFileSelect
|
||||||
|
}) => {
|
||||||
|
const { files } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, 100) // FIX: hacky
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleFileSelect = (file: CollectionFile) => {
|
||||||
|
onCollectionFileSelect(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={isOpen => {
|
||||||
|
setIsOpen(isOpen)
|
||||||
|
setSearch("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="bg-background w-full justify-start border-2 px-3 py-5"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="ml-2 flex items-center">
|
||||||
|
{selectedCollectionFiles.length} files selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconChevronDown />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: triggerRef.current?.offsetWidth }}
|
||||||
|
className="space-y-2 overflow-auto p-2"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Search files..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedCollectionFiles
|
||||||
|
.filter(file =>
|
||||||
|
file.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(file => (
|
||||||
|
<CollectionFileItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
selected={selectedCollectionFiles.some(
|
||||||
|
selectedCollectionFile => selectedCollectionFile.id === file.id
|
||||||
|
)}
|
||||||
|
onSelect={handleFileSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{files
|
||||||
|
.filter(
|
||||||
|
file =>
|
||||||
|
!selectedCollectionFiles.some(
|
||||||
|
selectedCollectionFile => selectedCollectionFile.id === file.id
|
||||||
|
) && file.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(file => (
|
||||||
|
<CollectionFileItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
selected={selectedCollectionFiles.some(
|
||||||
|
selectedCollectionFile => selectedCollectionFile.id === file.id
|
||||||
|
)}
|
||||||
|
onSelect={handleFileSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectionFileItemProps {
|
||||||
|
file: CollectionFile
|
||||||
|
selected: boolean
|
||||||
|
onSelect: (file: CollectionFile) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionFileItem: FC<CollectionFileItemProps> = ({
|
||||||
|
file,
|
||||||
|
selected,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const handleSelect = () => {
|
||||||
|
onSelect(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center justify-between py-0.5 hover:opacity-50"
|
||||||
|
onClick={handleSelect}
|
||||||
|
>
|
||||||
|
<div className="flex grow items-center truncate">
|
||||||
|
<div className="mr-2 min-w-[24px]">
|
||||||
|
<FileIcon type={file.type} size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="truncate">{file.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<IconCircleCheckFilled size={20} className="min-w-[30px] flex-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { COLLECTION_DESCRIPTION_MAX, COLLECTION_NAME_MAX } from "@/db/limits"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { CollectionFile } from "@/types"
|
||||||
|
import { IconBooks } from "@tabler/icons-react"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import { SidebarItem } from "../all/sidebar-display-item"
|
||||||
|
import { CollectionFileSelect } from "./collection-file-select"
|
||||||
|
|
||||||
|
interface CollectionItemProps {
|
||||||
|
collection: Tables<"collections">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollectionItem: FC<CollectionItemProps> = ({ collection }) => {
|
||||||
|
const [name, setName] = useState(collection.name)
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [description, setDescription] = useState(collection.description)
|
||||||
|
|
||||||
|
const handleFileSelect = (
|
||||||
|
file: CollectionFile,
|
||||||
|
setSelectedCollectionFiles: React.Dispatch<
|
||||||
|
React.SetStateAction<CollectionFile[]>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
setSelectedCollectionFiles(prevState => {
|
||||||
|
const isFileAlreadySelected = prevState.find(
|
||||||
|
selectedFile => selectedFile.id === file.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isFileAlreadySelected) {
|
||||||
|
return prevState.filter(selectedFile => selectedFile.id !== file.id)
|
||||||
|
} else {
|
||||||
|
return [...prevState, file]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
item={collection}
|
||||||
|
isTyping={isTyping}
|
||||||
|
contentType="collections"
|
||||||
|
icon={<IconBooks size={30} />}
|
||||||
|
updateState={{
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
}}
|
||||||
|
renderInputs={(renderState: {
|
||||||
|
startingCollectionFiles: CollectionFile[]
|
||||||
|
setStartingCollectionFiles: React.Dispatch<
|
||||||
|
React.SetStateAction<CollectionFile[]>
|
||||||
|
>
|
||||||
|
selectedCollectionFiles: CollectionFile[]
|
||||||
|
setSelectedCollectionFiles: React.Dispatch<
|
||||||
|
React.SetStateAction<CollectionFile[]>
|
||||||
|
>
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Files</Label>
|
||||||
|
|
||||||
|
<CollectionFileSelect
|
||||||
|
selectedCollectionFiles={
|
||||||
|
renderState.selectedCollectionFiles.length === 0
|
||||||
|
? renderState.startingCollectionFiles
|
||||||
|
: [
|
||||||
|
...renderState.startingCollectionFiles.filter(
|
||||||
|
startingFile =>
|
||||||
|
!renderState.selectedCollectionFiles.some(
|
||||||
|
selectedFile =>
|
||||||
|
selectedFile.id === startingFile.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...renderState.selectedCollectionFiles.filter(
|
||||||
|
selectedFile =>
|
||||||
|
!renderState.startingCollectionFiles.some(
|
||||||
|
startingFile =>
|
||||||
|
startingFile.id === selectedFile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onCollectionFileSelect={file =>
|
||||||
|
handleFileSelect(file, renderState.setSelectedCollectionFiles)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Collection name..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={COLLECTION_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Description</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Collection description..."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={COLLECTION_DESCRIPTION_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { COLLECTION_DESCRIPTION_MAX, COLLECTION_NAME_MAX } from "@/db/limits"
|
||||||
|
import { TablesInsert } from "@/supabase/types"
|
||||||
|
import { CollectionFile } from "@/types"
|
||||||
|
import { FC, useContext, useState } from "react"
|
||||||
|
import { CollectionFileSelect } from "./collection-file-select"
|
||||||
|
|
||||||
|
interface CreateCollectionProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (isOpen: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateCollection: FC<CreateCollectionProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange
|
||||||
|
}) => {
|
||||||
|
const { profile, selectedWorkspace } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [selectedCollectionFiles, setSelectedCollectionFiles] = useState<
|
||||||
|
CollectionFile[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
const handleFileSelect = (file: CollectionFile) => {
|
||||||
|
setSelectedCollectionFiles(prevState => {
|
||||||
|
const isFileAlreadySelected = prevState.find(
|
||||||
|
selectedFile => selectedFile.id === file.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isFileAlreadySelected) {
|
||||||
|
return prevState.filter(selectedFile => selectedFile.id !== file.id)
|
||||||
|
} else {
|
||||||
|
return [...prevState, file]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) return null
|
||||||
|
if (!selectedWorkspace) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarCreateItem
|
||||||
|
contentType="collections"
|
||||||
|
createState={
|
||||||
|
{
|
||||||
|
collectionFiles: selectedCollectionFiles.map(file => ({
|
||||||
|
user_id: profile.user_id,
|
||||||
|
collection_id: "",
|
||||||
|
file_id: file.id
|
||||||
|
})),
|
||||||
|
user_id: profile.user_id,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
} as TablesInsert<"collections">
|
||||||
|
}
|
||||||
|
isOpen={isOpen}
|
||||||
|
isTyping={isTyping}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
renderInputs={() => (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Files</Label>
|
||||||
|
|
||||||
|
<CollectionFileSelect
|
||||||
|
selectedCollectionFiles={selectedCollectionFiles}
|
||||||
|
onCollectionFileSelect={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Collection name..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={COLLECTION_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Description</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Collection description..."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={COLLECTION_DESCRIPTION_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { ACCEPTED_FILE_TYPES } from "@/components/chat/chat-hooks/use-select-file-handler"
|
||||||
|
import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { FILE_DESCRIPTION_MAX, FILE_NAME_MAX } from "@/db/limits"
|
||||||
|
import { TablesInsert } from "@/supabase/types"
|
||||||
|
import { FC, useContext, useState } from "react"
|
||||||
|
|
||||||
|
interface CreateFileProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (isOpen: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateFile: FC<CreateFileProps> = ({ isOpen, onOpenChange }) => {
|
||||||
|
const { profile, selectedWorkspace } = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const handleSelectedFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files) return
|
||||||
|
|
||||||
|
const file = e.target.files[0]
|
||||||
|
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setSelectedFile(file)
|
||||||
|
const fileNameWithoutExtension = file.name.split(".").slice(0, -1).join(".")
|
||||||
|
setName(fileNameWithoutExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) return null
|
||||||
|
if (!selectedWorkspace) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarCreateItem
|
||||||
|
contentType="files"
|
||||||
|
createState={
|
||||||
|
{
|
||||||
|
file: selectedFile,
|
||||||
|
user_id: profile.user_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
file_path: "",
|
||||||
|
size: selectedFile?.size || 0,
|
||||||
|
tokens: 0,
|
||||||
|
type: selectedFile?.type || 0
|
||||||
|
} as TablesInsert<"files">
|
||||||
|
}
|
||||||
|
isOpen={isOpen}
|
||||||
|
isTyping={isTyping}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
renderInputs={() => (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>File</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
onChange={handleSelectedFile}
|
||||||
|
accept={ACCEPTED_FILE_TYPES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="File name..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={FILE_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Description</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="File description..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={FILE_DESCRIPTION_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { FileIcon } from "@/components/ui/file-icon"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { FILE_DESCRIPTION_MAX, FILE_NAME_MAX } from "@/db/limits"
|
||||||
|
import { getFileFromStorage } from "@/db/storage/files"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import { SidebarItem } from "../all/sidebar-display-item"
|
||||||
|
|
||||||
|
interface FileItemProps {
|
||||||
|
file: Tables<"files">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileItem: FC<FileItemProps> = ({ file }) => {
|
||||||
|
const [name, setName] = useState(file.name)
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [description, setDescription] = useState(file.description)
|
||||||
|
|
||||||
|
const getLinkAndView = async () => {
|
||||||
|
const link = await getFileFromStorage(file.file_path)
|
||||||
|
window.open(link, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
item={file}
|
||||||
|
isTyping={isTyping}
|
||||||
|
contentType="files"
|
||||||
|
icon={<FileIcon type={file.type} size={30} />}
|
||||||
|
updateState={{ name, description }}
|
||||||
|
renderInputs={() => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer underline hover:opacity-50"
|
||||||
|
onClick={getLinkAndView}
|
||||||
|
>
|
||||||
|
View {file.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<div>{file.type}</div>
|
||||||
|
|
||||||
|
<div>{formatFileSize(file.size)}</div>
|
||||||
|
|
||||||
|
<div>{file.tokens.toLocaleString()} tokens</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="File name..."
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={FILE_NAME_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Description</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="File description..."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={FILE_DESCRIPTION_MAX}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatFileSize = (sizeInBytes: number): string => {
|
||||||
|
let size = sizeInBytes
|
||||||
|
let unit = "bytes"
|
||||||
|
|
||||||
|
if (size >= 1024) {
|
||||||
|
size /= 1024
|
||||||
|
unit = "KB"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size >= 1024) {
|
||||||
|
size /= 1024
|
||||||
|
unit = "MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size >= 1024) {
|
||||||
|
size /= 1024
|
||||||
|
unit = "GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)} ${unit}`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { ChatbotUIContext } from "@/context/context"
|
||||||
|
import { deleteFolder } from "@/db/folders"
|
||||||
|
import { supabase } from "@/lib/supabase/browser-client"
|
||||||
|
import { Tables } from "@/supabase/types"
|
||||||
|
import { ContentType } from "@/types"
|
||||||
|
import { IconTrash } from "@tabler/icons-react"
|
||||||
|
import { FC, useContext, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface DeleteFolderProps {
|
||||||
|
folder: Tables<"folders">
|
||||||
|
contentType: ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteFolder: FC<DeleteFolderProps> = ({
|
||||||
|
folder,
|
||||||
|
contentType
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
setChats,
|
||||||
|
setFolders,
|
||||||
|
setPresets,
|
||||||
|
setPrompts,
|
||||||
|
setFiles,
|
||||||
|
setCollections,
|
||||||
|
setAssistants,
|
||||||
|
setTools,
|
||||||
|
setModels
|
||||||
|
} = useContext(ChatbotUIContext)
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
||||||
|
|
||||||
|
const stateUpdateFunctions = {
|
||||||
|
chats: setChats,
|
||||||
|
presets: setPresets,
|
||||||
|
prompts: setPrompts,
|
||||||
|
files: setFiles,
|
||||||
|
collections: setCollections,
|
||||||
|
assistants: setAssistants,
|
||||||
|
tools: setTools,
|
||||||
|
models: setModels
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFolderOnly = async () => {
|
||||||
|
await deleteFolder(folder.id)
|
||||||
|
|
||||||
|
setFolders(prevState => prevState.filter(c => c.id !== folder.id))
|
||||||
|
|
||||||
|
setShowFolderDialog(false)
|
||||||
|
|
||||||
|
const setStateFunction = stateUpdateFunctions[contentType]
|
||||||
|
|
||||||
|
if (!setStateFunction) return
|
||||||
|
|
||||||
|
setStateFunction((prevItems: any) =>
|
||||||
|
prevItems.map((item: any) => {
|
||||||
|
if (item.folder_id === folder.id) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
folder_id: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFolderAndItems = async () => {
|
||||||
|
const setStateFunction = stateUpdateFunctions[contentType]
|
||||||
|
|
||||||
|
if (!setStateFunction) return
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from(contentType)
|
||||||
|
.delete()
|
||||||
|
.eq("folder_id", folder.id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStateFunction((prevItems: any) =>
|
||||||
|
prevItems.filter((item: any) => item.folder_id !== folder.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handleDeleteFolderOnly()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<IconTrash className="hover:opacity-50" size={18} />
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="min-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {folder.name}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this folder?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowFolderDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteFolderAndItems}
|
||||||
|
>
|
||||||
|
Delete Folder & Included Items
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteFolderOnly}
|
||||||
|
>
|
||||||
|
Delete Folder Only
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue