diff --git a/.env.local b/.env.local new file mode 100644 index 0000000000..8144cbad6f --- /dev/null +++ b/.env.local @@ -0,0 +1,36 @@ +# Supabase Public +NEXT_PUBLIC_SUPABASE_URL=https://xfvdikreoaorkfbhibgu.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 + +# Supabase Private +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhmdmRpa3Jlb2FvcmtmYmhpYmd1Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTcyODIwNzk1MywiZXhwIjoyMDQzNzgzOTUzfQ.LN1n_e2DTmDiw4WRibdTpGbHKB5SwGKEzkoh_lAUUek + +# 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 \ No newline at end of file diff --git a/chatbot-ui-main/.env.local.example b/chatbot-ui-main/.env.local.example new file mode 100644 index 0000000000..7cff1a7118 --- /dev/null +++ b/chatbot-ui-main/.env.local.example @@ -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 \ No newline at end of file diff --git a/chatbot-ui-main/.eslintrc.json b/chatbot-ui-main/.eslintrc.json new file mode 100644 index 0000000000..6ec5479fec --- /dev/null +++ b/chatbot-ui-main/.eslintrc.json @@ -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" + } + ] +} diff --git a/chatbot-ui-main/.github/funding.yaml b/chatbot-ui-main/.github/funding.yaml new file mode 100644 index 0000000000..d6494ef5b2 --- /dev/null +++ b/chatbot-ui-main/.github/funding.yaml @@ -0,0 +1,3 @@ +# If you find my open-source work helpful, please consider sponsoring me! + +github: mckaywrigley diff --git a/chatbot-ui-main/.gitignore b/chatbot-ui-main/.gitignore new file mode 100644 index 0000000000..f56e22be97 --- /dev/null +++ b/chatbot-ui-main/.gitignore @@ -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 diff --git a/chatbot-ui-main/.husky/pre-commit b/chatbot-ui-main/.husky/pre-commit new file mode 100644 index 0000000000..3ebdff6310 --- /dev/null +++ b/chatbot-ui-main/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +. "$(dirname -- "$0")/_/husky.sh" + +npm run lint:fix && npm run format:write && git add . diff --git a/chatbot-ui-main/.nvmrc b/chatbot-ui-main/.nvmrc new file mode 100644 index 0000000000..7ea6a59d34 --- /dev/null +++ b/chatbot-ui-main/.nvmrc @@ -0,0 +1 @@ +v20.11.0 diff --git a/chatbot-ui-main/README.md b/chatbot-ui-main/README.md new file mode 100644 index 0000000000..1d50749974 --- /dev/null +++ b/chatbot-ui-main/README.md @@ -0,0 +1,292 @@ +# Chatbot UI + +The open-source AI chat app for everyone. + +Chatbot UI + +## 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//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 +``` + +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) diff --git a/chatbot-ui-main/__tests__/lib/openapi-conversion.test.ts b/chatbot-ui-main/__tests__/lib/openapi-conversion.test.ts new file mode 100644 index 0000000000..7505590faf --- /dev/null +++ b/chatbot-ui-main/__tests__/lib/openapi-conversion.test.ts @@ -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) + }) +}) diff --git a/chatbot-ui-main/__tests__/playwright-test/.gitignore b/chatbot-ui-main/__tests__/playwright-test/.gitignore new file mode 100644 index 0000000000..68c5d18f00 --- /dev/null +++ b/chatbot-ui-main/__tests__/playwright-test/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/chatbot-ui-main/__tests__/playwright-test/package-lock.json b/chatbot-ui-main/__tests__/playwright-test/package-lock.json new file mode 100644 index 0000000000..6b2675df8d --- /dev/null +++ b/chatbot-ui-main/__tests__/playwright-test/package-lock.json @@ -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 + } + } +} diff --git a/chatbot-ui-main/__tests__/playwright-test/package.json b/chatbot-ui-main/__tests__/playwright-test/package.json new file mode 100644 index 0000000000..286e30dd1b --- /dev/null +++ b/chatbot-ui-main/__tests__/playwright-test/package.json @@ -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" + } +} diff --git a/chatbot-ui-main/__tests__/playwright-test/playwright.config.ts b/chatbot-ui-main/__tests__/playwright-test/playwright.config.ts new file mode 100644 index 0000000000..301801ee1d --- /dev/null +++ b/chatbot-ui-main/__tests__/playwright-test/playwright.config.ts @@ -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, + // }, +}); diff --git a/chatbot-ui-main/__tests__/playwright-test/tests/login.spec.ts b/chatbot-ui-main/__tests__/playwright-test/tests/login.spec.ts new file mode 100644 index 0000000000..0b69a70273 --- /dev/null +++ b/chatbot-ui-main/__tests__/playwright-test/tests/login.spec.ts @@ -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 \ No newline at end of file diff --git a/chatbot-ui-main/app/[locale]/[workspaceid]/chat/[chatid]/page.tsx b/chatbot-ui-main/app/[locale]/[workspaceid]/chat/[chatid]/page.tsx new file mode 100644 index 0000000000..30d082e82d --- /dev/null +++ b/chatbot-ui-main/app/[locale]/[workspaceid]/chat/[chatid]/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { ChatUI } from "@/components/chat/chat-ui" + +export default function ChatIDPage() { + return +} diff --git a/chatbot-ui-main/app/[locale]/[workspaceid]/chat/page.tsx b/chatbot-ui-main/app/[locale]/[workspaceid]/chat/page.tsx new file mode 100644 index 0000000000..15e189e691 --- /dev/null +++ b/chatbot-ui-main/app/[locale]/[workspaceid]/chat/page.tsx @@ -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 ? ( +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ) : ( + + )} + + ) +} diff --git a/chatbot-ui-main/app/[locale]/[workspaceid]/layout.tsx b/chatbot-ui-main/app/[locale]/[workspaceid]/layout.tsx new file mode 100644 index 0000000000..227a6e7903 --- /dev/null +++ b/chatbot-ui-main/app/[locale]/[workspaceid]/layout.tsx @@ -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 + } + + return {children} +} diff --git a/chatbot-ui-main/app/[locale]/[workspaceid]/page.tsx b/chatbot-ui-main/app/[locale]/[workspaceid]/page.tsx new file mode 100644 index 0000000000..b43e8d5eb8 --- /dev/null +++ b/chatbot-ui-main/app/[locale]/[workspaceid]/page.tsx @@ -0,0 +1,14 @@ +"use client" + +import { ChatbotUIContext } from "@/context/context" +import { useContext } from "react" + +export default function WorkspacePage() { + const { selectedWorkspace } = useContext(ChatbotUIContext) + + return ( +
+
{selectedWorkspace?.name}
+
+ ) +} diff --git a/chatbot-ui-main/app/[locale]/globals.css b/chatbot-ui-main/app/[locale]/globals.css new file mode 100644 index 0000000000..c0d1efcb5a --- /dev/null +++ b/chatbot-ui-main/app/[locale]/globals.css @@ -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; + } +} diff --git a/chatbot-ui-main/app/[locale]/help/page.tsx b/chatbot-ui-main/app/[locale]/help/page.tsx new file mode 100644 index 0000000000..c1753f460b --- /dev/null +++ b/chatbot-ui-main/app/[locale]/help/page.tsx @@ -0,0 +1,7 @@ +export default function HelpPage() { + return ( +
+
Help under construction.
+
+ ) +} diff --git a/chatbot-ui-main/app/[locale]/layout.tsx b/chatbot-ui-main/app/[locale]/layout.tsx new file mode 100644 index 0000000000..474058a910 --- /dev/null +++ b/chatbot-ui-main/app/[locale]/layout.tsx @@ -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( + 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 ( + + + + + +
+ {session ? {children} : children} +
+
+
+ + + ) +} diff --git a/chatbot-ui-main/app/[locale]/loading.tsx b/chatbot-ui-main/app/[locale]/loading.tsx new file mode 100644 index 0000000000..4cfc63fdec --- /dev/null +++ b/chatbot-ui-main/app/[locale]/loading.tsx @@ -0,0 +1,9 @@ +import { IconLoader2 } from "@tabler/icons-react" + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/chatbot-ui-main/app/[locale]/login/page.tsx b/chatbot-ui-main/app/[locale]/login/page.tsx new file mode 100644 index 0000000000..527422f65a --- /dev/null +++ b/chatbot-ui-main/app/[locale]/login/page.tsx @@ -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( + 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(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 ( +
+
+ + + + + + + + + + Login + + + + Sign Up + + +
+ Forgot your password? + +
+ + {searchParams?.message && ( +

+ {searchParams.message} +

+ )} + +
+ ) +} diff --git a/chatbot-ui-main/app/[locale]/login/password/page.tsx b/chatbot-ui-main/app/[locale]/login/password/page.tsx new file mode 100644 index 0000000000..e00cca3f65 --- /dev/null +++ b/chatbot-ui-main/app/[locale]/login/password/page.tsx @@ -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 +} diff --git a/chatbot-ui-main/app/[locale]/page.tsx b/chatbot-ui-main/app/[locale]/page.tsx new file mode 100644 index 0000000000..a5a0fc7daf --- /dev/null +++ b/chatbot-ui-main/app/[locale]/page.tsx @@ -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 ( +
+
+ +
+ +
Chatbot UI
+ + + Start Chatting + + +
+ ) +} diff --git a/chatbot-ui-main/app/[locale]/setup/page.tsx b/chatbot-ui-main/app/[locale]/setup/page.tsx new file mode 100644 index 0000000000..700e8e94f1 --- /dev/null +++ b/chatbot-ui-main/app/[locale]/setup/page.tsx @@ -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 ( + + + + ) + + // API Step + case 2: + return ( + + + + ) + + // Finish Step + case 3: + return ( + + + + ) + default: + return null + } + } + + if (loading) { + return null + } + + return ( +
+ {renderStep(currentStep)} +
+ ) +} diff --git a/chatbot-ui-main/app/api/assistants/openai/route.ts b/chatbot-ui-main/app/api/assistants/openai/route.ts new file mode 100644 index 0000000000..7e16e52b5e --- /dev/null +++ b/chatbot-ui-main/app/api/assistants/openai/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/anthropic/route.ts b/chatbot-ui-main/app/api/chat/anthropic/route.ts new file mode 100644 index 0000000000..4f6242f0b1 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/anthropic/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/azure/route.ts b/chatbot-ui-main/app/api/chat/azure/route.ts new file mode 100644 index 0000000000..642eb771fe --- /dev/null +++ b/chatbot-ui-main/app/api/chat/azure/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/custom/route.ts b/chatbot-ui-main/app/api/chat/custom/route.ts new file mode 100644 index 0000000000..2c8e7c850b --- /dev/null +++ b/chatbot-ui-main/app/api/chat/custom/route.ts @@ -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( + 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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/google/route.ts b/chatbot-ui-main/app/api/chat/google/route.ts new file mode 100644 index 0000000000..ad79139646 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/google/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/groq/route.ts b/chatbot-ui-main/app/api/chat/groq/route.ts new file mode 100644 index 0000000000..653de00d6e --- /dev/null +++ b/chatbot-ui-main/app/api/chat/groq/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/mistral/route.ts b/chatbot-ui-main/app/api/chat/mistral/route.ts new file mode 100644 index 0000000000..5153ca6c46 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/mistral/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/openai/route.ts b/chatbot-ui-main/app/api/chat/openai/route.ts new file mode 100644 index 0000000000..a0f8ad0c93 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/openai/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/openrouter/route.ts b/chatbot-ui-main/app/api/chat/openrouter/route.ts new file mode 100644 index 0000000000..34a8a74c07 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/openrouter/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/perplexity/route.ts b/chatbot-ui-main/app/api/chat/perplexity/route.ts new file mode 100644 index 0000000000..db700a2136 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/perplexity/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/chat/tools/route.ts b/chatbot-ui-main/app/api/chat/tools/route.ts new file mode 100644 index 0000000000..752df25110 --- /dev/null +++ b/chatbot-ui-main/app/api/chat/tools/route.ts @@ -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, 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 + }) + } +} diff --git a/chatbot-ui-main/app/api/command/route.ts b/chatbot-ui-main/app/api/command/route.ts new file mode 100644 index 0000000000..b10df7bd17 --- /dev/null +++ b/chatbot-ui-main/app/api/command/route.ts @@ -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 + }) + } +} diff --git a/chatbot-ui-main/app/api/keys/route.ts b/chatbot-ui-main/app/api/keys/route.ts new file mode 100644 index 0000000000..c0f8e38b80 --- /dev/null +++ b/chatbot-ui-main/app/api/keys/route.ts @@ -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 = { + 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 + >((acc, provider) => { + const key = envKeyMap[provider] + + if (key) { + acc[provider] = isUsingEnvironmentKey(key as EnvKey) + } + return acc + }, {}) + + return createResponse({ isUsingEnvKeyMap }, 200) +} diff --git a/chatbot-ui-main/app/api/retrieval/process/docx/route.ts b/chatbot-ui-main/app/api/retrieval/process/docx/route.ts new file mode 100644 index 0000000000..cea3d7a610 --- /dev/null +++ b/chatbot-ui-main/app/api/retrieval/process/docx/route.ts @@ -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( + 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 + }) + } +} diff --git a/chatbot-ui-main/app/api/retrieval/process/route.ts b/chatbot-ui-main/app/api/retrieval/process/route.ts new file mode 100644 index 0000000000..f0221aa4ed --- /dev/null +++ b/chatbot-ui-main/app/api/retrieval/process/route.ts @@ -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( + 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 + }) + } +} diff --git a/chatbot-ui-main/app/api/retrieval/retrieve/route.ts b/chatbot-ui-main/app/api/retrieval/retrieve/route.ts new file mode 100644 index 0000000000..9c2755a84e --- /dev/null +++ b/chatbot-ui-main/app/api/retrieval/retrieve/route.ts @@ -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( + 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 + }) + } +} diff --git a/chatbot-ui-main/app/api/username/available/route.ts b/chatbot-ui-main/app/api/username/available/route.ts new file mode 100644 index 0000000000..bf00ee07f6 --- /dev/null +++ b/chatbot-ui-main/app/api/username/available/route.ts @@ -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( + 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 + }) + } +} diff --git a/chatbot-ui-main/app/api/username/get/route.ts b/chatbot-ui-main/app/api/username/get/route.ts new file mode 100644 index 0000000000..d3cd158021 --- /dev/null +++ b/chatbot-ui-main/app/api/username/get/route.ts @@ -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( + 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 + }) + } +} diff --git a/chatbot-ui-main/app/auth/callback/route.ts b/chatbot-ui-main/app/auth/callback/route.ts new file mode 100644 index 0000000000..acf1c65daa --- /dev/null +++ b/chatbot-ui-main/app/auth/callback/route.ts @@ -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) + } +} diff --git a/chatbot-ui-main/components.json b/chatbot-ui-main/components.json new file mode 100644 index 0000000000..433a5ad510 --- /dev/null +++ b/chatbot-ui-main/components.json @@ -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" + } +} diff --git a/chatbot-ui-main/components/chat/assistant-picker.tsx b/chatbot-ui-main/components/chat/assistant-picker.tsx new file mode 100644 index 0000000000..04e2bdded0 --- /dev/null +++ b/chatbot-ui-main/components/chat/assistant-picker.tsx @@ -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 = ({}) => { + 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) => { + 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 && ( +
+ {filteredAssistants.length === 0 ? ( +
+ No matching assistants. +
+ ) : ( + <> + {filteredAssistants.map((item, index) => ( +
{ + 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.path === item.image_path + )?.url || "" + } + alt={item.name} + width={32} + height={32} + className="rounded" + /> + ) : ( + + )} + +
+
{item.name}
+ +
+ {item.description || "No description."} +
+
+
+ ))} + + )} +
+ )} + + ) +} diff --git a/chatbot-ui-main/components/chat/chat-command-input.tsx b/chatbot-ui-main/components/chat/chat-command-input.tsx new file mode 100644 index 0000000000..49afb0b4af --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-command-input.tsx @@ -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 = ({}) => { + const { + newMessageFiles, + chatFiles, + slashCommand, + isFilePickerOpen, + setIsFilePickerOpen, + hashtagCommand, + focusPrompt, + focusFile + } = useContext(ChatbotUIContext) + + const { handleSelectUserFile, handleSelectUserCollection } = + usePromptAndCommand() + + return ( + <> + + + file.id + )} + selectedCollectionIds={[]} + onSelectFile={handleSelectUserFile} + onSelectCollection={handleSelectUserCollection} + isFocused={focusFile} + /> + + + + + + ) +} diff --git a/chatbot-ui-main/components/chat/chat-files-display.tsx b/chatbot-ui-main/components/chat/chat-files-display.tsx new file mode 100644 index 0000000000..a0675053c8 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-files-display.tsx @@ -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 = ({}) => { + 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(null) + const [selectedImage, setSelectedImage] = useState(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 && ( + { + setShowPreview(isOpen) + setSelectedImage(null) + }} + /> + )} + + {showPreview && selectedFile && ( + { + setShowPreview(isOpen) + setSelectedFile(null) + }} + /> + )} + +
+
+ +
+ +
+
+ {messageImages.map((image, index) => ( +
+ File image { + setSelectedImage(image) + setShowPreview(true) + }} + /> + + { + e.stopPropagation() + setNewMessageImages( + newMessageImages.filter( + f => f.messageId !== image.messageId + ) + ) + setChatImages( + chatImages.filter(f => f.messageId !== image.messageId) + ) + }} + /> +
+ ))} + + {combinedChatFiles.map((file, index) => + file.id === "loading" ? ( +
+
+ +
+ +
+
{file.name}
+
{file.type}
+
+
+ ) : ( +
getLinkAndView(file)} + > +
+ {(() => { + let fileExtension = file.type.includes("/") + ? file.type.split("/")[1] + : file.type + + switch (fileExtension) { + case "pdf": + return + case "markdown": + return + case "txt": + return + case "json": + return + case "csv": + return + case "docx": + return + default: + return + } + })()} +
+ +
+
{file.name}
+
+ + { + e.stopPropagation() + setNewMessageFiles( + newMessageFiles.filter(f => f.id !== file.id) + ) + setChatFiles(chatFiles.filter(f => f.id !== file.id)) + }} + /> +
+ ) + )} +
+
+
+ + ) : ( + combinedMessageFiles.length > 0 && ( +
+ +
+ ) + ) +} + +const RetrievalToggle = ({}) => { + const { useRetrieval, setUseRetrieval } = useContext(ChatbotUIContext) + + return ( +
+ + {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."} +
+ } + trigger={ + { + e.stopPropagation() + setUseRetrieval(prev => !prev) + }} + /> + } + /> +
+ ) +} diff --git a/chatbot-ui-main/components/chat/chat-help.tsx b/chatbot-ui-main/components/chat/chat-help.tsx new file mode 100644 index 0000000000..4895ea4eeb --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-help.tsx @@ -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 = ({}) => { + useHotkey("/", () => setIsOpen(prevState => !prevState)) + + const [isOpen, setIsOpen] = useState(false) + + return ( + + + + + + + +
+ + + + + + + +
+ +
+ + + + + +
+
+ + + + +
Show Help
+
+
+ ⌘ +
+
+ Shift +
+
+ / +
+
+
+ + +
Show Workspaces
+
+
+ ⌘ +
+
+ Shift +
+
+ ; +
+
+
+ + +
New Chat
+
+
+ ⌘ +
+
+ Shift +
+
+ O +
+
+
+ + +
Focus Chat
+
+
+ ⌘ +
+
+ Shift +
+
+ L +
+
+
+ + +
Toggle Files
+
+
+ ⌘ +
+
+ Shift +
+
+ F +
+
+
+ + +
Toggle Retrieval
+
+
+ ⌘ +
+
+ Shift +
+
+ E +
+
+
+ + +
Open Settings
+
+
+ ⌘ +
+
+ Shift +
+
+ I +
+
+
+ + +
Open Quick Settings
+
+
+ ⌘ +
+
+ Shift +
+
+ P +
+
+
+ + +
Toggle Sidebar
+
+
+ ⌘ +
+
+ Shift +
+
+ S +
+
+
+
+
+ ) +} diff --git a/chatbot-ui-main/components/chat/chat-helpers/index.ts b/chatbot-ui-main/components/chat/chat-helpers/index.ts new file mode 100644 index 0000000000..17a2089638 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-helpers/index.ts @@ -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>, + 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>, + setFirstTokenReceived: React.Dispatch>, + setChatMessages: React.Dispatch>, + setToolInUse: React.Dispatch> +) => { + 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>, + setFirstTokenReceived: React.Dispatch>, + setChatMessages: React.Dispatch>, + setToolInUse: React.Dispatch> +) => { + 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>, + setChatMessages: React.Dispatch> +) => { + 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>, + setChatMessages: React.Dispatch>, + setToolInUse: React.Dispatch> +) => { + 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 | null>>, + setChats: React.Dispatch[]>>, + setChatFiles: React.Dispatch> +) => { + 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>, + setChatFileItems: React.Dispatch< + React.SetStateAction[]> + >, + setChatImages: React.Dispatch>, + 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) + } +} diff --git a/chatbot-ui-main/components/chat/chat-hooks/use-chat-handler.tsx b/chatbot-ui-main/components/chat/chat-hooks/use-chat-handler.tsx new file mode 100644 index 0000000000..f5ab04a25a --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-hooks/use-chat-handler.tsx @@ -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(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 + } +} diff --git a/chatbot-ui-main/components/chat/chat-hooks/use-chat-history.tsx b/chatbot-ui-main/components/chat/chat-hooks/use-chat-history.tsx new file mode 100644 index 0000000000..cbb376eb06 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-hooks/use-chat-history.tsx @@ -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( + 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 + } +} diff --git a/chatbot-ui-main/components/chat/chat-hooks/use-prompt-and-command.tsx b/chatbot-ui-main/components/chat/chat-hooks/use-prompt-and-command.tsx new file mode 100644 index 0000000000..aaa19250e5 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-hooks/use-prompt-and-command.tsx @@ -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 + } +} diff --git a/chatbot-ui-main/components/chat/chat-hooks/use-scroll.tsx b/chatbot-ui-main/components/chat/chat-hooks/use-scroll.tsx new file mode 100644 index 0000000000..9c6aea0d8d --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-hooks/use-scroll.tsx @@ -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(null) + const messagesEndRef = useRef(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 = 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 + } +} diff --git a/chatbot-ui-main/components/chat/chat-hooks/use-select-file-handler.tsx b/chatbot-ui-main/components/chat/chat-hooks/use-select-file-handler.tsx new file mode 100644 index 0000000000..103ce6f0a8 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-hooks/use-select-file-handler.tsx @@ -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 + } +} diff --git a/chatbot-ui-main/components/chat/chat-input.tsx b/chatbot-ui-main/components/chat/chat-input.tsx new file mode 100644 index 0000000000..761c6cdcf0 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-input.tsx @@ -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 = ({}) => { + const { t } = useTranslation() + + useHotkey("l", () => { + handleFocusChatInput() + }) + + const [isTyping, setIsTyping] = useState(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(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 ( + <> +
+ + + {selectedTools && + selectedTools.map((tool, index) => ( +
+ setSelectedTools( + selectedTools.filter( + selectedTool => selectedTool.id !== tool.id + ) + ) + } + > +
+ + +
{tool.name}
+
+
+ ))} + + {selectedAssistant && ( +
+ {selectedAssistant.image_path && ( + img.path === selectedAssistant.image_path + )?.base64 + } + width={28} + height={28} + alt={selectedAssistant.name} + /> + )} + +
+ Talking to {selectedAssistant.name} +
+
+ )} +
+ +
+
+ +
+ + <> + fileInputRef.current?.click()} + /> + + {/* Hidden input to select files from device */} + { + if (!e.target.files) return + handleSelectDeviceFile(e.target.files[0]) + }} + accept={filesToAccept} + /> + + + setIsTyping(true)} + onCompositionEnd={() => setIsTyping(false)} + /> + +
+ {isGenerating ? ( + + ) : ( + { + if (!userInput) return + + handleSendMessage(userInput, chatMessages, false) + }} + size={30} + /> + )} +
+
+ + ) +} diff --git a/chatbot-ui-main/components/chat/chat-messages.tsx b/chatbot-ui-main/components/chat/chat-messages.tsx new file mode 100644 index 0000000000..af13a88cac --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-messages.tsx @@ -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 = ({}) => { + const { chatMessages, chatFileItems } = useContext(ChatbotUIContext) + + const { handleSendEdit } = useChatHandler() + + const [editingMessage, setEditingMessage] = useState>() + + 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 ( + setEditingMessage(undefined)} + onSubmitEdit={handleSendEdit} + /> + ) + }) +} diff --git a/chatbot-ui-main/components/chat/chat-retrieval-settings.tsx b/chatbot-ui-main/components/chat/chat-retrieval-settings.tsx new file mode 100644 index 0000000000..2a47521721 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-retrieval-settings.tsx @@ -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 = ({}) => { + const { sourceCount, setSourceCount } = useContext(ChatbotUIContext) + + const [isOpen, setIsOpen] = useState(false) + + return ( + + + Adjust retrieval settings.} + trigger={ + + } + /> + + + +
+ + + { + setSourceCount(values[0]) + }} + min={1} + max={10} + step={1} + /> +
+ + + + +
+
+ ) +} diff --git a/chatbot-ui-main/components/chat/chat-scroll-buttons.tsx b/chatbot-ui-main/components/chat/chat-scroll-buttons.tsx new file mode 100644 index 0000000000..3eb6f2d643 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-scroll-buttons.tsx @@ -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 = ({ + isAtTop, + isAtBottom, + isOverflowing, + scrollToTop, + scrollToBottom +}) => { + return ( + <> + {!isAtTop && isOverflowing && ( + + )} + + {!isAtBottom && isOverflowing && ( + + )} + + ) +} diff --git a/chatbot-ui-main/components/chat/chat-secondary-buttons.tsx b/chatbot-ui-main/components/chat/chat-secondary-buttons.tsx new file mode 100644 index 0000000000..780f7654e4 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-secondary-buttons.tsx @@ -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 = ({}) => { + const { selectedChat } = useContext(ChatbotUIContext) + + const { handleNewChat } = useChatHandler() + + return ( + <> + {selectedChat && ( + <> + +
Chat Info
+ +
+
Model: {selectedChat.model}
+
Prompt: {selectedChat.prompt}
+ +
Temperature: {selectedChat.temperature}
+
Context Length: {selectedChat.context_length}
+ +
+ Profile Context:{" "} + {selectedChat.include_profile_context + ? "Enabled" + : "Disabled"} +
+
+ {" "} + Workspace Instructions:{" "} + {selectedChat.include_workspace_instructions + ? "Enabled" + : "Disabled"} +
+ +
+ Embeddings Provider: {selectedChat.embeddings_provider} +
+
+ + } + trigger={ +
+ +
+ } + /> + + Start a new chat} + trigger={ +
+ +
+ } + /> + + )} + + ) +} diff --git a/chatbot-ui-main/components/chat/chat-settings.tsx b/chatbot-ui-main/components/chat/chat-settings.tsx new file mode 100644 index 0000000000..8230d5f4d4 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-settings.tsx @@ -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 = ({}) => { + useHotkey("i", () => handleClick()) + + const { + chatSettings, + setChatSettings, + models, + availableHostedModels, + availableLocalModels, + availableOpenRouterModels + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(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 ( + + + + + + + + + + ) +} diff --git a/chatbot-ui-main/components/chat/chat-ui.tsx b/chatbot-ui-main/components/chat/chat-ui.tsx new file mode 100644 index 0000000000..ac9f13d3c2 --- /dev/null +++ b/chatbot-ui-main/components/chat/chat-ui.tsx @@ -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 = ({}) => { + 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[] = 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 + } + + return ( +
+
+ +
+ +
+ +
+ +
+
+ {selectedChat?.name || "Chat"} +
+
+ +
+
+ + + +
+
+ +
+ +
+ +
+ +
+
+ ) +} diff --git a/chatbot-ui-main/components/chat/file-picker.tsx b/chatbot-ui-main/components/chat/file-picker.tsx new file mode 100644 index 0000000000..00c4a7372d --- /dev/null +++ b/chatbot-ui-main/components/chat/file-picker.tsx @@ -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 = ({ + 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) => { + 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 && ( +
+ {filteredFiles.length === 0 && filteredCollections.length === 0 ? ( +
+ No matching files. +
+ ) : ( + <> + {[...filteredFiles, ...filteredCollections].map((item, index) => ( +
{ + 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 ? ( + ).type} size={32} /> + ) : ( + + )} + +
+
{item.name}
+ +
+ {item.description || "No description."} +
+
+
+ ))} + + )} +
+ )} + + ) +} diff --git a/chatbot-ui-main/components/chat/prompt-picker.tsx b/chatbot-ui-main/components/chat/prompt-picker.tsx new file mode 100644 index 0000000000..55592e37a9 --- /dev/null +++ b/chatbot-ui-main/components/chat/prompt-picker.tsx @@ -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 = ({}) => { + 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) => { + 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 + ) => { + if (!isTyping && e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSubmitPromptVariables() + } + } + + return ( + <> + {isPromptPickerOpen && ( +
+ {showPromptVariables ? ( + + + + Enter Prompt Variables + + +
+ {promptVariables.map((variable, index) => ( +
+ + + { + const newPromptVariables = [...promptVariables] + newPromptVariables[index].value = value + setPromptVariables(newPromptVariables) + }} + minRows={3} + maxRows={5} + onCompositionStart={() => setIsTyping(true)} + onCompositionEnd={() => setIsTyping(false)} + /> +
+ ))} +
+ +
+ + + +
+
+
+ ) : filteredPrompts.length === 0 ? ( +
+ No matching prompts. +
+ ) : ( + filteredPrompts.map((prompt, index) => ( +
{ + 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)} + > +
{prompt.name}
+ +
+ {prompt.content} +
+
+ )) + )} +
+ )} + + ) +} diff --git a/chatbot-ui-main/components/chat/quick-setting-option.tsx b/chatbot-ui-main/components/chat/quick-setting-option.tsx new file mode 100644 index 0000000000..6ddd48b3bd --- /dev/null +++ b/chatbot-ui-main/components/chat/quick-setting-option.tsx @@ -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 = ({ + contentType, + isSelected, + item, + onSelect, + image +}) => { + const modelDetails = LLM_LIST.find(model => model.modelId === item.model) + + return ( + +
+ {contentType === "presets" ? ( + + ) : image ? ( + Assistant + ) : ( + + )} +
+ +
+
{item.name}
+ + {item.description && ( +
{item.description}
+ )} +
+ +
+ {isSelected ? ( + + ) : null} +
+
+ ) +} diff --git a/chatbot-ui-main/components/chat/quick-settings.tsx b/chatbot-ui-main/components/chat/quick-settings.tsx new file mode 100644 index 0000000000..6eb5cabef4 --- /dev/null +++ b/chatbot-ui-main/components/chat/quick-settings.tsx @@ -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 = ({}) => { + 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(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 ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + {presets.length === 0 && assistants.length === 0 ? ( +
No items found.
+ ) : ( + <> + setSearch(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + {!!(selectedPreset || selectedAssistant) && ( + + | 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 }) => ( + + handleSelectQuickSetting( + item, + contentType as "presets" | "assistants" + ) + } + image={ + contentType === "assistants" + ? assistantImages.find( + image => + image.path === + (item as Tables<"assistants">).image_path + )?.base64 || "" + : "" + } + /> + ))} + + )} +
+
+ ) +} diff --git a/chatbot-ui-main/components/chat/tool-picker.tsx b/chatbot-ui-main/components/chat/tool-picker.tsx new file mode 100644 index 0000000000..10887379fa --- /dev/null +++ b/chatbot-ui-main/components/chat/tool-picker.tsx @@ -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 = ({}) => { + 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) => { + 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 && ( +
+ {filteredTools.length === 0 ? ( +
+ No matching tools. +
+ ) : ( + <> + {filteredTools.map((item, index) => ( +
{ + 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)} + > + + +
+
{item.name}
+ +
+ {item.description || "No description."} +
+
+
+ ))} + + )} +
+ )} + + ) +} diff --git a/chatbot-ui-main/components/icons/anthropic-svg.tsx b/chatbot-ui-main/components/icons/anthropic-svg.tsx new file mode 100644 index 0000000000..27b2cd18f4 --- /dev/null +++ b/chatbot-ui-main/components/icons/anthropic-svg.tsx @@ -0,0 +1,44 @@ +import { FC } from "react" + +interface AnthropicSVGProps { + height?: number + width?: number + className?: string +} + +export const AnthropicSVG: FC = ({ + height = 40, + width = 40, + className +}) => { + return ( + + + + + + + + + ) +} diff --git a/chatbot-ui-main/components/icons/chatbotui-svg.tsx b/chatbot-ui-main/components/icons/chatbotui-svg.tsx new file mode 100644 index 0000000000..4c29cf66f6 --- /dev/null +++ b/chatbot-ui-main/components/icons/chatbotui-svg.tsx @@ -0,0 +1,37 @@ +import { FC } from "react" + +interface ChatbotUISVGProps { + theme: "dark" | "light" + scale?: number +} + +export const ChatbotUISVG: FC = ({ theme, scale = 1 }) => { + return ( + + + + + + ) +} diff --git a/chatbot-ui-main/components/icons/google-svg.tsx b/chatbot-ui-main/components/icons/google-svg.tsx new file mode 100644 index 0000000000..8a86709ce7 --- /dev/null +++ b/chatbot-ui-main/components/icons/google-svg.tsx @@ -0,0 +1,42 @@ +import { FC } from "react" + +interface GoogleSVGProps { + height?: number + width?: number + className?: string +} + +export const GoogleSVG: FC = ({ + height = 40, + width = 40, + className +}) => { + return ( + + + + + + + ) +} diff --git a/chatbot-ui-main/components/icons/openai-svg.tsx b/chatbot-ui-main/components/icons/openai-svg.tsx new file mode 100644 index 0000000000..670c613598 --- /dev/null +++ b/chatbot-ui-main/components/icons/openai-svg.tsx @@ -0,0 +1,31 @@ +import { FC } from "react" + +interface OpenAISVGProps { + height?: number + width?: number + className?: string +} + +export const OpenAISVG: FC = ({ + height = 40, + width = 40, + className +}) => { + return ( + + + + ) +} diff --git a/chatbot-ui-main/components/messages/message-actions.tsx b/chatbot-ui-main/components/messages/message-actions.tsx new file mode 100644 index 0000000000..0e1c8c78ea --- /dev/null +++ b/chatbot-ui-main/components/messages/message-actions.tsx @@ -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 = ({ + 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 : ( +
+ {/* {((isAssistant && isHovering) || isLast) && ( + Fork Chat
} + trigger={ + + } + /> + )} */} + + {!isAssistant && isHovering && ( + Edit
} + trigger={ + + } + /> + )} + + {(isHovering || isLast) && ( + Copy
} + trigger={ + showCheckmark ? ( + + ) : ( + + ) + } + /> + )} + + {isLast && ( + Regenerate} + trigger={ + + } + /> + )} + + {/* {1 > 0 && isAssistant && } */} + + ) +} diff --git a/chatbot-ui-main/components/messages/message-codeblock.tsx b/chatbot-ui-main/components/messages/message-codeblock.tsx new file mode 100644 index 0000000000..2b8d79552d --- /dev/null +++ b/chatbot-ui-main/components/messages/message-codeblock.tsx @@ -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 = 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 ( +
+
+ {language} +
+ + + +
+
+ + {value} + +
+ ) + } +) + +MessageCodeBlock.displayName = "MessageCodeBlock" diff --git a/chatbot-ui-main/components/messages/message-markdown-memoized.tsx b/chatbot-ui-main/components/messages/message-markdown-memoized.tsx new file mode 100644 index 0000000000..2fc2106500 --- /dev/null +++ b/chatbot-ui-main/components/messages/message-markdown-memoized.tsx @@ -0,0 +1,9 @@ +import { FC, memo } from "react" +import ReactMarkdown, { Options } from "react-markdown" + +export const MessageMarkdownMemoized: FC = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className +) diff --git a/chatbot-ui-main/components/messages/message-markdown.tsx b/chatbot-ui-main/components/messages/message-markdown.tsx new file mode 100644 index 0000000000..88be7e9339 --- /dev/null +++ b/chatbot-ui-main/components/messages/message-markdown.tsx @@ -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 = ({ content }) => { + return ( + {children}

+ }, + img({ node, ...props }) { + return + }, + 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 + } + + if (typeof firstChildAsString === "string") { + childArray[0] = firstChildAsString.replace("`▍`", "▍") + } + + const match = /language-(\w+)/.exec(className || "") + + if ( + typeof firstChildAsString === "string" && + !firstChildAsString.includes("\n") + ) { + return ( + + {childArray} + + ) + } + + return ( + + ) + } + }} + > + {content} +
+ ) +} diff --git a/chatbot-ui-main/components/messages/message-replies.tsx b/chatbot-ui-main/components/messages/message-replies.tsx new file mode 100644 index 0000000000..e9dd75b67d --- /dev/null +++ b/chatbot-ui-main/components/messages/message-replies.tsx @@ -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 = ({}) => { + const [isOpen, setIsOpen] = useState(false) + + return ( + + + View Replies} + trigger={ +
setIsOpen(true)} + > + +
+ {1} +
+
+ } + /> +
+ + + + Are you sure absolutely sure? + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + +
+ ) +} diff --git a/chatbot-ui-main/components/messages/message.tsx b/chatbot-ui-main/components/messages/message.tsx new file mode 100644 index 0000000000..d0867d68ab --- /dev/null +++ b/chatbot-ui-main/components/messages/message.tsx @@ -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 = ({ + 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(null) + + const [isHovering, setIsHovering] = useState(false) + const [editedMessage, setEditedMessage] = useState(message.content) + + const [showImagePreview, setShowImagePreview] = useState(false) + const [selectedImage, setSelectedImage] = useState(null) + + const [showFileItemPreview, setShowFileItemPreview] = useState(false) + const [selectedFileItem, setSelectedFileItem] = + useState | 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 ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onKeyDown={handleKeyDown} + > +
+
+ +
+
+ {message.role === "system" ? ( +
+ + +
Prompt
+
+ ) : ( +
+ {message.role === "assistant" ? ( + messageAssistantImage ? ( + assistant image + ) : ( + {MODEL_DATA?.modelName}
} + trigger={ + + } + /> + ) + ) : profile?.image_url ? ( + user image + ) : ( + + )} + +
+ {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} +
+
+ )} + {!firstTokenReceived && + isGenerating && + isLast && + message.role === "assistant" ? ( + <> + {(() => { + switch (toolInUse) { + case "none": + return ( + + ) + case "retrieval": + return ( +
+ + +
Searching files...
+
+ ) + default: + return ( +
+ + +
Using {toolInUse}...
+
+ ) + } + })()} + + ) : isEditing ? ( + + ) : ( + + )} +
+ + {fileItems.length > 0 && ( +
+ {!viewSources ? ( +
setViewSources(true)} + > + {fileItems.length} + {fileItems.length > 1 ? " Sources " : " Source "} + from {Object.keys(fileSummary).length}{" "} + {Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "} + +
+ ) : ( + <> +
setViewSources(false)} + > + {fileItems.length} + {fileItems.length > 1 ? " Sources " : " Source "} + from {Object.keys(fileSummary).length}{" "} + {Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "} + +
+ +
+ {Object.values(fileSummary).map((file, index) => ( +
+
+
+ +
+ +
{file.name}
+
+ + {fileItems + .filter(fileItem => { + const parentFile = files.find( + parentFile => parentFile.id === fileItem.file_id + ) + return parentFile?.id === file.id + }) + .map((fileItem, index) => ( +
{ + setSelectedFileItem(fileItem) + setShowFileItemPreview(true) + }} + > +
+ -{" "} + {fileItem.content.substring(0, 200)}... +
+
+ ))} +
+ ))} +
+ + )} +
+ )} + +
+ {message.image_paths.map((path, index) => { + const item = chatImages.find(image => image.path === path) + + return ( + message image { + setSelectedImage({ + messageId: message.id, + path, + base64: path.startsWith("data") ? path : item?.base64 || "", + url: path.startsWith("data") ? "" : item?.url || "", + file: null + }) + + setShowImagePreview(true) + }} + loading="lazy" + /> + ) + })} +
+ {isEditing && ( +
+ + + +
+ )} +
+ + {showImagePreview && selectedImage && ( + { + setShowImagePreview(isOpen) + setSelectedImage(null) + }} + /> + )} + + {showFileItemPreview && selectedFileItem && ( + { + setShowFileItemPreview(isOpen) + setSelectedFileItem(null) + }} + /> + )} + + ) +} diff --git a/chatbot-ui-main/components/models/model-icon.tsx b/chatbot-ui-main/components/models/model-icon.tsx new file mode 100644 index 0000000000..27ca7b42c4 --- /dev/null +++ b/chatbot-ui-main/components/models/model-icon.tsx @@ -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 { + provider: ModelProvider + height: number + width: number +} + +export const ModelIcon: FC = ({ + provider, + height, + width, + ...props +}) => { + const { theme } = useTheme() + + switch (provider as ModelProvider) { + case "openai": + return ( + + ) + case "mistral": + return ( + Mistral + ) + case "groq": + return ( + Groq + ) + case "anthropic": + return ( + + ) + case "google": + return ( + + ) + case "perplexity": + return ( + Mistral + ) + default: + return + } +} diff --git a/chatbot-ui-main/components/models/model-option.tsx b/chatbot-ui-main/components/models/model-option.tsx new file mode 100644 index 0000000000..2344d3dc7d --- /dev/null +++ b/chatbot-ui-main/components/models/model-option.tsx @@ -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 = ({ model, onSelect }) => { + return ( + + {model.provider !== "ollama" && model.pricing && ( +
+
+ Input Cost:{" "} + {model.pricing.inputCost} {model.pricing.currency} per{" "} + {model.pricing.unit} +
+ {model.pricing.outputCost && ( +
+ Output Cost:{" "} + {model.pricing.outputCost} {model.pricing.currency} per{" "} + {model.pricing.unit} +
+ )} +
+ )} + + } + side="bottom" + trigger={ +
+
+ +
{model.modelName}
+
+
+ } + /> + ) +} diff --git a/chatbot-ui-main/components/models/model-select.tsx b/chatbot-ui-main/components/models/model-select.tsx new file mode 100644 index 0000000000..d25d6de507 --- /dev/null +++ b/chatbot-ui-main/components/models/model-select.tsx @@ -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 = ({ + selectedModelId, + onSelectModel +}) => { + const { + profile, + models, + availableHostedModels, + availableLocalModels, + availableOpenRouterModels + } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + const triggerRef = useRef(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>( + (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 ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + {allModels.length === 0 ? ( +
+ Unlock models by entering API keys in your profile settings. +
+ ) : ( + + )} +
+ + + setTab(value)}> + {availableLocalModels.length > 0 && ( + + Hosted + + Local + + )} + + + setSearch(e.target.value)} + /> + +
+ {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 ( +
+
+ {provider === "openai" && profile.use_azure_openai + ? "AZURE OPENAI" + : provider.toLocaleUpperCase()} +
+ +
+ {filteredModels.map(model => { + return ( +
+ {selectedModelId === model.modelId && ( + + )} + + handleSelectModel(model.modelId)} + /> +
+ ) + })} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/chatbot-ui-main/components/setup/api-step.tsx b/chatbot-ui-main/components/setup/api-step.tsx new file mode 100644 index 0000000000..16393be500 --- /dev/null +++ b/chatbot-ui-main/components/setup/api-step.tsx @@ -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 = ({ + 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 ( + <> +
+ + + + useAzureOpenai + ? onAzureOpenaiAPIKeyChange(e.target.value) + : onOpenaiAPIKeyChange(e.target.value) + } + /> +
+ +
+ {useAzureOpenai ? ( + <> +
+ + + onAzureOpenaiEndpointChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenai35TurboIDChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenai45TurboIDChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenai45VisionIDChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenaiEmbeddingsIDChange(e.target.value)} + /> +
+ + ) : ( + <> +
+ + + onOpenaiOrgIDChange(e.target.value)} + /> +
+ + )} +
+ +
+ + + onAnthropicAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onGoogleGeminiAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onMistralAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onGroqAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onPerplexityAPIKeyChange(e.target.value)} + /> +
+
+ + + onOpenrouterAPIKeyChange(e.target.value)} + /> +
+ + ) +} diff --git a/chatbot-ui-main/components/setup/finish-step.tsx b/chatbot-ui-main/components/setup/finish-step.tsx new file mode 100644 index 0000000000..d0747d32e4 --- /dev/null +++ b/chatbot-ui-main/components/setup/finish-step.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" + +interface FinishStepProps { + displayName: string +} + +export const FinishStep: FC = ({ displayName }) => { + return ( +
+
+ Welcome to Chatbot UI + {displayName.length > 0 ? `, ${displayName.split(" ")[0]}` : null}! +
+ +
Click next to start chatting.
+
+ ) +} diff --git a/chatbot-ui-main/components/setup/profile-step.tsx b/chatbot-ui-main/components/setup/profile-step.tsx new file mode 100644 index 0000000000..eaf8faf813 --- /dev/null +++ b/chatbot-ui-main/components/setup/profile-step.tsx @@ -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 = ({ + 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 ( + <> +
+
+ + +
+ {usernameAvailable ? ( +
AVAILABLE
+ ) : ( +
UNAVAILABLE
+ )} +
+
+ +
+ { + onUsernameChange(e.target.value) + checkUsernameAvailability(e.target.value) + }} + minLength={PROFILE_USERNAME_MIN} + maxLength={PROFILE_USERNAME_MAX} + /> + +
+ {loading ? ( + + ) : usernameAvailable ? ( + + ) : ( + + )} +
+
+ + +
+ +
+ + + onDisplayNameChange(e.target.value)} + maxLength={PROFILE_DISPLAY_NAME_MAX} + /> + + +
+ + ) +} diff --git a/chatbot-ui-main/components/setup/step-container.tsx b/chatbot-ui-main/components/setup/step-container.tsx new file mode 100644 index 0000000000..2a9ed6972a --- /dev/null +++ b/chatbot-ui-main/components/setup/step-container.tsx @@ -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 = ({ + stepDescription, + stepNum, + stepTitle, + onShouldProceed, + children, + showBackButton = false, + showNextButton = true +}) => { + const buttonRef = useRef(null) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + if (buttonRef.current) { + buttonRef.current.click() + } + } + } + + return ( + + + +
{stepTitle}
+ +
+ {stepNum} / {SETUP_STEP_COUNT} +
+
+ + {stepDescription} +
+ + {children} + + +
+ {showBackButton && ( + + )} +
+ +
+ {showNextButton && ( + + )} +
+
+
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/all/sidebar-create-item.tsx b/chatbot-ui-main/components/sidebar/items/all/sidebar-create-item.tsx new file mode 100644 index 0000000000..18d08ff4b1 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/all/sidebar-create-item.tsx @@ -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 = ({ + isOpen, + onOpenChange, + contentType, + renderInputs, + createState, + isTyping +}) => { + const { + selectedWorkspace, + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants, + setAssistantImages, + setTools, + setModels + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(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) => { + if (!isTyping && e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + buttonRef.current?.click() + } + } + + return ( + + +
+ + + Create{" "} + {contentType.charAt(0).toUpperCase() + contentType.slice(1, -1)} + + + +
{renderInputs()}
+
+ + +
+ + + +
+
+
+
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/all/sidebar-delete-item.tsx b/chatbot-ui-main/components/sidebar/items/all/sidebar-delete-item.tsx new file mode 100644 index 0000000000..5066bdfc8f --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/all/sidebar-delete-item.tsx @@ -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 = ({ + item, + contentType +}) => { + const { + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants, + setTools, + setModels + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(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) => { + if (e.key === "Enter") { + e.stopPropagation() + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Delete {contentType.slice(0, -1)} + + + Are you sure you want to delete {item.name}? + + + + + + + + + + + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/all/sidebar-display-item.tsx b/chatbot-ui-main/components/sidebar/items/all/sidebar-display-item.tsx new file mode 100644 index 0000000000..d8f0864036 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/all/sidebar-display-item.tsx @@ -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 = ({ + item, + contentType, + updateState, + renderInputs, + icon, + isTyping +}) => { + const { selectedWorkspace, setChats, setSelectedAssistant } = + useContext(ChatbotUIContext) + + const router = useRouter() + + const itemRef = useRef(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) => { + if (e.key === "Enter") { + e.stopPropagation() + itemRef.current?.click() + } + } + + // const handleClickAction = async ( + // e: React.MouseEvent + // ) => { + // e.stopPropagation() + + // const action = actionMap[contentType] + + // await action(item as any) + // } + + return ( + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {icon} + +
+ {item.name} +
+ + {/* TODO */} + {/* {isHovering && ( + Start chat with {contentType.slice(0, -1)}
} + trigger={ + + } + /> + )} */} + +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/all/sidebar-update-item.tsx b/chatbot-ui-main/components/sidebar/items/all/sidebar-update-item.tsx new file mode 100644 index 0000000000..1f8b346107 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/all/sidebar-update-item.tsx @@ -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 = ({ + item, + contentType, + children, + renderInputs, + updateState, + isTyping +}) => { + const { + workspaces, + selectedWorkspace, + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants, + setTools, + setModels, + setAssistantImages + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(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[]>([]) + const [startingAssistantTools, setStartingAssistantTools] = useState< + Tables<"tools">[] + >([]) + const [selectedAssistantFiles, setSelectedAssistantFiles] = useState< + Tables<"files">[] + >([]) + const [selectedAssistantCollections, setSelectedAssistantCollections] = + useState[]>([]) + 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, + createWorkspaceFn: ( + workspaces: { user_id: string; item_id: string; workspace_id: string }[] + ) => Promise, + 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) => { + if (!isTyping && e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + buttonRef.current?.click() + } + } + + return ( + + {children} + + +
+ + + Edit {contentType.slice(0, -1)} + + + +
+ {workspaces.length > 1 && ( +
+ + + +
+ )} + + {renderInputs(renderState[contentType])} +
+
+ + + + +
+ + + +
+
+
+
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/assistants/assistant-item.tsx b/chatbot-ui-main/components/sidebar/items/assistants/assistant-item.tsx new file mode 100644 index 0000000000..de807fdc98 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/assistants/assistant-item.tsx @@ -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 = ({ 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(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[]> + > + ) => { + 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[]> + > + ) => { + 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[]> + > + ) => { + 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 ( + + ) : ( + + ) + } + 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[]> + > + selectedAssistantFiles: Tables<"files">[] + setSelectedAssistantFiles: React.Dispatch< + React.SetStateAction[]> + > + startingAssistantCollections: Tables<"collections">[] + setStartingAssistantCollections: React.Dispatch< + React.SetStateAction[]> + > + selectedAssistantCollections: Tables<"collections">[] + setSelectedAssistantCollections: React.Dispatch< + React.SetStateAction[]> + > + startingAssistantTools: Tables<"tools">[] + setStartingAssistantTools: React.Dispatch< + React.SetStateAction[]> + > + selectedAssistantTools: Tables<"tools">[] + setSelectedAssistantTools: React.Dispatch< + React.SetStateAction[]> + > + }) => ( + <> +
+ + + setName(e.target.value)} + maxLength={ASSISTANT_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={ASSISTANT_DESCRIPTION_MAX} + /> +
+ +
+ + + +
+ + + +
+ + + + ![ + ...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 + ) + } + /> +
+ +
+ + + + !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) + } + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/assistants/assistant-retrieval-select.tsx b/chatbot-ui-main/components/sidebar/items/assistants/assistant-retrieval-select.tsx new file mode 100644 index 0000000000..fc69de7b26 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/assistants/assistant-retrieval-select.tsx @@ -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 = ({ + selectedAssistantRetrievalItems, + onAssistantRetrievalItemsSelect +}) => { + const { files, collections } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + const triggerRef = useRef(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 ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + setSearch(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + {selectedAssistantRetrievalItems + .filter(item => + item.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(item => ( + | 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 => ( + + selectedAssistantRetrieval.id === file.id + )} + onSelect={handleItemSelect} + /> + ))} + + {collections + .filter( + collection => + !selectedAssistantRetrievalItems.some( + selectedAssistantRetrieval => + selectedAssistantRetrieval.id === collection.id + ) && collection.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(collection => ( + + selectedAssistantRetrieval.id === collection.id + )} + onSelect={handleItemSelect} + /> + ))} + + + ) +} + +interface AssistantRetrievalOptionItemProps { + contentType: "files" | "collections" + item: Tables<"files"> | Tables<"collections"> + selected: boolean + onSelect: (item: Tables<"files"> | Tables<"collections">) => void +} + +const AssistantRetrievalItemOption: FC = ({ + contentType, + item, + selected, + onSelect +}) => { + const handleSelect = () => { + onSelect(item) + } + + return ( +
+
+ {contentType === "files" ? ( +
+ ).type} size={24} /> +
+ ) : ( +
+ +
+ )} + +
{item.name}
+
+ + {selected && ( + + )} +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/assistants/assistant-tool-select.tsx b/chatbot-ui-main/components/sidebar/items/assistants/assistant-tool-select.tsx new file mode 100644 index 0000000000..2efba48cc5 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/assistants/assistant-tool-select.tsx @@ -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 = ({ + selectedAssistantTools, + onAssistantToolsSelect +}) => { + const { tools } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + const triggerRef = useRef(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 ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + setSearch(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + {selectedAssistantTools + .filter(item => + item.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(tool => ( + + selectedAssistantRetrieval.id === tool.id + )} + onSelect={handleToolSelect} + /> + ))} + + {tools + .filter( + tool => + !selectedAssistantTools.some( + selectedAssistantRetrieval => + selectedAssistantRetrieval.id === tool.id + ) && tool.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(tool => ( + + selectedAssistantRetrieval.id === tool.id + )} + onSelect={handleToolSelect} + /> + ))} + + + ) +} + +interface AssistantToolItemProps { + tool: Tables<"tools"> + selected: boolean + onSelect: (tool: Tables<"tools">) => void +} + +const AssistantToolItem: FC = ({ + tool, + selected, + onSelect +}) => { + const handleSelect = () => { + onSelect(tool) + } + + return ( +
+
+
+ +
+ +
{tool.name}
+
+ + {selected && ( + + )} +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/assistants/create-assistant.tsx b/chatbot-ui-main/components/sidebar/items/assistants/create-assistant.tsx new file mode 100644 index 0000000000..754736661a --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/assistants/create-assistant.tsx @@ -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 = ({ + 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(null) + const [imageLink, setImageLink] = useState("") + const [selectedAssistantRetrievalItems, setSelectedAssistantRetrievalItems] = + useState[] | 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 ( + + 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={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={ASSISTANT_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={ASSISTANT_DESCRIPTION_MAX} + /> +
+ +
+ + + +
+ + + +
+ + + +
+ + {checkIfModelIsToolCompatible() ? ( +
+ + + +
+ ) : ( +
+ Model is not compatible with tools. +
+ )} + + )} + onOpenChange={onOpenChange} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/chat/chat-item.tsx b/chatbot-ui-main/components/sidebar/items/chat/chat-item.tsx new file mode 100644 index 0000000000..62c2d98c54 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/chat/chat-item.tsx @@ -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 = ({ 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(null) + + const handleClick = () => { + if (!selectedWorkspace) return + return router.push(`/${selectedWorkspace.id}/chat/${chat.id}`) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + 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 ( +
+ {chat.assistant_id ? ( + assistantImage ? ( + Assistant image + ) : ( + + ) + ) : ( + {MODEL_DATA?.modelName}
} + trigger={ + + } + /> + )} + +
+ {chat.name} +
+ +
{ + e.stopPropagation() + e.preventDefault() + }} + className={`ml-2 flex space-x-2 ${!isActive && "w-11 opacity-0 group-hover:opacity-100"}`} + > + + + +
+ + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/chat/delete-chat.tsx b/chatbot-ui-main/components/sidebar/items/chat/delete-chat.tsx new file mode 100644 index 0000000000..868e38a34d --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/chat/delete-chat.tsx @@ -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 = ({ chat }) => { + useHotkey("Backspace", () => setShowChatDialog(true)) + + const { setChats } = useContext(ChatbotUIContext) + const { handleNewChat } = useChatHandler() + + const buttonRef = useRef(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) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Delete {chat.name} + + + Are you sure you want to delete this chat? + + + + + + + + + + + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/chat/update-chat.tsx b/chatbot-ui-main/components/sidebar/items/chat/update-chat.tsx new file mode 100644 index 0000000000..e5893009a2 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/chat/update-chat.tsx @@ -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 = ({ chat }) => { + const { setChats } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [showChatDialog, setShowChatDialog] = useState(false) + const [name, setName] = useState(chat.name) + + const handleUpdateChat = async (e: React.MouseEvent) => { + 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) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Edit Chat + + +
+ + + setName(e.target.value)} /> +
+ + + + + + +
+
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/collections/collection-file-select.tsx b/chatbot-ui-main/components/sidebar/items/collections/collection-file-select.tsx new file mode 100644 index 0000000000..7f2f0de888 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/collections/collection-file-select.tsx @@ -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 = ({ + selectedCollectionFiles, + onCollectionFileSelect +}) => { + const { files } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + const triggerRef = useRef(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 ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + setSearch(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + {selectedCollectionFiles + .filter(file => + file.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(file => ( + selectedCollectionFile.id === file.id + )} + onSelect={handleFileSelect} + /> + ))} + + {files + .filter( + file => + !selectedCollectionFiles.some( + selectedCollectionFile => selectedCollectionFile.id === file.id + ) && file.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(file => ( + selectedCollectionFile.id === file.id + )} + onSelect={handleFileSelect} + /> + ))} + + + ) +} + +interface CollectionFileItemProps { + file: CollectionFile + selected: boolean + onSelect: (file: CollectionFile) => void +} + +const CollectionFileItem: FC = ({ + file, + selected, + onSelect +}) => { + const handleSelect = () => { + onSelect(file) + } + + return ( +
+
+
+ +
+ +
{file.name}
+
+ + {selected && ( + + )} +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/collections/collection-item.tsx b/chatbot-ui-main/components/sidebar/items/collections/collection-item.tsx new file mode 100644 index 0000000000..e6b1faa4a1 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/collections/collection-item.tsx @@ -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 = ({ 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 + > + ) => { + 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 ( + } + updateState={{ + name, + description + }} + renderInputs={(renderState: { + startingCollectionFiles: CollectionFile[] + setStartingCollectionFiles: React.Dispatch< + React.SetStateAction + > + selectedCollectionFiles: CollectionFile[] + setSelectedCollectionFiles: React.Dispatch< + React.SetStateAction + > + }) => { + return ( + <> +
+ + + + !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) + } + /> +
+ +
+ + + setName(e.target.value)} + maxLength={COLLECTION_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={COLLECTION_DESCRIPTION_MAX} + /> +
+ + ) + }} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/collections/create-collection.tsx b/chatbot-ui-main/components/sidebar/items/collections/create-collection.tsx new file mode 100644 index 0000000000..d2c24370ee --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/collections/create-collection.tsx @@ -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 = ({ + 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 ( + ({ + 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={() => ( + <> +
+ + + +
+ +
+ + + setName(e.target.value)} + maxLength={COLLECTION_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={COLLECTION_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/files/create-file.tsx b/chatbot-ui-main/components/sidebar/items/files/create-file.tsx new file mode 100644 index 0000000000..20220d3bce --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/files/create-file.tsx @@ -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 = ({ isOpen, onOpenChange }) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [isTyping, setIsTyping] = useState(false) + const [description, setDescription] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + + const handleSelectedFile = async (e: React.ChangeEvent) => { + 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 ( + + } + isOpen={isOpen} + isTyping={isTyping} + onOpenChange={onOpenChange} + renderInputs={() => ( + <> +
+ + + +
+ +
+ + + setName(e.target.value)} + maxLength={FILE_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={FILE_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/files/file-item.tsx b/chatbot-ui-main/components/sidebar/items/files/file-item.tsx new file mode 100644 index 0000000000..7941e2e7b3 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/files/file-item.tsx @@ -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 = ({ 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 ( + } + updateState={{ name, description }} + renderInputs={() => ( + <> +
+ View {file.name} +
+ +
+
{file.type}
+ +
{formatFileSize(file.size)}
+ +
{file.tokens.toLocaleString()} tokens
+
+ +
+ + + setName(e.target.value)} + maxLength={FILE_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={FILE_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} + +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}` +} diff --git a/chatbot-ui-main/components/sidebar/items/folders/delete-folder.tsx b/chatbot-ui-main/components/sidebar/items/folders/delete-folder.tsx new file mode 100644 index 0000000000..8a6fb0fae3 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/folders/delete-folder.tsx @@ -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 = ({ + folder, + contentType +}) => { + const { + setChats, + setFolders, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants, + setTools, + setModels + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(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 ( + + + + + + + + Delete {folder.name} + + + Are you sure you want to delete this folder? + + + + + + + + + + + + + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/folders/folder-item.tsx b/chatbot-ui-main/components/sidebar/items/folders/folder-item.tsx new file mode 100644 index 0000000000..6768f21d44 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/folders/folder-item.tsx @@ -0,0 +1,114 @@ +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { ContentType } from "@/types" +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react" +import { FC, useRef, useState } from "react" +import { DeleteFolder } from "./delete-folder" +import { UpdateFolder } from "./update-folder" + +interface FolderProps { + folder: Tables<"folders"> + contentType: ContentType + children: React.ReactNode + onUpdateFolder: (itemId: string, folderId: string | null) => void +} + +export const Folder: FC = ({ + folder, + contentType, + children, + onUpdateFolder +}) => { + const itemRef = useRef(null) + + const [isDragOver, setIsDragOver] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [isHovering, setIsHovering] = useState(false) + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + + setIsDragOver(false) + const itemId = e.dataTransfer.getData("text/plain") + onUpdateFolder(itemId, folder.id) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.stopPropagation() + itemRef.current?.click() + } + } + + const handleClick = (e: React.MouseEvent) => { + setIsExpanded(!isExpanded) + } + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+
+
+ {isExpanded ? ( + + ) : ( + + )} + +
{folder.name}
+
+ + {isHovering && ( +
{ + e.stopPropagation() + e.preventDefault() + }} + className="ml-2 flex space-x-2" + > + + + +
+ )} +
+
+ + {isExpanded && ( +
{children}
+ )} +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/folders/update-folder.tsx b/chatbot-ui-main/components/sidebar/items/folders/update-folder.tsx new file mode 100644 index 0000000000..d98833e2f7 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/folders/update-folder.tsx @@ -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 { updateFolder } from "@/db/folders" +import { Tables } from "@/supabase/types" +import { IconEdit } from "@tabler/icons-react" +import { FC, useContext, useRef, useState } from "react" + +interface UpdateFolderProps { + folder: Tables<"folders"> +} + +export const UpdateFolder: FC = ({ folder }) => { + const { setFolders } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [showFolderDialog, setShowFolderDialog] = useState(false) + const [name, setName] = useState(folder.name) + + const handleUpdateFolder = async (e: React.MouseEvent) => { + const updatedFolder = await updateFolder(folder.id, { + name + }) + setFolders(prevState => + prevState.map(c => (c.id === folder.id ? updatedFolder : c)) + ) + + setShowFolderDialog(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Edit Folder + + +
+ + + setName(e.target.value)} /> +
+ + + + + + +
+
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/items/models/create-model.tsx b/chatbot-ui-main/components/sidebar/items/models/create-model.tsx new file mode 100644 index 0000000000..c50ea63dc3 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/models/create-model.tsx @@ -0,0 +1,117 @@ +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 { MODEL_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreateModelProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreateModel: FC = ({ isOpen, onOpenChange }) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [isTyping, setIsTyping] = useState(false) + + const [apiKey, setApiKey] = useState("") + const [baseUrl, setBaseUrl] = useState("") + const [description, setDescription] = useState("") + const [modelId, setModelId] = useState("") + const [name, setName] = useState("") + const [contextLength, setContextLength] = useState(4096) + + if (!profile || !selectedWorkspace) return null + + return ( + + } + renderInputs={() => ( + <> +
+
Create a custom model.
+ +
+ Your API *must* be compatible + with the OpenAI SDK. +
+
+ +
+ + + setName(e.target.value)} + maxLength={MODEL_NAME_MAX} + /> +
+ +
+ + + setModelId(e.target.value)} + /> +
+ +
+ + + setBaseUrl(e.target.value)} + /> + +
+ Your API must be compatible with the OpenAI SDK. +
+
+ +
+ + + setApiKey(e.target.value)} + /> +
+ +
+ + + setContextLength(parseInt(e.target.value))} + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/models/model-item.tsx b/chatbot-ui-main/components/sidebar/items/models/model-item.tsx new file mode 100644 index 0000000000..b6f6f74d1b --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/models/model-item.tsx @@ -0,0 +1,102 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { MODEL_NAME_MAX } from "@/db/limits" +import { Tables, TablesUpdate } from "@/supabase/types" +import { IconSparkles } from "@tabler/icons-react" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface ModelItemProps { + model: Tables<"models"> +} + +export const ModelItem: FC = ({ model }) => { + const [isTyping, setIsTyping] = useState(false) + + const [apiKey, setApiKey] = useState(model.api_key) + const [baseUrl, setBaseUrl] = useState(model.base_url) + const [description, setDescription] = useState(model.description) + const [modelId, setModelId] = useState(model.model_id) + const [name, setName] = useState(model.name) + const [contextLength, setContextLength] = useState(model.context_length) + + return ( + } + updateState={ + { + api_key: apiKey, + base_url: baseUrl, + description, + context_length: contextLength, + model_id: modelId, + name + } as TablesUpdate<"models"> + } + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={MODEL_NAME_MAX} + /> +
+ +
+ + + setModelId(e.target.value)} + /> +
+ +
+ + + setBaseUrl(e.target.value)} + /> + +
+ Your API must be compatible with the OpenAI SDK. +
+
+ +
+ + + setApiKey(e.target.value)} + /> +
+ +
+ + + setContextLength(parseInt(e.target.value))} + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/presets/create-preset.tsx b/chatbot-ui-main/components/sidebar/items/presets/create-preset.tsx new file mode 100644 index 0000000000..aa3b6c8b6b --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/presets/create-preset.tsx @@ -0,0 +1,81 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { ChatSettingsForm } from "@/components/ui/chat-settings-form" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { PRESET_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreatePresetProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreatePreset: FC = ({ + isOpen, + onOpenChange +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [isTyping, setIsTyping] = useState(false) + const [description, setDescription] = useState("") + const [presetChatSettings, setPresetChatSettings] = useState({ + model: selectedWorkspace?.default_model, + 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 + }) + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + } + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PRESET_NAME_MAX} + /> +
+ + + + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/presets/preset-item.tsx b/chatbot-ui-main/components/sidebar/items/presets/preset-item.tsx new file mode 100644 index 0000000000..7ad71ffb15 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/presets/preset-item.tsx @@ -0,0 +1,75 @@ +import { ModelIcon } from "@/components/models/model-icon" +import { ChatSettingsForm } from "@/components/ui/chat-settings-form" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { PRESET_NAME_MAX } from "@/db/limits" +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { Tables } from "@/supabase/types" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface PresetItemProps { + preset: Tables<"presets"> +} + +export const PresetItem: FC = ({ preset }) => { + const [name, setName] = useState(preset.name) + const [isTyping, setIsTyping] = useState(false) + const [description, setDescription] = useState(preset.description) + const [presetChatSettings, setPresetChatSettings] = useState({ + model: preset.model, + prompt: preset.prompt, + temperature: preset.temperature, + contextLength: preset.context_length, + includeProfileContext: preset.include_profile_context, + includeWorkspaceInstructions: preset.include_workspace_instructions + }) + + const modelDetails = LLM_LIST.find(model => model.modelId === preset.model) + + return ( + + } + updateState={{ + name, + description, + include_profile_context: presetChatSettings.includeProfileContext, + include_workspace_instructions: + presetChatSettings.includeWorkspaceInstructions, + context_length: presetChatSettings.contextLength, + model: presetChatSettings.model, + prompt: presetChatSettings.prompt, + temperature: presetChatSettings.temperature + }} + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PRESET_NAME_MAX} + /> +
+ + + + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/prompts/create-prompt.tsx b/chatbot-ui-main/components/sidebar/items/prompts/create-prompt.tsx new file mode 100644 index 0000000000..2b785c0a49 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/prompts/create-prompt.tsx @@ -0,0 +1,72 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { TextareaAutosize } from "@/components/ui/textarea-autosize" +import { ChatbotUIContext } from "@/context/context" +import { PROMPT_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreatePromptProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreatePrompt: FC = ({ + isOpen, + onOpenChange +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + const [isTyping, setIsTyping] = useState(false) + const [name, setName] = useState("") + const [content, setContent] = useState("") + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + } + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PROMPT_NAME_MAX} + onCompositionStart={() => setIsTyping(true)} + onCompositionEnd={() => setIsTyping(false)} + /> +
+ +
+ + + setIsTyping(true)} + onCompositionEnd={() => setIsTyping(false)} + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/prompts/prompt-item.tsx b/chatbot-ui-main/components/sidebar/items/prompts/prompt-item.tsx new file mode 100644 index 0000000000..921e3157f8 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/prompts/prompt-item.tsx @@ -0,0 +1,57 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { TextareaAutosize } from "@/components/ui/textarea-autosize" +import { PROMPT_NAME_MAX } from "@/db/limits" +import { Tables } from "@/supabase/types" +import { IconPencil } from "@tabler/icons-react" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface PromptItemProps { + prompt: Tables<"prompts"> +} + +export const PromptItem: FC = ({ prompt }) => { + const [name, setName] = useState(prompt.name) + const [content, setContent] = useState(prompt.content) + const [isTyping, setIsTyping] = useState(false) + return ( + } + updateState={{ name, content }} + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PROMPT_NAME_MAX} + onCompositionStart={() => setIsTyping(true)} + onCompositionEnd={() => setIsTyping(false)} + /> +
+ +
+ + + setIsTyping(true)} + onCompositionEnd={() => setIsTyping(false)} + /> +
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/tools/create-tool.tsx b/chatbot-ui-main/components/sidebar/items/tools/create-tool.tsx new file mode 100644 index 0000000000..b134246a1a --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/tools/create-tool.tsx @@ -0,0 +1,172 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { TextareaAutosize } from "@/components/ui/textarea-autosize" +import { ChatbotUIContext } from "@/context/context" +import { TOOL_DESCRIPTION_MAX, TOOL_NAME_MAX } from "@/db/limits" +import { validateOpenAPI } from "@/lib/openapi-conversion" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreateToolProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreateTool: FC = ({ isOpen, onOpenChange }) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [isTyping, setIsTyping] = useState(false) + const [description, setDescription] = useState("") + const [url, setUrl] = useState("") + const [customHeaders, setCustomHeaders] = useState("") + const [schema, setSchema] = useState("") + const [schemaError, setSchemaError] = useState("") + + if (!profile || !selectedWorkspace) return null + + return ( + + } + isOpen={isOpen} + isTyping={isTyping} + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={TOOL_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={TOOL_DESCRIPTION_MAX} + /> +
+ + {/*
+ + + setUrl(e.target.value)} + /> +
*/} + + {/*
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
*/} + +
+ + + +
+ +
+ + + { + setSchema(value) + + try { + const parsedSchema = JSON.parse(value) + validateOpenAPI(parsedSchema) + .then(() => setSchemaError("")) // Clear error if validation is successful + .catch(error => setSchemaError(error.message)) // Set specific validation error message + } catch (error) { + setSchemaError("Invalid JSON format") // Set error for invalid JSON format + } + }} + minRows={15} + /> + +
{schemaError}
+
+ + )} + onOpenChange={onOpenChange} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/items/tools/tool-item.tsx b/chatbot-ui-main/components/sidebar/items/tools/tool-item.tsx new file mode 100644 index 0000000000..fb8d54a442 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/items/tools/tool-item.tsx @@ -0,0 +1,166 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { TextareaAutosize } from "@/components/ui/textarea-autosize" +import { TOOL_DESCRIPTION_MAX, TOOL_NAME_MAX } from "@/db/limits" +import { validateOpenAPI } from "@/lib/openapi-conversion" +import { Tables } from "@/supabase/types" +import { IconBolt } from "@tabler/icons-react" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface ToolItemProps { + tool: Tables<"tools"> +} + +export const ToolItem: FC = ({ tool }) => { + const [name, setName] = useState(tool.name) + const [isTyping, setIsTyping] = useState(false) + const [description, setDescription] = useState(tool.description) + const [url, setUrl] = useState(tool.url) + const [customHeaders, setCustomHeaders] = useState( + tool.custom_headers as string + ) + const [schema, setSchema] = useState(tool.schema as string) + const [schemaError, setSchemaError] = useState("") + + return ( + } + updateState={{ + name, + description, + url, + custom_headers: customHeaders, + schema + }} + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={TOOL_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={TOOL_DESCRIPTION_MAX} + /> +
+ + {/*
+ + + setUrl(e.target.value)} + /> +
*/} + + {/*
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
*/} + +
+ + + +
+ +
+ + + { + setSchema(value) + + try { + const parsedSchema = JSON.parse(value) + validateOpenAPI(parsedSchema) + .then(() => setSchemaError("")) // Clear error if validation is successful + .catch(error => setSchemaError(error.message)) // Set specific validation error message + } catch (error) { + setSchemaError("Invalid JSON format") // Set error for invalid JSON format + } + }} + minRows={15} + /> + +
{schemaError}
+
+ + )} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar-content.tsx b/chatbot-ui-main/components/sidebar/sidebar-content.tsx new file mode 100644 index 0000000000..1c5bd0860c --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar-content.tsx @@ -0,0 +1,50 @@ +import { Tables } from "@/supabase/types" +import { ContentType, DataListType } from "@/types" +import { FC, useState } from "react" +import { SidebarCreateButtons } from "./sidebar-create-buttons" +import { SidebarDataList } from "./sidebar-data-list" +import { SidebarSearch } from "./sidebar-search" + +interface SidebarContentProps { + contentType: ContentType + data: DataListType + folders: Tables<"folders">[] +} + +export const SidebarContent: FC = ({ + contentType, + data, + folders +}) => { + const [searchTerm, setSearchTerm] = useState("") + + const filteredData: any = data.filter(item => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + return ( + // Subtract 50px for the height of the workspace settings +
+
+ 0} + /> +
+ +
+ +
+ + +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar-create-buttons.tsx b/chatbot-ui-main/components/sidebar/sidebar-create-buttons.tsx new file mode 100644 index 0000000000..934365471f --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar-create-buttons.tsx @@ -0,0 +1,157 @@ +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatbotUIContext } from "@/context/context" +import { createFolder } from "@/db/folders" +import { ContentType } from "@/types" +import { IconFolderPlus, IconPlus } from "@tabler/icons-react" +import { FC, useContext, useState } from "react" +import { Button } from "../ui/button" +import { CreateAssistant } from "./items/assistants/create-assistant" +import { CreateCollection } from "./items/collections/create-collection" +import { CreateFile } from "./items/files/create-file" +import { CreateModel } from "./items/models/create-model" +import { CreatePreset } from "./items/presets/create-preset" +import { CreatePrompt } from "./items/prompts/create-prompt" +import { CreateTool } from "./items/tools/create-tool" + +interface SidebarCreateButtonsProps { + contentType: ContentType + hasData: boolean +} + +export const SidebarCreateButtons: FC = ({ + contentType, + hasData +}) => { + const { profile, selectedWorkspace, folders, setFolders } = + useContext(ChatbotUIContext) + const { handleNewChat } = useChatHandler() + + const [isCreatingPrompt, setIsCreatingPrompt] = useState(false) + const [isCreatingPreset, setIsCreatingPreset] = useState(false) + const [isCreatingFile, setIsCreatingFile] = useState(false) + const [isCreatingCollection, setIsCreatingCollection] = useState(false) + const [isCreatingAssistant, setIsCreatingAssistant] = useState(false) + const [isCreatingTool, setIsCreatingTool] = useState(false) + const [isCreatingModel, setIsCreatingModel] = useState(false) + + const handleCreateFolder = async () => { + if (!profile) return + if (!selectedWorkspace) return + + const createdFolder = await createFolder({ + user_id: profile.user_id, + workspace_id: selectedWorkspace.id, + name: "New Folder", + description: "", + type: contentType + }) + setFolders([...folders, createdFolder]) + } + + const getCreateFunction = () => { + switch (contentType) { + case "chats": + return async () => { + handleNewChat() + } + + case "presets": + return async () => { + setIsCreatingPreset(true) + } + + case "prompts": + return async () => { + setIsCreatingPrompt(true) + } + + case "files": + return async () => { + setIsCreatingFile(true) + } + + case "collections": + return async () => { + setIsCreatingCollection(true) + } + + case "assistants": + return async () => { + setIsCreatingAssistant(true) + } + + case "tools": + return async () => { + setIsCreatingTool(true) + } + + case "models": + return async () => { + setIsCreatingModel(true) + } + + default: + break + } + } + + return ( +
+ + + {hasData && ( + + )} + + {isCreatingPrompt && ( + + )} + + {isCreatingPreset && ( + + )} + + {isCreatingFile && ( + + )} + + {isCreatingCollection && ( + + )} + + {isCreatingAssistant && ( + + )} + + {isCreatingTool && ( + + )} + + {isCreatingModel && ( + + )} +
+ ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar-data-list.tsx b/chatbot-ui-main/components/sidebar/sidebar-data-list.tsx new file mode 100644 index 0000000000..ee9ba44e0f --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar-data-list.tsx @@ -0,0 +1,344 @@ +import { ChatbotUIContext } from "@/context/context" +import { updateAssistant } from "@/db/assistants" +import { updateChat } from "@/db/chats" +import { updateCollection } from "@/db/collections" +import { updateFile } from "@/db/files" +import { updateModel } from "@/db/models" +import { updatePreset } from "@/db/presets" +import { updatePrompt } from "@/db/prompts" +import { updateTool } from "@/db/tools" +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { ContentType, DataItemType, DataListType } from "@/types" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { Separator } from "../ui/separator" +import { AssistantItem } from "./items/assistants/assistant-item" +import { ChatItem } from "./items/chat/chat-item" +import { CollectionItem } from "./items/collections/collection-item" +import { FileItem } from "./items/files/file-item" +import { Folder } from "./items/folders/folder-item" +import { ModelItem } from "./items/models/model-item" +import { PresetItem } from "./items/presets/preset-item" +import { PromptItem } from "./items/prompts/prompt-item" +import { ToolItem } from "./items/tools/tool-item" + +interface SidebarDataListProps { + contentType: ContentType + data: DataListType + folders: Tables<"folders">[] +} + +export const SidebarDataList: FC = ({ + contentType, + data, + folders +}) => { + const { + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants, + setTools, + setModels + } = useContext(ChatbotUIContext) + + const divRef = useRef(null) + + const [isOverflowing, setIsOverflowing] = useState(false) + const [isDragOver, setIsDragOver] = useState(false) + + const getDataListComponent = ( + contentType: ContentType, + item: DataItemType + ) => { + switch (contentType) { + case "chats": + return } /> + + case "presets": + return } /> + + case "prompts": + return } /> + + case "files": + return } /> + + case "collections": + return ( + } + /> + ) + + case "assistants": + return ( + } + /> + ) + + case "tools": + return } /> + + case "models": + return } /> + + default: + return null + } + } + + const getSortedData = ( + data: any, + dateCategory: "Today" | "Yesterday" | "Previous Week" | "Older" + ) => { + const now = new Date() + const todayStart = new Date(now.setHours(0, 0, 0, 0)) + const yesterdayStart = new Date( + new Date().setDate(todayStart.getDate() - 1) + ) + const oneWeekAgoStart = new Date( + new Date().setDate(todayStart.getDate() - 7) + ) + + return data + .filter((item: any) => { + const itemDate = new Date(item.updated_at || item.created_at) + switch (dateCategory) { + case "Today": + return itemDate >= todayStart + case "Yesterday": + return itemDate >= yesterdayStart && itemDate < todayStart + case "Previous Week": + return itemDate >= oneWeekAgoStart && itemDate < yesterdayStart + case "Older": + return itemDate < oneWeekAgoStart + default: + return true + } + }) + .sort( + ( + a: { updated_at: string; created_at: string }, + b: { updated_at: string; created_at: string } + ) => + new Date(b.updated_at || b.created_at).getTime() - + new Date(a.updated_at || a.created_at).getTime() + ) + } + + const updateFunctions = { + chats: updateChat, + presets: updatePreset, + prompts: updatePrompt, + files: updateFile, + collections: updateCollection, + assistants: updateAssistant, + tools: updateTool, + models: updateModel + } + + const stateUpdateFunctions = { + chats: setChats, + presets: setPresets, + prompts: setPrompts, + files: setFiles, + collections: setCollections, + assistants: setAssistants, + tools: setTools, + models: setModels + } + + const updateFolder = async (itemId: string, folderId: string | null) => { + const item: any = data.find(item => item.id === itemId) + + if (!item) return null + + const updateFunction = updateFunctions[contentType] + const setStateFunction = stateUpdateFunctions[contentType] + + if (!updateFunction || !setStateFunction) return + + const updatedItem = await updateFunction(item.id, { + folder_id: folderId + }) + + setStateFunction((items: any) => + items.map((item: any) => + item.id === updatedItem.id ? updatedItem : item + ) + ) + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDragStart = (e: React.DragEvent, id: string) => { + e.dataTransfer.setData("text/plain", id) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + + const target = e.target as Element + + if (!target.closest("#folder")) { + const itemId = e.dataTransfer.getData("text/plain") + updateFolder(itemId, null) + } + + setIsDragOver(false) + } + + useEffect(() => { + if (divRef.current) { + setIsOverflowing( + divRef.current.scrollHeight > divRef.current.clientHeight + ) + } + }, [data]) + + const dataWithFolders = data.filter(item => item.folder_id) + const dataWithoutFolders = data.filter(item => item.folder_id === null) + + return ( + <> +
+ {data.length === 0 && ( +
+
+ No {contentType}. +
+
+ )} + + {(dataWithFolders.length > 0 || dataWithoutFolders.length > 0) && ( +
+ {folders.map(folder => ( + + {dataWithFolders + .filter(item => item.folder_id === folder.id) + .map(item => ( +
handleDragStart(e, item.id)} + > + {getDataListComponent(contentType, item)} +
+ ))} +
+ ))} + + {folders.length > 0 && } + + {contentType === "chats" ? ( + <> + {["Today", "Yesterday", "Previous Week", "Older"].map( + dateCategory => { + const sortedData = getSortedData( + dataWithoutFolders, + dateCategory as + | "Today" + | "Yesterday" + | "Previous Week" + | "Older" + ) + + return ( + sortedData.length > 0 && ( +
+
+ {dateCategory} +
+ +
+ {sortedData.map((item: any) => ( +
handleDragStart(e, item.id)} + > + {getDataListComponent(contentType, item)} +
+ ))} +
+
+ ) + ) + } + )} + + ) : ( +
+ {dataWithoutFolders.map(item => { + return ( +
handleDragStart(e, item.id)} + > + {getDataListComponent(contentType, item)} +
+ ) + })} +
+ )} +
+ )} +
+ +
+ + ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar-search.tsx b/chatbot-ui-main/components/sidebar/sidebar-search.tsx new file mode 100644 index 0000000000..b451bc8442 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar-search.tsx @@ -0,0 +1,23 @@ +import { ContentType } from "@/types" +import { FC } from "react" +import { Input } from "../ui/input" + +interface SidebarSearchProps { + contentType: ContentType + searchTerm: string + setSearchTerm: Function +} + +export const SidebarSearch: FC = ({ + contentType, + searchTerm, + setSearchTerm +}) => { + return ( + setSearchTerm(e.target.value)} + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar-switch-item.tsx b/chatbot-ui-main/components/sidebar/sidebar-switch-item.tsx new file mode 100644 index 0000000000..2ccc92f440 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar-switch-item.tsx @@ -0,0 +1,33 @@ +import { ContentType } from "@/types" +import { FC } from "react" +import { TabsTrigger } from "../ui/tabs" +import { WithTooltip } from "../ui/with-tooltip" + +interface SidebarSwitchItemProps { + contentType: ContentType + icon: React.ReactNode + onContentTypeChange: (contentType: ContentType) => void +} + +export const SidebarSwitchItem: FC = ({ + contentType, + icon, + onContentTypeChange +}) => { + return ( + {contentType[0].toUpperCase() + contentType.substring(1)}
+ } + trigger={ + onContentTypeChange(contentType as ContentType)} + > + {icon} + + } + /> + ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar-switcher.tsx b/chatbot-ui-main/components/sidebar/sidebar-switcher.tsx new file mode 100644 index 0000000000..6e1caa1838 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar-switcher.tsx @@ -0,0 +1,93 @@ +import { ContentType } from "@/types" +import { + IconAdjustmentsHorizontal, + IconBolt, + IconBooks, + IconFile, + IconMessage, + IconPencil, + IconRobotFace, + IconSparkles +} from "@tabler/icons-react" +import { FC } from "react" +import { TabsList } from "../ui/tabs" +import { WithTooltip } from "../ui/with-tooltip" +import { ProfileSettings } from "../utility/profile-settings" +import { SidebarSwitchItem } from "./sidebar-switch-item" + +export const SIDEBAR_ICON_SIZE = 28 + +interface SidebarSwitcherProps { + onContentTypeChange: (contentType: ContentType) => void +} + +export const SidebarSwitcher: FC = ({ + onContentTypeChange +}) => { + return ( +
+ + } + contentType="chats" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="presets" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="prompts" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="models" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="files" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="collections" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="assistants" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="tools" + onContentTypeChange={onContentTypeChange} + /> + + +
+ {/* TODO */} + {/* Import
} trigger={} /> */} + + {/* TODO */} + {/* */} + + Profile Settings
} + trigger={} + /> + + + ) +} diff --git a/chatbot-ui-main/components/sidebar/sidebar.tsx b/chatbot-ui-main/components/sidebar/sidebar.tsx new file mode 100644 index 0000000000..69a228e6b2 --- /dev/null +++ b/chatbot-ui-main/components/sidebar/sidebar.tsx @@ -0,0 +1,111 @@ +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { ContentType } from "@/types" +import { FC, useContext } from "react" +import { SIDEBAR_WIDTH } from "../ui/dashboard" +import { TabsContent } from "../ui/tabs" +import { WorkspaceSwitcher } from "../utility/workspace-switcher" +import { WorkspaceSettings } from "../workspace/workspace-settings" +import { SidebarContent } from "./sidebar-content" + +interface SidebarProps { + contentType: ContentType + showSidebar: boolean +} + +export const Sidebar: FC = ({ contentType, showSidebar }) => { + const { + folders, + chats, + presets, + prompts, + files, + collections, + assistants, + tools, + models + } = useContext(ChatbotUIContext) + + const chatFolders = folders.filter(folder => folder.type === "chats") + const presetFolders = folders.filter(folder => folder.type === "presets") + const promptFolders = folders.filter(folder => folder.type === "prompts") + const filesFolders = folders.filter(folder => folder.type === "files") + const collectionFolders = folders.filter( + folder => folder.type === "collections" + ) + const assistantFolders = folders.filter( + folder => folder.type === "assistants" + ) + const toolFolders = folders.filter(folder => folder.type === "tools") + const modelFolders = folders.filter(folder => folder.type === "models") + + const renderSidebarContent = ( + contentType: ContentType, + data: any[], + folders: Tables<"folders">[] + ) => { + return ( + + ) + } + + return ( + +
+
+ + + +
+ + {(() => { + switch (contentType) { + case "chats": + return renderSidebarContent("chats", chats, chatFolders) + + case "presets": + return renderSidebarContent("presets", presets, presetFolders) + + case "prompts": + return renderSidebarContent("prompts", prompts, promptFolders) + + case "files": + return renderSidebarContent("files", files, filesFolders) + + case "collections": + return renderSidebarContent( + "collections", + collections, + collectionFolders + ) + + case "assistants": + return renderSidebarContent( + "assistants", + assistants, + assistantFolders + ) + + case "tools": + return renderSidebarContent("tools", tools, toolFolders) + + case "models": + return renderSidebarContent("models", models, modelFolders) + + default: + return null + } + })()} +
+
+ ) +} diff --git a/chatbot-ui-main/components/ui/accordion.tsx b/chatbot-ui-main/components/ui/accordion.tsx new file mode 100644 index 0000000000..791ca2c78c --- /dev/null +++ b/chatbot-ui-main/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/chatbot-ui-main/components/ui/advanced-settings.tsx b/chatbot-ui-main/components/ui/advanced-settings.tsx new file mode 100644 index 0000000000..0c2bf85960 --- /dev/null +++ b/chatbot-ui-main/components/ui/advanced-settings.tsx @@ -0,0 +1,40 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@/components/ui/collapsible" +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react" +import { FC, useState } from "react" + +interface AdvancedSettingsProps { + children: React.ReactNode +} + +export const AdvancedSettings: FC = ({ children }) => { + const [isOpen, setIsOpen] = useState( + false + // localStorage.getItem("advanced-settings-open") === "true" + ) + + const handleOpenChange = (isOpen: boolean) => { + setIsOpen(isOpen) + // localStorage.setItem("advanced-settings-open", String(isOpen)) + } + + return ( + + +
+
Advanced Settings
+ {isOpen ? ( + + ) : ( + + )} +
+
+ + {children} +
+ ) +} diff --git a/chatbot-ui-main/components/ui/alert-dialog.tsx b/chatbot-ui-main/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000..d468c39290 --- /dev/null +++ b/chatbot-ui-main/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel +} diff --git a/chatbot-ui-main/components/ui/alert.tsx b/chatbot-ui-main/components/ui/alert.tsx new file mode 100644 index 0000000000..588ee66fe4 --- /dev/null +++ b/chatbot-ui-main/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive" + } + }, + defaultVariants: { + variant: "default" + } + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/chatbot-ui-main/components/ui/aspect-ratio.tsx b/chatbot-ui-main/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000000..d6a5226f5e --- /dev/null +++ b/chatbot-ui-main/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/chatbot-ui-main/components/ui/avatar.tsx b/chatbot-ui-main/components/ui/avatar.tsx new file mode 100644 index 0000000000..1cf1283528 --- /dev/null +++ b/chatbot-ui-main/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/chatbot-ui-main/components/ui/badge.tsx b/chatbot-ui-main/components/ui/badge.tsx new file mode 100644 index 0000000000..56f0ea4b42 --- /dev/null +++ b/chatbot-ui-main/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", + outline: "text-foreground" + } + }, + defaultVariants: { + variant: "default" + } + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/chatbot-ui-main/components/ui/brand.tsx b/chatbot-ui-main/components/ui/brand.tsx new file mode 100644 index 0000000000..ae42e07372 --- /dev/null +++ b/chatbot-ui-main/components/ui/brand.tsx @@ -0,0 +1,26 @@ +"use client" + +import Link from "next/link" +import { FC } from "react" +import { ChatbotUISVG } from "../icons/chatbotui-svg" + +interface BrandProps { + theme?: "dark" | "light" +} + +export const Brand: FC = ({ theme = "dark" }) => { + return ( + +
+ +
+ +
Chatbot UI
+ + ) +} diff --git a/chatbot-ui-main/components/ui/button.tsx b/chatbot-ui-main/components/ui/button.tsx new file mode 100644 index 0000000000..fb4fc2fabb --- /dev/null +++ b/chatbot-ui-main/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors hover:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border-input bg-background hover:bg-accent hover:text-accent-foreground border", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline" + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "size-10" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/chatbot-ui-main/components/ui/calendar.tsx b/chatbot-ui-main/components/ui/calendar.tsx new file mode 100644 index 0000000000..07c8aa48c3 --- /dev/null +++ b/chatbot-ui-main/components/ui/calendar.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/chatbot-ui-main/components/ui/card.tsx b/chatbot-ui-main/components/ui/card.tsx new file mode 100644 index 0000000000..a26fd5d7b7 --- /dev/null +++ b/chatbot-ui-main/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/chatbot-ui-main/components/ui/chat-settings-form.tsx b/chatbot-ui-main/components/ui/chat-settings-form.tsx new file mode 100644 index 0000000000..60d6b510da --- /dev/null +++ b/chatbot-ui-main/components/ui/chat-settings-form.tsx @@ -0,0 +1,253 @@ +"use client" + +import { ChatbotUIContext } from "@/context/context" +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import { ChatSettings } from "@/types" +import { IconInfoCircle } from "@tabler/icons-react" +import { FC, useContext } from "react" +import { ModelSelect } from "../models/model-select" +import { AdvancedSettings } from "./advanced-settings" +import { Checkbox } from "./checkbox" +import { Label } from "./label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./select" +import { Slider } from "./slider" +import { TextareaAutosize } from "./textarea-autosize" +import { WithTooltip } from "./with-tooltip" + +interface ChatSettingsFormProps { + chatSettings: ChatSettings + onChangeChatSettings: (value: ChatSettings) => void + useAdvancedDropdown?: boolean + showTooltip?: boolean +} + +export const ChatSettingsForm: FC = ({ + chatSettings, + onChangeChatSettings, + useAdvancedDropdown = true, + showTooltip = true +}) => { + const { profile, models } = useContext(ChatbotUIContext) + + if (!profile) return null + + return ( +
+
+ + + { + onChangeChatSettings({ ...chatSettings, model }) + }} + /> +
+ +
+ + + { + onChangeChatSettings({ ...chatSettings, prompt }) + }} + value={chatSettings.prompt} + minRows={3} + maxRows={6} + /> +
+ + {useAdvancedDropdown ? ( + + + + ) : ( +
+ +
+ )} +
+ ) +} + +interface AdvancedContentProps { + chatSettings: ChatSettings + onChangeChatSettings: (value: ChatSettings) => void + showTooltip: boolean +} + +const AdvancedContent: FC = ({ + chatSettings, + onChangeChatSettings, + showTooltip +}) => { + const { profile, selectedWorkspace, availableOpenRouterModels, models } = + useContext(ChatbotUIContext) + + const isCustomModel = models.some( + model => model.model_id === chatSettings.model + ) + + function findOpenRouterModel(modelId: string) { + return availableOpenRouterModels.find(model => model.modelId === modelId) + } + + const MODEL_LIMITS = CHAT_SETTING_LIMITS[chatSettings.model] || { + MIN_TEMPERATURE: 0, + MAX_TEMPERATURE: 1, + MAX_CONTEXT_LENGTH: + findOpenRouterModel(chatSettings.model)?.maxContext || 4096 + } + + return ( +
+
+ + + { + onChangeChatSettings({ + ...chatSettings, + temperature: temperature[0] + }) + }} + min={MODEL_LIMITS.MIN_TEMPERATURE} + max={MODEL_LIMITS.MAX_TEMPERATURE} + step={0.01} + /> +
+ +
+ + + { + onChangeChatSettings({ + ...chatSettings, + contextLength: contextLength[0] + }) + }} + min={0} + max={ + isCustomModel + ? models.find(model => model.model_id === chatSettings.model) + ?.context_length + : MODEL_LIMITS.MAX_CONTEXT_LENGTH + } + step={1} + /> +
+ +
+ + onChangeChatSettings({ + ...chatSettings, + includeProfileContext: value + }) + } + /> + + + + {showTooltip && ( + + {profile?.profile_context || "No profile context."} +
+ } + trigger={ + + } + /> + )} +
+ +
+ + onChangeChatSettings({ + ...chatSettings, + includeWorkspaceInstructions: value + }) + } + /> + + + + {showTooltip && ( + + {selectedWorkspace?.instructions || + "No workspace instructions."} +
+ } + trigger={ + + } + /> + )} +
+ +
+ + + +
+
+ ) +} diff --git a/chatbot-ui-main/components/ui/checkbox.tsx b/chatbot-ui-main/components/ui/checkbox.tsx new file mode 100644 index 0000000000..6abd7f87e7 --- /dev/null +++ b/chatbot-ui-main/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/chatbot-ui-main/components/ui/collapsible.tsx b/chatbot-ui-main/components/ui/collapsible.tsx new file mode 100644 index 0000000000..9fa48946af --- /dev/null +++ b/chatbot-ui-main/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/chatbot-ui-main/components/ui/command.tsx b/chatbot-ui-main/components/ui/command.tsx new file mode 100644 index 0000000000..bf47537f5c --- /dev/null +++ b/chatbot-ui-main/components/ui/command.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator +} diff --git a/chatbot-ui-main/components/ui/context-menu.tsx b/chatbot-ui-main/components/ui/context-menu.tsx new file mode 100644 index 0000000000..05b7f098a9 --- /dev/null +++ b/chatbot-ui-main/components/ui/context-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup +} diff --git a/chatbot-ui-main/components/ui/dashboard.tsx b/chatbot-ui-main/components/ui/dashboard.tsx new file mode 100644 index 0000000000..b5fbbe9aec --- /dev/null +++ b/chatbot-ui-main/components/ui/dashboard.tsx @@ -0,0 +1,133 @@ +"use client" + +import { Sidebar } from "@/components/sidebar/sidebar" +import { SidebarSwitcher } from "@/components/sidebar/sidebar-switcher" +import { Button } from "@/components/ui/button" +import { Tabs } from "@/components/ui/tabs" +import useHotkey from "@/lib/hooks/use-hotkey" +import { cn } from "@/lib/utils" +import { ContentType } from "@/types" +import { IconChevronCompactRight } from "@tabler/icons-react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { FC, useState } from "react" +import { useSelectFileHandler } from "../chat/chat-hooks/use-select-file-handler" +import { CommandK } from "../utility/command-k" + +export const SIDEBAR_WIDTH = 350 + +interface DashboardProps { + children: React.ReactNode +} + +export const Dashboard: FC = ({ children }) => { + useHotkey("s", () => setShowSidebar(prevState => !prevState)) + + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + const tabValue = searchParams.get("tab") || "chats" + + const { handleSelectDeviceFile } = useSelectFileHandler() + + const [contentType, setContentType] = useState( + tabValue as ContentType + ) + const [showSidebar, setShowSidebar] = useState( + localStorage.getItem("showSidebar") === "true" + ) + const [isDragging, setIsDragging] = useState(false) + + const onFileDrop = (event: React.DragEvent) => { + event.preventDefault() + + const files = event.dataTransfer.files + const file = files[0] + + handleSelectDeviceFile(file) + + setIsDragging(false) + } + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(true) + } + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(false) + } + + const onDragOver = (event: React.DragEvent) => { + event.preventDefault() + } + + const handleToggleSidebar = () => { + setShowSidebar(prevState => !prevState) + localStorage.setItem("showSidebar", String(!showSidebar)) + } + + return ( +
+ + +
+ {showSidebar && ( + { + setContentType(tabValue as ContentType) + router.replace(`${pathname}?tab=${tabValue}`) + }} + > + + + + + )} +
+ +
+ {isDragging ? ( +
+ drop file here +
+ ) : ( + children + )} + + +
+
+ ) +} diff --git a/chatbot-ui-main/components/ui/dialog.tsx b/chatbot-ui-main/components/ui/dialog.tsx new file mode 100644 index 0000000000..5c01896f87 --- /dev/null +++ b/chatbot-ui-main/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +"use client" + +import * as DialogPrimitive from "@radix-ui/react-dialog" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + {/* + + Close + */} + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +} diff --git a/chatbot-ui-main/components/ui/dropdown-menu.tsx b/chatbot-ui-main/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..0519d6561e --- /dev/null +++ b/chatbot-ui-main/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup +} diff --git a/chatbot-ui-main/components/ui/file-icon.tsx b/chatbot-ui-main/components/ui/file-icon.tsx new file mode 100644 index 0000000000..814dda3e31 --- /dev/null +++ b/chatbot-ui-main/components/ui/file-icon.tsx @@ -0,0 +1,36 @@ +import { + IconFile, + IconFileText, + IconFileTypeCsv, + IconFileTypeDocx, + IconFileTypePdf, + IconJson, + IconMarkdown, + IconPhoto +} from "@tabler/icons-react" +import { FC } from "react" + +interface FileIconProps { + type: string + size?: number +} + +export const FileIcon: FC = ({ type, size = 32 }) => { + if (type.includes("image")) { + return + } else if (type.includes("pdf")) { + return + } else if (type.includes("csv")) { + return + } else if (type.includes("docx")) { + return + } else if (type.includes("plain")) { + return + } else if (type.includes("json")) { + return + } else if (type.includes("markdown")) { + return + } else { + return + } +} diff --git a/chatbot-ui-main/components/ui/file-preview.tsx b/chatbot-ui-main/components/ui/file-preview.tsx new file mode 100644 index 0000000000..2bfbc70378 --- /dev/null +++ b/chatbot-ui-main/components/ui/file-preview.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { ChatFile, MessageImage } from "@/types" +import { IconFileFilled } from "@tabler/icons-react" +import Image from "next/image" +import { FC } from "react" +import { DrawingCanvas } from "../utility/drawing-canvas" +import { Dialog, DialogContent } from "./dialog" + +interface FilePreviewProps { + type: "image" | "file" | "file_item" + item: ChatFile | MessageImage | Tables<"file_items"> + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const FilePreview: FC = ({ + type, + item, + isOpen, + onOpenChange +}) => { + return ( + + + {(() => { + if (type === "image") { + const imageItem = item as MessageImage + + return imageItem.file ? ( + + ) : ( + File image + ) + } else if (type === "file_item") { + const fileItem = item as Tables<"file_items"> + return ( +
+
{fileItem.content}
+
+ ) + } else if (type === "file") { + return ( +
+ +
+ ) + } + })()} +
+
+ ) +} diff --git a/chatbot-ui-main/components/ui/form.tsx b/chatbot-ui-main/components/ui/form.tsx new file mode 100644 index 0000000000..38cb190f81 --- /dev/null +++ b/chatbot-ui-main/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +